| /* | |
| * Licensed to the Apache Software Foundation (ASF) under one | |
| * or more contributor license agreements. See the NOTICE file | |
| * distributed with this work for additional information | |
| * regarding copyright ownership. The ASF licenses this file | |
| * to you under the Apache License, Version 2.0 (the | |
| * "License"); you may not use this file except in compliance | |
| * with the License. You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, | |
| * software distributed under the License is distributed on an | |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| * KIND, either express or implied. See the License for the | |
| * specific language governing permissions and limitations | |
| * under the License. | |
| */ | |
| //! Native process primitives executing captured, optionally sandboxed commands. | |
| //! Durable background/PTY resource ownership belongs to the runtime. | |
| #[cfg(windows)] | |
| pub mod bootstrap; | |
| mod command; | |
| pub mod detached; | |
| mod directory; | |
| mod environment; | |
| pub mod pipe; | |
| pub use command::Command; | |
| #[cfg(target_os = "linux")] | |
| pub mod network_namespace; | |
| mod output; | |
| pub mod system; | |
| pub use output::{Captured as PipeResult, Outcome as ProcessOutcome, Termination}; | |
| #[cfg(unix)] | |
| mod group; | |
| mod process; | |
| pub mod pty; | |
| mod shell; | |
| mod tail; | |
| pub mod terminal; | |
| #[cfg(windows)] | |
| mod windows; | |
| use maka_agent_facts::tools::ToolError; | |
| use maka_agent_facts::tools::ToolExecutor; | |
| use maka_agent_facts::tools::ToolFuture; | |
| use serde::Deserialize; | |
| use serde_json::Value; | |
| use std::path::{Path, PathBuf}; | |
| use tokio_util::sync::CancellationToken; | |
| // The wire name describes the capability, not a platform-specific executable. | |
| pub const SHELL_NAME: &str = "Shell"; | |
| pub const SHELL_DESCRIPTION: &str = "Execute a command with the authorized filesystem and network access. Returns bounded stdout/stderr tails."; | |
| const DEFAULT_TIMEOUT_MS: u64 = 120_000; | |
| /// Bounded observations, not durable resource facts. Started precedes output. | |
| #[derive(Debug)] | |
| pub enum PipeEvent { | |
| Started, | |
| Output { | |
| stream: maka_agent_facts::shell_run::PipeStream, | |
| bytes: Vec<u8>, | |
| }, | |
| } | |
| pub type PipeFuture = | |
| std::pin::Pin<Box<dyn std::future::Future<Output = Result<PipeResult, ToolError>> + Send>>; | |
| pub struct ObservedProcess { | |
| pub events: tokio::sync::mpsc::Receiver<PipeEvent>, | |
| /// Poll concurrently with events. Completion includes native cleanup and | |
| /// bounded pipe drain; dropping this future requests cancellation. | |
| pub completion: PipeFuture, | |
| } | |
| pub fn shell_schema() -> Value { | |
| schemars::schema_for!(Input).into() | |
| } | |
| #[derive(Deserialize, schemars::JsonSchema)] | |
| #[serde(deny_unknown_fields)] | |
| struct Input { | |
| #[schemars(length(min = 1))] | |
| command: String, | |
| /// Load the shell's login profile. Set false to skip it. | |
| #[serde(default = "default_login")] | |
| login: bool, | |
| #[serde(default = "default_timeout")] | |
| #[schemars(range(min = 1))] | |
| timeout_ms: u64, | |
| } | |
| fn default_timeout() -> u64 { | |
| DEFAULT_TIMEOUT_MS | |
| } | |
| fn default_login() -> bool { | |
| true | |
| } | |
| #[derive(Clone)] | |
| pub struct ShellExecutor { | |
| cwd: PathBuf, | |
| directory: Option<std::sync::Arc<directory::Directory>>, | |
| shell: shell::ShellPlan, | |
| login: bool, | |
| sandbox: maka_sandbox::Sandbox, | |
| network_route: maka_network::Policy, | |
| #[cfg(target_os = "linux")] | |
| network_helper: Option<PathBuf>, | |
| #[cfg(windows)] | |
| backend: Option<std::sync::Arc<dyn bootstrap::Backend>>, | |
| } | |
| impl ShellExecutor { | |
| #[cfg(target_os = "linux")] | |
| pub fn with_network_helper(mut self, helper: PathBuf) -> Self { | |
| self.network_helper = Some(helper); | |
| self | |
| } | |
| pub fn with_network_route(mut self, route: maka_network::Policy) -> Self { | |
| self.network_route = route; | |
| self | |
| } | |
| #[cfg(windows)] | |
| pub fn with_backend(mut self, backend: std::sync::Arc<dyn bootstrap::Backend>) -> Self { | |
| self.backend = Some(backend); | |
| self | |
| } | |
| pub fn sandbox(&self) -> &maka_sandbox::Sandbox { | |
| &self.sandbox | |
| } | |
| /// Replace only the authorized policy; retain the captured cwd and shell. | |
| pub fn with_sandbox(&self, sandbox: maka_sandbox::Sandbox) -> Self { | |
| Self { | |
| sandbox, | |
| ..self.clone() | |
| } | |
| } | |
| /// Capture an already authorized isolation policy. This does not authorize | |
| /// execution; the Host still owns admission and durable dispatch. | |
| pub fn new(cwd: impl AsRef<Path>, sandbox: maka_sandbox::Sandbox) -> Result<Self, ToolError> { | |
| let cwd = std::fs::canonicalize(cwd).map_err(|e| failed(e.to_string()))?; | |
| #[cfg(windows)] | |
| let cwd = dunce::simplified(&cwd).to_owned(); | |
| if !cwd.is_dir() || cwd.to_str().is_none() { | |
| return Err(failed("Shell cwd must be a UTF-8 directory")); | |
| } | |
| Ok(Self { | |
| cwd, | |
| directory: None, | |
| shell: shell::ShellPlan::detect(), | |
| login: true, | |
| sandbox, | |
| network_route: maka_network::Policy::default(), | |
| #[cfg(target_os = "linux")] | |
| network_helper: None, | |
| #[cfg(windows)] | |
| backend: None, | |
| }) | |
| } | |
| /// Use the very directory already admitted by the embedding. A pathname | |
| /// replacement cannot redirect the command between admission and spawn. | |
| pub fn from_directory( | |
| cwd: PathBuf, | |
| directory: std::fs::File, | |
| sandbox: maka_sandbox::Sandbox, | |
| ) -> Result<Self, ToolError> { | |
| if !cwd.is_absolute() || cwd.to_str().is_none() { | |
| return Err(failed("Shell cwd must be an absolute UTF-8 directory")); | |
| } | |
| let directory = directory::Directory::capture(&cwd, directory).map_err(failed)?; | |
| Ok(Self { | |
| cwd, | |
| directory: Some(std::sync::Arc::new(directory)), | |
| shell: shell::ShellPlan::detect(), | |
| login: true, | |
| sandbox, | |
| network_route: maka_network::Policy::default(), | |
| #[cfg(target_os = "linux")] | |
| network_helper: None, | |
| #[cfg(windows)] | |
| backend: None, | |
| }) | |
| } | |
| pub fn description(&self) -> String { | |
| format!("{SHELL_DESCRIPTION} {}", self.shell.guidance()) | |
| } | |
| pub fn cwd(&self) -> &Path { | |
| &self.cwd | |
| } | |
| /// Control command profile loading without changing the executable or permissions. | |
| pub fn with_login_shell(mut self, login: bool) -> Self { | |
| self.login = login; | |
| self | |
| } | |
| /// Capture the selected interactive login shell and its display command. | |
| /// The caller still owns durable admission before native PTY spawn. | |
| pub fn interactive_pty(&self) -> Result<(String, pty::PtyCommand), ToolError> { | |
| let (source, command) = self.shell.interactive(&self.cwd); | |
| Ok((source, self.capture(command)?)) | |
| } | |
| /// The selected dialect and quoting match pipes, but stdin is a terminal. | |
| /// Capturing a plan performs no native effect. | |
| pub fn command_pty(&self, command: &str) -> Result<pty::PtyCommand, ToolError> { | |
| validate_command(command)?; | |
| self.prepare(command, true) | |
| } | |
| /// Capture a non-interactive launch and its authorized sandbox policy. | |
| pub fn command_pipes(&self, command: &str) -> Result<Command, ToolError> { | |
| validate_command(command)?; | |
| self.prepare(command, false) | |
| } | |
| /// Capture argv directly while retaining the authorized directory, sandbox | |
| /// and network route. Protocol processes must not pass arguments through a shell. | |
| pub fn command_program( | |
| &self, | |
| executable: impl Into<PathBuf>, | |
| args: &[String], | |
| environment: &std::collections::BTreeMap<String, String>, | |
| ) -> Result<Command, ToolError> { | |
| let mut command = Command::new(executable, self.cwd.clone()); | |
| for arg in args { | |
| command.arg(arg); | |
| } | |
| for (key, value) in environment { | |
| command.env(key, value); | |
| } | |
| self.capture(command) | |
| } | |
| fn prepare(&self, source: &str, terminal: bool) -> Result<Command, ToolError> { | |
| self.capture(self.shell.command(&self.cwd, source, terminal, self.login)) | |
| } | |
| fn capture(&self, mut command: Command) -> Result<Command, ToolError> { | |
| command.directory = self.directory.clone(); | |
| #[cfg(target_os = "linux")] | |
| let command = match &self.network_helper { | |
| Some(helper) => command.with_network_helper(helper.clone()), | |
| None => command, | |
| }; | |
| #[cfg(windows)] | |
| let command = match &self.backend { | |
| Some(backend) => command.with_backend(backend.clone()), | |
| None => command, | |
| }; | |
| command | |
| .network_route(self.network_route.clone()) | |
| .sandbox(&self.sandbox) | |
| .map_err(|e| failed(e.to_string())) | |
| } | |
| /// Prepare a background pipe execution. No process is started until | |
| /// completion is polled; the Host must first commit durable dispatch. | |
| pub fn observe( | |
| &self, | |
| command: String, | |
| timeout_ms: Option<u64>, | |
| cancellation: CancellationToken, | |
| ) -> Result<ObservedProcess, ToolError> { | |
| self.command_pipes(&command)? | |
| .observe(timeout_ms, cancellation) | |
| } | |
| } | |
| impl Command { | |
| /// No native resources exist until completion is polled. The accepted worker | |
| /// owns sandbox preparation, process startup and cleanup as one lifecycle. | |
| pub fn observe( | |
| self, | |
| timeout_ms: Option<u64>, | |
| cancellation: CancellationToken, | |
| ) -> Result<ObservedProcess, ToolError> { | |
| if timeout_ms.is_some_and(|ms| validate_timeout(ms).is_err()) { | |
| return Err(failed("invalid background shell command or timeout")); | |
| } | |
| let (observer, events) = tokio::sync::mpsc::channel(8); | |
| let completion = Box::pin(async move { | |
| let cancellation = cancellation.child_token(); | |
| let _cancel_on_drop = cancellation.clone().drop_guard(); | |
| tokio::spawn(process::run(self, timeout_ms, cancellation, Some(observer))) | |
| .await | |
| .map_err(|e| ToolError::CleanupUnconfirmed(format!("Shell worker lost: {e}")))? | |
| }); | |
| Ok(ObservedProcess { events, completion }) | |
| } | |
| } | |
| impl ToolExecutor for ShellExecutor { | |
| fn names(&self) -> Vec<String> { | |
| vec![SHELL_NAME.into()] | |
| } | |
| fn invoke(&self, name: String, input: Value, cancellation: CancellationToken) -> ToolFuture { | |
| let executor = self.clone(); | |
| Box::pin(async move { | |
| if name != SHELL_NAME { | |
| return Err(failed("unsupported tool")); | |
| } | |
| let input: Input = serde_json::from_value(input).map_err(|e| failed(e.to_string()))?; | |
| validate_command(&input.command)?; | |
| if validate_timeout(input.timeout_ms).is_err() { | |
| return Err(failed( | |
| "Shell requires a nonempty command and positive timeout_ms", | |
| )); | |
| } | |
| let cancellation = cancellation.child_token(); | |
| let executor = executor.with_login_shell(input.login); | |
| let plan = executor.prepare(&input.command, false)?; | |
| // Dropping the awaiter requests cancellation, but cannot abort the | |
| // worker that owns process termination, reaping and pipe drain. | |
| let _cancel_on_drop = cancellation.clone().drop_guard(); | |
| let captured = tokio::spawn(process::run( | |
| plan, | |
| Some(input.timeout_ms), | |
| cancellation, | |
| None, | |
| )) | |
| .await | |
| .map_err(|e| ToolError::CleanupUnconfirmed(format!("Shell worker lost: {e}")))??; | |
| Ok(captured.into_output(executor.cwd, input.command)) | |
| }) | |
| } | |
| } | |
| fn failed(message: impl std::fmt::Display) -> ToolError { | |
| ToolError::Failed(message.to_string()) | |
| } | |
| /// Native I/O APIs may carry an already classified cleanup failure. Preserve | |
| /// that fact when crossing back into the tool lifecycle; never inspect text. | |
| #[cfg(windows)] | |
| fn launch_error(error: std::io::Error) -> ToolError { | |
| error | |
| .get_ref() | |
| .and_then(|inner| inner.downcast_ref::<ToolError>()) | |
| .cloned() | |
| .unwrap_or_else(|| failed(error)) | |
| } | |
| pub fn validate_command(command: &str) -> Result<(), ToolError> { | |
| if command.trim().is_empty() || command.contains('\0') { | |
| return Err(failed( | |
| "Shell requires a nonempty command of at most 64 KiB without NUL", | |
| )); | |
| } | |
| Ok(()) | |
| } | |
| /// Validate only the native clock representation of a requested deadline. | |
| pub fn validate_timeout(ms: u64) -> Result<(), ToolError> { | |
| if ms == 0 | |
| || std::time::Instant::now() | |
| .checked_add(std::time::Duration::from_millis(ms)) | |
| .is_none() | |
| { | |
| return Err(failed( | |
| "shell timeout is not a positive representable duration", | |
| )); | |
| } | |
| Ok(()) | |
| } | |
| async fn timeout(ms: Option<u64>) { | |
| match ms { | |
| Some(ms) => tokio::time::sleep(std::time::Duration::from_millis(ms)).await, | |
| None => std::future::pending().await, | |
| } | |
| } |