| /* | |
| * 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 | |
| } | |
| } |