From 04240862e196ba38d8ed91341e2d6ab0e3526e8b Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 28 Jun 2026 09:21:22 +0200 Subject: [PATCH] Adding runner handshake --- crates/ocs_plugin_api/Cargo.toml | 3 +- crates/ocs_plugin_api/src/ipc/client.rs | 10 ++- crates/ocs_plugin_api/src/ipc/protocol.rs | 14 ++++ crates/ocs_plugin_api/src/process.rs | 80 ++++++++++++++++++++++- crates/ocs_plugin_api/src/runner.rs | 11 +++- 5 files changed, 114 insertions(+), 4 deletions(-) diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index e78d4eec..e6f86e5b 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -19,9 +19,10 @@ thiserror = { version = "1", optional = true } libloading = { version = "0.8", optional = true } memmap2 = { version = "0.9", optional = true } rkyv = { version = "0.7", features = ["validation", "std"], optional = true } +getrandom = { version = "0.2", optional = true } [features] # Enables the runtime host surface (`HostApi` trait) and the out-of-process # plugin runtime. The OpenCADStudio binary turns this on; pure-data consumers # leave it off. -host = ["dep:acadrust", "dep:interprocess", "dep:serde", "dep:bincode", "dep:thiserror", "dep:libloading", "dep:memmap2", "dep:rkyv"] +host = ["dep:acadrust", "dep:interprocess", "dep:serde", "dep:bincode", "dep:thiserror", "dep:libloading", "dep:memmap2", "dep:rkyv", "dep:getrandom"] diff --git a/crates/ocs_plugin_api/src/ipc/client.rs b/crates/ocs_plugin_api/src/ipc/client.rs index 72b5a51c..62c34c65 100644 --- a/crates/ocs_plugin_api/src/ipc/client.rs +++ b/crates/ocs_plugin_api/src/ipc/client.rs @@ -13,7 +13,7 @@ use interprocess::local_socket::{GenericNamespaced, Stream, ToNsName}; use crate::host::{DocumentReader, HostApi, InteractiveCommand, ReaderEntity}; use crate::ipc::protocol::{ - HostResponse, HostToPlugin, PluginRequest, PluginResponse, PluginToHost, + HostResponse, HostToPlugin, PluginRequest, PluginResponse, PluginToHost, RunnerHandshake, }; use crate::ipc::transport::{recv, send}; use crate::shm::{DocumentViewInfo, SharedDocumentReader}; @@ -46,6 +46,14 @@ impl IpcClient { self.stream.borrow_mut() } + /// Send the initial runner handshake presenting the pre-shared token. + pub fn send_handshake(&self, token: &str) -> Result<(), crate::ipc::transport::TransportError> { + send( + &mut self.stream.borrow_mut(), + &RunnerHandshake::Token(token.to_string()), + ) + } + /// Send a plugin request and wait for the matching response. Any nested /// host requests that arrive while we are waiting are treated as errors. pub fn request( diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs index fdfa368c..e199e060 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -22,6 +22,20 @@ pub enum InteractiveEvent { ObjectPick { handle: Handle, pt: [f64; 3] }, } +/// Initial handshake sent by the plugin runner immediately after connecting. +/// +/// The runner proves it was spawned by this host by presenting a pre-shared +/// token delivered through the `OCS_PLUGIN_TOKEN` environment variable. A +/// mismatch causes the host to close the connection. +#[derive(Debug, Serialize, Deserialize)] +pub enum RunnerHandshake { + Token(String), +} + +/// Environment variable through which the host passes the pre-shared +/// authentication token to the plugin runner child process. +pub const PLUGIN_TOKEN_ENV: &str = "OCS_PLUGIN_TOKEN"; + /// Requests the host sends to the plugin runner. #[derive(Debug, Serialize, Deserialize)] pub enum HostRequest { diff --git a/crates/ocs_plugin_api/src/process.rs b/crates/ocs_plugin_api/src/process.rs index 4f8da228..8532bf06 100644 --- a/crates/ocs_plugin_api/src/process.rs +++ b/crates/ocs_plugin_api/src/process.rs @@ -12,7 +12,8 @@ use interprocess::local_socket::{GenericNamespaced, ListenerOptions, Stream, ToN use crate::host::{CommandStep, HostApi}; use crate::ipc::protocol::{ - HostRequest, HostResponse, HostToPlugin, InteractiveEvent, PluginToHost, + HostRequest, HostResponse, HostToPlugin, InteractiveEvent, PluginToHost, RunnerHandshake, + PLUGIN_TOKEN_ENV, }; use crate::ipc::server::handle_plugin_request; use crate::ipc::transport::{recv, send}; @@ -37,6 +38,9 @@ fn spawn_timeout() -> Duration { /// Default maximum time to wait for a plugin call to respond. const CALL_TIMEOUT_DEFAULT: Duration = Duration::from_secs(30); +/// Length of the random pre-shared token used to authenticate the runner. +const PLUGIN_TOKEN_LEN: usize = 32; + fn call_timeout() -> Duration { std::env::var("OCS_PLUGIN_CALL_TIMEOUT_SECS") .ok() @@ -128,6 +132,8 @@ impl PluginProcess { cdylib_path.display() ); + let token = generate_token()?; + // Create the listener before spawning so the runner can connect immediately. let listener = ListenerOptions::new().name(socket_name_ref).create_sync()?; @@ -135,6 +141,7 @@ impl PluginProcess { .arg("--ocs-plugin-runner") .arg(&socket_name) .arg(cdylib_path) + .env(PLUGIN_TOKEN_ENV, &token) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -167,6 +174,19 @@ impl PluginProcess { } }; + // Verify the runner presented the token it received through the + // environment before allowing any host→runner requests. + let mut guard = stream.lock().unwrap_or_else(|e| e.into_inner()); + let handshake_stream = guard.as_mut().ok_or_else(shutdown_error)?; + if let Err(e) = verify_runner_handshake(handshake_stream, &token) { + drop(guard); + if let Some(child) = child.lock().unwrap_or_else(|e| e.into_inner()).take() { + reap(child); + } + return Err(e); + } + drop(guard); + // The runner first answers GetManifest and GetRibbon so the host can // build the UI without keeping the plugin object alive. let no_op = &mut |_| {}; @@ -559,6 +579,21 @@ fn distinct_runner_path(host: &Path) -> PathBuf { path } +/// Verify that the runner on the other end of `stream` presents `expected_token`. +fn verify_runner_handshake( + stream: &mut Stream, + expected_token: &str, +) -> Result<(), PluginError> { + match recv::(stream) { + Ok(RunnerHandshake::Token(ref presented)) if presented == expected_token => { + eprintln!("[plugin] runner authenticated"); + Ok(()) + } + Ok(RunnerHandshake::Token(_)) => Err(PluginError::Runner("authentication failed".into())), + Err(e) => Err(e.into()), + } +} + /// Generate a unique local socket name. fn generate_socket_name() -> String { static COUNTER: AtomicU64 = AtomicU64::new(0); @@ -566,6 +601,20 @@ fn generate_socket_name() -> String { format!("ocs_plugin_{}_{}", std::process::id(), n) } +/// Generate a 32-byte random token for runner authentication. +fn generate_token() -> Result { + let mut bytes = [0u8; PLUGIN_TOKEN_LEN]; + getrandom::getrandom(&mut bytes) + .map_err(|e| PluginError::Runner(format!("token generation failed: {e}")))?; + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for b in bytes { + out.push(HEX[(b >> 4) as usize] as char); + out.push(HEX[(b & 0x0f) as usize] as char); + } + Ok(out) +} + #[cfg(test)] mod tests { use super::*; @@ -594,6 +643,7 @@ mod timeout_tests { use crate::host::{DocumentReader, HostApi, ReaderEntity}; use crate::ipc::protocol::{ HostRequest, HostResponse, HostToPlugin, PluginRequest, PluginResponse, PluginToHost, + RunnerHandshake, }; use crate::ipc::transport::{recv, send}; use crate::ribbon::owned::OwnedPluginManifest; @@ -858,4 +908,32 @@ mod timeout_tests { None => std::env::remove_var("OCS_PLUGIN_CALL_TIMEOUT_SECS"), } } + + #[test] + fn runner_handshake_wrong_token_is_rejected() { + let (mut host_stream, runner_stream) = connected_pair(); + let _runner = thread::spawn(move || { + let mut peer = runner_stream; + send(&mut peer, &RunnerHandshake::Token("wrong-token".to_string())) + .expect("send handshake"); + }); + let result = verify_runner_handshake(&mut host_stream, "expected-token"); + assert!( + matches!(result, Err(PluginError::Runner(ref s)) if s == "authentication failed"), + "expected authentication failure, got {result:?}" + ); + } + + #[test] + fn runner_handshake_correct_token_is_accepted() { + let (mut host_stream, runner_stream) = connected_pair(); + let token = "correct-token".to_string(); + let expected = token.clone(); + let _runner = thread::spawn(move || { + let mut peer = runner_stream; + send(&mut peer, &RunnerHandshake::Token(token)).expect("send handshake"); + }); + let result = verify_runner_handshake(&mut host_stream, &expected); + assert!(result.is_ok(), "expected authentication success, got {result:?}"); + } } diff --git a/crates/ocs_plugin_api/src/runner.rs b/crates/ocs_plugin_api/src/runner.rs index 30bbcaad..4da00e94 100644 --- a/crates/ocs_plugin_api/src/runner.rs +++ b/crates/ocs_plugin_api/src/runner.rs @@ -13,7 +13,7 @@ use std::rc::Rc; use crate::host::{BuiltinPlugin, InteractiveCommand}; use crate::ipc::client::{InteractiveRegistry, IpcClient, PluginHostApi}; use crate::ipc::protocol::{ - HostRequest, HostResponse, HostToPlugin, InteractiveEvent, PluginToHost, + HostRequest, HostResponse, HostToPlugin, InteractiveEvent, PluginToHost, PLUGIN_TOKEN_ENV, }; use crate::ipc::transport::{recv, send}; use crate::ribbon::owned::OwnedRibbonGroup; @@ -29,8 +29,17 @@ pub fn run(socket_name: &str, cdylib_path: &Path) -> Result<(), Box t, + Err(_) => { + eprintln!("[runner] missing {PLUGIN_TOKEN_ENV}; exiting"); + std::process::exit(1); + } + }; + let client = IpcClient::connect(socket_name)?; eprintln!("[runner] connected to host"); + client.send_handshake(&token)?; loop { let msg: HostToPlugin = recv(&mut client.stream_ref())?;