blob: 3a8ba99883d0dda75f7eec15720cd2da9c31430e [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.
*/
#[cfg(any(target_os = "linux", windows))]
mod cleanup;
#[cfg(windows)]
mod windows;
#[cfg(any(target_os = "linux", windows))]
pub(crate) use cleanup::Cleanup;
use std::{
collections::BTreeMap,
ffi::{OsStr, OsString},
path::PathBuf,
};
/// Captured launch data shared by native pipe and PTY transports. It deliberately
/// does not try to reverse-engineer platform-specific std::Command internals.
pub struct Command {
pub(crate) executable: PathBuf,
pub(crate) args: Vec<Argument>,
pub(crate) cwd: PathBuf,
pub(crate) directory: Option<std::sync::Arc<crate::directory::Directory>>,
pub(crate) environment: BTreeMap<OsString, OsString>,
sandbox: maka_sandbox::Sandbox,
network_route: maka_network::Policy,
#[cfg(unix)]
proxy: Option<maka_network::proxy::Proxy>,
#[cfg(target_os = "macos")]
proxy_address: Option<std::net::SocketAddr>,
#[cfg(target_os = "linux")]
network_helper: Option<PathBuf>,
#[cfg(windows)]
backend: Option<std::sync::Arc<dyn crate::bootstrap::Backend>>,
#[cfg(windows)]
launch: Option<crate::bootstrap::Launch>,
#[cfg(windows)]
write_token: Option<std::sync::Arc<maka_sandbox::windows::WriteToken>>,
#[cfg(target_os = "linux")]
files: Vec<std::fs::File>,
#[cfg(target_os = "linux")]
pub(crate) mount_lease: Option<maka_sandbox::launch::MountLease>,
}
/// Only successful platform preparation can construct a native launch. Keeping
/// conversion off Command makes forgetting the sandbox a compile-time error.
pub(crate) struct Prepared(Command);
#[cfg_attr(windows, derive(serde::Serialize, serde::Deserialize))]
pub(crate) enum Argument {
Quoted(OsString),
#[cfg(windows)]
Verbatim(OsString),
}
impl AsRef<OsStr> for Argument {
fn as_ref(&self) -> &OsStr {
match self {
Self::Quoted(value) => value,
#[cfg(windows)]
Self::Verbatim(value) => value,
}
}
}
#[cfg(windows)]
impl Argument {
pub(crate) fn command_line(&self) -> std::io::Result<String> {
let value = self
.as_ref()
.to_str()
.ok_or_else(|| std::io::Error::other("PTY arguments must be UTF-8"))?;
Ok(match self {
Self::Quoted(_) => crate::shell::command::quote(value),
Self::Verbatim(_) => value.into(),
})
}
}
impl Command {
pub fn new(executable: impl Into<PathBuf>, cwd: impl Into<PathBuf>) -> Self {
Self {
executable: executable.into(),
args: Vec::new(),
cwd: cwd.into(),
directory: None,
environment: crate::environment::inherit(std::env::vars_os()),
sandbox: maka_sandbox::Sandbox::Disabled,
network_route: maka_network::Policy::default(),
#[cfg(unix)]
proxy: None,
#[cfg(target_os = "macos")]
proxy_address: None,
#[cfg(target_os = "linux")]
network_helper: None,
#[cfg(windows)]
backend: None,
#[cfg(windows)]
launch: None,
#[cfg(windows)]
write_token: None,
#[cfg(target_os = "linux")]
files: Vec::new(),
#[cfg(target_os = "linux")]
mount_lease: None,
}
}
pub fn arg(&mut self, value: impl AsRef<OsStr>) -> &mut Self {
self.args.push(Argument::Quoted(value.as_ref().to_owned()));
self
}
pub fn env_clear(&mut self) -> &mut Self {
self.environment.clear();
self
}
/// Capture upstream routing without granting network access.
pub fn network_route(mut self, route: maka_network::Policy) -> Self {
self.network_route = route;
self
}
#[cfg(target_os = "linux")]
pub fn with_network_helper(mut self, helper: PathBuf) -> Self {
self.network_helper = Some(helper);
self
}
/// Explicit executor-owned installation; never inferred from environment or
/// process-global state. Merely capturing it performs no native work.
#[cfg(windows)]
pub fn with_backend(mut self, backend: std::sync::Arc<dyn crate::bootstrap::Backend>) -> Self {
self.backend = Some(backend);
self
}
/// Apply the runner's write-restricted token at process creation. This is
/// only the token layer: the caller must already own account isolation,
/// network rules and ACL lifetime. It cannot replace a managed policy.
#[cfg(windows)]
pub fn with_write_token(
mut self,
token: std::sync::Arc<maka_sandbox::windows::WriteToken>,
) -> Self {
self.write_token = Some(token);
self
}
/// Capture an already authorized policy without creating native resources.
/// Dropping an unstarted plan never changes the filesystem. Windows verbatim
/// arguments retain their original representation.
pub fn sandbox(mut self, policy: &maka_sandbox::Sandbox) -> Result<Self, maka_sandbox::Error> {
if !self.executable.is_absolute() {
return Err(maka_sandbox::Error::Invalid(
"sandbox executable must be absolute".into(),
));
}
if let maka_sandbox::Sandbox::Managed { filesystem, .. } = policy {
filesystem.compile()?;
}
self.sandbox = policy.clone();
Ok(self)
}
/// Only the accepted process owner calls this, and must await startup rather
/// than race it against cancellation. Filesystem preparation runs off the
/// async scheduler; the owner's admission fence covers preparation and spawn.
pub(crate) async fn prepare(self) -> Result<Prepared, maka_agent_facts::tools::ToolError> {
if matches!(self.sandbox, maka_sandbox::Sandbox::Disabled) {
return Ok(Prepared(self));
}
#[cfg(target_os = "macos")]
let mut command = self;
#[cfg(target_os = "macos")]
if let maka_sandbox::Sandbox::Managed {
network: maka_sandbox::Network::Restricted { .. },
..
} = &command.sandbox
{
let maka_sandbox::Sandbox::Managed { network, .. } = &command.sandbox else {
unreachable!()
};
let (address, proxy) =
maka_network::proxy::Proxy::start(network.clone(), command.network_route.clone())
.await
.map_err(crate::failed)?;
let url = format!("http://{address}");
for key in [
"HTTP_PROXY",
"http_proxy",
"HTTPS_PROXY",
"https_proxy",
"ALL_PROXY",
"all_proxy",
] {
command.env(key, &url);
}
command.env("NO_PROXY", "").env("no_proxy", "");
command.proxy = Some(proxy);
command.proxy_address = Some(address);
}
#[cfg(target_os = "linux")]
let mut command = self;
#[cfg(target_os = "linux")]
if let maka_sandbox::Sandbox::Managed {
network: network @ maka_sandbox::Network::Restricted { .. },
..
} = &command.sandbox
{
use std::os::fd::AsRawFd;
let helper = command
.network_helper
.take()
.filter(|path| path.is_absolute())
.ok_or_else(|| {
std::io::Error::other(
"restricted network requires the trusted CLI namespace helper",
)
})
.map_err(crate::failed)?;
let (control, proxy) =
crate::network_namespace::prepare(network.clone(), command.network_route.clone())
.map_err(crate::failed)?;
let mut args = vec![
Argument::Quoted(crate::network_namespace::HELPER.into()),
Argument::Quoted(control.as_raw_fd().to_string().into()),
Argument::Quoted(command.executable.into_os_string()),
];
args.append(&mut command.args);
command.args = args;
command.executable = helper;
command.files.push(control);
command.proxy = Some(proxy);
}
#[cfg(windows)]
let command = self;
#[cfg(windows)]
if let maka_sandbox::Sandbox::Managed {
filesystem,
network,
} = &command.sandbox
{
let backend = command
.backend
.as_ref()
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::Unsupported,
"Windows sandbox execution requires an explicit installation",
)
})
.map_err(crate::failed)?;
let launch = backend
.prepare(
filesystem.clone(),
network.clone(),
command.network_route.clone(),
command.executable.clone(),
command.cwd.clone(),
)
.await
.map_err(crate::launch_error)?;
let mut command = command;
if let Some(address) = launch.proxy_address {
let url = format!("http://{address}");
for key in [
"HTTP_PROXY",
"http_proxy",
"HTTPS_PROXY",
"https_proxy",
"ALL_PROXY",
"all_proxy",
] {
command.env(key, &url);
}
command.env("NO_PROXY", "").env("no_proxy", "");
}
command.launch = Some(launch);
return Ok(Prepared(command));
}
// The asynchronous owner retains the live gateway while the native
// compiler runs. A failed compiler or lost worker cannot detach it.
#[cfg(unix)]
let mut proxy = command.proxy.take();
#[cfg(unix)]
let private_network = proxy.is_some();
#[cfg(windows)]
let private_network = false;
let prepared =
tokio::task::spawn_blocking(move || command.prepare_native(private_network)).await;
let error = match prepared {
Ok(Ok(prepared)) => {
#[cfg(unix)]
let mut prepared = prepared;
#[cfg(unix)]
{
prepared.0.proxy = proxy;
}
return Ok(prepared);
}
Ok(Err(maka_sandbox::Error::CleanupUnconfirmed(message))) => {
maka_agent_facts::tools::ToolError::CleanupUnconfirmed(message)
}
Ok(Err(error)) => crate::failed(error),
Err(error) => maka_agent_facts::tools::ToolError::CleanupUnconfirmed(error.to_string()),
};
#[cfg(unix)]
if let Some(proxy) = &mut proxy {
proxy.close().await.map_err(|error| {
maka_agent_facts::tools::ToolError::CleanupUnconfirmed(error.to_string())
})?;
}
Err(error)
}
fn prepare_native(mut self, _private_network: bool) -> Result<Prepared, maka_sandbox::Error> {
if let Some(name) = self
.environment
.keys()
.find(|name| crate::environment::controls_loader(name))
{
return Err(maka_sandbox::Error::Invalid(format!(
"{} controls the sandbox launcher's loader; set it inside the sandboxed command instead (for example with env)",
name.to_string_lossy()
)));
}
#[cfg(target_os = "linux")]
let launch = if _private_network {
self.sandbox.prepare_with_private_network(
&self.cwd,
self.directory.as_ref().map(|dir| &dir.file),
)?
} else {
self.sandbox
.prepare_captured(&self.cwd, self.directory.as_ref().map(|dir| &dir.file))?
};
#[cfg(windows)]
let launch = self.sandbox.prepare(&self.cwd)?;
#[cfg(target_os = "macos")]
let launch = match self.proxy_address {
Some(address) => self.sandbox.prepare_with_proxy(&self.cwd, address)?,
None => self.sandbox.prepare(&self.cwd)?,
};
match launch {
maka_sandbox::launch::Launch::Direct => {}
maka_sandbox::launch::Launch::Wrapped {
program,
args,
#[cfg(target_os = "linux")]
files,
#[cfg(target_os = "linux")]
lease,
} => {
let mut wrapped: Vec<_> = args.into_iter().map(Argument::Quoted).collect();
wrapped.push(Argument::Quoted(self.executable.into_os_string()));
wrapped.append(&mut self.args);
self.executable = program;
self.args = wrapped;
#[cfg(target_os = "linux")]
{
self.files.extend(files);
self.mount_lease = Some(lease);
}
}
}
Ok(Prepared(self))
}
pub fn args<I, S>(&mut self, values: I) -> &mut Self
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
self.args.extend(
values
.into_iter()
.map(|v| Argument::Quoted(v.as_ref().to_owned())),
);
self
}
/// Append literal Windows command-line syntax without CRT argument quoting.
/// The caller must supply the target program's quoting (e.g. cmd.exe /c).
#[cfg(windows)]
pub fn raw_arg(&mut self, value: impl AsRef<OsStr>) -> &mut Self {
self.args
.push(Argument::Verbatim(value.as_ref().to_owned()));
self
}
pub fn env(&mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> &mut Self {
let key = key.as_ref();
#[cfg(windows)]
self.environment.retain(|existing, _| {
existing.to_string_lossy().to_uppercase() != key.to_string_lossy().to_uppercase()
});
self.environment
.insert(key.to_owned(), value.as_ref().to_owned());
self
}
}
impl Prepared {
/// A failed/cancelled startup still owns every acquired preparation guard.
/// Attempt every cleanup before reporting a sticky unconfirmed result.
pub(crate) async fn abort(mut self) -> Result<(), maka_agent_facts::tools::ToolError> {
let mut failure = None;
#[cfg(unix)]
if let Some(proxy) = &mut self.0.proxy
&& let Err(error) = proxy.close().await
{
failure = Some(error.to_string());
}
#[cfg(target_os = "linux")]
if let Err(error) = Cleanup::from(self.0.mount_lease.take()).finish().await {
failure.get_or_insert_with(|| error.to_string());
}
#[cfg(windows)]
if let Some(launch) = &mut self.0.launch {
if let Err(error) = launch.runner.finish().await {
failure = Some(error.to_string());
}
}
match failure {
Some(message) => Err(maka_agent_facts::tools::ToolError::CleanupUnconfirmed(
message,
)),
None => Ok(()),
}
}
#[cfg(unix)]
pub(crate) fn take_proxy(&mut self) -> Option<maka_network::proxy::Proxy> {
self.0.proxy.take()
}
#[cfg(windows)]
pub(crate) fn take_launch(&mut self) -> Option<crate::bootstrap::Launch> {
self.0.launch.take()
}
#[cfg(windows)]
pub(crate) fn directory(&self) -> Option<std::sync::Arc<crate::directory::Directory>> {
self.0.directory.clone()
}
#[cfg(windows)]
pub(crate) fn runner_plan(self) -> crate::bootstrap::protocol::Plan {
crate::bootstrap::protocol::Plan {
executable: self.0.executable,
args: self.0.args,
cwd: self.0.cwd,
environment: self.0.environment.into_iter().collect(),
}
}
#[cfg(target_os = "linux")]
pub(crate) fn take_mount_lease(&mut self) -> Option<maka_sandbox::launch::MountLease> {
self.0.mount_lease.take()
}
#[cfg(unix)]
pub(crate) fn unix(&mut self) -> tokio::process::Command {
let plan = &mut self.0;
let mut command = tokio::process::Command::new(&plan.executable);
command
.args(&plan.args)
.current_dir(&plan.cwd)
.env_clear()
.envs(&plan.environment);
if let Some(directory) = plan.directory.clone() {
use std::os::fd::AsRawFd;
// Only async-signal-safe fchdir runs in the child. The captured
// descriptor remains live in this closure through exec.
unsafe {
command.pre_exec(move || {
if libc::fchdir(directory.file.as_raw_fd()) == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
}
#[cfg(target_os = "linux")]
if !plan.files.is_empty() {
use std::os::fd::AsRawFd;
let files = std::mem::take(&mut plan.files);
// Keep the pinned descriptors alive with Command. Clear CLOEXEC
// only after fork, never in the multithreaded Host.
unsafe {
command.pre_exec(move || {
for file in &files {
if libc::fcntl(file.as_raw_fd(), libc::F_SETFD, 0) == -1 {
return Err(std::io::Error::last_os_error());
}
}
Ok(())
});
}
}
command
}
}