//! Process execution, networking, and runtime event handling extracted from service.rs.
use secure_exec_vm_config as vm_config;
use crate::filesystem::{
handle_python_vfs_rpc_request as filesystem_handle_python_vfs_rpc_request,
service_javascript_fs_sync_rpc, service_javascript_module_sync_rpc,
};
use crate::protocol::{
BoundUdpSnapshotResponse, CloseStdinRequest, EventFrame, EventPayload, ExecuteRequest,
FindBoundUdpRequest, FindListenerRequest, GetProcessSnapshotRequest, GetSignalStateRequest,
GetZombieTimerCountRequest, GuestRuntimeKind, JavascriptChildProcessSpawnOptions,
JavascriptChildProcessSpawnRequest, JavascriptDgramBindRequest,
JavascriptDgramCreateSocketRequest, JavascriptDgramSendRequest, JavascriptDnsLookupRequest,
JavascriptDnsResolveRequest, JavascriptNetConnectRequest, JavascriptNetListenRequest,
JavascriptNetReserveTcpPortRequest, KillProcessRequest, ListenerSnapshotResponse,
OwnershipScope, ProcessExitedEvent, ProcessKilledResponse, ProcessOutputEvent,
ProcessSnapshotEntry, ProcessSnapshotResponse, ProcessSnapshotStatus, ProcessStartedResponse,
RequestFrame, ResponseFrame, ResponsePayload, SidecarRequestPayload, SignalDispositionAction,
SignalHandlerRegistration, SignalStateResponse, SocketStateEntry, StdinClosedResponse,
StdinWrittenResponse, StreamChannel, VmFetchRequest, VmFetchResponse, WasmPermissionTier,
WriteStdinRequest, ZombieTimerCountResponse,
};
use crate::service::{
audit_fields, dirname, emit_security_audit_event, emit_structured_event, javascript_error,
kernel_error, log_stale_process_event, normalize_host_path, normalize_path,
parse_javascript_child_process_spawn_request, path_is_within_root,
process_event_queue_overflow_error, python_error, wasm_error, MAX_PROCESS_EVENT_QUEUE,
};
use crate::state::{
ActiveCipherSession, ActiveDhSession, ActiveDiffieHellmanSession, ActiveEcdhSession,
ActiveExecution, ActiveExecutionEvent, ActiveHttp2Server, ActiveHttp2Session,
ActiveHttp2Stream, ActiveHttpServer, ActiveMappedHostFd, ActiveProcess, ActiveSqliteDatabase,
ActiveSqliteStatement, ActiveTcpListener, ActiveTcpSocket, ActiveTlsState, ActiveTlsStream,
ActiveUdpSocket, ActiveUnixListener, ActiveUnixSocket, BridgeError, ExitedProcessSnapshot,
Http2BridgeEvent, Http2RuntimeSnapshot, Http2SessionCommand, Http2SessionSnapshot,
Http2SocketSnapshot, JavascriptHttpLoopbackTarget, JavascriptSocketFamily,
JavascriptSocketPathContext, JavascriptTcpListenerEvent, JavascriptTcpSocketEvent,
JavascriptTlsBridgeOptions, JavascriptTlsClientHello, JavascriptTlsDataValue,
JavascriptTlsMaterial, JavascriptUdpFamily, JavascriptUdpSocketEvent,
JavascriptUnixListenerEvent, NetworkResourceCounts, PendingTcpSocket, PendingUnixSocket,
ProcNetEntry, ProcessEventEnvelope, ResolvedChildProcessExecution, ResolvedTcpConnectAddr,
SharedBridge, SharedSidecarRequestClient, SidecarKernel, SocketQueryKind, ToolExecution,
VmDnsConfig, VmListenPolicy, VmState, DEFAULT_JAVASCRIPT_NET_BACKLOG, EXECUTION_DRIVER_NAME,
EXECUTION_SANDBOX_ROOT_ENV, JAVASCRIPT_COMMAND, LOOPBACK_EXEMPT_PORTS_ENV,
MAPPED_HOST_FD_START, PYTHON_COMMAND, TOOL_DRIVER_NAME,
VM_LISTEN_ALLOW_PRIVILEGED_METADATA_KEY, WASM_COMMAND, WASM_STDIO_SYNC_RPC_ENV,
};
use crate::tools::{
format_tool_failure_output, is_tool_command, normalized_tool_command_name,
resolve_tool_command, ToolCommandResolution,
};
use crate::wire::{ProtocolFrame as WireProtocolFrame, WireFrameCodec, DEFAULT_MAX_FRAME_BYTES};
use crate::{DispatchResult, NativeSidecar, NativeSidecarBridge, SidecarError};
use base64::Engine;
use bytes::Bytes;
use h2::{client, server, Reason};
use hickory_resolver::proto::rr::{RData, Record, RecordType};
use hmac::{Hmac, Mac};
use http::{HeaderMap, HeaderName, HeaderValue, Method, Request, Response, Uri};
use md5::Md5;
use nix::libc;
use nix::sys::signal::{kill as send_signal, Signal};
use nix::sys::wait::WaitStatus;
#[cfg(not(target_os = "macos"))]
use nix::sys::wait::{waitid as wait_on_child, Id as WaitId, WaitPidFlag};
#[cfg(target_os = "macos")]
use nix::sys::wait::{waitpid, WaitPidFlag};
use nix::unistd::Pid;
use openssl::bn::{BigNum, BigNumContext};
use openssl::derive::Deriver;
use openssl::dh::Dh;
use openssl::ec::{EcGroup, EcKey, EcPoint, PointConversionForm};
use openssl::hash::MessageDigest;
use openssl::nid::Nid;
use openssl::pkey::{Id as PKeyId, PKey, Params, Private, Public};
use openssl::rand::rand_bytes;
use openssl::rsa::{Padding, Rsa};
use openssl::sign::{Signer, Verifier};
use openssl::symm::{Cipher, Crypter, Mode};
use pbkdf2::pbkdf2_hmac;
use rusqlite::types::ValueRef as SqliteValueRef;
use rusqlite::{
Connection as SqliteConnection, OpenFlags as SqliteOpenFlags, Statement as SqliteStatement,
};
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::crypto::aws_lc_rs;
use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName};
use rustls::{
ClientConfig, ClientConnection, DigitallySignedStruct, RootCertStore, ServerConfig,
ServerConnection, SignatureScheme,
};
use scrypt::{scrypt, Params as ScryptParams};
use secure_exec_bridge::LifecycleState;
use secure_exec_execution::wasm::WasmExecutionError;
use secure_exec_execution::{
javascript::handle_internal_bridge_call_from_host_context, v8_host::V8SessionHandle,
v8_runtime, CreateJavascriptContextRequest, CreatePythonContextRequest,
CreateWasmContextRequest, GuestRuntimeConfig, JavascriptExecutionEvent,
JavascriptExecutionLimits, JavascriptSyncRpcRequest, ModuleFsReader,
NodeSignalDispositionAction, NodeSignalHandlerRegistration, PythonExecutionEvent,
PythonExecutionLimits, PythonVfsRpcMethod, PythonVfsRpcRequest, PythonVfsRpcResponsePayload,
StartJavascriptExecutionRequest, StartPythonExecutionRequest, StartWasmExecutionRequest,
WasmExecutionEvent, WasmExecutionLimits, WasmPermissionTier as ExecutionWasmPermissionTier,
};
use secure_exec_kernel::dns::{
DnsLookupPolicy, DnsRecordResolution, DnsResolutionSource as KernelDnsResolutionSource,
};
use secure_exec_kernel::kernel::{KernelProcessHandle, SpawnOptions, VirtualProcessOptions};
use secure_exec_kernel::permissions::NetworkOperation;
use secure_exec_kernel::poll::{PollEvents, PollFd, PollTargetEntry, POLLERR, POLLHUP, POLLIN};
use secure_exec_kernel::process_table::{ProcessStatus, WaitPidFlags, SIGKILL, SIGTERM};
use secure_exec_kernel::pty::LineDisciplineConfig;
use secure_exec_kernel::resource_accounting::ResourceLimits;
use secure_exec_kernel::root_fs::RootFilesystemMode;
use secure_exec_kernel::socket_table::{
InetSocketAddress, SocketDomain, SocketId, SocketShutdown as KernelSocketShutdown, SocketSpec,
SocketState, SocketType,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use sha1::Sha1;
use sha2::{digest::Digest, Sha256, Sha512};
use socket2::{SockRef, TcpKeepalive};
use std::collections::VecDeque;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::fs;
use std::io::{Cursor, Read, Write};
use std::net::{
IpAddr, Ipv4Addr, Ipv6Addr, Shutdown, SocketAddr, TcpListener, TcpStream, ToSocketAddrs,
UdpSocket,
};
use std::os::unix::fs::{MetadataExt, PermissionsExt};
use std::os::unix::net::{SocketAddr as UnixSocketAddr, UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, RecvTimeoutError, Sender};
use std::sync::{Arc, Mutex, OnceLock, Weak};
use std::thread;
use std::time::{Duration, Instant};
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::runtime::Builder as TokioRuntimeBuilder;
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver};
use tokio_rustls::{TlsAcceptor, TlsConnector};
use url::Url;
const DEFAULT_KERNEL_STDIN_READ_MAX_BYTES: usize = 64 * 1024;
const DEFAULT_KERNEL_STDIN_READ_TIMEOUT_MS: u64 = 100;
const JAVASCRIPT_NET_TIMEOUT_SENTINEL: &str = "__secure_exec_net_timeout__";
const PYTHON_PYODIDE_GUEST_ROOT: &str = "/__agentos_pyodide";
const PYTHON_PYODIDE_CACHE_GUEST_ROOT: &str = "/__agentos_pyodide_cache";
const TCP_SOCKET_POLL_TIMEOUT: Duration = Duration::from_millis(100);
const TLS_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5);
const HTTP_LOOPBACK_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
pub(crate) const MAX_PER_PROCESS_STATE_HANDLES: usize = 1024;
const VM_FETCH_BUFFER_LIMIT_BYTES: usize = DEFAULT_MAX_FRAME_BYTES;
const DEFAULT_SCRYPT_COST: u64 = 16_384;
const DEFAULT_SCRYPT_BLOCK_SIZE: u32 = 8;
const DEFAULT_SCRYPT_PARALLELIZATION: u32 = 1;
const SQLITE_JS_SAFE_INTEGER_MAX: i64 = 9_007_199_254_740_991;
const HTTP_LOOPBACK_REQUEST_TIMEOUT_MS_ENV: &str =
"SECURE_EXEC_TEST_HTTP_LOOPBACK_REQUEST_TIMEOUT_MS";
trait Http2AsyncIo: AsyncRead + AsyncWrite + Unpin + Send {}
impl<T> Http2AsyncIo for T where T: AsyncRead + AsyncWrite + Unpin + Send {}
fn http_loopback_request_timeout() -> Duration {
static TIMEOUT: OnceLock<Duration> = OnceLock::new();
*TIMEOUT.get_or_init(|| {
std::env::var(HTTP_LOOPBACK_REQUEST_TIMEOUT_MS_ENV)
.ok()
.and_then(|value| value.parse::<u64>().ok())
.map(Duration::from_millis)
.unwrap_or(HTTP_LOOPBACK_REQUEST_TIMEOUT)
})
}
const DEFAULT_ALLOWED_NODE_BUILTINS: &[&str] = &[
"assert",
"buffer",
"console",
"child_process",
"crypto",
"dns",
"events",
"fs",
"http",
"http2",
"https",
"module",
"os",
"path",
"perf_hooks",
"querystring",
"sqlite",
"stream",
"string_decoder",
"timers",
"tls",
"tty",
"url",
"util",
"zlib",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum JavascriptCryptoDigestAlgorithm {
Md5,
Sha1,
Sha256,
Sha512,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct JavascriptScryptOptions {
#[serde(alias = "N")]
cost: Option<u64>,
#[serde(alias = "r")]
block_size: Option<u32>,
#[serde(alias = "p")]
parallelization: Option<u32>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct JavascriptHttpListenRequest {
server_id: u64,
#[serde(default)]
port: Option<u16>,
#[serde(default)]
hostname: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct JavascriptHttpRequestOptions {
method: Option<String>,
headers: BTreeMap<String, Value>,
body: Option<String>,
reject_unauthorized: Option<bool>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct JavascriptHttp2ServerListenRequest {
server_id: u64,
secure: bool,
port: Option<u16>,
host: Option<String>,
backlog: Option<u32>,
timeout: Option<u64>,
settings: BTreeMap<String, Value>,
tls: Option<JavascriptTlsBridgeOptions>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct JavascriptHttp2SessionConnectRequest {
authority: Option<String>,
protocol: Option<String>,
host: Option<String>,
port: Option<u16>,
settings: BTreeMap<String, Value>,
tls: Option<JavascriptTlsBridgeOptions>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct JavascriptHttp2RequestOptions {
end_stream: bool,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct JavascriptHttp2FileResponseOptions {
offset: Option<u64>,
length: Option<i64>,
}
#[derive(Debug, Clone)]
struct HttpHeaderCollection {
normalized: BTreeMap<String, Vec<String>>,
raw_pairs: Vec<(String, String)>,
}
#[derive(Debug)]
struct InsecureTlsVerifier {
supported_schemes: Vec<SignatureScheme>,
}
impl ServerCertVerifier for InsecureTlsVerifier {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
self.supported_schemes.clone()
}
}
impl ActiveProcess {
pub(crate) fn new(
kernel_pid: u32,
kernel_handle: KernelProcessHandle,
runtime: GuestRuntimeKind,
execution: ActiveExecution,
) -> Self {
Self {
kernel_pid,
kernel_handle,
kernel_stdin_writer_fd: None,
runtime,
detached: false,
execution,
guest_cwd: String::from("/"),
env: BTreeMap::new(),
host_cwd: PathBuf::from("/"),
mapped_host_fds: BTreeMap::new(),
next_mapped_host_fd: MAPPED_HOST_FD_START,
pending_execution_events: VecDeque::new(),
pending_self_signal_exit: None,
child_processes: BTreeMap::new(),
next_child_process_id: 0,
http_servers: BTreeMap::new(),
pending_http_requests: BTreeMap::new(),
http2: Default::default(),
tcp_listeners: BTreeMap::new(),
next_tcp_listener_id: 0,
tcp_sockets: BTreeMap::new(),
next_tcp_socket_id: 0,
tcp_port_reservations: BTreeMap::new(),
next_tcp_port_reservation_id: 0,
unix_listeners: BTreeMap::new(),
next_unix_listener_id: 0,
unix_sockets: BTreeMap::new(),
next_unix_socket_id: 0,
udp_sockets: BTreeMap::new(),
next_udp_socket_id: 0,
cipher_sessions: BTreeMap::new(),
next_cipher_session_id: 0,
diffie_hellman_sessions: BTreeMap::new(),
next_diffie_hellman_session_id: 0,
sqlite_databases: BTreeMap::new(),
next_sqlite_database_id: 0,
sqlite_statements: BTreeMap::new(),
next_sqlite_statement_id: 0,
module_resolution_cache: secure_exec_execution::LocalModuleResolutionCache::default(),
}
}
pub(crate) fn queue_pending_execution_event(
&mut self,
event: ActiveExecutionEvent,
) -> Result<(), SidecarError> {
if self.pending_execution_events.len() >= MAX_PROCESS_EVENT_QUEUE {
return Err(process_event_queue_overflow_error());
}
self.pending_execution_events.push_back(event);
Ok(())
}
pub(crate) fn with_host_cwd(mut self, host_cwd: PathBuf) -> Self {
self.host_cwd = host_cwd;
self
}
pub(crate) fn with_guest_cwd(mut self, guest_cwd: String) -> Self {
self.guest_cwd = guest_cwd;
self
}
pub(crate) fn with_env(mut self, env: BTreeMap<String, String>) -> Self {
self.env = env;
self
}
pub(crate) fn with_kernel_stdin_writer_fd(mut self, fd: u32) -> Self {
self.kernel_stdin_writer_fd = Some(fd);
self
}
pub(crate) fn with_detached(mut self, detached: bool) -> Self {
self.detached = detached;
self
}
pub(crate) fn allocate_mapped_host_fd(&mut self, fd: ActiveMappedHostFd) -> u32 {
let handle = self.next_mapped_host_fd;
self.next_mapped_host_fd = self
.next_mapped_host_fd
.checked_add(1)
.unwrap_or(MAPPED_HOST_FD_START);
self.mapped_host_fds.insert(handle, fd);
handle
}
pub(crate) fn mapped_host_fd(&self, fd: u32) -> Option<&ActiveMappedHostFd> {
self.mapped_host_fds.get(&fd)
}
pub(crate) fn mapped_host_fd_mut(&mut self, fd: u32) -> Option<&mut ActiveMappedHostFd> {
self.mapped_host_fds.get_mut(&fd)
}
pub(crate) fn close_mapped_host_fd(&mut self, fd: u32) -> bool {
self.mapped_host_fds.remove(&fd).is_some()
}
pub(crate) fn allocate_child_process_id(&mut self) -> String {
self.next_child_process_id += 1;
format!("child-{}", self.next_child_process_id)
}
fn allocate_tcp_listener_id(&mut self) -> String {
self.next_tcp_listener_id += 1;
format!("listener-{}", self.next_tcp_listener_id)
}
fn allocate_tcp_socket_id(&mut self) -> String {
self.next_tcp_socket_id += 1;
format!("socket-{}", self.next_tcp_socket_id)
}
fn allocate_tcp_port_reservation_id(&mut self) -> String {
self.next_tcp_port_reservation_id += 1;
format!("tcp-port-reservation-{}", self.next_tcp_port_reservation_id)
}
fn allocate_unix_listener_id(&mut self) -> String {
self.next_unix_listener_id += 1;
format!("unix-listener-{}", self.next_unix_listener_id)
}
fn allocate_unix_socket_id(&mut self) -> String {
self.next_unix_socket_id += 1;
format!("unix-socket-{}", self.next_unix_socket_id)
}
fn allocate_udp_socket_id(&mut self) -> String {
self.next_udp_socket_id += 1;
format!("udp-socket-{}", self.next_udp_socket_id)
}
pub(crate) fn network_resource_counts(&self) -> NetworkResourceCounts {
let mut counts = NetworkResourceCounts {
sockets: self.http_servers.len()
+ self.tcp_listeners.len()
+ self.tcp_sockets.len()
+ self.unix_listeners.len()
+ self.unix_sockets.len()
+ self.udp_sockets.len(),
connections: self.tcp_sockets.len() + self.unix_sockets.len(),
};
if let Ok(http2) = self.http2.shared.lock() {
counts.sockets += http2.servers.len() + http2.sessions.len();
counts.connections += http2.sessions.len();
}
for child in self.child_processes.values() {
let child_counts = child.network_resource_counts();
counts.sockets += child_counts.sockets;
counts.connections += child_counts.connections;
}
counts
}
fn sidecar_only_network_resource_counts(&self) -> NetworkResourceCounts {
let mut counts = NetworkResourceCounts {
sockets: self.http_servers.len()
+ self
.tcp_listeners
.values()
.filter(|listener| listener.kernel_socket_id.is_none())
.count()
+ self
.tcp_sockets
.values()
.filter(|socket| socket.kernel_socket_id.is_none())
.count()
+ self.unix_listeners.len()
+ self.unix_sockets.len()
+ self
.udp_sockets
.values()
.filter(|socket| socket.kernel_socket_id.is_none())
.count(),
connections: self
.tcp_sockets
.values()
.filter(|socket| socket.kernel_socket_id.is_none())
.count()
+ self.unix_sockets.len(),
};
if let Ok(http2) = self.http2.shared.lock() {
counts.sockets += http2.servers.len() + http2.sessions.len();
counts.connections += http2.sessions.len();
}
for child in self.child_processes.values() {
let child_counts = child.sidecar_only_network_resource_counts();
counts.sockets += child_counts.sockets;
counts.connections += child_counts.connections;
}
counts
}
}
fn poll_tool_process_event(
execution: &ToolExecution,
) -> Result<Option<ActiveExecutionEvent>, SidecarError> {
let event = execution
.pending_events
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.pop_front();
if event.is_some() {
return Ok(event);
}
if execution.events_overflowed.load(Ordering::Relaxed) {
return Err(process_event_queue_overflow_error());
}
Ok(None)
}
fn descendant_pending_execution_event_capacity(
root: &ActiveProcess,
child_path: &[&str],
) -> Option<usize> {
let mut child = root;
for child_process_id in child_path {
child = child.child_processes.get(*child_process_id)?;
}
Some(MAX_PROCESS_EVENT_QUEUE.saturating_sub(child.pending_execution_events.len()))
}
fn poll_child_execution_after_exit(
child: &mut ActiveProcess,
wait: Duration,
) -> Result<Option<ActiveExecutionEvent>, SidecarError> {
match child.execution.poll_event_blocking(wait) {
Ok(event) => Ok(event),
Err(SidecarError::Execution(message))
if child.runtime == GuestRuntimeKind::WebAssembly
&& message == WasmExecutionError::EventChannelClosed.to_string() =>
{
Ok(None)
}
Err(error) => Err(error),
}
}
fn closed_javascript_event_channel(message: &str) -> bool {
message == "guest JavaScript event channel closed unexpectedly"
}
fn closed_python_event_channel(message: &str) -> bool {
message == "guest Python event channel closed unexpectedly"
}
fn closed_wasm_event_channel(message: &str) -> bool {
message == WasmExecutionError::EventChannelClosed.to_string()
}
fn missing_vm_error(vm_id: &str) -> SidecarError {
SidecarError::InvalidState(format!("VM {vm_id} is no longer active"))
}
fn missing_process_error(vm_id: &str, process_id: &str) -> SidecarError {
SidecarError::InvalidState(format!(
"VM {vm_id} no longer has active process {process_id}"
))
}
fn is_broken_pipe_error(error: &SidecarError) -> bool {
matches!(error, SidecarError::Execution(message) if message.contains("Broken pipe") || message.contains("os error 32") || message.contains("EPIPE"))
}
fn javascript_child_process_gone_error(process_id: &str, child_path: &[&str]) -> SidecarError {
let child_label = if child_path.is_empty() {
process_id.to_owned()
} else {
format!("{process_id}/{}", child_path.join("/"))
};
SidecarError::Execution(format!(
"ECHILD: child_process {child_label} is no longer available"
))
}
fn is_javascript_child_process_gone_error(error: &SidecarError) -> bool {
matches!(
error,
SidecarError::Execution(message) if guest_errno_code(message) == Some("ECHILD")
)
}
fn loopback_tls_transport_registry(
) -> &'static Mutex<BTreeMap<String, Weak<crate::state::LoopbackTlsTransportPair>>> {
static REGISTRY: OnceLock<
Mutex<BTreeMap<String, Weak<crate::state::LoopbackTlsTransportPair>>>,
> = OnceLock::new();
REGISTRY.get_or_init(|| Mutex::new(BTreeMap::new()))
}
fn loopback_tls_transport_key(
vm_id: &str,
socket_id: SocketId,
peer_socket_id: SocketId,
) -> String {
let (lower, higher) = if socket_id <= peer_socket_id {
(socket_id, peer_socket_id)
} else {
(peer_socket_id, socket_id)
};
format!("{vm_id}:{lower}:{higher}")
}
fn loopback_tls_endpoint(
vm_id: &str,
socket_id: SocketId,
peer_socket_id: SocketId,
) -> Result<crate::state::LoopbackTlsEndpoint, SidecarError> {
let key = loopback_tls_transport_key(vm_id, socket_id, peer_socket_id);
let registry = loopback_tls_transport_registry();
let mut transports = registry.lock().map_err(|_| {
SidecarError::InvalidState(String::from(
"loopback TLS transport registry lock poisoned",
))
})?;
transports.retain(|_, pair| pair.strong_count() > 0);
let pair = transports
.get(&key)
.and_then(Weak::upgrade)
.unwrap_or_else(|| {
let pair = Arc::new(crate::state::LoopbackTlsTransportPair {
state: Mutex::new(crate::state::LoopbackTlsTransportPairState::default()),
ready: std::sync::Condvar::new(),
});
transports.insert(key, Arc::downgrade(&pair));
pair
});
Ok(crate::state::LoopbackTlsEndpoint {
pair,
is_lower_socket: socket_id <= peer_socket_id,
})
}
impl crate::state::LoopbackTlsEndpoint {
fn shutdown_write(&self) -> Result<(), SidecarError> {
let mut state = self.pair.state.lock().map_err(|_| {
SidecarError::InvalidState(String::from("loopback TLS transport lock poisoned"))
})?;
if self.is_lower_socket {
state.lower_write_closed = true;
} else {
state.higher_write_closed = true;
}
self.pair.ready.notify_all();
Ok(())
}
fn close_endpoint(&self) -> Result<(), SidecarError> {
let mut state = self.pair.state.lock().map_err(|_| {
SidecarError::InvalidState(String::from("loopback TLS transport lock poisoned"))
})?;
if self.is_lower_socket {
state.lower_write_closed = true;
state.lower_closed = true;
} else {
state.higher_write_closed = true;
state.higher_closed = true;
}
self.pair.ready.notify_all();
Ok(())
}
}
fn parse_tls_client_hello_from_bytes(
buffer: &[u8],
) -> Result<Option<JavascriptTlsClientHello>, SidecarError> {
if buffer.is_empty() {
return Ok(None);
}
let mut acceptor = rustls::server::Acceptor::default();
let mut cursor = Cursor::new(buffer);
acceptor.read_tls(&mut cursor).map_err(sidecar_net_error)?;
let Some(accepted) = acceptor.accept().map_err(|(error, _)| {
SidecarError::Execution(format!("failed to parse TLS client hello: {error}"))
})?
else {
return Ok(None);
};
let client_hello = accepted.client_hello();
let alpn_protocols = client_hello.alpn().map(|protocols| {
protocols
.filter_map(|protocol| String::from_utf8(protocol.to_vec()).ok())
.collect::<Vec<_>>()
});
Ok(Some(JavascriptTlsClientHello {
servername: client_hello.server_name().map(str::to_owned),
alpn_protocols,
}))
}
fn peek_loopback_tls_client_hello(
vm_id: &str,
socket_id: SocketId,
peer_socket_id: SocketId,
) -> Result<Option<JavascriptTlsClientHello>, SidecarError> {
let key = loopback_tls_transport_key(vm_id, socket_id, peer_socket_id);
let registry = loopback_tls_transport_registry();
let pair = registry
.lock()
.map_err(|_| {
SidecarError::InvalidState(String::from(
"loopback TLS transport registry lock poisoned",
))
})?
.get(&key)
.and_then(Weak::upgrade);
let Some(pair) = pair else {
return Ok(None);
};
let is_lower_socket = socket_id <= peer_socket_id;
let state = pair.state.lock().map_err(|_| {
SidecarError::InvalidState(String::from("loopback TLS transport lock poisoned"))
})?;
let buffered = if is_lower_socket {
state.higher_to_lower.iter().copied().collect::<Vec<_>>()
} else {
state.lower_to_higher.iter().copied().collect::<Vec<_>>()
};
drop(state);
parse_tls_client_hello_from_bytes(&buffered)
}
fn wait_for_loopback_peer_socket_id(
kernel: &SidecarKernel,
socket_id: SocketId,
) -> Option<SocketId> {
for _ in 0..50 {
if let Some(peer_socket_id) = kernel
.socket_get(socket_id)
.and_then(|record| record.peer_socket_id())
{
return Some(peer_socket_id);
}
std::thread::sleep(Duration::from_millis(10));
}
None
}
impl Drop for crate::state::LoopbackTlsEndpoint {
fn drop(&mut self) {
let _ = self.close_endpoint();
}
}
impl Read for crate::state::LoopbackTlsEndpoint {
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
let mut state = self
.pair
.state
.lock()
.map_err(|_| std::io::Error::other("loopback TLS transport lock poisoned"))?;
loop {
let (peer_write_closed, peer_closed) = if self.is_lower_socket {
(state.higher_write_closed, state.higher_closed)
} else {
(state.lower_write_closed, state.lower_closed)
};
let incoming = if self.is_lower_socket {
&mut state.higher_to_lower
} else {
&mut state.lower_to_higher
};
if !incoming.is_empty() {
let mut count = 0;
while count < buffer.len() {
let Some(byte) = incoming.pop_front() else {
break;
};
buffer[count] = byte;
count += 1;
}
return Ok(count);
}
if peer_write_closed || peer_closed {
return Ok(0);
}
let (next_state, wait_result) = self
.pair
.ready
.wait_timeout(state, TCP_SOCKET_POLL_TIMEOUT)
.map_err(|_| std::io::Error::other("loopback TLS transport lock poisoned"))?;
state = next_state;
if wait_result.timed_out() {
return Err(std::io::Error::new(
std::io::ErrorKind::WouldBlock,
"loopback TLS transport read timed out",
));
}
}
}
}
impl Write for crate::state::LoopbackTlsEndpoint {
fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
let mut state = self
.pair
.state
.lock()
.map_err(|_| std::io::Error::other("loopback TLS transport lock poisoned"))?;
let peer_closed = if self.is_lower_socket {
state.higher_closed
} else {
state.lower_closed
};
let outgoing = if self.is_lower_socket {
&mut state.lower_to_higher
} else {
&mut state.higher_to_lower
};
if peer_closed {
return Err(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"loopback TLS peer is closed",
));
}
outgoing.extend(buffer.iter().copied());
self.pair.ready.notify_all();
Ok(buffer.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
// TCP types moved to crate::state
struct ActiveTcpConnectRequest<'a, B> {
bridge: &'a SharedBridge<B>,
kernel: &'a mut SidecarKernel,
kernel_pid: u32,
vm_id: &'a str,
dns: &'a VmDnsConfig,
host: &'a str,
port: u16,
local_address: Option<&'a str>,
local_port: Option<u16>,
local_reservation: Option<(JavascriptSocketFamily, u16)>,
context: &'a JavascriptSocketPathContext,
}
struct ActiveUdpSendToRequest<'a, B> {
bridge: &'a SharedBridge<B>,
kernel: &'a mut SidecarKernel,
kernel_pid: u32,
vm_id: &'a str,
dns: &'a VmDnsConfig,
host: &'a str,
port: u16,
context: &'a JavascriptSocketPathContext,
contents: &'a [u8],
}
struct UdpRemoteAddrRequest<'a, B> {
bridge: &'a SharedBridge<B>,
kernel: &'a SidecarKernel,
vm_id: &'a str,
dns: &'a VmDnsConfig,
host: &'a str,
port: u16,
family: JavascriptUdpFamily,
context: &'a JavascriptSocketPathContext,
}
pub(crate) struct JavascriptSyncRpcServiceRequest<'a, B> {
pub(crate) bridge: &'a SharedBridge<B>,
pub(crate) vm_id: &'a str,
pub(crate) dns: &'a VmDnsConfig,
pub(crate) socket_paths: &'a JavascriptSocketPathContext,
pub(crate) kernel: &'a mut SidecarKernel,
pub(crate) process: &'a mut ActiveProcess,
pub(crate) sync_request: &'a JavascriptSyncRpcRequest,
pub(crate) resource_limits: &'a ResourceLimits,
pub(crate) network_counts: NetworkResourceCounts,
}
pub(crate) struct JavascriptNetSyncRpcServiceRequest<'a, B> {
pub(crate) bridge: &'a SharedBridge<B>,
pub(crate) vm_id: &'a str,
pub(crate) dns: &'a VmDnsConfig,
pub(crate) socket_paths: &'a JavascriptSocketPathContext,
pub(crate) kernel: &'a mut SidecarKernel,
pub(crate) process: &'a mut ActiveProcess,
pub(crate) sync_request: &'a JavascriptSyncRpcRequest,
pub(crate) resource_limits: &'a ResourceLimits,
pub(crate) network_counts: NetworkResourceCounts,
}
struct LoopbackHttpResponseWaitRequest<'a, B> {
bridge: &'a SharedBridge<B>,
vm_id: &'a str,
dns: &'a VmDnsConfig,
socket_paths: &'a JavascriptSocketPathContext,
kernel: &'a mut SidecarKernel,
process: &'a mut ActiveProcess,
resource_limits: &'a ResourceLimits,
request_key: (u64, u64),
}
pub(crate) struct LoopbackHttpDispatchRequest<'a, B> {
pub(crate) bridge: &'a SharedBridge<B>,
pub(crate) vm_id: &'a str,
pub(crate) dns: &'a VmDnsConfig,
pub(crate) socket_paths: &'a JavascriptSocketPathContext,
pub(crate) kernel: &'a mut SidecarKernel,
pub(crate) process: &'a mut ActiveProcess,
pub(crate) resource_limits: &'a ResourceLimits,
pub(crate) server_id: u64,
pub(crate) request_json: &'a str,
}
struct JavascriptDgramSyncRpcServiceRequest<'a, B> {
bridge: &'a SharedBridge<B>,
kernel: &'a mut SidecarKernel,
vm_id: &'a str,
dns: &'a VmDnsConfig,
socket_paths: &'a JavascriptSocketPathContext,
process: &'a mut ActiveProcess,
sync_request: &'a JavascriptSyncRpcRequest,
resource_limits: &'a ResourceLimits,
network_counts: NetworkResourceCounts,
}
struct JavascriptHttp2SyncRpcServiceRequest<'a, B> {
bridge: &'a SharedBridge<B>,
kernel: &'a mut SidecarKernel,
vm_id: &'a str,
dns: &'a VmDnsConfig,
socket_paths: &'a JavascriptSocketPathContext,
process: &'a mut ActiveProcess,
sync_request: &'a JavascriptSyncRpcRequest,
resource_limits: &'a ResourceLimits,
network_counts: NetworkResourceCounts,
}
impl ActiveTcpSocket {
fn connect<B>(request: ActiveTcpConnectRequest<'_, B>) -> Result<Self, SidecarError>
where
B: NativeSidecarBridge + Send + 'static,
BridgeError<B>: fmt::Debug + Send + Sync + 'static,
{
let ActiveTcpConnectRequest {
bridge,
kernel,
kernel_pid,
vm_id,
dns,
host,
port,
local_address,
local_port,
local_reservation,
context,
} = request;
let resolved = resolve_tcp_connect_addr(bridge, kernel, vm_id, dns, host, port, context)?;
if resolved.use_kernel_loopback {
let family = JavascriptSocketFamily::from_ip(resolved.guest_remote_addr.ip());
let requested_local_port = local_port.unwrap_or(0);
let local_port = if requested_local_port != 0
&& local_reservation == Some((family, requested_local_port))
{
requested_local_port
} else {
allocate_guest_listen_port(
requested_local_port,
family,
&context.used_tcp_guest_ports,
context.listen_policy,
)?
};
let local_ip = match (family, local_address) {
(JavascriptSocketFamily::Ipv4, Some("0.0.0.0")) => {
IpAddr::V4(Ipv4Addr::UNSPECIFIED)
}
(JavascriptSocketFamily::Ipv4, Some("127.0.0.1") | Some("localhost") | None) => {
IpAddr::V4(Ipv4Addr::LOCALHOST)
}
(JavascriptSocketFamily::Ipv6, Some("::")) => IpAddr::V6(Ipv6Addr::UNSPECIFIED),
(JavascriptSocketFamily::Ipv6, Some("::1") | Some("localhost") | None) => {
IpAddr::V6(Ipv6Addr::LOCALHOST)
}
(JavascriptSocketFamily::Ipv4, Some(other)) => {
return Err(SidecarError::Execution(format!(
"EACCES: TCP sockets must bind to loopback or unspecified addresses, got {other}"
)));
}
(JavascriptSocketFamily::Ipv6, Some(other)) => {
return Err(SidecarError::Execution(format!(
"EACCES: TCP sockets must bind to loopback or unspecified addresses, got {other}"
)));
}
};
let local_addr = SocketAddr::new(local_ip, local_port);
let spec = match family {
JavascriptSocketFamily::Ipv4 => SocketSpec::tcp(),
JavascriptSocketFamily::Ipv6 => {
SocketSpec::new(SocketDomain::Inet6, SocketType::Stream)
}
};
let socket_id = kernel
.socket_create(EXECUTION_DRIVER_NAME, kernel_pid, spec)
.map_err(kernel_error)?;
kernel
.socket_bind_inet(
EXECUTION_DRIVER_NAME,
kernel_pid,
socket_id,
InetSocketAddress::new(local_ip.to_string(), local_port),
)
.map_err(kernel_error)?;
kernel
.socket_connect_inet_loopback(
EXECUTION_DRIVER_NAME,
kernel_pid,
socket_id,
InetSocketAddress::new(
resolved.guest_remote_addr.ip().to_string(),
resolved.guest_remote_addr.port(),
),
)
.map_err(kernel_error)?;
return Ok(Self::from_kernel(
socket_id,
None,
local_addr,
resolved.guest_remote_addr,
));
}
let stream = TcpStream::connect_timeout(&resolved.actual_addr, Duration::from_secs(30))
.map_err(sidecar_net_error)?;
let guest_local_addr = stream.local_addr().map_err(sidecar_net_error)?;
Self::from_stream(stream, None, guest_local_addr, resolved.guest_remote_addr)
}
fn from_stream(
stream: TcpStream,
listener_id: Option<String>,
guest_local_addr: SocketAddr,
guest_remote_addr: SocketAddr,
) -> Result<Self, SidecarError> {
let read_stream = stream.try_clone().map_err(sidecar_net_error)?;
read_stream
.set_read_timeout(Some(TCP_SOCKET_POLL_TIMEOUT))
.map_err(sidecar_net_error)?;
let stream = Arc::new(Mutex::new(stream));
let pending_read_stream = Arc::new(Mutex::new(Some(read_stream)));
let (sender, events) = mpsc::channel();
let tls_mode = Arc::new(AtomicBool::new(false));
let tls_stream = Arc::new(Mutex::new(None));
let tls_state = Arc::new(Mutex::new(None));
let saw_local_shutdown = Arc::new(AtomicBool::new(false));
let saw_remote_end = Arc::new(AtomicBool::new(false));
let close_notified = Arc::new(AtomicBool::new(false));
Ok(Self {
stream: Some(stream),
pending_read_stream: Some(pending_read_stream),
events: Some(events),
event_sender: Some(sender),
kernel_socket_id: None,
no_delay: false,
keep_alive: false,
keep_alive_initial_delay_secs: None,
guest_local_addr,
guest_remote_addr,
listener_id,
tls_mode,
tls_stream,
tls_state,
saw_local_shutdown,
saw_remote_end,
close_notified,
})
}
fn from_kernel(
socket_id: SocketId,
listener_id: Option<String>,
guest_local_addr: SocketAddr,
guest_remote_addr: SocketAddr,
) -> Self {
let (sender, events) = mpsc::channel();
Self {
stream: None,
pending_read_stream: None,
events: Some(events),
event_sender: Some(sender),
kernel_socket_id: Some(socket_id),
no_delay: false,
keep_alive: false,
keep_alive_initial_delay_secs: None,
guest_local_addr,
guest_remote_addr,
listener_id,
tls_mode: Arc::new(AtomicBool::new(false)),
tls_stream: Arc::new(Mutex::new(None)),
tls_state: Arc::new(Mutex::new(None)),
saw_local_shutdown: Arc::new(AtomicBool::new(false)),
saw_remote_end: Arc::new(AtomicBool::new(false)),
close_notified: Arc::new(AtomicBool::new(false)),
}
}
fn poll(
&mut self,
kernel: &mut SidecarKernel,
kernel_pid: u32,
wait: Duration,
) -> Result<Option<JavascriptTcpSocketEvent>, SidecarError> {
if self.tls_mode.load(Ordering::SeqCst) {
self.ensure_tcp_reader()?;
return match self
.events
.as_ref()
.ok_or_else(|| {
SidecarError::InvalidState(String::from("TCP socket event channel missing"))
})?
.recv_timeout(wait)
{
Ok(event) => Ok(Some(event)),
Err(RecvTimeoutError::Timeout) => Ok(None),
Err(RecvTimeoutError::Disconnected) => Ok(None),
};
}
if let Some(socket_id) = self.kernel_socket_id {
let result = kernel
.poll_targets(
EXECUTION_DRIVER_NAME,
kernel_pid,
vec![PollTargetEntry::socket(
socket_id,
POLLIN | POLLHUP | POLLERR,
)],
i32::try_from(wait.as_millis()).unwrap_or(i32::MAX),
)
.map_err(kernel_error)?;
let revents = result
.targets
.first()
.map(|entry| entry.revents)
.unwrap_or_else(PollEvents::empty);
if revents.is_empty() {
return Ok(None);
}
if revents.intersects(POLLIN) {
return match kernel.socket_read(
EXECUTION_DRIVER_NAME,
kernel_pid,
socket_id,
64 * 1024,
) {
Ok(Some(bytes)) if !bytes.is_empty() => {
Ok(Some(JavascriptTcpSocketEvent::Data(bytes)))
}
Ok(Some(_)) => Ok(Some(JavascriptTcpSocketEvent::Data(Vec::new()))),
Ok(None) => Ok(Some(JavascriptTcpSocketEvent::End)),
Err(error) if error.code() == "EAGAIN" => Ok(None),
Err(error) => Ok(Some(JavascriptTcpSocketEvent::Error {
code: Some(error.code().to_string()),
message: error.to_string(),
})),
};
}
if revents.intersects(POLLHUP) {
return Ok(Some(JavascriptTcpSocketEvent::End));
}
if revents.intersects(POLLERR) {
return Ok(Some(JavascriptTcpSocketEvent::Error {
code: Some(String::from("EPIPE")),
message: String::from("kernel TCP socket reported POLLERR"),
}));
}
return Ok(None);
}
self.ensure_tcp_reader()?;
match self
.events
.as_ref()
.ok_or_else(|| {
SidecarError::InvalidState(String::from("TCP socket event channel missing"))
})?
.recv_timeout(wait)
{
Ok(event) => Ok(Some(event)),
Err(RecvTimeoutError::Timeout) => Ok(None),
Err(RecvTimeoutError::Disconnected) => Ok(None),
}
}
fn ensure_tcp_reader(&self) -> Result<(), SidecarError> {
if self.kernel_socket_id.is_some() {
return Ok(());
}
if self.tls_mode.load(Ordering::SeqCst) {
return Ok(());
}
let read_stream = self
.pending_read_stream
.as_ref()
.ok_or_else(|| {
SidecarError::InvalidState(String::from("TCP socket reader handle missing"))
})?
.lock()
.map_err(|_| {
SidecarError::InvalidState(String::from("TCP socket reader lock poisoned"))
})?
.take();
if let Some(read_stream) = read_stream {
spawn_tcp_socket_reader(
read_stream,
self.event_sender
.as_ref()
.ok_or_else(|| {
SidecarError::InvalidState(String::from("TCP socket event sender missing"))
})?
.clone(),
Arc::clone(&self.tls_mode),
Arc::clone(&self.saw_local_shutdown),
Arc::clone(&self.saw_remote_end),
Arc::clone(&self.close_notified),
);
}
Ok(())
}
fn socket_info(&self) -> Value {
json!({
"localAddress": self.guest_local_addr.ip().to_string(),
"localPort": self.guest_local_addr.port(),
"localFamily": socket_addr_family(&self.guest_local_addr),
"remoteAddress": self.guest_remote_addr.ip().to_string(),
"remotePort": self.guest_remote_addr.port(),
"remoteFamily": socket_addr_family(&self.guest_remote_addr),
})
}
fn set_no_delay(&mut self, enable: bool) -> Result<(), SidecarError> {
self.no_delay = enable;
if self.kernel_socket_id.is_some() {
return Ok(());
}
let stream = self
.stream
.as_ref()
.ok_or_else(|| SidecarError::InvalidState(String::from("TCP socket stream missing")))?
.lock()
.map_err(|_| SidecarError::InvalidState(String::from("TCP socket lock poisoned")))?;
stream.set_nodelay(enable).map_err(sidecar_net_error)
}
fn set_keep_alive(
&mut self,
enable: bool,
initial_delay_secs: Option<u64>,
) -> Result<(), SidecarError> {
self.keep_alive = enable;
self.keep_alive_initial_delay_secs = initial_delay_secs;
if self.kernel_socket_id.is_some() {
return Ok(());
}
let stream = self
.stream
.as_ref()
.ok_or_else(|| SidecarError::InvalidState(String::from("TCP socket stream missing")))?
.lock()
.map_err(|_| SidecarError::InvalidState(String::from("TCP socket lock poisoned")))?;
let socket = SockRef::from(&*stream);
socket.set_keepalive(enable).map_err(sidecar_net_error)?;
if enable {
if let Some(delay_secs) = initial_delay_secs.filter(|delay_secs| *delay_secs > 0) {
socket
.set_tcp_keepalive(
&TcpKeepalive::new().with_time(Duration::from_secs(delay_secs)),
)
.map_err(sidecar_net_error)?;
}
}
Ok(())
}
fn upgrade_tls(
&self,
vm_id: &str,
kernel: &SidecarKernel,
options: JavascriptTlsBridgeOptions,
) -> Result<(), SidecarError> {
if self.tls_mode.load(Ordering::SeqCst) {
return Ok(());
}
let client_hello = if options.is_server {
self.peek_tls_client_hello(vm_id, kernel)?
} else {
None
};
let tls_stream = if let Some(socket_id) = self.kernel_socket_id {
let peer_socket_id = wait_for_loopback_peer_socket_id(kernel, socket_id)
.ok_or_else(|| {
SidecarError::Execution(format!(
"ERR_NOT_IMPLEMENTED: kernel-backed loopback socket {socket_id} has no peer for TLS upgrade"
))
})?;
let endpoint = loopback_tls_endpoint(vm_id, socket_id, peer_socket_id)?;
if options.is_server {
ActiveTlsStream::LoopbackServer(build_server_loopback_tls_stream(
endpoint, &options,
)?)
} else {
ActiveTlsStream::LoopbackClient(build_client_loopback_tls_stream(
endpoint, &options,
)?)
}
} else {
self.pending_read_stream
.as_ref()
.ok_or_else(|| {
SidecarError::InvalidState(String::from("TCP socket reader handle missing"))
})?
.lock()
.map_err(|_| {
SidecarError::InvalidState(String::from("TCP socket reader lock poisoned"))
})?
.take();
let stream = self
.stream
.as_ref()
.ok_or_else(|| {
SidecarError::InvalidState(String::from("TCP socket stream missing"))
})?
.lock()
.map_err(|_| {
SidecarError::InvalidState(String::from("TCP socket lock poisoned"))
})?;
let cloned = stream.try_clone().map_err(sidecar_net_error)?;
drop(stream);
if options.is_server {
ActiveTlsStream::Server(build_server_tls_stream(cloned, &options)?)
} else {
ActiveTlsStream::Client(build_client_tls_stream(cloned, &options)?)
}
};
let tls_state = ActiveTlsState {
client_hello,
local_certificates: tls_local_certificates(&options)?,
session_reused: false,
};
self.tls_mode.store(true, Ordering::SeqCst);
{
let mut state = self
.tls_state
.lock()
.map_err(|_| SidecarError::InvalidState(String::from("TLS state lock poisoned")))?;
*state = Some(tls_state);
}
{
let mut stream = self.tls_stream.lock().map_err(|_| {
SidecarError::InvalidState(String::from("TLS stream lock poisoned"))
})?;
*stream = Some(tls_stream);
}
spawn_tls_socket_reader(
Arc::clone(&self.tls_stream),
self.event_sender
.as_ref()
.ok_or_else(|| {
SidecarError::InvalidState(String::from("TCP socket event sender missing"))
})?
.clone(),
Arc::clone(&self.saw_local_shutdown),
Arc::clone(&self.saw_remote_end),
Arc::clone(&self.close_notified),
);
Ok(())
}
fn peek_tls_client_hello(
&self,
vm_id: &str,
kernel: &SidecarKernel,
) -> Result<Option<JavascriptTlsClientHello>, SidecarError> {
if let Some(socket_id) = self.kernel_socket_id {
let Some(peer_socket_id) = kernel
.socket_get(socket_id)
.and_then(|record| record.peer_socket_id())
else {
return Ok(None);
};
return peek_loopback_tls_client_hello(vm_id, socket_id, peer_socket_id);
}
let stream = self
.stream
.as_ref()
.ok_or_else(|| SidecarError::InvalidState(String::from("TCP socket stream missing")))?
.lock()
.map_err(|_| SidecarError::InvalidState(String::from("TCP socket lock poisoned")))?;
let mut buffer = vec![0_u8; 16 * 1024];
let bytes = match stream.peek(&mut buffer) {
Ok(0) => return Ok(None),
Ok(bytes) => bytes,
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
) =>
{
return Ok(None);
}
Err(error) => return Err(sidecar_net_error(error)),
};
parse_tls_client_hello_from_bytes(&buffer[..bytes])
}
fn tls_client_hello_json(
&self,
vm_id: &str,
kernel: &SidecarKernel,
) -> Result<Value, SidecarError> {
if let Some(client_hello) = self
.tls_state
.lock()
.map_err(|_| SidecarError::InvalidState(String::from("TLS state lock poisoned")))?
.as_ref()
.and_then(|state| state.client_hello.clone())
{
return javascript_net_json_string(
serde_json::to_value(client_hello).map_err(|error| {
SidecarError::InvalidState(format!(
"failed to serialize TLS client hello: {error}"
))
})?,
"net.socket_get_tls_client_hello",
);
}
javascript_net_json_string(
serde_json::to_value(
self.peek_tls_client_hello(vm_id, kernel)?
.unwrap_or_default(),
)
.map_err(|error| {
SidecarError::InvalidState(format!("failed to serialize TLS client hello: {error}"))
})?,
"net.socket_get_tls_client_hello",
)
}
fn tls_query(&self, query: &str, detailed: bool) -> Result<Value, SidecarError> {
let state = self
.tls_state
.lock()
.map_err(|_| SidecarError::InvalidState(String::from("TLS state lock poisoned")))?
.clone();
let mut tls_stream = self
.tls_stream
.lock()
.map_err(|_| SidecarError::InvalidState(String::from("TLS stream lock poisoned")))?;
let Some(stream) = tls_stream.as_mut() else {
return javascript_net_json_string(
tls_bridge_undefined_value(),
"net.socket_tls_query",
);
};
let payload = match query {
"getSession" => tls_bridge_undefined_value(),
"isSessionReused" => Value::Bool(
state
.as_ref()
.is_some_and(|tls_state| tls_state.session_reused),
),
"getPeerCertificate" => {
let certificate = stream
.peer_certificates()
.and_then(|certificates| certificates.first())
.map(|certificate| {
tls_certificate_bridge_value(certificate.as_ref(), detailed)
});
certificate.unwrap_or_else(tls_bridge_undefined_value)
}
"getCertificate" => state
.as_ref()
.and_then(|tls_state| tls_state.local_certificates.first())
.map(|certificate| tls_certificate_bridge_value(certificate, detailed))
.unwrap_or_else(tls_bridge_undefined_value),
"getProtocol" => stream
.protocol_version()
.map(tls_protocol_name)
.map(Value::String)
.unwrap_or(Value::Null),
"getCipher" => stream
.negotiated_cipher_suite()
.map(tls_cipher_bridge_value)
.unwrap_or_else(tls_bridge_undefined_value),
other => {
return Err(SidecarError::InvalidState(format!(
"unsupported TLS query {other}"
)));
}
};
javascript_net_json_string(payload, "net.socket_tls_query")
}
fn write_all(
&self,
kernel: &mut SidecarKernel,
kernel_pid: u32,
contents: &[u8],
) -> Result<usize, SidecarError> {
if self.tls_mode.load(Ordering::SeqCst) {
let mut tls_stream = self.tls_stream.lock().map_err(|_| {
SidecarError::InvalidState(String::from("TLS stream lock poisoned"))
})?;
let stream = tls_stream.as_mut().ok_or_else(|| {
SidecarError::InvalidState(String::from("TLS stream missing for upgraded socket"))
})?;
stream.write_all(contents)?;
return Ok(contents.len());
}
if let Some(socket_id) = self.kernel_socket_id {
return kernel
.socket_write(EXECUTION_DRIVER_NAME, kernel_pid, socket_id, contents)
.map_err(kernel_error);
}
let mut stream = self
.stream
.as_ref()
.ok_or_else(|| SidecarError::InvalidState(String::from("TCP socket stream missing")))?
.lock()
.map_err(|_| SidecarError::InvalidState(String::from("TCP socket lock poisoned")))?;
stream.write_all(contents).map_err(sidecar_net_error)?;
Ok(contents.len())
}
fn shutdown_write(
&self,
kernel: &mut SidecarKernel,
kernel_pid: u32,
) -> Result<(), SidecarError> {
if self.tls_mode.load(Ordering::SeqCst) {
if let Some(stream) = self
.tls_stream
.lock()
.map_err(|_| SidecarError::InvalidState(String::from("TLS stream lock poisoned")))?
.as_mut()
{
let _ = stream.send_close_notify();
let _ = stream.shutdown_write();
}
if self.kernel_socket_id.is_some() {
self.saw_local_shutdown.store(true, Ordering::SeqCst);
return Ok(());
}
}
if let Some(socket_id) = self.kernel_socket_id {
return kernel
.socket_shutdown(
EXECUTION_DRIVER_NAME,
kernel_pid,
socket_id,
KernelSocketShutdown::Write,
)
.map_err(kernel_error);
}
let stream = self
.stream
.as_ref()
.ok_or_else(|| SidecarError::InvalidState(String::from("TCP socket stream missing")))?
.lock()
.map_err(|_| SidecarError::InvalidState(String::from("TCP socket lock poisoned")))?;
self.saw_local_shutdown.store(true, Ordering::SeqCst);
match stream.shutdown(Shutdown::Write) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotConnected => {}
Err(error) => return Err(sidecar_net_error(error)),
}
if self.saw_remote_end.load(Ordering::SeqCst)
&& !self.close_notified.swap(true, Ordering::SeqCst)
{
let _ = self
.event_sender
.as_ref()
.ok_or_else(|| {
SidecarError::InvalidState(String::from("TCP socket event sender missing"))
})?
.send(JavascriptTcpSocketEvent::Close { had_error: false });
}
Ok(())
}
fn close(&self, kernel: &mut SidecarKernel, kernel_pid: u32) -> Result<(), SidecarError> {
if self.tls_mode.load(Ordering::SeqCst) {
if let Some(stream) = self
.tls_stream
.lock()
.map_err(|_| SidecarError::InvalidState(String::from("TLS stream lock poisoned")))?
.as_mut()
{
let _ = stream.send_close_notify();
let _ = stream.close();
}
if self.kernel_socket_id.is_some() {
return Ok(());
}
}
if let Some(socket_id) = self.kernel_socket_id {
return kernel
.socket_close(EXECUTION_DRIVER_NAME, kernel_pid, socket_id)
.map_err(kernel_error);
}
let stream = self
.stream
.as_ref()
.ok_or_else(|| SidecarError::InvalidState(String::from("TCP socket stream missing")))?
.lock()
.map_err(|_| SidecarError::InvalidState(String::from("TCP socket lock poisoned")))?;
stream.shutdown(Shutdown::Both).map_err(sidecar_net_error)
}
}
impl ActiveTlsStream {
fn write_all(&mut self, contents: &[u8]) -> Result<(), SidecarError> {
match self {
Self::Client(stream) => {
stream.write_all(contents).map_err(sidecar_net_error)?;
stream.flush().map_err(sidecar_net_error)
}
Self::Server(stream) => {
stream.write_all(contents).map_err(sidecar_net_error)?;
stream.flush().map_err(sidecar_net_error)
}
Self::LoopbackClient(stream) => {
stream.write_all(contents).map_err(sidecar_net_error)?;
stream.flush().map_err(sidecar_net_error)
}
Self::LoopbackServer(stream) => {
stream.write_all(contents).map_err(sidecar_net_error)?;
stream.flush().map_err(sidecar_net_error)
}
}
}
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
match self {
Self::Client(stream) => stream.read(buffer),
Self::Server(stream) => stream.read(buffer),
Self::LoopbackClient(stream) => stream.read(buffer),
Self::LoopbackServer(stream) => stream.read(buffer),
}
}
fn send_close_notify(&mut self) -> Result<(), SidecarError> {
match self {
Self::Client(stream) => {
stream.conn.send_close_notify();
let _ = stream.conn.complete_io(&mut stream.sock);
}
Self::Server(stream) => {
stream.conn.send_close_notify();
let _ = stream.conn.complete_io(&mut stream.sock);
}
Self::LoopbackClient(stream) => {
stream.conn.send_close_notify();
let _ = stream.conn.complete_io(&mut stream.sock);
}
Self::LoopbackServer(stream) => {
stream.conn.send_close_notify();
let _ = stream.conn.complete_io(&mut stream.sock);
}
}
Ok(())
}
fn shutdown_write(&mut self) -> Result<(), SidecarError> {
match self {
Self::Client(stream) => stream
.sock
.shutdown(Shutdown::Write)
.map_err(sidecar_net_error),
Self::Server(stream) => stream
.sock
.shutdown(Shutdown::Write)
.map_err(sidecar_net_error),
Self::LoopbackClient(stream) => stream.sock.shutdown_write(),
Self::LoopbackServer(stream) => stream.sock.shutdown_write(),
}
}
fn close(&mut self) -> Result<(), SidecarError> {
match self {
Self::Client(stream) => stream
.sock
.shutdown(Shutdown::Both)
.map_err(sidecar_net_error),
Self::Server(stream) => stream
.sock
.shutdown(Shutdown::Both)
.map_err(sidecar_net_error),
Self::LoopbackClient(stream) => stream.sock.close_endpoint(),
Self::LoopbackServer(stream) => stream.sock.close_endpoint(),
}
}
fn peer_certificates(&self) -> Option<&[CertificateDer<'static>]> {
match self {
Self::Client(stream) => stream.conn.peer_certificates(),
Self::Server(stream) => stream.conn.peer_certificates(),
Self::LoopbackClient(stream) => stream.conn.peer_certificates(),
Self::LoopbackServer(stream) => stream.conn.peer_certificates(),
}
}
fn negotiated_cipher_suite(&self) -> Option<rustls::SupportedCipherSuite> {
match self {
Self::Client(stream) => stream.conn.negotiated_cipher_suite(),
Self::Server(stream) => stream.conn.negotiated_cipher_suite(),
Self::LoopbackClient(stream) => stream.conn.negotiated_cipher_suite(),
Self::LoopbackServer(stream) => stream.conn.negotiated_cipher_suite(),
}
}
fn protocol_version(&self) -> Option<rustls::ProtocolVersion> {
match self {
Self::Client(stream) => stream.conn.protocol_version(),
Self::Server(stream) => stream.conn.protocol_version(),
Self::LoopbackClient(stream) => stream.conn.protocol_version(),
Self::LoopbackServer(stream) => stream.conn.protocol_version(),
}
}
}
// ActiveTcpListener moved to crate::state
// Unix socket types moved to crate::state
impl ActiveUnixSocket {
fn connect(host_path: &Path, guest_path: &str) -> Result<Self, SidecarError> {
let stream = UnixStream::connect(host_path).map_err(sidecar_net_error)?;
Self::from_stream(stream, None, None, Some(guest_path.to_owned()))
}
fn from_stream(
stream: UnixStream,
listener_id: Option<String>,
local_path: Option<String>,
remote_path: Option<String>,
) -> Result<Self, SidecarError> {
let read_stream = stream.try_clone().map_err(sidecar_net_error)?;
let stream = Arc::new(Mutex::new(stream));
let (sender, events) = mpsc::channel();
let saw_local_shutdown = Arc::new(AtomicBool::new(false));
let saw_remote_end = Arc::new(AtomicBool::new(false));
let close_notified = Arc::new(AtomicBool::new(false));
spawn_unix_socket_reader(
read_stream,
sender.clone(),
Arc::clone(&saw_local_shutdown),
Arc::clone(&saw_remote_end),
Arc::clone(&close_notified),
);
Ok(Self {
stream,
events,
event_sender: sender,
listener_id,
local_path,
remote_path,
saw_local_shutdown,
saw_remote_end,
close_notified,
})
}
fn poll(&mut self, wait: Duration) -> Result<Option<JavascriptTcpSocketEvent>, SidecarError> {
match self.events.recv_timeout(wait) {
Ok(event) => Ok(Some(event)),
Err(RecvTimeoutError::Timeout) => Ok(None),
Err(RecvTimeoutError::Disconnected) => Ok(None),
}
}
fn socket_info(&self) -> Value {
json!({
"localPath": self.local_path.clone(),
"remotePath": self.remote_path.clone(),
})
}
fn write_all(&self, contents: &[u8]) -> Result<usize, SidecarError> {
let mut stream = self
.stream
.lock()
.map_err(|_| SidecarError::InvalidState(String::from("Unix socket lock poisoned")))?;
stream.write_all(contents).map_err(sidecar_net_error)?;
Ok(contents.len())
}
fn shutdown_write(&self) -> Result<(), SidecarError> {
let stream = self
.stream
.lock()
.map_err(|_| SidecarError::InvalidState(String::from("Unix socket lock poisoned")))?;
self.saw_local_shutdown.store(true, Ordering::SeqCst);
stream
.shutdown(Shutdown::Write)
.map_err(sidecar_net_error)?;
if self.saw_remote_end.load(Ordering::SeqCst)
&& !self.close_notified.swap(true, Ordering::SeqCst)
{
let _ = self
.event_sender
.send(JavascriptTcpSocketEvent::Close { had_error: false });
}
Ok(())
}
fn close(&self) -> Result<(), SidecarError> {
let stream = self
.stream
.lock()
.map_err(|_| SidecarError::InvalidState(String::from("Unix socket lock poisoned")))?;
stream.shutdown(Shutdown::Both).map_err(sidecar_net_error)
}
}
// ActiveUnixListener moved to crate::state
impl ActiveUnixListener {
fn bind(
host_path: &Path,
guest_path: &str,
backlog: Option<u32>,
) -> Result<Self, SidecarError> {
if let Some(parent) = host_path.parent() {
fs::create_dir_all(parent).map_err(sidecar_net_error)?;
}
let listener = UnixListener::bind(host_path).map_err(sidecar_net_error)?;
listener.set_nonblocking(true).map_err(sidecar_net_error)?;
Ok(Self {
listener,
path: guest_path.to_owned(),
backlog: usize::try_from(backlog.unwrap_or(DEFAULT_JAVASCRIPT_NET_BACKLOG))
.expect("default backlog fits within usize"),
active_connection_ids: BTreeSet::new(),
})
}
fn path(&self) -> &str {
&self.path
}
fn poll(
&mut self,
wait: Duration,
) -> Result<Option<JavascriptUnixListenerEvent>, SidecarError> {
let deadline = Instant::now() + wait;
loop {
match self.listener.accept() {
Ok((stream, remote_addr)) => {
if self.active_connection_ids.len() >= self.backlog {
let _ = stream.shutdown(Shutdown::Both);
if wait.is_zero() || Instant::now() >= deadline {
return Ok(None);
}
continue;
}
let local_path = Some(self.path.clone());
let remote_path = unix_socket_path(&remote_addr);
return Ok(Some(JavascriptUnixListenerEvent::Connection(
PendingUnixSocket {
stream,
local_path,
remote_path,
},
)));
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
if wait.is_zero() || Instant::now() >= deadline {
return Ok(None);
}
thread::sleep(Duration::from_millis(10));
}
Err(error) => {
return Ok(Some(JavascriptUnixListenerEvent::Error {
code: io_error_code(&error),
message: error.to_string(),
}));
}
}
}
}
fn close(&self) -> Result<(), SidecarError> {
Ok(())
}
fn active_connection_count(&self) -> usize {
self.active_connection_ids.len()
}
fn register_connection(&mut self, socket_id: &str) {
self.active_connection_ids.insert(socket_id.to_string());
}
fn release_connection(&mut self, socket_id: &str) {
self.active_connection_ids.remove(socket_id);
}
}
impl ActiveTcpListener {
fn bind(
bind_host: &str,
guest_host: &str,
guest_port: u16,
backlog: Option<u32>,
) -> Result<Self, SidecarError> {
let bind_addr = resolve_tcp_bind_addr(bind_host, 0)?;
let guest_addr = resolve_tcp_bind_addr(guest_host, guest_port)?;
let listener = TcpListener::bind(bind_addr).map_err(sidecar_net_error)?;
listener.set_nonblocking(true).map_err(sidecar_net_error)?;
let local_addr = listener.local_addr().map_err(sidecar_net_error)?;
Ok(Self {
listener: Some(listener),
kernel_socket_id: None,
local_addr: Some(local_addr),
guest_local_addr: guest_addr,
backlog: usize::try_from(backlog.unwrap_or(DEFAULT_JAVASCRIPT_NET_BACKLOG))
.expect("default backlog fits within usize"),
active_connection_ids: BTreeSet::new(),
})
}
fn bind_kernel(
kernel: &mut SidecarKernel,
kernel_pid: u32,
guest_host: &str,
guest_port: u16,
backlog: Option<u32>,
) -> Result<Self, SidecarError> {
let guest_addr = resolve_tcp_bind_addr(guest_host, guest_port)?;
let spec = match guest_addr {
SocketAddr::V4(_) => SocketSpec::tcp(),
SocketAddr::V6(_) => SocketSpec::new(SocketDomain::Inet6, SocketType::Stream),
};
let socket_id = kernel
.socket_create(EXECUTION_DRIVER_NAME, kernel_pid, spec)
.map_err(kernel_error)?;
kernel
.socket_bind_inet(
EXECUTION_DRIVER_NAME,
kernel_pid,
socket_id,
InetSocketAddress::new(guest_addr.ip().to_string(), guest_addr.port()),
)
.map_err(kernel_error)?;
kernel
.socket_listen(
EXECUTION_DRIVER_NAME,
kernel_pid,
socket_id,
usize::try_from(backlog.unwrap_or(DEFAULT_JAVASCRIPT_NET_BACKLOG))
.expect("default backlog fits within usize"),
)
.map_err(kernel_error)?;
Ok(Self {
listener: None,
kernel_socket_id: Some(socket_id),
local_addr: Some(guest_addr),
guest_local_addr: guest_addr,
backlog: usize::try_from(backlog.unwrap_or(DEFAULT_JAVASCRIPT_NET_BACKLOG))
.expect("default backlog fits within usize"),
active_connection_ids: BTreeSet::new(),
})
}
pub(crate) fn local_addr(&self) -> SocketAddr {
self.local_addr.unwrap_or(self.guest_local_addr)
}
fn guest_local_addr(&self) -> SocketAddr {
self.guest_local_addr
}
fn poll(
&mut self,
kernel: &mut SidecarKernel,
kernel_pid: u32,
wait: Duration,
) -> Result<Option<JavascriptTcpListenerEvent>, SidecarError> {
if let Some(socket_id) = self.kernel_socket_id {
let result = kernel
.poll_targets(
EXECUTION_DRIVER_NAME,
kernel_pid,
vec![PollTargetEntry::socket(socket_id, POLLIN)],
i32::try_from(wait.as_millis()).unwrap_or(i32::MAX),
)
.map_err(kernel_error)?;
let revents = result
.targets
.first()
.map(|entry| entry.revents)
.unwrap_or_else(PollEvents::empty);
if revents.is_empty() {
return Ok(None);
}
let accepted_socket_id =
match kernel.socket_accept(EXECUTION_DRIVER_NAME, kernel_pid, socket_id) {
Ok(accepted_socket_id) => accepted_socket_id,
Err(error) if error.code() == "EAGAIN" => return Ok(None),
Err(error) => {
return Ok(Some(JavascriptTcpListenerEvent::Error {
code: Some(error.code().to_string()),
message: error.to_string(),
}));
}
};
let accepted = kernel.socket_get(accepted_socket_id).ok_or_else(|| {
SidecarError::InvalidState(format!(
"accepted kernel TCP socket {accepted_socket_id} is missing"
))
})?;
let local_addr = accepted.local_address().ok_or_else(|| {
SidecarError::InvalidState(format!(
"accepted kernel TCP socket {accepted_socket_id} missing local address"
))
})?;
let remote_addr = accepted.peer_address().ok_or_else(|| {
SidecarError::InvalidState(format!(
"accepted kernel TCP socket {accepted_socket_id} missing peer address"
))
})?;
return Ok(Some(JavascriptTcpListenerEvent::Connection(
PendingTcpSocket {
stream: None,
kernel_socket_id: Some(accepted_socket_id),
preallocated: true,
guest_local_addr: resolve_tcp_bind_addr(local_addr.host(), local_addr.port())?,
guest_remote_addr: resolve_tcp_bind_addr(
remote_addr.host(),
remote_addr.port(),
)?,
},
)));
}
let deadline = Instant::now() + wait;
loop {
match self
.listener
.as_ref()
.ok_or_else(|| {
SidecarError::InvalidState(String::from("TCP listener socket missing"))
})?
.accept()
{
Ok((stream, remote_addr)) => {
if self.active_connection_ids.len() >= self.backlog {
let _ = stream.shutdown(Shutdown::Both);
if wait.is_zero() || Instant::now() >= deadline {
return Ok(None);
}
continue;
}
return Ok(Some(JavascriptTcpListenerEvent::Connection(
PendingTcpSocket {
stream: Some(stream),
kernel_socket_id: None,
preallocated: false,
guest_local_addr: self.guest_local_addr,
guest_remote_addr: SocketAddr::new(
remote_addr.ip(),
remote_addr.port(),
),
},
)));
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
if wait.is_zero() || Instant::now() >= deadline {
return Ok(None);
}
thread::sleep(Duration::from_millis(10));
}
Err(error) => {
return Ok(Some(JavascriptTcpListenerEvent::Error {
code: io_error_code(&error),
message: error.to_string(),
}));
}
}
}
}
fn close(&self, kernel: &mut SidecarKernel, kernel_pid: u32) -> Result<(), SidecarError> {
if let Some(socket_id) = self.kernel_socket_id {
kernel
.socket_close(EXECUTION_DRIVER_NAME, kernel_pid, socket_id)
.map_err(kernel_error)?;
}
Ok(())
}
fn active_connection_count(&self) -> usize {
self.active_connection_ids.len()
}
fn register_connection(&mut self, socket_id: &str) {
self.active_connection_ids.insert(socket_id.to_string());
}
fn release_connection(&mut self, socket_id: &str) {
self.active_connection_ids.remove(socket_id);
}
}
// UDP types moved to crate::state
impl ActiveUdpSocket {
fn new(
kernel: &mut SidecarKernel,
kernel_pid: u32,
family: JavascriptUdpFamily,
) -> Result<Self, SidecarError> {
let spec = match family {
JavascriptUdpFamily::Ipv4 => SocketSpec::udp(),
JavascriptUdpFamily::Ipv6 => SocketSpec::new(SocketDomain::Inet6, SocketType::Datagram),
};
let socket_id = kernel
.socket_create(EXECUTION_DRIVER_NAME, kernel_pid, spec)
.map_err(kernel_error)?;
Ok(Self {
family,
socket: None,
kernel_socket_id: Some(socket_id),
guest_local_addr: None,
recv_buffer_size: 0,
send_buffer_size: 0,
})
}
fn local_addr(&self) -> Option<SocketAddr> {
self.guest_local_addr
}
fn socket(&self) -> Result<&UdpSocket, SidecarError> {
self.socket
.as_ref()
.ok_or_else(|| SidecarError::Execution(String::from("EBADF: bad file descriptor")))
}
fn bind(
&mut self,
kernel: &mut SidecarKernel,
kernel_pid: u32,
host: Option<&str>,
port: u16,
context: &JavascriptSocketPathContext,
) -> Result<SocketAddr, SidecarError> {
if self.socket.is_some() || self.guest_local_addr.is_some() {
return Err(SidecarError::Execution(String::from(
"EINVAL: secure-exec dgram socket is already bound",
)));
}
let (bind_host, guest_host, guest_family) = normalize_udp_bind_host(host, self.family)?;
let guest_port = allocate_guest_listen_port(
port,
guest_family,
&context.used_udp_guest_ports,
context.listen_policy,
)?;
let local_addr = resolve_udp_bind_addr(guest_host, guest_port, self.family)?;
if let Some(socket_id) = self.kernel_socket_id {
kernel
.socket_bind_inet(
EXECUTION_DRIVER_NAME,
kernel_pid,
socket_id,
InetSocketAddress::new(local_addr.ip().to_string(), local_addr.port()),
)
.map_err(kernel_error)?;
} else {
let bind_addr = resolve_udp_bind_addr(bind_host, 0, self.family)?;
let socket = UdpSocket::bind(bind_addr).map_err(sidecar_net_error)?;
socket.set_nonblocking(true).map_err(sidecar_net_error)?;
self.socket = Some(socket);
}
self.guest_local_addr = Some(local_addr);
Ok(local_addr)
}
fn ensure_bound_for_send(
&mut self,
kernel: &mut SidecarKernel,
kernel_pid: u32,
context: &JavascriptSocketPathContext,
) -> Result<SocketAddr, SidecarError> {
if let Some(local_addr) = self.local_addr() {
return Ok(local_addr);
}
self.bind(kernel, kernel_pid, None, 0, context)
}
fn send_to<B>(
&mut self,
request: ActiveUdpSendToRequest<'_, B>,
) -> Result<(usize, SocketAddr), SidecarError>
where
B: NativeSidecarBridge + Send + 'static,
BridgeError<B>: fmt::Debug + Send + Sync + 'static,
{
let ActiveUdpSendToRequest {
bridge,
kernel,
kernel_pid,
vm_id,
dns,
host,
port,
context,
contents,
} = request;
let remote_addr = resolve_udp_addr(UdpRemoteAddrRequest {
bridge,
kernel,
vm_id,
dns,
host,
port,
family: self.family,
context,
})?;
let local_addr = self.ensure_bound_for_send(kernel, kernel_pid, context)?;
let written = if let Some(socket_id) = self.kernel_socket_id {
if is_loopback_ip(remote_addr.ip()) && remote_addr.port() == port {
kernel
.socket_send_to_inet_loopback(
EXECUTION_DRIVER_NAME,
kernel_pid,
socket_id,
InetSocketAddress::new(remote_addr.ip().to_string(), remote_addr.port()),
contents,
)
.map_err(kernel_error)?
} else {
return Err(SidecarError::Execution(String::from(
"ERR_NOT_IMPLEMENTED: external UDP datagrams are not yet supported by the kernel-backed V8 bridge",
)));
}
} else {
let socket = self.socket.as_ref().ok_or_else(|| {
SidecarError::InvalidState(String::from("UDP socket is not initialized"))
})?;
socket
.send_to(contents, remote_addr)
.map_err(sidecar_net_error)?
};
Ok((written, local_addr))
}
fn poll(
&self,
kernel: &mut SidecarKernel,
kernel_pid: u32,
wait: Duration,
) -> Result<Option<JavascriptUdpSocketEvent>, SidecarError> {
if let Some(socket_id) = self.kernel_socket_id {
let result = kernel
.poll_targets(
EXECUTION_DRIVER_NAME,
kernel_pid,
vec![PollTargetEntry::socket(socket_id, POLLIN)],
i32::try_from(wait.as_millis()).unwrap_or(i32::MAX),
)
.map_err(kernel_error)?;
let revents = result
.targets
.first()
.map(|entry| entry.revents)
.unwrap_or_else(PollEvents::empty);
if revents.is_empty() {
return Ok(None);
}
return match kernel.socket_recv_datagram(
EXECUTION_DRIVER_NAME,
kernel_pid,
socket_id,
64 * 1024,
) {
Ok(Some(datagram)) => {
let (source_address, payload) = datagram.into_parts();
let remote_addr = source_address
.map(|source| {
resolve_udp_bind_addr(source.host(), source.port(), self.family)
})
.transpose()?
.unwrap_or_else(|| match self.family {
JavascriptUdpFamily::Ipv4 => {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)
}
JavascriptUdpFamily::Ipv6 => {
SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0)
}
});
Ok(Some(JavascriptUdpSocketEvent::Message {
data: payload,
remote_addr,
}))
}
Ok(None) => Ok(None),
Err(error) if error.code() == "EAGAIN" => Ok(None),
Err(error) => Ok(Some(JavascriptUdpSocketEvent::Error {
code: Some(error.code().to_string()),
message: error.to_string(),
})),
};
}
let socket = self.socket()?;
let deadline = Instant::now() + wait;
let mut buffer = vec![0_u8; 64 * 1024];
loop {
match socket.recv_from(&mut buffer) {
Ok((bytes_read, remote_addr)) => {
return Ok(Some(JavascriptUdpSocketEvent::Message {
data: buffer[..bytes_read].to_vec(),
remote_addr,
}));
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
if wait.is_zero() || Instant::now() >= deadline {
return Ok(None);
}
thread::sleep(Duration::from_millis(10));
}
Err(error) => {
return Ok(Some(JavascriptUdpSocketEvent::Error {
code: io_error_code(&error),
message: error.to_string(),
}));
}
}
}
}
fn close(&mut self, kernel: &mut SidecarKernel, kernel_pid: u32) {
if let Some(socket_id) = self.kernel_socket_id {
let _ = kernel.socket_close(EXECUTION_DRIVER_NAME, kernel_pid, socket_id);
}
self.socket.take();
self.guest_local_addr = None;
}
fn set_buffer_size(&mut self, which: &str, size: usize) -> Result<(), SidecarError> {
match which {
"recv" => self.recv_buffer_size = size,
"send" => self.send_buffer_size = size,
other => {
return Err(SidecarError::InvalidState(format!(
"unsupported UDP buffer size kind {other}"
)));
}
}
if self.kernel_socket_id.is_some() {
return Ok(());
}
let socket = self.socket()?;
let socket = SockRef::from(socket);
match which {
"recv" => socket.set_recv_buffer_size(size).map_err(sidecar_net_error),
"send" => socket.set_send_buffer_size(size).map_err(sidecar_net_error),
other => Err(SidecarError::InvalidState(format!(
"unsupported UDP buffer size kind {other}"
))),
}
}
fn get_buffer_size(&self, which: &str) -> Result<usize, SidecarError> {
if self.kernel_socket_id.is_some() {
return Ok(match which {
"recv" => self.recv_buffer_size,
"send" => self.send_buffer_size,
other => {
return Err(SidecarError::InvalidState(format!(
"unsupported UDP buffer size kind {other}"
)));
}
});
}
let socket = self.socket()?;
let socket = SockRef::from(socket);
match which {
"recv" => socket.recv_buffer_size().map_err(sidecar_net_error),
"send" => socket.send_buffer_size().map_err(sidecar_net_error),
other => Err(SidecarError::InvalidState(format!(
"unsupported UDP buffer size kind {other}"
))),
}
}
}
// ActiveExecution, ActiveExecutionEvent, SocketQueryKind moved to crate::state
impl ActiveExecution {
pub(crate) fn uses_shared_v8_runtime(&self) -> bool {
match self {
Self::Javascript(execution) => execution.uses_shared_v8_runtime(),
Self::Python(execution) => execution.uses_shared_v8_runtime(),
Self::Wasm(execution) => execution.uses_shared_v8_runtime(),
Self::Tool(_) => false,
}
}
pub(crate) fn child_pid(&self) -> u32 {
match self {
Self::Javascript(execution) => execution.child_pid(),
Self::Python(execution) => execution.child_pid(),
Self::Wasm(execution) => execution.child_pid(),
Self::Tool(_) => 0,
}
}
pub(crate) fn write_stdin(&mut self, chunk: &[u8]) -> Result<(), SidecarError> {
match self {
Self::Javascript(execution) => execution
.write_stdin(chunk)
.map_err(|error| SidecarError::Execution(error.to_string())),
Self::Python(execution) => execution
.write_stdin(chunk)
.map_err(|error| SidecarError::Execution(error.to_string())),
Self::Wasm(execution) => execution
.write_stdin(chunk)
.map_err(|error| SidecarError::Execution(error.to_string())),
Self::Tool(_) => Ok(()),
}
}
pub(crate) fn close_stdin(&mut self) -> Result<(), SidecarError> {
match self {
Self::Javascript(execution) => execution
.close_stdin()
.map_err(|error| SidecarError::Execution(error.to_string())),
Self::Python(execution) => execution
.close_stdin()
.map_err(|error| SidecarError::Execution(error.to_string())),
Self::Wasm(execution) => execution
.close_stdin()
.map_err(|error| SidecarError::Execution(error.to_string())),
Self::Tool(_) => Ok(()),
}
}
pub(crate) fn respond_python_vfs_rpc_success(
&mut self,
id: u64,
payload: PythonVfsRpcResponsePayload,
) -> Result<(), SidecarError> {
match self {
Self::Python(execution) => execution
.respond_vfs_rpc_success(id, payload)
.map_err(|error| SidecarError::Execution(error.to_string())),
_ => Err(SidecarError::InvalidState(String::from(
"only Python executions can service Python VFS RPC responses",
))),
}
}
pub(crate) fn respond_python_vfs_rpc_error(
&mut self,
id: u64,
code: impl Into<String>,
message: impl Into<String>,
) -> Result<(), SidecarError> {
match self {
Self::Python(execution) => execution
.respond_vfs_rpc_error(id, code, message)
.map_err(|error| SidecarError::Execution(error.to_string())),
_ => Err(SidecarError::InvalidState(String::from(
"only Python executions can service Python VFS RPC responses",
))),
}
}
pub(crate) fn send_javascript_stream_event(
&self,
event_type: &str,
payload: Value,
) -> Result<(), SidecarError> {
match self {
Self::Javascript(execution) => execution
.send_stream_event(event_type, payload)
.map_err(|error| SidecarError::Execution(error.to_string())),
Self::Wasm(execution) => execution
.send_stream_event(event_type, payload)
.map_err(|error| SidecarError::Execution(error.to_string())),
_ => Err(SidecarError::InvalidState(String::from(
"only embedded V8 executions can receive JavaScript stream events",
))),
}
}
pub(crate) fn javascript_v8_session_handle(&self) -> Option<V8SessionHandle> {
match self {
Self::Javascript(execution) => Some(execution.v8_session_handle()),
Self::Wasm(execution) => Some(execution.v8_session_handle()),
_ => None,
}
}
pub(crate) fn terminate(&mut self) -> Result<(), SidecarError> {
match self {
Self::Javascript(execution) => execution
.terminate()
.map_err(|error| SidecarError::Execution(error.to_string())),
Self::Python(execution) => execution
.kill()
.map_err(|error| SidecarError::Execution(error.to_string())),
Self::Wasm(execution) => execution
.terminate()
.map_err(|error| SidecarError::Execution(error.to_string())),
Self::Tool(_) => Ok(()),
}
}
pub(crate) fn respond_javascript_sync_rpc_success(
&mut self,
id: u64,
result: Value,
) -> Result<(), SidecarError> {
match self {
Self::Javascript(execution) => execution
.respond_sync_rpc_success(id, result)
.map_err(|error| SidecarError::Execution(error.to_string())),
Self::Python(execution) => execution
.respond_javascript_sync_rpc_success(id, result)
.map_err(|error| SidecarError::Execution(error.to_string())),
Self::Wasm(execution) => execution
.respond_sync_rpc_success(id, result)
.map_err(|error| SidecarError::Execution(error.to_string())),
_ => Err(SidecarError::InvalidState(String::from(
"only JavaScript, Python, and WebAssembly executions can service JavaScript sync RPC responses",
))),
}
}
pub(crate) fn respond_javascript_sync_rpc_error(
&mut self,
id: u64,
code: impl Into<String>,
message: impl Into<String>,
) -> Result<(), SidecarError> {
match self {
Self::Javascript(execution) => execution
.respond_sync_rpc_error(id, code, message)
.map_err(|error| SidecarError::Execution(error.to_string())),
Self::Python(execution) => execution
.respond_javascript_sync_rpc_error(id, code, message)
.map_err(|error| SidecarError::Execution(error.to_string())),
Self::Wasm(execution) => execution
.respond_sync_rpc_error(id, code, message)
.map_err(|error| SidecarError::Execution(error.to_string())),
_ => Err(SidecarError::InvalidState(String::from(
"only JavaScript, Python, and WebAssembly executions can service JavaScript sync RPC responses",
))),
}
}
pub(crate) async fn poll_event(
&mut self,
timeout: Duration,
) -> Result<Option<ActiveExecutionEvent>, SidecarError> {
match self {
Self::Javascript(execution) => execution
.poll_event(timeout)
.await
.map(|event| {
event.map(|event| match event {
JavascriptExecutionEvent::Stdout(chunk) => {
ActiveExecutionEvent::Stdout(chunk)
}
JavascriptExecutionEvent::Stderr(chunk) => {
ActiveExecutionEvent::Stderr(chunk)
}
JavascriptExecutionEvent::SyncRpcRequest(request) => {
ActiveExecutionEvent::JavascriptSyncRpcRequest(request)
}
JavascriptExecutionEvent::SignalState {
signal,
registration,
} => ActiveExecutionEvent::SignalState {
signal,
registration: map_node_signal_registration(registration),
},
JavascriptExecutionEvent::Exited(code) => {
ActiveExecutionEvent::Exited(code)
}
})
})
.map_err(|error| SidecarError::Execution(error.to_string())),
Self::Python(execution) => execution
.poll_event(timeout)
.await
.map(|event| {
event.map(|event| match event {
PythonExecutionEvent::Stdout(chunk) => ActiveExecutionEvent::Stdout(chunk),
PythonExecutionEvent::Stderr(chunk) => ActiveExecutionEvent::Stderr(chunk),
PythonExecutionEvent::JavascriptSyncRpcRequest(request) => {
ActiveExecutionEvent::JavascriptSyncRpcRequest(request)
}
PythonExecutionEvent::VfsRpcRequest(request) => {
ActiveExecutionEvent::PythonVfsRpcRequest(request)
}
PythonExecutionEvent::Exited(code) => ActiveExecutionEvent::Exited(code),
})
})
.map_err(|error| SidecarError::Execution(error.to_string())),
Self::Wasm(execution) => execution
.poll_event(timeout)
.await
.map(|event| {
event.map(|event| match event {
WasmExecutionEvent::Stdout(chunk) => ActiveExecutionEvent::Stdout(chunk),
WasmExecutionEvent::Stderr(chunk) => ActiveExecutionEvent::Stderr(chunk),
WasmExecutionEvent::SyncRpcRequest(request) => {
ActiveExecutionEvent::JavascriptSyncRpcRequest(request)
}
WasmExecutionEvent::SignalState {
signal,
registration,
} => ActiveExecutionEvent::SignalState {
signal,
registration: map_wasm_signal_registration(registration),
},
WasmExecutionEvent::Exited(code) => ActiveExecutionEvent::Exited(code),
})
})
.map_err(|error| SidecarError::Execution(error.to_string())),
Self::Tool(execution) => {
let _ = timeout;
poll_tool_process_event(execution)
}
}
}
pub(crate) fn poll_event_blocking(
&mut self,
timeout: Duration,
) -> Result<Option<ActiveExecutionEvent>, SidecarError> {
match self {
Self::Javascript(execution) => execution
.poll_event_blocking(timeout)
.map(|event| {
event.map(|event| match event {
JavascriptExecutionEvent::Stdout(chunk) => {
ActiveExecutionEvent::Stdout(chunk)
}
JavascriptExecutionEvent::Stderr(chunk) => {
ActiveExecutionEvent::Stderr(chunk)
}
JavascriptExecutionEvent::SyncRpcRequest(request) => {
ActiveExecutionEvent::JavascriptSyncRpcRequest(request)
}
JavascriptExecutionEvent::SignalState {
signal,
registration,
} => ActiveExecutionEvent::SignalState {
signal,
registration: map_node_signal_registration(registration),
},
JavascriptExecutionEvent::Exited(code) => {
ActiveExecutionEvent::Exited(code)
}
})
})
.map_err(|error| SidecarError::Execution(error.to_string())),
Self::Python(execution) => execution
.poll_event_blocking(timeout)
.map(|event| {
event.map(|event| match event {
PythonExecutionEvent::Stdout(chunk) => ActiveExecutionEvent::Stdout(chunk),
PythonExecutionEvent::Stderr(chunk) => ActiveExecutionEvent::Stderr(chunk),
PythonExecutionEvent::JavascriptSyncRpcRequest(request) => {
ActiveExecutionEvent::JavascriptSyncRpcRequest(request)
}
PythonExecutionEvent::VfsRpcRequest(request) => {
ActiveExecutionEvent::PythonVfsRpcRequest(request)
}
PythonExecutionEvent::Exited(code) => ActiveExecutionEvent::Exited(code),
})
})
.map_err(|error| SidecarError::Execution(error.to_string())),
Self::Wasm(execution) => execution
.poll_event_blocking(timeout)
.map(|event| {
event.map(|event| match event {
WasmExecutionEvent::Stdout(chunk) => ActiveExecutionEvent::Stdout(chunk),
WasmExecutionEvent::Stderr(chunk) => ActiveExecutionEvent::Stderr(chunk),
WasmExecutionEvent::SyncRpcRequest(request) => {
ActiveExecutionEvent::JavascriptSyncRpcRequest(request)
}
WasmExecutionEvent::SignalState {
signal,
registration,
} => ActiveExecutionEvent::SignalState {
signal,
registration: map_wasm_signal_registration(registration),
},
WasmExecutionEvent::Exited(code) => ActiveExecutionEvent::Exited(code),
})
})
.map_err(|error| SidecarError::Execution(error.to_string())),
Self::Tool(execution) => {
let _ = timeout;
poll_tool_process_event(execution)
}
}
}
}
struct ToolProcessEventRequest {
sidecar_requests: SharedSidecarRequestClient,
connection_id: String,
session_id: String,
vm_id: String,
tool_resolution: ToolCommandResolution,
cancelled: Arc<AtomicBool>,
pending_events: Arc<Mutex<VecDeque<ActiveExecutionEvent>>>,
events_overflowed: Arc<AtomicBool>,
}
pub(crate) fn send_tool_process_event(
pending_events: &Arc<Mutex<VecDeque<ActiveExecutionEvent>>>,
events_overflowed: &AtomicBool,
event: ActiveExecutionEvent,
) -> bool {
let mut pending_events = pending_events
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if pending_events.len() >= MAX_PROCESS_EVENT_QUEUE {
events_overflowed.store(true, Ordering::Relaxed);
return false;
}
pending_events.push_back(event);
true
}
fn spawn_tool_process_events(request: ToolProcessEventRequest) {
let ToolProcessEventRequest {
sidecar_requests,
connection_id,
session_id,
vm_id,
tool_resolution,
cancelled,
pending_events,
events_overflowed,
} = request;
std::thread::spawn(move || match tool_resolution {
ToolCommandResolution::Failure(message) => {
if !send_tool_process_event(
&pending_events,
&events_overflowed,
ActiveExecutionEvent::Stderr(format_tool_failure_output(&message)),
) {
return;
}
let _ = send_tool_process_event(
&pending_events,
&events_overflowed,
ActiveExecutionEvent::Exited(1),
);
}
ToolCommandResolution::Invoke { request, timeout } => {
let response = sidecar_requests.invoke(
OwnershipScope::vm(connection_id.clone(), session_id.clone(), vm_id.clone()),
SidecarRequestPayload::HostCallback(request.clone()),
timeout,
);
if cancelled.load(Ordering::Relaxed) {
return;
}
match response {
Ok(crate::protocol::SidecarResponsePayload::HostCallbackResult(result)) => {
if let Some(value) = result.result {
let value: serde_json::Value = serde_json::from_str(&value)
.unwrap_or(serde_json::Value::String(value));
let stdout = serde_json::to_vec(&json!({
"ok": true,
"result": value,
}))
.unwrap_or_else(|error| {
format_tool_failure_output(&format!(
"failed to serialize tool result: {error}"
))
});
if !send_tool_process_event(
&pending_events,
&events_overflowed,
ActiveExecutionEvent::Stdout(stdout),
) {
return;
}
let _ = send_tool_process_event(
&pending_events,
&events_overflowed,
ActiveExecutionEvent::Exited(0),
);
} else {
let message = result
.error
.unwrap_or_else(|| String::from("tool invocation returned no result"));
if !send_tool_process_event(
&pending_events,
&events_overflowed,
ActiveExecutionEvent::Stderr(format_tool_failure_output(&message)),
) {
return;
}
let _ = send_tool_process_event(
&pending_events,
&events_overflowed,
ActiveExecutionEvent::Exited(1),
);
}
}
Ok(_) => {
if !send_tool_process_event(
&pending_events,
&events_overflowed,
ActiveExecutionEvent::Stderr(format_tool_failure_output(
"unexpected sidecar tool response",
)),
) {
return;
}
let _ = send_tool_process_event(
&pending_events,
&events_overflowed,
ActiveExecutionEvent::Exited(1),
);
}
Err(error) => {
if !send_tool_process_event(
&pending_events,
&events_overflowed,
ActiveExecutionEvent::Stderr(format_tool_failure_output(
&error.to_string(),
)),
) {
return;
}
let _ = send_tool_process_event(
&pending_events,
&events_overflowed,
ActiveExecutionEvent::Exited(1),
);
}
}
}
});
}
impl<B> NativeSidecar<B>
where
B: NativeSidecarBridge + Send + 'static,
BridgeError<B>: fmt::Debug + Send + Sync + 'static,
{
pub(crate) async fn execute(
&mut self,
request: &RequestFrame,
payload: ExecuteRequest,
) -> Result<DispatchResult, SidecarError> {
let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?;
self.require_owned_vm(&connection_id, &session_id, &vm_id)?;
let vm = self
.vms
.get_mut(&vm_id)
.ok_or_else(|| missing_vm_error(&vm_id))?;
if vm.active_processes.contains_key(&payload.process_id) {
return Err(SidecarError::InvalidState(format!(
"VM {vm_id} already has an active process with id {}",
payload.process_id
)));
}
if let Some(command) = payload.command.as_deref() {
if let Some(tool_resolution) =
resolve_tool_command(vm, command, &payload.args, payload.cwd.as_deref())?
{
let guest_cwd = payload
.cwd
.as_deref()
.map(normalize_path)
.unwrap_or_else(|| vm.guest_cwd.clone());
let kernel_handle = vm
.kernel
.create_virtual_process(
EXECUTION_DRIVER_NAME,
TOOL_DRIVER_NAME,
command,
std::iter::once(command.to_owned())
.chain(payload.args.iter().cloned())
.collect(),
VirtualProcessOptions {
env: vm.guest_env.clone(),
cwd: Some(guest_cwd.clone()),
..VirtualProcessOptions::default()
},
)
.map_err(kernel_error)?;
let kernel_pid = kernel_handle.pid();
let tool_execution = ToolExecution::default();
let cancelled = tool_execution.cancelled.clone();
let pending_events = tool_execution.pending_events.clone();
let events_overflowed = tool_execution.events_overflowed.clone();
vm.active_processes.insert(
payload.process_id.clone(),
ActiveProcess::new(
kernel_pid,
kernel_handle,
GuestRuntimeKind::JavaScript,
ActiveExecution::Tool(tool_execution),
)
.with_guest_cwd(guest_cwd.clone())
.with_host_cwd(resolve_vm_guest_path_to_host(vm, &guest_cwd)),
);
self.bridge.emit_lifecycle(&vm_id, LifecycleState::Busy)?;
spawn_tool_process_events(ToolProcessEventRequest {
sidecar_requests: self.sidecar_requests.clone(),
connection_id: connection_id.clone(),
session_id: session_id.clone(),
vm_id: vm_id.clone(),
tool_resolution,
cancelled,
pending_events,
events_overflowed,
});
return Ok(DispatchResult {
response: self.respond(
request,
ResponsePayload::ProcessStarted(ProcessStartedResponse {
process_id: payload.process_id,
pid: Some(kernel_pid),
}),
),
events: Vec::new(),
});
}
}
let resolved = resolve_execute_request(vm, &payload)?;
let mut env = resolved.env.clone();
let sandbox_root = normalize_host_path(&vm.cwd);
env.insert(
String::from(EXECUTION_SANDBOX_ROOT_ENV),
sandbox_root.to_string_lossy().into_owned(),
);
if resolved.runtime == GuestRuntimeKind::JavaScript {
env.insert(
String::from("SECURE_EXEC_KEEP_STDIN_OPEN"),
String::from("1"),
);
} else if resolved.runtime == GuestRuntimeKind::WebAssembly {
env.insert(String::from(WASM_STDIO_SYNC_RPC_ENV), String::from("1"));
}
let argv = std::iter::once(resolved.entrypoint.clone())
.chain(resolved.execution_args.iter().cloned())
.collect::<Vec<_>>();
let kernel_handle = vm
.kernel
.spawn_process(
&resolved.command,
argv,
SpawnOptions {
requester_driver: Some(String::from(EXECUTION_DRIVER_NAME)),
cwd: Some(resolved.guest_cwd.clone()),
..SpawnOptions::default()
},
)
.map_err(kernel_error)?;
let kernel_pid = kernel_handle.pid();
let (execution, process_env) = match resolved.runtime {
GuestRuntimeKind::JavaScript => {
let inline_code = load_javascript_entrypoint_source(
vm,
&resolved.host_cwd,
&resolved.entrypoint,
&env,
);
prepare_javascript_shadow(vm, &resolved)?;
let context =
self.javascript_engine
.create_context(CreateJavascriptContextRequest {
vm_id: vm_id.clone(),
bootstrap_module: None,
compile_cache_root: Some(self.cache_root.join("node-compile-cache")),
});
let module_reader = build_module_reader(vm, &resolved)
.map(|reader| Box::new(reader) as Box<dyn ModuleFsReader + Send>);
let execution = self
.javascript_engine
.start_execution_with_module_reader(
StartJavascriptExecutionRequest {
guest_runtime: guest_runtime_identity(vm, None, None),
vm_id: vm_id.clone(),
context_id: context.context_id,
argv: std::iter::once(resolved.entrypoint.clone())
.chain(resolved.execution_args.iter().cloned())
.collect(),
env: env.clone(),
cwd: resolved.host_cwd.clone(),
limits: javascript_execution_limits(vm),
inline_code,
},
module_reader,
)
.map_err(javascript_error)?;
(ActiveExecution::Javascript(execution), env.clone())
}
GuestRuntimeKind::Python => {
let python_file_path = python_file_entrypoint(&resolved.entrypoint);
let pyodide_dist_path = self
.python_engine
.bundled_pyodide_dist_path_for_vm(&vm_id)
.map_err(python_error)?;
let pyodide_cache_path = pyodide_dist_path
.parent()
.and_then(Path::parent)
.unwrap_or(pyodide_dist_path.as_path())
.join("pyodide-package-cache");
add_runtime_guest_path_mapping(
&mut env,
PYTHON_PYODIDE_GUEST_ROOT,
&pyodide_dist_path,
);
add_runtime_guest_path_mapping(
&mut env,
PYTHON_PYODIDE_CACHE_GUEST_ROOT,
&pyodide_cache_path,
);
add_runtime_host_access_path(
&mut env,
"AGENTOS_EXTRA_FS_READ_PATHS",
&pyodide_dist_path,
true,
);
add_runtime_host_access_path(
&mut env,
"AGENTOS_EXTRA_FS_READ_PATHS",
&pyodide_cache_path,
true,
);
add_runtime_host_access_path(
&mut env,
"AGENTOS_EXTRA_FS_WRITE_PATHS",
&pyodide_cache_path,
false,
);
let context = self
.python_engine
.create_context(CreatePythonContextRequest {
vm_id: vm_id.clone(),
pyodide_dist_path,
});
let execution = self
.python_engine
.start_execution(StartPythonExecutionRequest {
vm_id: vm_id.clone(),
context_id: context.context_id,
code: resolved.entrypoint.clone(),
file_path: python_file_path,
env: env.clone(),
cwd: resolved.host_cwd.clone(),
limits: python_execution_limits(vm),
guest_runtime: guest_runtime_identity(vm, None, None),
})
.map_err(python_error)?;
(ActiveExecution::Python(execution), env.clone())
}
GuestRuntimeKind::WebAssembly => {
let wasm_limits = wasm_execution_limits(vm);
let wasm_guest_runtime =
guest_runtime_identity(vm, Some(u64::from(kernel_pid)), Some(0));
let wasm_permission_tier = resolved.wasm_permission_tier.unwrap_or_else(|| {
resolve_wasm_permission_tier(
vm,
Some(&resolved.command),
None,
&resolved.entrypoint,
)
});
let context = self.wasm_engine.create_context(CreateWasmContextRequest {
vm_id: vm_id.clone(),
module_path: Some(resolved.entrypoint.clone()),
});
let execution = self
.wasm_engine
.start_execution(StartWasmExecutionRequest {
vm_id: vm_id.clone(),
context_id: context.context_id,
argv: resolved.process_args.clone(),
env: env.clone(),
cwd: resolved.host_cwd.clone(),
permission_tier: execution_wasm_permission_tier(wasm_permission_tier),
limits: wasm_limits,
guest_runtime: wasm_guest_runtime,
})
.map_err(wasm_error)?;
(ActiveExecution::Wasm(Box::new(execution)), env)
}
};
let child_pid = execution.child_pid();
let kernel_stdin_writer_fd = install_kernel_stdin_pipe(&mut vm.kernel, kernel_pid)?;
vm.active_processes.insert(
payload.process_id.clone(),
ActiveProcess::new(kernel_pid, kernel_handle, resolved.runtime, execution)
.with_kernel_stdin_writer_fd(kernel_stdin_writer_fd)
.with_guest_cwd(resolved.guest_cwd.clone())
.with_env(process_env)
.with_host_cwd(resolved.host_cwd.clone()),
);
self.bridge.emit_lifecycle(&vm_id, LifecycleState::Busy)?;
Ok(DispatchResult {
response: self.respond(
request,
ResponsePayload::ProcessStarted(ProcessStartedResponse {
process_id: payload.process_id,
pid: Some(if child_pid == 0 {
kernel_pid
} else {
child_pid
}),
}),
),
events: Vec::new(),
})
}
pub(crate) async fn write_stdin(
&mut self,
request: &RequestFrame,
payload: WriteStdinRequest,
) -> Result<DispatchResult, SidecarError> {
let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?;
self.require_owned_vm(&connection_id, &session_id, &vm_id)?;
let vm = self
.vms
.get_mut(&vm_id)
.ok_or_else(|| missing_vm_error(&vm_id))?;
let process = vm
.active_processes
.get_mut(&payload.process_id)
.ok_or_else(|| {
SidecarError::InvalidState(format!(
"VM {vm_id} has no active process {}",
payload.process_id
))
})?;
process.execution.write_stdin(&payload.chunk)?;
write_kernel_process_stdin(&mut vm.kernel, process, &payload.chunk)?;
Ok(DispatchResult {
response: self.respond(
request,
ResponsePayload::StdinWritten(StdinWrittenResponse {
process_id: payload.process_id,
accepted_bytes: payload.chunk.len() as u64,
}),
),
events: Vec::new(),
})
}
pub(crate) async fn close_stdin(
&mut self,
request: &RequestFrame,
payload: CloseStdinRequest,
) -> Result<DispatchResult, SidecarError> {
let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?;
self.require_owned_vm(&connection_id, &session_id, &vm_id)?;
let vm = self
.vms
.get_mut(&vm_id)
.ok_or_else(|| missing_vm_error(&vm_id))?;
let process = vm
.active_processes
.get_mut(&payload.process_id)
.ok_or_else(|| {
SidecarError::InvalidState(format!(
"VM {vm_id} has no active process {}",
payload.process_id
))
})?;
process.execution.close_stdin()?;
close_kernel_process_stdin(&mut vm.kernel, process)?;
Ok(DispatchResult {
response: self.respond(
request,
ResponsePayload::StdinClosed(StdinClosedResponse {
process_id: payload.process_id,
}),
),
events: Vec::new(),
})
}
pub(crate) async fn kill_process(
&mut self,
request: &RequestFrame,
payload: KillProcessRequest,
) -> Result<DispatchResult, SidecarError> {
let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?;
self.require_owned_vm(&connection_id, &session_id, &vm_id)?;
self.kill_process_internal(&vm_id, &payload.process_id, &payload.signal)?;
Ok(DispatchResult {
response: self.respond(
request,
ResponsePayload::ProcessKilled(ProcessKilledResponse {
process_id: payload.process_id,
}),
),
events: Vec::new(),
})
}
pub(crate) async fn find_listener(
&mut self,
request: &RequestFrame,
payload: FindListenerRequest,
) -> Result<DispatchResult, SidecarError> {
let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?;
self.require_owned_vm(&connection_id, &session_id, &vm_id)?;
require_vm_inspection_permission(
&self.bridge,
&vm_id,
"network.inspect",
"network",
&socket_query_resource(SocketQueryKind::TcpListener, &payload),
)?;
let listener =
find_socket_state_entry(self.vms.get(&vm_id), SocketQueryKind::TcpListener, &payload)?;
Ok(DispatchResult {
response: self.respond(
request,
ResponsePayload::ListenerSnapshot(ListenerSnapshotResponse { listener }),
),
events: Vec::new(),
})
}
pub(crate) async fn get_process_snapshot(
&mut self,
request: &RequestFrame,
_payload: GetProcessSnapshotRequest,
) -> Result<DispatchResult, SidecarError> {
let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?;
self.require_owned_vm(&connection_id, &session_id, &vm_id)?;
require_vm_inspection_permission(
&self.bridge,
&vm_id,
"process.inspect",
"process",
"process://snapshot",
)?;
let processes = self
.vms
.get_mut(&vm_id)
.map(|vm| {
prune_exited_process_snapshots(vm);
snapshot_vm_processes(vm)
})
.unwrap_or_default();
Ok(DispatchResult {
response: self.respond(
request,
ResponsePayload::ProcessSnapshot(ProcessSnapshotResponse { processes }),
),
events: Vec::new(),
})
}
pub(crate) async fn find_bound_udp(
&mut self,
request: &RequestFrame,
payload: FindBoundUdpRequest,
) -> Result<DispatchResult, SidecarError> {
let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?;
self.require_owned_vm(&connection_id, &session_id, &vm_id)?;
let lookup_request = FindListenerRequest {
host: payload.host,
port: payload.port,
path: None,
};
require_vm_inspection_permission(
&self.bridge,
&vm_id,
"network.inspect",
"network",
&socket_query_resource(SocketQueryKind::UdpBound, &lookup_request),
)?;
let socket = find_socket_state_entry(
self.vms.get(&vm_id),
SocketQueryKind::UdpBound,
&lookup_request,
)?;
Ok(DispatchResult {
response: self.respond(
request,
ResponsePayload::BoundUdpSnapshot(BoundUdpSnapshotResponse { socket }),
),
events: Vec::new(),
})
}
pub(crate) async fn vm_fetch(
&mut self,
request: &RequestFrame,
payload: VmFetchRequest,
) -> Result<DispatchResult, SidecarError> {
let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?;
self.require_owned_vm(&connection_id, &session_id, &vm_id)?;
let vm = self
.vms
.get_mut(&vm_id)
.ok_or_else(|| SidecarError::InvalidState(String::from("unknown sidecar VM")))?;
let target_path = if payload.path.starts_with('/') {
payload.path.clone()
} else {
format!("/{}", payload.path)
};
let request_url = Url::parse(&format!("http://127.0.0.1:{}{target_path}", payload.port))
.map_err(|error| {
SidecarError::InvalidState(format!(
"invalid vm.fetch target {target_path:?}: {error}"
))
})?;
let header_values: BTreeMap<String, Value> = serde_json::from_str(&payload.headers_json)
.map_err(|error| {
SidecarError::InvalidState(format!(
"vm.fetch headers_json must be valid JSON: {error}"
))
})?;
let options = JavascriptHttpRequestOptions {
method: Some(payload.method),
headers: header_values,
body: payload.body,
reject_unauthorized: None,
};
let headers = parse_http_header_collection(&options.headers, "vm.fetch headers")?;
let target_process_id = find_kernel_http_listener_process(vm, payload.port);
if let Some(target_process_id) = target_process_id {
let max_fetch_response_bytes = vm.limits.http.max_fetch_response_bytes;
let response_json = match dispatch_kernel_http_fetch(
&self.bridge,
&vm_id,
vm,
&target_process_id,
payload.port,
&target_path,
&options,
&headers,
max_fetch_response_bytes,
) {
Ok(response_json) => response_json,
Err(error) => {
if let Some(exit_code) = kernel_http_fetch_target_exit_code(&error) {
let _ = vm;
self.finish_active_process_exit(&vm_id, &target_process_id, exit_code)?;
}
return Err(error);
}
};
let response = self.respond(
request,
ResponsePayload::VmFetchResult(VmFetchResponse { response_json }),
);
ensure_vm_fetch_response_frame_within_limit(&response, self.config.max_frame_bytes)?;
return Ok(DispatchResult {
response,
events: Vec::new(),
});
}
let Some((target_process_id, server_id)) =
vm.active_processes
.iter()
.find_map(|(process_id, process)| {
process
.http_servers
.iter()
.find(|(_, server)| server.guest_local_addr.port() == payload.port)
.map(|(server_id, _)| (process_id.clone(), *server_id))
})
else {
return Err(SidecarError::Execution(format!(
"vm.fetch could not find a guest HTTP listener on port {}",
payload.port
)));
};
let socket_paths = build_javascript_socket_path_context(vm)?;
let resource_limits = vm.kernel.resource_limits().clone();
let process = vm
.active_processes
.get_mut(&target_process_id)
.ok_or_else(|| {
SidecarError::InvalidState(format!(
"vm.fetch target process disappeared: {target_process_id}"
))
})?;
let request_json = serialize_http_loopback_request(&request_url, &options, &headers)?;
let response_json = dispatch_loopback_http_request(LoopbackHttpDispatchRequest {
bridge: &self.bridge,
vm_id: &vm_id,
dns: &vm.dns,
socket_paths: &socket_paths,
kernel: &mut vm.kernel,
process,
resource_limits: &resource_limits,
server_id,
request_json: &request_json,
})?;
let response = self.respond(
request,
ResponsePayload::VmFetchResult(VmFetchResponse { response_json }),
);
ensure_vm_fetch_response_frame_within_limit(&response, self.config.max_frame_bytes)?;
Ok(DispatchResult {
response,
events: Vec::new(),
})
}
pub(crate) async fn get_signal_state(
&mut self,
request: &RequestFrame,
payload: GetSignalStateRequest,
) -> Result<DispatchResult, SidecarError> {
let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?;
self.require_owned_vm(&connection_id, &session_id, &vm_id)?;
let handlers = self
.vms
.get(&vm_id)
.and_then(|vm| vm.signal_states.get(&payload.process_id))
.cloned()
.unwrap_or_default();
Ok(DispatchResult {
response: self.respond(
request,
ResponsePayload::SignalState(SignalStateResponse {
process_id: payload.process_id,
handlers: handlers.into_iter().collect(),
}),
),
events: Vec::new(),
})
}
pub(crate) async fn get_zombie_timer_count(
&mut self,
request: &RequestFrame,
_payload: GetZombieTimerCountRequest,
) -> Result<DispatchResult, SidecarError> {
let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?;
self.require_owned_vm(&connection_id, &session_id, &vm_id)?;
let count = self
.vms
.get(&vm_id)
.map(|vm| vm.kernel.zombie_timer_count() as u64)
.unwrap_or_default();
Ok(DispatchResult {
response: self.respond(
request,
ResponsePayload::ZombieTimerCount(ZombieTimerCountResponse { count }),
),
events: Vec::new(),
})
}
pub(crate) fn kill_process_internal(
&mut self,
vm_id: &str,
process_id: &str,
signal: &str,
) -> Result<(), SidecarError> {
let signal_name = signal.to_owned();
let signal = parse_signal(signal)?;
let vm = self
.vms
.get_mut(vm_id)
.ok_or_else(|| SidecarError::InvalidState(format!("unknown sidecar VM {vm_id}")))?;
let process = vm.active_processes.get_mut(process_id).ok_or_else(|| {
SidecarError::InvalidState(format!("VM {vm_id} has no active process {process_id}"))
})?;
let kernel_pid = process.kernel_pid;
enum KillBehavior {
Tool,
SharedV8StateOnly,
SharedV8Continue,
SharedV8Terminate,
SharedV8DispatchOrTerminate,
Noop,
HostPid(u32),
}
let behavior = match &process.execution {
ActiveExecution::Tool(_) => KillBehavior::Tool,
ActiveExecution::Javascript(execution)
if execution.uses_shared_v8_runtime() && matches!(signal, 0 | libc::SIGSTOP) =>
{
KillBehavior::SharedV8StateOnly
}
ActiveExecution::Javascript(execution)
if execution.uses_shared_v8_runtime() && signal == libc::SIGCONT =>
{
KillBehavior::SharedV8Continue
}
ActiveExecution::Wasm(execution)
if execution.uses_shared_v8_runtime()
&& matches!(signal, 0 | libc::SIGSTOP | libc::SIGCONT) =>
{
KillBehavior::SharedV8StateOnly
}
ActiveExecution::Python(execution)
if execution.uses_shared_v8_runtime()
&& matches!(signal, 0 | libc::SIGSTOP | libc::SIGCONT) =>
{
KillBehavior::SharedV8StateOnly
}
ActiveExecution::Javascript(execution)
if execution.uses_shared_v8_runtime() && signal == SIGKILL =>
{
KillBehavior::SharedV8Terminate
}
ActiveExecution::Wasm(execution)
if execution.uses_shared_v8_runtime() && signal == SIGKILL =>
{
KillBehavior::SharedV8Terminate
}
ActiveExecution::Javascript(execution) if execution.uses_shared_v8_runtime() => {
KillBehavior::SharedV8DispatchOrTerminate
}
ActiveExecution::Wasm(execution) if execution.uses_shared_v8_runtime() => {
KillBehavior::SharedV8Terminate
}
ActiveExecution::Python(execution) if execution.uses_shared_v8_runtime() => {
KillBehavior::SharedV8Terminate
}
ActiveExecution::Javascript(execution) if execution.child_pid() == 0 => {
KillBehavior::Noop
}
_ => KillBehavior::HostPid(process.execution.child_pid()),
};
match behavior {
KillBehavior::Tool => {
let ActiveExecution::Tool(execution) = &process.execution else {
unreachable!("kill behavior must match tool execution");
};
if signal != 0 {
execution.cancelled.store(true, Ordering::Relaxed);
process.queue_pending_execution_event(ActiveExecutionEvent::Exited(
128 + signal,
))?;
}
}
KillBehavior::SharedV8StateOnly => {
if matches!(signal, libc::SIGSTOP | libc::SIGCONT) {
vm.kernel
.kill_process(EXECUTION_DRIVER_NAME, kernel_pid, signal)
.map_err(kernel_error)?;
}
}
KillBehavior::SharedV8Continue => {
vm.kernel
.kill_process(EXECUTION_DRIVER_NAME, kernel_pid, signal)
.map_err(kernel_error)?;
if signal != 0 && !dispatch_v8_process_signal(process, signal)? {
process.execution.terminate()?;
}
}
KillBehavior::SharedV8Terminate => {
if signal != 0 && matches!(process.execution, ActiveExecution::Python(_)) {
close_kernel_process_stdin(&mut vm.kernel, process)?;
}
process.execution.terminate()?;
let needs_synthetic_exit = matches!(process.execution, ActiveExecution::Wasm(_))
|| (signal == SIGKILL
&& matches!(process.execution, ActiveExecution::Javascript(_)));
if signal != 0 && needs_synthetic_exit {
process.queue_pending_execution_event(ActiveExecutionEvent::Exited(
128 + signal,
))?;
}
}
KillBehavior::SharedV8DispatchOrTerminate => {
if signal != 0 && !dispatch_v8_process_signal(process, signal)? {
process.execution.terminate()?;
}
}
KillBehavior::Noop => {}
KillBehavior::HostPid(pid) => {
if signal != 0 && matches!(process.execution, ActiveExecution::Python(_)) {
close_kernel_process_stdin(&mut vm.kernel, process)?;
}
signal_runtime_process(pid, signal)?;
}
}
emit_security_audit_event(
&self.bridge,
vm_id,
"security.process.kill",
audit_fields([
(String::from("source"), String::from("control_plane")),
(String::from("source_pid"), String::from("0")),
(String::from("target_pid"), process.kernel_pid.to_string()),
(String::from("process_id"), process_id.to_owned()),
(String::from("signal"), signal_name),
(
String::from("host_pid"),
process.execution.child_pid().to_string(),
),
]),
);
Ok(())
}
pub async fn pump_process_events(
&mut self,
ownership: &OwnershipScope,
) -> Result<bool, SidecarError> {
let mut emitted_any = false;
let mut queued_envelopes = Vec::new();
{
let pending_capacity = self.pending_process_event_capacity();
let receiver = self.process_event_receiver.as_mut().ok_or_else(|| {
SidecarError::InvalidState(String::from("process event receiver unavailable"))
})?;
loop {
if queued_envelopes.len() >= pending_capacity {
if receiver.is_empty() {
break;
}
return Err(process_event_queue_overflow_error());
}
match receiver.try_recv() {
Ok(envelope) => {
queued_envelopes.push(envelope);
emitted_any = true;
}
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
}
}
}
for envelope in queued_envelopes {
self.queue_pending_process_event(envelope)?;
}
let vm_ids = self.vm_ids_for_scope(ownership)?;
for vm_id in vm_ids {
while let Some(vm) = self.vms.get(&vm_id) {
let connection_id = vm.connection_id.clone();
let session_id = vm.session_id.clone();
let process_ids = self
.vms
.get(&vm_id)
.map(|vm| vm.active_processes.keys().cloned().collect::<Vec<_>>())
.unwrap_or_default();
let mut emitted_this_pass = false;
for process_id in process_ids {
if self
.vms
.get(&vm_id)
.is_some_and(|vm| vm.detached_child_processes.contains(&process_id))
{
continue;
}
enum ProcessPollResult {
Event(Box<Option<ActiveExecutionEvent>>),
RecoverClosedChannel,
}
let poll_result = {
let Some(vm) = self.vms.get_mut(&vm_id) else {
continue;
};
let Some(process) = vm.active_processes.get_mut(&process_id) else {
continue;
};
if let Some(event) = process.pending_execution_events.pop_front() {
ProcessPollResult::Event(Box::new(Some(event)))
} else {
match process.execution.poll_event(Duration::ZERO).await {
Ok(event) => ProcessPollResult::Event(Box::new(event)),
Err(SidecarError::Execution(message))
if (process.runtime == GuestRuntimeKind::JavaScript
&& closed_javascript_event_channel(&message))
|| (process.runtime == GuestRuntimeKind::Python
&& closed_python_event_channel(&message))
|| (process.runtime == GuestRuntimeKind::WebAssembly
&& closed_wasm_event_channel(&message)) =>
{
ProcessPollResult::RecoverClosedChannel
}
Err(other) => return Err(other),
}
}
};
let event = match poll_result {
ProcessPollResult::Event(event) => *event,
ProcessPollResult::RecoverClosedChannel => {
self.recover_closed_root_runtime_process_event(&vm_id, &process_id)?
}
};
let Some(event) = event else {
continue;
};
if Self::internal_execution_event(&event) {
// These events are sidecar work items, not client-facing
// process events. Handle them immediately so a sibling
// process can service sync RPCs while another request
// waits on VM-local networking.
self.handle_execution_event(&vm_id, &process_id, event)?;
} else {
self.queue_pending_process_event(ProcessEventEnvelope {
connection_id: connection_id.clone(),
session_id: session_id.clone(),
vm_id: vm_id.clone(),
process_id: process_id.clone(),
event,
})?;
}
emitted_any = true;
emitted_this_pass = true;
}
if !emitted_this_pass {
break;
}
}
if self.pump_detached_child_process_events(&vm_id)? {
emitted_any = true;
}
}
Ok(emitted_any)
}
fn internal_execution_event(event: &ActiveExecutionEvent) -> bool {
matches!(
event,
ActiveExecutionEvent::JavascriptSyncRpcRequest(_)
| ActiveExecutionEvent::PythonVfsRpcRequest(_)
| ActiveExecutionEvent::SignalState { .. }
)
}
fn recover_closed_root_runtime_process_event(
&mut self,
vm_id: &str,
process_id: &str,
) -> Result<Option<ActiveExecutionEvent>, SidecarError> {
let Some(vm) = self.vms.get_mut(vm_id) else {
return Ok(None);
};
let Some(process) = vm.active_processes.get(process_id) else {
return Ok(None);
};
if process.execution.uses_shared_v8_runtime() {
return Ok(None);
}
if process.runtime != GuestRuntimeKind::JavaScript
&& process.runtime != GuestRuntimeKind::Python
&& process.runtime != GuestRuntimeKind::WebAssembly
{
return Ok(None);
}
let runtime_child_pid = process.execution.child_pid();
if runtime_child_pid == 0 {
return Ok(None);
}
if let Some(status) = runtime_child_exit_status(runtime_child_pid)? {
return Ok(Some(ActiveExecutionEvent::Exited(status)));
}
if runtime_child_is_alive(runtime_child_pid)? {
return Ok(None);
}
Ok(Some(ActiveExecutionEvent::Exited(0)))
}
fn active_process_by_path<'a>(
process: &'a ActiveProcess,
child_path: &[&str],
) -> Option<&'a ActiveProcess> {
let mut current = process;
for child_id in child_path {
current = current.child_processes.get(*child_id)?;
}
Some(current)
}
fn active_process_by_path_mut<'a>(
process: &'a mut ActiveProcess,
child_path: &[&str],
) -> Option<&'a mut ActiveProcess> {
let mut current = process;
for child_id in child_path {
current = current.child_processes.get_mut(*child_id)?;
}
Some(current)
}
fn active_process_by_owned_path_mut<'a>(
process: &'a mut ActiveProcess,
child_path: &[String],
) -> Option<&'a mut ActiveProcess> {
let mut current = process;
for child_id in child_path {
current = current.child_processes.get_mut(child_id)?;
}
Some(current)
}
fn active_process_path_by_kernel_pid(
process: &ActiveProcess,
kernel_pid: u32,
) -> Option<Vec<String>> {
if process.kernel_pid == kernel_pid {
return Some(Vec::new());
}
for (child_id, child) in &process.child_processes {
let Some(mut path) = Self::active_process_path_by_kernel_pid(child, kernel_pid) else {
continue;
};
path.insert(0, child_id.clone());
return Some(path);
}
None
}
fn descendant_parent_process<'a>(
vm: &'a VmState,
process_id: &str,
child_path: &[&str],
) -> Option<&'a ActiveProcess> {
let root = vm.active_processes.get(process_id)?;
Self::active_process_by_path(root, child_path)
}
fn descendant_parent_process_mut<'a>(
vm: &'a mut VmState,
process_id: &str,
child_path: &[&str],
) -> Option<&'a mut ActiveProcess> {
let root = vm.active_processes.get_mut(process_id)?;
Self::active_process_by_path_mut(root, child_path)
}
fn child_process_path_label(process_id: &str, child_path: &[&str]) -> String {
if child_path.is_empty() {
process_id.to_owned()
} else {
format!("{process_id}/{}", child_path.join("/"))
}
}
fn adopt_detached_child_processes(
current_process_id: &str,
process: &mut ActiveProcess,
) -> Vec<(String, ActiveProcess)> {
let mut adopted = Vec::new();
let child_ids = process.child_processes.keys().cloned().collect::<Vec<_>>();
for child_id in child_ids {
let child_process_id = format!("{current_process_id}/{child_id}");
let Some(mut child) = process.child_processes.remove(&child_id) else {
continue;
};
if child.detached {
adopted.push((child_process_id, child));
continue;
}
adopted.extend(Self::adopt_detached_child_processes(
&child_process_id,
&mut child,
));
process.child_processes.insert(child_id, child);
}
adopted
}
fn child_process_signal_key<'a>(process_id: &'a str, child_path: &[&'a str]) -> &'a str {
child_path.last().copied().unwrap_or(process_id)
}
fn resolve_detached_child_process_path(
vm: &VmState,
detached_process_id: &str,
) -> Option<(String, Vec<String>)> {
let root_process_id = vm
.active_processes
.keys()
.filter(|candidate| {
detached_process_id == candidate.as_str()
|| detached_process_id
.strip_prefix(candidate.as_str())
.is_some_and(|remainder| remainder.starts_with('/'))
})
.max_by_key(|candidate| candidate.len())?
.clone();
let remainder = detached_process_id
.strip_prefix(root_process_id.as_str())
.unwrap_or_default();
if remainder.is_empty() {
return Some((root_process_id, Vec::new()));
}
Some((
root_process_id,
remainder
.trim_start_matches('/')
.split('/')
.map(str::to_owned)
.collect(),
))
}
fn pump_detached_child_process_events(&mut self, vm_id: &str) -> Result<bool, SidecarError> {
let detached_process_ids = self
.vms
.get(vm_id)
.map(|vm| {
vm.detached_child_processes
.iter()
.cloned()
.collect::<Vec<_>>()
})
.unwrap_or_default();
let mut emitted_any = false;
for detached_process_id in detached_process_ids {
let Some((root_process_id, child_path)) = self
.vms
.get(vm_id)
.and_then(|vm| Self::resolve_detached_child_process_path(vm, &detached_process_id))
else {
if let Some(vm) = self.vms.get_mut(vm_id) {
vm.detached_child_processes.remove(&detached_process_id);
}
continue;
};
if child_path.is_empty() {
loop {
enum ProcessPollResult {
Event(Box<Option<ActiveExecutionEvent>>),
RecoverClosedChannel,
}
let poll_result = {
let Some(vm) = self.vms.get_mut(vm_id) else {
break;
};
let Some(process) = vm.active_processes.get_mut(&root_process_id) else {
break;
};
if let Some(event) = process.pending_execution_events.pop_front() {
ProcessPollResult::Event(Box::new(Some(event)))
} else {
match process.execution.poll_event_blocking(Duration::ZERO) {
Ok(event) => ProcessPollResult::Event(Box::new(event)),
Err(SidecarError::Execution(message))
if (process.runtime == GuestRuntimeKind::JavaScript
&& closed_javascript_event_channel(&message))
|| (process.runtime == GuestRuntimeKind::Python
&& closed_python_event_channel(&message))
|| (process.runtime == GuestRuntimeKind::WebAssembly
&& closed_wasm_event_channel(&message)) =>
{
ProcessPollResult::RecoverClosedChannel
}
Err(error) => return Err(error),
}
}
};
let event = match poll_result {
ProcessPollResult::Event(event) => *event,
ProcessPollResult::RecoverClosedChannel => {
self.recover_closed_root_runtime_process_event(vm_id, &root_process_id)?
}
};
let Some(event) = event else {
break;
};
let Some((connection_id, session_id)) = self
.vms
.get(vm_id)
.map(|vm| (vm.connection_id.clone(), vm.session_id.clone()))
else {
break;
};
match event {
ActiveExecutionEvent::Stdout(chunk) => {
self.queue_pending_process_event(ProcessEventEnvelope {
connection_id,
session_id,
vm_id: vm_id.to_owned(),
process_id: detached_process_id.clone(),
event: ActiveExecutionEvent::Stdout(chunk),
})?;
emitted_any = true;
}
ActiveExecutionEvent::Stderr(chunk) => {
self.queue_pending_process_event(ProcessEventEnvelope {
connection_id,
session_id,
vm_id: vm_id.to_owned(),
process_id: detached_process_id.clone(),
event: ActiveExecutionEvent::Stderr(chunk),
})?;
emitted_any = true;
}
ActiveExecutionEvent::Exited(exit_code) => {
if let Some(vm) = self.vms.get_mut(vm_id) {
vm.detached_child_processes.remove(&detached_process_id);
}
self.queue_pending_process_event(ProcessEventEnvelope {
connection_id,
session_id,
vm_id: vm_id.to_owned(),
process_id: detached_process_id.clone(),
event: ActiveExecutionEvent::Exited(exit_code),
})?;
emitted_any = true;
break;
}
ActiveExecutionEvent::JavascriptSyncRpcRequest(request) => {
self.handle_javascript_sync_rpc_request(
vm_id,
&root_process_id,
request,
)?;
}
ActiveExecutionEvent::PythonVfsRpcRequest(request) => {
self.handle_python_vfs_rpc_request(vm_id, &root_process_id, *request)?;
}
ActiveExecutionEvent::SignalState {
signal,
registration,
} => {
if let Some(vm) = self.vms.get_mut(vm_id) {
vm.signal_states
.entry(root_process_id.clone())
.or_default()
.insert(signal, registration);
}
}
}
}
continue;
}
let parent_path = child_path[..child_path.len() - 1]
.iter()
.map(String::as_str)
.collect::<Vec<_>>();
let child_process_id = child_path.last().expect("child path cannot be empty");
loop {
let event = match self.poll_descendant_javascript_child_process(
vm_id,
&root_process_id,
&parent_path,
child_process_id,
0,
) {
Ok(event) => event,
Err(SidecarError::InvalidState(message))
if message.contains("unknown child process")
|| message.contains("unknown child process path") =>
{
if let Some(vm) = self.vms.get_mut(vm_id) {
vm.detached_child_processes.remove(&detached_process_id);
}
break;
}
Err(error) if is_javascript_child_process_gone_error(&error) => {
if let Some(vm) = self.vms.get_mut(vm_id) {
vm.detached_child_processes.remove(&detached_process_id);
}
break;
}
Err(error) => return Err(error),
};
let Some(event_type) = event.get("type").and_then(Value::as_str) else {
break;
};
let Some((connection_id, session_id)) = self
.vms
.get(vm_id)
.map(|vm| (vm.connection_id.clone(), vm.session_id.clone()))
else {
break;
};
let envelope = match event_type {
"stdout" => Some(ProcessEventEnvelope {
connection_id: connection_id.clone(),
session_id: session_id.clone(),
vm_id: vm_id.to_owned(),
process_id: detached_process_id.clone(),
event: ActiveExecutionEvent::Stdout(javascript_sync_rpc_bytes_arg(
&[event.get("data").cloned().unwrap_or(Value::Null)],
0,
"detached child_process stdout",
)?),
}),
"stderr" => Some(ProcessEventEnvelope {
connection_id: connection_id.clone(),
session_id: session_id.clone(),
vm_id: vm_id.to_owned(),
process_id: detached_process_id.clone(),
event: ActiveExecutionEvent::Stderr(javascript_sync_rpc_bytes_arg(
&[event.get("data").cloned().unwrap_or(Value::Null)],
0,
"detached child_process stderr",
)?),
}),
"exit" => {
if let Some(vm) = self.vms.get_mut(vm_id) {
vm.detached_child_processes.remove(&detached_process_id);
}
Some(ProcessEventEnvelope {
connection_id,
session_id,
vm_id: vm_id.to_owned(),
process_id: detached_process_id.clone(),
event: ActiveExecutionEvent::Exited(
event
.get("exitCode")
.and_then(Value::as_i64)
.map(|value| value as i32)
.unwrap_or(1),
),
})
}
_ => None,
};
let Some(envelope) = envelope else {
break;
};
self.queue_pending_process_event(envelope)?;
emitted_any = true;
if event_type == "exit" {
break;
}
}
}
Ok(emitted_any)
}
pub(crate) fn drain_queued_descendant_javascript_child_process_events(
&mut self,
vm_id: &str,
process_id: &str,
child_path: &[&str],
) -> Result<(), SidecarError> {
if child_path.is_empty() {
return Ok(());
}
let target_process_id = Self::child_process_path_label(process_id, child_path);
let mut child_capacity = self
.vms
.get(vm_id)
.and_then(|vm| vm.active_processes.get(process_id))
.and_then(|root| descendant_pending_execution_event_capacity(root, child_path));
let mut deferred = VecDeque::new();
while let Some(envelope) = self.pending_process_events.pop_front() {
if envelope.vm_id == vm_id && envelope.process_id == target_process_id {
if matches!(child_capacity, Some(0)) {
self.pending_process_events.push_front(envelope);
while let Some(deferred_envelope) = deferred.pop_back() {
self.pending_process_events.push_front(deferred_envelope);
}
return Err(process_event_queue_overflow_error());
}
if let Some(vm) = self.vms.get_mut(vm_id) {
if let Some(root) = vm.active_processes.get_mut(process_id) {
if let Some(child) = Self::active_process_by_path_mut(root, child_path) {
child.queue_pending_execution_event(envelope.event)?;
child_capacity = child_capacity.map(|capacity| capacity - 1);
continue;
}
}
}
}
deferred.push_back(envelope);
}
self.pending_process_events = deferred;
let mut queued = Vec::new();
{
let transfer_capacity = self
.pending_process_event_capacity()
.min(child_capacity.unwrap_or(usize::MAX));
let receiver = self.process_event_receiver.as_mut().ok_or_else(|| {
SidecarError::InvalidState(String::from("process event receiver unavailable"))
})?;
loop {
if queued.len() >= transfer_capacity {
if receiver.is_empty() {
break;
}
return Err(process_event_queue_overflow_error());
}
match receiver.try_recv() {
Ok(envelope) => queued.push(envelope),
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
}
}
}
for envelope in queued {
if envelope.vm_id == vm_id && envelope.process_id == target_process_id {
if let Some(vm) = self.vms.get_mut(vm_id) {
if let Some(root) = vm.active_processes.get_mut(process_id) {
if let Some(child) = Self::active_process_by_path_mut(root, child_path) {
child.queue_pending_execution_event(envelope.event)?;
continue;
}
}
}
}
self.queue_pending_process_event(envelope)?;
}
Ok(())
}
pub(crate) fn handle_execution_event(
&mut self,
vm_id: &str,
process_id: &str,
event: ActiveExecutionEvent,
) -> Result<Option<EventFrame>, SidecarError> {
let Some(vm) = self.vms.get(vm_id) else {
log_stale_process_event(&self.bridge, vm_id, process_id, "execution event dispatch");
return Ok(None);
};
if !vm.active_processes.contains_key(process_id) {
log_stale_process_event(&self.bridge, vm_id, process_id, "execution event dispatch");
return Ok(None);
}
let (connection_id, session_id) = { (vm.connection_id.clone(), vm.session_id.clone()) };
let ownership = OwnershipScope::vm(&connection_id, &session_id, vm_id);
if self.capture_extension_process_output_event(vm_id, process_id, &event) {
return Ok(None);
}
match event {
ActiveExecutionEvent::Stdout(chunk) => Ok(Some(EventFrame::new(
ownership,
EventPayload::ProcessOutput(ProcessOutputEvent {
process_id: process_id.to_owned(),
channel: StreamChannel::Stdout,
chunk,
}),
))),
ActiveExecutionEvent::Stderr(chunk) => Ok(Some(EventFrame::new(
ownership,
EventPayload::ProcessOutput(ProcessOutputEvent {
process_id: process_id.to_owned(),
channel: StreamChannel::Stderr,
chunk,
}),
))),
ActiveExecutionEvent::JavascriptSyncRpcRequest(request) => {
self.handle_javascript_sync_rpc_request(vm_id, process_id, request)?;
Ok(None)
}
ActiveExecutionEvent::PythonVfsRpcRequest(request) => {
self.handle_python_vfs_rpc_request(vm_id, process_id, *request)?;
Ok(None)
}
ActiveExecutionEvent::SignalState {
signal,
registration,
} => {
let Some(vm) = self.vms.get_mut(vm_id) else {
return Ok(None);
};
if !vm.active_processes.contains_key(process_id) {
return Ok(None);
}
vm.signal_states
.entry(process_id.to_owned())
.or_default()
.insert(signal, registration);
Ok(None)
}
ActiveExecutionEvent::Exited(exit_code) => {
let became_idle = self
.finish_active_process_exit(vm_id, process_id, exit_code)?
.unwrap_or(false);
if became_idle {
self.bridge.emit_lifecycle(vm_id, LifecycleState::Ready)?;
}
Ok(Some(EventFrame::new(
ownership,
EventPayload::ProcessExited(ProcessExitedEvent {
process_id: process_id.to_owned(),
exit_code,
}),
)))
}
}
}
pub(crate) fn finish_active_process_exit(
&mut self,
vm_id: &str,
process_id: &str,
exit_code: i32,
) -> Result<Option<bool>, SidecarError> {
let Some(vm) = self.vms.get_mut(vm_id) else {
log_stale_process_event(&self.bridge, vm_id, process_id, "process exit cleanup");
return Ok(None);
};
if !vm.active_processes.contains_key(process_id) {
log_stale_process_event(&self.bridge, vm_id, process_id, "process exit cleanup");
return Ok(None);
}
prune_exited_process_snapshots(vm);
let process_table = vm.kernel.list_processes();
let Some(mut process) = vm.active_processes.remove(process_id) else {
return Ok(None);
};
if let Some(info) = process_table.get(&process.kernel_pid) {
vm.exited_process_snapshots
.push_back(ExitedProcessSnapshot {
captured_at: Instant::now(),
process: build_process_snapshot_entry(
process_id,
&process,
info,
Some(exit_code),
),
});
}
let detached_children = Self::adopt_detached_child_processes(process_id, &mut process);
sync_process_host_writes_to_kernel(vm, &process)?;
terminate_child_process_tree(&mut vm.kernel, &mut process);
process.kernel_handle.finish(exit_code);
let _ = vm.kernel.wait_and_reap(process.kernel_pid);
vm.signal_states.remove(process_id);
for (detached_process_id, detached_child) in detached_children {
vm.detached_child_processes
.insert(detached_process_id.clone());
vm.active_processes
.insert(detached_process_id, detached_child);
}
let became_idle = vm.active_processes.is_empty();
self.prune_extension_process_resource(process_id);
Ok(Some(became_idle))
}
pub(crate) fn drain_process_events_blocking_with_limit(
&mut self,
vm_id: &str,
process_id: &str,
max_events: usize,
) -> Result<Vec<ActiveExecutionEvent>, SidecarError> {
let mut events = Vec::new();
if max_events == 0 {
return Ok(events);
}
let mut deadline = Instant::now() + Duration::from_millis(150);
loop {
if events.len() >= max_events {
break;
}
let event = {
let Some(vm) = self.vms.get_mut(vm_id) else {
break;
};
let Some(process) = vm.active_processes.get_mut(process_id) else {
break;
};
if let Some(event) = process.pending_execution_events.pop_front() {
Some(event)
} else {
match process.execution.poll_event_blocking(Duration::ZERO) {
Ok(event) => event,
Err(SidecarError::Execution(_)) => None,
Err(other) => return Err(other),
}
}
};
let Some(event) = event else {
if Instant::now() >= deadline {
break;
}
let blocking_wait = deadline.saturating_duration_since(Instant::now());
if blocking_wait.is_zero() {
break;
}
if events.len() >= max_events {
break;
}
let delayed_event = {
let Some(vm) = self.vms.get_mut(vm_id) else {
break;
};
let Some(process) = vm.active_processes.get_mut(process_id) else {
break;
};
if let Some(event) = process.pending_execution_events.pop_front() {
Some(event)
} else {
match process.execution.poll_event_blocking(blocking_wait) {
Ok(event) => event,
Err(SidecarError::Execution(_)) => None,
Err(other) => return Err(other),
}
}
};
let Some(event) = delayed_event else {
break;
};
events.push(event);
deadline = Instant::now() + Duration::from_millis(150);
continue;
};
events.push(event);
deadline = Instant::now() + Duration::from_millis(150);
}
Ok(events)
}
pub(crate) fn handle_python_vfs_rpc_request(
&mut self,
vm_id: &str,
process_id: &str,
request: PythonVfsRpcRequest,
) -> Result<(), SidecarError> {
match request.method {
PythonVfsRpcMethod::Read
| PythonVfsRpcMethod::Write
| PythonVfsRpcMethod::Stat
| PythonVfsRpcMethod::ReadDir
| PythonVfsRpcMethod::Mkdir => {
filesystem_handle_python_vfs_rpc_request(self, vm_id, process_id, request)
}
PythonVfsRpcMethod::HttpRequest => {
self.handle_python_http_rpc_request(vm_id, process_id, request)
}
PythonVfsRpcMethod::DnsLookup => {
self.handle_python_dns_rpc_request(vm_id, process_id, request)
}
PythonVfsRpcMethod::SubprocessRun => {
self.handle_python_subprocess_rpc_request(vm_id, process_id, request)
}
}
}
fn handle_python_http_rpc_request(
&mut self,
vm_id: &str,
process_id: &str,
request: PythonVfsRpcRequest,
) -> Result<(), SidecarError> {
let Some(vm) = self.vms.get(vm_id) else {
return Ok(());
};
if !vm.active_processes.contains_key(process_id) {
return Ok(());
}
let response = (|| {
let url_text = request.url.as_deref().ok_or_else(|| {
SidecarError::InvalidState(String::from("python httpRequest requires a url"))
})?;
let url = Url::parse(url_text)
.map_err(|error| SidecarError::Execution(format!("ERR_INVALID_URL: {error}")))?;
let host = url.host_str().ok_or_else(|| {
SidecarError::Execution(String::from("ERR_INVALID_URL: missing host"))
})?;
let port = url.port_or_known_default().ok_or_else(|| {
SidecarError::Execution(String::from("ERR_INVALID_URL: missing port"))
})?;
self.bridge.require_network_access(
vm_id,
NetworkOperation::Http,
format_tcp_resource(host, port),
)?;
// Pin the outbound connection to the IP addresses that pass the
// egress range guard at resolution time. A literal IP is validated
// directly; a hostname is resolved once here and the resulting
// address set is pinned into the HTTP client's resolver below so a
// rebinding DNS server cannot make the second (TLS/TCP) lookup land
// on a private/link-local/metadata IP that this check rejected.
let pinned_addresses = if let Ok(literal_ip) = host.parse::<IpAddr>() {
filter_dns_safe_ip_addrs(vec![literal_ip], host)?
} else {
filter_dns_safe_ip_addrs(
resolve_dns_ip_addrs(
&self.bridge,
&vm.kernel,
vm_id,
&vm.dns,
host,
DnsLookupPolicy::SkipPermissions,
)?,
host,
)?
};
let mut headers = BTreeMap::new();
for (name, value) in &request.headers {
headers.insert(name.clone(), Value::String(value.clone()));
}
let options = JavascriptHttpRequestOptions {
method: Some(
request
.http_method
.clone()
.unwrap_or_else(|| String::from("GET")),
),
headers,
body: request.body_base64.as_deref().map(|body| {
String::from_utf8(
base64::engine::general_purpose::STANDARD
.decode(body)
.unwrap_or_default(),
)
.unwrap_or_default()
}),
reject_unauthorized: None,
};
let headers =
parse_http_header_collection(&options.headers, "python httpRequest headers")?;
let response =
issue_outbound_http_request(&url, &options, &headers, &pinned_addresses)?;
let payload_json = response.as_str().ok_or_else(|| {
SidecarError::Execution(String::from(
"python httpRequest returned a non-string response payload",
))
})?;
let payload: Value = serde_json::from_str(payload_json).map_err(|error| {
SidecarError::Execution(format!(
"python httpRequest response must be valid JSON: {error}"
))
})?;
let header_map = payload
.get("headers")
.and_then(Value::as_array)
.map(|entries| {
let mut normalized = BTreeMap::<String, Vec<String>>::new();
for entry in entries {
let Some(pair) = entry.as_array() else {
continue;
};
let Some(name) = pair.first().and_then(Value::as_str) else {
continue;
};
let Some(value) = pair.get(1).and_then(Value::as_str) else {
continue;
};
normalized
.entry(name.to_owned())
.or_default()
.push(value.to_owned());
}
normalized
})
.unwrap_or_default();
Ok(PythonVfsRpcResponsePayload::Http {
status: payload
.get("status")
.and_then(Value::as_u64)
.map(|value| value as u16)
.unwrap_or_default(),
reason: payload
.get("statusText")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
url: payload
.get("url")
.and_then(Value::as_str)
.unwrap_or(url_text)
.to_owned(),
headers: header_map,
body_base64: payload
.get("body")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
})
})();
self.respond_python_rpc(vm_id, process_id, request.id, response)
}
fn handle_python_dns_rpc_request(
&mut self,
vm_id: &str,
process_id: &str,
request: PythonVfsRpcRequest,
) -> Result<(), SidecarError> {
let Some(vm) = self.vms.get(vm_id) else {
return Ok(());
};
if !vm.active_processes.contains_key(process_id) {
return Ok(());
}
let response = (|| {
let hostname = request.hostname.as_deref().ok_or_else(|| {
SidecarError::InvalidState(String::from("python dnsLookup requires a hostname"))
})?;
let mut addresses = filter_dns_safe_ip_addrs(
resolve_dns_ip_addrs(
&self.bridge,
&vm.kernel,
vm_id,
&vm.dns,
hostname,
DnsLookupPolicy::CheckPermissions,
)?,
hostname,
)?;
if let Some(family) = request.family {
addresses.retain(|address| {
matches!((family, address), (4, IpAddr::V4(_)) | (6, IpAddr::V6(_)))
});
}
Ok(PythonVfsRpcResponsePayload::DnsLookup {
addresses: addresses
.into_iter()
.map(|address| address.to_string())
.collect(),
})
})();
self.respond_python_rpc(vm_id, process_id, request.id, response)
}
fn handle_python_subprocess_rpc_request(
&mut self,
vm_id: &str,
process_id: &str,
request: PythonVfsRpcRequest,
) -> Result<(), SidecarError> {
let command = request.command.clone().ok_or_else(|| {
SidecarError::InvalidState(String::from("python subprocessRun requires a command"))
})?;
let (internal_bootstrap_env, cwd) = {
let Some(vm) = self.vms.get(vm_id) else {
return Ok(());
};
let Some(process) = vm.active_processes.get(process_id) else {
return Ok(());
};
let virtual_home = guest_virtual_home(vm);
let cwd = request.cwd.clone().or_else(|| {
guest_runtime_path_for_host_path(
&vm.guest_env,
&virtual_home,
&vm.host_cwd,
&process.host_cwd.to_string_lossy(),
)
});
(
sanitize_javascript_child_process_internal_bootstrap_env(&vm.guest_env),
cwd,
)
};
let response = self
.spawn_javascript_child_process_sync(
vm_id,
process_id,
JavascriptChildProcessSpawnRequest {
command,
args: request.args.clone(),
options: JavascriptChildProcessSpawnOptions {
cwd,
env: request.env.clone(),
input: None,
internal_bootstrap_env,
shell: request.shell,
detached: false,
stdio: vec![
String::from("pipe"),
String::from("pipe"),
String::from("pipe"),
],
timeout: None,
kill_signal: None,
},
},
request.max_buffer,
)
.map(|payload| PythonVfsRpcResponsePayload::SubprocessRun {
exit_code: payload
.get("code")
.and_then(Value::as_i64)
.map(|value| value as i32)
.unwrap_or(1),
stdout: payload
.get("stdout")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
stderr: payload
.get("stderr")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
max_buffer_exceeded: payload
.get("maxBufferExceeded")
.and_then(Value::as_bool)
.unwrap_or(false),
});
self.respond_python_rpc(vm_id, process_id, request.id, response)
}
fn respond_python_rpc(
&mut self,
vm_id: &str,
process_id: &str,
request_id: u64,
response: Result<PythonVfsRpcResponsePayload, SidecarError>,
) -> Result<(), SidecarError> {
let Some(vm) = self.vms.get_mut(vm_id) else {
return Ok(());
};
let Some(process) = vm.active_processes.get_mut(process_id) else {
return Ok(());
};
let result = match response {
Ok(payload) => process
.execution
.respond_python_vfs_rpc_success(request_id, payload),
Err(error) => process.execution.respond_python_vfs_rpc_error(
request_id,
"ERR_AGENTOS_PYTHON_VFS_RPC",
error.to_string(),
),
};
match result {
Ok(()) => Ok(()),
Err(error) if is_broken_pipe_error(&error) => Ok(()),
Err(error) => Err(error),
}
}
pub(crate) fn resolve_javascript_child_process_execution(
&self,
vm: &VmState,
parent_env: &BTreeMap<String, String>,
parent_guest_cwd: &str,
parent_host_cwd: &Path,
request: &JavascriptChildProcessSpawnRequest,
) -> Result<ResolvedChildProcessExecution, SidecarError> {
let mut runtime_env = parent_env.clone();
runtime_env.extend(request.options.internal_bootstrap_env.clone());
let (guest_cwd, host_cwd_override) = request
.options
.cwd
.as_deref()
.map(|cwd| {
let normalized_parent_host_cwd = normalize_host_path(parent_host_cwd);
let requested_host_cwd = normalize_host_path(Path::new(cwd));
if path_is_within_root(&requested_host_cwd, &normalized_parent_host_cwd) {
let relative = requested_host_cwd
.strip_prefix(&normalized_parent_host_cwd)
.unwrap_or_else(|_| Path::new(""));
let relative = relative.to_string_lossy().replace('\\', "/");
let guest_cwd = if relative.is_empty() {
parent_guest_cwd.to_owned()
} else {
normalize_path(&format!("{parent_guest_cwd}/{relative}"))
};
(guest_cwd, Some(requested_host_cwd))
} else if Path::new(cwd).is_relative() {
(
normalize_path(&format!("{parent_guest_cwd}/{cwd}")),
Some(normalize_host_path(&parent_host_cwd.join(cwd))),
)
} else {
(normalize_path(cwd), None)
}
})
.unwrap_or_else(|| (parent_guest_cwd.to_owned(), None));
let inherited_host_cwd = (host_cwd_override.is_none() && guest_cwd == parent_guest_cwd)
.then(|| normalize_host_path(parent_host_cwd));
let host_cwd = host_cwd_override
.or(inherited_host_cwd)
.or_else(|| {
host_runtime_path_for_guest_path_with_env(
vm,
&runtime_env,
&guest_cwd,
parent_host_cwd,
)
})
.unwrap_or_else(|| {
let candidate = PathBuf::from(&guest_cwd);
if guest_cwd == parent_guest_cwd {
normalize_host_path(parent_host_cwd)
} else if candidate.is_absolute() {
shadow_path_for_guest(vm, &guest_cwd)
} else {
vm.host_cwd.clone()
}
});
let mut env = parent_env.clone();
env.extend(request.options.env.clone());
// Child JavaScript executions must resolve their own entrypoint/eval state.
// Reusing the parent's values makes the sidecar load the wrong source file.
env.remove("AGENTOS_GUEST_ENTRYPOINT");
env.remove("AGENTOS_NODE_EVAL");
let (command, process_args) = if request.options.shell {
let tokens = tokenize_shell_free_command(&request.command);
let requires_shell = command_requires_shell(&request.command)
|| tokens.first().is_some_and(|command| {
is_posix_shell_builtin(command) || shell_first_token_requires_shell(command)
});
if requires_shell {
if !vm.command_guest_paths.contains_key("sh") {
return Err(SidecarError::InvalidState(format!(
"shell-mode child_process command requires /bin/sh, which is not \
installed in this VM (install a software package that provides sh, \
for example @secure-exec/coreutils): {}",
request.command
)));
}
(
String::from("sh"),
vec![String::from("-c"), request.command.clone()],
)
} else {
let Some((command, args)) = tokens.split_first() else {
return Err(SidecarError::InvalidState(String::from(
"child_process shell command must not be empty",
)));
};
(command.clone(), args.to_vec())
}
} else {
(request.command.clone(), request.args.clone())
};
let process_args = apply_shell_cwd_prefix(&command, process_args, &guest_cwd);
if is_tool_command(vm, &command) {
let command = normalized_tool_command_name(&command).unwrap_or(command);
return Ok(ResolvedChildProcessExecution {
command: command.clone(),
process_args: std::iter::once(command.clone())
.chain(process_args.iter().cloned())
.collect(),
runtime: GuestRuntimeKind::JavaScript,
entrypoint: command,
execution_args: process_args,
env,
guest_cwd,
host_cwd,
wasm_permission_tier: None,
tool_command: true,
});
}
if is_path_like_specifier(&command)
&& matches!(
Path::new(&command).extension().and_then(|ext| ext.to_str()),
Some("js" | "mjs" | "cjs" | "ts" | "mts" | "cts")
)
{
let guest_entrypoint = if command.starts_with('/') {
normalize_path(&command)
} else if command.starts_with("file:") {
normalize_path(command.trim_start_matches("file:"))
} else {
normalize_path(&format!("{guest_cwd}/{command}"))
};
let host_entrypoint = if command.starts_with("./") || command.starts_with("../") {
normalize_host_path(&host_cwd.join(&command))
} else {
host_runtime_path_for_guest_path_with_env(
vm,
&runtime_env,
&guest_entrypoint,
parent_host_cwd,
)
.unwrap_or_else(|| {
let candidate = PathBuf::from(&guest_entrypoint);
if candidate.is_absolute() {
candidate
} else {
host_cwd.join(&guest_entrypoint)
}
})
};
env.insert(String::from("AGENTOS_GUEST_ENTRYPOINT"), guest_entrypoint);
let guest_entrypoint = env.get("AGENTOS_GUEST_ENTRYPOINT").cloned();
prepare_guest_runtime_env(vm, &mut env, &guest_cwd, &host_cwd, guest_entrypoint)?;
return Ok(ResolvedChildProcessExecution {
command: command.clone(),
process_args: std::iter::once(command)
.chain(process_args.iter().cloned())
.collect(),
runtime: GuestRuntimeKind::JavaScript,
entrypoint: host_entrypoint.to_string_lossy().into_owned(),
execution_args: process_args,
env,
guest_cwd,
host_cwd,
wasm_permission_tier: None,
tool_command: false,
});
}
if is_node_runtime_command(&command) {
if let Some(cli) = resolve_host_node_cli_entrypoint(&command) {
env.insert(
String::from("AGENTOS_NODE_EVAL"),
build_host_node_cli_eval(&cli),
);
prepare_guest_runtime_env(vm, &mut env, &guest_cwd, &host_cwd, None)?;
add_runtime_guest_path_mapping(&mut env, &cli.guest_root, &cli.package_root);
add_runtime_host_access_path(
&mut env,
"AGENTOS_EXTRA_FS_READ_PATHS",
&cli.package_root,
true,
);
return Ok(ResolvedChildProcessExecution {
command: command.clone(),
process_args: std::iter::once(command.clone())
.chain(process_args.iter().cloned())
.collect(),
runtime: GuestRuntimeKind::JavaScript,
entrypoint: String::from("-e"),
execution_args: std::iter::once(cli.guest_entrypoint.clone())
.chain(process_args.iter().cloned())
.collect(),
env,
guest_cwd,
host_cwd,
wasm_permission_tier: None,
tool_command: false,
});
}
if process_args.is_empty() {
env.insert(String::from("AGENTOS_NODE_EVAL"), String::new());
prepare_guest_runtime_env(vm, &mut env, &guest_cwd, &host_cwd, None)?;
return Ok(ResolvedChildProcessExecution {
command: command.clone(),
process_args: vec![command.clone()],
runtime: GuestRuntimeKind::JavaScript,
entrypoint: String::from("-e"),
execution_args: Vec::new(),
env,
guest_cwd,
host_cwd,
wasm_permission_tier: None,
tool_command: false,
});
}
if let Some((entrypoint, execution_args)) =
resolve_special_node_cli_invocation(&process_args, &mut env)
{
prepare_guest_runtime_env(vm, &mut env, &guest_cwd, &host_cwd, None)?;
return Ok(ResolvedChildProcessExecution {
command: command.clone(),
process_args: std::iter::once(command.clone())
.chain(process_args.iter().cloned())
.collect(),
runtime: GuestRuntimeKind::JavaScript,
entrypoint,
execution_args,
env,
guest_cwd,
host_cwd,
wasm_permission_tier: None,
tool_command: false,
});
}
let Some(entrypoint_specifier) = process_args.first() else {
return Err(SidecarError::InvalidState(format!(
"{command} child_process spawn requires an entrypoint"
)));
};
let (entrypoint, execution_args) = if is_path_like_specifier(entrypoint_specifier) {
let guest_entrypoint = if entrypoint_specifier.starts_with('/') {
normalize_path(entrypoint_specifier)
} else if entrypoint_specifier.starts_with("file:") {
normalize_path(entrypoint_specifier.trim_start_matches("file:"))
} else {
normalize_path(&format!("{guest_cwd}/{entrypoint_specifier}"))
};
let host_entrypoint = if entrypoint_specifier.starts_with("./")
|| entrypoint_specifier.starts_with("../")
{
normalize_host_path(&host_cwd.join(entrypoint_specifier))
} else {
host_runtime_path_for_guest_path_with_env(
vm,
&runtime_env,
&guest_entrypoint,
parent_host_cwd,
)
.unwrap_or_else(|| {
let candidate = PathBuf::from(&guest_entrypoint);
if candidate.is_absolute() {
candidate
} else {
host_cwd.join(&guest_entrypoint)
}
})
};
env.insert(String::from("AGENTOS_GUEST_ENTRYPOINT"), guest_entrypoint);
(
host_entrypoint.to_string_lossy().into_owned(),
process_args.iter().skip(1).cloned().collect(),
)
} else {
(
entrypoint_specifier.clone(),
process_args.iter().skip(1).cloned().collect(),
)
};
let guest_entrypoint = env.get("AGENTOS_GUEST_ENTRYPOINT").cloned();
prepare_guest_runtime_env(vm, &mut env, &guest_cwd, &host_cwd, guest_entrypoint)?;
return Ok(ResolvedChildProcessExecution {
command: command.clone(),
process_args: std::iter::once(command)
.chain(process_args.iter().cloned())
.collect(),
runtime: GuestRuntimeKind::JavaScript,
entrypoint,
execution_args,
env,
guest_cwd,
host_cwd,
wasm_permission_tier: None,
tool_command: false,
});
}
if command == PYTHON_COMMAND {
return Err(SidecarError::InvalidState(String::from(
"nested python child_process execution is not supported yet",
)));
}
let guest_entrypoint = resolve_guest_command_entrypoint(
vm,
&guest_cwd,
&command,
env.get("PATH").map(String::as_str),
)
.ok_or_else(|| SidecarError::InvalidState(format!("command not found: {command}")))?;
let host_entrypoint = resolve_vm_guest_path_to_host(vm, &guest_entrypoint);
let wasm_permission_tier = vm.command_permissions.get(&command).copied().or_else(|| {
Path::new(&guest_entrypoint)
.file_name()
.and_then(|name| name.to_str())
.and_then(|name| vm.command_permissions.get(name).copied())
});
if let Some((javascript_guest_entrypoint, javascript_host_entrypoint)) =
resolve_javascript_command_entrypoint(vm, &guest_entrypoint, &host_entrypoint)
{
prepare_guest_runtime_env(
vm,
&mut env,
&guest_cwd,
&host_cwd,
Some(javascript_guest_entrypoint),
)?;
return Ok(ResolvedChildProcessExecution {
command: command.clone(),
process_args: std::iter::once(command)
.chain(process_args.iter().cloned())
.collect(),
runtime: GuestRuntimeKind::JavaScript,
entrypoint: javascript_host_entrypoint.to_string_lossy().into_owned(),
execution_args: process_args,
env,
guest_cwd,
host_cwd,
wasm_permission_tier: None,
tool_command: false,
});
}
prepare_guest_runtime_env(
vm,
&mut env,
&guest_cwd,
&host_cwd,
Some(guest_entrypoint.clone()),
)?;
Ok(ResolvedChildProcessExecution {
command: command.clone(),
process_args: std::iter::once(command)
.chain(process_args.iter().cloned())
.collect(),
runtime: GuestRuntimeKind::WebAssembly,
entrypoint: host_entrypoint.to_string_lossy().into_owned(),
execution_args: process_args,
env,
guest_cwd,
host_cwd,
wasm_permission_tier,
tool_command: false,
})
}
pub(crate) fn spawn_javascript_child_process(
&mut self,
vm_id: &str,
process_id: &str,
request: JavascriptChildProcessSpawnRequest,
) -> Result<Value, SidecarError> {
let resolved = {
let vm = self.vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?;
let parent = vm
.active_processes
.get(process_id)
.ok_or_else(|| missing_process_error(vm_id, process_id))?;
self.resolve_javascript_child_process_execution(
vm,
&parent.env,
&parent.guest_cwd,
&parent.host_cwd,
&request,
)?
};
let (parent_kernel_pid, child_process_id) = {
let vm = self
.vms
.get_mut(vm_id)
.ok_or_else(|| missing_vm_error(vm_id))?;
let process = vm
.active_processes
.get_mut(process_id)
.ok_or_else(|| missing_process_error(vm_id, process_id))?;
(process.kernel_pid, process.allocate_child_process_id())
};
let sidecar_requests = self.sidecar_requests.clone();
let vm = self
.vms
.get_mut(vm_id)
.ok_or_else(|| missing_vm_error(vm_id))?;
let (kernel_pid, kernel_handle, execution, kernel_stdin_writer_fd) = if resolved
.tool_command
{
let tool_resolution = resolve_tool_command(
vm,
&resolved.command,
&resolved.execution_args,
Some(&resolved.guest_cwd),
)?
.ok_or_else(|| {
SidecarError::InvalidState(format!(
"tool command no longer resolves: {}",
resolved.command
))
})?;
let kernel_handle = vm
.kernel
.create_virtual_process(
EXECUTION_DRIVER_NAME,
TOOL_DRIVER_NAME,
&resolved.command,
resolved.process_args.clone(),
VirtualProcessOptions {
parent_pid: Some(parent_kernel_pid),
env: resolved.env.clone(),
cwd: Some(resolved.guest_cwd.clone()),
},
)
.map_err(kernel_error)?;
let kernel_pid = kernel_handle.pid();
let tool_execution = ToolExecution::default();
let cancelled = tool_execution.cancelled.clone();
let pending_events = tool_execution.pending_events.clone();
let events_overflowed = tool_execution.events_overflowed.clone();
spawn_tool_process_events(ToolProcessEventRequest {
sidecar_requests: sidecar_requests.clone(),
connection_id: vm.connection_id.clone(),
session_id: vm.session_id.clone(),
vm_id: vm_id.to_owned(),
tool_resolution,
cancelled,
pending_events,
events_overflowed,
});
(
kernel_pid,
kernel_handle,
ActiveExecution::Tool(tool_execution),
None,
)
} else {
let kernel_command = match resolved.runtime {
GuestRuntimeKind::JavaScript => JAVASCRIPT_COMMAND,
GuestRuntimeKind::WebAssembly => WASM_COMMAND,
GuestRuntimeKind::Python => {
unreachable!("python child_process execution is rejected")
}
};
let kernel_handle = vm
.kernel
.spawn_process(
kernel_command,
resolved.process_args.clone(),
SpawnOptions {
requester_driver: Some(String::from(EXECUTION_DRIVER_NAME)),
parent_pid: Some(parent_kernel_pid),
env: resolved.env.clone(),
cwd: Some(resolved.guest_cwd.clone()),
},
)
.map_err(kernel_error)?;
let kernel_pid = kernel_handle.pid();
if request.options.detached {
vm.kernel
.setsid(EXECUTION_DRIVER_NAME, kernel_pid)
.map_err(kernel_error)?;
}
let mut execution_env = resolved.env.clone();
execution_env.insert(
String::from(EXECUTION_SANDBOX_ROOT_ENV),
normalize_host_path(&vm.cwd).to_string_lossy().into_owned(),
);
let execution = match resolved.runtime {
GuestRuntimeKind::JavaScript => {
execution_env.extend(sanitize_javascript_child_process_internal_bootstrap_env(
&request.options.internal_bootstrap_env,
));
execution_env.insert(
String::from("SECURE_EXEC_KEEP_STDIN_OPEN"),
String::from("1"),
);
let context =
self.javascript_engine
.create_context(CreateJavascriptContextRequest {
vm_id: vm_id.to_owned(),
bootstrap_module: None,
compile_cache_root: Some(
self.cache_root.join("node-compile-cache"),
),
});
let inline_code = load_javascript_entrypoint_source(
vm,
&resolved.host_cwd,
&resolved.entrypoint,
&execution_env,
);
prepare_javascript_shadow(vm, &resolved)?;
let module_reader = build_module_reader(vm, &resolved)
.map(|reader| Box::new(reader) as Box<dyn ModuleFsReader + Send>);
let execution = self
.javascript_engine
.start_execution_with_module_reader(
StartJavascriptExecutionRequest {
guest_runtime: guest_runtime_identity(
vm,
Some(u64::from(kernel_pid)),
Some(u64::from(parent_kernel_pid)),
),
vm_id: vm_id.to_owned(),
context_id: context.context_id,
argv: std::iter::once(resolved.entrypoint.clone())
.chain(resolved.execution_args.clone())
.collect(),
env: execution_env,
cwd: resolved.host_cwd.clone(),
limits: javascript_execution_limits(vm),
inline_code,
},
module_reader,
)
.map_err(javascript_error)?;
ActiveExecution::Javascript(execution)
}
GuestRuntimeKind::WebAssembly => {
execution_env.insert(String::from(WASM_STDIO_SYNC_RPC_ENV), String::from("1"));
let wasm_limits = wasm_execution_limits(vm);
let wasm_guest_runtime = guest_runtime_identity(
vm,
Some(u64::from(kernel_pid)),
Some(u64::from(parent_kernel_pid)),
);
let context = self.wasm_engine.create_context(CreateWasmContextRequest {
vm_id: vm_id.to_owned(),
module_path: Some(resolved.entrypoint.clone()),
});
let execution = self
.wasm_engine
.start_execution(StartWasmExecutionRequest {
vm_id: vm_id.to_owned(),
context_id: context.context_id,
argv: resolved.process_args.clone(),
env: execution_env,
cwd: resolved.host_cwd.clone(),
permission_tier: execution_wasm_permission_tier(
resolved
.wasm_permission_tier
.unwrap_or(WasmPermissionTier::Full),
),
limits: wasm_limits,
guest_runtime: wasm_guest_runtime,
})
.map_err(wasm_error)?;
ActiveExecution::Wasm(Box::new(execution))
}
GuestRuntimeKind::Python => {
unreachable!("python child_process execution is rejected")
}
};
let kernel_stdin_writer_fd = match javascript_child_process_stdin_mode(&request) {
"pipe" => Some(install_kernel_stdin_pipe(&mut vm.kernel, kernel_pid)?),
"ignore" => {
vm.kernel
.fd_close(EXECUTION_DRIVER_NAME, kernel_pid, 0)
.map_err(kernel_error)?;
None
}
"inherit" => None,
_ => Some(install_kernel_stdin_pipe(&mut vm.kernel, kernel_pid)?),
};
(kernel_pid, kernel_handle, execution, kernel_stdin_writer_fd)
};
let process = vm
.active_processes
.get_mut(process_id)
.ok_or_else(|| missing_process_error(vm_id, process_id))?;
process.child_processes.insert(
child_process_id.clone(),
ActiveProcess::new(kernel_pid, kernel_handle, resolved.runtime, execution)
.with_detached(request.options.detached)
.with_guest_cwd(resolved.guest_cwd.clone())
.with_env(resolved.env.clone())
.with_host_cwd(resolved.host_cwd.clone()),
);
if let Some(kernel_stdin_writer_fd) = kernel_stdin_writer_fd {
process
.child_processes
.get_mut(&child_process_id)
.ok_or_else(|| {
SidecarError::InvalidState(format!(
"child process {child_process_id} disappeared during spawn"
))
})?
.kernel_stdin_writer_fd = Some(kernel_stdin_writer_fd);
}
Ok(json!({
"childId": child_process_id,
"pid": kernel_pid,
"command": resolved.command,
"args": resolved.process_args,
}))
}
pub(crate) fn spawn_javascript_child_process_sync(
&mut self,
vm_id: &str,
process_id: &str,
request: JavascriptChildProcessSpawnRequest,
max_buffer: Option<usize>,
) -> Result<Value, SidecarError> {
let sync_input = javascript_child_process_sync_input_bytes(request.options.input.as_ref())?;
let timeout_deadline = request
.options
.timeout
.map(|timeout_ms| Instant::now() + Duration::from_millis(timeout_ms));
let timeout_signal = request
.options
.kill_signal
.clone()
.unwrap_or_else(|| String::from("SIGTERM"));
let spawned = self.spawn_javascript_child_process(vm_id, process_id, request)?;
let child_process_id = spawned
.get("childId")
.and_then(Value::as_str)
.ok_or_else(|| {
SidecarError::InvalidState(String::from(
"child_process.spawn_sync response is missing childId",
))
})?
.to_owned();
if let Some(input) = sync_input.as_deref() {
self.write_javascript_child_process_stdin(vm_id, process_id, &child_process_id, input)?;
}
self.close_javascript_child_process_stdin(vm_id, process_id, &child_process_id)?;
let max_buffer = max_buffer.unwrap_or(1024 * 1024);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut max_buffer_exceeded = false;
let mut kill_sent = false;
let mut timed_out = false;
let exit_code = loop {
let wait_ms = if let Some(deadline) = timeout_deadline {
let now = Instant::now();
if now >= deadline {
if !kill_sent {
timed_out = true;
self.kill_javascript_child_process(
vm_id,
process_id,
&child_process_id,
&timeout_signal,
)?;
kill_sent = true;
}
0
} else {
u64::try_from(deadline.saturating_duration_since(now).as_millis().min(50))
.unwrap_or(50)
}
} else {
50
};
let event =
self.poll_javascript_child_process(vm_id, process_id, &child_process_id, wait_ms)?;
if event.is_null() {
continue;
}
match event.get("type").and_then(Value::as_str) {
Some("stdout") => {
let chunk = javascript_sync_rpc_bytes_arg(
&[event.get("data").cloned().unwrap_or(Value::Null)],
0,
"child_process.spawn_sync stdout",
)?;
stdout.extend_from_slice(&chunk);
if stdout.len() > max_buffer && !kill_sent {
max_buffer_exceeded = true;
self.kill_javascript_child_process(
vm_id,
process_id,
&child_process_id,
"SIGTERM",
)?;
kill_sent = true;
}
}
Some("stderr") => {
let chunk = javascript_sync_rpc_bytes_arg(
&[event.get("data").cloned().unwrap_or(Value::Null)],
0,
"child_process.spawn_sync stderr",
)?;
stderr.extend_from_slice(&chunk);
if stderr.len() > max_buffer && !kill_sent {
max_buffer_exceeded = true;
self.kill_javascript_child_process(
vm_id,
process_id,
&child_process_id,
"SIGTERM",
)?;
kill_sent = true;
}
}
Some("exit") => {
break event
.get("exitCode")
.and_then(Value::as_i64)
.map(|value| value as i32)
.unwrap_or(1);
}
_ => {}
}
};
Ok(json!({
"stdout": String::from_utf8_lossy(&stdout),
"stderr": String::from_utf8_lossy(&stderr),
"code": exit_code,
"signal": if timed_out { Value::String(timeout_signal) } else { Value::Null },
"timedOut": timed_out,
"maxBufferExceeded": max_buffer_exceeded,
}))
}
fn spawn_descendant_javascript_child_process(
&mut self,
vm_id: &str,
process_id: &str,
current_process_path: &[&str],
request: JavascriptChildProcessSpawnRequest,
) -> Result<Value, SidecarError> {
let current_process_label =
Self::child_process_path_label(process_id, current_process_path);
let (resolved, parent_kernel_pid) = {
let vm = self.vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?;
let root = vm
.active_processes
.get(process_id)
.ok_or_else(|| missing_process_error(vm_id, process_id))?;
let parent =
Self::active_process_by_path(root, current_process_path).ok_or_else(|| {
SidecarError::InvalidState(format!(
"unknown child process path {current_process_label} during nested spawn"
))
})?;
(
self.resolve_javascript_child_process_execution(
vm,
&parent.env,
&parent.guest_cwd,
&parent.host_cwd,
&request,
)?,
parent.kernel_pid,
)
};
let sidecar_requests = self.sidecar_requests.clone();
let vm = self
.vms
.get_mut(vm_id)
.ok_or_else(|| missing_vm_error(vm_id))?;
let child_process_id = {
let root = vm
.active_processes
.get_mut(process_id)
.ok_or_else(|| missing_process_error(vm_id, process_id))?;
let parent =
Self::active_process_by_path_mut(root, current_process_path).ok_or_else(|| {
SidecarError::InvalidState(format!(
"unknown child process path {current_process_label} during nested spawn"
))
})?;
parent.allocate_child_process_id()
};
let mut child_path = current_process_path.to_vec();
child_path.push(child_process_id.as_str());
let (kernel_pid, kernel_handle, execution, kernel_stdin_writer_fd) = if resolved
.tool_command
{
let tool_resolution = resolve_tool_command(
vm,
&resolved.command,
&resolved.execution_args,
Some(&resolved.guest_cwd),
)?
.ok_or_else(|| {
SidecarError::InvalidState(format!(
"tool command no longer resolves: {}",
resolved.command
))
})?;
let kernel_handle = vm
.kernel
.create_virtual_process(
EXECUTION_DRIVER_NAME,
TOOL_DRIVER_NAME,
&resolved.command,
resolved.process_args.clone(),
VirtualProcessOptions {
parent_pid: Some(parent_kernel_pid),
env: resolved.env.clone(),
cwd: Some(resolved.guest_cwd.clone()),
},
)
.map_err(kernel_error)?;
let kernel_pid = kernel_handle.pid();
let tool_execution = ToolExecution::default();
let cancelled = tool_execution.cancelled.clone();
let pending_events = tool_execution.pending_events.clone();
let events_overflowed = tool_execution.events_overflowed.clone();
spawn_tool_process_events(ToolProcessEventRequest {
sidecar_requests: sidecar_requests.clone(),
connection_id: vm.connection_id.clone(),
session_id: vm.session_id.clone(),
vm_id: vm_id.to_owned(),
tool_resolution,
cancelled,
pending_events,
events_overflowed,
});
(
kernel_pid,
kernel_handle,
ActiveExecution::Tool(tool_execution),
None,
)
} else {
let kernel_command = match resolved.runtime {
GuestRuntimeKind::JavaScript => JAVASCRIPT_COMMAND,
GuestRuntimeKind::WebAssembly => WASM_COMMAND,
GuestRuntimeKind::Python => {
unreachable!("python child_process execution is rejected")
}
};
let kernel_handle = vm
.kernel
.spawn_process(
kernel_command,
resolved.process_args.clone(),
SpawnOptions {
requester_driver: Some(String::from(EXECUTION_DRIVER_NAME)),
parent_pid: Some(parent_kernel_pid),
env: resolved.env.clone(),
cwd: Some(resolved.guest_cwd.clone()),
},
)
.map_err(kernel_error)?;
let kernel_pid = kernel_handle.pid();
if request.options.detached {
vm.kernel
.setsid(EXECUTION_DRIVER_NAME, kernel_pid)
.map_err(kernel_error)?;
}
let mut execution_env = resolved.env.clone();
execution_env.insert(
String::from(EXECUTION_SANDBOX_ROOT_ENV),
normalize_host_path(&vm.cwd).to_string_lossy().into_owned(),
);
let execution = match resolved.runtime {
GuestRuntimeKind::JavaScript => {
execution_env.extend(sanitize_javascript_child_process_internal_bootstrap_env(
&request.options.internal_bootstrap_env,
));
execution_env.insert(
String::from("SECURE_EXEC_KEEP_STDIN_OPEN"),
String::from("1"),
);
let context =
self.javascript_engine
.create_context(CreateJavascriptContextRequest {
vm_id: vm_id.to_owned(),
bootstrap_module: None,
compile_cache_root: Some(
self.cache_root.join("node-compile-cache"),
),
});
let inline_code = load_javascript_entrypoint_source(
vm,
&resolved.host_cwd,
&resolved.entrypoint,
&execution_env,
);
prepare_javascript_shadow(vm, &resolved)?;
let module_reader = build_module_reader(vm, &resolved)
.map(|reader| Box::new(reader) as Box<dyn ModuleFsReader + Send>);
let execution = self
.javascript_engine
.start_execution_with_module_reader(
StartJavascriptExecutionRequest {
guest_runtime: guest_runtime_identity(
vm,
Some(u64::from(kernel_pid)),
Some(u64::from(parent_kernel_pid)),
),
vm_id: vm_id.to_owned(),
context_id: context.context_id,
argv: std::iter::once(resolved.entrypoint.clone())
.chain(resolved.execution_args.clone())
.collect(),
env: execution_env,
cwd: resolved.host_cwd.clone(),
limits: javascript_execution_limits(vm),
inline_code,
},
module_reader,
)
.map_err(javascript_error)?;
ActiveExecution::Javascript(execution)
}
GuestRuntimeKind::WebAssembly => {
execution_env.insert(String::from(WASM_STDIO_SYNC_RPC_ENV), String::from("1"));
let wasm_limits = wasm_execution_limits(vm);
let wasm_guest_runtime = guest_runtime_identity(
vm,
Some(u64::from(kernel_pid)),
Some(u64::from(parent_kernel_pid)),
);
let context = self.wasm_engine.create_context(CreateWasmContextRequest {
vm_id: vm_id.to_owned(),
module_path: Some(resolved.entrypoint.clone()),
});
let execution = self
.wasm_engine
.start_execution(StartWasmExecutionRequest {
vm_id: vm_id.to_owned(),
context_id: context.context_id,
argv: resolved.process_args.clone(),
env: execution_env,
cwd: resolved.host_cwd.clone(),
permission_tier: execution_wasm_permission_tier(
resolved
.wasm_permission_tier
.unwrap_or(WasmPermissionTier::Full),
),
limits: wasm_limits,
guest_runtime: wasm_guest_runtime,
})
.map_err(wasm_error)?;
ActiveExecution::Wasm(Box::new(execution))
}
GuestRuntimeKind::Python => {
unreachable!("python child_process execution is rejected")
}
};
let kernel_stdin_writer_fd = match javascript_child_process_stdin_mode(&request) {
"pipe" => Some(install_kernel_stdin_pipe(&mut vm.kernel, kernel_pid)?),
"ignore" => {
vm.kernel
.fd_close(EXECUTION_DRIVER_NAME, kernel_pid, 0)
.map_err(kernel_error)?;
None
}
"inherit" => None,
_ => Some(install_kernel_stdin_pipe(&mut vm.kernel, kernel_pid)?),
};
(kernel_pid, kernel_handle, execution, kernel_stdin_writer_fd)
};
let root = vm
.active_processes
.get_mut(process_id)
.ok_or_else(|| missing_process_error(vm_id, process_id))?;
let parent =
Self::active_process_by_path_mut(root, current_process_path).ok_or_else(|| {
SidecarError::InvalidState(format!(
"unknown child process path {current_process_label} during nested spawn"
))
})?;
parent.child_processes.insert(
child_process_id.clone(),
ActiveProcess::new(kernel_pid, kernel_handle, resolved.runtime, execution)
.with_detached(request.options.detached)
.with_guest_cwd(resolved.guest_cwd.clone())
.with_env(resolved.env.clone())
.with_host_cwd(resolved.host_cwd.clone()),
);
if let Some(kernel_stdin_writer_fd) = kernel_stdin_writer_fd {
parent
.child_processes
.get_mut(&child_process_id)
.ok_or_else(|| {
SidecarError::InvalidState(format!(
"child process {child_process_id} disappeared during nested spawn"
))
})?
.kernel_stdin_writer_fd = Some(kernel_stdin_writer_fd);
}
Ok(json!({
"childId": child_process_id,
"pid": kernel_pid,
"command": resolved.command,
"args": resolved.process_args,
}))
}
fn spawn_descendant_javascript_child_process_sync(
&mut self,
vm_id: &str,
process_id: &str,
current_process_path: &[&str],
request: JavascriptChildProcessSpawnRequest,
max_buffer: Option<usize>,
) -> Result<Value, SidecarError> {
let sync_input = javascript_child_process_sync_input_bytes(request.options.input.as_ref())?;
let timeout_deadline = request
.options
.timeout
.map(|timeout_ms| Instant::now() + Duration::from_millis(timeout_ms));
let timeout_signal = request
.options
.kill_signal
.clone()
.unwrap_or_else(|| String::from("SIGTERM"));
let spawned = self.spawn_descendant_javascript_child_process(
vm_id,
process_id,
current_process_path,
request,
)?;
let child_process_id = spawned
.get("childId")
.and_then(Value::as_str)
.ok_or_else(|| {
SidecarError::InvalidState(String::from(
"child_process.spawn_sync response is missing childId",
))
})?
.to_owned();
if let Some(input) = sync_input.as_deref() {
self.write_descendant_javascript_child_process_stdin(
vm_id,
process_id,
current_process_path,
&child_process_id,
input,
)?;
}
self.close_descendant_javascript_child_process_stdin(
vm_id,
process_id,
current_process_path,
&child_process_id,
)?;
let max_buffer = max_buffer.unwrap_or(1024 * 1024);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut max_buffer_exceeded = false;
let mut kill_sent = false;
let mut timed_out = false;
let exit_code = loop {
let wait_ms = if let Some(deadline) = timeout_deadline {
let now = Instant::now();
if now >= deadline {
if !kill_sent {
timed_out = true;
self.kill_descendant_javascript_child_process(
vm_id,
process_id,
current_process_path,
&child_process_id,
&timeout_signal,
)?;
kill_sent = true;
}
0
} else {
u64::try_from(deadline.saturating_duration_since(now).as_millis().min(50))
.unwrap_or(50)
}
} else {
50
};
let event = self.poll_descendant_javascript_child_process(
vm_id,
process_id,
current_process_path,
&child_process_id,
wait_ms,
)?;
if event.is_null() {
continue;
}
match event.get("type").and_then(Value::as_str) {
Some("stdout") => {
let chunk = javascript_sync_rpc_bytes_arg(
&[event.get("data").cloned().unwrap_or(Value::Null)],
0,
"child_process.spawn_sync stdout",
)?;
stdout.extend_from_slice(&chunk);
if stdout.len() > max_buffer && !kill_sent {
max_buffer_exceeded = true;
self.kill_descendant_javascript_child_process(
vm_id,
process_id,
current_process_path,
&child_process_id,
"SIGTERM",
)?;
kill_sent = true;
}
}
Some("stderr") => {
let chunk = javascript_sync_rpc_bytes_arg(
&[event.get("data").cloned().unwrap_or(Value::Null)],
0,
"child_process.spawn_sync stderr",
)?;
stderr.extend_from_slice(&chunk);
if stderr.len() > max_buffer && !kill_sent {
max_buffer_exceeded = true;
self.kill_descendant_javascript_child_process(
vm_id,
process_id,
current_process_path,
&child_process_id,
"SIGTERM",
)?;
kill_sent = true;
}
}
Some("exit") => {
break event
.get("exitCode")
.and_then(Value::as_i64)
.map(|value| value as i32)
.unwrap_or(1);
}
_ => {}
}
};
Ok(json!({
"stdout": String::from_utf8_lossy(&stdout),
"stderr": String::from_utf8_lossy(&stderr),
"code": exit_code,
"signal": if timed_out { Value::String(timeout_signal) } else { Value::Null },
"timedOut": timed_out,
"maxBufferExceeded": max_buffer_exceeded,
}))
}
fn handle_descendant_javascript_child_process_rpc(
&mut self,
vm_id: &str,
process_id: &str,
current_process_path: &[&str],
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
match request.method.as_str() {
"child_process.spawn" => {
let Some(vm) = self.vms.get(vm_id) else {
return Ok(Value::Null);
};
let (payload, _) = parse_javascript_child_process_spawn_request(vm, &request.args)?;
self.spawn_descendant_javascript_child_process(
vm_id,
process_id,
current_process_path,
payload,
)
}
"child_process.spawn_sync" => {
let Some(vm) = self.vms.get(vm_id) else {
return Ok(Value::Null);
};
let (payload, max_buffer) =
parse_javascript_child_process_spawn_request(vm, &request.args)?;
self.spawn_descendant_javascript_child_process_sync(
vm_id,
process_id,
current_process_path,
payload,
max_buffer,
)
}
"child_process.poll" => {
let child_process_id =
javascript_sync_rpc_arg_str(&request.args, 0, "child_process.poll child id")?;
let wait_ms = javascript_sync_rpc_arg_u64_optional(
&request.args,
1,
"child_process.poll wait ms",
)?
.unwrap_or_default();
self.poll_descendant_javascript_child_process(
vm_id,
process_id,
current_process_path,
child_process_id,
wait_ms,
)
}
"child_process.write_stdin" => {
let child_process_id = javascript_sync_rpc_arg_str(
&request.args,
0,
"child_process.write_stdin child id",
)?;
let chunk = javascript_sync_rpc_bytes_arg(
&request.args,
1,
"child_process.write_stdin chunk",
)?;
self.write_descendant_javascript_child_process_stdin(
vm_id,
process_id,
current_process_path,
child_process_id,
&chunk,
)?;
Ok(Value::Null)
}
"child_process.close_stdin" => {
let child_process_id = javascript_sync_rpc_arg_str(
&request.args,
0,
"child_process.close_stdin child id",
)?;
self.close_descendant_javascript_child_process_stdin(
vm_id,
process_id,
current_process_path,
child_process_id,
)?;
Ok(Value::Null)
}
"child_process.kill" => {
let child_process_id =
javascript_sync_rpc_arg_str(&request.args, 0, "child_process.kill child id")?;
let signal =
javascript_sync_rpc_arg_str(&request.args, 1, "child_process.kill signal")?;
self.kill_descendant_javascript_child_process(
vm_id,
process_id,
current_process_path,
child_process_id,
signal,
)?;
Ok(Value::Null)
}
_ => Err(SidecarError::InvalidState(format!(
"unsupported nested child process RPC method {}",
request.method
))),
}
}
fn poll_descendant_javascript_child_process(
&mut self,
vm_id: &str,
process_id: &str,
current_process_path: &[&str],
child_process_id: &str,
wait_ms: u64,
) -> Result<Value, SidecarError> {
let mut child_path = current_process_path.to_vec();
child_path.push(child_process_id);
let child_gone_error = || javascript_child_process_gone_error(process_id, &child_path);
let deadline = Instant::now() + Duration::from_millis(wait_ms);
let mut polled_once = false;
loop {
self.drain_queued_descendant_javascript_child_process_events(
vm_id,
process_id,
&child_path,
)?;
enum ChildPollResult {
Event(Box<Option<ActiveExecutionEvent>>),
RecoverRuntimeExit,
Timeout,
}
let wait = if wait_ms == 0 {
Duration::ZERO
} else {
deadline.saturating_duration_since(Instant::now())
};
let poll_result = {
let Some(vm) = self.vms.get_mut(vm_id) else {
return Ok(Value::Null);
};
let Some(parent) =
Self::descendant_parent_process_mut(vm, process_id, current_process_path)
else {
return Err(child_gone_error());
};
let Some(child) = parent.child_processes.get_mut(child_process_id) else {
return Err(child_gone_error());
};
if let Some(event) = child.pending_execution_events.pop_front() {
ChildPollResult::Event(Box::new(Some(event)))
} else if polled_once && wait.is_zero() {
ChildPollResult::Timeout
} else {
polled_once = true;
match child.execution.poll_event_blocking(wait) {
Ok(Some(event)) => ChildPollResult::Event(Box::new(Some(event))),
Ok(None) => ChildPollResult::RecoverRuntimeExit,
Err(SidecarError::Execution(message))
if (child.runtime == GuestRuntimeKind::JavaScript
&& closed_javascript_event_channel(&message))
|| (child.runtime == GuestRuntimeKind::Python
&& closed_python_event_channel(&message))
|| (child.runtime == GuestRuntimeKind::WebAssembly
&& closed_wasm_event_channel(&message)) =>
{
ChildPollResult::RecoverRuntimeExit
}
Err(error) => return Err(error),
}
}
};
let event = match poll_result {
ChildPollResult::Event(event) => *event,
ChildPollResult::Timeout => return Ok(Value::Null),
ChildPollResult::RecoverRuntimeExit => self
.recover_descendant_runtime_child_process_event(
vm_id,
process_id,
current_process_path,
child_process_id,
wait.as_millis().try_into().unwrap_or(u64::MAX),
)?,
};
let Some(event) = event else {
return Ok(Value::Null);
};
match event {
ActiveExecutionEvent::Stdout(chunk) => {
return Ok(json!({
"type": "stdout",
"data": javascript_sync_rpc_bytes_value(&chunk),
}));
}
ActiveExecutionEvent::Stderr(chunk) => {
return Ok(json!({
"type": "stderr",
"data": javascript_sync_rpc_bytes_value(&chunk),
}));
}
ActiveExecutionEvent::Exited(exit_code) => {
let had_trailing_events = {
let Some(vm) = self.vms.get_mut(vm_id) else {
return Ok(Value::Null);
};
let Some(parent) = Self::descendant_parent_process_mut(
vm,
process_id,
current_process_path,
) else {
return Ok(Value::Null);
};
let Some(child) = parent.child_processes.get_mut(child_process_id) else {
return Ok(Value::Null);
};
let deadline = Instant::now() + Duration::from_millis(150);
loop {
let wait = deadline.saturating_duration_since(Instant::now());
let next = poll_child_execution_after_exit(child, wait)?;
let Some(next) = next else {
break;
};
if matches!(next, ActiveExecutionEvent::Exited(_)) {
continue;
}
child.queue_pending_execution_event(next)?;
if Instant::now() >= deadline {
break;
}
}
if !child.pending_execution_events.is_empty() {
child.queue_pending_execution_event(ActiveExecutionEvent::Exited(
exit_code,
))?;
true
} else {
false
}
};
if had_trailing_events {
continue;
}
let parent_signal_key =
Self::child_process_signal_key(process_id, current_process_path);
let Some(vm) = self.vms.get_mut(vm_id) else {
return Ok(Value::Null);
};
let signal_name = {
let Some(parent) = Self::descendant_parent_process_mut(
vm,
process_id,
current_process_path,
) else {
return Ok(Value::Null);
};
let Some(child) = parent.child_processes.get_mut(child_process_id) else {
return Ok(Value::Null);
};
child.pending_self_signal_exit.take().and_then(|signal| {
if exit_code == 128 + signal {
canonical_signal_name(signal).map(str::to_owned)
} else {
None
}
})
};
let (parent_runtime_pid, parent_v8_signal_session, should_signal_parent) = {
let Some(parent) =
Self::descendant_parent_process(vm, process_id, current_process_path)
else {
return Ok(Value::Null);
};
(
parent.execution.child_pid(),
parent.execution.javascript_v8_session_handle().filter(|_| {
matches!(
&parent.execution,
ActiveExecution::Javascript(execution)
if execution.uses_shared_v8_runtime()
)
}),
vm.signal_states
.get(parent_signal_key)
.and_then(|handlers| handlers.get(&(libc::SIGCHLD as u32)))
.is_some_and(|registration| {
registration.action != SignalDispositionAction::Default
}),
)
};
let Some(parent) =
Self::descendant_parent_process_mut(vm, process_id, current_process_path)
else {
return Ok(Value::Null);
};
let Some(mut child) = parent.child_processes.remove(child_process_id) else {
return Ok(Value::Null);
};
let child_process_label =
Self::child_process_path_label(process_id, &child_path);
let detached_children =
Self::adopt_detached_child_processes(&child_process_label, &mut child);
sync_process_host_writes_to_kernel(vm, &child)?;
terminate_child_process_tree(&mut vm.kernel, &mut child);
child.kernel_handle.finish(exit_code);
let _ = vm.kernel.wait_and_reap(child.kernel_pid);
vm.signal_states.remove(child_process_id);
for (detached_process_id, detached_child) in detached_children {
vm.detached_child_processes
.insert(detached_process_id.clone());
vm.active_processes
.insert(detached_process_id, detached_child);
}
if should_signal_parent {
if let Some(session) = parent_v8_signal_session {
dispatch_v8_session_signal_async(session, libc::SIGCHLD);
} else {
signal_runtime_process(parent_runtime_pid, libc::SIGCHLD)?;
}
}
let mut payload = Map::new();
payload.insert(String::from("type"), Value::String(String::from("exit")));
payload.insert(String::from("exitCode"), Value::from(exit_code));
if let Some(signal_name) = signal_name {
payload.insert(String::from("signal"), Value::String(signal_name));
}
return Ok(Value::Object(payload));
}
ActiveExecutionEvent::JavascriptSyncRpcRequest(request) => {
let mut current_child_path = current_process_path.to_vec();
current_child_path.push(child_process_id);
let response = if request.method == "process.signal_state" {
let (signal, registration) =
parse_process_signal_state_request(&request.args)?;
let Some(vm) = self.vms.get_mut(vm_id) else {
return Ok(Value::Null);
};
let signal_key =
Self::child_process_signal_key(process_id, ¤t_child_path)
.to_owned();
apply_process_signal_state_update(
&mut vm.signal_states,
&signal_key,
signal,
registration,
);
Ok(Value::Null)
} else if request.method == "process.kill" {
self.handle_descendant_process_kill_rpc(
vm_id,
process_id,
current_process_path,
child_process_id,
&request,
)
} else if request.method.starts_with("child_process.") {
self.handle_descendant_javascript_child_process_rpc(
vm_id,
process_id,
¤t_child_path,
&request,
)
} else {
let Some(vm) = self.vms.get_mut(vm_id) else {
return Ok(Value::Null);
};
let resource_limits = vm.kernel.resource_limits().clone();
let network_counts = vm_network_resource_counts(vm);
let socket_paths = build_javascript_socket_path_context(vm)?;
let Some(root) = vm.active_processes.get_mut(process_id) else {
return Ok(Value::Null);
};
let Some(parent) =
Self::active_process_by_path_mut(root, current_process_path)
else {
return Ok(Value::Null);
};
let Some(child) = parent.child_processes.get_mut(child_process_id) else {
return Ok(Value::Null);
};
service_javascript_sync_rpc(JavascriptSyncRpcServiceRequest {
bridge: &self.bridge,
vm_id,
dns: &vm.dns,
socket_paths: &socket_paths,
kernel: &mut vm.kernel,
process: child,
sync_request: &request,
resource_limits: &resource_limits,
network_counts,
})
};
let Some(vm) = self.vms.get_mut(vm_id) else {
return Ok(Value::Null);
};
let Some(parent) =
Self::descendant_parent_process_mut(vm, process_id, current_process_path)
else {
return Ok(Value::Null);
};
let Some(child) = parent.child_processes.get_mut(child_process_id) else {
return Ok(Value::Null);
};
let parent_signal_event = response.as_ref().ok().and_then(|result| {
let target_path_label =
Self::child_process_path_label(process_id, current_process_path);
if request.method != "process.kill"
|| result.get("action").and_then(Value::as_str) != Some("user")
|| result.get("targetProcessPath").and_then(Value::as_str)
!= Some(target_path_label.as_str())
{
return None;
}
Some(json!({
"type": "signal",
"signal": result.get("signal").and_then(Value::as_str).unwrap_or_default(),
"number": result.get("number").and_then(Value::as_i64).unwrap_or_default(),
}))
});
match response {
Ok(result) => child
.execution
.respond_javascript_sync_rpc_success(request.id, result)
.or_else(ignore_stale_javascript_sync_rpc_response)?,
Err(error) => child
.execution
.respond_javascript_sync_rpc_error(
request.id,
javascript_sync_rpc_error_code(&error),
error.to_string(),
)
.or_else(ignore_stale_javascript_sync_rpc_response)?,
}
if let Some(event) = parent_signal_event {
return Ok(event);
}
}
ActiveExecutionEvent::PythonVfsRpcRequest(_) => {
return Err(SidecarError::InvalidState(String::from(
"nested Python child_process execution is not supported yet",
)));
}
ActiveExecutionEvent::SignalState {
signal,
registration,
} => {
let Some(vm) = self.vms.get_mut(vm_id) else {
return Ok(Value::Null);
};
let signal_key =
Self::child_process_signal_key(process_id, &child_path).to_owned();
apply_process_signal_state_update(
&mut vm.signal_states,
&signal_key,
signal,
registration.clone(),
);
return Ok(json!({
"type": "signal_state",
"signal": signal,
"registration": registration,
}));
}
}
}
}
fn recover_descendant_runtime_child_process_event(
&mut self,
vm_id: &str,
process_id: &str,
current_process_path: &[&str],
child_process_id: &str,
wait_ms: u64,
) -> Result<Option<ActiveExecutionEvent>, SidecarError> {
let (
parent_kernel_pid,
child_kernel_pid,
child_runtime_pid,
child_runtime,
child_shared_runtime,
) = {
let mut child_path = current_process_path.to_vec();
child_path.push(child_process_id);
let Some(vm) = self.vms.get_mut(vm_id) else {
return Ok(None);
};
let Some(parent) =
Self::descendant_parent_process_mut(vm, process_id, current_process_path)
else {
return Err(javascript_child_process_gone_error(process_id, &child_path));
};
let Some(child) = parent.child_processes.get_mut(child_process_id) else {
return Err(javascript_child_process_gone_error(process_id, &child_path));
};
(
parent.kernel_pid,
child.kernel_pid,
child.execution.child_pid(),
child.runtime.clone(),
child.execution.uses_shared_v8_runtime(),
)
};
if child_runtime != GuestRuntimeKind::JavaScript
&& child_runtime != GuestRuntimeKind::Python
&& child_runtime != GuestRuntimeKind::WebAssembly
{
return Ok(None);
}
let wait_deadline = Instant::now() + Duration::from_millis(wait_ms.min(25));
loop {
let Some(vm) = self.vms.get_mut(vm_id) else {
return Ok(None);
};
if let Some(process_info) = vm.kernel.list_processes().get(&child_kernel_pid) {
if process_info.status == ProcessStatus::Exited {
return Ok(Some(ActiveExecutionEvent::Exited(
process_info.exit_code.unwrap_or(0),
)));
}
}
if let Some(wait_result) = vm
.kernel
.waitpid_with_options(
EXECUTION_DRIVER_NAME,
parent_kernel_pid,
child_kernel_pid as i32,
WaitPidFlags::WNOHANG,
)
.map_err(kernel_error)?
{
return Ok(Some(ActiveExecutionEvent::Exited(wait_result.status)));
}
if !child_shared_runtime && child_runtime_pid != 0 {
if let Some(status) = runtime_child_exit_status(child_runtime_pid)? {
return Ok(Some(ActiveExecutionEvent::Exited(status)));
}
if !runtime_child_is_alive(child_runtime_pid)? {
return Ok(Some(ActiveExecutionEvent::Exited(0)));
}
}
if Instant::now() >= wait_deadline {
return Ok(None);
}
std::thread::sleep(Duration::from_millis(5));
}
}
fn write_descendant_javascript_child_process_stdin(
&mut self,
vm_id: &str,
process_id: &str,
current_process_path: &[&str],
child_process_id: &str,
chunk: &[u8],
) -> Result<(), SidecarError> {
let mut child_path = current_process_path.to_vec();
child_path.push(child_process_id);
let Some(vm) = self.vms.get_mut(vm_id) else {
return Err(javascript_child_process_gone_error(process_id, &child_path));
};
let Some(root) = vm.active_processes.get_mut(process_id) else {
return Err(javascript_child_process_gone_error(process_id, &child_path));
};
let Some(parent) = Self::active_process_by_path_mut(root, current_process_path) else {
return Err(javascript_child_process_gone_error(process_id, &child_path));
};
let Some(child) = parent.child_processes.get_mut(child_process_id) else {
return Err(javascript_child_process_gone_error(process_id, &child_path));
};
if let Err(error) = child.execution.write_stdin(chunk) {
if is_broken_pipe_error(&error) {
return Ok(());
}
return Err(error);
}
write_kernel_process_stdin(&mut vm.kernel, child, chunk)
}
fn close_descendant_javascript_child_process_stdin(
&mut self,
vm_id: &str,
process_id: &str,
current_process_path: &[&str],
child_process_id: &str,
) -> Result<(), SidecarError> {
let mut child_path = current_process_path.to_vec();
child_path.push(child_process_id);
let Some(vm) = self.vms.get_mut(vm_id) else {
return Err(javascript_child_process_gone_error(process_id, &child_path));
};
let Some(root) = vm.active_processes.get_mut(process_id) else {
return Err(javascript_child_process_gone_error(process_id, &child_path));
};
let Some(parent) = Self::active_process_by_path_mut(root, current_process_path) else {
return Err(javascript_child_process_gone_error(process_id, &child_path));
};
let Some(child) = parent.child_processes.get_mut(child_process_id) else {
return Err(javascript_child_process_gone_error(process_id, &child_path));
};
child.execution.close_stdin()?;
close_kernel_process_stdin(&mut vm.kernel, child)
}
fn kill_descendant_javascript_child_process(
&mut self,
vm_id: &str,
process_id: &str,
current_process_path: &[&str],
child_process_id: &str,
signal: &str,
) -> Result<(), SidecarError> {
let signal_name = signal.to_owned();
let signal = parse_signal(signal)?;
let Some(vm) = self.vms.get_mut(vm_id) else {
return Ok(());
};
let Some(root) = vm.active_processes.get_mut(process_id) else {
return Ok(());
};
let Some(parent) = Self::active_process_by_path_mut(root, current_process_path) else {
return Ok(());
};
let source_pid = parent.kernel_pid;
let Some(child) = parent.child_processes.get_mut(child_process_id) else {
return Ok(());
};
terminate_tracked_child_process_for_signal(&mut vm.kernel, child, signal)?;
let child_process_label = if current_process_path.is_empty() {
child_process_id.to_owned()
} else {
format!("{}/{}", current_process_path.join("/"), child_process_id)
};
emit_security_audit_event(
&self.bridge,
vm_id,
"security.process.kill",
audit_fields([
(String::from("source"), String::from("guest_child_process")),
(String::from("source_pid"), source_pid.to_string()),
(String::from("target_pid"), child.kernel_pid.to_string()),
(String::from("process_id"), process_id.to_owned()),
(String::from("child_process_id"), child_process_label),
(String::from("signal"), signal_name),
]),
);
Ok(())
}
fn handle_descendant_process_kill_rpc(
&mut self,
vm_id: &str,
process_id: &str,
current_process_path: &[&str],
child_process_id: &str,
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
let target_pid = javascript_sync_rpc_arg_i32(&request.args, 0, "process.kill target pid")?;
let signal_name = javascript_sync_rpc_arg_str(&request.args, 1, "process.kill signal")?;
let signal = parse_signal(signal_name)?;
let mut source_path = current_process_path.to_vec();
source_path.push(child_process_id);
if signal != 0 && target_pid < 0 {
let pgid = target_pid.unsigned_abs();
let caller_kernel_pid = {
let Some(vm) = self.vms.get(vm_id) else {
return Err(SidecarError::InvalidState(String::from(
"ESRCH: unknown VM during process.kill",
)));
};
let Some(root) = vm.active_processes.get(process_id) else {
return Err(SidecarError::InvalidState(format!(
"ESRCH: unknown process {process_id} during process.kill",
)));
};
let Some(source) = Self::active_process_by_path(root, &source_path) else {
return Err(SidecarError::InvalidState(format!(
"ESRCH: unknown child process {child_process_id} during process.kill",
)));
};
source.kernel_pid
};
let caller_is_member =
self.signal_vm_process_group(vm_id, caller_kernel_pid, pgid, signal_name)?;
if !caller_is_member {
return Ok(Value::Null);
}
let Some(vm) = self.vms.get_mut(vm_id) else {
return Ok(Value::Null);
};
let Some(root) = vm.active_processes.get_mut(process_id) else {
return Ok(Value::Null);
};
let Some(source) = Self::active_process_by_path_mut(root, &source_path) else {
return Ok(Value::Null);
};
source.pending_self_signal_exit = None;
if !matches!(
canonical_signal_name(signal),
Some("SIGWINCH" | "SIGCHLD" | "SIGCONT" | "SIGURG")
) {
source.pending_self_signal_exit = Some(signal);
}
return Ok(json!({
"self": true,
"action": "default",
}));
}
let Some(vm) = self.vms.get_mut(vm_id) else {
return Err(SidecarError::InvalidState(String::from(
"ESRCH: unknown VM during process.kill",
)));
};
if signal == 0 {
vm.kernel
.signal_process(EXECUTION_DRIVER_NAME, target_pid, signal)
.map_err(kernel_error)?;
return Ok(Value::Null);
}
let target_kernel_pid = u32::try_from(target_pid).map_err(|_| {
SidecarError::InvalidState(format!("EINVAL: invalid process pid {target_pid}"))
})?;
let (source_pid, located_target_path) = {
let Some(root) = vm.active_processes.get(process_id) else {
return Err(SidecarError::InvalidState(format!(
"ESRCH: unknown process {process_id} during process.kill",
)));
};
let Some(source) = Self::active_process_by_path(root, &source_path) else {
return Err(SidecarError::InvalidState(format!(
"ESRCH: unknown child process {child_process_id} during process.kill",
)));
};
vm.kernel
.signal_process(EXECUTION_DRIVER_NAME, target_pid, 0)
.map_err(kernel_error)?;
(
source.kernel_pid,
Self::active_process_path_by_kernel_pid(root, target_kernel_pid),
)
};
let Some(target_path) = located_target_path else {
// The target is alive but not part of this root's process tree.
// Resolve it VM-wide so cross-tree pids and untracked kernel
// processes still receive the signal.
self.signal_vm_kernel_pid(vm_id, target_kernel_pid, signal_name)?;
return Ok(Value::Null);
};
let Some(vm) = self.vms.get_mut(vm_id) else {
return Err(SidecarError::InvalidState(String::from(
"ESRCH: unknown VM during process.kill",
)));
};
if source_pid == target_kernel_pid {
let Some(root) = vm.active_processes.get_mut(process_id) else {
return Ok(Value::Null);
};
let Some(source) = Self::active_process_by_path_mut(root, &source_path) else {
return Ok(Value::Null);
};
source.pending_self_signal_exit = None;
if !matches!(
canonical_signal_name(signal),
Some("SIGWINCH" | "SIGCHLD" | "SIGCONT" | "SIGURG")
) {
source.pending_self_signal_exit = Some(signal);
}
return Ok(json!({
"self": true,
"action": "default",
}));
}
let signal_key = target_path.last().map(String::as_str).unwrap_or(process_id);
let registration = vm
.signal_states
.get(signal_key)
.and_then(|handlers| handlers.get(&(signal as u32)))
.cloned();
let action = match registration
.as_ref()
.map(|registration| ®istration.action)
{
Some(SignalDispositionAction::Ignore) => "ignore",
Some(SignalDispositionAction::User) => {
let Some(root) = vm.active_processes.get_mut(process_id) else {
return Ok(Value::Null);
};
let Some(target) = Self::active_process_by_owned_path_mut(root, &target_path)
else {
return Err(SidecarError::InvalidState(format!(
"ESRCH: unknown process pid {target_pid}"
)));
};
if let Some(session) = target.execution.javascript_v8_session_handle().filter(
|_| matches!(&target.execution, ActiveExecution::Javascript(execution) if execution.uses_shared_v8_runtime())
|| matches!(&target.execution, ActiveExecution::Wasm(execution) if execution.uses_shared_v8_runtime()),
) {
dispatch_v8_session_signal_async(session, signal);
} else if !dispatch_v8_process_signal(target, signal)? {
return Err(SidecarError::InvalidState(format!(
"unsupported guest signal delivery for pid {target_pid}"
)));
}
"user"
}
Some(SignalDispositionAction::Default) | None
if matches!(
canonical_signal_name(signal),
Some("SIGWINCH" | "SIGCHLD" | "SIGURG")
) =>
{
"ignore"
}
Some(SignalDispositionAction::Default) | None => {
let Some(root) = vm.active_processes.get_mut(process_id) else {
return Ok(Value::Null);
};
let Some(target) = Self::active_process_by_owned_path_mut(root, &target_path)
else {
return Err(SidecarError::InvalidState(format!(
"ESRCH: unknown process pid {target_pid}"
)));
};
apply_active_process_default_signal(&mut vm.kernel, target, signal)?;
"default"
}
};
let target_path_label = Self::child_process_path_label(
process_id,
&target_path.iter().map(String::as_str).collect::<Vec<_>>(),
);
emit_security_audit_event(
&self.bridge,
vm_id,
"security.process.kill",
audit_fields([
(String::from("source"), String::from("guest_process")),
(String::from("source_pid"), source_pid.to_string()),
(String::from("target_pid"), target_pid.to_string()),
(String::from("process_id"), process_id.to_owned()),
(
String::from("target_process_path"),
target_path_label.clone(),
),
(String::from("signal"), signal_name.to_owned()),
]),
);
Ok(json!({
"self": false,
"action": action,
"signal": signal_name,
"number": signal,
"targetProcessPath": target_path_label,
}))
}
pub(crate) fn poll_javascript_child_process(
&mut self,
vm_id: &str,
process_id: &str,
child_process_id: &str,
wait_ms: u64,
) -> Result<Value, SidecarError> {
self.poll_descendant_javascript_child_process(
vm_id,
process_id,
&[],
child_process_id,
wait_ms,
)
}
pub(crate) fn write_javascript_child_process_stdin(
&mut self,
vm_id: &str,
process_id: &str,
child_process_id: &str,
chunk: &[u8],
) -> Result<(), SidecarError> {
let Some(vm) = self.vms.get_mut(vm_id) else {
return Err(javascript_child_process_gone_error(
process_id,
&[child_process_id],
));
};
let Some(child) = vm
.active_processes
.get_mut(process_id)
.ok_or_else(|| missing_process_error(vm_id, process_id))?
.child_processes
.get_mut(child_process_id)
else {
return Err(javascript_child_process_gone_error(
process_id,
&[child_process_id],
));
};
if let Err(error) = child.execution.write_stdin(chunk) {
if is_broken_pipe_error(&error) {
return Ok(());
}
return Err(error);
}
write_kernel_process_stdin(&mut vm.kernel, child, chunk)
}
pub(crate) fn close_javascript_child_process_stdin(
&mut self,
vm_id: &str,
process_id: &str,
child_process_id: &str,
) -> Result<(), SidecarError> {
let Some(vm) = self.vms.get_mut(vm_id) else {
return Err(javascript_child_process_gone_error(
process_id,
&[child_process_id],
));
};
let Some(child) = vm
.active_processes
.get_mut(process_id)
.ok_or_else(|| missing_process_error(vm_id, process_id))?
.child_processes
.get_mut(child_process_id)
else {
return Err(javascript_child_process_gone_error(
process_id,
&[child_process_id],
));
};
child.execution.close_stdin()?;
close_kernel_process_stdin(&mut vm.kernel, child)
}
pub(crate) fn kill_javascript_child_process(
&mut self,
vm_id: &str,
process_id: &str,
child_process_id: &str,
signal: &str,
) -> Result<(), SidecarError> {
let signal_name = signal.to_owned();
let signal = parse_signal(signal)?;
let Some(vm) = self.vms.get_mut(vm_id) else {
return Ok(());
};
let process = vm
.active_processes
.get_mut(process_id)
.ok_or_else(|| missing_process_error(vm_id, process_id))?;
let source_pid = process.kernel_pid;
let child = process
.child_processes
.get_mut(child_process_id)
.ok_or_else(|| {
SidecarError::InvalidState(format!(
"unknown child process {child_process_id} during kill"
))
})?;
terminate_tracked_child_process_for_signal(&mut vm.kernel, child, signal)?;
emit_security_audit_event(
&self.bridge,
vm_id,
"security.process.kill",
audit_fields([
(String::from("source"), String::from("guest_child_process")),
(String::from("source_pid"), source_pid.to_string()),
(String::from("target_pid"), child.kernel_pid.to_string()),
(String::from("process_id"), process_id.to_owned()),
(
String::from("child_process_id"),
child_process_id.to_owned(),
),
(String::from("signal"), signal_name),
]),
);
Ok(())
}
/// Delivers a signal to one kernel pid inside a VM, resolving the target
/// through the active-process tree first so tracked sidecar executions get
/// the same termination handling as a direct `child_process.kill`.
/// Untracked kernel processes (for example WASM subprocess trees) receive
/// the signal through the kernel process table directly.
pub(crate) fn signal_vm_kernel_pid(
&mut self,
vm_id: &str,
target_kernel_pid: u32,
signal_name: &str,
) -> Result<(), SidecarError> {
let signal = parse_signal(signal_name)?;
let located = {
let Some(vm) = self.vms.get(vm_id) else {
return Err(SidecarError::InvalidState(String::from(
"ESRCH: unknown VM during process.kill",
)));
};
let alive = vm
.kernel
.list_processes()
.get(&target_kernel_pid)
.is_some_and(|info| info.status != ProcessStatus::Exited);
if !alive {
return Err(SidecarError::InvalidState(format!(
"ESRCH: no such process {target_kernel_pid}"
)));
}
vm.active_processes.iter().find_map(|(process_id, root)| {
Self::active_process_path_by_kernel_pid(root, target_kernel_pid)
.map(|path| (process_id.clone(), path))
})
};
match located {
Some((process_id, path)) if path.is_empty() => {
self.kill_process_internal(vm_id, &process_id, signal_name)
}
Some((process_id, path)) => {
let Some(vm) = self.vms.get_mut(vm_id) else {
return Ok(());
};
let Some(root) = vm.active_processes.get_mut(&process_id) else {
return Ok(());
};
let Some(target) = Self::active_process_by_owned_path_mut(root, &path) else {
return Err(SidecarError::InvalidState(format!(
"ESRCH: no such process {target_kernel_pid}"
)));
};
terminate_tracked_child_process_for_signal(&mut vm.kernel, target, signal)?;
emit_security_audit_event(
&self.bridge,
vm_id,
"security.process.kill",
audit_fields([
(String::from("source"), String::from("guest_process")),
(String::from("target_pid"), target_kernel_pid.to_string()),
(String::from("process_id"), process_id),
(String::from("signal"), signal_name.to_owned()),
]),
);
Ok(())
}
None => {
let Some(vm) = self.vms.get_mut(vm_id) else {
return Ok(());
};
let target_pid = i32::try_from(target_kernel_pid).map_err(|_| {
SidecarError::InvalidState(format!(
"EINVAL: invalid process pid {target_kernel_pid}"
))
})?;
vm.kernel
.signal_process(EXECUTION_DRIVER_NAME, target_pid, signal)
.map_err(kernel_error)?;
emit_security_audit_event(
&self.bridge,
vm_id,
"security.process.kill",
audit_fields([
(String::from("source"), String::from("guest_process")),
(String::from("target_pid"), target_kernel_pid.to_string()),
(String::from("signal"), signal_name.to_owned()),
]),
);
Ok(())
}
}
}
/// Delivers a signal to every live member of a VM process group, matching
/// Linux `kill(-pgid, sig)` semantics. Returns whether the caller itself
/// is a member of the group so entry points can apply self-signal
/// delivery; the caller is intentionally skipped here.
pub(crate) fn signal_vm_process_group(
&mut self,
vm_id: &str,
caller_kernel_pid: u32,
pgid: u32,
signal_name: &str,
) -> Result<bool, SidecarError> {
parse_signal(signal_name)?;
let members = {
let Some(vm) = self.vms.get(vm_id) else {
return Err(SidecarError::InvalidState(String::from(
"ESRCH: unknown VM during process.kill",
)));
};
vm.kernel
.list_processes()
.into_iter()
.filter(|(_, info)| info.pgid == pgid && info.status != ProcessStatus::Exited)
.map(|(pid, _)| pid)
.collect::<Vec<_>>()
};
if members.is_empty() {
return Err(SidecarError::InvalidState(format!(
"ESRCH: no such process group {pgid}"
)));
}
let mut caller_is_member = false;
for member_pid in members {
if member_pid == caller_kernel_pid {
caller_is_member = true;
continue;
}
match self.signal_vm_kernel_pid(vm_id, member_pid, signal_name) {
Ok(()) => {}
// Group members can exit while the group is being signaled. A
// vanished member is not an error for the group kill overall.
Err(error) if sidecar_error_is_esrch(&error) => {}
Err(error) => return Err(error),
}
}
Ok(caller_is_member)
}
}
/// Applies a kill signal to a tracked child execution. Shared-runtime
/// executions for lethal signals are terminated directly with a synthetic
/// signal exit so child polls observe a prompt close; everything else routes
/// through the kernel process table.
fn terminate_tracked_child_process_for_signal(
kernel: &mut SidecarKernel,
child: &mut ActiveProcess,
signal: i32,
) -> Result<(), SidecarError> {
let should_terminate_shared_runtime = child.execution.uses_shared_v8_runtime()
&& signal != 0
&& !matches!(
signal,
libc::SIGHUP
| libc::SIGINT
| libc::SIGTERM
| libc::SIGCHLD
| libc::SIGWINCH
| libc::SIGSTOP
| libc::SIGCONT
);
if should_terminate_shared_runtime {
child.execution.terminate()?;
child.pending_self_signal_exit = Some(signal);
child.queue_pending_execution_event(ActiveExecutionEvent::Exited(128 + signal))?;
} else {
kernel
.kill_process(EXECUTION_DRIVER_NAME, child.kernel_pid, signal)
.map_err(kernel_error)?;
}
Ok(())
}
fn sidecar_error_is_esrch(error: &SidecarError) -> bool {
error.to_string().contains("ESRCH")
}
fn apply_active_process_default_signal(
kernel: &mut SidecarKernel,
process: &mut ActiveProcess,
signal: i32,
) -> Result<(), SidecarError> {
if matches!(signal, libc::SIGSTOP | libc::SIGCONT) {
return kernel
.kill_process(EXECUTION_DRIVER_NAME, process.kernel_pid, signal)
.map_err(kernel_error);
}
if signal != 0 && matches!(process.execution, ActiveExecution::Python(_)) {
close_kernel_process_stdin(kernel, process)?;
}
if process.execution.uses_shared_v8_runtime() {
process.execution.terminate()?;
if signal != 0 && matches!(process.execution, ActiveExecution::Wasm(_)) {
process.queue_pending_execution_event(ActiveExecutionEvent::Exited(128 + signal))?;
}
return Ok(());
}
kernel
.kill_process(EXECUTION_DRIVER_NAME, process.kernel_pid, signal)
.map_err(kernel_error)
}
fn map_wasm_signal_registration(
registration: secure_exec_execution::wasm::WasmSignalHandlerRegistration,
) -> SignalHandlerRegistration {
SignalHandlerRegistration {
action: match registration.action {
secure_exec_execution::wasm::WasmSignalDispositionAction::Default => {
crate::protocol::SignalDispositionAction::Default
}
secure_exec_execution::wasm::WasmSignalDispositionAction::Ignore => {
crate::protocol::SignalDispositionAction::Ignore
}
secure_exec_execution::wasm::WasmSignalDispositionAction::User => {
crate::protocol::SignalDispositionAction::User
}
},
mask: registration.mask,
flags: registration.flags,
}
}
fn parse_process_signal_state_request(
args: &[Value],
) -> Result<(u32, SignalHandlerRegistration), SidecarError> {
let signal = javascript_sync_rpc_arg_u32(args, 0, "process.signal_state signal")?;
let action = javascript_sync_rpc_arg_str(args, 1, "process.signal_state action")?;
let mask_json = javascript_sync_rpc_arg_str(args, 2, "process.signal_state mask")?;
let flags = javascript_sync_rpc_arg_u32(args, 3, "process.signal_state flags")?;
let mask: Vec<u32> = serde_json::from_str(mask_json).map_err(|error| {
SidecarError::InvalidState(format!(
"process.signal_state mask must be valid JSON: {error}"
))
})?;
let action = match action.trim().to_ascii_lowercase().as_str() {
"default" => SignalDispositionAction::Default,
"ignore" => SignalDispositionAction::Ignore,
"user" => SignalDispositionAction::User,
other => {
return Err(SidecarError::InvalidState(format!(
"unsupported process.signal_state action {other}"
)));
}
};
Ok((
signal,
SignalHandlerRegistration {
action,
mask,
flags,
},
))
}
fn apply_process_signal_state_update(
signal_states: &mut BTreeMap<String, BTreeMap<u32, SignalHandlerRegistration>>,
process_id: &str,
signal: u32,
registration: SignalHandlerRegistration,
) {
if registration.action == SignalDispositionAction::Default
&& registration.mask.is_empty()
&& registration.flags == 0
{
let remove_process_entry = signal_states
.get_mut(process_id)
.map(|handlers| {
handlers.remove(&signal);
handlers.is_empty()
})
.unwrap_or(false);
if remove_process_entry {
signal_states.remove(process_id);
}
return;
}
signal_states
.entry(process_id.to_owned())
.or_default()
.insert(signal, registration);
}
fn map_node_signal_registration(
registration: NodeSignalHandlerRegistration,
) -> SignalHandlerRegistration {
SignalHandlerRegistration {
action: match registration.action {
NodeSignalDispositionAction::Default => SignalDispositionAction::Default,
NodeSignalDispositionAction::Ignore => SignalDispositionAction::Ignore,
NodeSignalDispositionAction::User => SignalDispositionAction::User,
},
mask: registration.mask,
flags: registration.flags,
}
}
fn javascript_child_process_sync_input_bytes(
value: Option<&Value>,
) -> Result<Option<Vec<u8>>, SidecarError> {
let Some(value) = value else {
return Ok(None);
};
match value {
Value::Null => Ok(None),
Value::String(text) => Ok(Some(text.as_bytes().to_vec())),
other => javascript_sync_rpc_bytes_arg(
std::slice::from_ref(other),
0,
"child_process.spawn_sync input",
)
.map(Some),
}
}
// bridge_permissions moved to crate::bridge
// reconcile_mounts, resolve_cwd moved to crate::vm
fn resolve_execute_request(
vm: &VmState,
payload: &ExecuteRequest,
) -> Result<ResolvedChildProcessExecution, SidecarError> {
let payload_env: BTreeMap<String, String> = payload
.env
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
if let Some(command) = payload.command.as_deref() {
return resolve_command_execution(
vm,
command,
&payload.args,
&payload_env,
payload.cwd.as_deref(),
payload.wasm_permission_tier,
);
}
let runtime = payload.runtime.clone().ok_or_else(|| {
SidecarError::InvalidState(String::from("execute requires either command or runtime"))
})?;
let entrypoint = payload.entrypoint.clone().ok_or_else(|| {
SidecarError::InvalidState(String::from(
"execute requires either command or entrypoint",
))
})?;
let (guest_cwd, host_cwd, allow_host_path_overrides) =
resolve_execution_cwds(vm, payload.cwd.as_deref());
let mut env = vm.guest_env.clone();
env.extend(payload_env.clone());
let requested_host_entrypoint = resolve_host_entrypoint_within_vm_host_cwd(vm, &entrypoint);
if requested_host_entrypoint.is_some() && !allow_host_path_overrides {
let requested_cwd = payload.cwd.as_deref().unwrap_or(guest_cwd.as_str());
return Err(SidecarError::InvalidState(format!(
"execution cwd {requested_cwd} is outside sandbox root {}",
vm.host_cwd.to_string_lossy()
)));
}
let host_entrypoint_override = allow_host_path_overrides
.then(|| resolve_host_entrypoint_within_vm_host_cwd(vm, &entrypoint))
.flatten();
let guest_entrypoint = host_entrypoint_override
.as_ref()
.map(|(guest_entrypoint, _)| guest_entrypoint.clone())
.or_else(|| guest_entrypoint_for_specifier(&guest_cwd, &entrypoint));
prepare_guest_runtime_env(vm, &mut env, &guest_cwd, &host_cwd, guest_entrypoint)?;
Ok(ResolvedChildProcessExecution {
command: match runtime {
GuestRuntimeKind::JavaScript => String::from(JAVASCRIPT_COMMAND),
GuestRuntimeKind::Python => String::from(PYTHON_COMMAND),
GuestRuntimeKind::WebAssembly => String::from(WASM_COMMAND),
},
process_args: std::iter::once(entrypoint.clone())
.chain(payload.args.iter().cloned())
.collect(),
runtime,
entrypoint: host_entrypoint_override
.map(|(_, host_entrypoint)| host_entrypoint)
.unwrap_or(entrypoint),
execution_args: payload.args.clone(),
env,
guest_cwd,
host_cwd,
wasm_permission_tier: payload.wasm_permission_tier,
tool_command: false,
})
}
fn resolve_command_execution(
vm: &VmState,
command: &str,
args: &[String],
extra_env: &BTreeMap<String, String>,
cwd: Option<&str>,
explicit_wasm_permission_tier: Option<WasmPermissionTier>,
) -> Result<ResolvedChildProcessExecution, SidecarError> {
let (guest_cwd, host_cwd, allow_host_path_overrides) = resolve_execution_cwds(vm, cwd);
let mut env = vm.guest_env.clone();
env.extend(extra_env.clone());
let args = apply_shell_cwd_prefix(command, args.to_vec(), &guest_cwd);
if is_tool_command(vm, command) {
let command = normalized_tool_command_name(command).unwrap_or_else(|| command.to_owned());
return Ok(ResolvedChildProcessExecution {
command: command.clone(),
process_args: std::iter::once(command.clone())
.chain(args.iter().cloned())
.collect(),
runtime: GuestRuntimeKind::JavaScript,
entrypoint: command,
execution_args: args,
env,
guest_cwd,
host_cwd,
wasm_permission_tier: None,
tool_command: true,
});
}
if is_node_runtime_command(command) {
if let Some(cli) = resolve_host_node_cli_entrypoint(command) {
env.insert(
String::from("AGENTOS_NODE_EVAL"),
build_host_node_cli_eval(&cli),
);
prepare_guest_runtime_env(vm, &mut env, &guest_cwd, &host_cwd, None)?;
add_runtime_guest_path_mapping(&mut env, &cli.guest_root, &cli.package_root);
add_runtime_host_access_path(
&mut env,
"AGENTOS_EXTRA_FS_READ_PATHS",
&cli.package_root,
true,
);
return Ok(ResolvedChildProcessExecution {
command: String::from(JAVASCRIPT_COMMAND),
process_args: std::iter::once(command.to_owned())
.chain(args.iter().cloned())
.collect(),
runtime: GuestRuntimeKind::JavaScript,
entrypoint: String::from("-e"),
execution_args: std::iter::once(cli.guest_entrypoint.clone())
.chain(args.iter().cloned())
.collect(),
env,
guest_cwd,
host_cwd,
wasm_permission_tier: None,
tool_command: false,
});
}
if args.is_empty() {
env.insert(String::from("AGENTOS_NODE_EVAL"), String::new());
prepare_guest_runtime_env(vm, &mut env, &guest_cwd, &host_cwd, None)?;
return Ok(ResolvedChildProcessExecution {
command: String::from(JAVASCRIPT_COMMAND),
process_args: vec![command.to_owned()],
runtime: GuestRuntimeKind::JavaScript,
entrypoint: String::from("-e"),
execution_args: Vec::new(),
env,
guest_cwd,
host_cwd,
wasm_permission_tier: None,
tool_command: false,
});
}
if let Some((entrypoint, execution_args)) =
resolve_special_node_cli_invocation(&args, &mut env)
{
prepare_guest_runtime_env(vm, &mut env, &guest_cwd, &host_cwd, None)?;
return Ok(ResolvedChildProcessExecution {
command: String::from(JAVASCRIPT_COMMAND),
process_args: std::iter::once(command.to_owned())
.chain(args.iter().cloned())
.collect(),
runtime: GuestRuntimeKind::JavaScript,
entrypoint,
execution_args,
env,
guest_cwd,
host_cwd,
wasm_permission_tier: None,
tool_command: false,
});
}
let Some(entrypoint_specifier) = args.first() else {
return Err(SidecarError::InvalidState(format!(
"{command} execution requires an entrypoint"
)));
};
let (entrypoint, execution_args, guest_entrypoint) = {
let requested_host_entrypoint =
resolve_host_entrypoint_within_vm_host_cwd(vm, entrypoint_specifier);
if requested_host_entrypoint.is_some() && !allow_host_path_overrides {
let requested_cwd = cwd.unwrap_or(guest_cwd.as_str());
return Err(SidecarError::InvalidState(format!(
"execution cwd {requested_cwd} is outside sandbox root {}",
vm.host_cwd.to_string_lossy()
)));
}
let host_entrypoint_override = allow_host_path_overrides
.then(|| resolve_host_entrypoint_within_vm_host_cwd(vm, entrypoint_specifier))
.flatten();
let guest_entrypoint = host_entrypoint_override
.as_ref()
.map(|(guest_entrypoint, _)| guest_entrypoint.clone())
.or_else(|| guest_entrypoint_for_specifier(&guest_cwd, entrypoint_specifier));
let entrypoint = host_entrypoint_override.map_or_else(
|| {
guest_entrypoint.as_ref().map_or_else(
|| entrypoint_specifier.clone(),
|guest_entrypoint| {
resolve_vm_guest_path_to_host(vm, guest_entrypoint)
.to_string_lossy()
.into_owned()
},
)
},
|(_, host_entrypoint)| host_entrypoint,
);
(
entrypoint,
args.iter().skip(1).cloned().collect(),
guest_entrypoint,
)
};
prepare_guest_runtime_env(vm, &mut env, &guest_cwd, &host_cwd, guest_entrypoint)?;
return Ok(ResolvedChildProcessExecution {
command: String::from(JAVASCRIPT_COMMAND),
process_args: std::iter::once(command.to_owned())
.chain(args.iter().cloned())
.collect(),
runtime: GuestRuntimeKind::JavaScript,
entrypoint,
execution_args,
env,
guest_cwd,
host_cwd,
wasm_permission_tier: None,
tool_command: false,
});
}
if command.ends_with(".js") || command.ends_with(".mjs") || command.ends_with(".cjs") {
let requested_host_entrypoint = resolve_host_entrypoint_within_vm_host_cwd(vm, command);
if requested_host_entrypoint.is_some() && !allow_host_path_overrides {
let requested_cwd = cwd.unwrap_or(guest_cwd.as_str());
return Err(SidecarError::InvalidState(format!(
"execution cwd {requested_cwd} is outside sandbox root {}",
vm.host_cwd.to_string_lossy()
)));
}
let host_entrypoint_override = allow_host_path_overrides
.then(|| resolve_host_entrypoint_within_vm_host_cwd(vm, command))
.flatten();
let guest_entrypoint = host_entrypoint_override
.as_ref()
.map(|(guest_entrypoint, _)| guest_entrypoint.clone())
.or_else(|| guest_entrypoint_for_specifier(&guest_cwd, command));
let entrypoint = host_entrypoint_override.map_or_else(
|| {
guest_entrypoint.as_ref().map_or_else(
|| command.to_owned(),
|guest_entrypoint| {
resolve_vm_guest_path_to_host(vm, guest_entrypoint)
.to_string_lossy()
.into_owned()
},
)
},
|(_, host_entrypoint)| host_entrypoint,
);
prepare_guest_runtime_env(vm, &mut env, &guest_cwd, &host_cwd, guest_entrypoint)?;
return Ok(ResolvedChildProcessExecution {
command: String::from(JAVASCRIPT_COMMAND),
process_args: std::iter::once(command.to_owned())
.chain(args.iter().cloned())
.collect(),
runtime: GuestRuntimeKind::JavaScript,
entrypoint,
execution_args: args.to_vec(),
env,
guest_cwd,
host_cwd,
wasm_permission_tier: None,
tool_command: false,
});
}
let guest_entrypoint = resolve_guest_command_entrypoint(
vm,
&guest_cwd,
command,
env.get("PATH").map(String::as_str),
)
.ok_or_else(|| {
SidecarError::InvalidState(format!(
"command not found on native sidecar path: {command}"
))
})?;
let wasm_permission_tier = explicit_wasm_permission_tier
.or_else(|| vm.command_permissions.get(command).copied())
.or_else(|| {
Path::new(&guest_entrypoint)
.file_name()
.and_then(|name| name.to_str())
.and_then(|name| vm.command_permissions.get(name).copied())
});
let host_entrypoint = resolve_vm_guest_path_to_host(vm, &guest_entrypoint);
if let Some((javascript_guest_entrypoint, javascript_host_entrypoint)) =
resolve_javascript_command_entrypoint(vm, &guest_entrypoint, &host_entrypoint)
{
prepare_guest_runtime_env(
vm,
&mut env,
&guest_cwd,
&host_cwd,
Some(javascript_guest_entrypoint),
)?;
return Ok(ResolvedChildProcessExecution {
command: command.to_owned(),
process_args: std::iter::once(command.to_owned())
.chain(args.iter().cloned())
.collect(),
runtime: GuestRuntimeKind::JavaScript,
entrypoint: javascript_host_entrypoint.to_string_lossy().into_owned(),
execution_args: args.to_vec(),
env,
guest_cwd,
host_cwd,
wasm_permission_tier: None,
tool_command: false,
});
}
prepare_guest_runtime_env(
vm,
&mut env,
&guest_cwd,
&host_cwd,
Some(guest_entrypoint.clone()),
)?;
Ok(ResolvedChildProcessExecution {
command: command.to_owned(),
process_args: std::iter::once(command.to_owned())
.chain(args.iter().cloned())
.collect(),
runtime: GuestRuntimeKind::WebAssembly,
entrypoint: host_entrypoint.to_string_lossy().into_owned(),
execution_args: args.to_vec(),
env,
guest_cwd,
host_cwd,
wasm_permission_tier,
tool_command: false,
})
}
const MAX_JAVASCRIPT_COMMAND_REDIRECT_DEPTH: usize = 4;
fn resolve_javascript_command_entrypoint(
vm: &VmState,
guest_entrypoint: &str,
host_entrypoint: &Path,
) -> Option<(String, PathBuf)> {
resolve_javascript_command_entrypoint_inner(
vm,
guest_entrypoint,
host_entrypoint,
MAX_JAVASCRIPT_COMMAND_REDIRECT_DEPTH,
)
}
fn resolve_javascript_command_entrypoint_inner(
vm: &VmState,
guest_entrypoint: &str,
host_entrypoint: &Path,
redirects_remaining: usize,
) -> Option<(String, PathBuf)> {
if redirects_remaining > 0 {
let symlink_target = fs::symlink_metadata(host_entrypoint)
.ok()
.filter(|metadata| metadata.file_type().is_symlink())
.and_then(|_| fs::read_link(host_entrypoint).ok());
if let Some(symlink_target) = symlink_target {
let guest_parent = Path::new(guest_entrypoint)
.parent()
.and_then(|path| path.to_str())
.unwrap_or("/");
let symlink_guest_entrypoint = if symlink_target.is_absolute() {
normalize_path(&symlink_target.to_string_lossy())
} else {
normalize_path(&format!(
"{guest_parent}/{}",
symlink_target.to_string_lossy().replace('\\', "/")
))
};
let symlink_host_entrypoint =
resolve_vm_guest_path_to_host(vm, &symlink_guest_entrypoint);
return resolve_javascript_command_entrypoint_inner(
vm,
&symlink_guest_entrypoint,
&symlink_host_entrypoint,
redirects_remaining - 1,
);
}
}
let script = load_executable_script_preview(host_entrypoint)?;
let interpreter = parse_script_interpreter_name(&script);
if interpreter.is_none() && is_probable_javascript_entrypoint(host_entrypoint, &script) {
return Some((guest_entrypoint.to_owned(), host_entrypoint.to_path_buf()));
}
let interpreter = interpreter?;
if interpreter == "node" {
return Some((guest_entrypoint.to_owned(), host_entrypoint.to_path_buf()));
}
if redirects_remaining == 0 || !matches!(interpreter.as_str(), "sh" | "bash" | "dash") {
return None;
}
let shim_target = parse_node_shell_shim_target(&script)?;
let guest_parent = Path::new(guest_entrypoint)
.parent()
.and_then(|path| path.to_str())
.unwrap_or("/");
let shim_guest_entrypoint = normalize_path(&format!("{guest_parent}/{shim_target}"));
let shim_host_entrypoint = resolve_vm_guest_path_to_host(vm, &shim_guest_entrypoint);
resolve_javascript_command_entrypoint_inner(
vm,
&shim_guest_entrypoint,
&shim_host_entrypoint,
redirects_remaining - 1,
)
}
fn load_executable_script_preview(path: &Path) -> Option<String> {
let bytes = fs::read(path).ok()?;
let preview_len = bytes.len().min(16 * 1024);
Some(String::from_utf8_lossy(&bytes[..preview_len]).into_owned())
}
fn parse_script_interpreter_name(script: &str) -> Option<String> {
let shebang = script.lines().next()?.strip_prefix("#!")?.trim();
let mut tokens = shebang.split_whitespace();
let command = tokens.next()?;
let command_name = Path::new(command).file_name()?.to_str()?;
if command_name == "env" {
for token in tokens {
if token.starts_with('-') {
continue;
}
return Path::new(token)
.file_name()
.and_then(|name| name.to_str())
.map(ToOwned::to_owned);
}
return None;
}
Some(command_name.to_owned())
}
fn parse_node_shell_shim_target(script: &str) -> Option<String> {
for line in script.lines() {
let trimmed = line.trim();
if !trimmed.starts_with("exec ") {
continue;
}
let mut remaining = trimmed;
while let Some(start) = remaining.find("\"$basedir/") {
let after_prefix = &remaining[start + "\"$basedir/".len()..];
let end = after_prefix.find('"')?;
let candidate = &after_prefix[..end];
remaining = &after_prefix[end + 1..];
if candidate.is_empty() || candidate == "node" || candidate.ends_with("/node") {
continue;
}
return Some(candidate.to_owned());
}
}
None
}
fn is_probable_javascript_entrypoint(path: &Path, script: &str) -> bool {
let extension = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default();
if matches!(extension, "js" | "cjs" | "mjs") {
return true;
}
if !path
.components()
.any(|component| component.as_os_str() == "node_modules")
{
return false;
}
let preview = script.trim_start_matches('\u{feff}').trim_start();
!preview.is_empty()
&& !preview.starts_with("#!")
&& (preview.starts_with("\"use strict\"")
|| preview.starts_with("'use strict'")
|| preview.starts_with("import ")
|| preview.starts_with("export ")
|| preview.starts_with("const ")
|| preview.starts_with("let ")
|| preview.starts_with("var ")
|| preview.starts_with("Object.defineProperty(exports")
|| preview.starts_with("module.exports")
|| preview.starts_with("require("))
}
fn resolve_guest_execution_cwd(vm: &VmState, value: Option<&str>) -> String {
value
.map(normalize_path)
.unwrap_or_else(|| vm.guest_cwd.clone())
}
fn resolve_execution_cwds(vm: &VmState, value: Option<&str>) -> (String, PathBuf, bool) {
if let Some(raw_cwd) = value {
let normalized_vm_host_cwd = normalize_host_path(&vm.host_cwd);
let requested_host_cwd = normalize_host_path(Path::new(raw_cwd));
if path_is_within_root(&requested_host_cwd, &normalized_vm_host_cwd) {
let relative = requested_host_cwd
.strip_prefix(&normalized_vm_host_cwd)
.unwrap_or_else(|_| Path::new(""));
let relative = relative.to_string_lossy().replace('\\', "/");
let guest_cwd = if relative.is_empty() {
String::from("/")
} else {
normalize_path(&format!("/{relative}"))
};
return (guest_cwd, requested_host_cwd, true);
}
}
let guest_cwd = resolve_guest_execution_cwd(vm, value);
let host_cwd = if value.is_none() {
vm.host_cwd.clone()
} else {
resolve_vm_guest_path_to_host(vm, &guest_cwd)
};
(guest_cwd, host_cwd, value.is_none())
}
fn resolve_vm_guest_path_to_host(vm: &VmState, guest_path: &str) -> PathBuf {
host_mount_path_for_guest_path(vm, guest_path)
.unwrap_or_else(|| shadow_path_for_guest(vm, guest_path))
}
fn shadow_path_for_guest(vm: &VmState, guest_path: &str) -> PathBuf {
let normalized = normalize_path(guest_path);
let relative = normalized.trim_start_matches('/');
if relative.is_empty() {
return vm.cwd.clone();
}
vm.cwd.join(relative)
}
fn apply_shell_cwd_prefix(command: &str, mut args: Vec<String>, guest_cwd: &str) -> Vec<String> {
if guest_cwd == "/" || !is_shell_command(command) {
return args;
}
let Some(flag) = args.first() else {
return args;
};
if !matches!(flag.as_str(), "-c" | "-lc") || args.len() < 2 {
return args;
}
let command_text = args[1].clone();
let quoted_cwd = shell_single_quote(guest_cwd);
args[1] = format!("cd {quoted_cwd} && {command_text}");
args
}
fn is_shell_command(command: &str) -> bool {
Path::new(command)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(command)
.trim_end_matches(".exe")
.eq("sh")
|| Path::new(command)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(command)
.trim_end_matches(".exe")
.eq("bash")
}
fn shell_single_quote(value: &str) -> String {
if value.is_empty() {
return String::from("''");
}
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
pub(crate) fn sync_active_process_host_writes_to_kernel(
vm: &mut VmState,
) -> Result<(), SidecarError> {
if vm.root_filesystem_mode != RootFilesystemMode::ReadOnly {
let shadow_root = vm.cwd.clone();
sync_host_directory_tree_to_kernel(vm, &shadow_root, "/")?;
}
let normalized_vm_root = normalize_host_path(&vm.cwd);
let extra_roots = collect_active_process_host_sync_roots(vm, &normalized_vm_root);
for (host_cwd, guest_cwd) in extra_roots {
sync_host_directory_tree_to_kernel(vm, &host_cwd, &guest_cwd)?;
}
Ok(())
}
fn collect_active_process_host_sync_roots(
vm: &VmState,
normalized_vm_root: &Path,
) -> Vec<(PathBuf, String)> {
let mut roots = Vec::new();
let mut seen = BTreeSet::new();
for process in vm.active_processes.values() {
collect_process_host_sync_roots(process, normalized_vm_root, &mut seen, &mut roots);
}
roots
}
fn collect_process_host_sync_roots(
process: &ActiveProcess,
normalized_vm_root: &Path,
seen: &mut BTreeSet<(PathBuf, String)>,
roots: &mut Vec<(PathBuf, String)>,
) {
let normalized_host_cwd = normalize_host_path(&process.host_cwd);
if !path_is_within_root(&normalized_host_cwd, normalized_vm_root) {
let guest_cwd = normalize_path(&process.guest_cwd);
if seen.insert((normalized_host_cwd.clone(), guest_cwd.clone())) {
roots.push((normalized_host_cwd, guest_cwd));
}
}
for child in process.child_processes.values() {
collect_process_host_sync_roots(child, normalized_vm_root, seen, roots);
}
}
fn sync_process_host_writes_to_kernel(
vm: &mut VmState,
process: &ActiveProcess,
) -> Result<(), SidecarError> {
if vm.root_filesystem_mode != RootFilesystemMode::ReadOnly {
let shadow_root = vm.cwd.clone();
sync_host_directory_tree_to_kernel(vm, &shadow_root, "/")?;
}
if !path_is_within_root(
&normalize_host_path(&process.host_cwd),
&normalize_host_path(&vm.cwd),
) {
sync_host_directory_tree_to_kernel(vm, &process.host_cwd, &process.guest_cwd)?;
}
Ok(())
}
fn sync_host_directory_tree_to_kernel(
vm: &mut VmState,
host_root: &Path,
guest_root: &str,
) -> Result<(), SidecarError> {
let normalized_host_root = normalize_host_path(host_root);
let normalized_guest_root = normalize_path(guest_root);
let mut synced_file_times = BTreeMap::new();
sync_host_directory_tree_to_kernel_inner(
vm,
&normalized_host_root,
&normalized_host_root,
&normalized_guest_root,
&mut synced_file_times,
)
}
fn sync_host_directory_tree_to_kernel_inner(
vm: &mut VmState,
host_root: &Path,
current_host_dir: &Path,
guest_root: &str,
synced_file_times: &mut BTreeMap<(u64, u64), (u64, u64)>,
) -> Result<(), SidecarError> {
let entries = match fs::read_dir(current_host_dir) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => {
return Err(SidecarError::Io(format!(
"failed to read host shadow directory {}: {error}",
current_host_dir.display()
)));
}
};
for entry in entries {
let entry = entry.map_err(|error| {
SidecarError::Io(format!(
"failed to read host shadow entry in {}: {error}",
current_host_dir.display()
))
})?;
let host_path = entry.path();
let file_type = entry.file_type().map_err(|error| {
SidecarError::Io(format!(
"failed to stat host shadow entry {}: {error}",
host_path.display()
))
})?;
let relative_path = host_path
.strip_prefix(host_root)
.map_err(|error| {
SidecarError::InvalidState(format!(
"failed to relativize host shadow path {} against {}: {error}",
host_path.display(),
host_root.display()
))
})?
.to_string_lossy()
.replace('\\', "/");
let guest_path = if guest_root == "/" {
normalize_path(&format!("/{relative_path}"))
} else {
normalize_path(&format!(
"{}/{}",
guest_root.trim_end_matches('/'),
relative_path
))
};
if should_skip_shadow_sync_path(vm, &guest_path) {
continue;
}
if file_type.is_dir() {
let metadata = entry.metadata().map_err(|error| {
SidecarError::Io(format!(
"failed to read host shadow metadata {}: {error}",
host_path.display()
))
})?;
if !is_shadow_bootstrap_dir(&guest_path)
&& !vm.kernel.exists(&guest_path).unwrap_or(false)
{
vm.kernel.mkdir(&guest_path, true).map_err(|error| {
SidecarError::InvalidState(format!(
"failed to sync host shadow directory {} to guest {}: {}",
host_path.display(),
guest_path,
kernel_error(error)
))
})?;
vm.kernel
.chmod(&guest_path, host_shadow_mode(&metadata))
.map_err(|error| {
SidecarError::InvalidState(format!(
"failed to sync host shadow directory mode {} to guest {}: {}",
host_path.display(),
guest_path,
kernel_error(error)
))
})?;
}
sync_host_directory_tree_to_kernel_inner(
vm,
host_root,
&host_path,
guest_root,
synced_file_times,
)?;
continue;
}
if file_type.is_file() {
let metadata = entry.metadata().map_err(|error| {
SidecarError::Io(format!(
"failed to read host shadow metadata {}: {error}",
host_path.display()
))
})?;
let timestamp_key = (metadata.dev(), metadata.ino());
let (atime_ms, mtime_ms) =
*synced_file_times.entry(timestamp_key).or_insert_with(|| {
(
metadata_time_ms(metadata.atime(), metadata.atime_nsec()),
metadata_time_ms(metadata.mtime(), metadata.mtime_nsec()),
)
});
let desired_mode = host_shadow_mode(&metadata);
let bytes = read_host_shadow_file(&host_path, desired_mode).map_err(|error| {
SidecarError::Io(format!(
"failed to read host shadow file {}: {error}",
host_path.display()
))
})?;
vm.kernel.write_file(&guest_path, bytes).map_err(|error| {
SidecarError::InvalidState(format!(
"failed to sync host shadow file {} to guest {}: {}",
host_path.display(),
guest_path,
kernel_error(error)
))
})?;
vm.kernel
.chmod(&guest_path, desired_mode)
.map_err(|error| {
SidecarError::InvalidState(format!(
"failed to sync host shadow file mode {} to guest {}: {}",
host_path.display(),
guest_path,
kernel_error(error)
))
})?;
vm.kernel
.utimes(&guest_path, atime_ms, mtime_ms)
.map_err(|error| {
SidecarError::InvalidState(format!(
"failed to sync host shadow file times {} to guest {}: {}",
host_path.display(),
guest_path,
kernel_error(error)
))
})?;
continue;
}
if file_type.is_symlink() {
let target = match fs::read_link(&host_path) {
Ok(target) => target,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => {
return Err(SidecarError::Io(format!(
"failed to read host shadow symlink {}: {error}",
host_path.display()
)));
}
};
replace_kernel_symlink(vm, &guest_path, &target.to_string_lossy())?;
}
}
Ok(())
}
fn replace_kernel_symlink(
vm: &mut VmState,
guest_path: &str,
target: &str,
) -> Result<(), SidecarError> {
if vm.kernel.symlink(target, guest_path).is_ok() {
return Ok(());
}
if let Ok(existing_target) = vm.kernel.read_link(guest_path) {
if existing_target == target {
return Ok(());
}
}
let _ = vm.kernel.remove_file(guest_path);
let _ = vm.kernel.remove_dir(guest_path);
vm.kernel
.symlink(target, guest_path)
.map_err(kernel_error)?;
Ok(())
}
fn host_shadow_mode(metadata: &fs::Metadata) -> u32 {
metadata.permissions().mode() & 0o7777
}
/// Reads a shadow-root file back into the kernel even when guest-visible mode
/// bits make it unreadable for the host user. The sidecar is the kernel for
/// this tree, so guest permission bits (for example a 0o200 write-only file
/// produced by `chmod` plus a shell append redirect) must not break the
/// exit-time shadow sync. The original mode is restored after the read.
fn read_host_shadow_file(host_path: &Path, mode: u32) -> std::io::Result<Vec<u8>> {
match fs::read(host_path) {
Ok(bytes) => Ok(bytes),
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
fs::set_permissions(host_path, fs::Permissions::from_mode(mode | 0o400))?;
let result = fs::read(host_path);
fs::set_permissions(host_path, fs::Permissions::from_mode(mode))?;
result
}
Err(error) => Err(error),
}
}
fn metadata_time_ms(seconds: i64, nanos: i64) -> u64 {
let seconds = seconds.max(0) as u64;
let nanos = nanos.max(0) as u64;
seconds
.saturating_mul(1_000)
.saturating_add(nanos / 1_000_000)
}
fn is_shadow_bootstrap_dir(path: &str) -> bool {
matches!(
path,
"/dev"
| "/proc"
| "/tmp"
| "/bin"
| "/lib"
| "/sbin"
| "/boot"
| "/etc"
| "/root"
| "/run"
| "/srv"
| "/sys"
| "/opt"
| "/mnt"
| "/media"
| "/home"
| "/home/agentos"
| "/usr"
| "/usr/bin"
| "/usr/games"
| "/usr/include"
| "/usr/lib"
| "/usr/libexec"
| "/usr/man"
| "/usr/local"
| "/usr/local/bin"
| "/usr/sbin"
| "/usr/share"
| "/usr/share/man"
| "/var"
| "/var/cache"
| "/var/empty"
| "/var/lib"
| "/var/lock"
| "/var/log"
| "/var/run"
| "/var/spool"
| "/var/tmp"
| "/etc/agentos"
| "/workspace"
)
}
#[cfg(test)]
mod shadow_sync_tests {
use super::{is_protected_agentos_shadow_sync_path, is_shadow_bootstrap_dir};
#[test]
fn shadow_bootstrap_sync_skips_virtual_home_tree() {
assert!(is_shadow_bootstrap_dir("/home"));
assert!(is_shadow_bootstrap_dir("/home/agentos"));
}
#[test]
fn protected_agentos_paths_are_not_shadow_synced() {
assert!(is_protected_agentos_shadow_sync_path("/etc/agentos"));
assert!(is_protected_agentos_shadow_sync_path(
"/etc/agentos/instructions.md"
));
assert!(!is_protected_agentos_shadow_sync_path("/etc/agentos-copy"));
assert!(!is_protected_agentos_shadow_sync_path("/etc/agentos.md"));
}
}
fn is_kernel_owned_shadow_sync_path(path: &str) -> bool {
matches!(path, "/dev" | "/proc" | "/sys")
|| path.starts_with("/dev/")
|| path.starts_with("/proc/")
|| path.starts_with("/sys/")
}
pub(crate) fn is_protected_agentos_shadow_sync_path(path: &str) -> bool {
path == "/etc/agentos" || path.starts_with("/etc/agentos/")
}
fn should_skip_shadow_sync_path(vm: &VmState, guest_path: &str) -> bool {
is_kernel_owned_shadow_sync_path(guest_path)
|| is_protected_agentos_shadow_sync_path(guest_path)
|| host_mount_path_for_guest_path_from_mounts(&vm.configuration.mounts, guest_path)
.is_some()
}
fn resolve_path_like_guest_specifier(cwd: &str, specifier: &str) -> String {
if specifier.starts_with("file://") {
normalize_path(specifier.trim_start_matches("file://"))
} else if specifier.starts_with("file:") {
normalize_path(specifier.trim_start_matches("file:"))
} else if specifier.starts_with('/') {
normalize_path(specifier)
} else {
normalize_path(&format!("{cwd}/{specifier}"))
}
}
fn guest_entrypoint_for_specifier(cwd: &str, specifier: &str) -> Option<String> {
is_path_like_specifier(specifier).then(|| resolve_path_like_guest_specifier(cwd, specifier))
}
fn is_node_runtime_command(command: &str) -> bool {
matches!(command, "node" | "npm" | "npx")
|| Path::new(command)
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| matches!(name, "node" | "npm" | "npx"))
}
fn resolve_special_node_cli_invocation(
args: &[String],
env: &mut BTreeMap<String, String>,
) -> Option<(String, Vec<String>)> {
let first = args.first()?;
match first.as_str() {
"-e" | "--eval" => {
env.insert(
String::from("AGENTOS_NODE_EVAL"),
args.get(1).cloned().unwrap_or_default(),
);
Some((first.clone(), args.iter().skip(2).cloned().collect()))
}
"-v" | "--version" => {
env.insert(
String::from("AGENTOS_NODE_EVAL"),
String::from("console.log(process.version);"),
);
Some((String::from("-e"), args.to_vec()))
}
_ => None,
}
}
fn node_runtime_command_name(command: &str) -> Option<&str> {
let name = Path::new(command)
.file_name()
.and_then(|name| name.to_str())?;
matches!(name, "node" | "npm" | "npx").then_some(name)
}
struct ResolvedHostNodeCliEntrypoint {
command_name: String,
guest_root: String,
guest_entrypoint: String,
package_root: PathBuf,
}
fn resolve_host_node_cli_entrypoint(command: &str) -> Option<ResolvedHostNodeCliEntrypoint> {
let command_name = node_runtime_command_name(command)?;
if !matches!(command_name, "npm" | "npx") {
return None;
}
let path = std::env::var_os("PATH")?;
for root in std::env::split_paths(&path) {
let candidate = root.join(command_name);
if !candidate.is_file() {
continue;
}
let entrypoint = candidate.canonicalize().ok().unwrap_or(candidate);
let package_root = entrypoint.parent()?.parent()?.to_path_buf();
let guest_root = format!("/__secure_exec/node-runtime/{command_name}");
let relative_entrypoint = entrypoint.strip_prefix(&package_root).ok()?;
let guest_entrypoint = normalize_path(&format!(
"{guest_root}/{}",
relative_entrypoint.to_string_lossy().replace('\\', "/")
));
return Some(ResolvedHostNodeCliEntrypoint {
command_name: command_name.to_owned(),
guest_root,
guest_entrypoint,
package_root,
});
}
None
}
fn build_host_node_cli_eval(cli: &ResolvedHostNodeCliEntrypoint) -> String {
let guest_npm_main = normalize_path(&format!("{}/lib/npm.js", cli.guest_root));
let guest_npm_cli = normalize_path(&format!("{}/bin/npm-cli.js", cli.guest_root));
let guest_package_json = normalize_path(&format!("{}/package.json", cli.guest_root));
let guest_display_module = normalize_path(&format!("{}/lib/utils/display.js", cli.guest_root));
let guest_log_file_module =
normalize_path(&format!("{}/lib/utils/log-file.js", cli.guest_root));
let debug_preamble = "const __agentOSDebugNpmCli = !!process.env.CODEX_DEBUG_NPM_CLI; const __agentOSDebugLog = (...args) => { if (__agentOSDebugNpmCli) { console.error('[secure-exec npm debug]', ...args); } }; const __agentOSIsProcessExitError = (error) => !!(error && typeof error === 'object' && (error._isProcessExit === true || error.name === 'ProcessExitError')); const __agentOSResolveExitCode = (code) => Number.isFinite(code) ? code : (Number.isFinite(process.exitCode) ? process.exitCode : 0); const __agentOSFinish = (code) => { process.exitCode = __agentOSResolveExitCode(code); }; if (__agentOSDebugNpmCli) { const __agentOSWrapAsyncFsMethod = (__agentOSTarget, __agentOSMethod) => { const __agentOSOriginal = __agentOSTarget[__agentOSMethod]; if (typeof __agentOSOriginal !== 'function' || __agentOSOriginal.__agentOSDebugWrapped) { return; } const __agentOSWrapped = async (...args) => { const target = args.length > 0 ? args[0] : '<none>'; __agentOSDebugLog(`fs.${__agentOSMethod}:start`, String(target)); try { const result = await __agentOSOriginal.apply(__agentOSTarget, args); __agentOSDebugLog(`fs.${__agentOSMethod}:done`, String(target)); return result; } catch (error) { __agentOSDebugLog(`fs.${__agentOSMethod}:error`, String(target), error && error.stack ? error.stack : String(error)); throw error; } }; __agentOSWrapped.__agentOSDebugWrapped = true; __agentOSTarget[__agentOSMethod] = __agentOSWrapped; }; const __agentOSWrapSyncFsMethod = (__agentOSTarget, __agentOSMethod) => { const __agentOSOriginal = __agentOSTarget[__agentOSMethod]; if (typeof __agentOSOriginal !== 'function' || __agentOSOriginal.__agentOSDebugWrapped) { return; } const __agentOSWrapped = (...args) => { const target = args.length > 0 ? args[0] : '<none>'; __agentOSDebugLog(`fs.${__agentOSMethod}:start`, String(target)); try { const result = __agentOSOriginal.apply(__agentOSTarget, args); __agentOSDebugLog(`fs.${__agentOSMethod}:done`, String(target)); return result; } catch (error) { __agentOSDebugLog(`fs.${__agentOSMethod}:error`, String(target), error && error.stack ? error.stack : String(error)); throw error; } }; __agentOSWrapped.__agentOSDebugWrapped = true; __agentOSTarget[__agentOSMethod] = __agentOSWrapped; }; const __agentOSFsPromiseModules = [require('fs/promises'), require('node:fs/promises')]; for (const __agentOSFsPromises of __agentOSFsPromiseModules) { for (const __agentOSMethod of ['access', 'lstat', 'mkdir', 'open', 'readFile', 'readdir', 'readlink', 'realpath', 'rename', 'rm', 'rmdir', 'stat', 'symlink', 'unlink', 'writeFile']) { __agentOSWrapAsyncFsMethod(__agentOSFsPromises, __agentOSMethod); } } const __agentOSFsModules = [require('fs'), require('node:fs')]; for (const __agentOSFs of __agentOSFsModules) { for (const __agentOSMethod of ['accessSync', 'existsSync', 'lstatSync', 'mkdirSync', 'openSync', 'readFileSync', 'readdirSync', 'readlinkSync', 'realpathSync', 'renameSync', 'rmSync', 'rmdirSync', 'statSync', 'symlinkSync', 'unlinkSync', 'writeFileSync']) { __agentOSWrapSyncFsMethod(__agentOSFs, __agentOSMethod); } } }";
let display_stub = format!(
"const __agentOSDisplayModulePath = require.resolve({display_module}); const __agentOSLogFileModulePath = require.resolve({log_file_module}); const __agentOSColorPassthrough = new Proxy((value) => value, {{ get: () => __agentOSColorPassthrough, apply: (_target, _thisArg, args) => args[0] }}); class __AgentOSNpmDisplayStub {{ constructor() {{ this.chalk = {{ noColor: __agentOSColorPassthrough, stdout: __agentOSColorPassthrough, stderr: __agentOSColorPassthrough }}; this._logPaused = true; this._logBuffer = []; this._outputBuffer = []; this._write = (stream, values) => {{ if (!Array.isArray(values) || values.length === 0) {{ return; }} const text = values.map((value) => typeof value === 'string' ? value : String(value)).join(' '); if (text.length === 0) {{ return; }} const normalized = text.replace(/\\r\\n/g, '\\n'); if (/^\\n?> npx\\n> /u.test(normalized)) {{ return; }} stream.write(text.endsWith('\\n') ? text : `${{text}}\\n`); }}; this._inputHandler = (level, ...args) => {{ if (level !== 'read') {{ return; }} const [resolve, reject, callback] = args; Promise.resolve().then(() => callback()).then(resolve, reject); }}; this._logHandler = (level, ...args) => {{ if (level === 'resume') {{ this._logPaused = false; for (const entry of this._logBuffer.splice(0)) {{ this._write(process.stderr, entry); }} return; }} if (level === 'pause') {{ this._logPaused = true; return; }} if (this._logPaused) {{ this._logBuffer.push(args); return; }} this._write(process.stderr, args); }}; this._outputHandler = (level, ...args) => {{ if (level === 'buffer') {{ this._outputBuffer.push(['standard', args]); return; }} if (level === 'flush') {{ for (const [bufferLevel, bufferArgs] of this._outputBuffer.splice(0)) {{ this._write(bufferLevel === 'error' ? process.stderr : process.stdout, bufferArgs); }} return; }} this._write(level === 'error' ? process.stderr : process.stdout, args); }}; process.on('input', this._inputHandler); process.on('log', this._logHandler); process.on('output', this._outputHandler); }} async load() {{ process.emit('log', 'resume'); process.emit('output', 'flush'); }} off() {{ if (this._inputHandler) {{ process.off('input', this._inputHandler); }} if (this._logHandler) {{ process.off('log', this._logHandler); }} if (this._outputHandler) {{ process.off('output', this._outputHandler); }} this._logBuffer.length = 0; this._outputBuffer.length = 0; }} }} class __AgentOSNpmLogFileStub {{ constructor() {{ this.files = []; }} async load() {{ return []; }} off() {{}} }} globalThis._moduleCache[__agentOSDisplayModulePath] = {{ exports: __AgentOSNpmDisplayStub }}; globalThis._moduleCache[__agentOSLogFileModulePath] = {{ exports: __AgentOSNpmLogFileStub }};",
display_module = serde_json::to_string(&guest_display_module)
.unwrap_or_else(|_| format!("\"{guest_display_module}\"")),
log_file_module = serde_json::to_string(&guest_log_file_module)
.unwrap_or_else(|_| format!("\"{guest_log_file_module}\"")),
);
let registry_fetch_stub = "const { createRequire: __agentOSCreateRequire } = require('module'); const __agentOSNpmRequire = __agentOSCreateRequire(require.resolve(__AGENTOS_NPM_MAIN__)); try { const __agentOSMinipassFetchPath = __agentOSNpmRequire.resolve('minipass-fetch'); const __agentOSMinipassFetch = __agentOSNpmRequire(__agentOSMinipassFetchPath); const { FetchError: __agentOSFetchError, Headers: __agentOSFetchHeaders, Request: __agentOSFetchRequest, Response: __agentOSFetchResponse, AbortError: __agentOSAbortError } = __agentOSMinipassFetch; const { Minipass: __agentOSMinipass } = __agentOSNpmRequire('minipass'); const __agentOSCreateBinaryMinipass = () => new __agentOSMinipass({ objectMode: false, encoding: null }); const __agentOSCloneBuffer = (buffer) => Buffer.isBuffer(buffer) ? Buffer.from(buffer) : Buffer.from(buffer ?? []); const __agentOSBufferToArrayBuffer = (buffer) => { const bytes = __agentOSCloneBuffer(buffer); return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); }; const __agentOSAttachBufferedBodyMethods = (response, responseBuffer) => { const __agentOSReadBuffer = async () => __agentOSCloneBuffer(responseBuffer); response.__agentOSBufferedBody = __agentOSCloneBuffer(responseBuffer); response.buffer = __agentOSReadBuffer; response.text = async () => (await __agentOSReadBuffer()).toString('utf8'); response.json = async () => JSON.parse(await response.text()); response.arrayBuffer = async () => __agentOSBufferToArrayBuffer(await __agentOSReadBuffer()); response.clone = () => { const clonedBody = __agentOSCreateBinaryMinipass(); const clonedBuffer = __agentOSCloneBuffer(responseBuffer); clonedBody.end(clonedBuffer); const clonedResponse = new __agentOSFetchResponse(clonedBody, { url: response.url, status: response.status, statusText: response.statusText, headers: response.headers, size: response.size, timeout: response.timeout, counter: response.counter, trailer: response.trailer }); return __agentOSAttachBufferedBodyMethods(clonedResponse, clonedBuffer); }; return response; }; const __agentOSNormalizeHeaders = (__agentOSHeaders) => { const normalized = {}; __agentOSHeaders.forEach((value, key) => { if (normalized[key] === undefined) { normalized[key] = value; return; } if (Array.isArray(normalized[key])) { normalized[key].push(value); return; } normalized[key] = [normalized[key], value]; }); return normalized; }; const __agentOSPatchedMinipassFetch = async (input, opts = {}) => { const request = input instanceof __agentOSFetchRequest ? input : new __agentOSFetchRequest(input, opts); const __agentOSController = !request.signal && typeof AbortController === 'function' ? new AbortController() : null; const __agentOSSignal = request.signal ?? __agentOSController?.signal; let __agentOSTimer = null; if (__agentOSController && Number.isFinite(request.timeout) && request.timeout > 0) { __agentOSTimer = setTimeout(() => __agentOSController.abort(new Error(`network timeout at: ${request.url}`)), request.timeout); __agentOSTimer.unref?.(); } try { const requestHeaders = {}; request.headers.forEach((value, key) => { requestHeaders[key] = value; }); const response = await fetch(request.url, { method: request.method, headers: requestHeaders, body: request.body ?? undefined, redirect: request.redirect ?? opts.redirect ?? 'follow', signal: __agentOSSignal, ...(request.body ? { duplex: 'half' } : {}) }); const responseBody = __agentOSCreateBinaryMinipass(); const contentType = String(response.headers.get('content-type') || '').toLowerCase(); const responseBuffer = contentType.includes('json') ? Buffer.from(JSON.stringify(await response.json())) : contentType.startsWith('text/') ? Buffer.from(await response.text()) : Buffer.from(await response.arrayBuffer()); responseBody.end(responseBuffer); return __agentOSAttachBufferedBodyMethods(new __agentOSFetchResponse(responseBody, { url: response.url, status: response.status, statusText: response.statusText, headers: __agentOSNormalizeHeaders(response.headers), size: request.size, timeout: request.timeout, counter: request.counter ?? opts.counter ?? 0, trailer: Promise.resolve(new __agentOSFetchHeaders()) }), responseBuffer); } catch (error) { if (error instanceof Error) { throw error; } throw new __agentOSFetchError(String(error), 'system', error); } finally { if (__agentOSTimer) { clearTimeout(__agentOSTimer); } } }; globalThis.__agentOSPatchedMinipassFetch = __agentOSPatchedMinipassFetch; __agentOSPatchedMinipassFetch.isRedirect = typeof __agentOSMinipassFetch.isRedirect === 'function' ? __agentOSMinipassFetch.isRedirect.bind(__agentOSMinipassFetch) : (code) => code === 301 || code === 302 || code === 303 || code === 307 || code === 308; __agentOSPatchedMinipassFetch.FetchError = __agentOSFetchError; __agentOSPatchedMinipassFetch.Headers = __agentOSFetchHeaders; __agentOSPatchedMinipassFetch.Request = __agentOSFetchRequest; __agentOSPatchedMinipassFetch.Response = __agentOSFetchResponse; __agentOSPatchedMinipassFetch.AbortError = __agentOSAbortError; globalThis._moduleCache[__agentOSMinipassFetchPath] = { exports: __agentOSPatchedMinipassFetch }; __agentOSDebugLog('patched-minipass-fetch', __agentOSMinipassFetchPath); const __agentOSCheckResponsePath = __agentOSNpmRequire.resolve('npm-registry-fetch/lib/check-response.js'); const __agentOSCheckResponse = __agentOSNpmRequire(__agentOSCheckResponsePath); const __agentOSEnsureResponseBodyStream = (response) => { if (!response || (response.body && typeof response.body.on === 'function')) { return response; } const body = __agentOSCreateBinaryMinipass(); const finishWithError = (error) => body.emit('error', error instanceof Error ? error : new Error(String(error))); try { if (typeof response.buffer === 'function') { Promise.resolve(response.buffer()).then((buffer) => body.end(buffer), finishWithError); } else if (Buffer.isBuffer(response.body) || typeof response.body === 'string') { body.end(response.body); } else if (response.body && typeof response.body[Symbol.asyncIterator] === 'function') { (async () => { try { for await (const chunk of response.body) { body.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } body.end(); } catch (error) { finishWithError(error); body.end(); } })(); } else { body.end(); } } catch (error) { finishWithError(error); body.end(); } return new __agentOSFetchResponse(body, response); }; globalThis._moduleCache[__agentOSCheckResponsePath] = { exports: (payload) => { const normalized = { ...payload, res: __agentOSEnsureResponseBodyStream(payload.res) }; __agentOSDebugLog('check-response-body', normalized.res && normalized.res.status, typeof (normalized.res && normalized.res.body), normalized.res && normalized.res.body && typeof normalized.res.body.on, normalized.res && normalized.res.body && normalized.res.body.constructor && normalized.res.body.constructor.name, !!(normalized.res && normalized.res.__agentOSBufferedBody), normalized.res && typeof normalized.res.json); return __agentOSCheckResponse(normalized); } }; __agentOSDebugLog('patched-check-response', __agentOSCheckResponsePath); } catch (error) { __agentOSDebugLog('patch-minipass-fetch-failed', error && error.stack ? error.stack : String(error)); } try { const __agentOSRegistryFetchPath = __agentOSNpmRequire.resolve('npm-registry-fetch'); const __agentOSRegistryFetch = __agentOSNpmRequire(__agentOSRegistryFetchPath); const __agentOSWrapRegistryFetch = (fn) => { const wrapResult = (promise) => Promise.resolve(promise).then((res) => { __agentOSDebugLog('registry-fetch-result', res && res.status, typeof (res && res.body), res && res.body && typeof res.body.on, res && res.body && res.body.constructor && res.body.constructor.name, !!(res && res.__agentOSBufferedBody), res && typeof res.json); return res; }); const wrapped = (uri, opts = {}) => wrapResult(globalThis.__agentOSPatchedMinipassFetch(uri, { method: opts.method, headers: opts.headers, body: opts.body, redirect: opts.redirect, signal: opts.signal, timeout: opts.timeout, size: opts.size, counter: opts.counter })); if (typeof fn.json === 'function') { wrapped.json = (uri, opts = {}) => wrapped(uri, opts).then((res) => res.json()); } if (fn.json && typeof fn.json.stream === 'function') { wrapped.json = wrapped.json || {}; wrapped.json.stream = (uri, path, opts = {}) => fn.json.stream(uri, path, { ...opts, agent: false }); } if (typeof fn.pickRegistry === 'function') { wrapped.pickRegistry = fn.pickRegistry.bind(fn); } if (typeof fn.getAuth === 'function') { wrapped.getAuth = fn.getAuth.bind(fn); } return wrapped; }; globalThis._moduleCache[__agentOSRegistryFetchPath] = { exports: __agentOSWrapRegistryFetch(__agentOSRegistryFetch) }; __agentOSDebugLog('patched-npm-registry-fetch', __agentOSRegistryFetchPath); } catch (error) { __agentOSDebugLog('patch-npm-registry-fetch-failed', error && error.stack ? error.stack : String(error)); }";
match cli.command_name.as_str() {
"npx" => format!(
"{debug_preamble} {display_stub} {registry_fetch_stub} process.argv[1] = require.resolve({npm_cli}); process.argv.splice(2, 0, 'exec'); __agentOSDebugLog('argv', JSON.stringify(process.argv), 'cwd', process.cwd()); (async () => {{ const pkg = require({package_json}); if (process.argv.includes('--version') || process.argv.includes('-v')) {{ __agentOSDebugLog('version-shortcut'); console.log(pkg.version); __agentOSFinish(0); return; }} const Npm = require({npm_main}); const npm = new Npm(); __agentOSDebugLog('before-load'); const loaded = await npm.load(); __agentOSDebugLog('after-load', loaded && loaded.command, JSON.stringify(loaded && loaded.args)); if (!loaded.exec) {{ __agentOSDebugLog('no-exec'); __agentOSFinish(); return; }} if (!loaded.command) {{ __agentOSDebugLog('no-command'); const {{ output }} = require('proc-log'); output.standard(npm.usage); __agentOSFinish(1); return; }} __agentOSDebugLog('before-exec', loaded.command, JSON.stringify(loaded.args)); await npm.exec(loaded.command, loaded.args); __agentOSDebugLog('after-exec', __agentOSResolveExitCode()); __agentOSFinish(); }})().catch((error) => {{ if (__agentOSIsProcessExitError(error)) {{ __agentOSDebugLog('process-exit-error', __agentOSResolveExitCode(error.code)); __agentOSFinish(error.code); return; }} console.error(error && error.stack ? error.stack : String(error)); __agentOSFinish(error && typeof error === 'object' && Number.isFinite(error.exitCode) ? error.exitCode : 1); }});",
debug_preamble = debug_preamble,
display_stub = display_stub,
registry_fetch_stub = registry_fetch_stub.replace(
"__AGENTOS_NPM_MAIN__",
&serde_json::to_string(&guest_npm_main)
.unwrap_or_else(|_| format!("\"{guest_npm_main}\"")),
),
npm_main = serde_json::to_string(&guest_npm_main)
.unwrap_or_else(|_| format!("\"{guest_npm_main}\"")),
npm_cli = serde_json::to_string(&guest_npm_cli)
.unwrap_or_else(|_| format!("\"{guest_npm_cli}\"")),
package_json = serde_json::to_string(&guest_package_json)
.unwrap_or_else(|_| format!("\"{guest_package_json}\"")),
),
_ => format!(
"{debug_preamble} {display_stub} {registry_fetch_stub} __agentOSDebugLog('argv', JSON.stringify(process.argv), 'cwd', process.cwd()); (async () => {{ const pkg = require({package_json}); if (process.argv.includes('--version') || process.argv.includes('-v')) {{ __agentOSDebugLog('version-shortcut'); console.log(pkg.version); __agentOSFinish(0); return; }} const Npm = require({npm_main}); const npm = new Npm(); __agentOSDebugLog('before-load'); const loaded = await npm.load(); __agentOSDebugLog('after-load', loaded && loaded.command, JSON.stringify(loaded && loaded.args)); if (!loaded.exec) {{ __agentOSDebugLog('no-exec'); __agentOSFinish(); return; }} if (!loaded.command) {{ __agentOSDebugLog('no-command'); const {{ output }} = require('proc-log'); output.standard(npm.usage); __agentOSFinish(1); return; }} __agentOSDebugLog('before-exec', loaded.command, JSON.stringify(loaded.args)); await npm.exec(loaded.command, loaded.args); __agentOSDebugLog('after-exec', __agentOSResolveExitCode()); __agentOSFinish(); }})().catch((error) => {{ if (__agentOSIsProcessExitError(error)) {{ __agentOSDebugLog('process-exit-error', __agentOSResolveExitCode(error.code)); __agentOSFinish(error.code); return; }} console.error(error && error.stack ? error.stack : String(error)); __agentOSFinish(error && typeof error === 'object' && Number.isFinite(error.exitCode) ? error.exitCode : 1); }});",
debug_preamble = debug_preamble,
display_stub = display_stub,
registry_fetch_stub = registry_fetch_stub.replace(
"__AGENTOS_NPM_MAIN__",
&serde_json::to_string(&guest_npm_main)
.unwrap_or_else(|_| format!("\"{guest_npm_main}\"")),
),
npm_main = serde_json::to_string(&guest_npm_main)
.unwrap_or_else(|_| format!("\"{guest_npm_main}\"")),
package_json = serde_json::to_string(&guest_package_json)
.unwrap_or_else(|_| format!("\"{guest_package_json}\"")),
),
}
}
fn resolve_guest_command_entrypoint(
vm: &VmState,
guest_cwd: &str,
command: &str,
path_env: Option<&str>,
) -> Option<String> {
if !is_path_like_specifier(command) {
if let Some(entrypoint) = vm.command_guest_paths.get(command) {
return Some(entrypoint.clone());
}
for search_dir in guest_command_search_dirs(vm, guest_cwd, path_env) {
let candidate = normalize_path(&format!("{search_dir}/{command}"));
if let Some(entrypoint) = resolve_guest_command_path_candidate(vm, &candidate) {
return Some(entrypoint);
}
}
return None;
}
let normalized = resolve_path_like_guest_specifier(guest_cwd, command);
resolve_guest_command_path_candidate(vm, &normalized).or_else(|| {
// Some guest shells materialize PATH lookups into absolute candidate paths.
// If that path points into a searched directory but does not exist, fall
// back to the command basename so the sidecar can remap VM command packages.
let parent_dir = Path::new(&normalized).parent()?.to_str()?;
if !guest_command_search_dirs(vm, guest_cwd, path_env)
.iter()
.any(|search_dir| normalize_path(search_dir) == normalize_path(parent_dir))
{
return None;
}
let file_name = Path::new(&normalized).file_name()?.to_str()?;
vm.command_guest_paths.get(file_name).cloned()
})
}
fn guest_command_search_dirs(vm: &VmState, guest_cwd: &str, path_env: Option<&str>) -> Vec<String> {
let mut search_dirs = Vec::new();
let mut seen = BTreeSet::new();
if let Some(path) = path_env.or_else(|| vm.guest_env.get("PATH").map(String::as_str)) {
for segment in path.split(':') {
let trimmed = segment.trim();
if trimmed.is_empty() {
continue;
}
let normalized = if trimmed.starts_with('/') {
normalize_path(trimmed)
} else {
normalize_path(&format!("{guest_cwd}/{trimmed}"))
};
if seen.insert(normalized.clone()) {
search_dirs.push(normalized);
}
}
}
for fallback in ["/bin", "/usr/bin", "/usr/local/bin"] {
let normalized = String::from(fallback);
if seen.insert(normalized.clone()) {
search_dirs.push(normalized);
}
}
search_dirs
}
fn resolve_guest_command_path_candidate(vm: &VmState, candidate: &str) -> Option<String> {
if candidate.starts_with("/bin/")
|| candidate.starts_with("/usr/bin/")
|| candidate.starts_with("/usr/local/bin/")
|| candidate.starts_with("/__secure_exec/commands/")
{
if let Some(file_name) = Path::new(candidate)
.file_name()
.and_then(|name| name.to_str())
{
if let Some(guest_entrypoint) = vm.command_guest_paths.get(file_name) {
return Some(guest_entrypoint.clone());
}
}
}
if vm
.kernel
.exists(candidate)
.ok()
.is_some_and(|exists| exists)
{
return Some(normalize_path(candidate));
}
resolve_vm_guest_path_to_host(vm, candidate)
.is_file()
.then(|| normalize_path(candidate))
}
fn resolve_host_entrypoint_within_vm_host_cwd(
vm: &VmState,
specifier: &str,
) -> Option<(String, String)> {
let candidate = Path::new(specifier);
if !candidate.is_absolute() {
return None;
}
let normalized_entrypoint = normalize_host_path(candidate);
let normalized_host_cwd = normalize_host_path(&vm.host_cwd);
if !path_is_within_root(&normalized_entrypoint, &normalized_host_cwd) {
return None;
}
let relative = normalized_entrypoint
.strip_prefix(&normalized_host_cwd)
.ok()?
.to_string_lossy()
.replace('\\', "/");
let guest_entrypoint = if relative.is_empty() {
String::from("/")
} else {
normalize_path(&format!("/{relative}"))
};
Some((
guest_entrypoint,
normalized_entrypoint.to_string_lossy().into_owned(),
))
}
fn prepare_guest_runtime_env(
vm: &VmState,
env: &mut BTreeMap<String, String>,
guest_cwd: &str,
host_cwd: &Path,
guest_entrypoint: Option<String>,
) -> Result<(), SidecarError> {
let user = vm.kernel.user_profile();
let path_mappings = runtime_guest_path_mappings(vm);
let read_paths = expand_host_access_paths(
std::iter::once(vm.cwd.clone())
.chain(
path_mappings
.iter()
.map(|mapping| PathBuf::from(&mapping.host_path)),
)
.chain(std::iter::once(host_cwd.to_path_buf()))
.collect::<Vec<_>>()
.as_slice(),
);
let write_paths = dedupe_host_paths(
std::iter::once(vm.cwd.clone())
.chain(std::iter::once(host_cwd.to_path_buf()))
.chain(runtime_guest_writable_host_paths(vm))
.collect::<Vec<_>>()
.as_slice(),
);
let allowed_node_builtins = configured_allowed_node_builtins(vm);
let loopback_exempt_ports = configured_loopback_exempt_ports(vm);
env.insert(
String::from("AGENTOS_GUEST_PATH_MAPPINGS"),
serde_json::to_string(&path_mappings).map_err(|error| {
SidecarError::InvalidState(format!("failed to encode guest path mappings: {error}"))
})?,
);
env.entry(String::from(EXECUTION_SANDBOX_ROOT_ENV))
.or_insert_with(|| normalize_host_path(&vm.cwd).to_string_lossy().into_owned());
env.insert(
String::from("AGENTOS_EXTRA_FS_READ_PATHS"),
serde_json::to_string(
&read_paths
.iter()
.map(|path| path.to_string_lossy().into_owned())
.collect::<Vec<_>>(),
)
.map_err(|error| {
SidecarError::InvalidState(format!("failed to encode read paths: {error}"))
})?,
);
env.insert(
String::from("AGENTOS_EXTRA_FS_WRITE_PATHS"),
serde_json::to_string(
&write_paths
.iter()
.map(|path| path.to_string_lossy().into_owned())
.collect::<Vec<_>>(),
)
.map_err(|error| {
SidecarError::InvalidState(format!("failed to encode write paths: {error}"))
})?,
);
env.insert(
String::from("AGENTOS_ALLOWED_NODE_BUILTINS"),
serde_json::to_string(&allowed_node_builtins).map_err(|error| {
SidecarError::InvalidState(format!("failed to encode allowed builtins: {error}"))
})?,
);
// The guest JS host platform drives subtractive global scrubbing in the
// per-execution runtime shim (see prepend_v8_runtime_shim).
env.insert(
String::from("AGENTOS_JS_PLATFORM"),
js_runtime_platform_env(vm).to_owned(),
);
// Module-resolution mode (omitted when full Node resolution / the default).
if let Some(resolution) = js_runtime_module_resolution_env(vm) {
env.insert(
String::from("AGENTOS_JS_MODULE_RESOLUTION"),
resolution.to_owned(),
);
}
// Builtin allow-list gate for the live resolver. Present only when builtins
// should be restricted (non-node platform => deny all; node + explicit
// allow-list => exactly those). Absent => unrestricted (node default).
if let Some(allowlist) = js_runtime_enforced_builtins(vm) {
env.insert(
String::from("AGENTOS_JS_BUILTIN_ALLOWLIST"),
serde_json::to_string(&allowlist).map_err(|error| {
SidecarError::InvalidState(format!(
"failed to encode jsRuntime builtin allow-list: {error}"
))
})?,
);
}
// Virtual OS identity (os.cpus/totalmem/freemem/homedir/userInfo/...) now
// rides the typed `guest_runtime` (see `guest_runtime_identity`), exposed to
// the guest as the `__agentOSVirtualOs` structured global by the runtime
// shim — no longer the `AGENTOS_VIRTUAL_OS_*` env vars.
// Virtual process uid/gid now ride the typed `guest_runtime` identity
// (see `guest_runtime_identity`), not the `AGENTOS_VIRTUAL_PROCESS_*` env.
env.entry(String::from("HOME"))
.or_insert_with(|| user.homedir.clone());
env.entry(String::from("USER"))
.or_insert_with(|| user.username.clone());
env.entry(String::from("LOGNAME"))
.or_insert_with(|| user.username.clone());
env.entry(String::from("SHELL"))
.or_insert_with(|| user.shell.clone());
env.entry(String::from("PATH")).or_insert_with(|| {
vm.guest_env
.get("PATH")
.cloned()
.unwrap_or_else(|| crate::vm::DEFAULT_GUEST_PATH_ENV.to_owned())
});
env.entry(String::from("TMPDIR"))
.or_insert_with(|| String::from("/tmp"));
env.insert(String::from("PWD"), guest_cwd.to_owned());
if !loopback_exempt_ports.is_empty() {
env.insert(
String::from(LOOPBACK_EXEMPT_PORTS_ENV),
serde_json::to_string(&loopback_exempt_ports).map_err(|error| {
SidecarError::InvalidState(format!("failed to encode loopback exemptions: {error}"))
})?,
);
}
if let Some(guest_entrypoint) = guest_entrypoint {
env.insert(String::from("AGENTOS_GUEST_ENTRYPOINT"), guest_entrypoint);
}
Ok(())
}
fn virtual_os_cpu_count(resource_limits: &ResourceLimits) -> usize {
resource_limits.virtual_cpu_count.unwrap_or(1).max(1)
}
fn virtual_os_totalmem_bytes(resource_limits: &ResourceLimits) -> u64 {
resource_limits
.max_wasm_memory_bytes
.unwrap_or(1024 * 1024 * 1024)
}
fn virtual_os_freemem_bytes(resource_limits: &ResourceLimits) -> u64 {
resource_limits
.max_wasm_memory_bytes
.unwrap_or(512 * 1024 * 1024)
}
/// Build the typed per-execution JavaScript limits from the per-VM `VmLimits`
/// (sourced from `CreateVmConfig` on the BARE wire). These ride the execution
/// request, not `AGENTOS_*` env vars — see the env-vs-wire rule in
/// `crates/sidecar/CLAUDE.md`.
fn javascript_execution_limits(vm: &VmState) -> JavascriptExecutionLimits {
JavascriptExecutionLimits {
v8_heap_limit_mb: vm.limits.js_runtime.v8_heap_limit_mb,
sync_rpc_wait_timeout_ms: vm.limits.js_runtime.sync_rpc_wait_timeout_ms,
}
}
/// Build the typed per-execution guest-runtime identity (virtual `process.*`)
/// from kernel state. Replaces the `AGENTOS_VIRTUAL_PROCESS_{UID,GID,PID,PPID}`
/// env round-trip: the runtime shim reads these from `guest_runtime`, not env.
/// `uid`/`gid` come from the VM user profile (applied to every guest);
/// `pid`/`ppid` are per-process and only set for paths that assigned them.
fn guest_runtime_identity(
vm: &VmState,
virtual_pid: Option<u64>,
virtual_ppid: Option<u64>,
) -> GuestRuntimeConfig {
let user = vm.kernel.user_profile();
let resource_limits = vm.kernel.resource_limits();
GuestRuntimeConfig {
virtual_uid: Some(u64::from(user.uid)),
virtual_gid: Some(u64::from(user.gid)),
virtual_pid,
virtual_ppid,
virtual_exec_path: None,
os_cpu_count: Some(virtual_os_cpu_count(resource_limits) as u64),
os_totalmem: Some(virtual_os_totalmem_bytes(resource_limits)),
os_freemem: Some(virtual_os_freemem_bytes(resource_limits)),
os_homedir: Some(user.homedir.clone()),
os_hostname: None,
os_shell: Some(user.shell.clone()),
os_user: Some(user.username.clone()),
}
}
/// The guest's virtual home directory, sourced from the VM user profile (the
/// same value carried to the guest as `os.homedir()` via `guest_runtime`). Used
/// by sidecar-internal `~`-path resolution; falls back to `/root` for a
/// non-absolute profile value.
fn guest_virtual_home(vm: &VmState) -> String {
let homedir = vm.kernel.user_profile().homedir;
if homedir.starts_with('/') {
homedir
} else {
String::from("/root")
}
}
/// Build the typed per-execution Python limits from the per-VM `VmLimits`.
fn python_execution_limits(vm: &VmState) -> PythonExecutionLimits {
PythonExecutionLimits {
output_buffer_max_bytes: Some(vm.limits.python.output_buffer_max_bytes),
execution_timeout_ms: Some(vm.limits.python.execution_timeout_ms),
max_old_space_mb: Some(vm.limits.python.max_old_space_mb),
vfs_rpc_timeout_ms: Some(vm.limits.python.vfs_rpc_timeout_ms),
}
}
/// Build the typed per-execution WebAssembly limits from the per-VM kernel
/// `ResourceLimits`. Replaces the old `apply_wasm_limit_env` env round-trip;
/// notably this is the path that finally enforces the stack cap that the
/// `AGENTOS_WASM_MAX_STACK_BYTES` env knob set but no reader consumed.
fn wasm_execution_limits(vm: &VmState) -> WasmExecutionLimits {
let resource_limits = vm.kernel.resource_limits();
WasmExecutionLimits {
max_fuel: resource_limits.max_wasm_fuel,
max_memory_bytes: resource_limits.max_wasm_memory_bytes,
max_stack_bytes: resource_limits
.max_wasm_stack_bytes
.map(|value| value as u64),
}
}
/// The guest JavaScript host platform configured for this VM, defaulting to
/// full Node.js emulation when no `jsRuntime` config was supplied at create.
fn js_runtime_platform(vm: &VmState) -> vm_config::JsRuntimePlatform {
vm.configuration
.js_runtime
.as_ref()
.map(|cfg| cfg.platform)
.unwrap_or(vm_config::JsRuntimePlatform::Node)
}
/// Lowercase wire name for the configured platform, mirroring the serde
/// representation of `vm_config::JsRuntimePlatform`.
fn js_runtime_platform_env(vm: &VmState) -> &'static str {
match js_runtime_platform(vm) {
vm_config::JsRuntimePlatform::Node => "node",
vm_config::JsRuntimePlatform::Browser => "browser",
vm_config::JsRuntimePlatform::Neutral => "neutral",
vm_config::JsRuntimePlatform::Bare => "bare",
}
}
/// Wire name for the configured module-resolution mode, or `None` when it is the
/// full-Node default (which the live resolver also assumes when the env is unset).
fn js_runtime_module_resolution_env(vm: &VmState) -> Option<&'static str> {
let resolution = vm
.configuration
.js_runtime
.as_ref()
.map(|cfg| cfg.module_resolution)
.unwrap_or(vm_config::JsModuleResolution::Node);
match resolution {
vm_config::JsModuleResolution::Node => None,
vm_config::JsModuleResolution::Relative => Some("relative"),
vm_config::JsModuleResolution::None => Some("none"),
}
}
/// The builtin allow-list the live resolver should enforce, or `None` to leave
/// builtins unrestricted (full Node default — preserving today's behavior).
/// Non-node platforms enforce an empty list (deny all builtins).
fn js_runtime_enforced_builtins(vm: &VmState) -> Option<Vec<String>> {
if js_runtime_platform(vm) != vm_config::JsRuntimePlatform::Node {
return Some(Vec::new());
}
vm.configuration
.js_runtime
.as_ref()
.and_then(|cfg| cfg.allowed_builtins.clone())
}
fn configured_allowed_node_builtins(vm: &VmState) -> Vec<String> {
// Non-node platforms expose no Node builtin modules at all.
if js_runtime_platform(vm) != vm_config::JsRuntimePlatform::Node {
return Vec::new();
}
// Under the node platform an explicit allow-list wins — including an explicit
// empty list, which means deny all. Absence falls back to the engine default.
let configured = match vm
.configuration
.js_runtime
.as_ref()
.and_then(|cfg| cfg.allowed_builtins.as_ref())
{
Some(list) => list.clone(),
None => DEFAULT_ALLOWED_NODE_BUILTINS
.iter()
.map(|value| (*value).to_owned())
.collect::<Vec<_>>(),
};
dedupe_strings(&configured)
}
fn configured_loopback_exempt_ports(vm: &VmState) -> Vec<String> {
if !vm.configuration.loopback_exempt_ports.is_empty() {
return vm
.configuration
.loopback_exempt_ports
.iter()
.map(ToString::to_string)
.collect();
}
vm.create_loopback_exempt_ports
.iter()
.map(ToString::to_string)
.collect()
}
/// Extract the `hostPath` string from a mount plugin's JSON-encoded config.
fn mount_config_host_path(config: &str) -> Option<String> {
serde_json::from_str::<Value>(config)
.ok()?
.get("hostPath")
.and_then(Value::as_str)
.map(str::to_owned)
}
fn runtime_guest_writable_host_paths(vm: &VmState) -> Vec<PathBuf> {
vm.configuration
.mounts
.iter()
.filter(|mount| !mount.read_only)
.filter_map(|mount| {
((mount.plugin.id == "host_dir") || (mount.plugin.id == "module_access"))
.then(|| mount_config_host_path(&mount.plugin.config))
.flatten()
.map(PathBuf::from)
})
.collect()
}
fn runtime_guest_path_mappings(vm: &VmState) -> Vec<RuntimeGuestPathMapping> {
let mut mappings = vm
.configuration
.mounts
.iter()
.filter_map(|mount| {
((mount.plugin.id == "host_dir") || (mount.plugin.id == "module_access"))
.then(|| {
mount_config_host_path(&mount.plugin.config).map(|host_path| {
RuntimeGuestPathMapping {
guest_path: normalize_path(&mount.guest_path),
host_path,
read_only: mount.read_only,
}
})
})
.flatten()
})
.collect::<Vec<_>>();
let mut command_root_mappings = vm
.command_guest_paths
.values()
.filter_map(|guest_path| {
Path::new(guest_path)
.parent()
.and_then(|parent| parent.to_str())
.map(normalize_path)
})
.collect::<BTreeSet<_>>()
.into_iter()
.map(|guest_path| RuntimeGuestPathMapping {
host_path: resolve_vm_guest_path_to_host(vm, &guest_path)
.to_string_lossy()
.into_owned(),
guest_path,
read_only: false,
})
.collect::<Vec<_>>();
mappings.append(&mut command_root_mappings);
let mut extra_node_modules_roots = mappings
.iter()
.filter(|mapping| mapping.guest_path.starts_with("/root/node_modules/"))
.filter_map(|mapping| {
host_node_modules_root(Path::new(&mapping.host_path)).map(|host_root| {
RuntimeGuestPathMapping {
guest_path: String::from("/root/node_modules"),
host_path: host_root.to_string_lossy().into_owned(),
read_only: mapping.read_only,
}
})
})
.collect::<Vec<_>>();
mappings.append(&mut extra_node_modules_roots);
mappings.push(RuntimeGuestPathMapping {
guest_path: String::from("/"),
host_path: vm.cwd.to_string_lossy().into_owned(),
read_only: false,
});
mappings.sort_by_key(|mapping| std::cmp::Reverse(mapping.guest_path.len()));
mappings.dedup_by(|left, right| {
left.guest_path == right.guest_path && left.host_path == right.host_path
});
mappings
}
/// Build a `Send`-able, read-only VFS module reader over the VM's read-only
/// `host_dir`/`module_access` mounts (and the derived `/root/node_modules` root
/// for nested mounts). When present, the V8 bridge thread resolves modules
/// inline against this reader — concurrently with the service loop — so a large
/// cold-start module graph never serializes behind / starves an in-flight ACP
/// `session/new` bootstrap on the single service-loop thread. The reader reads
/// the same mounted tree the guest sees (anchored `openat2`, escaping-symlink
/// refusal), never the host-direct path translator. Returns `None` when the VM
/// has no usable read-only mount, so resolution falls back to the service-loop
/// kernel reader.
fn build_module_reader(
vm: &VmState,
resolved: &ResolvedChildProcessExecution,
) -> Option<crate::plugins::host_dir::HostDirModuleReader> {
let mut pairs: Vec<(String, PathBuf)> = vm
.configuration
.mounts
.iter()
.filter(|mount| mount.read_only)
.filter(|mount| (mount.plugin.id == "host_dir") || (mount.plugin.id == "module_access"))
.filter_map(|mount| {
mount_config_host_path(&mount.plugin.config)
.map(|host_path| (normalize_path(&mount.guest_path), PathBuf::from(host_path)))
})
.collect();
let guest_entrypoint = resolved
.env
.get("AGENTOS_GUEST_ENTRYPOINT")
.map(|path| normalize_path(path));
if let Some(guest_entrypoint) = guest_entrypoint.as_deref() {
let entrypoint_in_read_only_mount = pairs.iter().any(|(guest_path, _)| {
guest_entrypoint == guest_path
|| guest_entrypoint.starts_with(&format!("{guest_path}/"))
});
if !entrypoint_in_read_only_mount {
return None;
}
}
// Mirror runtime_guest_path_mappings: a mount nested under
// `/root/node_modules/<pkg>` implies a `/root/node_modules` root the resolver
// walks, so expose that root too (e.g. software-package mounts).
let extra_roots: Vec<(String, PathBuf)> = pairs
.iter()
.filter(|(guest_path, _)| guest_path.starts_with("/root/node_modules/"))
.filter_map(|(_, host_path)| {
host_node_modules_root(host_path).map(|root| (String::from("/root/node_modules"), root))
})
.collect();
pairs.extend(extra_roots);
crate::plugins::host_dir::HostDirModuleReader::from_mounts(pairs)
}
fn host_node_modules_root(path: &Path) -> Option<PathBuf> {
if let Some(root) = path
.ancestors()
.filter(|candidate| {
candidate.file_name().and_then(|name| name.to_str()) == Some("node_modules")
})
.last()
.map(Path::to_path_buf)
{
return Some(root);
}
fs::canonicalize(path)
.ok()?
.ancestors()
.filter(|candidate| {
candidate.file_name().and_then(|name| name.to_str()) == Some("node_modules")
})
.last()
.map(Path::to_path_buf)
}
#[cfg(test)]
mod runtime_guest_path_mapping_tests {
use super::{host_node_modules_root, javascript_sync_rpc_option_bool};
use serde_json::json;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn host_node_modules_root_prefers_workspace_root_over_pnpm_package_node_modules() {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should be monotonic")
.as_nanos();
let temp = std::env::temp_dir().join(format!("secure-exec-sidecar-node-modules-{unique}"));
let workspace_node_modules = temp.join("node_modules");
let package_root = workspace_node_modules
.join(".pnpm")
.join("example@1.0.0")
.join("node_modules")
.join("@scope")
.join("pkg");
fs::create_dir_all(&package_root).expect("package root should be created");
let resolved =
host_node_modules_root(&package_root).expect("node_modules root should resolve");
assert_eq!(resolved, workspace_node_modules);
fs::remove_dir_all(&temp).expect("temp tree should be removed");
}
#[test]
fn host_node_modules_root_preserves_symlinked_workspace_node_modules_path() {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should be monotonic")
.as_nanos();
let temp =
std::env::temp_dir().join(format!("secure-exec-sidecar-node-modules-symlink-{unique}"));
let workspace_node_modules = temp.join("node_modules");
let package_link = workspace_node_modules.join("@scope").join("pkg");
let real_package = temp.join("registry").join("agent").join("pkg");
fs::create_dir_all(package_link.parent().expect("package parent should exist"))
.expect("scoped parent should be created");
fs::create_dir_all(&real_package).expect("real package root should be created");
std::os::unix::fs::symlink(&real_package, &package_link)
.expect("package symlink should be created");
let resolved =
host_node_modules_root(&package_link).expect("node_modules root should resolve");
assert_eq!(resolved, workspace_node_modules);
fs::remove_dir_all(&temp).expect("temp tree should be removed");
}
#[test]
fn javascript_sync_rpc_option_bool_accepts_boolean_recursive_argument() {
assert_eq!(
javascript_sync_rpc_option_bool(&[json!("/workspace"), json!(true)], 1, "recursive"),
Some(true)
);
assert_eq!(
javascript_sync_rpc_option_bool(
&[json!("/workspace"), json!({ "recursive": false })],
1,
"recursive"
),
Some(false)
);
}
}
#[cfg(test)]
mod kernel_poll_sync_rpc_tests {
use super::{
service_javascript_kernel_poll_sync_rpc, ActiveExecution, ActiveProcess,
JavascriptSyncRpcRequest, KernelPollFdResponse, SidecarKernel, ToolExecution,
EXECUTION_DRIVER_NAME, JAVASCRIPT_COMMAND,
};
use secure_exec_kernel::command_registry::CommandDriver;
use secure_exec_kernel::kernel::{KernelVmConfig, SpawnOptions};
use secure_exec_kernel::mount_table::MountTable;
use secure_exec_kernel::permissions::Permissions;
use secure_exec_kernel::poll::{POLLHUP, POLLIN};
use secure_exec_kernel::vfs::MemoryFileSystem;
use serde_json::{json, Value};
#[test]
fn javascript_kernel_poll_sync_rpc_reports_multiple_kernel_fds() {
let mut config = KernelVmConfig::new("vm-js-kernel-poll");
config.permissions = Permissions::allow_all();
let mut kernel = SidecarKernel::new(MountTable::new(MemoryFileSystem::new()), config);
kernel
.register_driver(CommandDriver::new(
EXECUTION_DRIVER_NAME,
[JAVASCRIPT_COMMAND],
))
.expect("register execution driver");
let kernel_handle = kernel
.spawn_process(
JAVASCRIPT_COMMAND,
Vec::new(),
SpawnOptions {
requester_driver: Some(String::from(EXECUTION_DRIVER_NAME)),
..SpawnOptions::default()
},
)
.expect("spawn javascript kernel process");
let pid = kernel_handle.pid();
let (stdin_read_fd, stdin_write_fd) = kernel
.open_pipe(EXECUTION_DRIVER_NAME, pid)
.expect("open kernel stdin pipe");
kernel
.fd_dup2(EXECUTION_DRIVER_NAME, pid, stdin_read_fd, 0)
.expect("dup stdin pipe onto fd 0");
kernel
.fd_close(EXECUTION_DRIVER_NAME, pid, stdin_read_fd)
.expect("close original stdin read fd");
let process = ActiveProcess::new(
pid,
kernel_handle,
super::GuestRuntimeKind::JavaScript,
ActiveExecution::Tool(ToolExecution::default()),
);
kernel
.fd_write(EXECUTION_DRIVER_NAME, pid, stdin_write_fd, b"poll-ready")
.expect("write kernel stdin payload");
kernel
.fd_close(EXECUTION_DRIVER_NAME, pid, stdin_write_fd)
.expect("close kernel stdin writer");
let response = service_javascript_kernel_poll_sync_rpc(
&mut kernel,
&process,
&JavascriptSyncRpcRequest {
id: 1,
method: String::from("__kernel_poll"),
args: vec![
json!([
{ "fd": 0, "events": POLLIN.bits() },
{ "fd": 1, "events": POLLIN.bits() }
]),
json!(250),
],
},
)
.expect("poll kernel fds");
assert_eq!(response["readyCount"], Value::from(1));
let fds: Vec<KernelPollFdResponse> =
serde_json::from_value(response["fds"].clone()).expect("kernel poll fd response");
assert_eq!(
fds,
vec![
KernelPollFdResponse {
fd: 0,
events: POLLIN.bits(),
revents: (POLLIN | POLLHUP).bits(),
},
KernelPollFdResponse {
fd: 1,
events: POLLIN.bits(),
revents: 0,
},
]
);
process.kernel_handle.finish(0);
kernel.waitpid(pid).expect("wait javascript kernel process");
}
}
fn dedupe_strings(values: &[String]) -> Vec<String> {
let mut seen = BTreeSet::new();
let mut deduped = Vec::new();
for value in values {
if seen.insert(value.clone()) {
deduped.push(value.clone());
}
}
deduped
}
fn dedupe_host_paths(paths: &[PathBuf]) -> Vec<PathBuf> {
let mut seen = BTreeSet::new();
let mut deduped = Vec::new();
for path in paths {
let normalized = normalize_host_path(path);
let key = normalized.to_string_lossy().into_owned();
if seen.insert(key) {
deduped.push(normalized);
}
}
deduped
}
fn expand_host_access_paths(paths: &[PathBuf]) -> Vec<PathBuf> {
let mut expanded = Vec::new();
let mut seen = BTreeSet::new();
let mut add_path = |candidate: PathBuf| {
let normalized = normalize_host_path(&candidate);
let key = normalized.to_string_lossy().into_owned();
if seen.insert(key) {
expanded.push(normalized);
}
};
for host_path in paths {
add_path(host_path.clone());
if let Ok(realpath) = fs::canonicalize(host_path) {
add_path(realpath);
}
if host_path.file_name().and_then(|name| name.to_str()) != Some("node_modules") {
continue;
}
let mut current = host_path.parent();
while let Some(parent) = current {
let candidate = parent.join("node_modules");
if candidate.exists() {
add_path(candidate.clone());
if let Ok(realpath) = fs::canonicalize(&candidate) {
add_path(realpath);
}
}
current = parent.parent();
}
}
expanded
}
fn prepare_javascript_shadow(
vm: &mut VmState,
resolved: &ResolvedChildProcessExecution,
) -> Result<(), SidecarError> {
let guest_entrypoint = resolved
.env
.get("AGENTOS_GUEST_ENTRYPOINT")
.cloned()
// An absolute `entrypoint` may be a host path that lives inside the VM's
// host cwd (callers can pass a fully-qualified host path). The guest sees
// it at its translated guest path (host_cwd -> guest_cwd), so the shadow
// must be keyed by that guest path rather than the raw host path. Falling
// back to the host path here would materialize the file at the wrong guest
// location and the runtime's `require()` would fail with "Cannot find
// module".
.or_else(|| {
resolve_host_entrypoint_within_vm_host_cwd(vm, &resolved.entrypoint)
.map(|(guest_entrypoint, _)| guest_entrypoint)
})
.or_else(|| {
resolved
.entrypoint
.starts_with('/')
.then(|| normalize_path(&resolved.entrypoint))
});
let Some(guest_entrypoint) = guest_entrypoint else {
return Ok(());
};
if host_mount_path_for_guest_path(vm, &guest_entrypoint).is_some() {
return Ok(());
}
if vm.kernel.lstat(&guest_entrypoint).is_err() {
let host_entrypoint = {
let candidate = Path::new(&resolved.entrypoint);
if candidate.is_absolute() {
candidate.to_path_buf()
} else {
resolved.host_cwd.join(candidate)
}
};
if host_entrypoint.exists() {
materialize_host_path_to_shadow(vm, &guest_entrypoint, &host_entrypoint)?;
// The shadow write only stages the file on the host side; the runtime
// resolves modules against the kernel VFS, so the staged entrypoint
// must be synced into the kernel before execution starts (otherwise
// `require()` reports "Cannot find module").
return sync_shadow_entrypoint_into_kernel(vm, &guest_entrypoint);
}
}
materialize_guest_path_to_shadow(vm, &guest_entrypoint)
}
/// Sync a freshly-staged shadow entrypoint into the kernel VFS so the runtime's
/// kernel-backed module resolver can read it. Mirrors the host->kernel file sync
/// used by the broader shadow reconciliation, but scoped to the single
/// entrypoint we just materialized.
fn sync_shadow_entrypoint_into_kernel(
vm: &mut VmState,
guest_entrypoint: &str,
) -> Result<(), SidecarError> {
if vm.kernel.exists(guest_entrypoint).unwrap_or(false) {
return Ok(());
}
let shadow_path = shadow_path_for_guest(vm, guest_entrypoint);
let bytes = match fs::read(&shadow_path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => {
return Err(SidecarError::Io(format!(
"failed to read staged shadow entrypoint {}: {error}",
shadow_path.display()
)));
}
};
if let Some(parent) = guest_parent_path(guest_entrypoint) {
if !vm.kernel.exists(&parent).unwrap_or(false) {
vm.kernel.mkdir(&parent, true).map_err(kernel_error)?;
}
}
vm.kernel
.write_file(guest_entrypoint, bytes)
.map_err(kernel_error)?;
Ok(())
}
fn guest_parent_path(guest_path: &str) -> Option<String> {
let parent = Path::new(guest_path).parent()?;
let parent = parent.to_string_lossy();
if parent.is_empty() || parent == "/" {
None
} else {
Some(parent.into_owned())
}
}
fn materialize_host_path_to_shadow(
vm: &VmState,
guest_path: &str,
host_path: &Path,
) -> Result<(), SidecarError> {
let shadow_path = shadow_path_for_guest(vm, guest_path);
let metadata = fs::symlink_metadata(host_path)
.map_err(|error| SidecarError::Io(format!("failed to stat host entrypoint: {error}")))?;
if metadata.file_type().is_symlink() {
if let Some(parent) = shadow_path.parent() {
fs::create_dir_all(parent).map_err(|error| {
SidecarError::Io(format!("failed to create shadow symlink parent: {error}"))
})?;
}
let _ = fs::remove_file(&shadow_path);
let _ = fs::remove_dir_all(&shadow_path);
let target = fs::read_link(host_path)
.map_err(|error| SidecarError::Io(format!("failed to read host symlink: {error}")))?;
std::os::unix::fs::symlink(&target, &shadow_path)
.map_err(|error| SidecarError::Io(format!("failed to mirror host symlink: {error}")))?;
return Ok(());
}
if metadata.is_dir() {
fs::create_dir_all(&shadow_path).map_err(|error| {
SidecarError::Io(format!("failed to create shadow directory: {error}"))
})?;
fs::set_permissions(
&shadow_path,
fs::Permissions::from_mode(metadata.permissions().mode() & 0o7777),
)
.map_err(|error| {
SidecarError::Io(format!(
"failed to set shadow directory mode on {}: {error}",
shadow_path.display()
))
})?;
return Ok(());
}
if let Some(parent) = shadow_path.parent() {
fs::create_dir_all(parent).map_err(|error| {
SidecarError::Io(format!("failed to create shadow parent: {error}"))
})?;
}
let bytes = fs::read(host_path)
.map_err(|error| SidecarError::Io(format!("failed to read host entrypoint: {error}")))?;
fs::write(&shadow_path, bytes).map_err(|error| {
SidecarError::Io(format!(
"failed to mirror host file into shadow root: {error}"
))
})?;
fs::set_permissions(
&shadow_path,
fs::Permissions::from_mode(metadata.permissions().mode() & 0o7777),
)
.map_err(|error| {
SidecarError::Io(format!(
"failed to set shadow file mode on {}: {error}",
shadow_path.display()
))
})?;
Ok(())
}
fn materialize_guest_path_to_shadow(
vm: &mut VmState,
guest_path: &str,
) -> Result<(), SidecarError> {
let stat = vm.kernel.lstat(guest_path).map_err(kernel_error)?;
let shadow_path = shadow_path_for_guest(vm, guest_path);
if stat.is_symbolic_link {
if let Some(parent) = shadow_path.parent() {
fs::create_dir_all(parent).map_err(|error| {
SidecarError::Io(format!("failed to create shadow symlink parent: {error}"))
})?;
}
let _ = fs::remove_file(&shadow_path);
let _ = fs::remove_dir_all(&shadow_path);
let target = vm.kernel.read_link(guest_path).map_err(kernel_error)?;
std::os::unix::fs::symlink(&target, &shadow_path)
.map_err(|error| SidecarError::Io(format!("failed to mirror symlink: {error}")))?;
return Ok(());
}
if stat.is_directory {
fs::create_dir_all(&shadow_path).map_err(|error| {
SidecarError::Io(format!("failed to create shadow directory: {error}"))
})?;
fs::set_permissions(&shadow_path, fs::Permissions::from_mode(stat.mode & 0o7777)).map_err(
|error| {
SidecarError::Io(format!(
"failed to set shadow directory mode on {}: {error}",
shadow_path.display()
))
},
)?;
return Ok(());
}
if let Some(parent) = shadow_path.parent() {
fs::create_dir_all(parent).map_err(|error| {
SidecarError::Io(format!("failed to create shadow parent: {error}"))
})?;
}
let bytes = vm.kernel.read_file(guest_path).map_err(kernel_error)?;
fs::write(&shadow_path, bytes).map_err(|error| {
SidecarError::Io(format!(
"failed to mirror guest file into shadow root: {error}"
))
})?;
fs::set_permissions(&shadow_path, fs::Permissions::from_mode(stat.mode & 0o7777)).map_err(
|error| {
SidecarError::Io(format!(
"failed to set shadow file mode on {}: {error}",
shadow_path.display()
))
},
)?;
Ok(())
}
fn load_javascript_entrypoint_source(
vm: &mut VmState,
host_cwd: &Path,
entrypoint: &str,
env: &BTreeMap<String, String>,
) -> Option<String> {
let mut read_guest_file = |path: &str| {
vm.kernel
.read_file(path)
.ok()
.and_then(|bytes| String::from_utf8(bytes).ok())
};
if let Some(source) = env
.get("AGENTOS_GUEST_ENTRYPOINT")
.filter(|path| path.starts_with('/'))
.and_then(|path| read_guest_file(path))
{
return Some(source);
}
if entrypoint.starts_with('/') {
if let Some(source) = read_guest_file(entrypoint) {
return Some(source);
}
}
let host_entrypoint = if Path::new(entrypoint).is_absolute() {
PathBuf::from(entrypoint)
} else {
host_cwd.join(entrypoint)
};
let normalized_entrypoint = normalize_host_path(&host_entrypoint);
let sandbox_root = normalize_host_path(&vm.cwd);
let host_cwd = normalize_host_path(&vm.host_cwd);
if !path_is_within_root(&normalized_entrypoint, &sandbox_root)
&& !path_is_within_root(&normalized_entrypoint, &host_cwd)
{
return None;
}
fs::read_to_string(&normalized_entrypoint).ok()
}
fn emit_dns_resolution_event<B>(
bridge: &SharedBridge<B>,
vm_id: &str,
hostname: &str,
source: KernelDnsResolutionSource,
addresses: &[IpAddr],
dns: &VmDnsConfig,
) where
B: NativeSidecarBridge + Send + 'static,
BridgeError<B>: fmt::Debug + Send + Sync + 'static,
{
let _ = emit_structured_event(
bridge,
vm_id,
"network.dns.resolved",
audit_fields([
("hostname", hostname.to_owned()),
("source", source.as_str().to_owned()),
(
"addresses",
addresses
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(","),
),
("address_count", addresses.len().to_string()),
("resolver_count", dns.name_servers.len().to_string()),
(
"resolvers",
dns.name_servers
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(","),
),
]),
);
}
fn emit_dns_record_resolution_event<B>(
bridge: &SharedBridge<B>,
vm_id: &str,
hostname: &str,
resolution: &DnsRecordResolution,
dns: &VmDnsConfig,
) where
B: NativeSidecarBridge + Send + 'static,
BridgeError<B>: fmt::Debug + Send + Sync + 'static,
{
if let Some(addresses) = dns_resolution_ip_addrs(resolution.records()) {
emit_dns_resolution_event(
bridge,
vm_id,
hostname,
resolution.source(),
&addresses,
dns,
);
return;
}
let _ = emit_structured_event(
bridge,
vm_id,
"network.dns.resolved",
audit_fields([
("hostname", hostname.to_owned()),
("source", resolution.source().as_str().to_owned()),
(
"addresses",
resolution
.records()
.iter()
.map(summarize_dns_record)
.collect::<Vec<_>>()
.join(","),
),
("address_count", resolution.records().len().to_string()),
("resolver_count", dns.name_servers.len().to_string()),
(
"resolvers",
dns.name_servers
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(","),
),
]),
);
}
fn emit_dns_resolution_failure_event<B>(
bridge: &SharedBridge<B>,
vm_id: &str,
hostname: &str,
dns: &VmDnsConfig,
error: &SidecarError,
) where
B: NativeSidecarBridge + Send + 'static,
BridgeError<B>: fmt::Debug + Send + Sync + 'static,
{
let _ = emit_structured_event(
bridge,
vm_id,
"network.dns.resolve_failed",
audit_fields([
("hostname", hostname.to_owned()),
("reason", error.to_string()),
("resolver_count", dns.name_servers.len().to_string()),
(
"resolvers",
dns.name_servers
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(","),
),
]),
);
}
fn parse_dns_record_type(rrtype: &str) -> Result<RecordType, SidecarError> {
match rrtype {
"A" => Ok(RecordType::A),
"AAAA" => Ok(RecordType::AAAA),
"MX" => Ok(RecordType::MX),
"TXT" => Ok(RecordType::TXT),
"SRV" => Ok(RecordType::SRV),
"CNAME" => Ok(RecordType::CNAME),
"PTR" => Ok(RecordType::PTR),
"NS" => Ok(RecordType::NS),
"SOA" => Ok(RecordType::SOA),
"NAPTR" => Ok(RecordType::NAPTR),
"CAA" => Ok(RecordType::CAA),
"ANY" => Ok(RecordType::ANY),
other => Err(SidecarError::Execution(format!(
"ERR_NOT_IMPLEMENTED: dns rrtype {other} is not supported by the secure-exec dns bridge"
))),
}
}
fn dns_resolution_to_node_value(
resolution: &DnsRecordResolution,
requested_type: &str,
) -> Result<Value, SidecarError> {
let safe_ips = dns_resolution_safe_ip_set(resolution.records(), resolution.hostname())?;
match requested_type {
"A" | "AAAA" => Ok(Value::Array(
resolution
.records()
.iter()
.filter_map(|record| dns_record_ip_string(record, &safe_ips))
.map(Value::String)
.collect(),
)),
"MX" => Ok(Value::Array(
resolution
.records()
.iter()
.filter_map(|record| match record.data() {
RData::MX(mx) => Some(json!({
"priority": mx.preference,
"exchange": normalize_dns_name_for_node(&mx.exchange),
"type": "MX",
})),
_ => None,
})
.collect(),
)),
"TXT" => Ok(Value::Array(
resolution
.records()
.iter()
.filter_map(|record| match record.data() {
RData::TXT(txt) => Some(Value::Array(
txt.txt_data
.iter()
.map(|entry| Value::String(String::from_utf8_lossy(entry).into_owned()))
.collect(),
)),
_ => None,
})
.collect(),
)),
"SRV" => Ok(Value::Array(
resolution
.records()
.iter()
.filter_map(|record| match record.data() {
RData::SRV(srv) => Some(json!({
"priority": srv.priority,
"weight": srv.weight,
"port": srv.port,
"name": normalize_dns_name_for_node(&srv.target),
"type": "SRV",
})),
_ => None,
})
.collect(),
)),
"CNAME" => Ok(Value::Array(
resolution
.records()
.iter()
.filter_map(|record| match record.data() {
RData::CNAME(name) => Some(Value::String(normalize_dns_name_for_node(&name.0))),
_ => None,
})
.collect(),
)),
"PTR" => Ok(Value::Array(
resolution
.records()
.iter()
.filter_map(|record| match record.data() {
RData::PTR(name) => Some(Value::String(normalize_dns_name_for_node(&name.0))),
_ => None,
})
.collect(),
)),
"NS" => Ok(Value::Array(
resolution
.records()
.iter()
.filter_map(|record| match record.data() {
RData::NS(name) => Some(Value::String(normalize_dns_name_for_node(&name.0))),
_ => None,
})
.collect(),
)),
"SOA" => resolution
.records()
.iter()
.find_map(|record| match record.data() {
RData::SOA(soa) => Some(json!({
"nsname": normalize_dns_name_for_node(&soa.mname),
"hostmaster": normalize_dns_name_for_node(&soa.rname),
"serial": soa.serial,
"refresh": soa.refresh,
"retry": soa.retry,
"expire": soa.expire,
"minttl": soa.minimum,
})),
_ => None,
})
.ok_or_else(|| {
SidecarError::Execution(String::from("failed to resolve DNS SOA record"))
}),
"NAPTR" => Ok(Value::Array(
resolution
.records()
.iter()
.filter_map(|record| match record.data() {
RData::NAPTR(naptr) => Some(json!({
"flags": String::from_utf8_lossy(&naptr.flags).into_owned(),
"service": String::from_utf8_lossy(&naptr.services).into_owned(),
"regexp": String::from_utf8_lossy(&naptr.regexp).into_owned(),
"replacement": normalize_dns_name_for_node(&naptr.replacement),
"order": naptr.order,
"preference": naptr.preference,
})),
_ => None,
})
.collect(),
)),
"CAA" => Ok(Value::Array(
resolution
.records()
.iter()
.filter_map(|record| match record.data() {
RData::CAA(caa) => {
let mut value = serde_json::Map::new();
value.insert(
"critical".to_owned(),
Value::from(u8::from(caa.issuer_critical)),
);
value.insert("type".to_owned(), Value::String(String::from("CAA")));
if caa.tag.eq_ignore_ascii_case("iodef") {
value.insert(
"iodef".to_owned(),
Value::String(
caa.value_as_iodef()
.map(|url| url.to_string())
.unwrap_or_else(|_| {
String::from_utf8_lossy(&caa.value).into_owned()
}),
),
);
} else if let Ok((issuer, _params)) = caa.value_as_issue() {
let field = if caa.tag.eq_ignore_ascii_case("issuewild") {
"issuewild"
} else {
"issue"
};
value.insert(
field.to_owned(),
Value::String(
issuer.as_ref().map(ToString::to_string).unwrap_or_else(|| {
String::from_utf8_lossy(&caa.value).into_owned()
}),
),
);
} else {
value.insert(
caa.tag.to_ascii_lowercase(),
Value::String(String::from_utf8_lossy(&caa.value).into_owned()),
);
}
Some(Value::Object(value))
}
_ => None,
})
.collect(),
)),
"ANY" => Ok(Value::Array(
resolution
.records()
.iter()
.filter_map(|record| dns_any_record_to_value(record, &safe_ips))
.collect(),
)),
other => Err(SidecarError::Execution(format!(
"ERR_NOT_IMPLEMENTED: dns rrtype {other} is not supported by the secure-exec dns bridge"
))),
}
}
fn dns_resolution_safe_ip_set(
records: &[Record],
hostname: &str,
) -> Result<BTreeSet<IpAddr>, SidecarError> {
let ips = records
.iter()
.filter_map(dns_record_ip_addr)
.collect::<Vec<_>>();
if ips.is_empty() {
return Ok(BTreeSet::new());
}
Ok(filter_dns_safe_ip_addrs(ips, hostname)?
.into_iter()
.collect())
}
fn dns_resolution_ip_addrs(records: &[Record]) -> Option<Vec<IpAddr>> {
let ips = records
.iter()
.filter_map(dns_record_ip_addr)
.collect::<Vec<_>>();
if ips.is_empty() {
return None;
}
Some(ips)
}
fn dns_record_ip_addr(record: &Record) -> Option<IpAddr> {
match record.data() {
RData::A(address) => Some(IpAddr::V4(**address)),
RData::AAAA(address) => Some(IpAddr::V6(**address)),
_ => None,
}
}
fn dns_record_ip_string(record: &Record, safe_ips: &BTreeSet<IpAddr>) -> Option<String> {
let ip = dns_record_ip_addr(record)?;
safe_ips.contains(&ip).then(|| ip.to_string())
}
fn dns_any_record_to_value(record: &Record, safe_ips: &BTreeSet<IpAddr>) -> Option<Value> {
let value = match record.data() {
RData::A(_) | RData::AAAA(_) => json!({
"address": dns_record_ip_string(record, safe_ips)?,
"ttl": record.ttl(),
"type": record.record_type().to_string(),
}),
RData::MX(mx) => json!({
"exchange": normalize_dns_name_for_node(&mx.exchange),
"priority": mx.preference,
"type": "MX",
}),
RData::TXT(txt) => json!({
"entries": txt
.txt_data
.iter()
.map(|entry| String::from_utf8_lossy(entry).into_owned())
.collect::<Vec<_>>(),
"type": "TXT",
}),
RData::SRV(srv) => json!({
"name": normalize_dns_name_for_node(&srv.target),
"port": srv.port,
"priority": srv.priority,
"weight": srv.weight,
"type": "SRV",
}),
RData::CNAME(name) => json!({
"value": normalize_dns_name_for_node(&name.0),
"type": "CNAME",
}),
RData::PTR(name) => json!({
"value": normalize_dns_name_for_node(&name.0),
"type": "PTR",
}),
RData::NS(name) => json!({
"value": normalize_dns_name_for_node(&name.0),
"type": "NS",
}),
RData::SOA(soa) => json!({
"nsname": normalize_dns_name_for_node(&soa.mname),
"hostmaster": normalize_dns_name_for_node(&soa.rname),
"serial": soa.serial,
"refresh": soa.refresh,
"retry": soa.retry,
"expire": soa.expire,
"minttl": soa.minimum,
"type": "SOA",
}),
RData::NAPTR(naptr) => json!({
"flags": String::from_utf8_lossy(&naptr.flags).into_owned(),
"service": String::from_utf8_lossy(&naptr.services).into_owned(),
"regexp": String::from_utf8_lossy(&naptr.regexp).into_owned(),
"replacement": normalize_dns_name_for_node(&naptr.replacement),
"order": naptr.order,
"preference": naptr.preference,
"type": "NAPTR",
}),
RData::CAA(caa) => {
let mut value = serde_json::Map::new();
value.insert(
"critical".to_owned(),
Value::from(u8::from(caa.issuer_critical)),
);
value.insert("type".to_owned(), Value::String(String::from("CAA")));
if caa.tag.eq_ignore_ascii_case("iodef") {
value.insert(
"iodef".to_owned(),
Value::String(
caa.value_as_iodef()
.map(|url| url.to_string())
.unwrap_or_else(|_| String::from_utf8_lossy(&caa.value).into_owned()),
),
);
} else if let Ok((issuer, _params)) = caa.value_as_issue() {
let field = if caa.tag.eq_ignore_ascii_case("issuewild") {
"issuewild"
} else {
"issue"
};
value.insert(
field.to_owned(),
Value::String(
issuer
.as_ref()
.map(ToString::to_string)
.unwrap_or_else(|| String::from_utf8_lossy(&caa.value).into_owned()),
),
);
}
Value::Object(value)
}
_ => return None,
};
Some(value)
}
fn normalize_dns_name_for_node(name: &impl ToString) -> String {
name.to_string().trim_end_matches('.').to_owned()
}
fn summarize_dns_record(record: &Record) -> String {
match record.data() {
RData::A(_) | RData::AAAA(_) => record.data().to_string(),
_ => format!("{} {}", record.record_type(), record.data()),
}
}
// build_root_filesystem, convert_root_lower_descriptor, convert_root_filesystem_entry,
// root_snapshot_entry moved to crate::bootstrap
// apply_root_filesystem_entry, ensure_parent_directories moved to crate::bootstrap
// ProcNetEntry moved to crate::state
fn find_socket_state_entry(
vm: Option<&VmState>,
kind: SocketQueryKind,
request: &FindListenerRequest,
) -> Result<Option<SocketStateEntry>, SidecarError> {
let vm = vm.ok_or_else(|| SidecarError::InvalidState(String::from("unknown sidecar VM")))?;
for (process_id, process) in &vm.active_processes {
if let Some(path) = request.path.as_deref() {
if matches!(kind, SocketQueryKind::TcpListener) {
for listener in process.unix_listeners.values() {
if listener.path() != path {
continue;
}
return Ok(Some(SocketStateEntry {
process_id: process_id.to_owned(),
host: None,
port: None,
path: Some(path.to_owned()),
}));
}
}
}
if request.path.is_none() {
if let Some(entry) =
find_kernel_socket_state_entry(&vm.kernel, process_id, process, kind, request)?
{
return Ok(Some(entry));
}
match kind {
SocketQueryKind::TcpListener => {
for server in process.http_servers.values() {
let local_addr = server.guest_local_addr;
let local_host = local_addr.ip().to_string();
if !socket_host_matches(request.host.as_deref(), &local_host) {
continue;
}
if let Some(port) = request.port {
if local_addr.port() != port {
continue;
}
}
return Ok(Some(SocketStateEntry {
process_id: process_id.to_owned(),
host: Some(local_host),
port: Some(local_addr.port()),
path: None,
}));
}
for listener in process.tcp_listeners.values() {
if listener.kernel_socket_id.is_some() {
continue;
}
let local_addr = listener.guest_local_addr();
let local_host = local_addr.ip().to_string();
if !socket_host_matches(request.host.as_deref(), &local_host) {
continue;
}
if let Some(port) = request.port {
if local_addr.port() != port {
continue;
}
}
return Ok(Some(SocketStateEntry {
process_id: process_id.to_owned(),
host: Some(local_host),
port: Some(local_addr.port()),
path: None,
}));
}
}
SocketQueryKind::UdpBound => {
for socket in process.udp_sockets.values() {
if socket.kernel_socket_id.is_some() {
continue;
}
let Some(local_addr) = socket.local_addr() else {
continue;
};
let local_host = local_addr.ip().to_string();
if !socket_host_matches(request.host.as_deref(), &local_host) {
continue;
}
if let Some(port) = request.port {
if local_addr.port() != port {
continue;
}
}
return Ok(Some(SocketStateEntry {
process_id: process_id.to_owned(),
host: Some(local_host),
port: Some(local_addr.port()),
path: None,
}));
}
}
}
}
let child_pid = process.execution.child_pid();
let inodes = socket_inodes_for_pid(child_pid)?;
if inodes.is_empty() {
continue;
}
if let Some(path) = request.path.as_deref() {
if let Some(listener) = find_unix_socket_for_pid(child_pid, &inodes, path, process_id)?
{
return Ok(Some(listener));
}
continue;
}
let table_paths = match kind {
SocketQueryKind::TcpListener => [
format!("/proc/{child_pid}/net/tcp"),
format!("/proc/{child_pid}/net/tcp6"),
],
SocketQueryKind::UdpBound => [
format!("/proc/{child_pid}/net/udp"),
format!("/proc/{child_pid}/net/udp6"),
],
};
for table_path in table_paths {
if let Some(entry) = find_inet_socket_for_pid(
&table_path,
&inodes,
kind,
request.host.as_deref(),
request.port,
process_id,
)? {
return Ok(Some(entry));
}
}
}
Ok(None)
}
fn require_vm_inspection_permission<B>(
bridge: &SharedBridge<B>,
vm_id: &str,
capability: &str,
domain: &str,
resource: &str,
) -> Result<(), SidecarError>
where
B: NativeSidecarBridge + Send + 'static,
BridgeError<B>: fmt::Debug + Send + Sync + 'static,
{
let decision = bridge.static_permission_decision(vm_id, capability, domain, Some(resource));
if decision.as_ref().is_some_and(|decision| decision.allow) {
return Ok(());
}
let reason = decision
.and_then(|decision| decision.reason)
.unwrap_or_else(|| format!("{capability} permission required"));
Err(SidecarError::Execution(format!(
"EACCES: permission denied, {resource}: {reason}"
)))
}
fn socket_query_resource(kind: SocketQueryKind, request: &FindListenerRequest) -> String {
if let Some(path) = request.path.as_deref() {
return format!("unix://{path}");
}
let host = request.host.as_deref().unwrap_or("*");
let port = request
.port
.map_or_else(|| String::from("*"), |port| port.to_string());
match kind {
SocketQueryKind::TcpListener => format!("tcp://{host}:{port}"),
SocketQueryKind::UdpBound => format!("udp://{host}:{port}"),
}
}
fn snapshot_vm_processes(vm: &VmState) -> Vec<ProcessSnapshotEntry> {
let process_table = vm.kernel.list_processes();
snapshot_vm_processes_inner(vm, &process_table)
}
fn snapshot_vm_processes_inner(
vm: &VmState,
process_table: &BTreeMap<u32, secure_exec_kernel::process_table::ProcessInfo>,
) -> Vec<ProcessSnapshotEntry> {
let mut entries = Vec::new();
for (process_id, process) in &vm.active_processes {
collect_process_snapshot_entries(process_id, process, process_table, &mut entries);
}
for exited in &vm.exited_process_snapshots {
entries.push(exited.process.clone());
}
entries
}
fn prune_exited_process_snapshots(vm: &mut VmState) {
let cutoff = Instant::now() - EXITED_PROCESS_SNAPSHOT_RETENTION;
while vm
.exited_process_snapshots
.front()
.is_some_and(|snapshot| snapshot.captured_at < cutoff)
{
vm.exited_process_snapshots.pop_front();
}
}
fn build_process_snapshot_entry(
process_id: &str,
process: &ActiveProcess,
info: &secure_exec_kernel::process_table::ProcessInfo,
exit_code: Option<i32>,
) -> ProcessSnapshotEntry {
ProcessSnapshotEntry {
process_id: process_id.to_owned(),
pid: info.pid,
ppid: info.ppid,
pgid: info.pgid,
sid: info.sid,
driver: info.driver.clone(),
command: info.command.clone(),
args: Vec::new(),
cwd: process.guest_cwd.clone(),
status: if exit_code.is_some() {
ProcessSnapshotStatus::Exited
} else {
match info.status {
ProcessStatus::Running => ProcessSnapshotStatus::Running,
ProcessStatus::Stopped => ProcessSnapshotStatus::Stopped,
ProcessStatus::Exited => ProcessSnapshotStatus::Exited,
}
},
exit_code: exit_code.or(info.exit_code),
}
}
fn collect_process_snapshot_entries(
process_id: &str,
process: &ActiveProcess,
process_table: &BTreeMap<u32, secure_exec_kernel::process_table::ProcessInfo>,
entries: &mut Vec<ProcessSnapshotEntry>,
) {
if let Some(info) = process_table.get(&process.kernel_pid) {
entries.push(build_process_snapshot_entry(
process_id, process, info, None,
));
}
for (child_id, child) in &process.child_processes {
let child_process_id = format!("{process_id}/{child_id}");
collect_process_snapshot_entries(&child_process_id, child, process_table, entries);
}
}
fn find_kernel_socket_state_entry(
kernel: &SidecarKernel,
process_id: &str,
process: &ActiveProcess,
kind: SocketQueryKind,
request: &FindListenerRequest,
) -> Result<Option<SocketStateEntry>, SidecarError> {
let entry = match kind {
SocketQueryKind::TcpListener => process
.tcp_listeners
.values()
.filter_map(|listener| listener.kernel_socket_id)
.find_map(|socket_id| {
kernel_socket_state_entry(kernel, process_id, socket_id, kind, request)
}),
SocketQueryKind::UdpBound => process
.udp_sockets
.values()
.filter_map(|socket| socket.kernel_socket_id)
.find_map(|socket_id| {
kernel_socket_state_entry(kernel, process_id, socket_id, kind, request)
}),
};
if entry.is_some() {
return Ok(entry);
}
for child in process.child_processes.values() {
if let Some(entry) =
find_kernel_socket_state_entry(kernel, process_id, child, kind, request)?
{
return Ok(Some(entry));
}
}
Ok(None)
}
fn kernel_socket_state_entry(
kernel: &SidecarKernel,
process_id: &str,
socket_id: SocketId,
kind: SocketQueryKind,
request: &FindListenerRequest,
) -> Option<SocketStateEntry> {
let record = kernel.socket_get(socket_id)?;
let local_address = record.local_address()?;
match kind {
SocketQueryKind::TcpListener if record.state() == SocketState::Listening => {}
SocketQueryKind::TcpListener => return None,
SocketQueryKind::UdpBound => {}
}
if !socket_host_matches(request.host.as_deref(), local_address.host()) {
return None;
}
if request
.port
.is_some_and(|port| local_address.port() != port)
{
return None;
}
Some(SocketStateEntry {
process_id: process_id.to_owned(),
host: Some(local_address.host().to_owned()),
port: Some(local_address.port()),
path: None,
})
}
fn socket_inodes_for_pid(pid: u32) -> Result<BTreeSet<u64>, SidecarError> {
let fd_dir = PathBuf::from(format!("/proc/{pid}/fd"));
let entries = match fs::read_dir(&fd_dir) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeSet::new()),
Err(error) => {
return Err(SidecarError::Io(format!(
"failed to read socket descriptors for process {pid}: {error}"
)));
}
};
let mut inodes = BTreeSet::new();
for entry in entries {
let entry = entry.map_err(|error| {
SidecarError::Io(format!(
"failed to inspect fd entry for process {pid}: {error}"
))
})?;
let target = match fs::read_link(entry.path()) {
Ok(target) => target,
Err(_) => continue,
};
if let Some(inode) = parse_socket_inode(&target) {
inodes.insert(inode);
}
}
Ok(inodes)
}
fn parse_socket_inode(target: &Path) -> Option<u64> {
let value = target.to_string_lossy();
let trimmed = value.strip_prefix("socket:[")?.strip_suffix(']')?;
trimmed.parse().ok()
}
fn unix_socket_path(addr: &UnixSocketAddr) -> Option<String> {
addr.as_pathname()
.map(|path| path.to_string_lossy().into_owned())
}
fn find_unix_socket_for_pid(
pid: u32,
inodes: &BTreeSet<u64>,
path: &str,
process_id: &str,
) -> Result<Option<SocketStateEntry>, SidecarError> {
let table_path = format!("/proc/{pid}/net/unix");
let contents = match fs::read_to_string(&table_path) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(SidecarError::Io(format!(
"failed to inspect unix sockets for process {pid}: {error}"
)));
}
};
for line in contents.lines().skip(1) {
let columns = line.split_whitespace().collect::<Vec<_>>();
if columns.len() < 8 {
continue;
}
let Ok(inode) = columns[6].parse::<u64>() else {
continue;
};
if !inodes.contains(&inode) || columns[7] != path {
continue;
}
return Ok(Some(SocketStateEntry {
process_id: process_id.to_owned(),
host: None,
port: None,
path: Some(path.to_owned()),
}));
}
Ok(None)
}
fn find_inet_socket_for_pid(
table_path: &str,
inodes: &BTreeSet<u64>,
kind: SocketQueryKind,
requested_host: Option<&str>,
requested_port: Option<u16>,
process_id: &str,
) -> Result<Option<SocketStateEntry>, SidecarError> {
for entry in parse_proc_net_entries(table_path)? {
if !inodes.contains(&entry.inode) {
continue;
}
if matches!(kind, SocketQueryKind::TcpListener) && entry.state != "0A" {
continue;
}
if !socket_host_matches(requested_host, &entry.local_host) {
continue;
}
if let Some(port) = requested_port {
if entry.local_port != port {
continue;
}
}
return Ok(Some(SocketStateEntry {
process_id: process_id.to_owned(),
host: Some(entry.local_host),
port: Some(entry.local_port),
path: None,
}));
}
Ok(None)
}
fn is_unspecified_socket_host(host: &str) -> bool {
host == "0.0.0.0" || host == "::"
}
fn is_loopback_socket_host(host: &str) -> bool {
host == "127.0.0.1" || host == "::1" || host.eq_ignore_ascii_case("localhost")
}
pub(crate) fn vm_network_resource_counts(vm: &VmState) -> NetworkResourceCounts {
let snapshot = vm.kernel.resource_snapshot();
let mut counts = NetworkResourceCounts {
sockets: snapshot.sockets,
connections: snapshot.socket_connections,
};
for process in vm.active_processes.values() {
let process_counts = process.sidecar_only_network_resource_counts();
counts.sockets += process_counts.sockets;
counts.connections += process_counts.connections;
}
counts
}
fn collect_javascript_socket_port_state(
kernel: &SidecarKernel,
process_id: &str,
process: &ActiveProcess,
tcp_guest_to_host: &mut BTreeMap<(JavascriptSocketFamily, u16), u16>,
http_loopback_targets: &mut BTreeMap<
(JavascriptSocketFamily, u16),
JavascriptHttpLoopbackTarget,
>,
udp_guest_to_host: &mut BTreeMap<(JavascriptSocketFamily, u16), u16>,
udp_host_to_guest: &mut BTreeMap<(JavascriptSocketFamily, u16), u16>,
used_tcp_ports: &mut BTreeMap<JavascriptSocketFamily, BTreeSet<u16>>,
used_udp_ports: &mut BTreeMap<JavascriptSocketFamily, BTreeSet<u16>>,
) {
for (family, port) in process.tcp_port_reservations.values() {
used_tcp_ports.entry(*family).or_default().insert(*port);
}
let mut record_tcp_listener = |guest_addr: SocketAddr, host_port: u16| {
let family = JavascriptSocketFamily::from_ip(guest_addr.ip());
used_tcp_ports
.entry(family)
.or_default()
.insert(guest_addr.port());
// VM-local loopback connects should also resolve listeners bound to
// unspecified guest addresses like 0.0.0.0/::.
tcp_guest_to_host.insert((family, guest_addr.port()), host_port);
};
for listener in process.tcp_listeners.values() {
let local_addr = listener
.kernel_socket_id
.and_then(|socket_id| kernel.socket_get(socket_id))
.and_then(|record| record.local_address().cloned())
.and_then(|address| resolve_tcp_bind_addr(address.host(), address.port()).ok())
.unwrap_or_else(|| listener.guest_local_addr());
record_tcp_listener(local_addr, local_addr.port());
}
for (server_id, server) in &process.http_servers {
let host_port = match server.listener.local_addr() {
Ok(addr) => addr.port(),
Err(_) => continue,
};
record_tcp_listener(server.guest_local_addr, host_port);
let family = JavascriptSocketFamily::from_ip(server.guest_local_addr.ip());
http_loopback_targets.insert(
(family, server.guest_local_addr.port()),
JavascriptHttpLoopbackTarget {
process_id: process_id.to_owned(),
server_id: *server_id,
},
);
}
if let Ok(http2) = process.http2.shared.lock() {
for server in http2.servers.values() {
record_tcp_listener(server.guest_local_addr, server.actual_local_addr.port());
}
}
for socket in process.tcp_sockets.values() {
let guest_addr = socket
.kernel_socket_id
.and_then(|socket_id| kernel.socket_get(socket_id))
.and_then(|record| record.local_address().cloned())
.and_then(|address| resolve_tcp_bind_addr(address.host(), address.port()).ok())
.unwrap_or(socket.guest_local_addr);
let family = JavascriptSocketFamily::from_ip(guest_addr.ip());
used_tcp_ports
.entry(family)
.or_default()
.insert(guest_addr.port());
}
for socket in process.udp_sockets.values() {
let guest_addr = socket
.kernel_socket_id
.and_then(|socket_id| kernel.socket_get(socket_id))
.and_then(|record| record.local_address().cloned())
.and_then(|address| {
resolve_udp_bind_addr(address.host(), address.port(), socket.family).ok()
})
.or_else(|| socket.local_addr());
let Some(guest_addr) = guest_addr else {
continue;
};
let family = JavascriptSocketFamily::from_ip(guest_addr.ip());
used_udp_ports
.entry(family)
.or_default()
.insert(guest_addr.port());
if let Some(host_addr) = socket
.socket
.as_ref()
.and_then(|socket| socket.local_addr().ok())
{
if is_loopback_ip(guest_addr.ip()) {
udp_guest_to_host.insert((family, guest_addr.port()), host_addr.port());
udp_host_to_guest.insert((family, host_addr.port()), guest_addr.port());
}
} else if socket.kernel_socket_id.is_some() && is_loopback_ip(guest_addr.ip()) {
udp_guest_to_host.insert((family, guest_addr.port()), guest_addr.port());
udp_host_to_guest.insert((family, guest_addr.port()), guest_addr.port());
}
}
for (child_process_id, child) in &process.child_processes {
let child_id = format!("{process_id}/{child_process_id}");
collect_javascript_socket_port_state(
kernel,
&child_id,
child,
tcp_guest_to_host,
http_loopback_targets,
udp_guest_to_host,
udp_host_to_guest,
used_tcp_ports,
used_udp_ports,
);
}
}
pub(crate) fn build_javascript_socket_path_context(
vm: &VmState,
) -> Result<JavascriptSocketPathContext, SidecarError> {
let mut loopback_exempt_ports = vm.create_loopback_exempt_ports.clone();
loopback_exempt_ports.extend(vm.configuration.loopback_exempt_ports.iter().copied());
let mut tcp_loopback_guest_to_host_ports = BTreeMap::new();
let mut http_loopback_targets = BTreeMap::new();
let mut udp_loopback_guest_to_host_ports = BTreeMap::new();
let mut udp_loopback_host_to_guest_ports = BTreeMap::new();
let mut used_tcp_guest_ports = BTreeMap::new();
let mut used_udp_guest_ports = BTreeMap::new();
for (process_id, process) in &vm.active_processes {
collect_javascript_socket_port_state(
&vm.kernel,
process_id,
process,
&mut tcp_loopback_guest_to_host_ports,
&mut http_loopback_targets,
&mut udp_loopback_guest_to_host_ports,
&mut udp_loopback_host_to_guest_ports,
&mut used_tcp_guest_ports,
&mut used_udp_guest_ports,
);
}
Ok(JavascriptSocketPathContext {
sandbox_root: vm.cwd.clone(),
mounts: vm.configuration.mounts.clone(),
listen_policy: vm.listen_policy,
loopback_exempt_ports,
tcp_loopback_guest_to_host_ports,
http_loopback_targets,
udp_loopback_guest_to_host_ports,
udp_loopback_host_to_guest_ports,
used_tcp_guest_ports,
used_udp_guest_ports,
})
}
fn check_network_resource_limit(
limit: Option<usize>,
current: usize,
additional: usize,
label: &str,
) -> Result<(), SidecarError> {
if let Some(limit) = limit {
if current.saturating_add(additional) > limit {
return Err(SidecarError::Execution(format!(
"EAGAIN: maximum {label} count reached"
)));
}
}
Ok(())
}
fn normalize_tcp_listen_host(
host: Option<&str>,
) -> Result<(JavascriptSocketFamily, &'static str, &'static str), SidecarError> {
match host.unwrap_or("127.0.0.1") {
"127.0.0.1" | "localhost" => Ok((JavascriptSocketFamily::Ipv4, "127.0.0.1", "127.0.0.1")),
"::1" => Ok((JavascriptSocketFamily::Ipv6, "::1", "::1")),
"0.0.0.0" => Ok((JavascriptSocketFamily::Ipv4, "127.0.0.1", "0.0.0.0")),
"::" => Ok((JavascriptSocketFamily::Ipv6, "::1", "::")),
other => Err(SidecarError::Execution(format!(
"EACCES: TCP listeners must bind to loopback or unspecified addresses, got {other}"
))),
}
}
fn normalize_udp_bind_host(
host: Option<&str>,
family: JavascriptUdpFamily,
) -> Result<(&'static str, &'static str, JavascriptSocketFamily), SidecarError> {
match (family, host) {
(JavascriptUdpFamily::Ipv4, None) | (JavascriptUdpFamily::Ipv4, Some("0.0.0.0")) => {
Ok(("127.0.0.1", "0.0.0.0", JavascriptSocketFamily::Ipv4))
}
(JavascriptUdpFamily::Ipv4, Some("127.0.0.1"))
| (JavascriptUdpFamily::Ipv4, Some("localhost")) => {
Ok(("127.0.0.1", "127.0.0.1", JavascriptSocketFamily::Ipv4))
}
(JavascriptUdpFamily::Ipv6, None) | (JavascriptUdpFamily::Ipv6, Some("::")) => {
Ok(("::1", "::", JavascriptSocketFamily::Ipv6))
}
(JavascriptUdpFamily::Ipv6, Some("::1"))
| (JavascriptUdpFamily::Ipv6, Some("localhost")) => {
Ok(("::1", "::1", JavascriptSocketFamily::Ipv6))
}
(JavascriptUdpFamily::Ipv4, Some(other)) => Err(SidecarError::Execution(format!(
"EACCES: udp4 sockets must bind to 127.0.0.1 or 0.0.0.0, got {other}"
))),
(JavascriptUdpFamily::Ipv6, Some(other)) => Err(SidecarError::Execution(format!(
"EACCES: udp6 sockets must bind to ::1 or ::, got {other}"
))),
}
}
fn allocate_guest_listen_port(
requested_port: u16,
family: JavascriptSocketFamily,
used_ports: &BTreeMap<JavascriptSocketFamily, BTreeSet<u16>>,
policy: VmListenPolicy,
) -> Result<u16, SidecarError> {
let is_allowed = |port: u16| {
port >= policy.port_min
&& port <= policy.port_max
&& (policy.allow_privileged || port >= 1024)
};
let used = used_ports.get(&family);
if requested_port != 0 {
if !is_allowed(requested_port) {
let reason = if requested_port < 1024 && !policy.allow_privileged {
format!(
"EACCES: privileged listen port {requested_port} requires {}=true",
VM_LISTEN_ALLOW_PRIVILEGED_METADATA_KEY
)
} else {
format!(
"EACCES: listen port {requested_port} is outside the allowed range {}-{}",
policy.port_min, policy.port_max
)
};
return Err(SidecarError::Execution(reason));
}
if used.is_some_and(|ports| ports.contains(&requested_port)) {
return Err(sidecar_net_error(std::io::Error::from_raw_os_error(
libc::EADDRINUSE,
)));
}
return Ok(requested_port);
}
let allocation_start = policy
.port_min
.max(if policy.allow_privileged { 1 } else { 1024 });
for candidate in allocation_start..=policy.port_max {
if used.is_some_and(|ports| ports.contains(&candidate)) {
continue;
}
return Ok(candidate);
}
Err(sidecar_net_error(std::io::Error::from_raw_os_error(
libc::EADDRINUSE,
)))
}
fn socket_host_matches(requested: Option<&str>, actual: &str) -> bool {
match requested {
None => true,
Some(requested) if requested == actual => true,
Some(requested)
if is_unspecified_socket_host(requested) && is_unspecified_socket_host(actual) =>
{
true
}
Some(requested) if is_unspecified_socket_host(requested) => is_loopback_socket_host(actual),
Some(requested) if requested.eq_ignore_ascii_case("localhost") => {
is_loopback_socket_host(actual)
}
_ => false,
}
}
fn parse_proc_net_entries(table_path: &str) -> Result<Vec<ProcNetEntry>, SidecarError> {
let contents = match fs::read_to_string(table_path) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => {
return Err(SidecarError::Io(format!(
"failed to inspect socket table {table_path}: {error}"
)));
}
};
let mut entries = Vec::new();
for line in contents.lines().skip(1) {
let columns = line.split_whitespace().collect::<Vec<_>>();
if columns.len() < 10 {
continue;
}
let Some((host, port)) = parse_proc_ip_port(columns[1]) else {
continue;
};
let Ok(inode) = columns[9].parse::<u64>() else {
continue;
};
entries.push(ProcNetEntry {
local_host: host,
local_port: port,
state: columns[3].to_owned(),
inode,
});
}
Ok(entries)
}
fn parse_proc_ip_port(value: &str) -> Option<(String, u16)> {
let (raw_ip, raw_port) = value.split_once(':')?;
let port = u16::from_str_radix(raw_port, 16).ok()?;
let host = match raw_ip.len() {
8 => {
let raw = u32::from_str_radix(raw_ip, 16).ok()?;
Ipv4Addr::from(raw.to_le_bytes()).to_string()
}
32 => {
let mut bytes = [0_u8; 16];
for (index, chunk) in raw_ip.as_bytes().chunks(8).enumerate() {
let word = u32::from_str_radix(std::str::from_utf8(chunk).ok()?, 16).ok()?;
bytes[index * 4..(index + 1) * 4].copy_from_slice(&word.to_le_bytes());
}
Ipv6Addr::from(bytes).to_string()
}
_ => return None,
};
Some((host, port))
}
fn python_file_entrypoint(entrypoint: &str) -> Option<PathBuf> {
let path = Path::new(entrypoint);
(path.extension().and_then(|extension| extension.to_str()) == Some("py"))
.then(|| path.to_path_buf())
}
fn add_runtime_guest_path_mapping(
env: &mut BTreeMap<String, String>,
guest_path: &str,
host_path: &Path,
) {
let mut mappings = env
.get("AGENTOS_GUEST_PATH_MAPPINGS")
.and_then(|value| serde_json::from_str::<Vec<Value>>(value).ok())
.unwrap_or_default();
mappings.retain(|mapping| {
mapping
.get("guestPath")
.and_then(Value::as_str)
.map(|existing| normalize_path(existing) != normalize_path(guest_path))
.unwrap_or(true)
});
mappings.push(json!({
"guestPath": normalize_path(guest_path),
"hostPath": host_path.display().to_string(),
}));
if let Ok(serialized) = serde_json::to_string(&mappings) {
env.insert(String::from("AGENTOS_GUEST_PATH_MAPPINGS"), serialized);
}
}
fn add_runtime_host_access_path(
env: &mut BTreeMap<String, String>,
key: &str,
host_path: &Path,
expand: bool,
) {
let existing = env
.get(key)
.and_then(|value| serde_json::from_str::<Vec<String>>(value).ok())
.unwrap_or_default()
.into_iter()
.map(PathBuf::from)
.collect::<Vec<_>>();
let mut paths = existing;
paths.push(host_path.to_path_buf());
let normalized = if expand {
expand_host_access_paths(&paths)
} else {
dedupe_host_paths(&paths)
};
let serialized = normalized
.iter()
.map(|path| path.to_string_lossy().into_owned())
.collect::<Vec<_>>();
if let Ok(serialized) = serde_json::to_string(&serialized) {
env.insert(key.to_owned(), serialized);
}
}
// discover_command_guest_paths moved to crate::bootstrap
fn is_path_like_specifier(specifier: &str) -> bool {
specifier.starts_with('/')
|| specifier.starts_with("./")
|| specifier.starts_with("../")
|| specifier.starts_with("file:")
}
fn execution_wasm_permission_tier(tier: WasmPermissionTier) -> ExecutionWasmPermissionTier {
match tier {
WasmPermissionTier::Full => ExecutionWasmPermissionTier::Full,
WasmPermissionTier::ReadWrite => ExecutionWasmPermissionTier::ReadWrite,
WasmPermissionTier::ReadOnly => ExecutionWasmPermissionTier::ReadOnly,
WasmPermissionTier::Isolated => ExecutionWasmPermissionTier::Isolated,
}
}
fn resolve_wasm_permission_tier(
vm: &VmState,
command_name: Option<&str>,
explicit_tier: Option<WasmPermissionTier>,
entrypoint: &str,
) -> WasmPermissionTier {
explicit_tier
.or_else(|| command_name.and_then(|command| vm.command_permissions.get(command).copied()))
.or_else(|| {
Path::new(entrypoint)
.file_name()
.and_then(|name| name.to_str())
.and_then(|command| vm.command_permissions.get(command).copied())
})
.unwrap_or(WasmPermissionTier::Full)
}
fn tokenize_shell_free_command(command: &str) -> Vec<String> {
command
.split_whitespace()
.filter(|segment| !segment.is_empty())
.map(str::to_owned)
.collect()
}
fn is_posix_shell_builtin(command: &str) -> bool {
matches!(
command,
"." | ":"
| "break"
| "cd"
| "continue"
| "eval"
| "exec"
| "exit"
| "export"
| "readonly"
| "return"
| "set"
| "shift"
| "times"
| "trap"
| "umask"
| "unset"
)
}
/// Single-token checks for shell-mode commands whose first word forces a real
/// shell even when the command string has no shell metacharacters. This is not
/// a parser: env-assignment prefixes (`FOO=bar cmd`) and shell reserved words
/// have no meaning outside `sh`, so whitespace-tokenizing them would silently
/// run the wrong program.
fn shell_first_token_requires_shell(token: &str) -> bool {
token.contains('=') || is_shell_reserved_word(token)
}
fn is_shell_reserved_word(token: &str) -> bool {
matches!(
token,
"if" | "then"
| "elif"
| "else"
| "fi"
| "for"
| "in"
| "do"
| "done"
| "while"
| "until"
| "case"
| "esac"
| "{"
| "}"
| "!"
)
}
fn command_requires_shell(command: &str) -> bool {
command.chars().any(|ch| {
matches!(
ch,
'|' | '&'
| ';'
| '<'
| '>'
| '('
| ')'
| '$'
| '`'
| '*'
| '?'
| '['
| ']'
| '{'
| '}'
| '~'
| '\''
| '"'
| '\\'
| '\n'
)
})
}
fn host_mount_path_for_guest_path(vm: &VmState, guest_path: &str) -> Option<PathBuf> {
let normalized = normalize_path(guest_path);
let mut mounts = vm
.configuration
.mounts
.iter()
.filter_map(|mount| {
((mount.plugin.id == "host_dir") || (mount.plugin.id == "module_access"))
.then(|| {
mount_config_host_path(&mount.plugin.config)
.map(|host_path| (mount.guest_path.as_str(), host_path))
})
.flatten()
})
.collect::<Vec<_>>();
mounts.sort_by_key(|mount| std::cmp::Reverse(mount.0.len()));
for (guest_root, host_root) in mounts {
if normalized != guest_root && !normalized.starts_with(&format!("{guest_root}/")) {
continue;
}
let suffix = normalized
.strip_prefix(guest_root)
.unwrap_or_default()
.trim_start_matches('/');
let mut path = PathBuf::from(host_root);
if !suffix.is_empty() {
path.push(suffix);
}
return Some(path);
}
None
}
fn host_runtime_path_for_guest_path_with_env(
vm: &VmState,
runtime_env: &BTreeMap<String, String>,
guest_path: &str,
default_host_cwd: &Path,
) -> Option<PathBuf> {
if let Some(path) = host_mount_path_for_guest_path(vm, guest_path) {
return Some(path);
}
if let Some(path) = host_path_from_runtime_guest_mappings(runtime_env, guest_path) {
return Some(path);
}
let normalized = normalize_path(guest_path);
let virtual_home = guest_virtual_home(vm);
if normalized == virtual_home || normalized.starts_with(&format!("{virtual_home}/")) {
let suffix = normalized
.strip_prefix(&virtual_home)
.unwrap_or_default()
.trim_start_matches('/');
let mut host_path = default_host_cwd.to_path_buf();
if !suffix.is_empty() {
host_path.push(suffix);
}
return Some(host_path);
}
None
}
#[derive(Deserialize, Serialize)]
struct RuntimeGuestPathMapping {
#[serde(rename = "guestPath")]
guest_path: String,
#[serde(rename = "hostPath")]
host_path: String,
#[serde(rename = "readOnly", default)]
read_only: bool,
}
pub(crate) fn host_path_from_runtime_guest_mappings(
runtime_env: &BTreeMap<String, String>,
guest_path: &str,
) -> Option<PathBuf> {
let mappings = runtime_env
.get("AGENTOS_GUEST_PATH_MAPPINGS")
.and_then(|value| serde_json::from_str::<Vec<RuntimeGuestPathMapping>>(value).ok())?;
let normalized = normalize_path(guest_path);
let mut sorted_mappings = mappings
.into_iter()
.filter_map(|mapping| {
(!mapping.guest_path.is_empty() && !mapping.host_path.is_empty()).then_some((
normalize_path(&mapping.guest_path),
PathBuf::from(mapping.host_path),
))
})
.collect::<Vec<_>>();
sorted_mappings.sort_by_key(|mapping| std::cmp::Reverse(mapping.0.len()));
for (guest_root, mut host_root) in sorted_mappings {
if guest_root != "/"
&& normalized != guest_root
&& !normalized.starts_with(&format!("{guest_root}/"))
{
continue;
}
if guest_root == "/" && !normalized.starts_with('/') {
continue;
}
if host_root.is_relative() {
host_root = std::env::current_dir().ok()?.join(host_root);
}
let suffix = if guest_root == "/" {
normalized.trim_start_matches('/')
} else {
normalized
.strip_prefix(&guest_root)
.unwrap_or_default()
.trim_start_matches('/')
};
if !suffix.is_empty() {
host_root.push(suffix);
}
return Some(host_root);
}
None
}
fn guest_runtime_path_for_host_path(
runtime_env: &BTreeMap<String, String>,
virtual_home: &str,
cwd: &Path,
host_path: &str,
) -> Option<String> {
let resolved = if host_path.starts_with("file://") {
PathBuf::from(host_path.trim_start_matches("file://"))
} else if host_path.starts_with("file:") {
PathBuf::from(host_path.trim_start_matches("file:"))
} else {
let candidate = PathBuf::from(host_path);
if candidate.is_absolute() {
candidate
} else if host_path.starts_with("./") || host_path.starts_with("../") {
cwd.join(candidate)
} else {
return None;
}
};
let normalized = normalize_host_path(&resolved);
if let Some(path) = guest_path_from_runtime_host_mappings(runtime_env, &normalized) {
return Some(path);
}
let normalized_cwd = normalize_host_path(cwd);
if !path_is_within_root(&normalized, &normalized_cwd) {
return None;
}
let virtual_home = if virtual_home.starts_with('/') {
virtual_home.to_string()
} else {
String::from("/root")
};
let suffix = normalized
.strip_prefix(&normalized_cwd)
.ok()?
.to_string_lossy()
.replace('\\', "/")
.trim_start_matches('/')
.to_owned();
Some(if suffix.is_empty() {
virtual_home
} else {
normalize_path(&format!("{virtual_home}/{suffix}"))
})
}
fn guest_path_from_runtime_host_mappings(
runtime_env: &BTreeMap<String, String>,
host_path: &Path,
) -> Option<String> {
let mappings = runtime_env
.get("AGENTOS_GUEST_PATH_MAPPINGS")
.and_then(|value| serde_json::from_str::<Vec<RuntimeGuestPathMapping>>(value).ok())?;
let normalized = normalize_host_path(host_path);
let mut sorted_mappings = mappings
.into_iter()
.filter_map(|mapping| {
(!mapping.guest_path.is_empty() && !mapping.host_path.is_empty()).then_some((
normalize_path(&mapping.guest_path),
normalize_host_path(Path::new(&mapping.host_path)),
))
})
.collect::<Vec<_>>();
sorted_mappings.sort_by_key(|mapping| std::cmp::Reverse(mapping.1.as_os_str().len()));
for (guest_root, host_root) in sorted_mappings {
if !path_is_within_root(&normalized, &host_root) {
continue;
}
let suffix = normalized
.strip_prefix(&host_root)
.ok()?
.to_string_lossy()
.replace('\\', "/")
.trim_start_matches('/')
.to_owned();
return Some(if suffix.is_empty() {
guest_root
} else if guest_root == "/" {
normalize_path(&format!("/{suffix}"))
} else {
normalize_path(&format!("{guest_root}/{suffix}"))
});
}
None
}
fn host_mount_path_for_guest_path_from_mounts(
mounts: &[crate::protocol::MountDescriptor],
guest_path: &str,
) -> Option<PathBuf> {
let normalized = normalize_path(guest_path);
let mut host_mounts = mounts
.iter()
.filter_map(|mount| {
((mount.plugin.id == "host_dir") || (mount.plugin.id == "module_access"))
.then(|| {
mount_config_host_path(&mount.plugin.config)
.map(|host_path| (mount.guest_path.as_str(), host_path))
})
.flatten()
})
.collect::<Vec<_>>();
host_mounts.sort_by_key(|mount| std::cmp::Reverse(mount.0.len()));
for (guest_root, host_root) in host_mounts {
if normalized != guest_root && !normalized.starts_with(&format!("{guest_root}/")) {
continue;
}
let suffix = normalized
.strip_prefix(guest_root)
.unwrap_or_default()
.trim_start_matches('/');
let mut path = PathBuf::from(host_root);
if !suffix.is_empty() {
path.push(suffix);
}
return Some(path);
}
None
}
#[cfg(test)]
mod host_mount_path_for_guest_path_from_mounts_tests {
use super::host_mount_path_for_guest_path_from_mounts;
use crate::protocol::{MountDescriptor, MountPluginDescriptor};
use serde_json::json;
use std::path::PathBuf;
#[test]
fn resolves_module_access_mount_paths() {
let mounts = vec![MountDescriptor {
guest_path: String::from("/root/node_modules"),
read_only: true,
plugin: MountPluginDescriptor {
id: String::from("module_access"),
config: json!({
"hostPath": "/tmp/workspace/node_modules",
})
.to_string(),
},
}];
let resolved =
host_mount_path_for_guest_path_from_mounts(&mounts, "/root/node_modules/pkg/index.js")
.expect("module_access mount should resolve");
assert_eq!(
resolved,
PathBuf::from("/tmp/workspace/node_modules/pkg/index.js")
);
}
}
fn resolve_guest_socket_host_path(
context: &JavascriptSocketPathContext,
guest_path: &str,
) -> PathBuf {
if let Some(path) = host_mount_path_for_guest_path_from_mounts(&context.mounts, guest_path) {
return path;
}
let normalized = normalize_path(guest_path);
let mut host_path = context.sandbox_root.clone();
let suffix = normalized.trim_start_matches('/');
if !suffix.is_empty() {
host_path.push(suffix);
}
host_path
}
fn ensure_kernel_parent_directories(
kernel: &mut SidecarKernel,
path: &str,
) -> Result<(), SidecarError> {
let parent = dirname(path);
if parent != "/" && !kernel.exists(&parent).map_err(kernel_error)? {
kernel.mkdir(&parent, true).map_err(kernel_error)?;
}
Ok(())
}
// JavascriptChildProcessSpawnOptions, JavascriptChildProcessSpawnRequest moved to crate::protocol
// ResolvedChildProcessExecution moved to crate::state
pub(crate) fn sanitize_javascript_child_process_internal_bootstrap_env(
env: &BTreeMap<String, String>,
) -> BTreeMap<String, String> {
const ALLOWED_KEYS: &[&str] = &[
"AGENTOS_ALLOWED_NODE_BUILTINS",
"AGENTOS_GUEST_PATH_MAPPINGS",
"AGENTOS_LOOPBACK_EXEMPT_PORTS",
"AGENTOS_VIRTUAL_PROCESS_EXEC_PATH",
"AGENTOS_VIRTUAL_PROCESS_UID",
"AGENTOS_VIRTUAL_PROCESS_GID",
"AGENTOS_VIRTUAL_PROCESS_VERSION",
];
env.iter()
.filter(|(key, _)| {
ALLOWED_KEYS.contains(&key.as_str()) || key.starts_with("AGENTOS_VIRTUAL_OS_")
})
.map(|(key, value)| (key.clone(), value.clone()))
.collect()
}
// Network request types moved to crate::protocol
// VmDnsConfig, DnsResolutionSource moved to crate::state
fn resolve_tcp_bind_addr(host: &str, port: u16) -> Result<SocketAddr, SidecarError> {
(host, port)
.to_socket_addrs()
.map_err(sidecar_net_error)?
.next()
.ok_or_else(|| {
SidecarError::Execution(format!("failed to resolve TCP bind address {host}:{port}"))
})
}
pub(crate) fn format_dns_resource(hostname: &str) -> String {
format!("dns://{hostname}")
}
pub(crate) fn format_tcp_resource(host: &str, port: u16) -> String {
format!("tcp://{host}:{port}")
}
fn is_loopback_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ip) => ip.is_loopback(),
IpAddr::V6(ip) => {
ip.is_loopback()
|| ip
.to_ipv4_mapped()
.is_some_and(|mapped| mapped.is_loopback())
}
}
}
fn loopback_cidr(ip: IpAddr) -> &'static str {
match ip {
IpAddr::V4(ip) if ip.is_loopback() => "127.0.0.0/8",
IpAddr::V6(ip)
if ip
.to_ipv4_mapped()
.is_some_and(|mapped| mapped.is_loopback()) =>
{
"127.0.0.0/8"
}
IpAddr::V6(_) => "::1/128",
IpAddr::V4(_) => "127.0.0.0/8",
}
}
/// Returns the embedded IPv4 address of an IPv4-compatible IPv6 address
/// (`::a.b.c.d`): the first six 16-bit segments are zero and the final 32 bits
/// hold the IPv4 address. The all-zero (`::`) and loopback (`::1`) addresses are
/// deliberately excluded so they are handled by the unspecified/loopback paths
/// rather than treated as IPv4-compatible.
fn ipv4_compatible_embedded(ip: Ipv6Addr) -> Option<Ipv4Addr> {
let segments = ip.segments();
if segments[0..6].iter().any(|&s| s != 0) {
return None;
}
let embedded = (u32::from(segments[6]) << 16) | u32::from(segments[7]);
// Skip :: (0.0.0.0) and ::1 (0.0.0.1) — these are the IPv6 unspecified /
// loopback addresses, not IPv4-compatible representations of an IPv4 host.
if embedded == 0 || embedded == 1 {
return None;
}
Some(Ipv4Addr::from(embedded))
}
fn restricted_non_loopback_ip_range(ip: IpAddr) -> Option<(&'static str, &'static str)> {
match ip {
IpAddr::V4(ip) => {
if ip.is_unspecified() {
// 0.0.0.0 is unspecified; the host stack routes a connect() to
// it back to 127.0.0.1, so it must not bypass the loopback gate.
return Some(("0.0.0.0/32", "unspecified"));
}
let [first, second, ..] = ip.octets();
match (first, second) {
(10, _) => Some(("10.0.0.0/8", "private")),
(100, 64..=127) => Some(("100.64.0.0/10", "carrier-grade-nat")),
(172, 16..=31) => Some(("172.16.0.0/12", "private")),
(192, 168) => Some(("192.168.0.0/16", "private")),
(169, 254) => Some(("169.254.0.0/16", "link-local")),
// 224.0.0.0/4 is the IPv4 multicast range and 240.0.0.0/4 is
// reserved/future-use (255.255.255.255 broadcast falls in it).
// Neither is a legitimate unicast egress target, so a guest
// connect to them must be denied rather than attempted.
(224..=239, _) => Some(("224.0.0.0/4", "multicast")),
(240..=255, _) => Some(("240.0.0.0/4", "reserved")),
_ => None,
}
}
IpAddr::V6(ip) => {
if let Some(mapped) = ip.to_ipv4_mapped() {
return restricted_non_loopback_ip_range(IpAddr::V4(mapped));
}
// IPv4-compatible IPv6 (::a.b.c.d): the first six segments are zero
// and the last two carry an embedded IPv4 address. `to_ipv4_mapped`
// returns None for this form, so without canonicalizing it here a
// guest could spell a restricted IPv4 target (e.g. cloud-metadata
// ::169.254.169.254) and bypass the IPv4 classifier. `::`/`::1` are
// excluded so they fall through to the unspecified/loopback paths.
if let Some(compat) = ipv4_compatible_embedded(ip) {
return restricted_non_loopback_ip_range(IpAddr::V4(compat));
}
if ip.is_unspecified() {
// :: is the IPv6 unspecified address; same routing hazard as
// 0.0.0.0, so deny it rather than letting it reach the host.
return Some(("::/128", "unspecified"));
}
let segments = ip.segments();
if (segments[0] & 0xfe00) == 0xfc00 {
return Some(("fc00::/7", "unique-local"));
}
if (segments[0] & 0xffc0) == 0xfe80 {
return Some(("fe80::/10", "link-local"));
}
None
}
}
}
fn blocked_dns_resolution_error(
resource: &str,
ip: IpAddr,
cidr: &str,
label: &str,
) -> SidecarError {
SidecarError::Execution(format!(
"EACCES: blocked outbound network access to {resource}: {ip} is within restricted {label} range {cidr}"
))
}
fn blocked_loopback_connect_error(resource: &str, ip: IpAddr, port: u16) -> SidecarError {
SidecarError::Execution(format!(
"EACCES: blocked outbound network access to {resource}: {ip} is loopback ({}) and port {port} is not owned by this VM and is not listed in {LOOPBACK_EXEMPT_PORTS_ENV}",
loopback_cidr(ip)
))
}
fn filter_dns_safe_ip_addrs(
addresses: Vec<IpAddr>,
hostname: &str,
) -> Result<Vec<IpAddr>, SidecarError> {
let resource = format_dns_resource(hostname);
let mut allowed = Vec::new();
let mut blocked = None;
for ip in addresses {
if let Some((cidr, label)) = restricted_non_loopback_ip_range(ip) {
blocked.get_or_insert((ip, cidr, label));
continue;
}
allowed.push(ip);
}
if allowed.is_empty() {
let (ip, cidr, label) = blocked.expect("blocked DNS results should capture a reason");
return Err(blocked_dns_resolution_error(&resource, ip, cidr, label));
}
Ok(allowed)
}
fn loopback_connect_allowed(context: &JavascriptSocketPathContext, port: u16) -> bool {
context.loopback_port_allowed(port)
}
fn filter_tcp_connect_ip_addrs(
addresses: Vec<IpAddr>,
host: &str,
port: u16,
context: &JavascriptSocketPathContext,
) -> Result<Vec<IpAddr>, SidecarError> {
let resource = format_tcp_resource(host, port);
let mut allowed = Vec::new();
let mut blocked = None;
for ip in addresses {
if let Some((cidr, label)) = restricted_non_loopback_ip_range(ip) {
blocked.get_or_insert_with(|| blocked_dns_resolution_error(&resource, ip, cidr, label));
continue;
}
if is_loopback_ip(ip) && !loopback_connect_allowed(context, port) {
blocked.get_or_insert_with(|| blocked_loopback_connect_error(&resource, ip, port));
continue;
}
allowed.push(ip);
}
if allowed.is_empty() {
return Err(blocked.expect("blocked TCP connect results should capture a reason"));
}
Ok(allowed)
}
fn resolve_tcp_connect_addr<B>(
bridge: &SharedBridge<B>,
kernel: &SidecarKernel,
vm_id: &str,
dns: &VmDnsConfig,
host: &str,
port: u16,
context: &JavascriptSocketPathContext,
) -> Result<ResolvedTcpConnectAddr, SidecarError>
where
B: NativeSidecarBridge + Send + 'static,
BridgeError<B>: fmt::Debug + Send + Sync + 'static,
{
let allowed = filter_tcp_connect_ip_addrs(
resolve_dns_ip_addrs(
bridge,
kernel,
vm_id,
dns,
host,
DnsLookupPolicy::SkipPermissions,
)?,
host,
port,
context,
)?;
let ip = allowed
.iter()
.copied()
.find(|candidate| {
let family = JavascriptSocketFamily::from_ip(*candidate);
context.translate_tcp_loopback_port(family, port).is_some()
})
// We do not implement Happy Eyeballs yet, so prefer IPv4 over a
// verbatim IPv6-first DNS answer for general outbound TCP connects.
.or_else(|| allowed.iter().copied().find(IpAddr::is_ipv4))
.or_else(|| allowed.first().copied())
.ok_or_else(|| {
SidecarError::Execution(format!("failed to resolve TCP address {host}:{port}"))
})?;
let family = JavascriptSocketFamily::from_ip(ip);
let translated_loopback_port = context.translate_tcp_loopback_port(family, port);
let use_kernel_loopback = is_loopback_ip(ip) && translated_loopback_port == Some(port);
let actual_port = if is_loopback_ip(ip) {
translated_loopback_port.unwrap_or(port)
} else {
port
};
Ok(ResolvedTcpConnectAddr {
actual_addr: SocketAddr::new(ip, actual_port),
guest_remote_addr: SocketAddr::new(ip, port),
use_kernel_loopback,
})
}
fn resolve_dns_ip_addrs<B>(
bridge: &SharedBridge<B>,
kernel: &SidecarKernel,
vm_id: &str,
dns: &VmDnsConfig,
hostname: &str,
policy: DnsLookupPolicy,
) -> Result<Vec<IpAddr>, SidecarError>
where
B: NativeSidecarBridge + Send + 'static,
BridgeError<B>: fmt::Debug + Send + Sync + 'static,
{
let resolution = match kernel.resolve_dns(hostname, policy) {
Ok(resolution) => resolution,
Err(error) => {
let sidecar_error = kernel_error(error.clone());
if error.code() != "EACCES" {
emit_dns_resolution_failure_event(bridge, vm_id, hostname, dns, &sidecar_error);
}
return Err(sidecar_error);
}
};
emit_dns_resolution_event(
bridge,
vm_id,
hostname,
resolution.source(),
resolution.addresses(),
dns,
);
Ok(resolution.addresses().to_vec())
}
fn resolve_dns_records<B>(
bridge: &SharedBridge<B>,
kernel: &SidecarKernel,
vm_id: &str,
dns: &VmDnsConfig,
hostname: &str,
record_type: RecordType,
policy: DnsLookupPolicy,
) -> Result<DnsRecordResolution, SidecarError>
where
B: NativeSidecarBridge + Send + 'static,
BridgeError<B>: fmt::Debug + Send + Sync + 'static,
{
let resolution = match kernel.resolve_dns_records(hostname, record_type, policy) {
Ok(resolution) => resolution,
Err(error) => {
let sidecar_error = kernel_error(error.clone());
if error.code() != "EACCES" {
emit_dns_resolution_failure_event(bridge, vm_id, hostname, dns, &sidecar_error);
}
return Err(sidecar_error);
}
};
emit_dns_record_resolution_event(bridge, vm_id, hostname, &resolution, dns);
Ok(resolution)
}
fn filter_dns_ip_addrs(
addresses: Vec<IpAddr>,
family: Option<u8>,
) -> Result<Vec<IpAddr>, SidecarError> {
let filtered: Vec<_> = match family.unwrap_or(0) {
0 => addresses,
4 => addresses
.into_iter()
.filter(|ip| matches!(ip, IpAddr::V4(_)))
.collect(),
6 => addresses
.into_iter()
.filter(|ip| matches!(ip, IpAddr::V6(_)))
.collect(),
other => {
return Err(SidecarError::InvalidState(format!(
"unsupported dns family {other}"
)));
}
};
if filtered.is_empty() {
return Err(SidecarError::Execution(String::from(
"failed to resolve DNS address for requested family",
)));
}
Ok(filtered)
}
fn resolve_udp_bind_addr(
host: &str,
port: u16,
family: JavascriptUdpFamily,
) -> Result<SocketAddr, SidecarError> {
(host, port)
.to_socket_addrs()
.map_err(sidecar_net_error)?
.find(|addr| family.matches_addr(addr))
.ok_or_else(|| {
SidecarError::Execution(format!(
"failed to resolve {} UDP bind address {host}:{port}",
family.socket_type()
))
})
}
fn resolve_udp_addr<B>(request: UdpRemoteAddrRequest<'_, B>) -> Result<SocketAddr, SidecarError>
where
B: NativeSidecarBridge + Send + 'static,
BridgeError<B>: fmt::Debug + Send + Sync + 'static,
{
let UdpRemoteAddrRequest {
bridge,
kernel,
vm_id,
dns,
host,
port,
family,
context,
} = request;
resolve_dns_ip_addrs(
bridge,
kernel,
vm_id,
dns,
host,
DnsLookupPolicy::SkipPermissions,
)?
.into_iter()
.map(|ip| {
let family_key = JavascriptSocketFamily::from_ip(ip);
let actual_port = if is_loopback_ip(ip) {
context
.translate_udp_loopback_port(family_key, port)
.unwrap_or(port)
} else {
port
};
SocketAddr::new(ip, actual_port)
})
.find(|addr| family.matches_addr(addr))
.ok_or_else(|| {
SidecarError::Execution(format!(
"failed to resolve {} UDP address {host}:{port}",
family.socket_type()
))
})
}
fn socket_addr_family(addr: &SocketAddr) -> &'static str {
match addr {
SocketAddr::V4(_) => "IPv4",
SocketAddr::V6(_) => "IPv6",
}
}
fn javascript_net_timeout_value() -> Value {
Value::String(String::from(JAVASCRIPT_NET_TIMEOUT_SENTINEL))
}
fn javascript_net_json_string(value: Value, label: &str) -> Result<Value, SidecarError> {
serde_json::to_string(&value)
.map(Value::String)
.map_err(|error| {
SidecarError::InvalidState(format!("failed to serialize {label} payload: {error}"))
})
}
fn javascript_net_read_value(
event: Option<JavascriptTcpSocketEvent>,
) -> Result<Value, SidecarError> {
match event {
Some(JavascriptTcpSocketEvent::Data(chunk)) => Ok(Value::String(
base64::engine::general_purpose::STANDARD.encode(chunk),
)),
Some(JavascriptTcpSocketEvent::End | JavascriptTcpSocketEvent::Close { .. }) => {
Ok(Value::Null)
}
Some(JavascriptTcpSocketEvent::Error { code, message }) => {
let detail = code.unwrap_or_else(|| String::from("socket read"));
Err(SidecarError::Execution(format!("{detail}: {message}")))
}
None => Ok(javascript_net_timeout_value()),
}
}
fn io_error_code(error: &std::io::Error) -> Option<String> {
match error.raw_os_error() {
Some(libc::EADDRINUSE) => Some(String::from("EADDRINUSE")),
Some(libc::EADDRNOTAVAIL) => Some(String::from("EADDRNOTAVAIL")),
Some(libc::ECONNREFUSED) => Some(String::from("ECONNREFUSED")),
Some(libc::ECONNRESET) => Some(String::from("ECONNRESET")),
Some(libc::EINVAL) => Some(String::from("EINVAL")),
Some(libc::EPIPE) => Some(String::from("EPIPE")),
Some(libc::ETIMEDOUT) => Some(String::from("ETIMEDOUT")),
Some(libc::EHOSTUNREACH) => Some(String::from("EHOSTUNREACH")),
Some(libc::ENETUNREACH) => Some(String::from("ENETUNREACH")),
_ => None,
}
}
fn sidecar_net_error(error: std::io::Error) -> SidecarError {
let message = match io_error_code(&error) {
Some(code) => format!("{code}: {error}"),
None => error.to_string(),
};
SidecarError::Execution(message)
}
fn tls_provider() -> Arc<rustls::crypto::CryptoProvider> {
Arc::new(aws_lc_rs::default_provider())
}
fn tls_local_certificates(
options: &JavascriptTlsBridgeOptions,
) -> Result<Vec<Vec<u8>>, SidecarError> {
let Some(certificates) = options.cert.as_ref() else {
return Ok(Vec::new());
};
tls_material_entries(certificates)
}
fn tls_material_entries(material: &JavascriptTlsMaterial) -> Result<Vec<Vec<u8>>, SidecarError> {
match material {
JavascriptTlsMaterial::Single(entry) => tls_data_value(entry).map(|value| vec![value]),
JavascriptTlsMaterial::Many(entries) => entries.iter().map(tls_data_value).collect(),
}
}
fn tls_data_value(value: &JavascriptTlsDataValue) -> Result<Vec<u8>, SidecarError> {
match value {
JavascriptTlsDataValue::Buffer { data } => base64::engine::general_purpose::STANDARD
.decode(data)
.map_err(|error| {
SidecarError::InvalidState(format!("TLS material contains invalid base64: {error}"))
}),
JavascriptTlsDataValue::String { data } => Ok(data.as_bytes().to_vec()),
}
}
fn tls_certificates_from_material(
material: &JavascriptTlsMaterial,
) -> Result<Vec<CertificateDer<'static>>, SidecarError> {
let mut certificates = Vec::new();
for entry in tls_material_entries(material)? {
let mut reader = std::io::BufReader::new(Cursor::new(entry.clone()));
let parsed = rustls_pemfile::certs(&mut reader)
.collect::<Result<Vec<_>, _>>()
.map_err(sidecar_net_error)?;
if parsed.is_empty() {
certificates.push(CertificateDer::from(entry));
} else {
certificates.extend(parsed);
}
}
if certificates.is_empty() {
return Err(SidecarError::InvalidState(String::from(
"TLS certificate material did not contain any certificates",
)));
}
Ok(certificates)
}
fn tls_private_key_from_material(
material: &JavascriptTlsMaterial,
) -> Result<PrivateKeyDer<'static>, SidecarError> {
for entry in tls_material_entries(material)? {
let mut reader = std::io::BufReader::new(Cursor::new(entry));
if let Some(key) = rustls_pemfile::private_key(&mut reader).map_err(sidecar_net_error)? {
return Ok(key);
}
}
Err(SidecarError::InvalidState(String::from(
"TLS private key material did not contain a supported key",
)))
}
fn tls_root_store(options: &JavascriptTlsBridgeOptions) -> Result<RootCertStore, SidecarError> {
let mut roots = RootCertStore::empty();
if let Some(ca) = options.ca.as_ref() {
for certificate in tls_certificates_from_material(ca)? {
roots.add(certificate).map_err(|error| {
SidecarError::InvalidState(format!("failed to add TLS CA certificate: {error}"))
})?;
}
return Ok(roots);
}
for certificate in rustls_native_certs::load_native_certs().certs {
roots.add(certificate).map_err(|error| {
SidecarError::InvalidState(format!(
"failed to add native TLS certificate to root store: {error}"
))
})?;
}
Ok(roots)
}
fn build_client_tls_stream(
stream: TcpStream,
options: &JavascriptTlsBridgeOptions,
) -> Result<rustls::StreamOwned<ClientConnection, TcpStream>, SidecarError> {
let config = build_client_tls_config(options)?;
let server_name = options
.servername
.clone()
.unwrap_or_else(|| String::from("localhost"));
let server_name = ServerName::try_from(server_name)
.map_err(|_| SidecarError::InvalidState(String::from("invalid TLS servername")))?;
stream
.set_read_timeout(Some(TLS_HANDSHAKE_TIMEOUT))
.map_err(sidecar_net_error)?;
stream
.set_write_timeout(Some(TLS_HANDSHAKE_TIMEOUT))
.map_err(sidecar_net_error)?;
let mut tls_stream = rustls::StreamOwned::new(
ClientConnection::new(Arc::new(config), server_name).map_err(|error| {
SidecarError::Execution(format!("failed to start TLS client: {error}"))
})?,
stream,
);
while tls_stream.conn.is_handshaking() {
tls_stream
.conn
.complete_io(&mut tls_stream.sock)
.map_err(sidecar_net_error)?;
}
tls_stream
.sock
.set_read_timeout(Some(TCP_SOCKET_POLL_TIMEOUT))
.map_err(sidecar_net_error)?;
tls_stream
.sock
.set_write_timeout(None)
.map_err(sidecar_net_error)?;
Ok(tls_stream)
}
fn build_client_loopback_tls_stream(
transport: crate::state::LoopbackTlsEndpoint,
options: &JavascriptTlsBridgeOptions,
) -> Result<rustls::StreamOwned<ClientConnection, crate::state::LoopbackTlsEndpoint>, SidecarError>
{
let config = build_client_tls_config(options)?;
let server_name = options
.servername
.clone()
.unwrap_or_else(|| String::from("localhost"));
let server_name = ServerName::try_from(server_name)
.map_err(|_| SidecarError::InvalidState(String::from("invalid TLS servername")))?;
let mut tls_stream = rustls::StreamOwned::new(
ClientConnection::new(Arc::new(config), server_name).map_err(|error| {
SidecarError::Execution(format!("failed to start TLS client: {error}"))
})?,
transport,
);
match tls_stream.conn.complete_io(&mut tls_stream.sock) {
Ok(_) => {}
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
) => {}
Err(error) => return Err(sidecar_net_error(error)),
}
Ok(tls_stream)
}
fn build_client_tls_config(
options: &JavascriptTlsBridgeOptions,
) -> Result<ClientConfig, SidecarError> {
let provider = tls_provider();
let builder = ClientConfig::builder_with_provider(provider.clone())
.with_safe_default_protocol_versions()
.map_err(|error| {
SidecarError::InvalidState(format!("invalid TLS protocol config: {error}"))
})?;
let mut config = if options.reject_unauthorized == Some(false) {
let verifier = Arc::new(InsecureTlsVerifier {
supported_schemes: provider
.signature_verification_algorithms
.supported_schemes(),
});
builder
.dangerous()
.with_custom_certificate_verifier(verifier)
.with_no_client_auth()
} else {
builder
.with_root_certificates(tls_root_store(options)?)
.with_no_client_auth()
};
if let Some(protocols) = options.alpn_protocols.as_ref() {
config.alpn_protocols = protocols
.iter()
.map(|protocol| protocol.as_bytes().to_vec())
.collect();
}
Ok(config)
}
fn build_server_tls_stream(
stream: TcpStream,
options: &JavascriptTlsBridgeOptions,
) -> Result<rustls::StreamOwned<ServerConnection, TcpStream>, SidecarError> {
let config = build_server_tls_config(options)?;
stream
.set_read_timeout(Some(TLS_HANDSHAKE_TIMEOUT))
.map_err(sidecar_net_error)?;
stream
.set_write_timeout(Some(TLS_HANDSHAKE_TIMEOUT))
.map_err(sidecar_net_error)?;
let mut tls_stream = rustls::StreamOwned::new(
ServerConnection::new(Arc::new(config)).map_err(|error| {
SidecarError::Execution(format!("failed to start TLS server: {error}"))
})?,
stream,
);
while tls_stream.conn.is_handshaking() {
tls_stream
.conn
.complete_io(&mut tls_stream.sock)
.map_err(sidecar_net_error)?;
}
tls_stream
.sock
.set_read_timeout(Some(TCP_SOCKET_POLL_TIMEOUT))
.map_err(sidecar_net_error)?;
tls_stream
.sock
.set_write_timeout(None)
.map_err(sidecar_net_error)?;
Ok(tls_stream)
}
fn build_server_loopback_tls_stream(
transport: crate::state::LoopbackTlsEndpoint,
options: &JavascriptTlsBridgeOptions,
) -> Result<rustls::StreamOwned<ServerConnection, crate::state::LoopbackTlsEndpoint>, SidecarError>
{
let config = build_server_tls_config(options)?;
Ok(rustls::StreamOwned::new(
ServerConnection::new(Arc::new(config)).map_err(|error| {
SidecarError::Execution(format!("failed to start TLS server: {error}"))
})?,
transport,
))
}
fn build_server_tls_config(
options: &JavascriptTlsBridgeOptions,
) -> Result<ServerConfig, SidecarError> {
let certificates = tls_certificates_from_material(options.cert.as_ref().ok_or_else(|| {
SidecarError::InvalidState(String::from("TLS server upgrade requires a certificate"))
})?)?;
let key = tls_private_key_from_material(options.key.as_ref().ok_or_else(|| {
SidecarError::InvalidState(String::from("TLS server upgrade requires a private key"))
})?)?;
let mut config = ServerConfig::builder_with_provider(tls_provider())
.with_safe_default_protocol_versions()
.map_err(|error| {
SidecarError::InvalidState(format!("invalid TLS protocol config: {error}"))
})?
.with_no_client_auth()
.with_single_cert(certificates, key)
.map_err(|error| {
SidecarError::InvalidState(format!("invalid TLS server config: {error}"))
})?;
if let Some(protocols) = options.alpn_protocols.as_ref() {
config.alpn_protocols = protocols
.iter()
.map(|protocol| protocol.as_bytes().to_vec())
.collect();
}
Ok(config)
}
fn tls_protocol_name(version: rustls::ProtocolVersion) -> String {
match version {
rustls::ProtocolVersion::TLSv1_2 => String::from("TLSv1.2"),
rustls::ProtocolVersion::TLSv1_3 => String::from("TLSv1.3"),
other => other
.as_str()
.map(str::to_owned)
.unwrap_or_else(|| format!("{other:?}")),
}
}
fn tls_cipher_bridge_value(suite: rustls::SupportedCipherSuite) -> Value {
tls_bridge_object(vec![
(
"name",
suite
.suite()
.as_str()
.map(|value| Value::String(value.to_owned()))
.unwrap_or(Value::Null),
),
(
"standardName",
suite
.suite()
.as_str()
.map(|value| Value::String(value.to_owned()))
.unwrap_or(Value::Null),
),
(
"version",
Value::String(if suite.tls13().is_some() {
String::from("TLSv1.3")
} else {
String::from("TLSv1.2")
}),
),
])
}
fn tls_certificate_bridge_value(certificate: &[u8], detailed: bool) -> Value {
let mut fields = vec![("raw", tls_bridge_buffer_value(certificate))];
if detailed {
fields.push(("issuerCertificate", tls_bridge_undefined_value()));
}
tls_bridge_object(fields)
}
fn tls_bridge_buffer_value(bytes: &[u8]) -> Value {
json!({
"type": "buffer",
"data": base64::engine::general_purpose::STANDARD.encode(bytes),
})
}
fn tls_bridge_object(entries: Vec<(&str, Value)>) -> Value {
let value = entries
.into_iter()
.map(|(key, value)| (key.to_owned(), value))
.collect::<serde_json::Map<String, Value>>();
json!({
"type": "object",
"id": 1,
"value": value,
})
}
fn tls_bridge_undefined_value() -> Value {
json!({
"type": "undefined",
})
}
fn spawn_tcp_socket_reader(
stream: TcpStream,
sender: Sender<JavascriptTcpSocketEvent>,
tls_mode: Arc<AtomicBool>,
saw_local_shutdown: Arc<AtomicBool>,
saw_remote_end: Arc<AtomicBool>,
close_notified: Arc<AtomicBool>,
) {
thread::spawn(move || {
let mut stream = stream;
let mut buffer = vec![0_u8; 64 * 1024];
loop {
if tls_mode.load(Ordering::SeqCst) {
break;
}
match stream.read(&mut buffer) {
Ok(0) => {
saw_remote_end.store(true, Ordering::SeqCst);
let _ = sender.send(JavascriptTcpSocketEvent::End);
if saw_local_shutdown.load(Ordering::SeqCst)
&& !close_notified.swap(true, Ordering::SeqCst)
{
let _ = sender.send(JavascriptTcpSocketEvent::Close { had_error: false });
}
break;
}
Ok(bytes_read) => {
if sender
.send(JavascriptTcpSocketEvent::Data(
buffer[..bytes_read].to_vec(),
))
.is_err()
{
break;
}
}
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
) =>
{
continue;
}
Err(error) => {
let code = io_error_code(&error);
let _ = sender.send(JavascriptTcpSocketEvent::Error {
code,
message: error.to_string(),
});
if !close_notified.swap(true, Ordering::SeqCst) {
let _ = sender.send(JavascriptTcpSocketEvent::Close { had_error: true });
}
break;
}
}
}
});
}
fn spawn_tls_socket_reader(
tls_stream: Arc<Mutex<Option<ActiveTlsStream>>>,
sender: Sender<JavascriptTcpSocketEvent>,
saw_local_shutdown: Arc<AtomicBool>,
saw_remote_end: Arc<AtomicBool>,
close_notified: Arc<AtomicBool>,
) {
thread::spawn(move || {
let mut buffer = vec![0_u8; 64 * 1024];
loop {
let read_result = {
let mut guard = match tls_stream.lock() {
Ok(guard) => guard,
Err(_) => return,
};
let Some(stream) = guard.as_mut() else {
return;
};
stream.read(&mut buffer)
};
match read_result {
Ok(0) => {
saw_remote_end.store(true, Ordering::SeqCst);
let _ = sender.send(JavascriptTcpSocketEvent::End);
if saw_local_shutdown.load(Ordering::SeqCst)
&& !close_notified.swap(true, Ordering::SeqCst)
{
let _ = sender.send(JavascriptTcpSocketEvent::Close { had_error: false });
}
break;
}
Ok(bytes_read) => {
if sender
.send(JavascriptTcpSocketEvent::Data(
buffer[..bytes_read].to_vec(),
))
.is_err()
{
break;
}
}
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
) =>
{
// The TLS reader and writer share one rustls stream mutex. Yield after
// timed-out reads so request writes can acquire the lock promptly.
std::thread::sleep(Duration::from_millis(1));
continue;
}
Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => {
saw_remote_end.store(true, Ordering::SeqCst);
let _ = sender.send(JavascriptTcpSocketEvent::End);
if saw_local_shutdown.load(Ordering::SeqCst)
&& !close_notified.swap(true, Ordering::SeqCst)
{
let _ = sender.send(JavascriptTcpSocketEvent::Close { had_error: false });
}
break;
}
Err(error) => {
let code = io_error_code(&error);
let _ = sender.send(JavascriptTcpSocketEvent::Error {
code,
message: error.to_string(),
});
if !close_notified.swap(true, Ordering::SeqCst) {
let _ = sender.send(JavascriptTcpSocketEvent::Close { had_error: true });
}
break;
}
}
}
});
}
fn spawn_unix_socket_reader(
stream: UnixStream,
sender: Sender<JavascriptTcpSocketEvent>,
saw_local_shutdown: Arc<AtomicBool>,
saw_remote_end: Arc<AtomicBool>,
close_notified: Arc<AtomicBool>,
) {
thread::spawn(move || {
let mut stream = stream;
let mut buffer = vec![0_u8; 64 * 1024];
loop {
match stream.read(&mut buffer) {
Ok(0) => {
saw_remote_end.store(true, Ordering::SeqCst);
let _ = sender.send(JavascriptTcpSocketEvent::End);
if saw_local_shutdown.load(Ordering::SeqCst)
&& !close_notified.swap(true, Ordering::SeqCst)
{
let _ = sender.send(JavascriptTcpSocketEvent::Close { had_error: false });
}
break;
}
Ok(bytes_read) => {
if sender
.send(JavascriptTcpSocketEvent::Data(
buffer[..bytes_read].to_vec(),
))
.is_err()
{
break;
}
}
Err(error) => {
let code = io_error_code(&error);
let _ = sender.send(JavascriptTcpSocketEvent::Error {
code,
message: error.to_string(),
});
if !close_notified.swap(true, Ordering::SeqCst) {
let _ = sender.send(JavascriptTcpSocketEvent::Close { had_error: true });
}
break;
}
}
}
});
}
fn terminate_child_process_tree(kernel: &mut SidecarKernel, process: &mut ActiveProcess) {
let sqlite_database_ids = process.sqlite_databases.keys().copied().collect::<Vec<_>>();
for database_id in sqlite_database_ids {
let _ = close_sqlite_database(kernel, process, database_id);
}
process.sqlite_statements.clear();
process.http_servers.clear();
process.pending_http_requests.clear();
if let Ok(mut http2) = process.http2.shared.lock() {
let sessions = http2.sessions.values().cloned().collect::<Vec<_>>();
http2.server_events.clear();
http2.session_events.clear();
http2.streams.clear();
http2.servers.clear();
http2.sessions.clear();
drop(http2);
for session in sessions {
let (respond_to, _rx) = mpsc::channel();
let _ = session.command_tx.send(Http2SessionCommand::Close {
abrupt: true,
respond_to,
});
}
}
let listener_ids = process.tcp_listeners.keys().cloned().collect::<Vec<_>>();
for listener_id in listener_ids {
if let Some(listener) = process.tcp_listeners.remove(&listener_id) {
let _ = listener.close(kernel, process.kernel_pid);
}
}
let sockets = process.tcp_sockets.keys().cloned().collect::<Vec<_>>();
for socket_id in sockets {
if let Some(socket) = process.tcp_sockets.remove(&socket_id) {
let _ = socket.close(kernel, process.kernel_pid);
}
}
let unix_listener_ids = process.unix_listeners.keys().cloned().collect::<Vec<_>>();
for listener_id in unix_listener_ids {
if let Some(listener) = process.unix_listeners.remove(&listener_id) {
let _ = listener.close();
}
}
let unix_sockets = process.unix_sockets.keys().cloned().collect::<Vec<_>>();
for socket_id in unix_sockets {
if let Some(socket) = process.unix_sockets.remove(&socket_id) {
let _ = socket.close();
}
}
let udp_socket_ids = process.udp_sockets.keys().cloned().collect::<Vec<_>>();
for socket_id in udp_socket_ids {
if let Some(mut socket) = process.udp_sockets.remove(&socket_id) {
socket.close(kernel, process.kernel_pid);
}
}
let child_ids = process.child_processes.keys().cloned().collect::<Vec<_>>();
for child_id in child_ids {
let Some(mut child) = process.child_processes.remove(&child_id) else {
continue;
};
terminate_child_process_tree(kernel, &mut child);
let _ = kernel.kill_process(EXECUTION_DRIVER_NAME, child.kernel_pid, SIGTERM);
let _ = signal_runtime_process(child.execution.child_pid(), SIGTERM);
child.kernel_handle.finish(0);
let _ = kernel.wait_and_reap(child.kernel_pid);
}
}
fn service_javascript_sqlite_sync_rpc(
kernel: &mut SidecarKernel,
process: &mut ActiveProcess,
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
match request.method.as_str() {
"sqlite.constants" => Ok(json!({})),
"sqlite.open" => sqlite_open_database(kernel, process, request),
"sqlite.close" => {
let database_id =
javascript_sync_rpc_arg_u64(&request.args, 0, "sqlite.close database id")?;
close_sqlite_database(kernel, process, database_id)?;
Ok(Value::Null)
}
"sqlite.exec" => sqlite_exec_database(kernel, process, request),
"sqlite.query" => sqlite_query_database(process, request),
"sqlite.prepare" => sqlite_prepare_statement(process, request),
"sqlite.location" => {
let database_id =
javascript_sync_rpc_arg_u64(&request.args, 0, "sqlite.location database id")?;
let database = sqlite_database(process, database_id)?;
Ok(database
.vm_path
.as_ref()
.map(|path| Value::String(path.clone()))
.unwrap_or(Value::Null))
}
"sqlite.checkpoint" => {
let database_id =
javascript_sync_rpc_arg_u64(&request.args, 0, "sqlite.checkpoint database id")?;
let kernel_pid = process.kernel_pid;
let database = sqlite_database_mut(process, database_id)?;
sqlite_sync_database(kernel, kernel_pid, database)?;
Ok(Value::Null)
}
"sqlite.statement.run" => sqlite_run_statement(kernel, process, request),
"sqlite.statement.get" => sqlite_get_statement(process, request),
"sqlite.statement.all" | "sqlite.statement.iterate" => {
sqlite_all_statement(process, request)
}
"sqlite.statement.columns" => sqlite_statement_columns(process, request),
"sqlite.statement.setReturnArrays" => {
let statement_id = javascript_sync_rpc_arg_u64(
&request.args,
0,
"sqlite.statement.setReturnArrays statement id",
)?;
let enabled = javascript_sync_rpc_arg_bool(
&request.args,
1,
"sqlite.statement.setReturnArrays enabled",
)?;
sqlite_statement_mut(process, statement_id)?.return_arrays = enabled;
Ok(Value::Null)
}
"sqlite.statement.setReadBigInts" => {
let statement_id = javascript_sync_rpc_arg_u64(
&request.args,
0,
"sqlite.statement.setReadBigInts statement id",
)?;
let enabled = javascript_sync_rpc_arg_bool(
&request.args,
1,
"sqlite.statement.setReadBigInts enabled",
)?;
sqlite_statement_mut(process, statement_id)?.read_bigints = enabled;
Ok(Value::Null)
}
"sqlite.statement.setAllowBareNamedParameters" => {
let statement_id = javascript_sync_rpc_arg_u64(
&request.args,
0,
"sqlite.statement.setAllowBareNamedParameters statement id",
)?;
let enabled = javascript_sync_rpc_arg_bool(
&request.args,
1,
"sqlite.statement.setAllowBareNamedParameters enabled",
)?;
sqlite_statement_mut(process, statement_id)?.allow_bare_named_parameters = enabled;
Ok(Value::Null)
}
"sqlite.statement.setAllowUnknownNamedParameters" => {
let statement_id = javascript_sync_rpc_arg_u64(
&request.args,
0,
"sqlite.statement.setAllowUnknownNamedParameters statement id",
)?;
let enabled = javascript_sync_rpc_arg_bool(
&request.args,
1,
"sqlite.statement.setAllowUnknownNamedParameters enabled",
)?;
sqlite_statement_mut(process, statement_id)?.allow_unknown_named_parameters = enabled;
Ok(Value::Null)
}
"sqlite.statement.finalize" => {
let statement_id = javascript_sync_rpc_arg_u64(
&request.args,
0,
"sqlite.statement.finalize statement id",
)?;
process
.sqlite_statements
.remove(&statement_id)
.ok_or_else(|| {
SidecarError::InvalidState(format!(
"sqlite statement handle not found: {statement_id}"
))
})?;
Ok(Value::Null)
}
other => Err(SidecarError::InvalidState(format!(
"unsupported JavaScript sqlite sync RPC method {other}"
))),
}
}
fn sqlite_open_database(
kernel: &mut SidecarKernel,
process: &mut ActiveProcess,
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
ensure_per_process_state_handle_capacity(process.sqlite_databases.len(), "sqlite database")?;
let path = request.args.first().and_then(Value::as_str);
let vm_path = path.filter(|value| !value.is_empty() && *value != ":memory:");
let options = request.args.get(1);
let read_only = sqlite_option_bool(options, "readOnly").unwrap_or(false);
let create = sqlite_option_bool(options, "create").unwrap_or(!read_only);
let timeout_ms = sqlite_option_u64(options, "timeout");
process.next_sqlite_database_id += 1;
let database_id = process.next_sqlite_database_id;
let host_path = if vm_path.is_some() {
Some(
std::env::temp_dir()
.join(format!(
"secure-exec-sidecar-sqlite-{}-{database_id}",
process.kernel_pid
))
.join("database.sqlite"),
)
} else {
None
};
if let Some(host_path) = host_path.as_ref() {
if let Some(parent) = host_path.parent() {
fs::create_dir_all(parent).map_err(|error| {
SidecarError::Io(format!(
"failed to prepare sqlite temp directory {}: {error}",
parent.display()
))
})?;
}
}
if let (Some(vm_path), Some(host_path)) = (vm_path, host_path.as_ref()) {
if kernel
.exists_for_process(EXECUTION_DRIVER_NAME, process.kernel_pid, vm_path)
.map_err(kernel_error)?
{
let contents = kernel
.read_file_for_process(EXECUTION_DRIVER_NAME, process.kernel_pid, vm_path)
.map_err(kernel_error)?;
fs::write(host_path, contents).map_err(|error| {
SidecarError::Io(format!(
"failed to materialize sqlite database {}: {error}",
host_path.display()
))
})?;
} else if read_only && !create {
return Err(SidecarError::InvalidState(format!(
"sqlite database does not exist: {vm_path}"
)));
}
}
let target = host_path
.as_ref()
.map(|path| path.to_string_lossy().into_owned())
.unwrap_or_else(|| String::from(":memory:"));
let mut flags = if read_only {
SqliteOpenFlags::SQLITE_OPEN_READ_ONLY
} else {
SqliteOpenFlags::SQLITE_OPEN_READ_WRITE
};
if create && !read_only {
flags |= SqliteOpenFlags::SQLITE_OPEN_CREATE;
}
let connection = SqliteConnection::open_with_flags(&target, flags).map_err(|error| {
SidecarError::InvalidState(format!(
"sqlite database open failed for {}: {error}",
vm_path.unwrap_or(":memory:")
))
})?;
if let Some(timeout_ms) = timeout_ms {
connection
.busy_timeout(Duration::from_millis(timeout_ms))
.map_err(sqlite_error)?;
}
if host_path.is_some() && !read_only {
let _ = connection.pragma_update(None, "journal_mode", "WAL");
}
process.sqlite_databases.insert(
database_id,
ActiveSqliteDatabase {
connection,
host_path,
vm_path: vm_path.map(String::from),
dirty: false,
transaction_depth: 0,
read_only,
},
);
Ok(json!(database_id))
}
fn sqlite_exec_database(
kernel: &mut SidecarKernel,
process: &mut ActiveProcess,
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
let database_id = javascript_sync_rpc_arg_u64(&request.args, 0, "sqlite.exec database id")?;
let sql = javascript_sync_rpc_arg_str(&request.args, 1, "sqlite.exec sql")?;
let kernel_pid = process.kernel_pid;
let database = sqlite_database_mut(process, database_id)?;
let before = database.connection.total_changes();
database
.connection
.execute_batch(sql)
.map_err(sqlite_error)?;
mark_sqlite_mutation(database, sql);
sqlite_sync_database(kernel, kernel_pid, database)?;
Ok(json!(database
.connection
.total_changes()
.saturating_sub(before)))
}
fn sqlite_query_database(
process: &mut ActiveProcess,
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
let database_id = javascript_sync_rpc_arg_u64(&request.args, 0, "sqlite.query database id")?;
let sql = javascript_sync_rpc_arg_str(&request.args, 1, "sqlite.query sql")?;
let params = request.args.get(2);
let options = request.args.get(3);
let return_arrays = sqlite_option_bool(options, "returnArrays").unwrap_or(false);
let read_bigints = sqlite_option_bool(options, "readBigInts").unwrap_or(false);
let database = sqlite_database_mut(process, database_id)?;
sqlite_query_rows(
&mut database.connection,
sql,
params,
return_arrays,
read_bigints,
true,
false,
)
}
fn sqlite_prepare_statement(
process: &mut ActiveProcess,
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
ensure_per_process_state_handle_capacity(process.sqlite_statements.len(), "sqlite statement")?;
let database_id = javascript_sync_rpc_arg_u64(&request.args, 0, "sqlite.prepare database id")?;
let sql = javascript_sync_rpc_arg_str(&request.args, 1, "sqlite.prepare sql")?;
let _ = sqlite_database(process, database_id)?;
process.next_sqlite_statement_id += 1;
let statement_id = process.next_sqlite_statement_id;
process.sqlite_statements.insert(
statement_id,
ActiveSqliteStatement {
database_id,
sql: sql.to_owned(),
return_arrays: false,
read_bigints: false,
allow_bare_named_parameters: false,
allow_unknown_named_parameters: false,
},
);
Ok(json!(statement_id))
}
fn sqlite_run_statement(
kernel: &mut SidecarKernel,
process: &mut ActiveProcess,
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
let statement_id =
javascript_sync_rpc_arg_u64(&request.args, 0, "sqlite.statement.run statement id")?;
let params = request.args.get(1);
let statement_state = sqlite_statement(process, statement_id)?.clone();
let kernel_pid = process.kernel_pid;
let database = sqlite_database_mut(process, statement_state.database_id)?;
let before = database.connection.total_changes();
{
let mut statement = database
.connection
.prepare(&statement_state.sql)
.map_err(sqlite_error)?;
bind_sqlite_parameters(
&mut statement,
params,
statement_state.allow_bare_named_parameters,
statement_state.allow_unknown_named_parameters,
)?;
statement.raw_execute().map_err(sqlite_error)?;
}
let changes = database.connection.total_changes().saturating_sub(before);
let last_insert_rowid = database.connection.last_insert_rowid();
mark_sqlite_mutation(database, &statement_state.sql);
sqlite_sync_database(kernel, kernel_pid, database)?;
let result = json!({
"changes": changes,
"lastInsertRowid": encode_sqlite_integer(last_insert_rowid, true),
});
Ok(result)
}
fn sqlite_get_statement(
process: &mut ActiveProcess,
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
let statement_id =
javascript_sync_rpc_arg_u64(&request.args, 0, "sqlite.statement.get statement id")?;
let params = request.args.get(1);
let statement_state = sqlite_statement(process, statement_id)?.clone();
let database = sqlite_database_mut(process, statement_state.database_id)?;
let rows = sqlite_query_rows(
&mut database.connection,
&statement_state.sql,
params,
statement_state.return_arrays,
statement_state.read_bigints,
statement_state.allow_bare_named_parameters,
statement_state.allow_unknown_named_parameters,
)?;
Ok(rows
.as_array()
.and_then(|rows| rows.first().cloned())
.unwrap_or(Value::Null))
}
fn sqlite_all_statement(
process: &mut ActiveProcess,
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
let statement_id =
javascript_sync_rpc_arg_u64(&request.args, 0, "sqlite.statement.all statement id")?;
let params = request.args.get(1);
let statement_state = sqlite_statement(process, statement_id)?.clone();
let database = sqlite_database_mut(process, statement_state.database_id)?;
sqlite_query_rows(
&mut database.connection,
&statement_state.sql,
params,
statement_state.return_arrays,
statement_state.read_bigints,
statement_state.allow_bare_named_parameters,
statement_state.allow_unknown_named_parameters,
)
}
fn sqlite_statement_columns(
process: &mut ActiveProcess,
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
let statement_id =
javascript_sync_rpc_arg_u64(&request.args, 0, "sqlite.statement.columns statement id")?;
let statement_state = sqlite_statement(process, statement_id)?.clone();
let database = sqlite_database_mut(process, statement_state.database_id)?;
let statement = database
.connection
.prepare(&statement_state.sql)
.map_err(sqlite_error)?;
Ok(Value::Array(
statement
.column_names()
.iter()
.map(|name| json!({ "name": name }))
.collect(),
))
}
fn sqlite_query_rows(
connection: &mut SqliteConnection,
sql: &str,
params: Option<&Value>,
return_arrays: bool,
read_bigints: bool,
allow_bare_named_parameters: bool,
allow_unknown_named_parameters: bool,
) -> Result<Value, SidecarError> {
let mut statement = connection.prepare(sql).map_err(sqlite_error)?;
let column_names = statement
.column_names()
.iter()
.map(|name| (*name).to_owned())
.collect::<Vec<_>>();
let column_count = statement.column_count();
bind_sqlite_parameters(
&mut statement,
params,
allow_bare_named_parameters,
allow_unknown_named_parameters,
)?;
let mut rows = statement.raw_query();
let mut encoded_rows = Vec::new();
while let Some(row) = rows.next().map_err(sqlite_error)? {
encoded_rows.push(encode_sqlite_row(
row,
&column_names,
column_count,
return_arrays,
read_bigints,
)?);
}
Ok(Value::Array(encoded_rows))
}
fn encode_sqlite_row(
row: &rusqlite::Row<'_>,
column_names: &[String],
column_count: usize,
return_arrays: bool,
read_bigints: bool,
) -> Result<Value, SidecarError> {
if return_arrays {
let mut values = Vec::with_capacity(column_count);
for index in 0..column_count {
values.push(encode_sqlite_value_ref(
row.get_ref(index).map_err(sqlite_error)?,
read_bigints,
)?);
}
return Ok(Value::Array(values));
}
let mut object = Map::with_capacity(column_count);
for (index, name) in column_names.iter().enumerate() {
object.insert(
name.clone(),
encode_sqlite_value_ref(row.get_ref(index).map_err(sqlite_error)?, read_bigints)?,
);
}
Ok(Value::Object(object))
}
fn encode_sqlite_value_ref(
value: SqliteValueRef<'_>,
read_bigints: bool,
) -> Result<Value, SidecarError> {
Ok(match value {
SqliteValueRef::Null => Value::Null,
SqliteValueRef::Integer(number) => encode_sqlite_integer(number, read_bigints),
SqliteValueRef::Real(number) => json!(number),
SqliteValueRef::Text(text) => Value::String(String::from_utf8_lossy(text).into_owned()),
SqliteValueRef::Blob(bytes) => json!({
"__agentosSqliteType": "uint8array",
"value": base64::engine::general_purpose::STANDARD.encode(bytes),
}),
})
}
fn encode_sqlite_integer(number: i64, read_bigints: bool) -> Value {
if read_bigints || number.abs() > SQLITE_JS_SAFE_INTEGER_MAX {
json!({
"__agentosSqliteType": "bigint",
"value": number.to_string(),
})
} else {
json!(number)
}
}
fn bind_sqlite_parameters(
statement: &mut SqliteStatement<'_>,
params: Option<&Value>,
allow_bare_named_parameters: bool,
allow_unknown_named_parameters: bool,
) -> Result<(), SidecarError> {
let Some(params) = params else {
return Ok(());
};
match params {
Value::Null => Ok(()),
Value::Array(values) => {
for (index, value) in values.iter().enumerate() {
statement
.raw_bind_parameter(index + 1, decode_sqlite_parameter(value)?)
.map_err(sqlite_error)?;
}
Ok(())
}
Value::Object(map)
if map
.get("__agentosSqliteType")
.and_then(Value::as_str)
.is_none() =>
{
for (key, value) in map {
let index =
resolve_sqlite_parameter_index(statement, key, allow_bare_named_parameters)?;
let Some(index) = index else {
if allow_unknown_named_parameters {
continue;
}
return Err(SidecarError::InvalidState(format!(
"sqlite named parameter not found: {key}"
)));
};
statement
.raw_bind_parameter(index, decode_sqlite_parameter(value)?)
.map_err(sqlite_error)?;
}
Ok(())
}
other => statement
.raw_bind_parameter(1, decode_sqlite_parameter(other)?)
.map_err(sqlite_error),
}
}
fn resolve_sqlite_parameter_index(
statement: &mut SqliteStatement<'_>,
key: &str,
allow_bare_named_parameters: bool,
) -> Result<Option<usize>, SidecarError> {
let mut candidates = vec![key.to_owned()];
if allow_bare_named_parameters
&& !key.starts_with(':')
&& !key.starts_with('@')
&& !key.starts_with('$')
{
candidates.push(format!(":{key}"));
candidates.push(format!("@{key}"));
candidates.push(format!("${key}"));
}
for candidate in candidates {
if let Some(index) = statement
.parameter_index(&candidate)
.map_err(sqlite_error)?
{
return Ok(Some(index));
}
}
Ok(None)
}
fn decode_sqlite_parameter(value: &Value) -> Result<rusqlite::types::Value, SidecarError> {
Ok(match value {
Value::Null => rusqlite::types::Value::Null,
Value::Bool(value) => rusqlite::types::Value::Integer(i64::from(*value)),
Value::Number(value) => match (value.as_i64(), value.as_f64()) {
(Some(integer), _) => rusqlite::types::Value::Integer(integer),
(_, Some(real)) => rusqlite::types::Value::Real(real),
_ => {
return Err(SidecarError::InvalidState(String::from(
"sqlite parameter number is not representable",
)));
}
},
Value::String(value) => rusqlite::types::Value::Text(value.clone()),
Value::Array(_) => {
return Err(SidecarError::InvalidState(String::from(
"sqlite parameters do not support nested arrays",
)));
}
Value::Object(map) => match map.get("__agentosSqliteType").and_then(Value::as_str) {
Some("bigint") => rusqlite::types::Value::Integer(
map.get("value")
.and_then(Value::as_str)
.ok_or_else(|| {
SidecarError::InvalidState(String::from(
"sqlite bigint parameter missing string value",
))
})?
.parse::<i64>()
.map_err(|error| {
SidecarError::InvalidState(format!(
"sqlite bigint parameter is not a signed 64-bit integer: {error}"
))
})?,
),
Some("uint8array") => rusqlite::types::Value::Blob(
base64::engine::general_purpose::STANDARD
.decode(map.get("value").and_then(Value::as_str).ok_or_else(|| {
SidecarError::InvalidState(String::from(
"sqlite blob parameter missing base64 value",
))
})?)
.map_err(|error| {
SidecarError::InvalidState(format!(
"sqlite blob parameter contains invalid base64: {error}"
))
})?,
),
Some(other) => {
return Err(SidecarError::InvalidState(format!(
"unsupported sqlite tagged parameter type {other}"
)));
}
None => {
return Err(SidecarError::InvalidState(String::from(
"sqlite named parameter objects must be passed as the top-level params object",
)));
}
},
})
}
fn close_sqlite_database(
kernel: &mut SidecarKernel,
process: &mut ActiveProcess,
database_id: u64,
) -> Result<(), SidecarError> {
let mut database = process
.sqlite_databases
.remove(&database_id)
.ok_or_else(|| {
SidecarError::InvalidState(format!("sqlite database handle not found: {database_id}"))
})?;
process
.sqlite_statements
.retain(|_, statement| statement.database_id != database_id);
sqlite_sync_database(kernel, process.kernel_pid, &mut database)?;
let host_path = database.host_path.clone();
drop(database);
cleanup_sqlite_host_artifacts(host_path.as_deref())?;
Ok(())
}
fn ensure_per_process_state_handle_capacity(len: usize, label: &str) -> Result<(), SidecarError> {
if len >= MAX_PER_PROCESS_STATE_HANDLES {
return Err(SidecarError::InvalidState(format!(
"{label} handle limit exceeded: limit is {MAX_PER_PROCESS_STATE_HANDLES}"
)));
}
Ok(())
}
fn sqlite_sync_database(
kernel: &mut SidecarKernel,
kernel_pid: u32,
database: &mut ActiveSqliteDatabase,
) -> Result<(), SidecarError> {
if !database.dirty
|| database.transaction_depth > 0
|| database.read_only
|| database.host_path.is_none()
|| database.vm_path.is_none()
{
return Ok(());
}
let _ = database
.connection
.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)");
let host_path = database.host_path.as_ref().expect("sqlite host path");
if !host_path.exists() {
return Ok(());
}
ensure_vm_parent_dir(
kernel,
kernel_pid,
database.vm_path.as_deref().expect("sqlite vm path"),
)?;
let contents = fs::read(host_path).map_err(|error| {
SidecarError::Io(format!(
"failed to read sqlite temp database {}: {error}",
host_path.display()
))
})?;
kernel
.write_file_for_process(
EXECUTION_DRIVER_NAME,
kernel_pid,
database.vm_path.as_deref().expect("sqlite vm path"),
contents,
None,
)
.map_err(kernel_error)?;
database.dirty = false;
Ok(())
}
fn cleanup_sqlite_host_artifacts(host_path: Option<&Path>) -> Result<(), SidecarError> {
let Some(host_path) = host_path else {
return Ok(());
};
let parent = host_path.parent().map(PathBuf::from);
for suffix in ["", "-wal", "-shm"] {
let path = PathBuf::from(format!("{}{}", host_path.display(), suffix));
if path.exists() {
fs::remove_file(&path).map_err(|error| {
SidecarError::Io(format!(
"failed to remove sqlite temp artifact {}: {error}",
path.display()
))
})?;
}
}
if let Some(parent) = parent {
let _ = fs::remove_dir_all(parent);
}
Ok(())
}
fn ensure_vm_parent_dir(
kernel: &mut SidecarKernel,
kernel_pid: u32,
path: &str,
) -> Result<(), SidecarError> {
let parent = dirname(path);
if parent == "/" || parent == "." {
return Ok(());
}
let mut current = String::new();
for segment in parent.split('/').filter(|segment| !segment.is_empty()) {
current.push('/');
current.push_str(segment);
if !kernel
.exists_for_process(EXECUTION_DRIVER_NAME, kernel_pid, ¤t)
.map_err(kernel_error)?
{
kernel
.mkdir_for_process(EXECUTION_DRIVER_NAME, kernel_pid, ¤t, false, None)
.map_err(kernel_error)?;
}
}
Ok(())
}
fn sqlite_database(
process: &ActiveProcess,
database_id: u64,
) -> Result<&ActiveSqliteDatabase, SidecarError> {
process.sqlite_databases.get(&database_id).ok_or_else(|| {
SidecarError::InvalidState(format!("sqlite database handle not found: {database_id}"))
})
}
fn sqlite_database_mut(
process: &mut ActiveProcess,
database_id: u64,
) -> Result<&mut ActiveSqliteDatabase, SidecarError> {
process
.sqlite_databases
.get_mut(&database_id)
.ok_or_else(|| {
SidecarError::InvalidState(format!("sqlite database handle not found: {database_id}"))
})
}
fn sqlite_statement(
process: &ActiveProcess,
statement_id: u64,
) -> Result<&ActiveSqliteStatement, SidecarError> {
process.sqlite_statements.get(&statement_id).ok_or_else(|| {
SidecarError::InvalidState(format!("sqlite statement handle not found: {statement_id}"))
})
}
fn sqlite_statement_mut(
process: &mut ActiveProcess,
statement_id: u64,
) -> Result<&mut ActiveSqliteStatement, SidecarError> {
process
.sqlite_statements
.get_mut(&statement_id)
.ok_or_else(|| {
SidecarError::InvalidState(format!("sqlite statement handle not found: {statement_id}"))
})
}
fn mark_sqlite_mutation(database: &mut ActiveSqliteDatabase, sql: &str) {
let normalized = sql.trim_start().to_ascii_lowercase();
if normalized.starts_with("begin") || normalized.starts_with("savepoint") {
database.dirty = true;
database.transaction_depth += 1;
return;
}
if normalized.starts_with("commit") || normalized.starts_with("release savepoint") {
database.dirty = true;
database.transaction_depth = database.transaction_depth.saturating_sub(1);
return;
}
if normalized.starts_with("rollback") && !normalized.starts_with("rollback to") {
database.dirty = true;
database.transaction_depth = database.transaction_depth.saturating_sub(1);
return;
}
if normalized.starts_with("insert")
|| normalized.starts_with("update")
|| normalized.starts_with("delete")
|| normalized.starts_with("replace")
|| normalized.starts_with("create")
|| normalized.starts_with("alter")
|| normalized.starts_with("drop")
|| normalized.starts_with("vacuum")
|| normalized.starts_with("reindex")
|| normalized.starts_with("analyze")
|| normalized.starts_with("attach")
|| normalized.starts_with("detach")
|| normalized.starts_with("pragma")
{
database.dirty = true;
}
}
fn sqlite_option_bool(options: Option<&Value>, key: &str) -> Option<bool> {
options
.and_then(|value| value.get(key))
.and_then(Value::as_bool)
}
fn sqlite_option_u64(options: Option<&Value>, key: &str) -> Option<u64> {
options
.and_then(|value| value.get(key))
.and_then(Value::as_u64)
}
fn sqlite_error(error: rusqlite::Error) -> SidecarError {
SidecarError::InvalidState(format!("sqlite error: {error}"))
}
pub(crate) fn javascript_sync_rpc_arg_str<'a>(
args: &'a [Value],
index: usize,
label: &str,
) -> Result<&'a str, SidecarError> {
args.get(index)
.and_then(Value::as_str)
.ok_or_else(|| SidecarError::InvalidState(format!("{label} must be a string argument")))
}
pub(crate) fn javascript_sync_rpc_arg_bool(
args: &[Value],
index: usize,
label: &str,
) -> Result<bool, SidecarError> {
args.get(index)
.and_then(Value::as_bool)
.ok_or_else(|| SidecarError::InvalidState(format!("{label} must be a boolean argument")))
}
pub(crate) fn javascript_sync_rpc_encoding(args: &[Value]) -> Option<String> {
args.get(1).and_then(|value| {
value.as_str().map(str::to_owned).or_else(|| {
value
.get("encoding")
.and_then(Value::as_str)
.map(str::to_owned)
})
})
}
pub(crate) fn javascript_sync_rpc_option_bool(
args: &[Value],
index: usize,
key: &str,
) -> Option<bool> {
let value = args.get(index)?;
if key == "recursive" {
if let Some(boolean) = value.as_bool() {
return Some(boolean);
}
}
value.get(key).and_then(Value::as_bool)
}
pub(crate) fn javascript_sync_rpc_option_u32(
args: &[Value],
index: usize,
key: &str,
) -> Result<Option<u32>, SidecarError> {
let Some(value) = args.get(index).and_then(|value| {
if value.is_object() {
value.get(key)
} else if key == "mode" && value.is_number() {
Some(value)
} else {
None
}
}) else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
let numeric = value
.as_u64()
.or_else(|| {
value
.as_f64()
.filter(|number| number.is_finite() && *number >= 0.0)
.map(|number| number as u64)
})
.ok_or_else(|| SidecarError::InvalidState(format!("{key} must be numeric")))?;
u32::try_from(numeric)
.map(Some)
.map_err(|_| SidecarError::InvalidState(format!("{key} must fit within u32")))
}
pub(crate) fn javascript_sync_rpc_arg_u32(
args: &[Value],
index: usize,
label: &str,
) -> Result<u32, SidecarError> {
let value = javascript_sync_rpc_arg_u64(args, index, label)?;
u32::try_from(value)
.map_err(|_| SidecarError::InvalidState(format!("{label} must fit within u32")))
}
pub(crate) fn javascript_sync_rpc_arg_i32(
args: &[Value],
index: usize,
label: &str,
) -> Result<i32, SidecarError> {
let Some(value) = args.get(index) else {
return Err(SidecarError::InvalidState(format!("{label} is required")));
};
let numeric = value
.as_i64()
.or_else(|| {
value
.as_f64()
.filter(|number| number.is_finite())
.map(|number| number as i64)
})
.ok_or_else(|| SidecarError::InvalidState(format!("{label} must be a numeric argument")))?;
i32::try_from(numeric)
.map_err(|_| SidecarError::InvalidState(format!("{label} must fit within i32")))
}
pub(crate) fn javascript_sync_rpc_arg_u32_optional(
args: &[Value],
index: usize,
label: &str,
) -> Result<Option<u32>, SidecarError> {
javascript_sync_rpc_arg_u64_optional(args, index, label)?
.map(|value| {
u32::try_from(value)
.map_err(|_| SidecarError::InvalidState(format!("{label} must fit within u32")))
})
.transpose()
}
pub(crate) fn javascript_sync_rpc_arg_u64(
args: &[Value],
index: usize,
label: &str,
) -> Result<u64, SidecarError> {
let Some(value) = args.get(index) else {
return Err(SidecarError::InvalidState(format!("{label} is required")));
};
value
.as_u64()
.or_else(|| {
value
.as_f64()
.filter(|number| number.is_finite() && *number >= 0.0)
.map(|number| number as u64)
})
.ok_or_else(|| SidecarError::InvalidState(format!("{label} must be a numeric argument")))
}
pub(crate) fn javascript_sync_rpc_arg_u64_optional(
args: &[Value],
index: usize,
label: &str,
) -> Result<Option<u64>, SidecarError> {
let Some(value) = args.get(index) else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
javascript_sync_rpc_arg_u64(args, index, label).map(Some)
}
pub(crate) fn javascript_sync_rpc_bytes_arg(
args: &[Value],
index: usize,
label: &str,
) -> Result<Vec<u8>, SidecarError> {
let Some(value) = args.get(index) else {
return Err(SidecarError::InvalidState(format!("{label} is required")));
};
if let Some(text) = value.as_str() {
return Ok(text.as_bytes().to_vec());
}
let Some(base64_value) = value
.get("__agentOSType")
.and_then(Value::as_str)
.filter(|kind| *kind == "bytes")
.and_then(|_| value.get("base64"))
.and_then(Value::as_str)
else {
return Err(SidecarError::InvalidState(format!(
"{label} must be a string or encoded bytes payload"
)));
};
base64::engine::general_purpose::STANDARD
.decode(base64_value)
.map_err(|error| {
SidecarError::InvalidState(format!("{label} contains invalid base64: {error}"))
})
}
pub(crate) fn javascript_sync_rpc_bytes_value(bytes: &[u8]) -> Value {
json!({
"__agentOSType": "bytes",
"base64": base64::engine::general_purpose::STANDARD.encode(bytes),
})
}
#[derive(Debug, Deserialize)]
struct KernelPollFdRequest {
fd: u32,
events: u16,
}
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
struct KernelPollFdResponse {
fd: u32,
events: u16,
revents: u16,
}
fn javascript_sync_rpc_base64_arg(
args: &[Value],
index: usize,
label: &str,
) -> Result<Vec<u8>, SidecarError> {
let value = javascript_sync_rpc_arg_str(args, index, label)?;
base64::engine::general_purpose::STANDARD
.decode(value)
.map_err(|error| {
SidecarError::InvalidState(format!("{label} contains invalid base64: {error}"))
})
}
pub(crate) fn service_javascript_sync_rpc<B>(
request: JavascriptSyncRpcServiceRequest<'_, B>,
) -> Result<Value, SidecarError>
where
B: NativeSidecarBridge + Send + 'static,
BridgeError<B>: fmt::Debug + Send + Sync + 'static,
{
let JavascriptSyncRpcServiceRequest {
bridge,
vm_id,
dns,
socket_paths,
kernel,
process,
sync_request: request,
resource_limits,
network_counts,
} = request;
match request.method.as_str() {
// Module resolution / loading / format detection read the kernel VFS so
// the resolver sees exactly what the guest and `kernel.readFile()` see.
"_resolveModule"
| "_resolveModuleSync"
| "__resolve_module"
| "_batchResolveModules"
| "__batch_resolve_modules"
| "_loadFile"
| "_loadFileSync"
| "__load_file"
| "_moduleFormat"
| "__module_format" => service_javascript_module_sync_rpc(kernel, process, request),
// Polyfills are static guest expressions, not VFS reads.
"_loadPolyfill" | "__load_polyfill" => {
service_javascript_internal_bridge_sync_rpc(process, request)
}
"__kernel_stdin_read" => match &process.execution {
ActiveExecution::Javascript(execution) => execution
.read_kernel_stdin_sync_rpc(request)
.map_err(|error| SidecarError::Execution(error.to_string())),
ActiveExecution::Python(_) | ActiveExecution::Wasm(_) | ActiveExecution::Tool(_) => {
service_javascript_kernel_stdin_sync_rpc(kernel, process, request)
}
},
"__kernel_stdio_write" => {
service_javascript_kernel_stdio_write_sync_rpc(kernel, process, request)
}
"__kernel_poll" => service_javascript_kernel_poll_sync_rpc(kernel, process, request),
"__pty_set_raw_mode" => {
service_javascript_pty_set_raw_mode_sync_rpc(kernel, process, request)
}
"crypto.hashDigest"
| "crypto.hmacDigest"
| "crypto.pbkdf2"
| "crypto.scrypt"
| "crypto.cipheriv"
| "crypto.decipheriv"
| "crypto.cipherivCreate"
| "crypto.cipherivUpdate"
| "crypto.cipherivFinal"
| "crypto.sign"
| "crypto.verify"
| "crypto.asymmetricOp"
| "crypto.createKeyObject"
| "crypto.generateKeyPairSync"
| "crypto.generateKeySync"
| "crypto.generatePrimeSync"
| "crypto.diffieHellman"
| "crypto.diffieHellmanGroup"
| "crypto.diffieHellmanSessionCreate"
| "crypto.diffieHellmanSessionCall"
| "crypto.diffieHellmanSessionDestroy"
| "crypto.subtle" => service_javascript_crypto_sync_rpc(process, request),
"dns.lookup" | "dns.resolve" | "dns.resolve4" | "dns.resolve6" => {
service_javascript_dns_sync_rpc(bridge, kernel, vm_id, dns, request)
}
"net.http_listen" | "net.http_close" | "net.http_wait" | "net.http_respond" => {
service_javascript_net_sync_rpc(JavascriptNetSyncRpcServiceRequest {
bridge,
vm_id,
dns,
socket_paths,
kernel,
process,
sync_request: request,
resource_limits,
network_counts,
})
}
"net.http2_server_listen"
| "net.http2_server_poll"
| "net.http2_server_close"
| "net.http2_server_respond"
| "net.http2_server_wait"
| "net.http2_session_connect"
| "net.http2_session_request"
| "net.http2_session_settings"
| "net.http2_session_set_local_window_size"
| "net.http2_session_goaway"
| "net.http2_session_close"
| "net.http2_session_destroy"
| "net.http2_session_poll"
| "net.http2_session_wait"
| "net.http2_stream_respond"
| "net.http2_stream_push_stream"
| "net.http2_stream_write"
| "net.http2_stream_end"
| "net.http2_stream_close"
| "net.http2_stream_pause"
| "net.http2_stream_resume"
| "net.http2_stream_respond_with_file" => {
service_javascript_http2_sync_rpc(JavascriptHttp2SyncRpcServiceRequest {
bridge,
kernel,
vm_id,
dns,
socket_paths,
process,
sync_request: request,
resource_limits,
network_counts,
})
}
"net.connect"
| "net.reserve_tcp_port"
| "net.release_tcp_port"
| "net.listen"
| "net.poll"
| "net.socket_wait_connect"
| "net.socket_read"
| "net.socket_set_no_delay"
| "net.socket_set_keep_alive"
| "net.socket_upgrade_tls"
| "net.socket_get_tls_client_hello"
| "net.socket_tls_query"
| "net.server_poll"
| "net.server_accept"
| "net.server_connections"
| "net.upgrade_socket_write"
| "net.upgrade_socket_end"
| "net.upgrade_socket_destroy"
| "net.write"
| "net.shutdown"
| "net.destroy"
| "net.server_close"
| "tls.get_ciphers" => {
service_javascript_net_sync_rpc(JavascriptNetSyncRpcServiceRequest {
bridge,
vm_id,
dns,
socket_paths,
kernel,
process,
sync_request: request,
resource_limits,
network_counts,
})
}
"dgram.createSocket"
| "dgram.bind"
| "dgram.send"
| "dgram.poll"
| "dgram.close"
| "dgram.address"
| "dgram.setBufferSize"
| "dgram.getBufferSize" => {
service_javascript_dgram_sync_rpc(JavascriptDgramSyncRpcServiceRequest {
bridge,
kernel,
vm_id,
dns,
socket_paths,
process,
sync_request: request,
resource_limits,
network_counts,
})
}
"sqlite.constants"
| "sqlite.open"
| "sqlite.close"
| "sqlite.exec"
| "sqlite.query"
| "sqlite.prepare"
| "sqlite.location"
| "sqlite.checkpoint"
| "sqlite.statement.run"
| "sqlite.statement.get"
| "sqlite.statement.all"
| "sqlite.statement.iterate"
| "sqlite.statement.columns"
| "sqlite.statement.setReturnArrays"
| "sqlite.statement.setReadBigInts"
| "sqlite.statement.setAllowBareNamedParameters"
| "sqlite.statement.setAllowUnknownNamedParameters"
| "sqlite.statement.finalize" => {
service_javascript_sqlite_sync_rpc(kernel, process, request)
}
"process.kill" => {
let target_pid =
javascript_sync_rpc_arg_i32(&request.args, 0, "process.kill target pid")?;
let signal = javascript_sync_rpc_arg_str(&request.args, 1, "process.kill signal")?;
let parsed_signal = parse_signal(signal)?;
if parsed_signal == 0 {
kernel
.signal_process(EXECUTION_DRIVER_NAME, target_pid, parsed_signal)
.map_err(kernel_error)?;
return Ok(Value::Null);
}
let process_pid = i32::try_from(process.kernel_pid)
.map_err(|_| SidecarError::InvalidState("process pid exceeds i32".into()))?;
if target_pid != process_pid {
return Err(SidecarError::InvalidState(format!(
"unknown process pid {target_pid}"
)));
}
process.pending_self_signal_exit = None;
if parsed_signal != 0
&& !matches!(
canonical_signal_name(parsed_signal),
Some("SIGWINCH" | "SIGCHLD" | "SIGCONT" | "SIGURG")
)
{
process.pending_self_signal_exit = Some(parsed_signal);
}
Ok(json!({
"self": true,
"action": "default",
}))
}
"process.umask" => {
let new_mask = javascript_sync_rpc_arg_u32_optional(&request.args, 0, "process umask")?;
kernel
.umask(EXECUTION_DRIVER_NAME, process.kernel_pid, new_mask)
.map(|mask| json!(mask))
.map_err(kernel_error)
}
"fs.chmodSync" | "fs.promises.chmod" => {
let response =
service_javascript_fs_sync_rpc(kernel, process, process.kernel_pid, request)?;
mirror_process_chmod_to_host(process, request)?;
Ok(response)
}
_ => service_javascript_fs_sync_rpc(kernel, process, process.kernel_pid, request),
}
}
fn service_javascript_internal_bridge_sync_rpc(
process: &ActiveProcess,
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
// Module resolution / loading / format now reads the kernel VFS via
// `service_javascript_module_sync_rpc`. This host-context path only handles
// polyfills, which are static guest expressions independent of the FS.
let method = match request.method.as_str() {
"_loadPolyfill" | "__load_polyfill" => "_loadPolyfill",
other => {
return Err(SidecarError::InvalidState(format!(
"unsupported JavaScript internal bridge method {other}"
)));
}
};
handle_internal_bridge_call_from_host_context(
&process.host_cwd,
&process.guest_cwd,
&process.env,
method,
&request.args,
)
.ok_or_else(|| {
SidecarError::InvalidState(format!(
"JavaScript internal bridge method {method} returned no value"
))
})
}
fn mirror_process_chmod_to_host(
process: &ActiveProcess,
request: &JavascriptSyncRpcRequest,
) -> Result<(), SidecarError> {
let guest_path = javascript_sync_rpc_arg_str(&request.args, 0, "filesystem chmod path")?;
let mode = javascript_sync_rpc_arg_u32(&request.args, 1, "filesystem chmod mode")? & 0o7777;
let Some(host_path) = resolve_process_guest_path_to_host(process, guest_path) else {
return Ok(());
};
if !host_path.exists() {
return Ok(());
}
fs::set_permissions(&host_path, fs::Permissions::from_mode(mode)).map_err(|error| {
SidecarError::Io(format!(
"failed to mirror chmod to host path {}: {error}",
host_path.display()
))
})
}
fn resolve_process_guest_path_to_host(
process: &ActiveProcess,
guest_path: &str,
) -> Option<PathBuf> {
let normalized_guest_path = if guest_path.starts_with('/') {
normalize_path(guest_path)
} else {
normalize_path(&format!(
"{}/{}",
process.guest_cwd.trim_end_matches('/'),
guest_path
))
};
if let Some(host_path) =
host_path_from_runtime_guest_mappings(&process.env, &normalized_guest_path)
{
return Some(host_path);
}
let normalized_guest_cwd = normalize_path(&process.guest_cwd);
let mut host_root = normalize_host_path(&process.host_cwd);
for _ in normalized_guest_cwd
.trim_start_matches('/')
.split('/')
.filter(|segment| !segment.is_empty())
{
host_root = host_root.parent()?.to_path_buf();
}
if normalized_guest_path == "/" {
Some(host_root)
} else {
Some(host_root.join(normalized_guest_path.trim_start_matches('/')))
}
}
pub(crate) fn service_javascript_crypto_sync_rpc(
process: &mut ActiveProcess,
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
match request.method.as_str() {
"crypto.hashDigest" => {
let algorithm = javascript_crypto_digest_algorithm(
&request.args,
0,
"crypto.hashDigest algorithm",
)?;
let data = javascript_sync_rpc_base64_arg(&request.args, 1, "crypto.hashDigest data")?;
Ok(Value::String(
base64::engine::general_purpose::STANDARD.encode(algorithm.digest(&data)),
))
}
"crypto.hmacDigest" => {
let algorithm = javascript_crypto_digest_algorithm(
&request.args,
0,
"crypto.hmacDigest algorithm",
)?;
let key = javascript_sync_rpc_base64_arg(&request.args, 1, "crypto.hmacDigest key")?;
let data = javascript_sync_rpc_base64_arg(&request.args, 2, "crypto.hmacDigest data")?;
Ok(Value::String(
base64::engine::general_purpose::STANDARD.encode(algorithm.hmac(&key, &data)?),
))
}
"crypto.pbkdf2" => {
let password =
javascript_sync_rpc_base64_arg(&request.args, 0, "crypto.pbkdf2 password")?;
let salt = javascript_sync_rpc_base64_arg(&request.args, 1, "crypto.pbkdf2 salt")?;
let iterations =
javascript_sync_rpc_arg_u32(&request.args, 2, "crypto.pbkdf2 iterations")?;
if iterations == 0 {
return Err(SidecarError::InvalidState(String::from(
"crypto.pbkdf2 iterations must be greater than zero",
)));
}
let key_len = usize::try_from(javascript_sync_rpc_arg_u64(
&request.args,
3,
"crypto.pbkdf2 key length",
)?)
.map_err(|_| {
SidecarError::InvalidState(String::from(
"crypto.pbkdf2 key length must fit within usize",
))
})?;
let algorithm =
javascript_crypto_digest_algorithm(&request.args, 4, "crypto.pbkdf2 digest")?;
let mut output = vec![0u8; key_len];
algorithm.pbkdf2(&password, &salt, iterations, &mut output);
Ok(Value::String(
base64::engine::general_purpose::STANDARD.encode(output),
))
}
"crypto.scrypt" => {
let password =
javascript_sync_rpc_base64_arg(&request.args, 0, "crypto.scrypt password")?;
let salt = javascript_sync_rpc_base64_arg(&request.args, 1, "crypto.scrypt salt")?;
let key_len = usize::try_from(javascript_sync_rpc_arg_u64(
&request.args,
2,
"crypto.scrypt key length",
)?)
.map_err(|_| {
SidecarError::InvalidState(String::from(
"crypto.scrypt key length must fit within usize",
))
})?;
let options_json =
javascript_sync_rpc_arg_str(&request.args, 3, "crypto.scrypt options")?;
let options: JavascriptScryptOptions =
serde_json::from_str(options_json).map_err(|error| {
SidecarError::InvalidState(format!(
"crypto.scrypt options must be valid JSON: {error}"
))
})?;
let cost = options.cost.unwrap_or(DEFAULT_SCRYPT_COST);
if cost == 0 || !cost.is_power_of_two() {
return Err(SidecarError::InvalidState(String::from(
"crypto.scrypt cost must be a positive power of two",
)));
}
let log_n = u8::try_from(cost.ilog2()).map_err(|_| {
SidecarError::InvalidState(String::from(
"crypto.scrypt cost exceeds supported parameter range",
))
})?;
let params = ScryptParams::new(
log_n,
options.block_size.unwrap_or(DEFAULT_SCRYPT_BLOCK_SIZE),
options
.parallelization
.unwrap_or(DEFAULT_SCRYPT_PARALLELIZATION),
key_len,
)
.map_err(|error| {
SidecarError::InvalidState(format!("crypto.scrypt options are invalid: {error}"))
})?;
let mut output = vec![0u8; key_len];
scrypt(&password, &salt, ¶ms, &mut output).map_err(|error| {
SidecarError::Execution(format!("crypto.scrypt failed: {error}"))
})?;
Ok(Value::String(
base64::engine::general_purpose::STANDARD.encode(output),
))
}
"crypto.cipheriv" => service_javascript_crypto_cipheriv_sync_rpc(request),
"crypto.decipheriv" => service_javascript_crypto_decipheriv_sync_rpc(request),
"crypto.cipherivCreate" => {
service_javascript_crypto_cipheriv_create_sync_rpc(process, request)
}
"crypto.cipherivUpdate" => {
service_javascript_crypto_cipheriv_update_sync_rpc(process, request)
}
"crypto.cipherivFinal" => {
service_javascript_crypto_cipheriv_final_sync_rpc(process, request)
}
"crypto.sign" => service_javascript_crypto_sign_sync_rpc(request),
"crypto.verify" => service_javascript_crypto_verify_sync_rpc(request),
"crypto.asymmetricOp" => service_javascript_crypto_asymmetric_op_sync_rpc(request),
"crypto.createKeyObject" => service_javascript_crypto_create_key_object_sync_rpc(request),
"crypto.generateKeyPairSync" => {
service_javascript_crypto_generate_key_pair_sync_rpc(request)
}
"crypto.generateKeySync" => service_javascript_crypto_generate_key_sync_rpc(request),
"crypto.generatePrimeSync" => service_javascript_crypto_generate_prime_sync_rpc(request),
"crypto.diffieHellman" => service_javascript_crypto_diffie_hellman_sync_rpc(request),
"crypto.diffieHellmanGroup" => {
service_javascript_crypto_diffie_hellman_group_sync_rpc(request)
}
"crypto.diffieHellmanSessionCreate" => {
service_javascript_crypto_diffie_hellman_session_create_sync_rpc(process, request)
}
"crypto.diffieHellmanSessionCall" => {
service_javascript_crypto_diffie_hellman_session_call_sync_rpc(process, request)
}
"crypto.diffieHellmanSessionDestroy" => {
service_javascript_crypto_diffie_hellman_session_destroy_sync_rpc(process, request)
}
"crypto.subtle" => service_javascript_crypto_subtle_sync_rpc(request),
_ => Err(SidecarError::InvalidState(format!(
"unsupported JavaScript crypto sync RPC method {}",
request.method
))),
}
}
fn javascript_crypto_digest_algorithm(
args: &[Value],
index: usize,
label: &str,
) -> Result<JavascriptCryptoDigestAlgorithm, SidecarError> {
JavascriptCryptoDigestAlgorithm::parse(javascript_sync_rpc_arg_str(args, index, label)?)
}
impl JavascriptCryptoDigestAlgorithm {
fn parse(value: &str) -> Result<Self, SidecarError> {
match value.trim().to_ascii_lowercase().replace('-', "").as_str() {
"md5" => Ok(Self::Md5),
"sha1" => Ok(Self::Sha1),
"sha256" => Ok(Self::Sha256),
"sha512" => Ok(Self::Sha512),
_ => Err(SidecarError::InvalidState(format!(
"unsupported crypto digest algorithm {value}"
))),
}
}
fn digest(self, data: &[u8]) -> Vec<u8> {
match self {
Self::Md5 => Md5::digest(data).to_vec(),
Self::Sha1 => Sha1::digest(data).to_vec(),
Self::Sha256 => Sha256::digest(data).to_vec(),
Self::Sha512 => Sha512::digest(data).to_vec(),
}
}
fn hmac(self, key: &[u8], data: &[u8]) -> Result<Vec<u8>, SidecarError> {
match self {
Self::Md5 => {
let mut mac = Hmac::<Md5>::new_from_slice(key).map_err(|error| {
SidecarError::InvalidState(format!("invalid HMAC key: {error}"))
})?;
mac.update(data);
Ok(mac.finalize().into_bytes().to_vec())
}
Self::Sha1 => {
let mut mac = Hmac::<Sha1>::new_from_slice(key).map_err(|error| {
SidecarError::InvalidState(format!("invalid HMAC key: {error}"))
})?;
mac.update(data);
Ok(mac.finalize().into_bytes().to_vec())
}
Self::Sha256 => {
let mut mac = Hmac::<Sha256>::new_from_slice(key).map_err(|error| {
SidecarError::InvalidState(format!("invalid HMAC key: {error}"))
})?;
mac.update(data);
Ok(mac.finalize().into_bytes().to_vec())
}
Self::Sha512 => {
let mut mac = Hmac::<Sha512>::new_from_slice(key).map_err(|error| {
SidecarError::InvalidState(format!("invalid HMAC key: {error}"))
})?;
mac.update(data);
Ok(mac.finalize().into_bytes().to_vec())
}
}
}
fn pbkdf2(self, password: &[u8], salt: &[u8], iterations: u32, output: &mut [u8]) {
match self {
Self::Md5 => pbkdf2_hmac::<Md5>(password, salt, iterations, output),
Self::Sha1 => pbkdf2_hmac::<Sha1>(password, salt, iterations, output),
Self::Sha256 => pbkdf2_hmac::<Sha256>(password, salt, iterations, output),
Self::Sha512 => pbkdf2_hmac::<Sha512>(password, salt, iterations, output),
}
}
}
#[derive(Debug, Clone)]
enum JavascriptCryptoKeyMaterial {
Private(PKey<Private>),
Public(PKey<Public>),
Secret(Vec<u8>),
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct JavascriptSerializedSandboxKeyObject {
#[serde(rename = "type")]
kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pem: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
raw: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "asymmetricKeyType")]
asymmetric_key_type: Option<String>,
#[serde(
skip_serializing_if = "Option::is_none",
rename = "asymmetricKeyDetails"
)]
asymmetric_key_details: Option<Map<String, Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
jwk: Option<Value>,
}
#[derive(Debug, Clone)]
struct JavascriptDirectKeyInput {
key: JavascriptCryptoKeyMaterial,
padding: Option<Padding>,
}
fn service_javascript_crypto_cipheriv_sync_rpc(
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
service_javascript_crypto_cipheriv_inner(request, false)
}
fn service_javascript_crypto_decipheriv_sync_rpc(
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
service_javascript_crypto_cipheriv_inner(request, true)
}
fn service_javascript_crypto_cipheriv_create_sync_rpc(
process: &mut ActiveProcess,
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
ensure_per_process_state_handle_capacity(process.cipher_sessions.len(), "cipher session")?;
let mode = javascript_sync_rpc_arg_str(&request.args, 0, "crypto.cipherivCreate mode")?;
let decrypt = mode == "decipher";
let algorithm =
javascript_sync_rpc_arg_str(&request.args, 1, "crypto.cipherivCreate algorithm")?;
let key = javascript_sync_rpc_base64_arg(&request.args, 2, "crypto.cipherivCreate key")?;
let iv = javascript_sync_rpc_base64_arg_optional(&request.args, 3, "crypto.cipherivCreate iv")?;
let options =
javascript_sync_rpc_json_arg_optional(&request.args, 4, "crypto.cipherivCreate options")?;
let auth_tag_len = javascript_crypto_requested_aead_tag_len(algorithm, options.as_ref())?;
let context = javascript_crypto_build_cipher_context(
algorithm,
&key,
iv.as_deref(),
decrypt,
options.as_ref(),
)?;
process.next_cipher_session_id += 1;
let session_id = process.next_cipher_session_id;
process.cipher_sessions.insert(
session_id,
ActiveCipherSession {
algorithm: algorithm.to_string(),
auth_tag_len,
context,
},
);
Ok(json!(session_id))
}
fn service_javascript_crypto_cipheriv_update_sync_rpc(
process: &mut ActiveProcess,
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
let session_id =
javascript_sync_rpc_arg_u64(&request.args, 0, "crypto.cipherivUpdate session id")?;
let data = javascript_sync_rpc_base64_arg(&request.args, 1, "crypto.cipherivUpdate data")?;
let session = process
.cipher_sessions
.get_mut(&session_id)
.ok_or_else(|| {
SidecarError::InvalidState(format!("Cipher session {session_id} not found"))
})?;
let result = javascript_crypto_cipher_update(&mut session.context, &data)?;
Ok(Value::String(
base64::engine::general_purpose::STANDARD.encode(result),
))
}
fn service_javascript_crypto_cipheriv_final_sync_rpc(
process: &mut ActiveProcess,
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
let session_id =
javascript_sync_rpc_arg_u64(&request.args, 0, "crypto.cipherivFinal session id")?;
let mut session = process.cipher_sessions.remove(&session_id).ok_or_else(|| {
SidecarError::InvalidState(format!("Cipher session {session_id} not found"))
})?;
let data = javascript_crypto_cipher_finalize(&mut session.context)?;
let mut response = Map::new();
response.insert(
String::from("data"),
Value::String(base64::engine::general_purpose::STANDARD.encode(data)),
);
if javascript_crypto_is_aead(&session.algorithm) {
let mut auth_tag = vec![0_u8; session.auth_tag_len];
session
.context
.get_tag(&mut auth_tag)
.map_err(javascript_crypto_openssl_error)?;
response.insert(
String::from("authTag"),
Value::String(base64::engine::general_purpose::STANDARD.encode(auth_tag)),
);
}
Ok(Value::String(serde_json::to_string(&response).map_err(
|error| SidecarError::InvalidState(format!("serialize cipher final response: {error}")),
)?))
}
fn service_javascript_crypto_sign_sync_rpc(
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
let algorithm = request.args.first().and_then(Value::as_str);
let data = javascript_sync_rpc_base64_arg(&request.args, 1, "crypto.sign data")?;
let key_json = javascript_sync_rpc_arg_str(&request.args, 2, "crypto.sign key")?;
let key_input =
javascript_crypto_parse_direct_key_input(key_json, Some("private"), "crypto.sign key")?;
let private_key = javascript_crypto_expect_private_key(key_input.key, "crypto.sign key")?;
let mut signer = javascript_crypto_new_signer(algorithm, &private_key)?;
if let Some(padding) = key_input.padding {
signer
.set_rsa_padding(padding)
.map_err(javascript_crypto_openssl_error)?;
}
signer
.update(&data)
.map_err(javascript_crypto_openssl_error)?;
Ok(Value::String(
base64::engine::general_purpose::STANDARD.encode(
signer
.sign_to_vec()
.map_err(javascript_crypto_openssl_error)?,
),
))
}
fn service_javascript_crypto_verify_sync_rpc(
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
let algorithm = request.args.first().and_then(Value::as_str);
let data = javascript_sync_rpc_base64_arg(&request.args, 1, "crypto.verify data")?;
let key_json = javascript_sync_rpc_arg_str(&request.args, 2, "crypto.verify key")?;
let signature = javascript_sync_rpc_base64_arg(&request.args, 3, "crypto.verify signature")?;
let key_input =
javascript_crypto_parse_direct_key_input(key_json, Some("public"), "crypto.verify key")?;
let public_key = javascript_crypto_expect_public_key(key_input.key, "crypto.verify key")?;
let mut verifier = javascript_crypto_new_verifier(algorithm, &public_key)?;
if let Some(padding) = key_input.padding {
verifier
.set_rsa_padding(padding)
.map_err(javascript_crypto_openssl_error)?;
}
verifier
.update(&data)
.map_err(javascript_crypto_openssl_error)?;
Ok(json!(verifier
.verify(&signature)
.map_err(javascript_crypto_openssl_error)?))
}
fn service_javascript_crypto_asymmetric_op_sync_rpc(
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
let operation = javascript_sync_rpc_arg_str(&request.args, 0, "crypto.asymmetricOp operation")?;
let key_json = javascript_sync_rpc_arg_str(&request.args, 1, "crypto.asymmetricOp key")?;
let data = javascript_sync_rpc_base64_arg(&request.args, 2, "crypto.asymmetricOp data")?;
let expect_kind = match operation {
"publicEncrypt" | "publicDecrypt" => Some("public"),
"privateEncrypt" | "privateDecrypt" => Some("private"),
other => {
return Err(SidecarError::InvalidState(format!(
"Unsupported asymmetric crypto operation: {other}"
)));
}
};
let key_input =
javascript_crypto_parse_direct_key_input(key_json, expect_kind, "crypto.asymmetricOp key")?;
let padding = key_input.padding.unwrap_or(Padding::PKCS1);
let mut output = vec![0_u8; javascript_crypto_rsa_output_size(&key_input.key)?];
let written = match (operation, key_input.key) {
("publicEncrypt", JavascriptCryptoKeyMaterial::Public(key))
| ("publicDecrypt", JavascriptCryptoKeyMaterial::Public(key)) => {
let rsa = key.rsa().map_err(javascript_crypto_openssl_error)?;
if operation == "publicEncrypt" {
rsa.public_encrypt(&data, &mut output, padding)
.map_err(javascript_crypto_openssl_error)?
} else {
rsa.public_decrypt(&data, &mut output, padding)
.map_err(javascript_crypto_openssl_error)?
}
}
("privateEncrypt", JavascriptCryptoKeyMaterial::Private(key))
| ("privateDecrypt", JavascriptCryptoKeyMaterial::Private(key)) => {
let rsa = key.rsa().map_err(javascript_crypto_openssl_error)?;
if operation == "privateEncrypt" {
rsa.private_encrypt(&data, &mut output, padding)
.map_err(javascript_crypto_openssl_error)?
} else {
rsa.private_decrypt(&data, &mut output, padding)
.map_err(javascript_crypto_openssl_error)?
}
}
_ => {
return Err(SidecarError::InvalidState(format!(
"{operation} requires an RSA {} key",
expect_kind.unwrap_or("asymmetric")
)));
}
};
output.truncate(written);
Ok(Value::String(
base64::engine::general_purpose::STANDARD.encode(output),
))
}
fn service_javascript_crypto_create_key_object_sync_rpc(
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
let operation =
javascript_sync_rpc_arg_str(&request.args, 0, "crypto.createKeyObject operation")?;
let key_json = javascript_sync_rpc_arg_str(&request.args, 1, "crypto.createKeyObject key")?;
let expected = match operation {
"createPrivateKey" => Some("private"),
"createPublicKey" => Some("public"),
other => {
return Err(SidecarError::InvalidState(format!(
"Unsupported key creation operation: {other}"
)));
}
};
let key_input =
javascript_crypto_parse_direct_key_input(key_json, expected, "crypto.createKeyObject key")?;
Ok(Value::String(
serde_json::to_string(&javascript_crypto_serialize_sandbox_key_object(
&key_input.key,
)?)
.map_err(|error| {
SidecarError::InvalidState(format!("serialize crypto key object: {error}"))
})?,
))
}
fn service_javascript_crypto_generate_key_pair_sync_rpc(
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
let key_type =
javascript_sync_rpc_arg_str(&request.args, 0, "crypto.generateKeyPairSync type")?;
let options = javascript_crypto_parse_serialized_options_arg(
&request.args,
1,
"crypto.generateKeyPairSync options",
)?
.unwrap_or(Value::Object(Map::new()));
let public_encoding = options.get("publicKeyEncoding").cloned();
let private_encoding = options.get("privateKeyEncoding").cloned();
let private_key = match key_type {
"rsa" => {
let bits = options
.get("modulusLength")
.and_then(Value::as_u64)
.unwrap_or(2048) as u32;
let exponent = options
.get("publicExponent")
.map(|value| javascript_crypto_u32_from_bridge_value(value, "rsa publicExponent"))
.transpose()?
.unwrap_or(65_537);
let exponent = BigNum::from_u32(exponent).map_err(javascript_crypto_openssl_error)?;
let rsa =
Rsa::generate_with_e(bits, &exponent).map_err(javascript_crypto_openssl_error)?;
PKey::from_rsa(rsa).map_err(javascript_crypto_openssl_error)?
}
"ec" => {
let named_curve = options
.get("namedCurve")
.and_then(Value::as_str)
.ok_or_else(|| {
SidecarError::InvalidState(String::from(
"crypto.generateKeyPairSync ec requires namedCurve",
))
})?;
let group = EcGroup::from_curve_name(javascript_crypto_curve_nid(named_curve)?)
.map_err(javascript_crypto_openssl_error)?;
let key = EcKey::generate(&group).map_err(javascript_crypto_openssl_error)?;
PKey::from_ec_key(key).map_err(javascript_crypto_openssl_error)?
}
"ed25519" => PKey::generate_ed25519().map_err(javascript_crypto_openssl_error)?,
"x25519" => PKey::generate_x25519().map_err(javascript_crypto_openssl_error)?,
other => {
return Err(SidecarError::InvalidState(format!(
"unsupported crypto key pair type {other}"
)));
}
};
let public_key = PKey::public_key_from_pem(
&private_key
.public_key_to_pem()
.map_err(javascript_crypto_openssl_error)?,
)
.map_err(javascript_crypto_openssl_error)?;
let response = if public_encoding.is_some() || private_encoding.is_some() {
json!({
"publicKey": javascript_crypto_serialize_encoded_key_value_public(&public_key, public_encoding.as_ref())?,
"privateKey": javascript_crypto_serialize_encoded_key_value_private(&private_key, private_encoding.as_ref())?,
})
} else {
json!({
"publicKey": javascript_crypto_serialize_sandbox_key_object(&JavascriptCryptoKeyMaterial::Public(public_key))?,
"privateKey": javascript_crypto_serialize_sandbox_key_object(&JavascriptCryptoKeyMaterial::Private(private_key))?,
})
};
Ok(Value::String(serde_json::to_string(&response).map_err(
|error| SidecarError::InvalidState(format!("serialize generated key pair: {error}")),
)?))
}
fn service_javascript_crypto_generate_key_sync_rpc(
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
let key_type = javascript_sync_rpc_arg_str(&request.args, 0, "crypto.generateKeySync type")?;
let options = javascript_crypto_parse_serialized_options_arg(
&request.args,
1,
"crypto.generateKeySync options",
)?
.unwrap_or(Value::Object(Map::new()));
let bit_length = options
.get("length")
.and_then(Value::as_u64)
.ok_or_else(|| {
SidecarError::InvalidState(String::from(
"crypto.generateKeySync options.length is required",
))
})? as usize;
let mut raw = vec![0_u8; bit_length.div_ceil(8)];
rand_bytes(&mut raw).map_err(javascript_crypto_openssl_error)?;
let serialized = match key_type {
"hmac" => javascript_crypto_serialize_sandbox_key_object(
&JavascriptCryptoKeyMaterial::Secret(raw),
)?,
"aes" => javascript_crypto_serialize_sandbox_key_object(
&JavascriptCryptoKeyMaterial::Secret(raw),
)?,
other => {
return Err(SidecarError::InvalidState(format!(
"unsupported crypto.generateKeySync type {other}"
)));
}
};
Ok(Value::String(serde_json::to_string(&serialized).map_err(
|error| SidecarError::InvalidState(format!("serialize generated key: {error}")),
)?))
}
fn service_javascript_crypto_generate_prime_sync_rpc(
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
let bits =
javascript_sync_rpc_arg_u64(&request.args, 0, "crypto.generatePrimeSync size")? as i32;
let options = javascript_crypto_parse_serialized_options_arg(
&request.args,
1,
"crypto.generatePrimeSync options",
)?
.unwrap_or(Value::Object(Map::new()));
let safe = options
.get("safe")
.and_then(Value::as_bool)
.unwrap_or(false);
let add = options
.get("add")
.map(|value| javascript_crypto_bignum_from_bridge_value(value, "prime add"))
.transpose()?;
let rem = options
.get("rem")
.map(|value| javascript_crypto_bignum_from_bridge_value(value, "prime rem"))
.transpose()?;
let mut prime = BigNum::new().map_err(javascript_crypto_openssl_error)?;
prime
.generate_prime(bits, safe, add.as_deref(), rem.as_deref())
.map_err(javascript_crypto_openssl_error)?;
let payload = if options
.get("bigint")
.and_then(Value::as_bool)
.unwrap_or(false)
{
json!({
"__type": "bigint",
"value": prime.to_dec_str().map_err(javascript_crypto_openssl_error)?.to_string(),
})
} else {
json!({
"__type": "buffer",
"value": base64::engine::general_purpose::STANDARD.encode(prime.to_vec()),
})
};
Ok(Value::String(serde_json::to_string(&payload).map_err(
|error| SidecarError::InvalidState(format!("serialize generated prime: {error}")),
)?))
}
fn service_javascript_crypto_diffie_hellman_sync_rpc(
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
let options = javascript_sync_rpc_arg_str(&request.args, 0, "crypto.diffieHellman options")?;
let parsed: Value = serde_json::from_str(options).map_err(|error| {
SidecarError::InvalidState(format!(
"crypto.diffieHellman options must be valid JSON: {error}"
))
})?;
let private_key = javascript_crypto_parse_key_material_value(
parsed.get("privateKey").ok_or_else(|| {
SidecarError::InvalidState(String::from("crypto.diffieHellman missing privateKey"))
})?,
Some("private"),
"crypto.diffieHellman privateKey",
)?;
let public_key = javascript_crypto_parse_key_material_value(
parsed.get("publicKey").ok_or_else(|| {
SidecarError::InvalidState(String::from("crypto.diffieHellman missing publicKey"))
})?,
Some("public"),
"crypto.diffieHellman publicKey",
)?;
let private_key =
javascript_crypto_expect_private_key(private_key, "crypto.diffieHellman privateKey")?;
let public_key =
javascript_crypto_expect_public_key(public_key, "crypto.diffieHellman publicKey")?;
let mut deriver = Deriver::new(&private_key).map_err(javascript_crypto_openssl_error)?;
deriver
.set_peer(&public_key)
.map_err(javascript_crypto_openssl_error)?;
let secret = deriver
.derive_to_vec()
.map_err(javascript_crypto_openssl_error)?;
Ok(Value::String(
serde_json::to_string(&json!({
"__type": "buffer",
"value": base64::engine::general_purpose::STANDARD.encode(secret),
}))
.map_err(|error| {
SidecarError::InvalidState(format!("serialize derived secret: {error}"))
})?,
))
}
fn service_javascript_crypto_diffie_hellman_group_sync_rpc(
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
let name = javascript_sync_rpc_arg_str(&request.args, 0, "crypto.diffieHellmanGroup name")?;
let params = javascript_crypto_named_dh_group(name)?;
let response = json!({
"prime": {
"__type": "buffer",
"value": base64::engine::general_purpose::STANDARD.encode(params.prime_p().to_vec()),
},
"generator": {
"__type": "buffer",
"value": base64::engine::general_purpose::STANDARD.encode(params.generator().to_vec()),
},
});
Ok(Value::String(serde_json::to_string(&response).map_err(
|error| {
SidecarError::InvalidState(format!("serialize diffieHellmanGroup response: {error}"))
},
)?))
}
fn service_javascript_crypto_diffie_hellman_session_create_sync_rpc(
process: &mut ActiveProcess,
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
ensure_per_process_state_handle_capacity(
process.diffie_hellman_sessions.len(),
"diffie-hellman session",
)?;
let raw = javascript_sync_rpc_arg_str(
&request.args,
0,
"crypto.diffieHellmanSessionCreate request",
)?;
let parsed: Value = serde_json::from_str(raw).map_err(|error| {
SidecarError::InvalidState(format!(
"crypto.diffieHellmanSessionCreate request must be valid JSON: {error}"
))
})?;
let session = match parsed.get("type").and_then(Value::as_str) {
Some("group") => {
let name = parsed.get("name").and_then(Value::as_str).ok_or_else(|| {
SidecarError::InvalidState(String::from(
"crypto.diffieHellmanSessionCreate group requires name",
))
})?;
ActiveDiffieHellmanSession::Dh(ActiveDhSession {
params: javascript_crypto_named_dh_group(name)?,
key_pair: None,
})
}
Some("dh") => {
let args = parsed
.get("args")
.and_then(Value::as_array)
.ok_or_else(|| {
SidecarError::InvalidState(String::from(
"crypto.diffieHellmanSessionCreate dh requires args",
))
})?;
let params = javascript_crypto_build_dh_params(args)?;
ActiveDiffieHellmanSession::Dh(ActiveDhSession {
params,
key_pair: None,
})
}
Some("ecdh") => {
let curve = parsed.get("name").and_then(Value::as_str).ok_or_else(|| {
SidecarError::InvalidState(String::from(
"crypto.diffieHellmanSessionCreate ecdh requires name",
))
})?;
ActiveDiffieHellmanSession::Ecdh(ActiveEcdhSession {
curve: curve.to_string(),
key_pair: None,
})
}
other => {
return Err(SidecarError::InvalidState(format!(
"Unsupported Diffie-Hellman session type: {}",
other.unwrap_or("<missing>")
)));
}
};
process.next_diffie_hellman_session_id += 1;
let session_id = process.next_diffie_hellman_session_id;
process.diffie_hellman_sessions.insert(session_id, session);
Ok(json!(session_id))
}
fn service_javascript_crypto_diffie_hellman_session_call_sync_rpc(
process: &mut ActiveProcess,
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
let session_id = javascript_sync_rpc_arg_u64(
&request.args,
0,
"crypto.diffieHellmanSessionCall session id",
)?;
let raw =
javascript_sync_rpc_arg_str(&request.args, 1, "crypto.diffieHellmanSessionCall request")?;
let parsed: Value = serde_json::from_str(raw).map_err(|error| {
SidecarError::InvalidState(format!(
"crypto.diffieHellmanSessionCall request must be valid JSON: {error}"
))
})?;
let method = parsed
.get("method")
.and_then(Value::as_str)
.ok_or_else(|| {
SidecarError::InvalidState(String::from(
"crypto.diffieHellmanSessionCall request missing method",
))
})?;
let args = parsed
.get("args")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let session = process
.diffie_hellman_sessions
.get_mut(&session_id)
.ok_or_else(|| {
SidecarError::InvalidState(format!("Diffie-Hellman session {session_id} not found"))
})?;
let (result, has_result) = match session {
ActiveDiffieHellmanSession::Dh(session) => {
javascript_crypto_call_dh_session(session, method, &args)?
}
ActiveDiffieHellmanSession::Ecdh(session) => {
javascript_crypto_call_ecdh_session(session, method, &args)?
}
};
Ok(Value::String(
serde_json::to_string(&json!({
"result": result,
"hasResult": has_result,
}))
.map_err(|error| {
SidecarError::InvalidState(format!("serialize diffie session result: {error}"))
})?,
))
}
fn service_javascript_crypto_diffie_hellman_session_destroy_sync_rpc(
process: &mut ActiveProcess,
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
let session_id = javascript_sync_rpc_arg_u64(
&request.args,
0,
"crypto.diffieHellmanSessionDestroy session id",
)?;
process
.diffie_hellman_sessions
.remove(&session_id)
.ok_or_else(|| {
SidecarError::InvalidState(format!("Diffie-Hellman session {session_id} not found"))
})?;
Ok(Value::Null)
}
fn service_javascript_crypto_subtle_sync_rpc(
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
let raw = javascript_sync_rpc_arg_str(&request.args, 0, "crypto.subtle request")?;
let parsed: Value = serde_json::from_str(raw).map_err(|error| {
SidecarError::InvalidState(format!("crypto.subtle request must be valid JSON: {error}"))
})?;
let op = parsed.get("op").and_then(Value::as_str).ok_or_else(|| {
SidecarError::InvalidState(String::from("crypto.subtle request missing op"))
})?;
match op {
"digest" => {
let algorithm = parsed
.get("algorithm")
.and_then(Value::as_str)
.ok_or_else(|| {
SidecarError::InvalidState(String::from(
"crypto.subtle.digest missing algorithm",
))
})?;
let data = parsed.get("data").and_then(Value::as_str).ok_or_else(|| {
SidecarError::InvalidState(String::from("crypto.subtle.digest missing data"))
})?;
let bytes = base64::engine::general_purpose::STANDARD
.decode(data)
.map_err(|error| {
SidecarError::InvalidState(format!("crypto.subtle.digest data base64: {error}"))
})?;
let digest = JavascriptCryptoDigestAlgorithm::parse(algorithm)?.digest(&bytes);
Ok(Value::String(
serde_json::to_string(&json!({
"data": base64::engine::general_purpose::STANDARD.encode(digest),
}))
.map_err(|error| {
SidecarError::InvalidState(format!("serialize crypto.subtle digest: {error}"))
})?,
))
}
"generateKey" => {
let algorithm = parsed.get("algorithm").ok_or_else(|| {
SidecarError::InvalidState(String::from(
"crypto.subtle.generateKey missing algorithm",
))
})?;
let name =
javascript_crypto_subtle_algorithm_name(algorithm, "crypto.subtle.generateKey")?;
if !matches!(name, "AES-GCM" | "AES-CBC" | "AES-CTR" | "AES-KW") {
return Err(SidecarError::InvalidState(format!(
"Unsupported key algorithm: {name}"
)));
}
let length_bits = algorithm
.get("length")
.and_then(Value::as_u64)
.ok_or_else(|| {
SidecarError::InvalidState(String::from(
"crypto.subtle.generateKey AES algorithm requires length",
))
})?;
if length_bits % 8 != 0 {
return Err(SidecarError::InvalidState(String::from(
"crypto.subtle.generateKey length must be byte-aligned",
)));
}
let length_bytes = usize::try_from(length_bits / 8).map_err(|_| {
SidecarError::InvalidState(String::from(
"crypto.subtle.generateKey length is too large",
))
})?;
let mut raw = vec![0_u8; length_bytes];
rand_bytes(&mut raw).map_err(javascript_crypto_openssl_error)?;
let key = javascript_crypto_serialize_subtle_secret_key(
&raw,
javascript_crypto_normalize_subtle_secret_algorithm(algorithm.clone(), &raw)?,
parsed
.get("extractable")
.and_then(Value::as_bool)
.unwrap_or(false),
parsed.get("usages").cloned().unwrap_or_else(|| json!([])),
)?;
Ok(Value::String(
serde_json::to_string(&json!({ "key": key })).map_err(|error| {
SidecarError::InvalidState(format!(
"serialize crypto.subtle generated key: {error}"
))
})?,
))
}
"importKey" => {
let format = parsed
.get("format")
.and_then(Value::as_str)
.ok_or_else(|| {
SidecarError::InvalidState(String::from(
"crypto.subtle.importKey missing format",
))
})?;
if format != "raw" {
return Err(SidecarError::InvalidState(format!(
"Unsupported import format: {format}"
)));
}
let key_data = parsed
.get("keyData")
.and_then(Value::as_str)
.ok_or_else(|| {
SidecarError::InvalidState(String::from(
"crypto.subtle.importKey missing keyData",
))
})?;
let raw = base64::engine::general_purpose::STANDARD
.decode(key_data)
.map_err(|error| {
SidecarError::InvalidState(format!(
"crypto.subtle.importKey keyData base64: {error}"
))
})?;
let algorithm = parsed.get("algorithm").ok_or_else(|| {
SidecarError::InvalidState(String::from(
"crypto.subtle.importKey missing algorithm",
))
})?;
let key = javascript_crypto_serialize_subtle_secret_key(
&raw,
javascript_crypto_normalize_subtle_secret_algorithm(algorithm.clone(), &raw)?,
parsed
.get("extractable")
.and_then(Value::as_bool)
.unwrap_or(false),
parsed.get("usages").cloned().unwrap_or_else(|| json!([])),
)?;
Ok(Value::String(
serde_json::to_string(&json!({ "key": key })).map_err(|error| {
SidecarError::InvalidState(format!(
"serialize crypto.subtle imported key: {error}"
))
})?,
))
}
"exportKey" => {
let format = parsed
.get("format")
.and_then(Value::as_str)
.ok_or_else(|| {
SidecarError::InvalidState(String::from(
"crypto.subtle.exportKey missing format",
))
})?;
if format != "raw" {
return Err(SidecarError::InvalidState(format!(
"Unsupported export format: {format}"
)));
}
let raw = javascript_crypto_subtle_key_raw(
parsed.get("key").ok_or_else(|| {
SidecarError::InvalidState(String::from("crypto.subtle.exportKey missing key"))
})?,
"crypto.subtle.exportKey key",
)?;
Ok(Value::String(
serde_json::to_string(&json!({
"data": base64::engine::general_purpose::STANDARD.encode(raw),
}))
.map_err(|error| {
SidecarError::InvalidState(format!("serialize crypto.subtle export: {error}"))
})?,
))
}
"encrypt" | "decrypt" => service_javascript_crypto_subtle_aes_crypt_sync_rpc(op, &parsed),
_ => Err(SidecarError::InvalidState(format!(
"Unsupported subtle operation: {op}"
))),
}
}
fn javascript_crypto_subtle_algorithm_name<'a>(
algorithm: &'a Value,
label: &str,
) -> Result<&'a str, SidecarError> {
if let Some(name) = algorithm.as_str() {
return Ok(name);
}
algorithm
.get("name")
.and_then(Value::as_str)
.ok_or_else(|| SidecarError::InvalidState(format!("{label} algorithm missing name")))
}
fn javascript_crypto_normalize_subtle_secret_algorithm(
algorithm: Value,
raw: &[u8],
) -> Result<Value, SidecarError> {
let mut object = match algorithm {
Value::String(name) => {
let mut object = Map::new();
object.insert(String::from("name"), Value::String(name));
object
}
Value::Object(object) => object,
_ => {
return Err(SidecarError::InvalidState(String::from(
"crypto.subtle secret algorithm must be a string or object",
)));
}
};
let name = object
.get("name")
.and_then(Value::as_str)
.ok_or_else(|| {
SidecarError::InvalidState(String::from("crypto.subtle secret algorithm missing name"))
})?
.to_string();
if matches!(name.as_str(), "AES-GCM" | "AES-CBC" | "AES-CTR" | "AES-KW")
&& !object.contains_key("length")
{
object.insert(String::from("length"), json!(raw.len() * 8));
}
Ok(Value::Object(object))
}
fn javascript_crypto_serialize_subtle_secret_key(
raw: &[u8],
algorithm: Value,
extractable: bool,
usages: Value,
) -> Result<Value, SidecarError> {
let raw_base64 = base64::engine::general_purpose::STANDARD.encode(raw);
let source_key_object_data = javascript_crypto_serialize_sandbox_key_object(
&JavascriptCryptoKeyMaterial::Secret(raw.to_vec()),
)?;
Ok(json!({
"type": "secret",
"algorithm": algorithm,
"extractable": extractable,
"usages": usages,
"_raw": raw_base64,
"_sourceKeyObjectData": source_key_object_data,
}))
}
fn javascript_crypto_subtle_key_raw(key: &Value, label: &str) -> Result<Vec<u8>, SidecarError> {
let raw = key.get("_raw").and_then(Value::as_str).ok_or_else(|| {
SidecarError::InvalidState(format!("{label} must be a raw secret CryptoKey"))
})?;
base64::engine::general_purpose::STANDARD
.decode(raw)
.map_err(|error| SidecarError::InvalidState(format!("{label} raw base64: {error}")))
}
fn service_javascript_crypto_subtle_aes_crypt_sync_rpc(
op: &str,
parsed: &Value,
) -> Result<Value, SidecarError> {
let algorithm = parsed.get("algorithm").ok_or_else(|| {
SidecarError::InvalidState(format!("crypto.subtle.{op} missing algorithm"))
})?;
let name = javascript_crypto_subtle_algorithm_name(algorithm, &format!("crypto.subtle.{op}"))?;
if name != "AES-GCM" {
return Err(SidecarError::InvalidState(format!(
"Unsupported subtle AES operation algorithm: {name}"
)));
}
let key = javascript_crypto_subtle_key_raw(
parsed
.get("key")
.ok_or_else(|| SidecarError::InvalidState(format!("crypto.subtle.{op} missing key")))?,
&format!("crypto.subtle.{op} key"),
)?;
let iv = algorithm.get("iv").and_then(Value::as_str).ok_or_else(|| {
SidecarError::InvalidState(format!("crypto.subtle.{op} AES-GCM missing iv"))
})?;
let iv = base64::engine::general_purpose::STANDARD
.decode(iv)
.map_err(|error| {
SidecarError::InvalidState(format!("crypto.subtle.{op} iv base64: {error}"))
})?;
let data = parsed
.get("data")
.and_then(Value::as_str)
.ok_or_else(|| SidecarError::InvalidState(format!("crypto.subtle.{op} missing data")))?;
let mut data = base64::engine::general_purpose::STANDARD
.decode(data)
.map_err(|error| {
SidecarError::InvalidState(format!("crypto.subtle.{op} data base64: {error}"))
})?;
let tag_len = javascript_crypto_subtle_aes_gcm_tag_len(algorithm)?;
let mut options = Map::new();
options.insert(String::from("authTagLength"), json!(tag_len));
if let Some(additional_data) = algorithm.get("additionalData").and_then(Value::as_str) {
options.insert(
String::from("aad"),
Value::String(additional_data.to_string()),
);
}
let decrypt = op == "decrypt";
if decrypt {
if data.len() < tag_len {
return Err(SidecarError::InvalidState(String::from(
"crypto.subtle.decrypt AES-GCM data shorter than auth tag",
)));
}
let auth_tag = data.split_off(data.len() - tag_len);
options.insert(
String::from("authTag"),
Value::String(base64::engine::general_purpose::STANDARD.encode(auth_tag)),
);
}
let cipher_name = format!("aes-{}-gcm", key.len() * 8);
let mut context = javascript_crypto_build_cipher_context(
&cipher_name,
&key,
Some(&iv),
decrypt,
Some(&Value::Object(options)),
)?;
let mut output = javascript_crypto_cipher_update(&mut context, &data)?;
output.extend(javascript_crypto_cipher_finalize(&mut context)?);
if !decrypt {
let mut auth_tag = vec![0_u8; tag_len];
context
.get_tag(&mut auth_tag)
.map_err(javascript_crypto_openssl_error)?;
output.extend(auth_tag);
}
Ok(Value::String(
serde_json::to_string(&json!({
"data": base64::engine::general_purpose::STANDARD.encode(output),
}))
.map_err(|error| {
SidecarError::InvalidState(format!("serialize crypto.subtle {op}: {error}"))
})?,
))
}
fn javascript_crypto_subtle_aes_gcm_tag_len(algorithm: &Value) -> Result<usize, SidecarError> {
let tag_bits = algorithm
.get("tagLength")
.and_then(Value::as_u64)
.unwrap_or(128);
if !tag_bits.is_multiple_of(8) {
return Err(SidecarError::InvalidState(String::from(
"crypto.subtle AES-GCM tagLength must be byte-aligned",
)));
}
usize::try_from(tag_bits / 8).map_err(|_| {
SidecarError::InvalidState(String::from("crypto.subtle AES-GCM tagLength too large"))
})
}
fn service_javascript_crypto_cipheriv_inner(
request: &JavascriptSyncRpcRequest,
decrypt: bool,
) -> Result<Value, SidecarError> {
let label = if decrypt {
"crypto.decipheriv"
} else {
"crypto.cipheriv"
};
let algorithm = javascript_sync_rpc_arg_str(&request.args, 0, &format!("{label} algorithm"))?;
let key = javascript_sync_rpc_base64_arg(&request.args, 1, &format!("{label} key"))?;
let iv = javascript_sync_rpc_base64_arg_optional(&request.args, 2, &format!("{label} iv"))?;
let data = javascript_sync_rpc_base64_arg(&request.args, 3, &format!("{label} data"))?;
let options =
javascript_sync_rpc_json_arg_optional(&request.args, 4, &format!("{label} options"))?;
let auth_tag_len = javascript_crypto_requested_aead_tag_len(algorithm, options.as_ref())?;
let mut context = javascript_crypto_build_cipher_context(
algorithm,
&key,
iv.as_deref(),
decrypt,
options.as_ref(),
)?;
let payload = javascript_crypto_cipher_update(&mut context, &data)?;
let final_bytes = javascript_crypto_cipher_finalize(&mut context)?;
if decrypt {
let mut output = payload;
output.extend(final_bytes);
return Ok(Value::String(
base64::engine::general_purpose::STANDARD.encode(output),
));
}
let mut response = Map::new();
let mut encrypted = payload;
encrypted.extend(final_bytes);
response.insert(
String::from("data"),
Value::String(base64::engine::general_purpose::STANDARD.encode(encrypted)),
);
if javascript_crypto_is_aead(algorithm) {
let mut auth_tag = vec![0_u8; auth_tag_len];
context
.get_tag(&mut auth_tag)
.map_err(javascript_crypto_openssl_error)?;
response.insert(
String::from("authTag"),
Value::String(base64::engine::general_purpose::STANDARD.encode(auth_tag)),
);
}
Ok(Value::String(serde_json::to_string(&response).map_err(
|error| SidecarError::InvalidState(format!("serialize {label} response: {error}")),
)?))
}
fn javascript_sync_rpc_base64_arg_optional(
args: &[Value],
index: usize,
label: &str,
) -> Result<Option<Vec<u8>>, SidecarError> {
if args.get(index).is_none() || args[index].is_null() {
return Ok(None);
}
javascript_sync_rpc_base64_arg(args, index, label).map(Some)
}
fn javascript_sync_rpc_json_arg_optional(
args: &[Value],
index: usize,
label: &str,
) -> Result<Option<Value>, SidecarError> {
if args.get(index).is_none() || args[index].is_null() {
return Ok(None);
}
let raw = javascript_sync_rpc_arg_str(args, index, label)?;
serde_json::from_str(raw)
.map(Some)
.map_err(|error| SidecarError::InvalidState(format!("{label} must be valid JSON: {error}")))
}
fn javascript_crypto_parse_direct_key_input(
raw: &str,
expected: Option<&str>,
label: &str,
) -> Result<JavascriptDirectKeyInput, SidecarError> {
let parsed: Value = serde_json::from_str(raw).map_err(|error| {
SidecarError::InvalidState(format!("{label} must be valid JSON: {error}"))
})?;
let padding = match parsed.as_object().and_then(|value| value.get("padding")) {
Some(value) => javascript_crypto_padding_from_value(value)?,
None => None,
};
Ok(JavascriptDirectKeyInput {
key: javascript_crypto_parse_key_material_value(&parsed, expected, label)?,
padding,
})
}
fn javascript_crypto_parse_key_material_value(
value: &Value,
expected: Option<&str>,
label: &str,
) -> Result<JavascriptCryptoKeyMaterial, SidecarError> {
if let Some(object) = value.as_object() {
if object.get("__type").and_then(Value::as_str) == Some("keyObject") {
let serialized = object.get("value").ok_or_else(|| {
SidecarError::InvalidState(format!("{label} keyObject is missing a value"))
})?;
return javascript_crypto_parse_serialized_key_object(serialized, expected, label);
}
if object.contains_key("type") && (object.contains_key("pem") || object.contains_key("raw"))
{
return javascript_crypto_parse_serialized_key_object(value, expected, label);
}
if let Some(source) = object.get("key") {
return javascript_crypto_parse_key_source(
source,
object.get("format").and_then(Value::as_str),
object.get("type").and_then(Value::as_str),
expected,
label,
);
}
}
javascript_crypto_parse_key_source(value, None, None, expected, label)
}
fn javascript_crypto_parse_key_source(
source: &Value,
format: Option<&str>,
kind: Option<&str>,
expected: Option<&str>,
label: &str,
) -> Result<JavascriptCryptoKeyMaterial, SidecarError> {
match source {
Value::String(pem) => javascript_crypto_parse_key_from_pem(pem.as_bytes(), expected, label),
Value::Object(object) if object.get("__type").and_then(Value::as_str) == Some("buffer") => {
let data = javascript_crypto_decode_bridge_buffer(source, label)?;
javascript_crypto_parse_key_from_bytes(&data, format, kind, expected, label)
}
Value::Object(_) => {
if format == Some("jwk") {
return Err(SidecarError::InvalidState(format!(
"{label} jwk inputs are not supported yet"
)));
}
Err(SidecarError::InvalidState(format!(
"{label} has an unsupported key shape"
)))
}
_ => Err(SidecarError::InvalidState(format!(
"{label} has an unsupported key value"
))),
}
}
fn javascript_crypto_parse_key_from_pem(
pem: &[u8],
expected: Option<&str>,
label: &str,
) -> Result<JavascriptCryptoKeyMaterial, SidecarError> {
match expected {
Some("private") => PKey::private_key_from_pem(pem)
.map(JavascriptCryptoKeyMaterial::Private)
.map_err(|error| {
SidecarError::InvalidState(format!("{label} private key is invalid: {error}"))
}),
Some("public") => PKey::public_key_from_pem(pem)
.map(JavascriptCryptoKeyMaterial::Public)
.map_err(|error| {
SidecarError::InvalidState(format!("{label} public key is invalid: {error}"))
}),
_ => PKey::private_key_from_pem(pem)
.map(JavascriptCryptoKeyMaterial::Private)
.or_else(|_| PKey::public_key_from_pem(pem).map(JavascriptCryptoKeyMaterial::Public))
.map_err(|error| {
SidecarError::InvalidState(format!("{label} PEM key is invalid: {error}"))
}),
}
}
fn javascript_crypto_parse_key_from_bytes(
der: &[u8],
format: Option<&str>,
kind: Option<&str>,
expected: Option<&str>,
label: &str,
) -> Result<JavascriptCryptoKeyMaterial, SidecarError> {
match (format.unwrap_or("der"), kind.or(expected)) {
("der", Some("pkcs8")) | ("der", Some("private")) => PKey::private_key_from_der(der)
.map(JavascriptCryptoKeyMaterial::Private)
.map_err(|error| {
SidecarError::InvalidState(format!("{label} private key DER is invalid: {error}"))
}),
("der", Some("spki")) | ("der", Some("public")) => PKey::public_key_from_der(der)
.map(JavascriptCryptoKeyMaterial::Public)
.map_err(|error| {
SidecarError::InvalidState(format!("{label} public key DER is invalid: {error}"))
}),
_ => Err(SidecarError::InvalidState(format!(
"{label} unsupported key bytes format"
))),
}
}
fn javascript_crypto_parse_serialized_key_object(
value: &Value,
expected: Option<&str>,
label: &str,
) -> Result<JavascriptCryptoKeyMaterial, SidecarError> {
let serialized: JavascriptSerializedSandboxKeyObject = serde_json::from_value(value.clone())
.map_err(|error| {
SidecarError::InvalidState(format!("{label} keyObject is invalid: {error}"))
})?;
match serialized.kind.as_str() {
"secret" => {
if expected == Some("public") || expected == Some("private") {
return Err(SidecarError::InvalidState(format!(
"{label} expected an asymmetric key"
)));
}
Ok(JavascriptCryptoKeyMaterial::Secret(
base64::engine::general_purpose::STANDARD
.decode(serialized.raw.unwrap_or_default())
.map_err(|error| {
SidecarError::InvalidState(format!(
"{label} secret key contains invalid base64: {error}"
))
})?,
))
}
"private" => {
let pem = serialized.pem.ok_or_else(|| {
SidecarError::InvalidState(format!("{label} private keyObject is missing pem"))
})?;
javascript_crypto_parse_key_from_pem(pem.as_bytes(), Some("private"), label)
}
"public" => {
let pem = serialized.pem.ok_or_else(|| {
SidecarError::InvalidState(format!("{label} public keyObject is missing pem"))
})?;
javascript_crypto_parse_key_from_pem(pem.as_bytes(), Some("public"), label)
}
other => Err(SidecarError::InvalidState(format!(
"{label} has unsupported keyObject type {other}"
))),
}
}
fn javascript_crypto_expect_private_key(
key: JavascriptCryptoKeyMaterial,
label: &str,
) -> Result<PKey<Private>, SidecarError> {
match key {
JavascriptCryptoKeyMaterial::Private(key) => Ok(key),
_ => Err(SidecarError::InvalidState(format!(
"{label} requires a private key"
))),
}
}
fn javascript_crypto_expect_public_key(
key: JavascriptCryptoKeyMaterial,
label: &str,
) -> Result<PKey<Public>, SidecarError> {
match key {
JavascriptCryptoKeyMaterial::Public(key) => Ok(key),
JavascriptCryptoKeyMaterial::Private(key) => {
let pem = key
.public_key_to_pem()
.map_err(javascript_crypto_openssl_error)?;
PKey::public_key_from_pem(&pem).map_err(javascript_crypto_openssl_error)
}
_ => Err(SidecarError::InvalidState(format!(
"{label} requires a public key"
))),
}
}
fn javascript_crypto_new_signer<'a>(
algorithm: Option<&'a str>,
key: &'a PKey<Private>,
) -> Result<Signer<'a>, SidecarError> {
if matches!(key.id(), PKeyId::ED25519 | PKeyId::ED448) || algorithm.is_none() {
return Signer::new_without_digest(key).map_err(javascript_crypto_openssl_error);
}
Signer::new(
javascript_crypto_message_digest_from_name(algorithm.ok_or_else(|| {
SidecarError::InvalidState(String::from("crypto.sign requires a digest algorithm"))
})?)?,
key,
)
.map_err(javascript_crypto_openssl_error)
}
fn javascript_crypto_new_verifier<'a>(
algorithm: Option<&'a str>,
key: &'a PKey<Public>,
) -> Result<Verifier<'a>, SidecarError> {
if matches!(key.id(), PKeyId::ED25519 | PKeyId::ED448) || algorithm.is_none() {
return Verifier::new_without_digest(key).map_err(javascript_crypto_openssl_error);
}
Verifier::new(
javascript_crypto_message_digest_from_name(algorithm.ok_or_else(|| {
SidecarError::InvalidState(String::from("crypto.verify requires a digest algorithm"))
})?)?,
key,
)
.map_err(javascript_crypto_openssl_error)
}
fn javascript_crypto_message_digest_from_name(name: &str) -> Result<MessageDigest, SidecarError> {
match name.trim().to_ascii_lowercase().replace('-', "").as_str() {
"md5" => Ok(MessageDigest::md5()),
"sha1" => Ok(MessageDigest::sha1()),
"sha256" => Ok(MessageDigest::sha256()),
"sha384" => Ok(MessageDigest::sha384()),
"sha512" => Ok(MessageDigest::sha512()),
other => Err(SidecarError::InvalidState(format!(
"unsupported crypto digest algorithm {other}"
))),
}
}
fn javascript_crypto_padding_from_value(value: &Value) -> Result<Option<Padding>, SidecarError> {
let Some(number) = value.as_i64() else {
return Ok(None);
};
let padding = match number {
1 => Padding::PKCS1,
3 => Padding::NONE,
4 => Padding::PKCS1_OAEP,
6 => Padding::PKCS1_PSS,
other => {
return Err(SidecarError::InvalidState(format!(
"unsupported RSA padding constant {other}"
)));
}
};
Ok(Some(padding))
}
fn javascript_crypto_decode_bridge_buffer(
value: &Value,
label: &str,
) -> Result<Vec<u8>, SidecarError> {
let base64_value = value
.as_object()
.filter(|object| object.get("__type").and_then(Value::as_str) == Some("buffer"))
.and_then(|object| object.get("value"))
.and_then(Value::as_str)
.ok_or_else(|| {
SidecarError::InvalidState(format!("{label} must be a serialized bridge buffer"))
})?;
base64::engine::general_purpose::STANDARD
.decode(base64_value)
.map_err(|error| {
SidecarError::InvalidState(format!("{label} contains invalid base64: {error}"))
})
}
fn javascript_crypto_serialize_sandbox_key_object(
key: &JavascriptCryptoKeyMaterial,
) -> Result<Value, SidecarError> {
let serialized = match key {
JavascriptCryptoKeyMaterial::Private(key) => JavascriptSerializedSandboxKeyObject {
kind: String::from("private"),
pem: Some(
String::from_utf8(
key.private_key_to_pem_pkcs8()
.map_err(javascript_crypto_openssl_error)?,
)
.map_err(|error| {
SidecarError::InvalidState(format!("private key PEM is not utf8: {error}"))
})?,
),
raw: None,
asymmetric_key_type: javascript_crypto_pkey_type_name(key.id()),
asymmetric_key_details: None,
jwk: None,
},
JavascriptCryptoKeyMaterial::Public(key) => JavascriptSerializedSandboxKeyObject {
kind: String::from("public"),
pem: Some(
String::from_utf8(
key.public_key_to_pem()
.map_err(javascript_crypto_openssl_error)?,
)
.map_err(|error| {
SidecarError::InvalidState(format!("public key PEM is not utf8: {error}"))
})?,
),
raw: None,
asymmetric_key_type: javascript_crypto_pkey_type_name(key.id()),
asymmetric_key_details: None,
jwk: None,
},
JavascriptCryptoKeyMaterial::Secret(raw) => JavascriptSerializedSandboxKeyObject {
kind: String::from("secret"),
pem: None,
raw: Some(base64::engine::general_purpose::STANDARD.encode(raw)),
asymmetric_key_type: None,
asymmetric_key_details: None,
jwk: None,
},
};
serde_json::to_value(serialized)
.map_err(|error| SidecarError::InvalidState(format!("serialize key object: {error}")))
}
fn javascript_crypto_pkey_type_name(id: PKeyId) -> Option<String> {
match id {
PKeyId::RSA => Some(String::from("rsa")),
PKeyId::EC => Some(String::from("ec")),
PKeyId::ED25519 => Some(String::from("ed25519")),
PKeyId::ED448 => Some(String::from("ed448")),
PKeyId::X25519 => Some(String::from("x25519")),
PKeyId::X448 => Some(String::from("x448")),
PKeyId::DH => Some(String::from("dh")),
_ => None,
}
}
fn javascript_crypto_rsa_output_size(
key: &JavascriptCryptoKeyMaterial,
) -> Result<usize, SidecarError> {
match key {
JavascriptCryptoKeyMaterial::Private(key) => key
.rsa()
.map(|rsa| rsa.size() as usize)
.map_err(javascript_crypto_openssl_error),
JavascriptCryptoKeyMaterial::Public(key) => key
.rsa()
.map(|rsa| rsa.size() as usize)
.map_err(javascript_crypto_openssl_error),
JavascriptCryptoKeyMaterial::Secret(_) => Err(SidecarError::InvalidState(String::from(
"RSA operations require an asymmetric key",
))),
}
}
fn javascript_crypto_parse_serialized_options_arg(
args: &[Value],
index: usize,
label: &str,
) -> Result<Option<Value>, SidecarError> {
let Some(raw) = args.get(index).and_then(Value::as_str) else {
return Ok(None);
};
let parsed: Value = serde_json::from_str(raw).map_err(|error| {
SidecarError::InvalidState(format!("{label} must be valid JSON: {error}"))
})?;
if parsed.get("hasOptions").and_then(Value::as_bool) == Some(true) {
Ok(parsed.get("options").cloned())
} else {
Ok(None)
}
}
fn javascript_crypto_u32_from_bridge_value(
value: &Value,
label: &str,
) -> Result<u32, SidecarError> {
if let Some(number) = value.as_u64() {
return u32::try_from(number)
.map_err(|_| SidecarError::InvalidState(format!("{label} must fit within u32")));
}
let bytes = javascript_crypto_decode_bridge_buffer(value, label)?;
if bytes.len() > 4 {
return Err(SidecarError::InvalidState(format!(
"{label} buffer is too large for u32"
)));
}
Ok(bytes
.into_iter()
.fold(0_u32, |acc, byte| (acc << 8) | u32::from(byte)))
}
fn javascript_crypto_bignum_from_bridge_value(
value: &Value,
label: &str,
) -> Result<BigNum, SidecarError> {
if let Some(object) = value.as_object() {
if object.get("__type").and_then(Value::as_str) == Some("bigint") {
let decimal = object.get("value").and_then(Value::as_str).ok_or_else(|| {
SidecarError::InvalidState(format!("{label} bigint is missing a value"))
})?;
return BigNum::from_dec_str(decimal).map_err(javascript_crypto_openssl_error);
}
}
let bytes = javascript_crypto_decode_bridge_buffer(value, label)?;
BigNum::from_slice(&bytes).map_err(javascript_crypto_openssl_error)
}
fn javascript_crypto_curve_nid(name: &str) -> Result<Nid, SidecarError> {
match name {
"prime256v1" | "P-256" => Ok(Nid::X9_62_PRIME256V1),
"secp384r1" | "P-384" => Ok(Nid::SECP384R1),
"secp521r1" | "P-521" => Ok(Nid::SECP521R1),
"secp256k1" => Ok(Nid::SECP256K1),
other => Err(SidecarError::InvalidState(format!(
"unsupported EC curve {other}"
))),
}
}
fn javascript_crypto_named_dh_group(name: &str) -> Result<Dh<Params>, SidecarError> {
match name {
"modp2" => Dh::get_1024_160().map_err(javascript_crypto_openssl_error),
"modp14" | "modp15" | "modp16" | "modp17" | "modp18" => {
Dh::get_2048_256().map_err(javascript_crypto_openssl_error)
}
other => Err(SidecarError::InvalidState(format!(
"unsupported Diffie-Hellman group {other}"
))),
}
}
fn javascript_crypto_clone_dh_params(params: &Dh<Params>) -> Result<Dh<Params>, SidecarError> {
Dh::from_pqg(
params
.prime_p()
.to_owned()
.map_err(javascript_crypto_openssl_error)?,
params
.prime_q()
.map(|value| value.to_owned().map_err(javascript_crypto_openssl_error))
.transpose()?,
params
.generator()
.to_owned()
.map_err(javascript_crypto_openssl_error)?,
)
.map_err(javascript_crypto_openssl_error)
}
fn javascript_crypto_build_dh_params(args: &[Value]) -> Result<Dh<Params>, SidecarError> {
let Some(first) = args.first() else {
return Err(SidecarError::InvalidState(String::from(
"Diffie-Hellman session args are required",
)));
};
if let Some(bits) = first.as_u64() {
let generator = args
.get(1)
.map(|value| javascript_crypto_u32_from_bridge_value(value, "Diffie-Hellman generator"))
.transpose()?
.unwrap_or(2);
return Dh::generate_params(bits as u32, generator)
.map_err(javascript_crypto_openssl_error);
}
let prime = javascript_crypto_bignum_from_bridge_value(first, "Diffie-Hellman prime")?;
let generator = args
.get(1)
.map(|value| javascript_crypto_bignum_from_bridge_value(value, "Diffie-Hellman generator"))
.transpose()?
.unwrap_or(BigNum::from_u32(2).map_err(javascript_crypto_openssl_error)?);
Dh::from_pqg(prime, None, generator).map_err(javascript_crypto_openssl_error)
}
fn javascript_crypto_call_dh_session(
session: &mut ActiveDhSession,
method: &str,
args: &[Value],
) -> Result<(Value, bool), SidecarError> {
match method {
"verifyError" => Ok((Value::Null, false)),
"generateKeys" => {
if session.key_pair.is_none() {
session.key_pair = Some(
javascript_crypto_clone_dh_params(&session.params)?
.generate_key()
.map_err(javascript_crypto_openssl_error)?,
);
}
let public = session
.key_pair
.as_ref()
.expect("dh key pair")
.public_key()
.to_vec();
Ok((javascript_crypto_bridge_buffer_value(&public), true))
}
"computeSecret" => {
if session.key_pair.is_none() {
session.key_pair = Some(
javascript_crypto_clone_dh_params(&session.params)?
.generate_key()
.map_err(javascript_crypto_openssl_error)?,
);
}
let peer = javascript_crypto_bignum_from_bridge_value(
args.first().ok_or_else(|| {
SidecarError::InvalidState(String::from(
"computeSecret requires peer public key",
))
})?,
"Diffie-Hellman peer public key",
)?;
let secret = session
.key_pair
.as_ref()
.expect("dh key pair")
.compute_key(&peer)
.map_err(javascript_crypto_openssl_error)?;
Ok((javascript_crypto_bridge_buffer_value(&secret), true))
}
"getPrime" => Ok((
javascript_crypto_bridge_buffer_value(&session.params.prime_p().to_vec()),
true,
)),
"getGenerator" => Ok((
javascript_crypto_bridge_buffer_value(&session.params.generator().to_vec()),
true,
)),
"getPublicKey" => {
if session.key_pair.is_none() {
session.key_pair = Some(
javascript_crypto_clone_dh_params(&session.params)?
.generate_key()
.map_err(javascript_crypto_openssl_error)?,
);
}
Ok((
javascript_crypto_bridge_buffer_value(
&session
.key_pair
.as_ref()
.expect("dh key pair")
.public_key()
.to_vec(),
),
true,
))
}
"getPrivateKey" => {
if session.key_pair.is_none() {
session.key_pair = Some(
javascript_crypto_clone_dh_params(&session.params)?
.generate_key()
.map_err(javascript_crypto_openssl_error)?,
);
}
Ok((
javascript_crypto_bridge_buffer_value(
&session
.key_pair
.as_ref()
.expect("dh key pair")
.private_key()
.to_vec(),
),
true,
))
}
other => Err(SidecarError::InvalidState(format!(
"Unsupported Diffie-Hellman method: {other}"
))),
}
}
fn javascript_crypto_call_ecdh_session(
session: &mut ActiveEcdhSession,
method: &str,
args: &[Value],
) -> Result<(Value, bool), SidecarError> {
let nid = javascript_crypto_curve_nid(&session.curve)?;
let group = EcGroup::from_curve_name(nid).map_err(javascript_crypto_openssl_error)?;
match method {
"verifyError" => Ok((Value::Null, false)),
"generateKeys" => {
if session.key_pair.is_none() {
session.key_pair =
Some(EcKey::generate(&group).map_err(javascript_crypto_openssl_error)?);
}
let mut ctx = BigNumContext::new().map_err(javascript_crypto_openssl_error)?;
let bytes = session
.key_pair
.as_ref()
.expect("ecdh key pair")
.public_key()
.to_bytes(&group, PointConversionForm::UNCOMPRESSED, &mut ctx)
.map_err(javascript_crypto_openssl_error)?;
Ok((javascript_crypto_bridge_buffer_value(&bytes), true))
}
"computeSecret" => {
if session.key_pair.is_none() {
session.key_pair =
Some(EcKey::generate(&group).map_err(javascript_crypto_openssl_error)?);
}
let peer_bytes = javascript_crypto_decode_bridge_buffer(
args.first().ok_or_else(|| {
SidecarError::InvalidState(String::from(
"computeSecret requires peer public key",
))
})?,
"ECDH peer public key",
)?;
let mut ctx = BigNumContext::new().map_err(javascript_crypto_openssl_error)?;
let peer_point = EcPoint::from_bytes(&group, &peer_bytes, &mut ctx)
.map_err(javascript_crypto_openssl_error)?;
let peer_key = EcKey::from_public_key(&group, &peer_point)
.map_err(javascript_crypto_openssl_error)?;
let private =
PKey::from_ec_key(session.key_pair.as_ref().expect("ecdh key pair").to_owned())
.map_err(javascript_crypto_openssl_error)?;
let peer = PKey::from_ec_key(peer_key).map_err(javascript_crypto_openssl_error)?;
let mut deriver = Deriver::new(&private).map_err(javascript_crypto_openssl_error)?;
deriver
.set_peer(&peer)
.map_err(javascript_crypto_openssl_error)?;
let secret = deriver
.derive_to_vec()
.map_err(javascript_crypto_openssl_error)?;
Ok((javascript_crypto_bridge_buffer_value(&secret), true))
}
"getPublicKey" => {
if session.key_pair.is_none() {
session.key_pair =
Some(EcKey::generate(&group).map_err(javascript_crypto_openssl_error)?);
}
let mut ctx = BigNumContext::new().map_err(javascript_crypto_openssl_error)?;
let bytes = session
.key_pair
.as_ref()
.expect("ecdh key pair")
.public_key()
.to_bytes(&group, PointConversionForm::UNCOMPRESSED, &mut ctx)
.map_err(javascript_crypto_openssl_error)?;
Ok((javascript_crypto_bridge_buffer_value(&bytes), true))
}
"getPrivateKey" => {
if session.key_pair.is_none() {
session.key_pair =
Some(EcKey::generate(&group).map_err(javascript_crypto_openssl_error)?);
}
Ok((
javascript_crypto_bridge_buffer_value(
&session
.key_pair
.as_ref()
.expect("ecdh key pair")
.private_key()
.to_vec(),
),
true,
))
}
other => Err(SidecarError::InvalidState(format!(
"Unsupported Diffie-Hellman method: {other}"
))),
}
}
fn javascript_crypto_serialize_encoded_key_value_public(
key: &PKey<Public>,
encoding: Option<&Value>,
) -> Result<Value, SidecarError> {
if let Some(encoding) = encoding {
let format = encoding
.get("format")
.and_then(Value::as_str)
.unwrap_or("pem");
return Ok(match format {
"der" => json!({
"kind": "buffer",
"value": base64::engine::general_purpose::STANDARD
.encode(key.public_key_to_der().map_err(javascript_crypto_openssl_error)?),
}),
_ => json!({
"kind": "string",
"value": String::from_utf8(
key.public_key_to_pem().map_err(javascript_crypto_openssl_error)?,
)
.map_err(|error| SidecarError::InvalidState(format!("public key PEM utf8: {error}")))?,
}),
});
}
javascript_crypto_serialize_sandbox_key_object(&JavascriptCryptoKeyMaterial::Public(
key.to_owned(),
))
}
fn javascript_crypto_serialize_encoded_key_value_private(
key: &PKey<Private>,
encoding: Option<&Value>,
) -> Result<Value, SidecarError> {
if let Some(encoding) = encoding {
let format = encoding
.get("format")
.and_then(Value::as_str)
.unwrap_or("pem");
return Ok(match format {
"der" => json!({
"kind": "buffer",
"value": base64::engine::general_purpose::STANDARD
.encode(key.private_key_to_der().map_err(javascript_crypto_openssl_error)?),
}),
_ => json!({
"kind": "string",
"value": String::from_utf8(
key.private_key_to_pem_pkcs8().map_err(javascript_crypto_openssl_error)?,
)
.map_err(|error| SidecarError::InvalidState(format!("private key PEM utf8: {error}")))?,
}),
});
}
javascript_crypto_serialize_sandbox_key_object(&JavascriptCryptoKeyMaterial::Private(
key.to_owned(),
))
}
fn javascript_crypto_bridge_buffer_value(bytes: &[u8]) -> Value {
json!({
"__type": "buffer",
"value": base64::engine::general_purpose::STANDARD.encode(bytes),
})
}
fn javascript_crypto_build_cipher_context(
algorithm: &str,
key: &[u8],
iv: Option<&[u8]>,
decrypt: bool,
options: Option<&Value>,
) -> Result<Crypter, SidecarError> {
let cipher = javascript_crypto_cipher_from_name(algorithm)?;
let mode = if decrypt {
Mode::Decrypt
} else {
Mode::Encrypt
};
let mut context =
Crypter::new(cipher, mode, key, iv).map_err(javascript_crypto_openssl_error)?;
if let Some(auto_padding) = options
.and_then(|value| value.get("autoPadding"))
.and_then(Value::as_bool)
{
context.pad(auto_padding);
}
if javascript_crypto_is_aead(algorithm) {
if let Some(aad) = options
.and_then(|value| value.get("aad"))
.and_then(Value::as_str)
{
context
.aad_update(
&base64::engine::general_purpose::STANDARD
.decode(aad)
.map_err(|error| {
SidecarError::InvalidState(format!(
"cipher aad contains invalid base64: {error}"
))
})?,
)
.map_err(javascript_crypto_openssl_error)?;
}
if decrypt {
if let Some(auth_tag) = options
.and_then(|value| value.get("authTag"))
.and_then(Value::as_str)
{
let decoded = base64::engine::general_purpose::STANDARD
.decode(auth_tag)
.map_err(|error| {
SidecarError::InvalidState(format!(
"cipher authTag contains invalid base64: {error}"
))
})?;
context
.set_tag(&decoded)
.map_err(javascript_crypto_openssl_error)?;
}
}
}
Ok(context)
}
fn javascript_crypto_requested_aead_tag_len(
algorithm: &str,
options: Option<&Value>,
) -> Result<usize, SidecarError> {
if !javascript_crypto_is_aead(algorithm) {
return Ok(0);
}
let requested = options
.and_then(|value| value.get("authTagLength"))
.and_then(Value::as_u64)
.unwrap_or(javascript_crypto_aead_tag_len(algorithm) as u64);
usize::try_from(requested).map_err(|_| {
SidecarError::InvalidState(String::from("cipher authTagLength must fit within usize"))
})
}
fn javascript_crypto_cipher_update(
context: &mut Crypter,
data: &[u8],
) -> Result<Vec<u8>, SidecarError> {
let mut output = vec![0_u8; data.len() + 32];
let written = context
.update(data, &mut output)
.map_err(javascript_crypto_openssl_error)?;
output.truncate(written);
Ok(output)
}
fn javascript_crypto_cipher_finalize(context: &mut Crypter) -> Result<Vec<u8>, SidecarError> {
let mut output = vec![0_u8; 32];
let written = context
.finalize(&mut output)
.map_err(javascript_crypto_openssl_error)?;
output.truncate(written);
Ok(output)
}
fn javascript_crypto_cipher_from_name(name: &str) -> Result<Cipher, SidecarError> {
match name.to_ascii_lowercase().as_str() {
"aes-128-cbc" => Ok(Cipher::aes_128_cbc()),
"aes-192-cbc" => Ok(Cipher::aes_192_cbc()),
"aes-256-cbc" => Ok(Cipher::aes_256_cbc()),
"aes-128-ctr" => Ok(Cipher::aes_128_ctr()),
"aes-192-ctr" => Ok(Cipher::aes_192_ctr()),
"aes-256-ctr" => Ok(Cipher::aes_256_ctr()),
"aes-128-gcm" => Ok(Cipher::aes_128_gcm()),
"aes-192-gcm" => Ok(Cipher::aes_192_gcm()),
"aes-256-gcm" => Ok(Cipher::aes_256_gcm()),
other => Err(SidecarError::InvalidState(format!(
"unsupported crypto cipher algorithm {other}"
))),
}
}
fn javascript_crypto_is_aead(algorithm: &str) -> bool {
algorithm.to_ascii_lowercase().ends_with("-gcm")
}
fn javascript_crypto_aead_tag_len(_algorithm: &str) -> usize {
16
}
fn javascript_crypto_openssl_error(error: openssl::error::ErrorStack) -> SidecarError {
SidecarError::Execution(format!("crypto operation failed: {error}"))
}
fn service_javascript_kernel_stdin_sync_rpc(
kernel: &mut SidecarKernel,
process: &mut ActiveProcess,
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
let max_bytes =
javascript_sync_rpc_arg_u64_optional(&request.args, 0, "__kernel_stdin_read max bytes")?
.map(|value| value.clamp(1, DEFAULT_KERNEL_STDIN_READ_MAX_BYTES as u64) as usize)
.unwrap_or(DEFAULT_KERNEL_STDIN_READ_MAX_BYTES);
let timeout_ms =
javascript_sync_rpc_arg_u64_optional(&request.args, 1, "__kernel_stdin_read timeout ms")?
.unwrap_or(DEFAULT_KERNEL_STDIN_READ_TIMEOUT_MS);
match kernel
.fd_read_with_timeout_result(
EXECUTION_DRIVER_NAME,
process.kernel_pid,
0,
max_bytes,
Some(Duration::from_millis(timeout_ms)),
)
.map_err(kernel_error)
{
Ok(Some(chunk)) if !chunk.is_empty() => Ok(json!({
"dataBase64": base64::engine::general_purpose::STANDARD.encode(chunk),
})),
Ok(Some(_)) => Ok(Value::Null),
Ok(None) => Ok(json!({
"done": true,
})),
Err(SidecarError::Kernel(error)) if error.starts_with("EAGAIN:") => Ok(Value::Null),
Err(error) => Err(error),
}
}
fn service_javascript_pty_set_raw_mode_sync_rpc(
kernel: &mut SidecarKernel,
process: &mut ActiveProcess,
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
let enabled = javascript_sync_rpc_arg_bool(&request.args, 0, "__pty_set_raw_mode enabled")?;
kernel
.pty_set_discipline(
EXECUTION_DRIVER_NAME,
process.kernel_pid,
0,
LineDisciplineConfig {
canonical: Some(!enabled),
echo: Some(!enabled),
isig: Some(!enabled),
},
)
.map_err(kernel_error)?;
Ok(Value::Null)
}
fn service_javascript_kernel_stdio_write_sync_rpc(
kernel: &mut SidecarKernel,
process: &mut ActiveProcess,
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
let fd = javascript_sync_rpc_arg_u32(&request.args, 0, "__kernel_stdio_write fd")?;
let chunk = javascript_sync_rpc_bytes_arg(&request.args, 1, "__kernel_stdio_write chunk")?;
let written = match fd {
1 => kernel
.write_process_stdout(EXECUTION_DRIVER_NAME, process.kernel_pid, &chunk)
.map_err(kernel_error)?,
2 => kernel
.write_process_stderr(EXECUTION_DRIVER_NAME, process.kernel_pid, &chunk)
.map_err(kernel_error)?,
other => {
return Err(SidecarError::InvalidState(format!(
"__kernel_stdio_write only supports fd 1/2, got {other}"
)));
}
};
let event = if fd == 1 {
ActiveExecutionEvent::Stdout(chunk)
} else {
ActiveExecutionEvent::Stderr(chunk)
};
process.queue_pending_execution_event(event)?;
Ok(json!(written))
}
fn service_javascript_kernel_poll_sync_rpc(
kernel: &mut SidecarKernel,
process: &ActiveProcess,
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError> {
let fd_requests: Vec<KernelPollFdRequest> = serde_json::from_value(
request
.args
.first()
.cloned()
.unwrap_or_else(|| Value::Array(Vec::new())),
)
.map_err(|error| {
SidecarError::InvalidState(format!(
"__kernel_poll fd list must be a JSON array of {{ fd, events }} objects: {error}"
))
})?;
let timeout_ms =
javascript_sync_rpc_arg_u64_optional(&request.args, 1, "__kernel_poll timeout ms")?
.unwrap_or_default();
let timeout_ms = i32::try_from(timeout_ms).map_err(|_| {
SidecarError::InvalidState(String::from("__kernel_poll timeout ms must fit within i32"))
})?;
let poll_fds = fd_requests
.iter()
.map(|entry| PollFd {
fd: entry.fd,
events: PollEvents::from_bits(entry.events),
revents: PollEvents::empty(),
})
.collect::<Vec<_>>();
let result = kernel
.poll_fds(
EXECUTION_DRIVER_NAME,
process.kernel_pid,
poll_fds,
timeout_ms,
)
.map_err(kernel_error)?;
Ok(json!({
"readyCount": result.ready_count,
"fds": result
.fds
.into_iter()
.map(|entry| KernelPollFdResponse {
fd: entry.fd,
events: entry.events.bits(),
revents: entry.revents.bits(),
})
.collect::<Vec<_>>(),
}))
}
fn install_kernel_stdin_pipe(kernel: &mut SidecarKernel, pid: u32) -> Result<u32, SidecarError> {
let (read_fd, write_fd) = kernel
.open_pipe(EXECUTION_DRIVER_NAME, pid)
.map_err(kernel_error)?;
kernel
.fd_dup2(EXECUTION_DRIVER_NAME, pid, read_fd, 0)
.map_err(kernel_error)?;
kernel
.fd_close(EXECUTION_DRIVER_NAME, pid, read_fd)
.map_err(kernel_error)?;
Ok(write_fd)
}
fn javascript_child_process_stdin_mode(request: &JavascriptChildProcessSpawnRequest) -> &str {
request
.options
.stdio
.first()
.map(String::as_str)
.unwrap_or("pipe")
}
pub(crate) fn write_kernel_process_stdin(
kernel: &mut SidecarKernel,
process: &mut ActiveProcess,
chunk: &[u8],
) -> Result<(), SidecarError> {
if process.runtime == GuestRuntimeKind::JavaScript {
return Ok(());
}
let Some(writer_fd) = process.kernel_stdin_writer_fd else {
return Ok(());
};
kernel
.fd_write(EXECUTION_DRIVER_NAME, process.kernel_pid, writer_fd, chunk)
.map(|_| ())
.map_err(kernel_error)
}
pub(crate) fn close_kernel_process_stdin(
kernel: &mut SidecarKernel,
process: &mut ActiveProcess,
) -> Result<(), SidecarError> {
let Some(writer_fd) = process.kernel_stdin_writer_fd.take() else {
return Ok(());
};
kernel
.fd_close(EXECUTION_DRIVER_NAME, process.kernel_pid, writer_fd)
.map_err(kernel_error)
}
fn parse_http_header_collection(
headers: &BTreeMap<String, Value>,
label: &str,
) -> Result<HttpHeaderCollection, SidecarError> {
let mut normalized = BTreeMap::<String, Vec<String>>::new();
let mut raw_pairs = Vec::new();
for (raw_name, value) in headers {
let normalized_name = raw_name.to_ascii_lowercase();
let values = match value {
Value::String(text) => vec![text.clone()],
Value::Array(values) => values
.iter()
.map(|entry| {
entry.as_str().map(str::to_owned).ok_or_else(|| {
SidecarError::InvalidState(format!(
"{label} header {raw_name} must contain only strings"
))
})
})
.collect::<Result<Vec<_>, _>>()?,
other => {
return Err(SidecarError::InvalidState(format!(
"{label} header {raw_name} must be a string or string array, received {other}"
)));
}
};
raw_pairs.extend(
values
.iter()
.cloned()
.map(|entry| (raw_name.clone(), entry)),
);
normalized
.entry(normalized_name)
.or_default()
.extend(values);
}
Ok(HttpHeaderCollection {
normalized,
raw_pairs,
})
}
fn http_headers_json(headers: &HttpHeaderCollection) -> Value {
let map = headers
.normalized
.iter()
.map(|(name, values)| {
let value = if values.len() == 1 {
Value::String(values[0].clone())
} else {
Value::Array(values.iter().cloned().map(Value::String).collect())
};
(name.clone(), value)
})
.collect::<Map<String, Value>>();
Value::Object(map)
}
fn http_raw_headers_json(headers: &HttpHeaderCollection) -> Value {
Value::Array(
headers
.raw_pairs
.iter()
.flat_map(|(name, value)| [Value::String(name.clone()), Value::String(value.clone())])
.collect(),
)
}
fn is_loopback_request_host(host: &str) -> bool {
let bare = host
.strip_prefix('[')
.and_then(|value| value.strip_suffix(']'))
.unwrap_or(host);
matches!(bare, "localhost" | "127.0.0.1" | "::1")
}
fn serialize_http_loopback_request(
url: &Url,
options: &JavascriptHttpRequestOptions,
headers: &HttpHeaderCollection,
) -> Result<String, SidecarError> {
let body_base64 = options
.body
.as_ref()
.map(|body| base64::engine::general_purpose::STANDARD.encode(body.as_bytes()));
serde_json::to_string(&json!({
"method": options.method.clone().unwrap_or_else(|| String::from("GET")),
"url": http_request_target(url),
"headers": http_headers_json(headers),
"rawHeaders": http_raw_headers_json(headers),
"bodyBase64": body_base64,
}))
.map_err(|error| SidecarError::Execution(format!("ERR_AGENTOS_NODE_SYNC_RPC: {error}")))
}
fn http_request_target(url: &Url) -> String {
let path = if url.path().is_empty() {
"/"
} else {
url.path()
};
format!(
"{path}{}",
url.query()
.map(|query| format!("?{query}"))
.unwrap_or_default()
)
}
fn find_kernel_http_listener_process(vm: &VmState, port: u16) -> Option<String> {
vm.active_processes
.iter()
.find_map(|(process_id, process)| {
process.tcp_listeners.values().find_map(|listener| {
let socket_id = listener.kernel_socket_id?;
let record = vm.kernel.socket_get(socket_id)?;
let local_addr = record
.local_address()
.and_then(|address| resolve_tcp_bind_addr(address.host(), address.port()).ok())
.unwrap_or_else(|| listener.guest_local_addr());
if local_addr.port() == port && is_vm_local_http_listener_addr(local_addr.ip()) {
Some(process_id.to_owned())
} else {
None
}
})
})
}
fn is_vm_local_http_listener_addr(ip: IpAddr) -> bool {
ip.is_loopback() || ip.is_unspecified()
}
fn serialize_kernel_http_fetch_request(
port: u16,
path: &str,
options: &JavascriptHttpRequestOptions,
headers: &HttpHeaderCollection,
) -> Vec<u8> {
let method = options.method.as_deref().unwrap_or("GET");
let mut lines = vec![format!("{method} {path} HTTP/1.1")];
let mut has_host = false;
let mut has_connection = false;
let mut has_content_length = false;
for (name, values) in &headers.normalized {
match name.as_str() {
"host" => has_host = true,
"connection" => has_connection = true,
"content-length" => has_content_length = true,
_ => {}
}
lines.push(format!("{name}: {}", values.join(", ")));
}
if !has_host {
lines.push(format!("Host: 127.0.0.1:{port}"));
}
if !has_connection {
lines.push(String::from("Connection: close"));
}
let body = options.body.as_deref().unwrap_or("").as_bytes();
if !has_content_length && !body.is_empty() {
lines.push(format!("Content-Length: {}", body.len()));
}
lines.push(String::new());
lines.push(String::new());
let mut request = lines.join("\r\n").into_bytes();
request.extend_from_slice(body);
request
}
fn parse_kernel_http_fetch_response(
buffer: &[u8],
peer_closed: bool,
url: &str,
) -> Result<Option<String>, SidecarError> {
let Some(header_end) = find_http_header_end(buffer) else {
return Ok(None);
};
let header_bytes = &buffer[..header_end];
let head = String::from_utf8_lossy(header_bytes);
let mut lines = head.split("\r\n");
let status_line = lines.next().unwrap_or_default();
let mut status_parts = status_line.splitn(3, ' ');
let version = status_parts.next().unwrap_or_default();
if !version.starts_with("HTTP/") {
return Err(SidecarError::Execution(format!(
"invalid vm.fetch HTTP response status line: {status_line}"
)));
}
let status = status_parts
.next()
.ok_or_else(|| {
SidecarError::Execution(format!(
"invalid vm.fetch HTTP response status line: {status_line}"
))
})?
.parse::<u16>()
.map_err(|error| {
SidecarError::Execution(format!(
"invalid vm.fetch HTTP response status code in {status_line:?}: {error}"
))
})?;
let status_text = status_parts.next().unwrap_or_default();
let mut headers = Vec::new();
let mut raw_headers = Vec::new();
let mut content_length = None;
let mut transfer_encoding_values = Vec::new();
for line in lines {
if line.is_empty() {
continue;
}
let Some((name, value)) = line.split_once(':') else {
return Err(SidecarError::Execution(format!(
"invalid vm.fetch HTTP response header line: {line}"
)));
};
let value = value.trim().to_owned();
let normalized = name.to_ascii_lowercase();
if normalized == "content-length" {
content_length = Some(value.parse::<usize>().map_err(|error| {
SidecarError::Execution(format!(
"invalid vm.fetch Content-Length header {value:?}: {error}"
))
})?);
} else if normalized == "transfer-encoding" {
transfer_encoding_values.push(value.clone());
}
headers.push(json!([normalized, value.clone()]));
raw_headers.push(Value::String(name.to_owned()));
raw_headers.push(Value::String(value));
}
let body_start = header_end + 4;
let transfer_encoding = transfer_encoding_tokens(&transfer_encoding_values);
let is_chunked = transfer_encoding.iter().any(|token| token == "chunked");
let body = if is_chunked {
if content_length.is_some() {
return Err(SidecarError::Execution(String::from(
"vm.fetch HTTP response cannot include both Transfer-Encoding: chunked and Content-Length",
)));
}
if transfer_encoding.len() != 1 {
return Err(SidecarError::Execution(format!(
"unsupported vm.fetch Transfer-Encoding: {}",
transfer_encoding.join(", ")
)));
}
let Some(decoded) = decode_kernel_http_chunked_body(&buffer[body_start..])? else {
return Ok(None);
};
decoded
} else if !transfer_encoding.is_empty() {
return Err(SidecarError::Execution(format!(
"unsupported vm.fetch Transfer-Encoding: {}",
transfer_encoding.join(", ")
)));
} else if let Some(content_length) = content_length {
let body_end = body_start.saturating_add(content_length);
if buffer.len() < body_end {
return Ok(None);
}
buffer[body_start..body_end].to_vec()
} else if peer_closed {
buffer[body_start..].to_vec()
} else {
return Ok(None);
};
serde_json::to_string(&json!({
"status": status,
"statusText": status_text,
"headers": headers,
"rawHeaders": raw_headers,
"body": base64::engine::general_purpose::STANDARD.encode(&body),
"bodyEncoding": "base64",
"url": url,
}))
.map(Some)
.map_err(|error| SidecarError::Execution(format!("ERR_AGENTOS_NODE_SYNC_RPC: {error}")))
}
fn find_http_header_end(buffer: &[u8]) -> Option<usize> {
buffer.windows(4).position(|window| window == b"\r\n\r\n")
}
fn find_crlf(buffer: &[u8], start: usize) -> Option<usize> {
buffer
.get(start..)?
.windows(2)
.position(|window| window == b"\r\n")
.map(|offset| start + offset)
}
fn transfer_encoding_tokens(values: &[String]) -> Vec<String> {
values
.iter()
.flat_map(|value| value.split(','))
.map(|token| token.trim().to_ascii_lowercase())
.filter(|token| !token.is_empty())
.collect()
}
fn decode_kernel_http_chunked_body(buffer: &[u8]) -> Result<Option<Vec<u8>>, SidecarError> {
let mut offset = 0;
let mut body = Vec::new();
loop {
let Some(line_end) = find_crlf(buffer, offset) else {
return Ok(None);
};
let size_line = std::str::from_utf8(&buffer[offset..line_end]).map_err(|error| {
SidecarError::Execution(format!(
"invalid vm.fetch chunk size line encoding: {error}"
))
})?;
let size_part = size_line.split(';').next().unwrap_or_default();
if size_part.is_empty() || !size_part.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(SidecarError::Execution(format!(
"invalid vm.fetch chunk size line: {size_line:?}"
)));
}
let chunk_size = usize::from_str_radix(size_part, 16).map_err(|error| {
SidecarError::Execution(format!(
"invalid vm.fetch chunk size {size_part:?}: {error}"
))
})?;
let chunk_start = line_end + 2;
let chunk_end = chunk_start
.checked_add(chunk_size)
.ok_or_else(|| SidecarError::Execution(String::from("vm.fetch chunk size overflow")))?;
if chunk_size > 0 {
let chunk_terminator_end = chunk_end.checked_add(2).ok_or_else(|| {
SidecarError::Execution(String::from("vm.fetch chunk terminator overflow"))
})?;
if chunk_terminator_end > buffer.len() {
return Ok(None);
}
if buffer.get(chunk_end..chunk_terminator_end) != Some(b"\r\n") {
return Err(SidecarError::Execution(String::from(
"invalid vm.fetch chunk terminator",
)));
}
body.extend_from_slice(&buffer[chunk_start..chunk_end]);
offset = chunk_terminator_end;
continue;
}
if buffer.get(chunk_start..chunk_start + 2) == Some(b"\r\n") {
return Ok(Some(body));
}
let Some(trailer_end) = find_http_header_end(&buffer[chunk_start..]) else {
return Ok(None);
};
let trailer_bytes = &buffer[chunk_start..chunk_start + trailer_end];
let trailers = String::from_utf8_lossy(trailer_bytes);
for line in trailers.split("\r\n") {
if line.is_empty() {
continue;
}
if line.starts_with(' ') || line.starts_with('\t') || !line.contains(':') {
return Err(SidecarError::Execution(format!(
"invalid vm.fetch chunk trailer line: {line}"
)));
}
}
return Ok(Some(body));
}
}
fn kernel_http_fetch_target_exit_code(error: &SidecarError) -> Option<i32> {
let SidecarError::Execution(message) = error else {
return None;
};
message
.strip_prefix("vm.fetch target exited before responding (exit code ")?
.strip_suffix(')')?
.parse()
.ok()
}
fn service_host_fetch_target_event<B>(
bridge: &SharedBridge<B>,
vm_id: &str,
dns: &VmDnsConfig,
socket_paths: &JavascriptSocketPathContext,
kernel: &mut SidecarKernel,
process: &mut ActiveProcess,
resource_limits: &ResourceLimits,
wait: Duration,
) -> Result<bool, SidecarError>
where
B: NativeSidecarBridge + Send + 'static,
BridgeError<B>: fmt::Debug + Send + Sync + 'static,
{
let Some(event) = process
.execution
.poll_event_blocking(wait)
.map_err(|error| SidecarError::Execution(error.to_string()))?
else {
return Ok(false);
};
match event {
ActiveExecutionEvent::JavascriptSyncRpcRequest(request) => {
let network_counts = process.network_resource_counts();
let response = service_javascript_sync_rpc(JavascriptSyncRpcServiceRequest {
bridge,
vm_id,
dns,
socket_paths,
kernel,
process,
sync_request: &request,
resource_limits,
network_counts,
});
match response {
Ok(result) => process
.execution
.respond_javascript_sync_rpc_success(request.id, result)
.or_else(ignore_stale_javascript_sync_rpc_response)?,
Err(error) => process
.execution
.respond_javascript_sync_rpc_error(
request.id,
javascript_sync_rpc_error_code(&error),
error.to_string(),
)
.or_else(ignore_stale_javascript_sync_rpc_response)?,
}
}
ActiveExecutionEvent::Exited(code) => {
return Err(SidecarError::Execution(format!(
"vm.fetch target exited before responding (exit code {code})"
)));
}
other => {
process.queue_pending_execution_event(other)?;
}
}
Ok(true)
}
fn drain_host_fetch_target_events<B>(
bridge: &SharedBridge<B>,
vm_id: &str,
vm: &mut VmState,
target_process_id: &str,
socket_paths: &JavascriptSocketPathContext,
resource_limits: &ResourceLimits,
) -> Result<(), SidecarError>
where
B: NativeSidecarBridge + Send + 'static,
BridgeError<B>: fmt::Debug + Send + Sync + 'static,
{
for _ in 0..32 {
let dns = vm.dns.clone();
let Some(process) = vm.active_processes.get_mut(target_process_id) else {
break;
};
let serviced = service_host_fetch_target_event(
bridge,
vm_id,
&dns,
socket_paths,
&mut vm.kernel,
process,
resource_limits,
Duration::from_millis(1),
)?;
if !serviced {
break;
}
}
Ok(())
}
fn dispatch_kernel_http_fetch<B>(
bridge: &SharedBridge<B>,
vm_id: &str,
vm: &mut VmState,
target_process_id: &str,
port: u16,
path: &str,
options: &JavascriptHttpRequestOptions,
headers: &HttpHeaderCollection,
max_fetch_response_bytes: usize,
) -> Result<String, SidecarError>
where
B: NativeSidecarBridge + Send + 'static,
BridgeError<B>: fmt::Debug + Send + Sync + 'static,
{
let socket_paths = build_javascript_socket_path_context(vm)?;
let family = JavascriptSocketFamily::Ipv4;
let local_port = allocate_guest_listen_port(
0,
family,
&socket_paths.used_tcp_guest_ports,
socket_paths.listen_policy,
)?;
let resource_limits = vm.kernel.resource_limits().clone();
let network_counts = vm_network_resource_counts(vm);
check_network_resource_limit(
resource_limits.max_sockets,
network_counts.sockets,
2,
"socket",
)?;
check_network_resource_limit(
resource_limits.max_connections,
network_counts.connections,
2,
"connection",
)?;
let kernel_pid = vm
.active_processes
.get(target_process_id)
.ok_or_else(|| {
SidecarError::InvalidState(format!(
"vm.fetch target process disappeared: {target_process_id}"
))
})?
.kernel_pid;
let socket_id = vm
.kernel
.socket_create(EXECUTION_DRIVER_NAME, kernel_pid, SocketSpec::tcp())
.map_err(kernel_error)?;
let result = dispatch_kernel_http_fetch_with_socket(
bridge,
vm_id,
vm,
target_process_id,
kernel_pid,
socket_id,
local_port,
port,
path,
options,
headers,
&socket_paths,
&resource_limits,
max_fetch_response_bytes,
);
let close_result = vm
.kernel
.socket_close(EXECUTION_DRIVER_NAME, kernel_pid, socket_id)
.map_err(kernel_error);
let cleanup_result = if result.is_err() {
drain_host_fetch_target_events(
bridge,
vm_id,
vm,
target_process_id,
&socket_paths,
&resource_limits,
)
} else {
Ok(())
};
match (result, close_result) {
(Ok(response), Ok(())) => cleanup_result.map(|()| response),
(Err(error), _) => Err(error),
(Ok(_), Err(error)) => Err(error),
}
}
#[allow(clippy::too_many_arguments)]
fn dispatch_kernel_http_fetch_with_socket<B>(
bridge: &SharedBridge<B>,
vm_id: &str,
vm: &mut VmState,
target_process_id: &str,
kernel_pid: u32,
socket_id: SocketId,
local_port: u16,
port: u16,
path: &str,
options: &JavascriptHttpRequestOptions,
headers: &HttpHeaderCollection,
socket_paths: &JavascriptSocketPathContext,
resource_limits: &ResourceLimits,
max_fetch_response_bytes: usize,
) -> Result<String, SidecarError>
where
B: NativeSidecarBridge + Send + 'static,
BridgeError<B>: fmt::Debug + Send + Sync + 'static,
{
vm.kernel
.socket_bind_inet(
EXECUTION_DRIVER_NAME,
kernel_pid,
socket_id,
InetSocketAddress::new("127.0.0.1", local_port),
)
.map_err(kernel_error)?;
vm.kernel
.socket_connect_inet_loopback(
EXECUTION_DRIVER_NAME,
kernel_pid,
socket_id,
InetSocketAddress::new("127.0.0.1", port),
)
.map_err(kernel_error)?;
let request_bytes = serialize_kernel_http_fetch_request(port, path, options, headers);
vm.kernel
.socket_write(EXECUTION_DRIVER_NAME, kernel_pid, socket_id, &request_bytes)
.map_err(kernel_error)?;
let mut response_buffer = Vec::new();
let mut peer_closed = false;
let url = format!("http://127.0.0.1:{port}{path}");
let deadline = Instant::now() + http_loopback_request_timeout();
loop {
if let Some(response) =
parse_kernel_http_fetch_response(&response_buffer, peer_closed, &url)?
{
ensure_vm_fetch_response_within_limit(&response, "vm.fetch", max_fetch_response_bytes)?;
return Ok(response);
}
if Instant::now() >= deadline {
let preview = String::from_utf8_lossy(&response_buffer);
return Err(SidecarError::Execution(format!(
"vm.fetch timed out waiting for kernel TCP HTTP response ({} buffered bytes: {:?})",
response_buffer.len(),
preview.chars().take(200).collect::<String>()
)));
}
{
let dns = vm.dns.clone();
let process = vm
.active_processes
.get_mut(target_process_id)
.ok_or_else(|| {
SidecarError::InvalidState(format!(
"vm.fetch target process disappeared: {target_process_id}"
))
})?;
service_host_fetch_target_event(
bridge,
vm_id,
&dns,
socket_paths,
&mut vm.kernel,
process,
resource_limits,
Duration::from_millis(5),
)?;
}
let poll = vm
.kernel
.poll_targets(
EXECUTION_DRIVER_NAME,
kernel_pid,
vec![PollTargetEntry::socket(
socket_id,
POLLIN | POLLHUP | POLLERR,
)],
5,
)
.map_err(kernel_error)?;
let revents = poll
.targets
.first()
.map(|entry| entry.revents)
.unwrap_or_else(PollEvents::empty);
if revents.intersects(POLLERR) {
return Err(SidecarError::Execution(String::from(
"vm.fetch kernel TCP socket reported POLLERR",
)));
}
if revents.intersects(POLLIN) {
match vm
.kernel
.socket_read(EXECUTION_DRIVER_NAME, kernel_pid, socket_id, 64 * 1024)
{
Ok(Some(bytes)) if !bytes.is_empty() => {
response_buffer.extend(bytes);
ensure_vm_fetch_raw_response_buffer_within_limit(
response_buffer.len(),
"vm.fetch",
)?;
}
Ok(Some(_)) => {}
Ok(None) => peer_closed = true,
Err(error) if error.code() == "EAGAIN" => {}
Err(error) => return Err(kernel_error(error)),
}
}
if revents.intersects(POLLHUP) {
peer_closed = true;
}
}
}
fn outbound_http_response_json(url: &Url, response: ureq::Response) -> Result<Value, SidecarError> {
let status = response.status();
let status_text = response.status_text().to_owned();
let mut header_pairs = Vec::new();
let mut raw_headers = Vec::new();
for raw_name in response.headers_names() {
for value in response.all(&raw_name) {
header_pairs.push(json!([raw_name.to_ascii_lowercase(), value]));
raw_headers.push(Value::String(raw_name.clone()));
raw_headers.push(Value::String(value.to_owned()));
}
}
let mut reader = response.into_reader();
let mut body = Vec::new();
reader.read_to_end(&mut body).map_err(|error| {
SidecarError::Execution(format!("failed to read HTTP response: {error}"))
})?;
serde_json::to_string(&json!({
"status": status,
"statusText": status_text,
"headers": header_pairs,
"rawHeaders": raw_headers,
"body": base64::engine::general_purpose::STANDARD.encode(body),
"bodyEncoding": "base64",
"url": url.as_str(),
}))
.map(Value::String)
.map_err(|error| SidecarError::Execution(format!("ERR_AGENTOS_NODE_SYNC_RPC: {error}")))
}
/// Split a ureq resolver `netloc` (`host:port`, with optional `[..]` IPv6
/// brackets) into its host and port components. Returns `None` if the port is
/// missing or unparseable.
fn split_netloc(netloc: &str) -> Option<(&str, u16)> {
let (host, port) = netloc.rsplit_once(':')?;
let port: u16 = port.parse().ok()?;
let host = host
.strip_prefix('[')
.and_then(|rest| rest.strip_suffix(']'))
.unwrap_or(host);
Some((host, port))
}
fn issue_outbound_http_request(
url: &Url,
options: &JavascriptHttpRequestOptions,
headers: &HttpHeaderCollection,
pinned_addresses: &[IpAddr],
) -> Result<Value, SidecarError> {
let method = options.method.as_deref().unwrap_or("GET");
// Pin the underlying resolver to the egress-vetted addresses. ureq performs
// its own DNS resolution for the TCP/TLS connect; without this override an
// https:// request would re-resolve the hostname through the host resolver
// (a rebinding DNS server could then return a private/metadata IP that the
// earlier range check would have rejected). The pinned resolver returns only
// the vetted addresses and refuses any host it was not vetted for, while the
// request URL keeps the original hostname so TLS SNI and the Host header stay
// correct.
let pinned_host = url.host_str().map(str::to_owned);
let pinned: Vec<IpAddr> = pinned_addresses.to_vec();
let resolver = move |netloc: &str| -> std::io::Result<Vec<SocketAddr>> {
let (host, port) = split_netloc(netloc).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("invalid network location: {netloc}"),
)
})?;
let expected_host = pinned_host.as_deref();
if expected_host != Some(host) {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"EACCES: outbound HTTP resolver pinned to {expected_host:?}, refusing {host}"
),
));
}
if pinned.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"EACCES: no egress-vetted address available for outbound HTTP request",
));
}
Ok(pinned.iter().map(|ip| SocketAddr::new(*ip, port)).collect())
};
let mut agent_builder = ureq::AgentBuilder::new()
.resolver(resolver)
.timeout_connect(Duration::from_secs(5))
.timeout_read(Duration::from_secs(15))
.timeout_write(Duration::from_secs(15));
if url.scheme() == "https" {
let tls_options = JavascriptTlsBridgeOptions {
is_server: false,
servername: url.host_str().map(str::to_owned),
alpn_protocols: Some(vec![String::from("http/1.1")]),
reject_unauthorized: options.reject_unauthorized,
..JavascriptTlsBridgeOptions::default()
};
agent_builder = agent_builder.tls_config(Arc::new(build_client_tls_config(&tls_options)?));
}
let agent = agent_builder.build();
let mut request = agent.request_url(method, url);
for (name, values) in &headers.normalized {
if name == "host" {
continue;
}
let header_value = values.join(", ");
request = request.set(name, &header_value);
}
let response = match options.body.as_deref() {
Some(body) => request.send_string(body),
None => request.call(),
};
match response {
Ok(response) => outbound_http_response_json(url, response),
Err(ureq::Error::Status(_, response)) => outbound_http_response_json(url, response),
Err(ureq::Error::Transport(error)) => Err(SidecarError::Execution(format!(
"ERR_HTTP_REQUEST_FAILED: {error}"
))),
}
}
fn wait_for_loopback_http_response<B>(
request: LoopbackHttpResponseWaitRequest<'_, B>,
) -> Result<String, SidecarError>
where
B: NativeSidecarBridge + Send + 'static,
BridgeError<B>: fmt::Debug + Send + Sync + 'static,
{
let LoopbackHttpResponseWaitRequest {
bridge,
vm_id,
dns,
socket_paths,
kernel,
process,
resource_limits,
request_key,
} = request;
let deadline = Instant::now() + http_loopback_request_timeout();
loop {
if let Some(response) = process
.pending_http_requests
.get(&request_key)
.and_then(|response| response.clone())
{
process.pending_http_requests.remove(&request_key);
return Ok(response);
}
if Instant::now() >= deadline {
process.pending_http_requests.remove(&request_key);
return Err(SidecarError::Execution(String::from(
"HTTP loopback request timed out waiting for net.http_respond",
)));
}
let Some(event) = process
.execution
.poll_event_blocking(Duration::from_millis(10))
.map_err(|error| SidecarError::Execution(error.to_string()))?
else {
continue;
};
match event {
ActiveExecutionEvent::JavascriptSyncRpcRequest(request) => {
let network_counts = process.network_resource_counts();
let response = service_javascript_sync_rpc(JavascriptSyncRpcServiceRequest {
bridge,
vm_id,
dns,
socket_paths,
kernel,
process,
sync_request: &request,
resource_limits,
network_counts,
});
match response {
Ok(result) => process
.execution
.respond_javascript_sync_rpc_success(request.id, result)
.or_else(ignore_stale_javascript_sync_rpc_response)?,
Err(error) => process
.execution
.respond_javascript_sync_rpc_error(
request.id,
javascript_sync_rpc_error_code(&error),
error.to_string(),
)
.or_else(ignore_stale_javascript_sync_rpc_response)?,
}
}
ActiveExecutionEvent::Exited(code) => {
process.pending_http_requests.remove(&request_key);
return Err(SidecarError::Execution(format!(
"HTTP loopback server exited before responding (exit code {code})"
)));
}
ActiveExecutionEvent::Stdout(_)
| ActiveExecutionEvent::Stderr(_)
| ActiveExecutionEvent::PythonVfsRpcRequest(_)
| ActiveExecutionEvent::SignalState { .. } => {}
}
}
}
pub(crate) fn dispatch_loopback_http_request<B>(
request: LoopbackHttpDispatchRequest<'_, B>,
) -> Result<String, SidecarError>
where
B: NativeSidecarBridge + Send + 'static,
BridgeError<B>: fmt::Debug + Send + Sync + 'static,
{
let LoopbackHttpDispatchRequest {
bridge,
vm_id,
dns,
socket_paths,
kernel,
process,
resource_limits,
server_id,
request_json,
} = request;
let request_id = {
let server = process.http_servers.get_mut(&server_id).ok_or_else(|| {
SidecarError::InvalidState(format!("HTTP target server disappeared: {server_id}"))
})?;
server.next_request_id += 1;
server.next_request_id
};
process
.pending_http_requests
.insert((server_id, request_id), None);
process.execution.send_javascript_stream_event(
"http_request",
json!({
"serverId": server_id,
"requestId": request_id,
"request": request_json,
}),
)?;
wait_for_loopback_http_response(LoopbackHttpResponseWaitRequest {
bridge,
vm_id,
dns,
socket_paths,
kernel,
process,
resource_limits,
request_key: (server_id, request_id),
})
}
fn ensure_vm_fetch_response_within_limit(
response_json: &str,
operation: &str,
limit: usize,
) -> Result<(), SidecarError> {
let size = response_json.len();
if size > limit {
return Err(SidecarError::Execution(format!(
"{operation} payload is {size} bytes, limit is {limit}"
)));
}
Ok(())
}
fn ensure_vm_fetch_raw_response_buffer_within_limit(
size: usize,
operation: &str,
) -> Result<(), SidecarError> {
if size > VM_FETCH_BUFFER_LIMIT_BYTES {
return Err(SidecarError::Execution(format!(
"{operation} raw response buffer is {size} bytes, limit is {VM_FETCH_BUFFER_LIMIT_BYTES}"
)));
}
Ok(())
}
pub(crate) fn ensure_vm_fetch_response_frame_within_limit(
response: &ResponseFrame,
max_frame_bytes: usize,
) -> Result<(), SidecarError> {
let max_frame_bytes = max_frame_bytes.min(VM_FETCH_BUFFER_LIMIT_BYTES);
let frame = crate::protocol::to_generated_protocol_frame(
&crate::protocol::ProtocolFrame::Response(response.clone()),
)
.map_err(|error| SidecarError::FrameTooLarge(error.to_string()))?;
let WireProtocolFrame::ResponseFrame(_) = &frame else {
return Err(SidecarError::FrameTooLarge(String::from(
"vm fetch response converted to non-response wire frame",
)));
};
WireFrameCodec::new(max_frame_bytes)
.encode(&frame)
.map(|_| ())
.map_err(|error| SidecarError::FrameTooLarge(error.to_string()))
}
fn service_javascript_dns_sync_rpc<B>(
bridge: &SharedBridge<B>,
kernel: &SidecarKernel,
vm_id: &str,
dns: &VmDnsConfig,
request: &JavascriptSyncRpcRequest,
) -> Result<Value, SidecarError>
where
B: NativeSidecarBridge + Send + 'static,
BridgeError<B>: fmt::Debug + Send + Sync + 'static,
{
match request.method.as_str() {
"dns.lookup" => {
let payload = request
.args
.first()
.cloned()
.ok_or_else(|| {
SidecarError::InvalidState(String::from(
"dns.lookup requires a request payload",
))
})
.and_then(|value| {
serde_json::from_value::<JavascriptDnsLookupRequest>(value).map_err(|error| {
SidecarError::InvalidState(format!("invalid dns.lookup payload: {error}"))
})
})?;
let addresses = filter_dns_ip_addrs(
resolve_dns_ip_addrs(
bridge,
kernel,
vm_id,
dns,
&payload.hostname,
DnsLookupPolicy::CheckPermissions,
)?,
payload.family,
)?;
let addresses = filter_dns_safe_ip_addrs(addresses, &payload.hostname)?;
Ok(Value::Array(
addresses
.into_iter()
.map(|ip| {
json!({
"address": ip.to_string(),
"family": if ip.is_ipv6() { 6 } else { 4 },
})
})
.collect(),
))
}
"dns.resolve" | "dns.resolve4" | "dns.resolve6" => {
let payload = request
.args
.first()
.cloned()
.ok_or_else(|| {
SidecarError::InvalidState(String::from(
"dns.resolve requires a request payload",
))
})
.and_then(|value| {
serde_json::from_value::<JavascriptDnsResolveRequest>(value).map_err(|error| {
SidecarError::InvalidState(format!("invalid dns.resolve payload: {error}"))
})
})?;
let requested_type = match request.method.as_str() {
"dns.resolve4" => String::from("A"),
"dns.resolve6" => String::from("AAAA"),
_ => payload
.rrtype
.as_deref()
.unwrap_or("A")
.to_ascii_uppercase(),
};
let record_type = parse_dns_record_type(&requested_type)?;
let resolution = resolve_dns_records(
bridge,
kernel,
vm_id,
dns,
&payload.hostname,
record_type,
DnsLookupPolicy::CheckPermissions,
)?;
dns_resolution_to_node_value(&resolution, &requested_type)
}
other => Err(SidecarError::InvalidState(format!(
"unsupported JavaScript dns sync RPC method {other}"
))),
}
}
fn service_javascript_dgram_sync_rpc<B>(
request: JavascriptDgramSyncRpcServiceRequest<'_, B>,
) -> Result<Value, SidecarError>
where
B: NativeSidecarBridge + Send + 'static,
BridgeError<B>: fmt::Debug + Send + Sync + 'static,
{
let JavascriptDgramSyncRpcServiceRequest {
bridge,
kernel,
vm_id,
dns,
socket_paths,
process,
sync_request: request,
resource_limits,
network_counts,
} = request;
match request.method.as_str() {
"dgram.createSocket" => {
check_network_resource_limit(
resource_limits.max_sockets,
network_counts.sockets,
1,
"socket",
)?;
let payload = request
.args
.first()
.cloned()
.ok_or_else(|| {
SidecarError::InvalidState(String::from(
"dgram.createSocket requires a request payload",
))
})
.and_then(|value| {
serde_json::from_value::<JavascriptDgramCreateSocketRequest>(value).map_err(
|error| {
SidecarError::InvalidState(format!(
"invalid dgram.createSocket payload: {error}"
))
},
)
})?;
let family = JavascriptUdpFamily::from_socket_type(&payload.socket_type)?;
let socket_id = process.allocate_udp_socket_id();
process.udp_sockets.insert(
socket_id.clone(),
ActiveUdpSocket::new(kernel, process.kernel_pid, family)?,
);
Ok(json!({
"socketId": socket_id,
"type": family.socket_type(),
}))
}
"dgram.bind" => {
let socket_id = javascript_sync_rpc_arg_str(&request.args, 0, "dgram.bind socket id")?;
let payload = request
.args
.get(1)
.cloned()
.ok_or_else(|| {
SidecarError::InvalidState(String::from(
"dgram.bind requires a request payload",
))
})
.and_then(|value| {
serde_json::from_value::<JavascriptDgramBindRequest>(value).map_err(|error| {
SidecarError::InvalidState(format!("invalid dgram.bind payload: {error}"))
})
})?;
let socket = process.udp_sockets.get_mut(socket_id).ok_or_else(|| {
SidecarError::InvalidState(format!("unknown UDP socket {socket_id}"))
})?;
let local_addr = socket.bind(
kernel,
process.kernel_pid,
payload.address.as_deref(),
payload.port,
socket_paths,
)?;
Ok(json!({
"localAddress": local_addr.ip().to_string(),
"localPort": local_addr.port(),
"family": socket_addr_family(&local_addr),
}))
}
"dgram.send" => {
let socket_id = javascript_sync_rpc_arg_str(&request.args, 0, "dgram.send socket id")?;
let chunk = javascript_sync_rpc_bytes_arg(&request.args, 1, "dgram.send payload")?;
let payload = request
.args
.get(2)
.cloned()
.ok_or_else(|| {
SidecarError::InvalidState(String::from(
"dgram.send requires a request payload",
))
})
.and_then(|value| {
serde_json::from_value::<JavascriptDgramSendRequest>(value).map_err(|error| {
SidecarError::InvalidState(format!("invalid dgram.send payload: {error}"))
})
})?;
let socket = process.udp_sockets.get_mut(socket_id).ok_or_else(|| {
SidecarError::InvalidState(format!("unknown UDP socket {socket_id}"))
})?;
let (written, local_addr) = socket.send_to(ActiveUdpSendToRequest {
bridge,
kernel,
kernel_pid: process.kernel_pid,
vm_id,
dns,
host: payload.address.as_deref().unwrap_or("localhost"),
port: payload.port,
context: socket_paths,
contents: &chunk,
})?;
Ok(json!({
"bytes": written,
"localAddress": local_addr.ip().to_string(),
"localPort": local_addr.port(),
"family": socket_addr_family(&local_addr),
}))
}
"dgram.poll" => {
let socket_id = javascript_sync_rpc_arg_str(&request.args, 0, "dgram.poll socket id")?;
let wait_ms =
javascript_sync_rpc_arg_u64_optional(&request.args, 1, "dgram.poll wait ms")?
.unwrap_or_default();
let event = {
let socket = process.udp_sockets.get(socket_id).ok_or_else(|| {
SidecarError::InvalidState(format!("unknown UDP socket {socket_id}"))
})?;
socket.poll(kernel, process.kernel_pid, Duration::from_millis(wait_ms))?
};
match event {
Some(JavascriptUdpSocketEvent::Message { data, remote_addr }) => {
let family = JavascriptSocketFamily::from_ip(remote_addr.ip());
let guest_remote_port = if is_loopback_ip(remote_addr.ip()) {
socket_paths
.guest_udp_port_for_host_port(family, remote_addr.port())
.unwrap_or(remote_addr.port())
} else {
remote_addr.port()
};
Ok(json!({
"type": "message",
"data": javascript_sync_rpc_bytes_value(&data),
"remoteAddress": remote_addr.ip().to_string(),
"remotePort": guest_remote_port,
"remoteFamily": socket_addr_family(&remote_addr),
}))
}
Some(JavascriptUdpSocketEvent::Error { code, message }) => Ok(json!({
"type": "error",
"code": code,
"message": message,
})),
None => Ok(Value::Null),
}
}
"dgram.close" => {
let socket_id = javascript_sync_rpc_arg_str(&request.args, 0, "dgram.close socket id")?;
let mut socket = process.udp_sockets.remove(socket_id).ok_or_else(|| {
SidecarError::InvalidState(format!("unknown UDP socket {socket_id}"))
})?;
socket.close(kernel, process.kernel_pid);
Ok(Value::Null)
}
"dgram.address" => {
let socket_id =
javascript_sync_rpc_arg_str(&request.args, 0, "dgram.address socket id")?;
let socket = process.udp_sockets.get(socket_id).ok_or_else(|| {
SidecarError::InvalidState(format!("unknown UDP socket {socket_id}"))
})?;
let local_addr = socket.local_addr().ok_or_else(|| {
SidecarError::Execution(String::from("EBADF: bad file descriptor"))
})?;
javascript_net_json_string(
json!({
"address": local_addr.ip().to_string(),
"port": local_addr.port(),
"family": socket_addr_family(&local_addr),
}),
"dgram.address",
)
}
"dgram.setBufferSize" => {
let socket_id =
javascript_sync_rpc_arg_str(&request.args, 0, "dgram.setBufferSize socket id")?;
let which =
javascript_sync_rpc_arg_str(&request.args, 1, "dgram.setBufferSize buffer kind")?;
let size = javascript_sync_rpc_arg_u64(&request.args, 2, "dgram.setBufferSize size")?;
let size = usize::try_from(size).map_err(|_| {
SidecarError::InvalidState(String::from(
"dgram.setBufferSize size must fit within usize",
))
})?;
let socket = process.udp_sockets.get_mut(socket_id).ok_or_else(|| {
SidecarError::InvalidState(format!("unknown UDP socket {socket_id}"))
})?;
socket.set_buffer_size(which, size)?;
Ok(Value::Null)
}
"dgram.getBufferSize" => {
let socket_id =
javascript_sync_rpc_arg_str(&request.args, 0, "dgram.getBufferSize socket id")?;
let which =
javascript_sync_rpc_arg_str(&request.args, 1, "dgram.getBufferSize buffer kind")?;
let socket = process.udp_sockets.get(socket_id).ok_or_else(|| {
SidecarError::InvalidState(format!("unknown UDP socket {socket_id}"))
})?;
let size = socket.get_buffer_size(which)?;
Ok(json!(size))
}
other => Err(SidecarError::InvalidState(format!(
"unsupported JavaScript dgram sync RPC method {other}"
))),
}
}
#[derive(Debug)]
struct ClientHttp2StreamState {
send_stream: Option<h2::SendStream<Bytes>>,
}
#[derive(Debug)]
struct ServerHttp2StreamState {
send_response: Option<ServerHttp2Responder>,
send_stream: Option<h2::SendStream<Bytes>>,
}
#[derive(Debug)]
enum ServerHttp2Responder {
Regular(server::SendResponse<Bytes>),
Pushed(server::SendPushedResponse<Bytes>),
}
const HTTP2_DEFAULT_WINDOW_SIZE: u32 = 65_535;
const HTTP2_POLL_DELAY: Duration = Duration::from_millis(10);
fn http2_runtime_snapshot() -> Http2RuntimeSnapshot {
Http2RuntimeSnapshot {
effective_local_window_size: HTTP2_DEFAULT_WINDOW_SIZE,
local_window_size: HTTP2_DEFAULT_WINDOW_SIZE,
remote_window_size: HTTP2_DEFAULT_WINDOW_SIZE,
next_stream_id: 1,
outbound_queue_size: 1,
deflate_dynamic_table_size: 0,
inflate_dynamic_table_size: 0,
}
}
fn http2_snapshot_json(snapshot: &Http2SessionSnapshot) -> Result<String, SidecarError> {
serde_json::to_string(snapshot)
.map_err(|error| SidecarError::Execution(format!("ERR_AGENTOS_NODE_SYNC_RPC: {error}")))
}
fn http2_event_value(event: &Http2BridgeEvent) -> Result<Value, SidecarError> {
serde_json::to_string(event)
.map(Value::String)
.map_err(|error| SidecarError::Execution(format!("ERR_AGENTOS_NODE_SYNC_RPC: {error}")))
}
fn push_http2_server_event(
shared: &Arc<Mutex<crate::state::Http2SharedState>>,
server_id: u64,
event: Http2BridgeEvent,
) {
if let Ok(mut state) = shared.lock() {
state
.server_events
.entry(server_id)
.or_default()
.push_back(event);
}
}
fn push_http2_session_event(
shared: &Arc<Mutex<crate::state::Http2SharedState>>,
session_id: u64,
event: Http2BridgeEvent,
) {
if let Ok(mut state) = shared.lock() {
state
.session_events
.entry(session_id)
.or_default()
.push_back(event);
}
}
fn pop_http2_event(
queue: &mut BTreeMap<u64, VecDeque<Http2BridgeEvent>>,
id: u64,
) -> Option<Http2BridgeEvent> {
queue.get_mut(&id).and_then(VecDeque::pop_front)
}
fn wait_for_http2_event(
shared: &Arc<Mutex<crate::state::Http2SharedState>>,
id: u64,
is_server: bool,
wait_ms: u64,
) -> Option<Http2BridgeEvent> {
let deadline = Instant::now() + Duration::from_millis(wait_ms);
loop {
if let Ok(mut state) = shared.lock() {
let queue = if is_server {
&mut state.server_events
} else {
&mut state.session_events
};
if let Some(event) = pop_http2_event(queue, id) {
return Some(event);
}
}
if wait_ms == 0 || Instant::now() >= deadline {
return None;
}
thread::sleep(HTTP2_POLL_DELAY);
}
}
fn next_http2_session_id(shared: &mut crate::state::Http2SharedState) -> u64 {
shared.next_session_id += 1;
shared.next_session_id
}
fn next_http2_stream_id(shared: &mut crate::state::Http2SharedState) -> u64 {
shared.next_stream_id += 1;
shared.next_stream_id
}
fn http2_reason(code: Option<u32>) -> Reason {
code.unwrap_or(Reason::NO_ERROR.into()).into()
}
fn http2_error_payload(message: impl Into<String>) -> String {
serde_json::to_string(&json!({
"name": "Error",
"code": "ERR_HTTP2_ERROR",
"message": message.into(),
}))
.unwrap_or_else(|_| {
String::from(
"{\"name\":\"Error\",\"code\":\"ERR_HTTP2_ERROR\",\"message\":\"HTTP/2 bridge error\"}",
)
})
}
fn http2_socket_snapshot(local_addr: SocketAddr, remote_addr: SocketAddr) -> Http2SocketSnapshot {
Http2SocketSnapshot {
encrypted: false,
allow_half_open: false,
local_address: Some(local_addr.ip().to_string()),
local_port: Some(local_addr.port()),
local_family: Some(socket_addr_family(&local_addr).to_string()),
remote_address: Some(remote_addr.ip().to_string()),
remote_port: Some(remote_addr.port()),
remote_family: Some(socket_addr_family(&remote_addr).to_string()),
servername: None,
alpn_protocol: Some(String::from("h2c")),
}
}
fn http2_wait_result(kind: &str, id: u64) -> Value {
json!({
"kind": kind,
"id": id,
})
}
fn is_http2_terminal_event(event: &Http2BridgeEvent, is_server: bool, id: u64) -> bool {
if is_server {
event.kind == "serverClose" && event.id == id
} else {
event.kind == "sessionClose" && event.id == id
}
}
fn dispatch_http2_wait_loop(
process: &ActiveProcess,
id: u64,
is_server: bool,
) -> Result<Value, SidecarError> {
loop {
if let Some(event) = wait_for_http2_event(&process.http2.shared, id, is_server, 50) {
let payload = serde_json::to_value(&event).map_err(|error| {
SidecarError::Execution(format!("ERR_AGENTOS_NODE_SYNC_RPC: {error}"))
})?;
process
.execution
.send_javascript_stream_event("http2", payload.clone())?;
if is_http2_terminal_event(&event, is_server, id) {
return Ok(payload);
}
continue;
}
let exists = process
.http2
.shared
.lock()
.map(|state| {
if is_server {
state.servers.contains_key(&id)
} else {
state.sessions.contains_key(&id)
}
})
.unwrap_or(false);
if !exists {
return Ok(if is_server {
http2_wait_result("serverClose", id)
} else {
http2_wait_result("sessionClose", id)
});
}
}
}
fn dispatch_http_wait_loop(process: &ActiveProcess, server_id: u64) -> Result<Value, SidecarError> {
loop {
if !process.http_servers.contains_key(&server_id) {
return Ok(json!({
"kind": "serverClose",
"id": server_id,
}));
}
thread::sleep(Duration::from_millis(25));
}
}
fn http2_settings_from_value(settings: &BTreeMap<String, Value>) -> BTreeMap<String, Value> {
settings.clone()
}
fn parse_http2_headers_json(
headers_json: &str,
label: &str,
) -> Result<BTreeMap<String, Value>, SidecarError> {
serde_json::from_str::<BTreeMap<String, Value>>(headers_json)
.map_err(|error| SidecarError::InvalidState(format!("{label} must be valid JSON: {error}")))
}
fn apply_http2_header_values(
header_map: &mut HeaderMap,
name: &str,
value: &Value,
) -> Result<(), SidecarError> {
let header_name = HeaderName::from_bytes(name.as_bytes()).map_err(|error| {
SidecarError::InvalidState(format!("invalid HTTP/2 header name {name:?}: {error}"))
})?;
match value {
Value::Array(values) => {
for value in values {
apply_http2_header_values(header_map, name, value)?;
}
}
Value::String(text) => {
let value = HeaderValue::from_str(text).map_err(|error| {
SidecarError::InvalidState(format!(
"invalid HTTP/2 header value for {name}: {error}"
))
})?;
header_map.append(header_name.clone(), value);
}
Value::Number(number) => {
let value = HeaderValue::from_str(&number.to_string()).map_err(|error| {
SidecarError::InvalidState(format!(
"invalid HTTP/2 numeric header value for {name}: {error}"
))
})?;
header_map.append(header_name.clone(), value);
}
Value::Bool(boolean) => {
let value = HeaderValue::from_str(if *boolean { "true" } else { "false" }).map_err(
|error| {
SidecarError::InvalidState(format!(
"invalid HTTP/2 boolean header value for {name}: {error}"
))
},
)?;
header_map.append(header_name.clone(), value);
}
Value::Null => {}
Value::Object(_) => {
return Err(SidecarError::InvalidState(format!(
"unsupported HTTP/2 header object value for {name}"
)));
}
}
Ok(())
}
fn build_http2_request(headers_json: &str) -> Result<Request<()>, SidecarError> {
let headers = parse_http2_headers_json(headers_json, "HTTP/2 request headers")?;
let method = headers
.get(":method")
.and_then(Value::as_str)
.unwrap_or("GET");
let path = headers.get(":path").and_then(Value::as_str).unwrap_or("/");
let mut builder = Request::builder()
.method(Method::from_bytes(method.as_bytes()).map_err(|error| {
SidecarError::InvalidState(format!("invalid HTTP/2 method {method:?}: {error}"))
})?)
.uri(path.parse::<Uri>().map_err(|error| {
SidecarError::InvalidState(format!("invalid HTTP/2 path {path:?}: {error}"))
})?);
{
let header_map = builder.headers_mut().expect("request header map");
for (name, value) in &headers {
if name.starts_with(':') {
continue;
}
apply_http2_header_values(header_map, name, value)?;
}
}
builder
.body(())
.map_err(|error| SidecarError::InvalidState(format!("invalid HTTP/2 request: {error}")))
}
fn build_http2_response(headers_json: &str) -> Result<Response<()>, SidecarError> {
let headers = parse_http2_headers_json(headers_json, "HTTP/2 response headers")?;
let status = headers
.get(":status")
.and_then(Value::as_u64)
.or_else(|| {
headers
.get(":status")
.and_then(Value::as_str)
.and_then(|value| value.parse::<u16>().ok().map(u64::from))
})
.unwrap_or(200);
let mut builder = Response::builder().status(status as u16);
{
let header_map = builder.headers_mut().expect("response header map");
for (name, value) in &headers {
if name.starts_with(':') {
continue;
}
apply_http2_header_values(header_map, name, value)?;
}
}
builder.body(()).map_err(|error| {
SidecarError::InvalidState(format!("invalid HTTP/2 response headers: {error}"))
})
}
fn serialize_http2_headers_map(
pseudo: BTreeMap<String, Value>,
headers: &HeaderMap,
) -> Result<String, SidecarError> {
let mut serialized = pseudo;
for (name, value) in headers {
let name = name.as_str().to_string();
let value = Value::String(
value
.to_str()
.map_err(|error| {
SidecarError::Execution(format!("invalid HTTP/2 header value: {error}"))
})?
.to_owned(),
);
match serialized.get_mut(&name) {
Some(Value::Array(values)) => values.push(value),
Some(existing) => {
let first = existing.clone();
*existing = Value::Array(vec![first, value]);
}
None => {
serialized.insert(name, value);
}
}
}
serde_json::to_string(&serialized)
.map_err(|error| SidecarError::Execution(format!("ERR_AGENTOS_NODE_SYNC_RPC: {error}")))
}
fn serialize_http2_request_headers(
request: &Request<h2::RecvStream>,
) -> Result<String, SidecarError> {
let mut pseudo = BTreeMap::new();
pseudo.insert(
String::from(":method"),
Value::String(request.method().as_str().to_string()),
);
pseudo.insert(
String::from(":path"),
Value::String(
request
.uri()
.path_and_query()
.map(|value| value.as_str().to_string())
.unwrap_or_else(|| String::from("/")),
),
);
serialize_http2_headers_map(pseudo, request.headers())
}
fn serialize_http2_response_headers(
response: &Response<h2::RecvStream>,
) -> Result<String, SidecarError> {
let mut pseudo = BTreeMap::new();
pseudo.insert(
String::from(":status"),
Value::Number(serde_json::Number::from(response.status().as_u16())),
);
serialize_http2_headers_map(pseudo, response.headers())
}
fn remove_http2_session_resources(
shared: &Arc<Mutex<crate::state::Http2SharedState>>,
session_id: u64,
) {
if let Ok(mut state) = shared.lock() {
state.sessions.remove(&session_id);
state.session_events.remove(&session_id);
let stream_ids = state
.streams
.iter()
.filter_map(|(stream_id, stream)| {
(stream.session_id == session_id).then_some(*stream_id)
})
.collect::<Vec<_>>();
for stream_id in stream_ids {
state.streams.remove(&stream_id);
}
}
}
fn spawn_http2_client_session(
shared: Arc<Mutex<crate::state::Http2SharedState>>,
session_id: u64,
remote_addr: SocketAddr,
tls: Option<JavascriptTlsBridgeOptions>,
snapshot: Arc<Mutex<Http2SessionSnapshot>>,
mut command_rx: UnboundedReceiver<Http2SessionCommand>,
) {
thread::spawn(move || {
let runtime = match TokioRuntimeBuilder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(error) => {
push_http2_session_event(
&shared,
session_id,
Http2BridgeEvent {
kind: String::from("sessionError"),
id: session_id,
data: Some(http2_error_payload(error.to_string())),
..Http2BridgeEvent::default()
},
);
remove_http2_session_resources(&shared, session_id);
return;
}
};
runtime.block_on(async move {
let stream = match tokio::net::TcpStream::connect(remote_addr).await {
Ok(stream) => stream,
Err(error) => {
push_http2_session_event(
&shared,
session_id,
Http2BridgeEvent {
kind: String::from("sessionError"),
id: session_id,
data: Some(http2_error_payload(error.to_string())),
..Http2BridgeEvent::default()
},
);
remove_http2_session_resources(&shared, session_id);
return;
}
};
let local_addr = match stream.local_addr() {
Ok(addr) => addr,
Err(error) => {
push_http2_session_event(
&shared,
session_id,
Http2BridgeEvent {
kind: String::from("sessionError"),
id: session_id,
data: Some(http2_error_payload(error.to_string())),
..Http2BridgeEvent::default()
},
);
remove_http2_session_resources(&shared, session_id);
return;
}
};
{
let mut snapshot_guard = snapshot.lock().expect("http2 snapshot lock");
snapshot_guard.socket = http2_socket_snapshot(local_addr, remote_addr);
if let Some(options) = tls.as_ref() {
snapshot_guard.encrypted = true;
snapshot_guard.alpn_protocol = Some(String::from("h2"));
snapshot_guard.socket.encrypted = true;
snapshot_guard.socket.servername = options.servername.clone();
snapshot_guard.socket.alpn_protocol = Some(String::from("h2"));
}
snapshot_guard.state = http2_runtime_snapshot();
}
if let Ok(snapshot_json) =
http2_snapshot_json(&snapshot.lock().expect("http2 snapshot lock").clone())
{
push_http2_session_event(
&shared,
session_id,
Http2BridgeEvent {
kind: String::from("sessionConnect"),
id: session_id,
data: Some(snapshot_json),
..Http2BridgeEvent::default()
},
);
}
let io: Pin<Box<dyn Http2AsyncIo>> = if let Some(options) = tls.as_ref() {
let server_name = match ServerName::try_from(
options
.servername
.clone()
.unwrap_or_else(|| String::from("localhost")),
) {
Ok(server_name) => server_name,
Err(_) => {
push_http2_session_event(
&shared,
session_id,
Http2BridgeEvent {
kind: String::from("sessionError"),
id: session_id,
data: Some(http2_error_payload("invalid TLS servername")),
..Http2BridgeEvent::default()
},
);
remove_http2_session_resources(&shared, session_id);
return;
}
};
let connector = match build_client_tls_config(options) {
Ok(config) => TlsConnector::from(Arc::new(config)),
Err(error) => {
push_http2_session_event(
&shared,
session_id,
Http2BridgeEvent {
kind: String::from("sessionError"),
id: session_id,
data: Some(http2_error_payload(error.to_string())),
..Http2BridgeEvent::default()
},
);
remove_http2_session_resources(&shared, session_id);
return;
}
};
match connector.connect(server_name, stream).await {
Ok(tls_stream) => Box::pin(tls_stream),
Err(error) => {
push_http2_session_event(
&shared,
session_id,
Http2BridgeEvent {
kind: String::from("sessionError"),
id: session_id,
data: Some(http2_error_payload(error.to_string())),
..Http2BridgeEvent::default()
},
);
remove_http2_session_resources(&shared, session_id);
return;
}
}
} else {
Box::pin(stream)
};
let (mut sender, connection) = match client::handshake(io).await {
Ok(parts) => parts,
Err(error) => {
push_http2_session_event(
&shared,
session_id,
Http2BridgeEvent {
kind: String::from("sessionError"),
id: session_id,
data: Some(http2_error_payload(error.to_string())),
..Http2BridgeEvent::default()
},
);
remove_http2_session_resources(&shared, session_id);
return;
}
};
let (status_tx, mut status_rx) = unbounded_channel::<Result<(), String>>();
tokio::spawn(async move {
let _ = status_tx.send(connection.await.map_err(|error| error.to_string()));
});
let streams: Arc<Mutex<BTreeMap<u64, ClientHttp2StreamState>>> =
Arc::new(Mutex::new(BTreeMap::new()));
loop {
tokio::select! {
Some(result) = status_rx.recv() => {
if let Err(message) = result {
push_http2_session_event(
&shared,
session_id,
Http2BridgeEvent {
kind: String::from("sessionError"),
id: session_id,
data: Some(http2_error_payload(message)),
..Http2BridgeEvent::default()
},
);
}
push_http2_session_event(
&shared,
session_id,
Http2BridgeEvent {
kind: String::from("sessionClose"),
id: session_id,
..Http2BridgeEvent::default()
},
);
remove_http2_session_resources(&shared, session_id);
break;
}
Some(command) = command_rx.recv() => {
match command {
Http2SessionCommand::Request { headers_json, options_json, respond_to } => {
let request = match build_http2_request(&headers_json) {
Ok(request) => request,
Err(error) => {
let _ = respond_to.send(Err(error.to_string()));
continue;
}
};
let options: JavascriptHttp2RequestOptions =
serde_json::from_str(&options_json).unwrap_or_default();
let stream_id = {
let mut state = shared.lock().expect("http2 shared state");
let stream_id = next_http2_stream_id(&mut state);
state.streams.insert(
stream_id,
ActiveHttp2Stream {
session_id,
paused: Arc::new(AtomicBool::new(false)),
},
);
stream_id
};
match sender.send_request(request, options.end_stream) {
Ok((response_future, send_stream)) => {
if !options.end_stream {
streams
.lock()
.expect("http2 client streams")
.insert(stream_id, ClientHttp2StreamState { send_stream: Some(send_stream) });
}
let shared_clone = Arc::clone(&shared);
let snapshot_clone = Arc::clone(&snapshot);
tokio::spawn(async move {
match response_future.await {
Ok(response) => {
if let Ok(headers_json) = serialize_http2_response_headers(&response) {
push_http2_session_event(
&shared_clone,
session_id,
Http2BridgeEvent {
kind: String::from("clientResponseHeaders"),
id: stream_id,
data: Some(headers_json),
..Http2BridgeEvent::default()
},
);
}
let mut body = response.into_body();
while let Some(chunk) = body.data().await {
match chunk {
Ok(bytes) => {
let paused = {
let state = shared_clone.lock().expect("http2 shared state");
state.streams.get(&stream_id).map(|stream| Arc::clone(&stream.paused))
};
if let Some(paused) = paused {
while paused.load(Ordering::SeqCst) {
tokio::time::sleep(HTTP2_POLL_DELAY).await;
}
}
let _ = body.flow_control().release_capacity(bytes.len());
push_http2_session_event(
&shared_clone,
session_id,
Http2BridgeEvent {
kind: String::from("clientData"),
id: stream_id,
data: Some(base64::engine::general_purpose::STANDARD.encode(bytes)),
..Http2BridgeEvent::default()
},
);
}
Err(error) => {
push_http2_session_event(
&shared_clone,
session_id,
Http2BridgeEvent {
kind: String::from("clientError"),
id: stream_id,
data: Some(http2_error_payload(error.to_string())),
..Http2BridgeEvent::default()
},
);
break;
}
}
}
{
let mut snapshot = snapshot_clone.lock().expect("http2 snapshot lock");
snapshot.state.next_stream_id =
snapshot.state.next_stream_id.saturating_add(2);
}
push_http2_session_event(
&shared_clone,
session_id,
Http2BridgeEvent {
kind: String::from("clientEnd"),
id: stream_id,
..Http2BridgeEvent::default()
},
);
push_http2_session_event(
&shared_clone,
session_id,
Http2BridgeEvent {
kind: String::from("clientClose"),
id: stream_id,
extra_number: Some(0),
..Http2BridgeEvent::default()
},
);
if let Ok(mut state) = shared_clone.lock() {
state.streams.remove(&stream_id);
}
}
Err(error) => {
push_http2_session_event(
&shared_clone,
session_id,
Http2BridgeEvent {
kind: String::from("clientError"),
id: stream_id,
data: Some(http2_error_payload(error.to_string())),
..Http2BridgeEvent::default()
},
);
push_http2_session_event(
&shared_clone,
session_id,
Http2BridgeEvent {
kind: String::from("clientClose"),
id: stream_id,
extra_number: Some(u32::from(Reason::INTERNAL_ERROR) as u64),
..Http2BridgeEvent::default()
},
);
if let Ok(mut state) = shared_clone.lock() {
state.streams.remove(&stream_id);
}
}
}
});
let _ = respond_to.send(Ok(json!(stream_id)));
}
Err(error) => {
if let Ok(mut state) = shared.lock() {
state.streams.remove(&stream_id);
}
let _ = respond_to.send(Err(error.to_string()));
}
}
}
Http2SessionCommand::Settings { settings_json, respond_to } => {
let settings = serde_json::from_str::<BTreeMap<String, Value>>(&settings_json)
.unwrap_or_default();
{
let mut snapshot = snapshot.lock().expect("http2 snapshot lock");
snapshot.local_settings = http2_settings_from_value(&settings);
}
if let Ok(headers_json) = serde_json::to_string(&settings) {
push_http2_session_event(
&shared,
session_id,
Http2BridgeEvent {
kind: String::from("sessionLocalSettings"),
id: session_id,
data: Some(headers_json.clone()),
..Http2BridgeEvent::default()
},
);
push_http2_session_event(
&shared,
session_id,
Http2BridgeEvent {
kind: String::from("sessionSettingsAck"),
id: session_id,
..Http2BridgeEvent::default()
},
);
}
let _ = respond_to.send(Ok(Value::Null));
}
Http2SessionCommand::SetLocalWindowSize { size, respond_to } => {
{
let mut snapshot = snapshot.lock().expect("http2 snapshot lock");
snapshot.state.local_window_size = size;
snapshot.state.effective_local_window_size = size;
}
let value = snapshot
.lock()
.ok()
.and_then(|snapshot| http2_snapshot_json(&snapshot.clone()).ok())
.map(Value::String)
.unwrap_or(Value::Null);
let _ = respond_to.send(Ok(value));
}
Http2SessionCommand::Goaway { error_code, last_stream_id, opaque_data, respond_to } => {
push_http2_session_event(
&shared,
session_id,
Http2BridgeEvent {
kind: String::from("sessionGoaway"),
id: session_id,
data: opaque_data.map(|value| {
base64::engine::general_purpose::STANDARD.encode(value)
}),
extra_number: Some(error_code as u64),
flags: Some(last_stream_id as u64),
..Http2BridgeEvent::default()
},
);
let _ = respond_to.send(Ok(Value::Null));
}
Http2SessionCommand::Close { respond_to, .. } => {
let _ = respond_to.send(Ok(Value::Null));
push_http2_session_event(
&shared,
session_id,
Http2BridgeEvent {
kind: String::from("sessionClose"),
id: session_id,
..Http2BridgeEvent::default()
},
);
remove_http2_session_resources(&shared, session_id);
break;
}
Http2SessionCommand::StreamWrite { stream_id, chunk, end_stream, respond_to } => {
let result = streams
.lock()
.expect("http2 client streams")
.get_mut(&stream_id)
.and_then(|stream| stream.send_stream.as_mut())
.ok_or_else(|| SidecarError::InvalidState(format!("unknown HTTP/2 client stream {stream_id}")))
.and_then(|stream| stream.send_data(Bytes::from(chunk), end_stream).map_err(|error| SidecarError::Execution(error.to_string())));
match result {
Ok(()) => {
if end_stream {
streams.lock().expect("http2 client streams").remove(&stream_id);
}
let _ = respond_to.send(Ok(Value::Bool(true)));
}
Err(error) => {
let _ = respond_to.send(Err(error.to_string()));
}
}
}
Http2SessionCommand::StreamClose { stream_id, error_code, respond_to } => {
let mut streams = streams.lock().expect("http2 client streams");
let Some(mut state) = streams.remove(&stream_id) else {
let _ = respond_to.send(Err(format!("unknown HTTP/2 client stream {stream_id}")));
continue;
};
if let Some(stream) = state.send_stream.as_mut() {
stream.send_reset(http2_reason(error_code));
}
if let Ok(mut state) = shared.lock() {
state.streams.remove(&stream_id);
}
push_http2_session_event(
&shared,
session_id,
Http2BridgeEvent {
kind: String::from("clientClose"),
id: stream_id,
extra_number: Some(u32::from(http2_reason(error_code)) as u64),
..Http2BridgeEvent::default()
},
);
let _ = respond_to.send(Ok(Value::Null));
}
Http2SessionCommand::StreamRespond { respond_to, .. }
| Http2SessionCommand::StreamPush { respond_to, .. }
| Http2SessionCommand::StreamRespondWithFile { respond_to, .. } => {
let _ = respond_to.send(Err(String::from("HTTP/2 client streams cannot send server responses")));
}
}
}
else => break,
}
}
});
});
}
fn spawn_http2_server_session(
shared: Arc<Mutex<crate::state::Http2SharedState>>,
server_id: u64,
session_id: u64,
stream: TcpStream,
tls: Option<JavascriptTlsBridgeOptions>,
snapshot: Arc<Mutex<Http2SessionSnapshot>>,
mut command_rx: UnboundedReceiver<Http2SessionCommand>,
) {
thread::spawn(move || {
let runtime = match TokioRuntimeBuilder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(error) => {
push_http2_server_event(
&shared,
server_id,
Http2BridgeEvent {
kind: String::from("serverStreamError"),
id: session_id,
data: Some(http2_error_payload(error.to_string())),
..Http2BridgeEvent::default()
},
);
remove_http2_session_resources(&shared, session_id);
return;
}
};
runtime.block_on(async move {
if let Err(error) = stream.set_nonblocking(true) {
push_http2_server_event(
&shared,
server_id,
Http2BridgeEvent {
kind: String::from("serverStreamError"),
id: session_id,
data: Some(http2_error_payload(error.to_string())),
..Http2BridgeEvent::default()
},
);
remove_http2_session_resources(&shared, session_id);
return;
}
let stream = match tokio::net::TcpStream::from_std(stream) {
Ok(stream) => stream,
Err(error) => {
push_http2_server_event(
&shared,
server_id,
Http2BridgeEvent {
kind: String::from("serverStreamError"),
id: session_id,
data: Some(http2_error_payload(error.to_string())),
..Http2BridgeEvent::default()
},
);
remove_http2_session_resources(&shared, session_id);
return;
}
};
let local_addr = match stream.local_addr() {
Ok(addr) => addr,
Err(error) => {
push_http2_server_event(
&shared,
server_id,
Http2BridgeEvent {
kind: String::from("serverStreamError"),
id: session_id,
data: Some(http2_error_payload(error.to_string())),
..Http2BridgeEvent::default()
},
);
remove_http2_session_resources(&shared, session_id);
return;
}
};
let remote_addr = match stream.peer_addr() {
Ok(addr) => addr,
Err(error) => {
push_http2_server_event(
&shared,
server_id,
Http2BridgeEvent {
kind: String::from("serverStreamError"),
id: session_id,
data: Some(http2_error_payload(error.to_string())),
..Http2BridgeEvent::default()
},
);
remove_http2_session_resources(&shared, session_id);
return;
}
};
{
let mut snapshot_guard = snapshot.lock().expect("http2 snapshot lock");
snapshot_guard.socket = http2_socket_snapshot(local_addr, remote_addr);
if tls.is_some() {
snapshot_guard.encrypted = true;
snapshot_guard.alpn_protocol = Some(String::from("h2"));
snapshot_guard.socket.encrypted = true;
snapshot_guard.socket.alpn_protocol = Some(String::from("h2"));
}
snapshot_guard.state = http2_runtime_snapshot();
}
if let Ok(snapshot_json) =
http2_snapshot_json(&snapshot.lock().expect("http2 snapshot lock").clone())
{
push_http2_server_event(
&shared,
server_id,
Http2BridgeEvent {
kind: String::from(if tls.is_some() {
"serverSecureConnection"
} else {
"serverConnection"
}),
id: server_id,
data: Some(serde_json::to_string(&http2_socket_snapshot(local_addr, remote_addr)).unwrap_or_default()),
..Http2BridgeEvent::default()
},
);
push_http2_server_event(
&shared,
server_id,
Http2BridgeEvent {
kind: String::from("serverSession"),
id: server_id,
data: Some(snapshot_json),
extra_number: Some(session_id),
..Http2BridgeEvent::default()
},
);
}
let io: Pin<Box<dyn Http2AsyncIo>> = if let Some(options) = tls.as_ref() {
let acceptor = match build_server_tls_config(options) {
Ok(config) => TlsAcceptor::from(Arc::new(config)),
Err(error) => {
push_http2_server_event(
&shared,
server_id,
Http2BridgeEvent {
kind: String::from("serverStreamError"),
id: session_id,
data: Some(http2_error_payload(error.to_string())),
..Http2BridgeEvent::default()
},
);
remove_http2_session_resources(&shared, session_id);
return;
}
};
match acceptor.accept(stream).await {
Ok(tls_stream) => Box::pin(tls_stream),
Err(error) => {
push_http2_server_event(
&shared,
server_id,
Http2BridgeEvent {
kind: String::from("serverStreamError"),
id: session_id,
data: Some(http2_error_payload(error.to_string())),
..Http2BridgeEvent::default()
},
);
remove_http2_session_resources(&shared, session_id);
return;
}
}
} else {
Box::pin(stream)
};
let mut connection = match server::handshake(io).await {
Ok(connection) => connection,
Err(error) => {
push_http2_server_event(
&shared,
server_id,
Http2BridgeEvent {
kind: String::from("serverStreamError"),
id: session_id,
data: Some(http2_error_payload(error.to_string())),
..Http2BridgeEvent::default()
},
);
remove_http2_session_resources(&shared, session_id);
return;
}
};
let streams: Arc<Mutex<BTreeMap<u64, ServerHttp2StreamState>>> =
Arc::new(Mutex::new(BTreeMap::new()));
loop {
tokio::select! {
incoming = connection.accept() => {
match incoming {
Some(Ok((request, respond))) => {
let headers_json = match serialize_http2_request_headers(&request) {
Ok(headers) => headers,
Err(error) => {
push_http2_server_event(
&shared,
server_id,
Http2BridgeEvent {
kind: String::from("serverStreamError"),
id: server_id,
data: Some(http2_error_payload(error.to_string())),
..Http2BridgeEvent::default()
},
);
continue;
}
};
let stream_id = {
let mut state = shared.lock().expect("http2 shared state");
let stream_id = next_http2_stream_id(&mut state);
state.streams.insert(
stream_id,
ActiveHttp2Stream {
session_id,
paused: Arc::new(AtomicBool::new(false)),
},
);
stream_id
};
streams.lock().expect("http2 server streams").insert(
stream_id,
ServerHttp2StreamState {
send_response: Some(ServerHttp2Responder::Regular(respond)),
send_stream: None,
},
);
let snapshot_json = snapshot
.lock()
.ok()
.and_then(|snapshot| http2_snapshot_json(&snapshot.clone()).ok());
push_http2_server_event(
&shared,
server_id,
Http2BridgeEvent {
kind: String::from("serverStream"),
id: server_id,
data: Some(stream_id.to_string()),
extra: snapshot_json,
extra_number: Some(session_id),
extra_headers: Some(headers_json),
flags: Some(0),
},
);
let shared_clone = Arc::clone(&shared);
tokio::spawn(async move {
let mut body = request.into_body();
while let Some(chunk) = body.data().await {
match chunk {
Ok(bytes) => {
let paused = {
let state = shared_clone.lock().expect("http2 shared state");
state.streams.get(&stream_id).map(|stream| Arc::clone(&stream.paused))
};
if let Some(paused) = paused {
while paused.load(Ordering::SeqCst) {
tokio::time::sleep(HTTP2_POLL_DELAY).await;
}
}
let _ = body.flow_control().release_capacity(bytes.len());
push_http2_server_event(
&shared_clone,
server_id,
Http2BridgeEvent {
kind: String::from("serverStreamData"),
id: stream_id,
data: Some(base64::engine::general_purpose::STANDARD.encode(bytes)),
..Http2BridgeEvent::default()
},
);
}
Err(error) => {
push_http2_server_event(
&shared_clone,
server_id,
Http2BridgeEvent {
kind: String::from("serverStreamError"),
id: stream_id,
data: Some(http2_error_payload(error.to_string())),
..Http2BridgeEvent::default()
},
);
break;
}
}
}
push_http2_server_event(
&shared_clone,
server_id,
Http2BridgeEvent {
kind: String::from("serverStreamEnd"),
id: stream_id,
..Http2BridgeEvent::default()
},
);
});
}
Some(Err(error)) => {
push_http2_server_event(
&shared,
server_id,
Http2BridgeEvent {
kind: String::from("serverStreamError"),
id: server_id,
data: Some(http2_error_payload(error.to_string())),
..Http2BridgeEvent::default()
},
);
break;
}
None => {
push_http2_server_event(
&shared,
server_id,
Http2BridgeEvent {
kind: String::from("sessionClose"),
id: session_id,
..Http2BridgeEvent::default()
},
);
remove_http2_session_resources(&shared, session_id);
break;
}
}
}
Some(command) = command_rx.recv() => {
match command {
Http2SessionCommand::Settings { settings_json, respond_to } => {
let settings = serde_json::from_str::<BTreeMap<String, Value>>(&settings_json)
.unwrap_or_default();
if let Some(initial_window_size) = settings
.get("initialWindowSize")
.and_then(Value::as_u64)
{
let _ = connection.set_initial_window_size(initial_window_size as u32);
}
{
let mut snapshot = snapshot.lock().expect("http2 snapshot lock");
snapshot.local_settings = http2_settings_from_value(&settings);
}
if let Ok(headers_json) = serde_json::to_string(&settings) {
push_http2_session_event(
&shared,
session_id,
Http2BridgeEvent {
kind: String::from("sessionLocalSettings"),
id: session_id,
data: Some(headers_json),
..Http2BridgeEvent::default()
},
);
}
let _ = respond_to.send(Ok(Value::Null));
}
Http2SessionCommand::SetLocalWindowSize { size, respond_to } => {
connection.set_target_window_size(size);
{
let mut snapshot = snapshot.lock().expect("http2 snapshot lock");
snapshot.state.local_window_size = size;
snapshot.state.effective_local_window_size = size;
}
let value = snapshot
.lock()
.ok()
.and_then(|snapshot| http2_snapshot_json(&snapshot.clone()).ok())
.map(Value::String)
.unwrap_or(Value::Null);
let _ = respond_to.send(Ok(value));
}
Http2SessionCommand::Goaway { error_code, last_stream_id, opaque_data, respond_to } => {
connection.abrupt_shutdown(http2_reason(Some(error_code)));
push_http2_session_event(
&shared,
session_id,
Http2BridgeEvent {
kind: String::from("sessionGoaway"),
id: session_id,
data: opaque_data.map(|value| {
base64::engine::general_purpose::STANDARD.encode(value)
}),
extra_number: Some(error_code as u64),
flags: Some(last_stream_id as u64),
..Http2BridgeEvent::default()
},
);
let _ = respond_to.send(Ok(Value::Null));
}
Http2SessionCommand::Close { abrupt, respond_to } => {
if abrupt {
connection.abrupt_shutdown(Reason::NO_ERROR);
} else {
connection.graceful_shutdown();
}
let _ = respond_to.send(Ok(Value::Null));
push_http2_session_event(
&shared,
session_id,
Http2BridgeEvent {
kind: String::from("sessionClose"),
id: session_id,
..Http2BridgeEvent::default()
},
);
remove_http2_session_resources(&shared, session_id);
break;
}
Http2SessionCommand::StreamRespond { stream_id, headers_json, respond_to } => {
let response = match build_http2_response(&headers_json) {
Ok(response) => response,
Err(error) => {
let _ = respond_to.send(Err(error.to_string()));
continue;
}
};
let mut streams = streams.lock().expect("http2 server streams");
let Some(state) = streams.get_mut(&stream_id) else {
let _ = respond_to.send(Err(format!("unknown HTTP/2 server stream {stream_id}")));
continue;
};
let Some(send_response) = state.send_response.as_mut() else {
let _ = respond_to.send(Err(format!("HTTP/2 server stream {stream_id} already responded")));
continue;
};
match match send_response {
ServerHttp2Responder::Regular(send_response) => {
send_response.send_response(response, false)
}
ServerHttp2Responder::Pushed(send_response) => {
send_response.send_response(response, false)
}
} {
Ok(send_stream) => {
state.send_stream = Some(send_stream);
state.send_response = None;
let _ = respond_to.send(Ok(Value::Null));
}
Err(error) => {
let _ = respond_to.send(Err(error.to_string()));
}
}
}
Http2SessionCommand::StreamPush { stream_id, headers_json, respond_to } => {
let request = match build_http2_request(&headers_json) {
Ok(request) => request,
Err(error) => {
let _ = respond_to.send(Err(error.to_string()));
continue;
}
};
let mut streams_guard = streams.lock().expect("http2 server streams");
let Some(state) = streams_guard.get_mut(&stream_id) else {
let _ = respond_to.send(Err(format!("unknown HTTP/2 server stream {stream_id}")));
continue;
};
let Some(send_response) = state.send_response.as_mut() else {
let _ = respond_to.send(Err(format!("HTTP/2 server stream {stream_id} cannot push after responding")));
continue;
};
let ServerHttp2Responder::Regular(send_response) = send_response else {
let _ = respond_to.send(Err(format!("HTTP/2 pushed stream {stream_id} cannot create nested push promises")));
continue;
};
match send_response.push_request(request) {
Ok(pushed) => {
let pushed_stream_id = {
let mut state = shared.lock().expect("http2 shared state");
let pushed_stream_id = next_http2_stream_id(&mut state);
state.streams.insert(
pushed_stream_id,
ActiveHttp2Stream {
session_id,
paused: Arc::new(AtomicBool::new(false)),
},
);
pushed_stream_id
};
streams_guard.insert(
pushed_stream_id,
ServerHttp2StreamState {
send_response: Some(ServerHttp2Responder::Pushed(pushed)),
send_stream: None,
},
);
let _ = respond_to.send(Ok(json!({
"streamId": pushed_stream_id,
"headers": headers_json,
}).to_string().into()));
}
Err(error) => {
let _ = respond_to.send(Err(error.to_string()));
}
}
}
Http2SessionCommand::StreamWrite { stream_id, chunk, end_stream, respond_to } => {
let mut streams = streams.lock().expect("http2 server streams");
let Some(state) = streams.get_mut(&stream_id) else {
let _ = respond_to.send(Err(format!("unknown HTTP/2 server stream {stream_id}")));
continue;
};
let Some(send_stream) = state.send_stream.as_mut() else {
let _ = respond_to.send(Err(format!("HTTP/2 server stream {stream_id} has not sent response headers")));
continue;
};
match send_stream.send_data(Bytes::from(chunk), end_stream) {
Ok(()) => {
if end_stream {
streams.remove(&stream_id);
if let Ok(mut state) = shared.lock() {
state.streams.remove(&stream_id);
}
push_http2_server_event(
&shared,
server_id,
Http2BridgeEvent {
kind: String::from("serverStreamClose"),
id: stream_id,
extra_number: Some(0),
..Http2BridgeEvent::default()
},
);
}
let _ = respond_to.send(Ok(Value::Bool(true)));
}
Err(error) => {
let _ = respond_to.send(Err(error.to_string()));
}
}
}
Http2SessionCommand::StreamClose { stream_id, error_code, respond_to } => {
let mut streams_guard = streams.lock().expect("http2 server streams");
let Some(mut state) = streams_guard.remove(&stream_id) else {
let _ = respond_to.send(Err(format!("unknown HTTP/2 server stream {stream_id}")));
continue;
};
let reason = http2_reason(error_code);
if let Some(send_stream) = state.send_stream.as_mut() {
send_stream.send_reset(reason);
}
if let Some(send_response) = state.send_response.as_mut() {
match send_response {
ServerHttp2Responder::Regular(send_response) => {
send_response.send_reset(reason)
}
ServerHttp2Responder::Pushed(send_response) => {
send_response.send_reset(reason)
}
}
}
if let Ok(mut shared_guard) = shared.lock() {
shared_guard.streams.remove(&stream_id);
}
push_http2_server_event(
&shared,
server_id,
Http2BridgeEvent {
kind: String::from("serverStreamClose"),
id: stream_id,
extra_number: Some(u32::from(reason) as u64),
..Http2BridgeEvent::default()
},
);
let _ = respond_to.send(Ok(Value::Null));
}
Http2SessionCommand::StreamRespondWithFile { stream_id, body, headers_json, options_json, respond_to } => {
let options: JavascriptHttp2FileResponseOptions =
serde_json::from_str(&options_json).unwrap_or_default();
let response = match build_http2_response(&headers_json) {
Ok(response) => response,
Err(error) => {
let _ = respond_to.send(Err(error.to_string()));
continue;
}
};
let offset = usize::try_from(options.offset.unwrap_or_default()).unwrap_or(0);
let body = if offset >= body.len() {
Vec::new()
} else {
let body = &body[offset..];
match options.length {
Some(length) if length >= 0 => {
body[..body.len().min(length as usize)].to_vec()
}
_ => body.to_vec(),
}
};
let mut streams_guard = streams.lock().expect("http2 server streams");
let Some(state) = streams_guard.get_mut(&stream_id) else {
let _ = respond_to.send(Err(format!("unknown HTTP/2 server stream {stream_id}")));
continue;
};
let Some(send_response) = state.send_response.as_mut() else {
let _ = respond_to.send(Err(format!("HTTP/2 server stream {stream_id} already responded")));
continue;
};
match match send_response {
ServerHttp2Responder::Regular(send_response) => {
send_response.send_response(response, body.is_empty())
}
ServerHttp2Responder::Pushed(send_response) => {
send_response.send_response(response, body.is_empty())
}
} {
Ok(mut send_stream) => {
state.send_response = None;
if body.is_empty() {
streams_guard.remove(&stream_id);
if let Ok(mut shared_guard) = shared.lock() {
shared_guard.streams.remove(&stream_id);
}
} else {
if let Err(error) = send_stream.send_data(Bytes::from(body), true) {
let _ = respond_to.send(Err(error.to_string()));
continue;
}
streams_guard.remove(&stream_id);
if let Ok(mut shared_guard) = shared.lock() {
shared_guard.streams.remove(&stream_id);
}
}
push_http2_server_event(
&shared,
server_id,
Http2BridgeEvent {
kind: String::from("serverStreamClose"),
id: stream_id,
extra_number: Some(0),
..Http2BridgeEvent::default()
},
);
let _ = respond_to.send(Ok(Value::Null));
}
Err(error) => {
let _ = respond_to.send(Err(error.to_string()));
}
}
}
Http2SessionCommand::Request { respond_to, .. } => {
let _ = respond_to.send(Err(String::from("HTTP/2 server sessions cannot initiate client requests")));
}
}
}
else => break,
}
}
});
});
}
fn spawn_http2_server_accept_loop(
shared: Arc<Mutex<crate::state::Http2SharedState>>,
server_id: u64,
listener: TcpListener,
) {
thread::spawn(move || {
let listener = listener;
loop {
let closed = shared
.lock()
.ok()
.and_then(|state| {
state
.servers
.get(&server_id)
.map(|server| server.closed.load(Ordering::SeqCst))
})
.unwrap_or(true);
if closed {
break;
}
match listener.accept() {
Ok((stream, _)) => {
let (command_tx, command_rx) = unbounded_channel();
let (guest_local_addr, secure, tls) = {
let state = shared.lock().expect("http2 shared state");
let server = state.servers.get(&server_id).expect("http2 server state");
(server.guest_local_addr, server.secure, server.tls.clone())
};
let (local_addr, remote_addr) = match (stream.local_addr(), stream.peer_addr())
{
(Ok(local_addr), Ok(remote_addr)) => (local_addr, remote_addr),
_ => continue,
};
let session_snapshot = Arc::new(Mutex::new(Http2SessionSnapshot {
encrypted: secure,
alpn_protocol: Some(if secure {
String::from("h2")
} else {
String::from("h2c")
}),
local_settings: BTreeMap::new(),
remote_settings: BTreeMap::new(),
state: http2_runtime_snapshot(),
socket: Http2SocketSnapshot {
local_address: Some(guest_local_addr.ip().to_string()),
local_port: Some(guest_local_addr.port()),
local_family: Some(socket_addr_family(&guest_local_addr).to_string()),
remote_address: Some(remote_addr.ip().to_string()),
remote_port: Some(remote_addr.port()),
remote_family: Some(socket_addr_family(&remote_addr).to_string()),
..http2_socket_snapshot(local_addr, remote_addr)
},
..Http2SessionSnapshot::default()
}));
let session_id = {
let mut state = shared.lock().expect("http2 shared state");
let session_id = next_http2_session_id(&mut state);
state
.sessions
.insert(session_id, ActiveHttp2Session { command_tx });
session_id
};
spawn_http2_server_session(
Arc::clone(&shared),
server_id,
session_id,
stream,
tls,
session_snapshot,
command_rx,
);
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(HTTP2_POLL_DELAY);
}
Err(error) => {
push_http2_server_event(
&shared,
server_id,
Http2BridgeEvent {
kind: String::from("serverStreamError"),
id: server_id,
data: Some(http2_error_payload(error.to_string())),
..Http2BridgeEvent::default()
},
);
thread::sleep(HTTP2_POLL_DELAY);
}
}
}
});
}
fn send_http2_command(
session: &ActiveHttp2Session,
command: impl FnOnce(Sender<Result<Value, String>>) -> Http2SessionCommand,
) -> Result<Value, SidecarError> {
let (respond_to, response_rx) = mpsc::channel();
session.command_tx.send(command(respond_to)).map_err(|_| {
SidecarError::InvalidState(String::from("HTTP/2 session command channel closed"))
})?;
response_rx
.recv_timeout(Duration::from_secs(30))
.map_err(|_| {
SidecarError::Execution(String::from("timed out waiting for HTTP/2 session command"))
})?
.map_err(SidecarError::Execution)
}
fn parse_http2_server_listen_payload(
request: &JavascriptSyncRpcRequest,
) -> Result<JavascriptHttp2ServerListenRequest, SidecarError> {
let payload_json =
javascript_sync_rpc_arg_str(&request.args, 0, "net.http2_server_listen payload")?;
serde_json::from_str(payload_json).map_err(|error| {
SidecarError::InvalidState(format!(
"net.http2_server_listen payload must be valid JSON: {error}"
))
})
}
fn parse_http2_connect_payload(
request: &JavascriptSyncRpcRequest,
) -> Result<JavascriptHttp2SessionConnectRequest, SidecarError> {
let payload_json =
javascript_sync_rpc_arg_str(&request.args, 0, "net.http2_session_connect payload")?;
serde_json::from_str(payload_json).map_err(|error| {
SidecarError::InvalidState(format!(
"net.http2_session_connect payload must be valid JSON: {error}"
))
})
}
fn http2_session_for_id(
process: &ActiveProcess,
session_id: u64,
) -> Result<ActiveHttp2Session, SidecarError> {
let shared = process
.http2
.shared
.lock()
.map_err(|_| SidecarError::InvalidState(String::from("HTTP/2 state lock poisoned")))?;
shared
.sessions
.get(&session_id)
.cloned()
.ok_or_else(|| SidecarError::InvalidState(format!("unknown HTTP/2 session {session_id}")))
}
fn http2_stream_for_id(
process: &ActiveProcess,
stream_id: u64,
) -> Result<ActiveHttp2Stream, SidecarError> {
let shared = process
.http2
.shared
.lock()
.map_err(|_| SidecarError::InvalidState(String::from("HTTP/2 state lock poisoned")))?;
shared
.streams
.get(&stream_id)
.cloned()
.ok_or_else(|| SidecarError::InvalidState(format!("unknown HTTP/2 stream {stream_id}")))
}
fn service_javascript_http2_sync_rpc<B>(
request: JavascriptHttp2SyncRpcServiceRequest<'_, B>,
) -> Result<Value, SidecarError>
where
B: NativeSidecarBridge + Send + 'static,
BridgeError<B>: fmt::Debug + Send + Sync + 'static,
{
let JavascriptHttp2SyncRpcServiceRequest {
bridge,
kernel,
vm_id,
dns,
socket_paths,
process,
sync_request: request,
resource_limits,
network_counts,
} = request;
match request.method.as_str() {
"net.http2_server_listen" => {
check_network_resource_limit(
resource_limits.max_sockets,
network_counts.sockets,
1,
"socket",
)?;
let payload = parse_http2_server_listen_payload(request)?;
let (family, bind_host, guest_host) =
normalize_tcp_listen_host(payload.host.as_deref())?;
let requested_port = payload.port.unwrap_or(0);
bridge.require_network_access(
vm_id,
NetworkOperation::Listen,
format_tcp_resource(bind_host, requested_port),
)?;
let port = allocate_guest_listen_port(
requested_port,
family,
&socket_paths.used_tcp_guest_ports,
socket_paths.listen_policy,
)?;
let mut listener =
ActiveTcpListener::bind(bind_host, guest_host, port, payload.backlog)?;
let guest_local_addr = listener.guest_local_addr();
let closed = Arc::new(AtomicBool::new(false));
{
let mut state = process.http2.shared.lock().map_err(|_| {
SidecarError::InvalidState(String::from("HTTP/2 state lock poisoned"))
})?;
state.servers.insert(
payload.server_id,
ActiveHttp2Server {
actual_local_addr: listener.local_addr(),
guest_local_addr,
secure: payload.secure,
tls: payload.tls.clone().map(|mut tls| {
tls.is_server = payload.secure;
if payload.secure && tls.alpn_protocols.is_none() {
tls.alpn_protocols = Some(vec![String::from("h2")]);
}
tls
}),
closed: Arc::clone(&closed),
},
);
state.server_events.entry(payload.server_id).or_default();
}
spawn_http2_server_accept_loop(
Arc::clone(&process.http2.shared),
payload.server_id,
listener.listener.take().ok_or_else(|| {
SidecarError::InvalidState(String::from(
"HTTP/2 listener missing host TCP socket",
))
})?,
);
javascript_net_json_string(
json!({
"address": {
"address": guest_local_addr.ip().to_string(),
"family": socket_addr_family(&guest_local_addr),
"port": guest_local_addr.port(),
}
}),
"net.http2_server_listen",
)
}
"net.http2_server_poll" => {
let server_id =
javascript_sync_rpc_arg_u64(&request.args, 0, "net.http2_server_poll server id")?;
let wait_ms = javascript_sync_rpc_arg_u64_optional(
&request.args,
1,
"net.http2_server_poll wait ms",
)?
.unwrap_or_default();
match wait_for_http2_event(&process.http2.shared, server_id, true, wait_ms) {
Some(event) => http2_event_value(&event),
None => Ok(Value::Null),
}
}
"net.http2_server_wait" => {
let server_id =
javascript_sync_rpc_arg_u64(&request.args, 0, "net.http2_server_wait server id")?;
dispatch_http2_wait_loop(process, server_id, true)
}
"net.http2_server_close" => {
let server_id =
javascript_sync_rpc_arg_u64(&request.args, 0, "net.http2_server_close server id")?;
let server = {
let mut state = process.http2.shared.lock().map_err(|_| {
SidecarError::InvalidState(String::from("HTTP/2 state lock poisoned"))
})?;
state.servers.remove(&server_id)
}
.ok_or_else(|| {
SidecarError::InvalidState(format!("unknown HTTP/2 server {server_id}"))
})?;
server.closed.store(true, Ordering::SeqCst);
push_http2_server_event(
&process.http2.shared,
server_id,
Http2BridgeEvent {
kind: String::from("serverClose"),
id: server_id,
..Http2BridgeEvent::default()
},
);
Ok(Value::Null)
}
"net.http2_server_respond" => {
let server_id = javascript_sync_rpc_arg_u64(
&request.args,
0,
"net.http2_server_respond server id",
)?;
let request_id = javascript_sync_rpc_arg_u64(
&request.args,
1,
"net.http2_server_respond request id",
)?;
let response_json =
javascript_sync_rpc_arg_str(&request.args, 2, "net.http2_server_respond payload")?;
ensure_vm_fetch_response_within_limit(
response_json,
"net.http2_server_respond",
VM_FETCH_BUFFER_LIMIT_BYTES,
)?;
serde_json::from_str::<Value>(response_json).map_err(|error| {
SidecarError::Execution(format!(
"net.http2_server_respond payload must be valid JSON: {error}"
))
})?;
let Some(pending) = process
.pending_http_requests
.get_mut(&(server_id, request_id))
else {
return Err(SidecarError::InvalidState(format!(
"unknown pending HTTP/2 request {request_id} for server {server_id}"
)));
};
*pending = Some(response_json.to_owned());
Ok(Value::Bool(true))
}
"net.http2_session_connect" => {
check_network_resource_limit(
resource_limits.max_sockets,
network_counts.sockets,
1,
"socket",
)?;
check_network_resource_limit(
resource_limits.max_connections,
network_counts.connections,
1,
"connection",
)?;
let payload = parse_http2_connect_payload(request)?;
let authority = payload.authority.clone().unwrap_or_else(|| {
format!(
"{}://{}:{}",
payload.protocol.as_deref().unwrap_or("http"),
payload.host.as_deref().unwrap_or("localhost"),
payload.port.unwrap_or(80)
)
});
let url = Url::parse(&authority).map_err(|error| {
SidecarError::InvalidState(format!(
"invalid HTTP/2 authority {authority:?}: {error}"
))
})?;
let secure = url.scheme() == "https" || payload.protocol.as_deref() == Some("https:");
let host = payload
.host
.as_deref()
.or_else(|| url.host_str())
.unwrap_or("localhost");
let port = payload.port.or_else(|| url.port()).unwrap_or(80);
bridge.require_network_access(
vm_id,
NetworkOperation::Http,
format_tcp_resource(host, port),
)?;
let resolved = {
let shared = process.http2.shared.lock().map_err(|_| {
SidecarError::InvalidState(String::from("HTTP/2 state lock poisoned"))
})?;
shared
.servers
.values()
.find(|server| {
is_loopback_request_host(host) && server.guest_local_addr.port() == port
})
.map(|server| ResolvedTcpConnectAddr {
actual_addr: server.actual_local_addr,
guest_remote_addr: server.guest_local_addr,
use_kernel_loopback: false,
})
};
let resolved = match resolved {
Some(resolved) => resolved,
None => {
resolve_tcp_connect_addr(bridge, kernel, vm_id, dns, host, port, socket_paths)?
}
};
let (command_tx, command_rx) = unbounded_channel();
let snapshot = Arc::new(Mutex::new(Http2SessionSnapshot {
encrypted: secure,
alpn_protocol: Some(String::from(if secure { "h2" } else { "h2c" })),
local_settings: http2_settings_from_value(&payload.settings),
remote_settings: BTreeMap::new(),
state: http2_runtime_snapshot(),
socket: Http2SocketSnapshot {
encrypted: secure,
remote_address: Some(resolved.guest_remote_addr.ip().to_string()),
remote_port: Some(resolved.guest_remote_addr.port()),
remote_family: Some(
socket_addr_family(&resolved.guest_remote_addr).to_string(),
),
servername: if secure {
payload
.tls
.as_ref()
.and_then(|tls| tls.servername.clone())
.or_else(|| Some(host.to_string()))
} else {
None
},
alpn_protocol: Some(String::from(if secure { "h2" } else { "h2c" })),
..Http2SocketSnapshot::default()
},
..Http2SessionSnapshot::default()
}));
let session_id = {
let mut state = process.http2.shared.lock().map_err(|_| {
SidecarError::InvalidState(String::from("HTTP/2 state lock poisoned"))
})?;
let session_id = next_http2_session_id(&mut state);
state
.sessions
.insert(session_id, ActiveHttp2Session { command_tx });
state.session_events.entry(session_id).or_default();
session_id
};
spawn_http2_client_session(
Arc::clone(&process.http2.shared),
session_id,
resolved.actual_addr,
if secure {
Some(payload.tls.unwrap_or(JavascriptTlsBridgeOptions {
is_server: false,
servername: Some(host.to_string()),
alpn_protocols: Some(vec![String::from("h2")]),
..JavascriptTlsBridgeOptions::default()
}))
} else {
None
},
Arc::clone(&snapshot),
command_rx,
);
let snapshot_json =
http2_snapshot_json(&snapshot.lock().expect("http2 snapshot lock").clone())?;
javascript_net_json_string(
json!({
"sessionId": session_id,
"state": snapshot_json,
}),
"net.http2_session_connect",
)
}
"net.http2_session_request" => {
let session_id = javascript_sync_rpc_arg_u64(
&request.args,
0,
"net.http2_session_request session id",
)?;
let headers_json =
javascript_sync_rpc_arg_str(&request.args, 1, "net.http2_session_request headers")?;
let options_json =
javascript_sync_rpc_arg_str(&request.args, 2, "net.http2_session_request options")?;
let session = http2_session_for_id(process, session_id)?;
send_http2_command(&session, |respond_to| Http2SessionCommand::Request {
headers_json: headers_json.to_owned(),
options_json: options_json.to_owned(),
respond_to,
})
}
"net.http2_session_settings" => {
let session_id = javascript_sync_rpc_arg_u64(
&request.args,
0,
"net.http2_session_settings session id",
)?;
let settings_json = javascript_sync_rpc_arg_str(
&request.args,
1,
"net.http2_session_settings settings",
)?;
let session = http2_session_for_id(process, session_id)?;
send_http2_command(&session, |respond_to| Http2SessionCommand::Settings {
settings_json: settings_json.to_owned(),
respond_to,
})
}
"net.http2_session_set_local_window_size" => {
let session_id = javascript_sync_rpc_arg_u64(
&request.args,
0,
"net.http2_session_set_local_window_size session id",
)?;
let window_size = javascript_sync_rpc_arg_u64(
&request.args,
1,
"net.http2_session_set_local_window_size window size",
)?;
let session = http2_session_for_id(process, session_id)?;
send_http2_command(&session, |respond_to| {
Http2SessionCommand::SetLocalWindowSize {
size: window_size as u32,
respond_to,
}
})
}
"net.http2_session_goaway" => {
let session_id = javascript_sync_rpc_arg_u64(
&request.args,
0,
"net.http2_session_goaway session id",
)?;
let error_code = javascript_sync_rpc_arg_u64(
&request.args,
1,
"net.http2_session_goaway error code",
)?;
let last_stream_id = javascript_sync_rpc_arg_u64(
&request.args,
2,
"net.http2_session_goaway last stream id",
)?;
let opaque_data = request
.args
.get(3)
.and_then(Value::as_str)
.map(|value| {
base64::engine::general_purpose::STANDARD
.decode(value)
.map_err(|error| {
SidecarError::InvalidState(format!("invalid GOAWAY payload: {error}"))
})
})
.transpose()?;
let session = http2_session_for_id(process, session_id)?;
send_http2_command(&session, |respond_to| Http2SessionCommand::Goaway {
error_code: error_code as u32,
last_stream_id: last_stream_id as u32,
opaque_data,
respond_to,
})
}
"net.http2_session_close" | "net.http2_session_destroy" => {
let session_id = javascript_sync_rpc_arg_u64(
&request.args,
0,
"net.http2_session_close session id",
)?;
let session = http2_session_for_id(process, session_id)?;
send_http2_command(&session, |respond_to| Http2SessionCommand::Close {
abrupt: request.method == "net.http2_session_destroy",
respond_to,
})
}
"net.http2_session_poll" => {
let session_id =
javascript_sync_rpc_arg_u64(&request.args, 0, "net.http2_session_poll session id")?;
let wait_ms = javascript_sync_rpc_arg_u64_optional(
&request.args,
1,
"net.http2_session_poll wait ms",
)?
.unwrap_or_default();
match wait_for_http2_event(&process.http2.shared, session_id, false, wait_ms) {
Some(event) => http2_event_value(&event),
None => Ok(Value::Null),
}
}
"net.http2_session_wait" => {
let session_id =
javascript_sync_rpc_arg_u64(&request.args, 0, "net.http2_session_wait session id")?;
dispatch_http2_wait_loop(process, session_id, false)
}
"net.http2_stream_respond" => {
let stream_id = javascript_sync_rpc_arg_u64(
&request.args,
0,
"net.http2_stream_respond stream id",
)?;
let headers_json =
javascript_sync_rpc_arg_str(&request.args, 1, "net.http2_stream_respond headers")?;
let stream = http2_stream_for_id(process, stream_id)?;
let session = http2_session_for_id(process, stream.session_id)?;
send_http2_command(&session, |respond_to| Http2SessionCommand::StreamRespond {
stream_id,
headers_json: headers_json.to_owned(),
respond_to,
})
}
"net.http2_stream_push_stream" => {
let stream_id = javascript_sync_rpc_arg_u64(
&request.args,
0,
"net.http2_stream_push_stream stream id",
)?;
let headers_json = javascript_sync_rpc_arg_str(
&request.args,
1,
"net.http2_stream_push_stream headers",
)?;
let _options_json = javascript_sync_rpc_arg_str(
&request.args,
2,
"net.http2_stream_push_stream options",
)?;
let stream = http2_stream_for_id(process, stream_id)?;
let session = http2_session_for_id(process, stream.session_id)?;
send_http2_command(&session, |respond_to| Http2SessionCommand::StreamPush {
stream_id,
headers_json: headers_json.to_owned(),
respond_to,
})
}
"net.http2_stream_write" => {
let stream_id =
javascript_sync_rpc_arg_u64(&request.args, 0, "net.http2_stream_write stream id")?;
let chunk =
javascript_sync_rpc_base64_arg(&request.args, 1, "net.http2_stream_write data")?;
let stream = http2_stream_for_id(process, stream_id)?;
let session = http2_session_for_id(process, stream.session_id)?;
send_http2_command(&session, |respond_to| Http2SessionCommand::StreamWrite {
stream_id,
chunk,
end_stream: false,
respond_to,
})
}
"net.http2_stream_end" => {
let stream_id =
javascript_sync_rpc_arg_u64(&request.args, 0, "net.http2_stream_end stream id")?;
let chunk = request
.args
.get(1)
.and_then(Value::as_str)
.map(|value| {
base64::engine::general_purpose::STANDARD
.decode(value)
.map_err(|error| {
SidecarError::InvalidState(format!(
"invalid HTTP/2 stream payload: {error}"
))
})
})
.transpose()?
.unwrap_or_default();
let stream = http2_stream_for_id(process, stream_id)?;
let session = http2_session_for_id(process, stream.session_id)?;
send_http2_command(&session, |respond_to| Http2SessionCommand::StreamWrite {
stream_id,
chunk,
end_stream: true,
respond_to,
})
}
"net.http2_stream_close" => {
let stream_id =
javascript_sync_rpc_arg_u64(&request.args, 0, "net.http2_stream_close stream id")?;
let code = javascript_sync_rpc_arg_u64_optional(
&request.args,
1,
"net.http2_stream_close error code",
)?
.map(|value| value as u32);
let stream = http2_stream_for_id(process, stream_id)?;
let session = http2_session_for_id(process, stream.session_id)?;
send_http2_command(&session, |respond_to| Http2SessionCommand::StreamClose {
stream_id,
error_code: code,
respond_to,
})
}
"net.http2_stream_pause" => {
let stream_id =
javascript_sync_rpc_arg_u64(&request.args, 0, "net.http2_stream_pause stream id")?;
let stream = http2_stream_for_id(process, stream_id)?;
stream.paused.store(true, Ordering::SeqCst);
Ok(Value::Null)
}
"net.http2_stream_resume" => {
let stream_id =
javascript_sync_rpc_arg_u64(&request.args, 0, "net.http2_stream_resume stream id")?;
let stream = http2_stream_for_id(process, stream_id)?;
stream.paused.store(false, Ordering::SeqCst);
Ok(Value::Null)
}
"net.http2_stream_respond_with_file" => {
let stream_id = javascript_sync_rpc_arg_u64(
&request.args,
0,
"net.http2_stream_respond_with_file stream id",
)?;
let path = javascript_sync_rpc_arg_str(
&request.args,
1,
"net.http2_stream_respond_with_file path",
)?;
let headers_json = javascript_sync_rpc_arg_str(
&request.args,
2,
"net.http2_stream_respond_with_file headers",
)?;
let options_json = javascript_sync_rpc_arg_str(
&request.args,
3,
"net.http2_stream_respond_with_file options",
)?;
let stream = http2_stream_for_id(process, stream_id)?;
let session = http2_session_for_id(process, stream.session_id)?;
let guest_path = resolve_http2_file_response_guest_path(process, path);
let body = kernel.read_file(&guest_path).map_err(kernel_error)?;
send_http2_command(&session, |respond_to| {
Http2SessionCommand::StreamRespondWithFile {
stream_id,
body,
headers_json: headers_json.to_owned(),
options_json: options_json.to_owned(),
respond_to,
}
})
}
other => Err(SidecarError::InvalidState(format!(
"unsupported JavaScript HTTP/2 sync RPC method {other}"
))),
}
}
const JAVASCRIPT_NET_POLL_MAX_WAIT: Duration = Duration::from_millis(50);
const EXITED_PROCESS_SNAPSHOT_RETENTION: Duration = Duration::from_secs(2);
fn resolve_http2_file_response_guest_path(process: &ActiveProcess, path: &str) -> String {
if Path::new(path).is_absolute() {
normalize_path(path)
} else {
normalize_path(&format!("{}/{}", process.guest_cwd, path))
}
}
pub(crate) fn clamp_javascript_net_poll_wait(wait_ms: u64) -> Duration {
// WASM net.poll runs on the sidecar's sync-RPC main thread. Guest-controlled waits
// must stay bounded so one VM cannot stall dispose/shutdown or unrelated VM work.
if wait_ms == 0 {
Duration::ZERO
} else {
Duration::from_millis(wait_ms).min(JAVASCRIPT_NET_POLL_MAX_WAIT)
}
}
pub(crate) fn service_javascript_net_sync_rpc<B>(
request: JavascriptNetSyncRpcServiceRequest<'_, B>,
) -> Result<Value, SidecarError>
where
B: NativeSidecarBridge + Send + 'static,
BridgeError<B>: fmt::Debug + Send + Sync + 'static,
{
let JavascriptNetSyncRpcServiceRequest {
bridge,
vm_id,
dns,
socket_paths,
kernel,
process,
sync_request: request,
resource_limits,
network_counts,
} = request;
match request.method.as_str() {
"net.http_listen" => {
check_network_resource_limit(
resource_limits.max_sockets,
network_counts.sockets,
1,
"socket",
)?;
let payload_json =
javascript_sync_rpc_arg_str(&request.args, 0, "net.http_listen payload")?;
let payload: JavascriptHttpListenRequest =
serde_json::from_str(payload_json).map_err(|error| {
SidecarError::InvalidState(format!(
"net.http_listen payload must be valid JSON: {error}"
))
})?;
let (family, bind_host, guest_host) =
normalize_tcp_listen_host(payload.hostname.as_deref())?;
let requested_port = payload.port.unwrap_or(0);
bridge.require_network_access(
vm_id,
NetworkOperation::Listen,
format_tcp_resource(bind_host, requested_port),
)?;
let port = allocate_guest_listen_port(
requested_port,
family,
&socket_paths.used_tcp_guest_ports,
socket_paths.listen_policy,
)?;
let mut listener = ActiveTcpListener::bind(
bind_host,
guest_host,
port,
Some(DEFAULT_JAVASCRIPT_NET_BACKLOG),
)?;
let guest_local_addr = listener.guest_local_addr();
process.http_servers.insert(
payload.server_id,
ActiveHttpServer {
listener: listener.listener.take().ok_or_else(|| {
SidecarError::InvalidState(String::from(
"HTTP listener missing host TCP socket",
))
})?,
guest_local_addr,
next_request_id: 0,
},
);
serde_json::to_string(&json!({
"address": {
"address": guest_local_addr.ip().to_string(),
"family": socket_addr_family(&guest_local_addr),
"port": guest_local_addr.port(),
}
}))
.map(Value::String)
.map_err(|error| {
SidecarError::Execution(format!("ERR_AGENTOS_NODE_SYNC_RPC: {error}"))
})
}
"net.http_close" => {
let server_id =
javascript_sync_rpc_arg_u64(&request.args, 0, "net.http_close server id")?;
let server = process.http_servers.remove(&server_id).ok_or_else(|| {
SidecarError::InvalidState(format!("unknown HTTP server {server_id}"))
})?;
drop(server.listener);
process
.pending_http_requests
.retain(|(pending_server_id, _), _| *pending_server_id != server_id);
Ok(Value::Null)
}
"net.http_wait" => {
let server_id =
javascript_sync_rpc_arg_u64(&request.args, 0, "net.http_wait server id")?;
dispatch_http_wait_loop(process, server_id)
}
"net.http_respond" => {
let server_id =
javascript_sync_rpc_arg_u64(&request.args, 0, "net.http_respond server id")?;
let request_id =
javascript_sync_rpc_arg_u64(&request.args, 1, "net.http_respond request id")?;
let response_json =
javascript_sync_rpc_arg_str(&request.args, 2, "net.http_respond payload")?;
ensure_vm_fetch_response_within_limit(
response_json,
"net.http_respond",
VM_FETCH_BUFFER_LIMIT_BYTES,
)?;
serde_json::from_str::<Value>(response_json).map_err(|error| {
SidecarError::Execution(format!(
"net.http_respond payload must be valid JSON: {error}"
))
})?;
let Some(pending) = process
.pending_http_requests
.get_mut(&(server_id, request_id))
else {
return Err(SidecarError::InvalidState(format!(
"unknown pending HTTP request {request_id} for server {server_id}"
)));
};
*pending = Some(response_json.to_owned());
Ok(Value::Null)
}
"net.reserve_tcp_port" => {
let payload = request
.args
.first()
.cloned()
.ok_or_else(|| {
SidecarError::InvalidState(String::from(
"net.reserve_tcp_port requires a request payload",
))
})
.and_then(|value| {
serde_json::from_value::<JavascriptNetReserveTcpPortRequest>(value).map_err(
|error| {
SidecarError::InvalidState(format!(
"invalid net.reserve_tcp_port payload: {error}"
))
},
)
})?;
let (family, _bind_host, guest_host) =
normalize_tcp_listen_host(payload.host.as_deref())?;
let requested_port = payload.port.unwrap_or(0);
let port = allocate_guest_listen_port(
requested_port,
family,
&socket_paths.used_tcp_guest_ports,
socket_paths.listen_policy,
)?;
let reservation_id = process.allocate_tcp_port_reservation_id();
process
.tcp_port_reservations
.insert(reservation_id.clone(), (family, port));
Ok(json!({
"reservationId": reservation_id,
"localAddress": guest_host,
"localPort": port,
"family": match family {
JavascriptSocketFamily::Ipv4 => "IPv4",
JavascriptSocketFamily::Ipv6 => "IPv6",
},
}))
}
"net.release_tcp_port" => {
let reservation_id =
javascript_sync_rpc_arg_str(&request.args, 0, "net.release_tcp_port reservation")?;
process.tcp_port_reservations.remove(reservation_id);
Ok(Value::Null)
}
"net.connect" => {
check_network_resource_limit(
resource_limits.max_sockets,
network_counts.sockets,
1,
"socket",
)?;
check_network_resource_limit(
resource_limits.max_connections,
network_counts.connections,
1,
"connection",
)?;
let payload = request
.args
.first()
.cloned()
.ok_or_else(|| {
SidecarError::InvalidState(String::from(
"net.connect requires a request payload",
))
})
.and_then(|value| {
serde_json::from_value::<JavascriptNetConnectRequest>(value).map_err(|error| {
SidecarError::InvalidState(format!("invalid net.connect payload: {error}"))
})
})?;
if let Some(path) = payload.path.as_deref() {
let guest_path = normalize_path(path);
let host_path = resolve_guest_socket_host_path(socket_paths, &guest_path);
let socket = ActiveUnixSocket::connect(&host_path, &guest_path)?;
let socket_id = process.allocate_unix_socket_id();
process.unix_sockets.insert(socket_id.clone(), socket);
Ok(json!({
"socketId": socket_id,
"remotePath": guest_path,
}))
} else {
let port = payload.port.ok_or_else(|| {
SidecarError::InvalidState(String::from(
"net.connect requires either a path or port",
))
})?;
let host = payload.host.as_deref().unwrap_or("localhost");
let local_reservation = payload.local_reservation.as_deref().and_then(|id| {
process
.tcp_port_reservations
.remove(id)
.map(|reservation| (id.to_owned(), reservation))
});
bridge.require_network_access(
vm_id,
NetworkOperation::Http,
format_tcp_resource(host, port),
)?;
if is_loopback_socket_host(host) {
let families = [JavascriptSocketFamily::Ipv4, JavascriptSocketFamily::Ipv6];
if let Some((family, target)) = families.iter().find_map(|family| {
socket_paths
.http_loopback_target(*family, port)
.map(|target| (*family, target))
}) {
if let Some((reservation_id, reservation)) = local_reservation {
process
.tcp_port_reservations
.insert(reservation_id, reservation);
}
let remote_address = match family {
JavascriptSocketFamily::Ipv4 => "127.0.0.1",
JavascriptSocketFamily::Ipv6 => "::1",
};
return Ok(json!({
"loopbackHttpTarget": {
"processId": target.process_id.clone(),
"serverId": target.server_id,
"host": remote_address,
"port": port,
},
"localAddress": match family {
JavascriptSocketFamily::Ipv4 => "127.0.0.1",
JavascriptSocketFamily::Ipv6 => "::1",
},
"localPort": payload.local_port.unwrap_or(0),
"remoteAddress": remote_address,
"remotePort": port,
"remoteFamily": match family {
JavascriptSocketFamily::Ipv4 => "IPv4",
JavascriptSocketFamily::Ipv6 => "IPv6",
},
}));
}
}
let connect_result = ActiveTcpSocket::connect(ActiveTcpConnectRequest {
bridge,
kernel,
kernel_pid: process.kernel_pid,
vm_id,
dns,
host,
port,
local_address: payload.local_address.as_deref(),
local_port: payload.local_port,
local_reservation: local_reservation
.as_ref()
.map(|(_, reservation)| *reservation),
context: socket_paths,
});
if let Err(error) = connect_result {
if let Some((reservation_id, reservation)) = local_reservation {
process
.tcp_port_reservations
.insert(reservation_id, reservation);
}
return Err(error);
}
let socket = connect_result?;
let socket_id = process.allocate_tcp_socket_id();
let local_addr = socket.guest_local_addr;
let remote_addr = socket.guest_remote_addr;
process.tcp_sockets.insert(socket_id.clone(), socket);
Ok(json!({
"socketId": socket_id,
"localAddress": local_addr.ip().to_string(),
"localPort": local_addr.port(),
"remoteAddress": remote_addr.ip().to_string(),
"remotePort": remote_addr.port(),
"remoteFamily": socket_addr_family(&remote_addr),
}))
}
}
"net.listen" => {
check_network_resource_limit(
resource_limits.max_sockets,
network_counts.sockets,
1,
"socket",
)?;
let payload = request
.args
.first()
.cloned()
.ok_or_else(|| {
SidecarError::InvalidState(String::from(
"net.listen requires a request payload",
))
})
.and_then(|value| match value {
Value::String(json) => {
serde_json::from_str::<JavascriptNetListenRequest>(&json).map_err(|error| {
SidecarError::InvalidState(format!(
"invalid net.listen payload: {error}"
))
})
}
other => serde_json::from_value::<JavascriptNetListenRequest>(other).map_err(
|error| {
SidecarError::InvalidState(format!(
"invalid net.listen payload: {error}"
))
},
),
})?;
if let Some(path) = payload.path.as_deref() {
let guest_path = normalize_path(path);
if kernel.exists(&guest_path).map_err(kernel_error)? {
return Err(sidecar_net_error(std::io::Error::from_raw_os_error(
libc::EADDRINUSE,
)));
}
let host_path = resolve_guest_socket_host_path(socket_paths, &guest_path);
let on_host_mount =
host_mount_path_for_guest_path_from_mounts(&socket_paths.mounts, &guest_path)
.is_some();
let listener = ActiveUnixListener::bind(&host_path, &guest_path, payload.backlog)?;
if !on_host_mount {
ensure_kernel_parent_directories(kernel, &guest_path)?;
kernel
.write_file(&guest_path, Vec::new())
.map_err(kernel_error)?;
}
let listener_id = process.allocate_unix_listener_id();
process.unix_listeners.insert(listener_id.clone(), listener);
Ok(json!({
"serverId": listener_id,
"path": guest_path,
}))
} else {
let (family, bind_host, guest_host) =
normalize_tcp_listen_host(payload.host.as_deref())?;
let requested_port = payload.port.unwrap_or(0);
bridge.require_network_access(
vm_id,
NetworkOperation::Listen,
format_tcp_resource(bind_host, requested_port),
)?;
let local_reservation = payload.local_reservation.as_deref().and_then(|id| {
process
.tcp_port_reservations
.remove(id)
.map(|reservation| (id.to_owned(), reservation))
});
let port = if requested_port != 0
&& local_reservation
.as_ref()
.map(|(_, reservation)| *reservation)
== Some((family, requested_port))
{
requested_port
} else {
allocate_guest_listen_port(
requested_port,
family,
&socket_paths.used_tcp_guest_ports,
socket_paths.listen_policy,
)?
};
let listener_result = ActiveTcpListener::bind_kernel(
kernel,
process.kernel_pid,
guest_host,
port,
payload.backlog,
);
if let Err(error) = listener_result {
if let Some((reservation_id, reservation)) = local_reservation {
process
.tcp_port_reservations
.insert(reservation_id, reservation);
}
return Err(error);
}
let listener = listener_result?;
let listener_id = process.allocate_tcp_listener_id();
let local_addr = listener.guest_local_addr();
process.tcp_listeners.insert(listener_id.clone(), listener);
Ok(json!({
"serverId": listener_id,
"localAddress": local_addr.ip().to_string(),
"localPort": local_addr.port(),
"family": socket_addr_family(&local_addr),
}))
}
}
"net.poll" => {
let socket_id = javascript_sync_rpc_arg_str(&request.args, 0, "net.poll socket id")?;
let wait_ms =
javascript_sync_rpc_arg_u64_optional(&request.args, 1, "net.poll wait ms")?
.unwrap_or_default();
let wait = clamp_javascript_net_poll_wait(wait_ms);
let event = if let Some(socket) = process.tcp_sockets.get_mut(socket_id) {
socket.poll(kernel, process.kernel_pid, wait)?
} else if let Some(socket) = process.unix_sockets.get_mut(socket_id) {
socket.poll(wait)?
} else {
return Err(SidecarError::InvalidState(format!(
"unknown net socket {socket_id}"
)));
};
match event {
Some(JavascriptTcpSocketEvent::Data(chunk)) => Ok(json!({
"type": "data",
"data": javascript_sync_rpc_bytes_value(&chunk),
})),
Some(JavascriptTcpSocketEvent::End) => Ok(json!({
"type": "end",
})),
Some(JavascriptTcpSocketEvent::Error { code, message }) => Ok(json!({
"type": "error",
"code": code,
"message": message,
})),
Some(JavascriptTcpSocketEvent::Close { had_error }) => {
if let Some(socket) = process.tcp_sockets.remove(socket_id) {
if let Some(listener_id) = socket.listener_id.as_deref() {
if let Some(listener) = process.tcp_listeners.get_mut(listener_id) {
listener.release_connection(socket_id);
}
}
} else if let Some(socket) = process.unix_sockets.remove(socket_id) {
if let Some(listener_id) = socket.listener_id.as_deref() {
if let Some(listener) = process.unix_listeners.get_mut(listener_id) {
listener.release_connection(socket_id);
}
}
}
Ok(json!({
"type": "close",
"hadError": had_error,
}))
}
None => Ok(Value::Null),
}
}
"net.socket_wait_connect" => {
let socket_id =
javascript_sync_rpc_arg_str(&request.args, 0, "net.socket_wait_connect socket id")?;
if let Some(socket) = process.tcp_sockets.get(socket_id) {
javascript_net_json_string(socket.socket_info(), "net.socket_wait_connect")
} else {
let socket = process.unix_sockets.get(socket_id).ok_or_else(|| {
SidecarError::InvalidState(format!("unknown net socket {socket_id}"))
})?;
javascript_net_json_string(socket.socket_info(), "net.socket_wait_connect")
}
}
"net.socket_read" => {
let socket_id =
javascript_sync_rpc_arg_str(&request.args, 0, "net.socket_read socket id")?;
if let Some(socket) = process.tcp_sockets.get_mut(socket_id) {
javascript_net_read_value(socket.poll(
kernel,
process.kernel_pid,
Duration::ZERO,
)?)
} else {
let socket = process.unix_sockets.get_mut(socket_id).ok_or_else(|| {
SidecarError::InvalidState(format!("unknown net socket {socket_id}"))
})?;
javascript_net_read_value(socket.poll(Duration::ZERO)?)
}
}
"net.socket_set_no_delay" => {
let socket_id =
javascript_sync_rpc_arg_str(&request.args, 0, "net.socket_set_no_delay socket id")?;
let enable =
javascript_sync_rpc_arg_bool(&request.args, 1, "net.socket_set_no_delay enabled")?;
if let Some(socket) = process.tcp_sockets.get_mut(socket_id) {
socket.set_no_delay(enable)?;
} else if !process.unix_sockets.contains_key(socket_id) {
return Err(SidecarError::InvalidState(format!(
"unknown net socket {socket_id}"
)));
}
Ok(Value::Null)
}
"net.socket_set_keep_alive" => {
let socket_id = javascript_sync_rpc_arg_str(
&request.args,
0,
"net.socket_set_keep_alive socket id",
)?;
let enable = javascript_sync_rpc_arg_bool(
&request.args,
1,
"net.socket_set_keep_alive enabled",
)?;
let initial_delay_secs = javascript_sync_rpc_arg_u64_optional(
&request.args,
2,
"net.socket_set_keep_alive initial delay seconds",
)?;
if let Some(socket) = process.tcp_sockets.get_mut(socket_id) {
socket.set_keep_alive(enable, initial_delay_secs)?;
} else if !process.unix_sockets.contains_key(socket_id) {
return Err(SidecarError::InvalidState(format!(
"unknown net socket {socket_id}"
)));
}
Ok(Value::Null)
}
"net.socket_upgrade_tls" => {
let socket_id =
javascript_sync_rpc_arg_str(&request.args, 0, "net.socket_upgrade_tls socket id")?;
let options_json =
javascript_sync_rpc_arg_str(&request.args, 1, "net.socket_upgrade_tls options")?;
let options: JavascriptTlsBridgeOptions =
serde_json::from_str(options_json).map_err(|error| {
SidecarError::InvalidState(format!(
"net.socket_upgrade_tls options must be valid JSON: {error}"
))
})?;
let socket = process.tcp_sockets.get(socket_id).ok_or_else(|| {
SidecarError::InvalidState(format!(
"unknown TCP socket {socket_id} for TLS upgrade"
))
})?;
socket.upgrade_tls(vm_id, kernel, options)?;
Ok(Value::Null)
}
"net.socket_get_tls_client_hello" => {
let socket_id = javascript_sync_rpc_arg_str(
&request.args,
0,
"net.socket_get_tls_client_hello socket id",
)?;
let socket = process.tcp_sockets.get(socket_id).ok_or_else(|| {
SidecarError::InvalidState(format!(
"unknown TCP socket {socket_id} for TLS client hello query"
))
})?;
socket.tls_client_hello_json(vm_id, kernel)
}
"net.socket_tls_query" => {
let socket_id =
javascript_sync_rpc_arg_str(&request.args, 0, "net.socket_tls_query socket id")?;
let query =
javascript_sync_rpc_arg_str(&request.args, 1, "net.socket_tls_query query")?;
let detailed = request
.args
.get(2)
.and_then(Value::as_bool)
.unwrap_or(false);
let socket = process.tcp_sockets.get(socket_id).ok_or_else(|| {
SidecarError::InvalidState(format!("unknown TCP socket {socket_id} for TLS query"))
})?;
socket.tls_query(query, detailed)
}
"net.server_poll" => {
let listener_id =
javascript_sync_rpc_arg_str(&request.args, 0, "net.server_poll listener id")?;
let wait_ms =
javascript_sync_rpc_arg_u64_optional(&request.args, 1, "net.server_poll wait ms")?
.unwrap_or_default();
let tcp_event = if let Some(listener) = process.tcp_listeners.get_mut(listener_id) {
Some(listener.poll(kernel, process.kernel_pid, Duration::from_millis(wait_ms))?)
} else {
None
};
if let Some(event) = tcp_event {
return match event {
Some(JavascriptTcpListenerEvent::Connection(pending)) => {
let PendingTcpSocket {
stream,
kernel_socket_id,
preallocated,
guest_local_addr,
guest_remote_addr,
} = pending;
if !preallocated {
if let Err(error) = check_network_resource_limit(
resource_limits.max_sockets,
network_counts.sockets,
1,
"socket",
)
.and_then(|()| {
check_network_resource_limit(
resource_limits.max_connections,
network_counts.connections,
1,
"connection",
)
}) {
if let Some(stream) = stream {
let _ = stream.shutdown(Shutdown::Both);
}
return Ok(json!({
"type": "error",
"code": "EAGAIN",
"message": error.to_string(),
}));
}
}
let socket = if let Some(stream) = stream {
ActiveTcpSocket::from_stream(
stream,
Some(listener_id.to_string()),
guest_local_addr,
guest_remote_addr,
)?
} else {
ActiveTcpSocket::from_kernel(
kernel_socket_id.ok_or_else(|| {
SidecarError::InvalidState(String::from(
"kernel TCP accept missing socket id",
))
})?,
Some(listener_id.to_string()),
guest_local_addr,
guest_remote_addr,
)
};
let socket_id = process.allocate_tcp_socket_id();
if let Some(listener) = process.tcp_listeners.get_mut(listener_id) {
listener.register_connection(&socket_id);
}
process.tcp_sockets.insert(socket_id.clone(), socket);
Ok(json!({
"type": "connection",
"socketId": socket_id,
"localAddress": guest_local_addr.ip().to_string(),
"localPort": guest_local_addr.port(),
"remoteAddress": guest_remote_addr.ip().to_string(),
"remotePort": guest_remote_addr.port(),
"remoteFamily": socket_addr_family(&guest_remote_addr),
}))
}
Some(JavascriptTcpListenerEvent::Error { code, message }) => Ok(json!({
"type": "error",
"code": code,
"message": message,
})),
None => Ok(Value::Null),
};
}
let event = {
let listener = process.unix_listeners.get_mut(listener_id).ok_or_else(|| {
SidecarError::InvalidState(format!("unknown net listener {listener_id}"))
})?;
listener.poll(Duration::from_millis(wait_ms))?
};
match event {
Some(JavascriptUnixListenerEvent::Connection(pending)) => {
if let Err(error) = check_network_resource_limit(
resource_limits.max_sockets,
network_counts.sockets,
1,
"socket",
)
.and_then(|()| {
check_network_resource_limit(
resource_limits.max_connections,
network_counts.connections,
1,
"connection",
)
}) {
let _ = pending.stream.shutdown(Shutdown::Both);
return Ok(json!({
"type": "error",
"code": "EAGAIN",
"message": error.to_string(),
}));
}
let socket = ActiveUnixSocket::from_stream(
pending.stream,
Some(listener_id.to_string()),
pending.local_path.clone(),
pending.remote_path.clone(),
)?;
let socket_id = process.allocate_unix_socket_id();
if let Some(listener) = process.unix_listeners.get_mut(listener_id) {
listener.register_connection(&socket_id);
}
process.unix_sockets.insert(socket_id.clone(), socket);
Ok(json!({
"type": "connection",
"socketId": socket_id,
"localPath": pending.local_path,
"remotePath": pending.remote_path,
}))
}
Some(JavascriptUnixListenerEvent::Error { code, message }) => Ok(json!({
"type": "error",
"code": code,
"message": message,
})),
None => Ok(Value::Null),
}
}
"net.server_accept" => {
let listener_id =
javascript_sync_rpc_arg_str(&request.args, 0, "net.server_accept listener id")?;
if let Some(listener) = process.tcp_listeners.get_mut(listener_id) {
return match listener.poll(kernel, process.kernel_pid, Duration::ZERO)? {
Some(JavascriptTcpListenerEvent::Connection(pending)) => {
let PendingTcpSocket {
stream,
kernel_socket_id,
preallocated,
guest_local_addr,
guest_remote_addr,
} = pending;
if !preallocated {
check_network_resource_limit(
resource_limits.max_sockets,
network_counts.sockets,
1,
"socket",
)?;
check_network_resource_limit(
resource_limits.max_connections,
network_counts.connections,
1,
"connection",
)?;
}
let info = json!({
"localAddress": guest_local_addr.ip().to_string(),
"localPort": guest_local_addr.port(),
"localFamily": socket_addr_family(&guest_local_addr),
"remoteAddress": guest_remote_addr.ip().to_string(),
"remotePort": guest_remote_addr.port(),
"remoteFamily": socket_addr_family(&guest_remote_addr),
});
let socket = if let Some(stream) = stream {
ActiveTcpSocket::from_stream(
stream,
Some(listener_id.to_string()),
guest_local_addr,
guest_remote_addr,
)?
} else {
ActiveTcpSocket::from_kernel(
kernel_socket_id.ok_or_else(|| {
SidecarError::InvalidState(String::from(
"kernel TCP accept missing socket id",
))
})?,
Some(listener_id.to_string()),
guest_local_addr,
guest_remote_addr,
)
};
let socket_id = process.allocate_tcp_socket_id();
if let Some(listener) = process.tcp_listeners.get_mut(listener_id) {
listener.register_connection(&socket_id);
}
process.tcp_sockets.insert(socket_id.clone(), socket);
javascript_net_json_string(
json!({
"socketId": socket_id,
"info": info,
}),
"net.server_accept",
)
}
Some(JavascriptTcpListenerEvent::Error { code, message }) => {
let detail = code.unwrap_or_else(|| String::from("server accept"));
Err(SidecarError::Execution(format!("{detail}: {message}")))
}
None => Ok(javascript_net_timeout_value()),
};
}
let listener = process.unix_listeners.get_mut(listener_id).ok_or_else(|| {
SidecarError::InvalidState(format!("unknown net listener {listener_id}"))
})?;
match listener.poll(Duration::ZERO)? {
Some(JavascriptUnixListenerEvent::Connection(pending)) => {
check_network_resource_limit(
resource_limits.max_sockets,
network_counts.sockets,
1,
"socket",
)?;
check_network_resource_limit(
resource_limits.max_connections,
network_counts.connections,
1,
"connection",
)?;
let info = json!({
"localPath": pending.local_path.clone(),
"remotePath": pending.remote_path.clone(),
});
let socket = ActiveUnixSocket::from_stream(
pending.stream,
Some(listener_id.to_string()),
pending.local_path,
pending.remote_path,
)?;
let socket_id = process.allocate_unix_socket_id();
if let Some(listener) = process.unix_listeners.get_mut(listener_id) {
listener.register_connection(&socket_id);
}
process.unix_sockets.insert(socket_id.clone(), socket);
javascript_net_json_string(
json!({
"socketId": socket_id,
"info": info,
}),
"net.server_accept",
)
}
Some(JavascriptUnixListenerEvent::Error { code, message }) => {
let detail = code.unwrap_or_else(|| String::from("server accept"));
Err(SidecarError::Execution(format!("{detail}: {message}")))
}
None => Ok(javascript_net_timeout_value()),
}
}
"net.server_connections" => {
let listener_id = javascript_sync_rpc_arg_str(
&request.args,
0,
"net.server_connections listener id",
)?;
if let Some(listener) = process.tcp_listeners.get(listener_id) {
Ok(json!(listener.active_connection_count()))
} else {
let listener = process.unix_listeners.get(listener_id).ok_or_else(|| {
SidecarError::InvalidState(format!("unknown net listener {listener_id}"))
})?;
Ok(json!(listener.active_connection_count()))
}
}
"net.upgrade_socket_write" => {
let socket_id = javascript_sync_rpc_arg_str(
&request.args,
0,
"net.upgrade_socket_write socket id",
)?;
let chunk =
javascript_sync_rpc_base64_arg(&request.args, 1, "net.upgrade_socket_write chunk")?;
let socket = process.tcp_sockets.get(socket_id).ok_or_else(|| {
SidecarError::InvalidState(format!("unknown TCP socket {socket_id}"))
})?;
socket
.write_all(kernel, process.kernel_pid, &chunk)
.map(|written| json!(written))
}
"net.upgrade_socket_end" => {
let socket_id =
javascript_sync_rpc_arg_str(&request.args, 0, "net.upgrade_socket_end socket id")?;
let socket = process.tcp_sockets.get(socket_id).ok_or_else(|| {
SidecarError::InvalidState(format!("unknown TCP socket {socket_id}"))
})?;
socket.shutdown_write(kernel, process.kernel_pid)?;
Ok(Value::Null)
}
"net.upgrade_socket_destroy" => {
let socket_id = javascript_sync_rpc_arg_str(
&request.args,
0,
"net.upgrade_socket_destroy socket id",
)?;
let socket = process.tcp_sockets.remove(socket_id).ok_or_else(|| {
SidecarError::InvalidState(format!("unknown TCP socket {socket_id}"))
})?;
if let Some(listener_id) = socket.listener_id.as_deref() {
if let Some(listener) = process.tcp_listeners.get_mut(listener_id) {
listener.release_connection(socket_id);
}
}
let _ = socket.close(kernel, process.kernel_pid);
Ok(Value::Null)
}
"net.write" => {
let socket_id = javascript_sync_rpc_arg_str(&request.args, 0, "net.write socket id")?;
let chunk = javascript_sync_rpc_bytes_arg(&request.args, 1, "net.write chunk")?;
if let Some(socket) = process.tcp_sockets.get(socket_id) {
socket
.write_all(kernel, process.kernel_pid, &chunk)
.map(|written| json!(written))
} else {
let socket = process.unix_sockets.get(socket_id).ok_or_else(|| {
SidecarError::InvalidState(format!("unknown net socket {socket_id}"))
})?;
socket.write_all(&chunk).map(|written| json!(written))
}
}
"net.shutdown" => {
let socket_id =
javascript_sync_rpc_arg_str(&request.args, 0, "net.shutdown socket id")?;
if let Some(socket) = process.tcp_sockets.get(socket_id) {
socket.shutdown_write(kernel, process.kernel_pid)?;
} else {
let socket = process.unix_sockets.get(socket_id).ok_or_else(|| {
SidecarError::InvalidState(format!("unknown net socket {socket_id}"))
})?;
socket.shutdown_write()?;
}
Ok(Value::Null)
}
"net.destroy" => {
let socket_id = javascript_sync_rpc_arg_str(&request.args, 0, "net.destroy socket id")?;
if let Some(socket) = process.tcp_sockets.remove(socket_id) {
if let Some(listener_id) = socket.listener_id.as_deref() {
if let Some(listener) = process.tcp_listeners.get_mut(listener_id) {
listener.release_connection(socket_id);
}
}
let _ = socket.close(kernel, process.kernel_pid);
Ok(Value::Null)
} else {
let socket = process.unix_sockets.remove(socket_id).ok_or_else(|| {
SidecarError::InvalidState(format!("unknown net socket {socket_id}"))
})?;
if let Some(listener_id) = socket.listener_id.as_deref() {
if let Some(listener) = process.unix_listeners.get_mut(listener_id) {
listener.release_connection(socket_id);
}
}
let _ = socket.close();
Ok(Value::Null)
}
}
"net.server_close" => {
let listener_id =
javascript_sync_rpc_arg_str(&request.args, 0, "net.server_close listener id")?;
if let Some(listener) = process.tcp_listeners.remove(listener_id) {
listener.close(kernel, process.kernel_pid)?;
Ok(Value::Null)
} else {
let listener = process.unix_listeners.remove(listener_id).ok_or_else(|| {
SidecarError::InvalidState(format!("unknown net listener {listener_id}"))
})?;
listener.close()?;
Ok(Value::Null)
}
}
"tls.get_ciphers" => javascript_net_json_string(
Value::Array(
tls_provider()
.cipher_suites
.iter()
.filter_map(|suite| {
suite
.suite()
.as_str()
.map(|value| Value::String(value.to_owned()))
})
.collect(),
),
"tls.get_ciphers",
),
_ => Err(SidecarError::InvalidState(format!(
"unsupported JavaScript net sync RPC method {}",
request.method
))),
}
}
fn signal_name_for_stream_event(signal: i32) -> Option<&'static str> {
match signal {
libc::SIGHUP => Some("SIGHUP"),
libc::SIGINT => Some("SIGINT"),
libc::SIGUSR1 => Some("SIGUSR1"),
libc::SIGALRM => Some("SIGALRM"),
libc::SIGCONT => Some("SIGCONT"),
libc::SIGTERM => Some("SIGTERM"),
libc::SIGCHLD => Some("SIGCHLD"),
libc::SIGWINCH => Some("SIGWINCH"),
_ => None,
}
}
pub(crate) fn canonical_signal_name(signal: i32) -> Option<&'static str> {
match signal {
1 => Some("SIGHUP"),
2 => Some("SIGINT"),
3 => Some("SIGQUIT"),
4 => Some("SIGILL"),
5 => Some("SIGTRAP"),
6 => Some("SIGABRT"),
7 => Some("SIGBUS"),
8 => Some("SIGFPE"),
9 => Some("SIGKILL"),
10 => Some("SIGUSR1"),
11 => Some("SIGSEGV"),
12 => Some("SIGUSR2"),
13 => Some("SIGPIPE"),
14 => Some("SIGALRM"),
15 => Some("SIGTERM"),
17 => Some("SIGCHLD"),
18 => Some("SIGCONT"),
19 => Some("SIGSTOP"),
20 => Some("SIGTSTP"),
21 => Some("SIGTTIN"),
22 => Some("SIGTTOU"),
23 => Some("SIGURG"),
24 => Some("SIGXCPU"),
25 => Some("SIGXFSZ"),
26 => Some("SIGVTALRM"),
27 => Some("SIGPROF"),
28 => Some("SIGWINCH"),
29 => Some("SIGIO"),
30 => Some("SIGPWR"),
31 => Some("SIGSYS"),
_ => None,
}
}
fn dispatch_v8_process_signal(process: &ActiveProcess, signal: i32) -> Result<bool, SidecarError> {
let Some(signal_name) = signal_name_for_stream_event(signal) else {
return Ok(false);
};
process.execution.send_javascript_stream_event(
"signal",
json!({
"signal": signal_name,
"number": signal,
"action": "default",
}),
)?;
Ok(true)
}
fn dispatch_v8_session_signal_async(session: V8SessionHandle, signal: i32) {
let Some(signal_name) = signal_name_for_stream_event(signal).map(str::to_owned) else {
return;
};
thread::spawn(move || {
thread::sleep(Duration::from_millis(1));
let payload = v8_runtime::json_to_cbor_payload(&json!({
"signal": signal_name,
"number": signal,
"action": "default",
}))
.unwrap_or_default();
let _ = session.send_stream_event("signal", payload);
});
}
pub(crate) fn parse_signal(signal: &str) -> Result<i32, SidecarError> {
let trimmed = signal.trim();
if trimmed.is_empty() {
return Err(SidecarError::InvalidState(String::from(
"kill_process requires a non-empty signal",
)));
}
if let Ok(value) = trimmed.parse::<i32>() {
return match value {
0..=31 => Ok(value),
_ => Err(SidecarError::InvalidState(format!(
"unsupported kill_process signal {signal}"
))),
};
}
let upper = trimmed.to_ascii_uppercase();
let normalized = upper.strip_prefix("SIG").unwrap_or(&upper);
signal_number_from_name(normalized).ok_or_else(|| {
SidecarError::InvalidState(format!("unsupported kill_process signal {signal}"))
})
}
fn signal_number_from_name(signal: &str) -> Option<i32> {
match signal {
"0" => Some(0),
"HUP" => Some(1),
"INT" => Some(2),
"QUIT" => Some(3),
"ILL" => Some(4),
"TRAP" => Some(5),
"ABRT" | "IOT" => Some(6),
"BUS" => Some(7),
"FPE" => Some(8),
"KILL" => Some(9),
"USR1" => Some(10),
"SEGV" => Some(11),
"USR2" => Some(12),
"PIPE" => Some(13),
"ALRM" => Some(14),
"TERM" => Some(15),
"STKFLT" => Some(16),
"CHLD" => Some(17),
"CONT" => Some(18),
"STOP" => Some(19),
"TSTP" => Some(20),
"TTIN" => Some(21),
"TTOU" => Some(22),
"URG" => Some(23),
"XCPU" => Some(24),
"XFSZ" => Some(25),
"VTALRM" => Some(26),
"PROF" => Some(27),
"WINCH" => Some(28),
"IO" | "POLL" => Some(29),
"PWR" => Some(30),
"SYS" => Some(31),
_ => None,
}
}
pub(crate) fn runtime_child_is_alive(child_pid: u32) -> Result<bool, SidecarError> {
Ok(runtime_child_exit_status(child_pid)?.is_none())
}
#[cfg(not(target_os = "macos"))]
fn runtime_child_exit_status(child_pid: u32) -> Result<Option<i32>, SidecarError> {
if child_pid == 0 {
return Ok(Some(0));
}
let wait_flags = WaitPidFlag::WNOHANG
| WaitPidFlag::WNOWAIT
| WaitPidFlag::WEXITED
| WaitPidFlag::WUNTRACED
| WaitPidFlag::WCONTINUED;
match wait_on_child(WaitId::Pid(Pid::from_raw(child_pid as i32)), wait_flags) {
Ok(WaitStatus::StillAlive)
| Ok(WaitStatus::Stopped(_, _))
| Ok(WaitStatus::Continued(_)) => Ok(None),
Ok(WaitStatus::Exited(_, status)) => Ok(Some(status)),
Ok(WaitStatus::Signaled(_, signal, _)) => Ok(Some(128 + signal as i32)),
#[cfg(any(target_os = "linux", target_os = "android"))]
Ok(WaitStatus::PtraceEvent(_, _, _) | WaitStatus::PtraceSyscall(_)) => Ok(None),
Err(nix::errno::Errno::ECHILD) => Ok(Some(0)),
Err(error) => Err(SidecarError::Execution(format!(
"failed to inspect guest runtime process {child_pid}: {error}"
))),
}
}
// macOS nix exposes no `waitid`/`WNOWAIT`, so we poll with `waitpid(WNOHANG)`.
// NOTE: unlike Linux's `waitid(WNOWAIT)`, `waitpid` REAPS an exited child rather
// than leaving it waitable. That is correct for this poll (the sidecar is the
// reaping parent), but a second status query after exit returns ECHILD → treated
// as "exited(0)" below.
#[cfg(target_os = "macos")]
fn runtime_child_exit_status(child_pid: u32) -> Result<Option<i32>, SidecarError> {
if child_pid == 0 {
return Ok(Some(0));
}
match waitpid(Pid::from_raw(child_pid as i32), Some(WaitPidFlag::WNOHANG)) {
Ok(WaitStatus::StillAlive)
| Ok(WaitStatus::Stopped(_, _))
| Ok(WaitStatus::Continued(_)) => Ok(None),
Ok(WaitStatus::Exited(_, status)) => Ok(Some(status)),
Ok(WaitStatus::Signaled(_, signal, _)) => Ok(Some(128 + signal as i32)),
Err(nix::errno::Errno::ECHILD) => Ok(Some(0)),
Err(error) => Err(SidecarError::Execution(format!(
"failed to inspect guest runtime process {child_pid}: {error}"
))),
}
}
pub(crate) fn signal_runtime_process(child_pid: u32, signal: i32) -> Result<(), SidecarError> {
if child_pid == 0 {
return Ok(());
}
if !runtime_child_is_alive(child_pid)? {
return Ok(());
}
if signal == 0 {
return Ok(());
}
let parsed = Signal::try_from(signal).map_err(|_| {
SidecarError::InvalidState(format!("unsupported kill_process signal {signal}"))
})?;
let result = send_signal(Pid::from_raw(child_pid as i32), Some(parsed));
match result {
Ok(()) => Ok(()),
Err(nix::errno::Errno::ESRCH) => Ok(()),
Err(error) => Err(SidecarError::Execution(format!(
"failed to signal guest runtime process {child_pid}: {error}"
))),
}
}
pub(crate) fn error_code(error: &SidecarError) -> &'static str {
match error {
SidecarError::InvalidState(_) => "invalid_state",
SidecarError::ProtocolVersionMismatch(_) => "protocol_version_mismatch",
SidecarError::BridgeVersionMismatch(_) => "bridge_version_mismatch",
SidecarError::Conflict(_) => "conflict",
SidecarError::Unauthorized(_) => "unauthorized",
SidecarError::Unsupported(_) => "unsupported",
SidecarError::FrameTooLarge(_) => "frame_too_large",
SidecarError::Kernel(_) => "kernel_error",
SidecarError::Plugin(_) => "plugin_error",
SidecarError::Execution(_) => "execution_error",
SidecarError::Bridge(_) => "bridge_error",
SidecarError::Io(_) => "io_error",
}
}
fn guest_errno_code(message: &str) -> Option<&str> {
const TRUSTED_PREFIXES: &[&str] = &[
"ERR_AGENTOS_NODE_SYNC_RPC",
"ERR_AGENTOS_PYTHON_VFS_RPC",
"ERR_AGENTOS_BRIDGE",
];
let mut segments = message.split(':').map(str::trim);
let first = segments.next()?;
if is_guest_errno_segment(first) {
return Some(first);
}
if TRUSTED_PREFIXES.contains(&first) {
let second = segments.next()?;
if is_guest_errno_segment(second) {
return Some(second);
}
}
None
}
fn is_guest_errno_segment(segment: &str) -> bool {
segment.len() >= 2
&& segment.starts_with('E')
&& !segment.starts_with("ERR_")
&& segment[1..]
.bytes()
.all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_')
}
pub(crate) fn javascript_sync_rpc_error_code(error: &SidecarError) -> String {
let message = error.to_string();
if let Some(code) = guest_errno_code(&message) {
return code.to_owned();
}
if message.starts_with("ERR_NATIVE_BINARY_NOT_SUPPORTED:") {
return String::from("ERR_NATIVE_BINARY_NOT_SUPPORTED");
}
let lower = message.to_ascii_lowercase();
if lower.contains("no such file or directory")
|| lower.contains("entry not found")
|| lower.contains("not found")
{
return String::from("ENOENT");
}
if lower.contains("permission denied") {
return String::from("EACCES");
}
if lower.contains("already exists")
|| lower.contains("already registered")
|| lower.contains("file exists")
{
return String::from("EEXIST");
}
if lower.contains("invalid argument") {
return String::from("EINVAL");
}
String::from("ERR_AGENTOS_NODE_SYNC_RPC")
}
pub(crate) fn ignore_stale_javascript_sync_rpc_response(
error: SidecarError,
) -> Result<(), SidecarError> {
match error {
SidecarError::Execution(message)
if message.ends_with("is no longer pending")
&& message.starts_with("sync RPC request ") =>
{
Ok(())
}
SidecarError::Execution(message) => {
let lower = message.to_ascii_lowercase();
if lower.contains("sync rpc response")
&& (lower.contains("broken pipe") || lower.contains("channel closed unexpectedly"))
{
Ok(())
} else {
Err(SidecarError::Execution(message))
}
}
other => Err(other),
}
}
#[cfg(test)]
mod error_code_tests {
use super::{guest_errno_code, javascript_sync_rpc_error_code, SidecarError};
#[test]
fn guest_errno_code_rejects_guest_controlled_errno_segments() {
assert_eq!(guest_errno_code("user said 'EACCES: denied'"), None);
assert_eq!(
guest_errno_code("prefix: user said 'EPERM': more text"),
None
);
assert_eq!(guest_errno_code("ERR_AGENTOS_FAKE: EACCES: denied"), None);
}
#[test]
fn guest_errno_code_accepts_trusted_secure_exec_prefixes() {
assert_eq!(
guest_errno_code("ERR_AGENTOS_NODE_SYNC_RPC: EACCES: permission denied on /foo"),
Some("EACCES")
);
assert_eq!(
guest_errno_code("ERR_AGENTOS_PYTHON_VFS_RPC: ENOENT: missing file"),
Some("ENOENT")
);
assert_eq!(guest_errno_code("EEXIST: already exists"), Some("EEXIST"));
}
#[test]
fn javascript_sync_rpc_error_code_ignores_spoofed_errnos() {
let error = SidecarError::Execution(String::from("user said 'EACCES: denied'"));
assert_eq!(
javascript_sync_rpc_error_code(&error),
"ERR_AGENTOS_NODE_SYNC_RPC"
);
}
#[test]
fn javascript_sync_rpc_error_code_preserves_real_sidecar_errnos() {
let error = SidecarError::Execution(String::from(
"ERR_AGENTOS_NODE_SYNC_RPC: EACCES: permission denied on /foo",
));
assert_eq!(javascript_sync_rpc_error_code(&error), "EACCES");
}
#[test]
fn javascript_sync_rpc_error_code_maps_file_exists_messages() {
let error = SidecarError::Io(String::from(
"failed to create mapped guest directory /.next/server: File exists (os error 17)",
));
assert_eq!(javascript_sync_rpc_error_code(&error), "EEXIST");
}
#[test]
fn javascript_sync_rpc_error_code_preserves_native_binary_rejections() {
let error = SidecarError::Execution(String::from(
"ERR_NATIVE_BINARY_NOT_SUPPORTED: refused to execute native ELF guest binary at /tmp/fake-rg inside the VM",
));
assert_eq!(
javascript_sync_rpc_error_code(&error),
"ERR_NATIVE_BINARY_NOT_SUPPORTED"
);
}
}
#[cfg(test)]
mod ssrf_egress_classifier_tests {
// F-005/006/007 (sec-sidecar T1/T7/T11): the egress classifier must treat the
// unspecified address (0.0.0.0 / ::), CGNAT (100.64.0.0/10), IPv6 spellings of
// restricted IPv4 targets (::a.b.c.d), and reserved/multicast (240/4, 224/4) as
// restricted. 0.0.0.0 routes to 127.0.0.1 on connect(), so leaving it
// unclassified let a guest bypass the loopback port-ownership gate.
//
// These are bounded SAFEGUARD tests: they exercise the classifier and the DNS
// egress filter directly (no network I/O, no Node), so they run fast and
// deterministically. See FAILURES.md#F-005, #F-006, #F-007.
use super::{
filter_dns_safe_ip_addrs, is_loopback_ip, restricted_non_loopback_ip_range, SidecarError,
};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
fn assert_restricted(ip: IpAddr, expected_label: &str) {
let classification = restricted_non_loopback_ip_range(ip);
assert!(
classification.is_some(),
"{ip} must be classified as a restricted egress target"
);
let (_cidr, label) = classification.unwrap();
assert_eq!(
label, expected_label,
"{ip} should be labelled {expected_label}, got {label}"
);
}
fn assert_dns_denied(ip: IpAddr, label: &str) {
match filter_dns_safe_ip_addrs(vec![ip], "attacker.example") {
Err(SidecarError::Execution(message)) => assert!(
message.starts_with("EACCES:"),
"{label}: egress filter must deny with EACCES, got: {message}"
),
other => panic!("{label}: expected EACCES denial, got {other:?}"),
}
}
// F-005 (sec-sidecar T1).
#[test]
fn classifier_denies_unspecified_and_cgnat_targets() {
// 0.0.0.0 (IPv4 unspecified) -> would route to host loopback.
assert_restricted(IpAddr::V4(Ipv4Addr::UNSPECIFIED), "unspecified");
// :: (IPv6 unspecified).
assert_restricted(IpAddr::V6(Ipv6Addr::UNSPECIFIED), "unspecified");
// CGNAT 100.64.0.0/10 spans 100.64.x.x .. 100.127.x.x.
assert_restricted(
IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1)),
"carrier-grade-nat",
);
assert_restricted(
IpAddr::V4(Ipv4Addr::new(100, 127, 255, 254)),
"carrier-grade-nat",
);
// Guard against over-blocking: addresses just outside 100.64/10 stay allowed.
assert!(
restricted_non_loopback_ip_range(IpAddr::V4(Ipv4Addr::new(100, 63, 255, 255)))
.is_none(),
"100.63.255.255 is outside CGNAT and must remain allowed"
);
assert!(
restricted_non_loopback_ip_range(IpAddr::V4(Ipv4Addr::new(100, 128, 0, 0))).is_none(),
"100.128.0.0 is outside CGNAT and must remain allowed"
);
// The DNS egress filter must also deny these via EACCES.
assert_dns_denied(IpAddr::V4(Ipv4Addr::UNSPECIFIED), "0.0.0.0 (unspecified)");
assert_dns_denied(IpAddr::V6(Ipv6Addr::UNSPECIFIED), ":: (unspecified)");
assert_dns_denied(
IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1)),
"100.64.0.1 (CGNAT)",
);
}
// F-006 (sec-sidecar T7).
#[test]
fn classifier_denies_ipv6_spelled_metadata_addresses() {
// The IPv4-mapped form (::ffff:169.254.169.254) was already handled; the
// IPv4-compatible form (::169.254.169.254) is the gap this fixes.
let mapped = "::ffff:169.254.169.254".parse::<Ipv6Addr>().unwrap();
assert_restricted(IpAddr::V6(mapped), "link-local");
let compat = "::169.254.169.254".parse::<Ipv6Addr>().unwrap();
assert_restricted(IpAddr::V6(compat), "link-local");
// Other IPv4-compatible private/CGNAT spellings must also be canonicalized.
assert_restricted(
IpAddr::V6("::10.0.0.1".parse::<Ipv6Addr>().unwrap()),
"private",
);
assert_restricted(
IpAddr::V6("::100.64.0.1".parse::<Ipv6Addr>().unwrap()),
"carrier-grade-nat",
);
// Guard against over-blocking: the IPv6 unspecified/loopback addresses
// are not IPv4-compatible host targets, and a public IPv4-compatible
// address must remain allowed.
assert_eq!(
restricted_non_loopback_ip_range(IpAddr::V6(Ipv6Addr::UNSPECIFIED)),
Some(("::/128", "unspecified")),
":: must classify as unspecified, not via the IPv4-compat path"
);
assert!(
restricted_non_loopback_ip_range(IpAddr::V6(Ipv6Addr::LOCALHOST)).is_none()
|| is_loopback_ip(IpAddr::V6(Ipv6Addr::LOCALHOST)),
"::1 must not be classified as a restricted IPv4-compatible target"
);
assert!(
restricted_non_loopback_ip_range(IpAddr::V6("::8.8.8.8".parse::<Ipv6Addr>().unwrap()))
.is_none(),
"::8.8.8.8 (public IPv4-compatible) must remain allowed"
);
// The DNS egress filter must deny the IPv4-compat metadata spelling.
assert_dns_denied(
IpAddr::V6("::169.254.169.254".parse::<Ipv6Addr>().unwrap()),
"::169.254.169.254 (IPv4-compat metadata)",
);
}
// F-007 (sec-sidecar T11).
#[test]
fn classifier_denies_reserved_and_multicast_targets() {
// 224.0.0.0/4 (multicast) and 240.0.0.0/4 (reserved / future use) are not
// legitimate unicast egress targets; a guest connect to them must be
// classified as restricted and denied.
assert_restricted(IpAddr::V4(Ipv4Addr::new(224, 0, 0, 1)), "multicast");
assert_restricted(IpAddr::V4(Ipv4Addr::new(239, 255, 255, 255)), "multicast");
assert_restricted(IpAddr::V4(Ipv4Addr::new(240, 0, 0, 1)), "reserved");
// 255.255.255.255 (limited broadcast) falls in 240.0.0.0/4.
assert_restricted(IpAddr::V4(Ipv4Addr::BROADCAST), "reserved");
// IPv4-compatible IPv6 spellings must canonicalize and be denied too.
assert_restricted(
IpAddr::V6("::224.0.0.1".parse::<Ipv6Addr>().unwrap()),
"multicast",
);
assert_restricted(
IpAddr::V6("::240.0.0.1".parse::<Ipv6Addr>().unwrap()),
"reserved",
);
// Guard against over-blocking: addresses just outside 224/4 stay allowed.
assert!(
restricted_non_loopback_ip_range(IpAddr::V4(Ipv4Addr::new(223, 255, 255, 255)))
.is_none(),
"223.255.255.255 is outside 224/4 and must remain allowed"
);
// The DNS egress filter must also deny these via EACCES.
assert_dns_denied(
IpAddr::V4(Ipv4Addr::new(240, 0, 0, 1)),
"240.0.0.1 (reserved)",
);
assert_dns_denied(
IpAddr::V4(Ipv4Addr::new(224, 0, 0, 1)),
"224.0.0.1 (multicast)",
);
}
}
/// Adversarial coverage for the DNS-rebinding gap (VECTORS.md D.3) on the
/// Python/Pyodide `httpRequestSync` outbound HTTP path. The egress range guard
/// (`filter_dns_safe_ip_addrs`) runs at resolution time, but `ureq` performs its
/// own DNS resolution for the TCP/TLS connect, so a rebinding DNS server could
/// previously make the second lookup land on a private/link-local/metadata IP
/// the first check rejected. The fix pins `ureq`'s resolver to the vetted
/// address set; these tests prove the connect is pinned and refuses any other
/// host or an empty (fully-rejected) address set.
#[cfg(test)]
mod dns_rebinding_pin_tests {
use super::{issue_outbound_http_request, split_netloc, JavascriptHttpRequestOptions};
use std::collections::BTreeMap;
use std::io::{Read, Write};
use std::net::{IpAddr, Ipv4Addr, TcpListener};
use std::thread;
use url::Url;
fn empty_headers() -> super::HttpHeaderCollection {
super::parse_http_header_collection(&BTreeMap::new(), "test headers")
.expect("empty header collection")
}
fn options() -> JavascriptHttpRequestOptions {
JavascriptHttpRequestOptions {
method: Some(String::from("GET")),
headers: BTreeMap::new(),
body: None,
reject_unauthorized: None,
}
}
#[test]
fn split_netloc_handles_hostnames_and_bracketed_ipv6() {
assert_eq!(
split_netloc("attacker.example:80"),
Some(("attacker.example", 80))
);
assert_eq!(split_netloc("[::1]:443"), Some(("::1", 443)));
assert_eq!(split_netloc("10.0.0.1:8080"), Some(("10.0.0.1", 8080)));
assert_eq!(split_netloc("no-port"), None);
assert_eq!(split_netloc("host:notaport"), None);
}
/// A loopback HTTP server stands in for the egress-vetted target. The
/// request URL uses a *different* hostname (`attacker.example`) whose real
/// DNS would resolve elsewhere; pinning forces the connect onto the vetted
/// IP only. If the resolver were unpinned, the request would fail to reach
/// this server (and on a real host could land on a private/metadata IP).
#[test]
fn outbound_http_connect_is_pinned_to_vetted_ip() {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind loopback server");
let port = listener.local_addr().expect("local addr").port();
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept");
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf);
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nhi")
.expect("write response");
let _ = stream.flush();
});
let url = Url::parse(&format!("http://attacker.example:{port}/")).expect("url");
let pinned = vec![IpAddr::V4(Ipv4Addr::LOCALHOST)];
let result = issue_outbound_http_request(&url, &options(), &empty_headers(), &pinned)
.expect("pinned request should reach the vetted loopback target");
let payload = result.as_str().expect("string payload");
assert!(
payload.contains("\"status\":200"),
"expected 200 from pinned target, got: {payload}"
);
server.join().expect("server thread");
}
/// With no vetted address (every resolved IP was rejected by the range
/// guard, or the literal IP was a blocked range), the pinned resolver must
/// refuse rather than fall back to the host resolver.
#[test]
fn outbound_http_refuses_when_no_vetted_address() {
let url = Url::parse("https://attacker.example/").expect("url");
let error = issue_outbound_http_request(&url, &options(), &empty_headers(), &[])
.expect_err("empty pinned set must be refused");
let message = error.to_string();
assert!(
message.contains("EACCES") || message.contains("ERR_HTTP_REQUEST_FAILED"),
"expected an egress refusal, got: {message}"
);
}
}