blob: 746780737a6f210fdf460e847efbb9c51bccbc13 [file]
/*
* 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,
}
}