1use crate::common::{
2 encode_json_string, encode_json_string_array, encode_json_string_map, frozen_time_ms,
3};
4use crate::javascript::{
5 CreateJavascriptContextRequest, GuestRuntimeConfig, JavascriptExecution,
6 JavascriptExecutionEngine, JavascriptExecutionError, JavascriptExecutionEvent,
7 JavascriptExecutionLimits, JavascriptSyncRpcRequest, StartJavascriptExecutionRequest,
8};
9use crate::node_import_cache::NodeImportCache;
10use crate::runtime_support::{env_flag_enabled, file_fingerprint, warmup_marker_path};
11use crate::signal::{NodeSignalDispositionAction, NodeSignalHandlerRegistration};
12use crate::v8_host::{V8RuntimeHost, V8SessionHandle};
13use crate::v8_runtime;
14use agentos_bridge::queue_tracker::{
15 register_limit, warn_limit_exhausted, QueueGauge, TrackedLimit,
16};
17use base64::Engine as _;
18use serde_json::{json, Value};
19use std::collections::{BTreeMap, HashMap, VecDeque};
20use std::fmt;
21use std::fs;
22use std::fs::OpenOptions;
23use std::io::{Read, Write};
24use std::os::unix::fs::{FileExt, MetadataExt, PermissionsExt};
25use std::path::{Path, PathBuf};
26use std::sync::{Arc, Mutex, OnceLock};
27use std::time::{Duration, Instant};
28
29const WASM_MODULE_PATH_ENV: &str = "AGENTOS_WASM_MODULE_PATH";
30const WASM_GUEST_ARGV_ENV: &str = "AGENTOS_GUEST_ARGV";
31const WASM_GUEST_ENV_ENV: &str = "AGENTOS_GUEST_ENV";
32const WASM_PERMISSION_TIER_ENV: &str = "AGENTOS_WASM_PERMISSION_TIER";
33const WASM_PREWARM_ONLY_ENV: &str = "AGENTOS_WASM_PREWARM_ONLY";
34const WASM_HOST_CWD_ENV: &str = "AGENTOS_WASM_HOST_CWD";
35const WASM_SANDBOX_ROOT_ENV: &str = "AGENTOS_SANDBOX_ROOT";
36const WASM_WARMUP_DEBUG_ENV: &str = "AGENTOS_WASM_WARMUP_DEBUG";
37pub const WASM_MAX_FUEL_ENV: &str = "AGENTOS_WASM_MAX_FUEL";
38pub const WASM_MAX_MEMORY_BYTES_ENV: &str = "AGENTOS_WASM_MAX_MEMORY_BYTES";
39pub const WASM_MAX_STACK_BYTES_ENV: &str = "AGENTOS_WASM_MAX_STACK_BYTES";
40const WASM_WARMUP_METRICS_PREFIX: &str = "__AGENTOS_WASM_WARMUP_METRICS__:";
41const WASM_SIGNAL_STATE_PREFIX: &str = "__AGENTOS_WASM_SIGNAL_STATE__:";
42const WASM_WARMUP_MARKER_VERSION: &str = "1";
43const WASM_PAGE_BYTES: u64 = 65_536;
44const WASM_TIMEOUT_EXIT_CODE: i32 = 124;
45const MAX_WASM_MODULE_FILE_BYTES: u64 = 256 * 1024 * 1024;
46const MAX_WASM_IMPORT_SECTION_ENTRIES: usize = 16_384;
47const MAX_WASM_MEMORY_SECTION_ENTRIES: usize = 1_024;
48const MAX_WASM_VARUINT_BYTES: usize = 10;
49const DEFAULT_WASM_GUEST_HOME: &str = "/root";
50const DEFAULT_WASM_GUEST_USER: &str = "root";
51const DEFAULT_WASM_GUEST_SHELL: &str = "/bin/sh";
52const DEFAULT_WASM_GUEST_PATH: &str =
53 "/usr/local/sbin:/usr/local/bin:/opt/agentos/bin:/usr/sbin:/usr/bin:/sbin:/bin";
54const DEFAULT_WASM_PREWARM_TIMEOUT_MS: u64 = 30_000;
57const DEFAULT_WASM_RUNNER_HEAP_LIMIT_MB: u32 = 2048;
77const _: () = assert!(DEFAULT_WASM_RUNNER_HEAP_LIMIT_MB > 128);
80const MAX_SYNC_WASM_PREWARM_MODULE_BYTES: u64 = 16 * 1024 * 1024;
81const WASM_CAPTURED_OUTPUT_LIMIT_BYTES: usize = 16 * 1024 * 1024;
82const WASM_SYNC_READ_LIMIT_BYTES: usize = 16 * 1024 * 1024;
83const WASM_INLINE_RUNNER_ENTRYPOINT: &str = "./__agentos_wasm_runner__.mjs";
84const WASM_SNAPSHOT_RUNNER_ENV: &str = "AGENTOS_WASM_SNAPSHOT_RUNNER";
85const WASM_RUNNER_NO_CACHE_ENV: &str = "AGENTOS_WASM_RUNNER_NO_CACHE";
86const WASM_MODULE_BYTES_CACHE_CAPACITY: usize = 64;
87const NODE_WASI_MODULE_SOURCE: &str = include_str!("../assets/runners/wasi-module.js");
88const WASM_SIDECAR_ROUTED_FS_SYNC_METHODS: &[&str] = &[
89 "fs.accessSync",
90 "fs.chmodSync",
91 "fs.closeSync",
92 "fs.existsSync",
93 "fs.fdatasyncSync",
94 "fs.fstatSync",
95 "fs.fsyncSync",
96 "fs.ftruncateSync",
97 "fs.linkSync",
98 "fs.lstatSync",
99 "fs.mkdirSync",
100 "fs.openSync",
101 "fs.readFileSync",
102 "fs.readSync",
103 "fs.readdirSync",
104 "fs.readlinkSync",
105 "fs.renameSync",
106 "fs.rmdirSync",
107 "fs.statSync",
108 "fs.symlinkSync",
109 "fs.unlinkSync",
110 "fs.writeFileSync",
111 "fs.writeSync",
112];
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum WasmSignalDispositionAction {
116 Default,
117 Ignore,
118 User,
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
122#[serde(rename_all = "kebab-case")]
123pub enum WasmPermissionTier {
124 Full,
125 ReadWrite,
126 ReadOnly,
127 Isolated,
128}
129
130impl WasmPermissionTier {
131 fn as_env_value(self) -> &'static str {
132 match self {
133 Self::Full => "full",
134 Self::ReadWrite => "read-write",
135 Self::ReadOnly => "read-only",
136 Self::Isolated => "isolated",
137 }
138 }
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct WasmSignalHandlerRegistration {
143 pub action: WasmSignalDispositionAction,
144 pub mask: Vec<u32>,
145 pub flags: u32,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct CreateWasmContextRequest {
150 pub vm_id: String,
151 pub module_path: Option<String>,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct WasmContext {
156 pub context_id: String,
157 pub vm_id: String,
158 pub module_path: Option<String>,
159}
160
161#[derive(Debug, Clone, Default, PartialEq, Eq)]
167pub struct WasmExecutionLimits {
168 pub max_fuel: Option<u64>,
170 pub max_memory_bytes: Option<u64>,
173 pub max_stack_bytes: Option<u64>,
177 pub prewarm_timeout_ms: Option<u64>,
179 pub runner_heap_limit_mb: Option<u32>,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct StartWasmExecutionRequest {
185 pub vm_id: String,
186 pub context_id: String,
187 pub argv: Vec<String>,
188 pub env: BTreeMap<String, String>,
189 pub cwd: PathBuf,
190 pub permission_tier: WasmPermissionTier,
191 pub limits: WasmExecutionLimits,
193 pub guest_runtime: GuestRuntimeConfig,
197}
198
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub enum WasmExecutionEvent {
201 Stdout(Vec<u8>),
202 Stderr(Vec<u8>),
203 SyncRpcRequest(JavascriptSyncRpcRequest),
204 SignalState {
205 signal: u32,
206 registration: WasmSignalHandlerRegistration,
207 },
208 Exited(i32),
209}
210
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct WasmExecutionResult {
213 pub execution_id: String,
214 pub exit_code: i32,
215 pub stdout: Vec<u8>,
216 pub stderr: Vec<u8>,
217}
218
219#[derive(Debug, Clone, PartialEq, Eq)]
220struct ResolvedWasmModule {
221 specifier: String,
222 resolved_path: PathBuf,
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226pub enum NativeBinaryFormat {
227 Elf,
228 MachO,
229 PeCoff,
230}
231
232impl NativeBinaryFormat {
233 fn display_name(self) -> &'static str {
234 match self {
235 Self::Elf => "ELF",
236 Self::MachO => "Mach-O",
237 Self::PeCoff => "PE/COFF",
238 }
239 }
240}
241
242#[derive(Debug)]
243pub enum WasmExecutionError {
244 MissingContext(String),
245 VmMismatch {
246 expected: String,
247 found: String,
248 },
249 MissingModulePath,
250 InvalidLimit(String),
251 InvalidModule(String),
252 NativeBinaryNotSupported {
253 path: PathBuf,
254 header: Vec<u8>,
255 format: NativeBinaryFormat,
256 },
257 NonWasmBinary {
258 path: PathBuf,
259 header: Vec<u8>,
260 shell_shim: bool,
261 },
262 PrepareWarmPath(std::io::Error),
263 WarmupSpawn(std::io::Error),
264 WarmupTimeout(Duration),
265 WarmupFailed {
266 exit_code: i32,
267 stderr: String,
268 },
269 Spawn(std::io::Error),
270 RpcResponse(String),
271 StdinClosed,
272 Stdin(std::io::Error),
273 OutputBufferExceeded {
274 stream: &'static str,
275 limit: usize,
276 },
277 EventChannelClosed,
278}
279
280impl fmt::Display for WasmExecutionError {
281 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
282 match self {
283 Self::MissingContext(context_id) => {
284 write!(f, "unknown guest WebAssembly context: {context_id}")
285 }
286 Self::VmMismatch { expected, found } => {
287 write!(
288 f,
289 "guest WebAssembly context belongs to vm {expected}, not {found}"
290 )
291 }
292 Self::MissingModulePath => {
293 f.write_str("guest WebAssembly execution requires a module path")
294 }
295 Self::InvalidLimit(message) => write!(f, "invalid WebAssembly limit: {message}"),
296 Self::InvalidModule(message) => write!(f, "invalid WebAssembly module: {message}"),
297 Self::NativeBinaryNotSupported {
298 path,
299 header,
300 format,
301 } => {
302 let header_hex = header
303 .iter()
304 .map(|byte| format!("{byte:02x}"))
305 .collect::<Vec<_>>()
306 .join(" ");
307 write!(
308 f,
309 "ERR_NATIVE_BINARY_NOT_SUPPORTED: refused to execute native {} guest binary at {} inside the VM; only WebAssembly binaries are runnable there (header bytes: [{header_hex}])",
310 format.display_name(),
311 path.display()
312 )
313 }
314 Self::NonWasmBinary {
315 path,
316 header,
317 shell_shim,
318 } => {
319 let header_hex = header
320 .iter()
321 .map(|byte| format!("{byte:02x}"))
322 .collect::<Vec<_>>()
323 .join(" ");
324 if *shell_shim {
325 write!(
326 f,
327 "refused to compile guest WebAssembly module at {}: file is a shell-shim script (starts with \"#!\", header bytes: [{header_hex}]) instead of a \"\\0asm\" WebAssembly binary",
328 path.display()
329 )
330 } else {
331 write!(
332 f,
333 "refused to compile guest WebAssembly module at {}: first {} byte(s) [{header_hex}] do not match the \"\\0asm\" WebAssembly magic word",
334 path.display(),
335 header.len()
336 )
337 }
338 }
339 Self::PrepareWarmPath(err) => {
340 write!(f, "failed to prepare shared WebAssembly warm path: {err}")
341 }
342 Self::WarmupSpawn(err) => {
343 write!(f, "failed to start WebAssembly warmup runtime: {err}")
344 }
345 Self::WarmupTimeout(timeout) => {
346 write!(
347 f,
348 "WebAssembly warmup exceeded the configured timeout after {} ms",
349 timeout.as_millis()
350 )
351 }
352 Self::WarmupFailed { exit_code, stderr } => {
353 if stderr.trim().is_empty() {
354 write!(f, "WebAssembly warmup exited with status {exit_code}")
355 } else {
356 write!(
357 f,
358 "WebAssembly warmup exited with status {exit_code}: {}",
359 stderr.trim()
360 )
361 }
362 }
363 Self::Spawn(err) => write!(f, "failed to start guest WebAssembly runtime: {err}"),
364 Self::RpcResponse(message) => {
365 write!(
366 f,
367 "failed to write guest WebAssembly sync RPC response: {message}"
368 )
369 }
370 Self::StdinClosed => f.write_str("guest WebAssembly stdin is already closed"),
371 Self::Stdin(err) => write!(f, "failed to write guest stdin: {err}"),
372 Self::OutputBufferExceeded { stream, limit } => {
373 write!(
374 f,
375 "guest WebAssembly {stream} exceeded the captured output limit of {limit} bytes"
376 )
377 }
378 Self::EventChannelClosed => {
379 f.write_str("guest WebAssembly event channel closed unexpectedly")
380 }
381 }
382 }
383}
384
385impl std::error::Error for WasmExecutionError {}
386
387#[derive(Debug)]
388pub struct WasmExecution {
389 execution_id: String,
390 child_pid: u32,
391 inner: JavascriptExecution,
392 execution_timeout: Option<Duration>,
393 execution_started_at: Instant,
394 timeout_reported: bool,
395 fuel_gauge: Option<Arc<QueueGauge>>,
396 internal_sync_rpc: WasmInternalSyncRpc,
397 pending_events: VecDeque<WasmExecutionEvent>,
398 stdout_stream_buffer: Vec<u8>,
399 stderr_stream_buffer: Vec<u8>,
400}
401
402#[derive(Debug)]
403struct WasmInternalSyncRpc {
404 module_guest_paths: Vec<String>,
405 module_host_path: PathBuf,
406 guest_cwd: String,
407 host_cwd: PathBuf,
408 sandbox_root: Option<PathBuf>,
409 guest_path_mappings: Vec<WasmGuestPathMapping>,
410 route_fs_through_sidecar: bool,
411 next_fd: u32,
412 open_files: BTreeMap<u32, fs::File>,
413 pending_events: VecDeque<WasmExecutionEvent>,
414}
415
416#[derive(Debug, Clone)]
417struct WasmGuestPathMapping {
418 guest_path: String,
419 host_path: PathBuf,
420 read_only: bool,
421}
422
423impl WasmExecution {
424 pub fn execution_id(&self) -> &str {
425 &self.execution_id
426 }
427
428 pub fn child_pid(&self) -> u32 {
429 self.child_pid
430 }
431
432 pub fn v8_session_handle(&self) -> V8SessionHandle {
433 self.inner.v8_session_handle()
434 }
435
436 pub fn uses_shared_v8_runtime(&self) -> bool {
437 self.inner.uses_shared_v8_runtime()
438 }
439
440 pub fn write_stdin(&mut self, chunk: &[u8]) -> Result<(), WasmExecutionError> {
441 self.inner.write_stdin(chunk).map_err(map_javascript_error)
442 }
443
444 pub fn write_stdin_kernel_only(&mut self, chunk: &[u8]) -> Result<(), WasmExecutionError> {
451 self.inner
452 .write_kernel_stdin_only(chunk)
453 .map_err(map_javascript_error)
454 }
455
456 pub fn close_stdin(&mut self) -> Result<(), WasmExecutionError> {
457 self.inner.close_stdin().map_err(map_javascript_error)
458 }
459
460 pub fn send_stream_event(
461 &self,
462 event_type: &str,
463 payload: Value,
464 ) -> Result<(), WasmExecutionError> {
465 self.inner
466 .send_stream_event(event_type, payload)
467 .map_err(map_javascript_error)
468 }
469
470 pub fn terminate(&self) -> Result<(), WasmExecutionError> {
471 self.inner.terminate().map_err(map_javascript_error)
472 }
473
474 pub fn respond_sync_rpc_success(
475 &mut self,
476 id: u64,
477 result: Value,
478 ) -> Result<(), WasmExecutionError> {
479 self.inner
480 .respond_sync_rpc_success(id, result)
481 .map_err(map_javascript_error)
482 }
483
484 pub fn respond_sync_rpc_raw_success(
485 &mut self,
486 id: u64,
487 payload: Vec<u8>,
488 ) -> Result<(), WasmExecutionError> {
489 self.inner
490 .respond_sync_rpc_raw_success(id, payload)
491 .map_err(map_javascript_error)
492 }
493
494 pub fn respond_sync_rpc_error(
495 &mut self,
496 id: u64,
497 code: impl Into<String>,
498 message: impl Into<String>,
499 ) -> Result<(), WasmExecutionError> {
500 self.inner
501 .respond_sync_rpc_error(id, code, message)
502 .map_err(map_javascript_error)
503 }
504
505 pub async fn poll_event(
506 &mut self,
507 timeout: Duration,
508 ) -> Result<Option<WasmExecutionEvent>, WasmExecutionError> {
509 loop {
510 if let Some(event) = self.pending_events.pop_front() {
511 return Ok(Some(event));
512 }
513 if let Some(event) = self.internal_sync_rpc.pending_events.pop_front() {
514 self.enqueue_wasm_event(event)?;
515 continue;
516 }
517 if let Some(event) = self.timeout_event_if_expired()? {
518 return Ok(Some(event));
519 }
520 let poll_timeout = self.deadline_capped_timeout(timeout);
521 match self
522 .inner
523 .poll_event(poll_timeout)
524 .await
525 .map_err(map_javascript_error)?
526 {
527 Some(event) => {
528 if let JavascriptExecutionEvent::SyncRpcRequest(request) = &event {
529 if self.handle_internal_sync_rpc(request)? {
530 continue;
531 }
532 if let Some(signal_state) = self.handle_signal_state_sync_rpc(request)? {
533 return Ok(Some(signal_state));
534 }
535 }
536 self.enqueue_javascript_event(event)?;
537 }
538 None if poll_timeout < timeout => continue,
539 None => return Ok(None),
540 }
541 }
542 }
543
544 pub fn poll_event_blocking(
545 &mut self,
546 timeout: Duration,
547 ) -> Result<Option<WasmExecutionEvent>, WasmExecutionError> {
548 loop {
549 if let Some(event) = self.pending_events.pop_front() {
550 return Ok(Some(event));
551 }
552 if let Some(event) = self.internal_sync_rpc.pending_events.pop_front() {
553 self.enqueue_wasm_event(event)?;
554 continue;
555 }
556 if let Some(event) = self.timeout_event_if_expired()? {
557 return Ok(Some(event));
558 }
559 let poll_timeout = self.deadline_capped_timeout(timeout);
560 match self
561 .inner
562 .poll_event_blocking(poll_timeout)
563 .map_err(map_javascript_error)?
564 {
565 Some(event) => {
566 if let JavascriptExecutionEvent::SyncRpcRequest(request) = &event {
567 if self.handle_internal_sync_rpc(request)? {
568 continue;
569 }
570 if let Some(signal_state) = self.handle_signal_state_sync_rpc(request)? {
571 return Ok(Some(signal_state));
572 }
573 }
574 self.enqueue_javascript_event(event)?;
575 }
576 None if poll_timeout < timeout => continue,
577 None => return Ok(None),
578 }
579 }
580 }
581
582 pub fn wait(mut self) -> Result<WasmExecutionResult, WasmExecutionError> {
583 self.close_stdin()?;
584 let mut stdout = Vec::new();
585 let mut stderr = Vec::new();
586
587 loop {
588 match self.poll_event_blocking(Duration::from_millis(50))? {
589 Some(WasmExecutionEvent::Stdout(chunk)) => {
590 append_wasm_captured_output(&mut stdout, &chunk, "stdout")?;
591 }
592 Some(WasmExecutionEvent::Stderr(chunk)) => {
593 append_wasm_captured_output(&mut stderr, &chunk, "stderr")?;
594 }
595 Some(WasmExecutionEvent::SyncRpcRequest(request)) => {
596 if self.handle_wait_sync_rpc_request(&request, &mut stdout, &mut stderr)? {
597 continue;
598 }
599 return Err(WasmExecutionError::RpcResponse(format!(
600 "unexpected guest WebAssembly sync RPC request {} while waiting",
601 request.method
602 )));
603 }
604 Some(WasmExecutionEvent::SignalState { .. }) => {}
605 Some(WasmExecutionEvent::Exited(exit_code)) => {
606 return Ok(WasmExecutionResult {
607 execution_id: self.execution_id,
608 exit_code,
609 stdout,
610 stderr,
611 });
612 }
613 None => {}
614 }
615 }
616 }
617
618 fn deadline_capped_timeout(&self, timeout: Duration) -> Duration {
619 self.execution_timeout
620 .map(|limit| {
621 let elapsed = self.execution_started_at.elapsed();
622 if elapsed >= limit {
623 Duration::ZERO
624 } else {
625 timeout.min(limit.saturating_sub(elapsed))
626 }
627 })
628 .unwrap_or(timeout)
629 }
630
631 fn timeout_event_if_expired(
632 &mut self,
633 ) -> Result<Option<WasmExecutionEvent>, WasmExecutionError> {
634 if self.timeout_reported {
635 return Ok(None);
636 }
637 let Some(limit) = self.execution_timeout else {
638 return Ok(None);
639 };
640 let elapsed = self.execution_started_at.elapsed();
641 if let Some(gauge) = &self.fuel_gauge {
644 gauge.observe_depth(duration_millis_saturating_usize(elapsed));
645 }
646 if elapsed < limit {
647 return Ok(None);
648 }
649
650 let _ = self.inner.terminate();
651 self.timeout_reported = true;
652 let capacity = duration_millis_saturating_usize(limit);
653 warn_limit_exhausted(TrackedLimit::WasmFuelMs, capacity, capacity);
654 self.enqueue_wasm_event(WasmExecutionEvent::Stderr(
655 b"WebAssembly fuel budget exhausted\n".to_vec(),
656 ))?;
657 self.enqueue_wasm_event(WasmExecutionEvent::Exited(WASM_TIMEOUT_EXIT_CODE))?;
658 Ok(self.pending_events.pop_front())
659 }
660
661 fn handle_internal_sync_rpc(
662 &mut self,
663 request: &JavascriptSyncRpcRequest,
664 ) -> Result<bool, WasmExecutionError> {
665 handle_internal_wasm_sync_rpc_request(&mut self.inner, &mut self.internal_sync_rpc, request)
666 }
667
668 fn handle_signal_state_sync_rpc(
669 &mut self,
670 request: &JavascriptSyncRpcRequest,
671 ) -> Result<Option<WasmExecutionEvent>, WasmExecutionError> {
672 translate_wasm_signal_state_sync_rpc_request(&mut self.inner, request)
673 }
674
675 fn enqueue_javascript_event(
676 &mut self,
677 event: JavascriptExecutionEvent,
678 ) -> Result<(), WasmExecutionError> {
679 match event {
680 JavascriptExecutionEvent::Stdout(chunk) => {
681 self.enqueue_stream_chunk(StreamChannel::Stdout, chunk)?
682 }
683 JavascriptExecutionEvent::Stderr(chunk) => {
684 self.enqueue_stream_chunk(StreamChannel::Stderr, chunk)?
685 }
686 JavascriptExecutionEvent::SyncRpcRequest(request) => {
687 self.pending_events
688 .push_back(WasmExecutionEvent::SyncRpcRequest(request));
689 }
690 JavascriptExecutionEvent::SignalState {
691 signal,
692 registration,
693 } => {
694 self.pending_events
695 .push_back(WasmExecutionEvent::SignalState {
696 signal,
697 registration: registration.into(),
698 });
699 }
700 JavascriptExecutionEvent::Exited(code) => {
701 self.flush_stream_buffers();
702 self.pending_events
703 .push_back(WasmExecutionEvent::Exited(code));
704 }
705 }
706 Ok(())
707 }
708
709 fn enqueue_wasm_event(&mut self, event: WasmExecutionEvent) -> Result<(), WasmExecutionError> {
710 match event {
711 WasmExecutionEvent::Stdout(chunk) => {
712 self.enqueue_stream_chunk(StreamChannel::Stdout, chunk)?
713 }
714 WasmExecutionEvent::Stderr(chunk) => {
715 self.enqueue_stream_chunk(StreamChannel::Stderr, chunk)?
716 }
717 WasmExecutionEvent::Exited(code) => {
718 self.flush_stream_buffers();
719 self.pending_events
720 .push_back(WasmExecutionEvent::Exited(code));
721 }
722 other => self.pending_events.push_back(other),
723 }
724 Ok(())
725 }
726
727 fn enqueue_stream_chunk(
728 &mut self,
729 channel: StreamChannel,
730 chunk: Vec<u8>,
731 ) -> Result<(), WasmExecutionError> {
732 let buffer = match channel {
733 StreamChannel::Stdout => &mut self.stdout_stream_buffer,
734 StreamChannel::Stderr => &mut self.stderr_stream_buffer,
735 };
736 let stream = match channel {
737 StreamChannel::Stdout => "stdout",
738 StreamChannel::Stderr => "stderr",
739 };
740 ensure_wasm_output_capacity(buffer.len(), chunk.len(), stream)?;
741 buffer.extend_from_slice(&chunk);
742
743 let mut pending_stream_chunk = Vec::new();
744 while let Some(newline_index) = buffer.iter().position(|byte| *byte == b'\n') {
745 let line = buffer.drain(..=newline_index).collect::<Vec<_>>();
746 if let Some(signal_state) = parse_wasm_signal_state_line(&line)? {
747 if !pending_stream_chunk.is_empty() {
748 self.pending_events.push_back(match channel {
749 StreamChannel::Stdout => {
750 WasmExecutionEvent::Stdout(std::mem::take(&mut pending_stream_chunk))
751 }
752 StreamChannel::Stderr => {
753 WasmExecutionEvent::Stderr(std::mem::take(&mut pending_stream_chunk))
754 }
755 });
756 }
757 self.pending_events.push_back(signal_state);
758 continue;
759 }
760 pending_stream_chunk.extend_from_slice(&line);
761 }
762 if !pending_stream_chunk.is_empty() {
763 self.pending_events.push_back(match channel {
764 StreamChannel::Stdout => WasmExecutionEvent::Stdout(pending_stream_chunk),
765 StreamChannel::Stderr => WasmExecutionEvent::Stderr(pending_stream_chunk),
766 });
767 }
768
769 Ok(())
770 }
771
772 fn flush_stream_buffers(&mut self) {
773 if !self.stdout_stream_buffer.is_empty() {
774 self.pending_events
775 .push_back(WasmExecutionEvent::Stdout(std::mem::take(
776 &mut self.stdout_stream_buffer,
777 )));
778 }
779 if !self.stderr_stream_buffer.is_empty() {
780 self.pending_events
781 .push_back(WasmExecutionEvent::Stderr(std::mem::take(
782 &mut self.stderr_stream_buffer,
783 )));
784 }
785 }
786
787 fn handle_wait_sync_rpc_request(
788 &mut self,
789 request: &JavascriptSyncRpcRequest,
790 stdout: &mut Vec<u8>,
791 stderr: &mut Vec<u8>,
792 ) -> Result<bool, WasmExecutionError> {
793 if self
794 .inner
795 .handle_kernel_stdin_sync_rpc(request)
796 .map_err(map_javascript_error)?
797 {
798 return Ok(true);
799 }
800
801 if request.method != "__kernel_stdio_write" {
802 return Ok(false);
803 }
804
805 let Some(descriptor) = request.args.first().and_then(Value::as_u64) else {
806 return Err(WasmExecutionError::RpcResponse(String::from(
807 "missing __kernel_stdio_write descriptor",
808 )));
809 };
810 let bytes = decode_wasm_bytes_arg(
811 request.args.get(1),
812 "__kernel_stdio_write payload bytes",
813 WASM_CAPTURED_OUTPUT_LIMIT_BYTES,
814 )?;
815
816 match descriptor {
817 1 => append_wasm_captured_output(stdout, &bytes, "stdout")?,
818 2 => append_wasm_captured_output(stderr, &bytes, "stderr")?,
819 other => {
820 return Err(WasmExecutionError::RpcResponse(format!(
821 "unsupported __kernel_stdio_write descriptor {other}",
822 )));
823 }
824 }
825
826 self.respond_sync_rpc_success(request.id, json!(bytes.len()))?;
827 Ok(true)
828 }
829}
830
831#[derive(Clone, Copy)]
832enum StreamChannel {
833 Stdout,
834 Stderr,
835}
836
837#[derive(Debug, Default)]
838pub struct WasmExecutionEngine {
839 next_context_id: usize,
840 next_execution_id: usize,
841 contexts: BTreeMap<String, WasmContext>,
842 import_caches: BTreeMap<String, NodeImportCache>,
843 javascript_context_ids: BTreeMap<String, String>,
844 javascript_engine: JavascriptExecutionEngine,
845}
846
847impl WasmExecutionEngine {
848 pub fn create_context(&mut self, request: CreateWasmContextRequest) -> WasmContext {
849 self.next_context_id += 1;
850 self.import_caches.entry(request.vm_id.clone()).or_default();
851 let javascript_context =
852 self.javascript_engine
853 .create_context(CreateJavascriptContextRequest {
854 vm_id: request.vm_id.clone(),
855 bootstrap_module: None,
856 compile_cache_root: None,
857 });
858
859 let context = WasmContext {
860 context_id: format!("wasm-ctx-{}", self.next_context_id),
861 vm_id: request.vm_id,
862 module_path: request.module_path,
863 };
864 self.javascript_context_ids
865 .insert(context.context_id.clone(), javascript_context.context_id);
866 self.contexts
867 .insert(context.context_id.clone(), context.clone());
868 context
869 }
870
871 pub fn start_execution(
872 &mut self,
873 request: StartWasmExecutionRequest,
874 ) -> Result<WasmExecution, WasmExecutionError> {
875 let context = self
876 .contexts
877 .get(&request.context_id)
878 .cloned()
879 .ok_or_else(|| WasmExecutionError::MissingContext(request.context_id.clone()))?;
880
881 if context.vm_id != request.vm_id {
882 return Err(WasmExecutionError::VmMismatch {
883 expected: context.vm_id,
884 found: request.vm_id,
885 });
886 }
887
888 let resolved_module = resolve_wasm_module(&context, &request)?;
889 verify_wasm_module_header(&resolved_module)?;
890 let prewarm_timeout = resolve_wasm_prewarm_timeout(&request)?;
891 let javascript_context_id = self
892 .javascript_context_ids
893 .get(&context.context_id)
894 .cloned()
895 .ok_or_else(|| WasmExecutionError::MissingContext(context.context_id.clone()))?;
896 {
897 let import_cache = self.import_caches.entry(context.vm_id.clone()).or_default();
898 import_cache
899 .ensure_materialized_with_timeout(prewarm_timeout)
900 .map_err(WasmExecutionError::PrepareWarmPath)?;
901 }
902 let frozen_time_ms = frozen_time_ms();
903 validate_module_limits(&resolved_module, &request)?;
904 wasm_stack_limit_bytes(&request)?;
908 let execution_timeout = resolve_wasm_execution_timeout(&request)?;
909 let import_cache = self
910 .import_caches
911 .get(&context.vm_id)
912 .expect("vm import cache should exist after materialization");
913 let warmup_metrics = match prewarm_wasm_path(
914 import_cache,
915 &mut self.javascript_engine,
916 &javascript_context_id,
917 &resolved_module,
918 &request,
919 frozen_time_ms,
920 prewarm_timeout,
921 ) {
922 Ok(metrics) => metrics,
923 Err(WasmExecutionError::WarmupTimeout(_)) => None,
924 Err(error) => return Err(error),
925 };
926
927 self.next_execution_id += 1;
928 let execution_id = format!("exec-{}", self.next_execution_id);
929 let javascript_execution = start_wasm_javascript_execution(
930 &mut self.javascript_engine,
931 import_cache,
932 &javascript_context_id,
933 &resolved_module,
934 &request,
935 WasmJavascriptExecutionOptions {
936 frozen_time_ms,
937 prewarm_only: false,
938 warmup_metrics: warmup_metrics.as_deref(),
939 },
940 )?;
941 let child_pid = javascript_execution.child_pid();
942 let sandbox_root = wasm_sandbox_root(&request.env);
943 let guest_path_mappings = wasm_guest_path_mappings(&request);
944
945 Ok(WasmExecution {
946 execution_id,
947 child_pid,
948 inner: javascript_execution,
949 execution_timeout,
950 execution_started_at: Instant::now(),
951 timeout_reported: false,
952 fuel_gauge: execution_timeout.map(|limit| {
955 register_limit(
956 TrackedLimit::WasmFuelMs,
957 duration_millis_saturating_usize(limit),
958 )
959 }),
960 pending_events: VecDeque::new(),
961 stdout_stream_buffer: Vec::new(),
962 stderr_stream_buffer: Vec::new(),
963 internal_sync_rpc: WasmInternalSyncRpc {
964 module_guest_paths: wasm_guest_module_paths(
965 &resolved_module.specifier,
966 &request.env,
967 ),
968 module_host_path: resolved_module.resolved_path.clone(),
969 guest_cwd: wasm_guest_cwd(&request.env),
970 host_cwd: request.cwd.clone(),
971 sandbox_root: sandbox_root.clone(),
972 guest_path_mappings,
973 route_fs_through_sidecar: sandbox_root.is_some(),
974 next_fd: 64,
975 open_files: BTreeMap::new(),
976 pending_events: VecDeque::new(),
977 },
978 })
979 }
980
981 pub fn dispose_vm(&mut self, vm_id: &str) {
982 self.contexts.retain(|_, context| context.vm_id != vm_id);
983 self.javascript_context_ids
984 .retain(|wasm_context_id, _| self.contexts.contains_key(wasm_context_id));
985 self.import_caches.remove(vm_id);
986 self.javascript_engine.dispose_vm(vm_id);
987 }
988}
989
990fn map_javascript_error(error: JavascriptExecutionError) -> WasmExecutionError {
991 match error {
992 JavascriptExecutionError::EmptyArgv => WasmExecutionError::Spawn(std::io::Error::new(
993 std::io::ErrorKind::InvalidInput,
994 "guest WebAssembly bootstrap requires a JavaScript entrypoint",
995 )),
996 JavascriptExecutionError::MissingContext(context_id) => {
997 WasmExecutionError::MissingContext(context_id)
998 }
999 JavascriptExecutionError::VmMismatch { expected, found } => {
1000 WasmExecutionError::VmMismatch { expected, found }
1001 }
1002 JavascriptExecutionError::PrepareImportCache(error) => {
1003 WasmExecutionError::PrepareWarmPath(error)
1004 }
1005 JavascriptExecutionError::Spawn(error) => WasmExecutionError::Spawn(error),
1006 JavascriptExecutionError::PendingSyncRpcRequest(id) => WasmExecutionError::RpcResponse(
1007 format!("guest WebAssembly sync RPC request {id} is still pending"),
1008 ),
1009 JavascriptExecutionError::ExpiredSyncRpcRequest(id) => WasmExecutionError::RpcResponse(
1010 format!("guest WebAssembly sync RPC request {id} is no longer pending"),
1011 ),
1012 JavascriptExecutionError::RpcResponse(message) => WasmExecutionError::RpcResponse(message),
1013 JavascriptExecutionError::Terminate(error) => WasmExecutionError::Spawn(error),
1014 JavascriptExecutionError::StdinClosed => WasmExecutionError::StdinClosed,
1015 JavascriptExecutionError::Stdin(error) => WasmExecutionError::Stdin(error),
1016 JavascriptExecutionError::OutputBufferExceeded { stream, limit } => {
1017 WasmExecutionError::OutputBufferExceeded { stream, limit }
1018 }
1019 JavascriptExecutionError::EventChannelClosed => WasmExecutionError::EventChannelClosed,
1020 }
1021}
1022
1023fn handle_internal_wasm_sync_rpc_request(
1024 execution: &mut JavascriptExecution,
1025 internal_sync_rpc: &mut WasmInternalSyncRpc,
1026 request: &JavascriptSyncRpcRequest,
1027) -> Result<bool, WasmExecutionError> {
1028 if execution
1032 .try_service_standalone_module_sync_rpc(request)
1033 .map_err(map_javascript_error)?
1034 {
1035 return Ok(true);
1036 }
1037
1038 if matches!(
1039 request.method.as_str(),
1040 "fs.promises.readFile" | "fs.readFileSync"
1041 ) && request
1042 .args
1043 .first()
1044 .and_then(Value::as_str)
1045 .is_some_and(|path| {
1046 internal_sync_rpc
1047 .module_guest_paths
1048 .iter()
1049 .any(|candidate| candidate == path)
1050 })
1051 {
1052 let module_bytes =
1053 fs::read(&internal_sync_rpc.module_host_path).map_err(WasmExecutionError::Spawn)?;
1054 execution
1055 .respond_sync_rpc_success(
1056 request.id,
1057 Value::String(v8_runtime::base64_encode_pub(&module_bytes)),
1058 )
1059 .map_err(map_javascript_error)?;
1060 return Ok(true);
1061 }
1062
1063 if wasm_sync_rpc_method_routes_through_sidecar_kernel(request, internal_sync_rpc) {
1064 return Ok(false);
1065 }
1066
1067 if request.method == "__kernel_isatty" {
1068 execution
1069 .respond_sync_rpc_success(request.id, Value::Bool(false))
1070 .map_err(map_javascript_error)?;
1071 return Ok(true);
1072 }
1073
1074 if request.method == "__kernel_tty_size" {
1075 execution
1076 .respond_sync_rpc_success(request.id, json!([80, 24]))
1077 .map_err(map_javascript_error)?;
1078 return Ok(true);
1079 }
1080
1081 if request.method == "fs.openSync" {
1082 let Some(path) = request.args.first().and_then(Value::as_str) else {
1083 return Err(WasmExecutionError::RpcResponse(String::from(
1084 "missing fs.openSync path",
1085 )));
1086 };
1087 let Some(host_path) = translate_wasm_guest_path(path, internal_sync_rpc) else {
1088 return Ok(false);
1089 };
1090 let flags = request.args.get(1).unwrap_or(&Value::Null);
1091 if wasm_open_flags_require_write(flags)
1092 && wasm_host_path_is_read_only(&host_path, internal_sync_rpc)
1093 {
1094 return respond_wasm_sync_rpc_value(
1095 execution,
1096 request,
1097 path,
1098 Err(wasm_read_only_filesystem_error(path)),
1099 )
1100 .map(|()| true);
1101 }
1102 let file = match open_wasm_guest_file(&host_path, flags) {
1103 Ok(file) => file,
1104 Err(error) => {
1105 return respond_wasm_sync_rpc_value(execution, request, path, Err(error))
1106 .map(|()| true);
1107 }
1108 };
1109 let fd = internal_sync_rpc.next_fd;
1110 internal_sync_rpc.next_fd += 1;
1111 internal_sync_rpc.open_files.insert(fd, file);
1112 execution
1113 .respond_sync_rpc_success(request.id, json!(fd))
1114 .map_err(map_javascript_error)?;
1115 return Ok(true);
1116 }
1117
1118 if matches!(request.method.as_str(), "fs.statSync" | "fs.lstatSync") {
1119 let Some(path) = request.args.first().and_then(Value::as_str) else {
1120 return Err(WasmExecutionError::RpcResponse(format!(
1121 "missing {} path",
1122 request.method
1123 )));
1124 };
1125 let Some(host_path) = translate_wasm_guest_path(path, internal_sync_rpc) else {
1126 return Ok(false);
1127 };
1128 let metadata = if request.method == "fs.lstatSync" {
1129 fs::symlink_metadata(&host_path)
1130 } else {
1131 fs::metadata(&host_path)
1132 };
1133 return respond_wasm_sync_rpc_metadata(execution, request, path, metadata).map(|()| true);
1134 }
1135
1136 if request.method == "fs.fstatSync" {
1137 let Some(fd) = request.args.first().and_then(Value::as_u64) else {
1138 return Err(WasmExecutionError::RpcResponse(String::from(
1139 "missing fs.fstatSync fd",
1140 )));
1141 };
1142 let Some(file) = internal_sync_rpc.open_files.get(&(fd as u32)) else {
1143 return Ok(false);
1144 };
1145 return respond_wasm_sync_rpc_metadata(
1146 execution,
1147 request,
1148 &fd.to_string(),
1149 file.metadata(),
1150 )
1151 .map(|()| true);
1152 }
1153
1154 if request.method == "fs.ftruncateSync" {
1155 let Some(fd) = request.args.first().and_then(Value::as_u64) else {
1156 return Err(WasmExecutionError::RpcResponse(String::from(
1157 "missing fs.ftruncateSync fd",
1158 )));
1159 };
1160 let length = request.args.get(1).and_then(Value::as_u64).unwrap_or(0);
1161 let Some(file) = internal_sync_rpc.open_files.get_mut(&(fd as u32)) else {
1162 return Ok(false);
1163 };
1164 let result = file.set_len(length);
1165 return respond_wasm_sync_rpc_unit(execution, request, &fd.to_string(), result)
1166 .map(|()| true);
1167 }
1168
1169 if request.method == "fs.closeSync" {
1170 let Some(fd) = request.args.first().and_then(Value::as_u64) else {
1171 return Err(WasmExecutionError::RpcResponse(String::from(
1172 "missing fs.closeSync fd",
1173 )));
1174 };
1175 if internal_sync_rpc.open_files.remove(&(fd as u32)).is_none() {
1176 return Ok(false);
1177 }
1178 execution
1179 .respond_sync_rpc_success(request.id, Value::Null)
1180 .map_err(map_javascript_error)?;
1181 return Ok(true);
1182 }
1183
1184 if request.method == "fs.chmodSync" {
1185 let Some(path) = request.args.first().and_then(Value::as_str) else {
1186 return Err(WasmExecutionError::RpcResponse(String::from(
1187 "missing fs.chmodSync path",
1188 )));
1189 };
1190 let Some(host_path) = translate_wasm_guest_path(path, internal_sync_rpc) else {
1191 return Ok(false);
1192 };
1193 if wasm_host_path_is_read_only(&host_path, internal_sync_rpc) {
1194 return respond_wasm_sync_rpc_unit(
1195 execution,
1196 request,
1197 path,
1198 Err(wasm_read_only_filesystem_error(path)),
1199 )
1200 .map(|()| true);
1201 }
1202 let mode = request.args.get(1).and_then(Value::as_u64).unwrap_or(0) as u32;
1203 let result = (|| -> Result<(), std::io::Error> {
1204 let mut permissions = fs::metadata(&host_path)?.permissions();
1205 permissions.set_mode(mode);
1206 fs::set_permissions(&host_path, permissions)
1207 })();
1208 return respond_wasm_sync_rpc_unit(execution, request, path, result).map(|()| true);
1209 }
1210
1211 if request.method == "fs.mkdirSync" {
1212 let Some(path) = request.args.first().and_then(Value::as_str) else {
1213 return Err(WasmExecutionError::RpcResponse(String::from(
1214 "missing fs.mkdirSync path",
1215 )));
1216 };
1217 let Some(host_path) = translate_wasm_guest_path(path, internal_sync_rpc) else {
1218 return Ok(false);
1219 };
1220 if wasm_host_path_is_read_only(&host_path, internal_sync_rpc) {
1221 return respond_wasm_sync_rpc_unit(
1222 execution,
1223 request,
1224 path,
1225 Err(wasm_read_only_filesystem_error(path)),
1226 )
1227 .map(|()| true);
1228 }
1229 let recursive = request
1230 .args
1231 .get(1)
1232 .map(|value| match value {
1233 Value::Bool(flag) => *flag,
1234 Value::Object(options) => options
1235 .get("recursive")
1236 .and_then(Value::as_bool)
1237 .unwrap_or(false),
1238 _ => false,
1239 })
1240 .unwrap_or(false);
1241 let result = if recursive {
1242 fs::create_dir_all(&host_path)
1243 } else {
1244 fs::create_dir(&host_path)
1245 };
1246 return respond_wasm_sync_rpc_unit(execution, request, path, result).map(|()| true);
1247 }
1248
1249 if request.method == "fs.rmdirSync" {
1250 let Some(path) = request.args.first().and_then(Value::as_str) else {
1251 return Err(WasmExecutionError::RpcResponse(String::from(
1252 "missing fs.rmdirSync path",
1253 )));
1254 };
1255 let Some(host_path) = translate_wasm_guest_path(path, internal_sync_rpc) else {
1256 return Ok(false);
1257 };
1258 if wasm_host_path_is_read_only(&host_path, internal_sync_rpc) {
1259 return respond_wasm_sync_rpc_unit(
1260 execution,
1261 request,
1262 path,
1263 Err(wasm_read_only_filesystem_error(path)),
1264 )
1265 .map(|()| true);
1266 }
1267 return respond_wasm_sync_rpc_unit(execution, request, path, fs::remove_dir(&host_path))
1268 .map(|()| true);
1269 }
1270
1271 if request.method == "fs.unlinkSync" {
1272 let Some(path) = request.args.first().and_then(Value::as_str) else {
1273 return Err(WasmExecutionError::RpcResponse(String::from(
1274 "missing fs.unlinkSync path",
1275 )));
1276 };
1277 let Some(host_path) = translate_wasm_guest_path(path, internal_sync_rpc) else {
1278 return Ok(false);
1279 };
1280 if wasm_host_path_is_read_only(&host_path, internal_sync_rpc) {
1281 return respond_wasm_sync_rpc_unit(
1282 execution,
1283 request,
1284 path,
1285 Err(wasm_read_only_filesystem_error(path)),
1286 )
1287 .map(|()| true);
1288 }
1289 return respond_wasm_sync_rpc_unit(execution, request, path, fs::remove_file(&host_path))
1290 .map(|()| true);
1291 }
1292
1293 if request.method == "fs.renameSync" {
1294 let Some(source) = request.args.first().and_then(Value::as_str) else {
1295 return Err(WasmExecutionError::RpcResponse(String::from(
1296 "missing fs.renameSync source",
1297 )));
1298 };
1299 let Some(destination) = request.args.get(1).and_then(Value::as_str) else {
1300 return Err(WasmExecutionError::RpcResponse(String::from(
1301 "missing fs.renameSync destination",
1302 )));
1303 };
1304 let Some(host_source) = translate_wasm_guest_path(source, internal_sync_rpc) else {
1305 return Ok(false);
1306 };
1307 let Some(host_destination) = translate_wasm_guest_path(destination, internal_sync_rpc)
1308 else {
1309 return Ok(false);
1310 };
1311 if wasm_mutation_touches_read_only_mapping(
1312 &host_source,
1313 &host_destination,
1314 internal_sync_rpc,
1315 ) {
1316 return respond_wasm_sync_rpc_unit(
1317 execution,
1318 request,
1319 source,
1320 Err(wasm_read_only_filesystem_error(source)),
1321 )
1322 .map(|()| true);
1323 }
1324 return respond_wasm_sync_rpc_unit(
1325 execution,
1326 request,
1327 source,
1328 fs::rename(&host_source, &host_destination),
1329 )
1330 .map(|()| true);
1331 }
1332
1333 if request.method == "fs.linkSync" {
1334 let Some(source) = request.args.first().and_then(Value::as_str) else {
1335 return Err(WasmExecutionError::RpcResponse(String::from(
1336 "missing fs.linkSync source",
1337 )));
1338 };
1339 let Some(destination) = request.args.get(1).and_then(Value::as_str) else {
1340 return Err(WasmExecutionError::RpcResponse(String::from(
1341 "missing fs.linkSync destination",
1342 )));
1343 };
1344 let Some(host_source) = translate_wasm_guest_path(source, internal_sync_rpc) else {
1345 return Ok(false);
1346 };
1347 let Some(host_destination) = translate_wasm_guest_path(destination, internal_sync_rpc)
1348 else {
1349 return Ok(false);
1350 };
1351 if wasm_host_path_is_read_only(&host_source, internal_sync_rpc)
1352 || wasm_host_path_is_read_only(&host_destination, internal_sync_rpc)
1353 {
1354 return respond_wasm_sync_rpc_unit(
1355 execution,
1356 request,
1357 source,
1358 Err(wasm_read_only_filesystem_error(source)),
1359 )
1360 .map(|()| true);
1361 }
1362 return respond_wasm_sync_rpc_unit(
1363 execution,
1364 request,
1365 source,
1366 fs::hard_link(&host_source, &host_destination),
1367 )
1368 .map(|()| true);
1369 }
1370
1371 if request.method == "fs.symlinkSync" {
1372 let Some(target) = request.args.first().and_then(Value::as_str) else {
1373 return Err(WasmExecutionError::RpcResponse(String::from(
1374 "missing fs.symlinkSync target",
1375 )));
1376 };
1377 let Some(link_path) = request.args.get(1).and_then(Value::as_str) else {
1378 return Err(WasmExecutionError::RpcResponse(String::from(
1379 "missing fs.symlinkSync path",
1380 )));
1381 };
1382 let target_path = if target.starts_with('/') {
1383 let Some(path) = translate_wasm_guest_path(target, internal_sync_rpc) else {
1384 return Ok(false);
1385 };
1386 path
1387 } else {
1388 PathBuf::from(target)
1389 };
1390 let Some(host_link_path) = translate_wasm_guest_path(link_path, internal_sync_rpc) else {
1391 return Ok(false);
1392 };
1393 if wasm_host_path_is_read_only(&host_link_path, internal_sync_rpc) {
1394 return respond_wasm_sync_rpc_unit(
1395 execution,
1396 request,
1397 link_path,
1398 Err(wasm_read_only_filesystem_error(link_path)),
1399 )
1400 .map(|()| true);
1401 }
1402 return respond_wasm_sync_rpc_unit(
1403 execution,
1404 request,
1405 link_path,
1406 std::os::unix::fs::symlink(&target_path, &host_link_path),
1407 )
1408 .map(|()| true);
1409 }
1410
1411 if request.method == "fs.readdirSync" {
1412 let Some(path) = request.args.first().and_then(Value::as_str) else {
1413 return Err(WasmExecutionError::RpcResponse(String::from(
1414 "missing fs.readdirSync path",
1415 )));
1416 };
1417 let Some(host_path) = translate_wasm_guest_path(path, internal_sync_rpc) else {
1418 return Ok(false);
1419 };
1420 let entries = fs::read_dir(&host_path)
1421 .and_then(|entries| {
1422 entries
1423 .map(|entry| {
1424 entry.map(|value| value.file_name().to_string_lossy().into_owned())
1425 })
1426 .collect::<Result<Vec<_>, _>>()
1427 })
1428 .map(|entries| json!(entries));
1429 return respond_wasm_sync_rpc_value(execution, request, path, entries).map(|()| true);
1430 }
1431
1432 if request.method == "fs.readlinkSync" {
1433 let Some(path) = request.args.first().and_then(Value::as_str) else {
1434 return Err(WasmExecutionError::RpcResponse(String::from(
1435 "missing fs.readlinkSync path",
1436 )));
1437 };
1438 let Some(host_path) = translate_wasm_guest_path(path, internal_sync_rpc) else {
1439 return Ok(false);
1440 };
1441 let target = fs::read_link(&host_path).map(|target| {
1442 Value::String(
1443 translate_wasm_host_symlink_target(&target, internal_sync_rpc)
1444 .unwrap_or_else(|| target.to_string_lossy().into_owned()),
1445 )
1446 });
1447 return respond_wasm_sync_rpc_value(execution, request, path, target).map(|()| true);
1448 }
1449
1450 if request.method == "fs.writeSync" {
1451 let Some(fd) = request.args.first().and_then(Value::as_u64) else {
1452 return Err(WasmExecutionError::RpcResponse(String::from(
1453 "missing fs.writeSync fd",
1454 )));
1455 };
1456 let bytes = decode_wasm_bytes_arg(
1457 request.args.get(1),
1458 "fs.writeSync bytes",
1459 WASM_CAPTURED_OUTPUT_LIMIT_BYTES,
1460 )?;
1461 if fd == 1 || fd == 2 {
1462 let bytes_len = bytes.len();
1463 internal_sync_rpc.pending_events.push_back(if fd == 1 {
1464 WasmExecutionEvent::Stdout(bytes)
1465 } else {
1466 WasmExecutionEvent::Stderr(bytes)
1467 });
1468 execution
1469 .respond_sync_rpc_success(request.id, json!(bytes_len))
1470 .map_err(map_javascript_error)?;
1471 return Ok(true);
1472 }
1473 let position = request.args.get(2).and_then(Value::as_u64);
1474 let Some(file) = internal_sync_rpc.open_files.get_mut(&(fd as u32)) else {
1475 return Ok(false);
1476 };
1477 let written = if let Some(position) = position {
1478 file.write_at(&bytes, position)
1479 .map_err(WasmExecutionError::Spawn)?
1480 } else {
1481 file.write(&bytes).map_err(WasmExecutionError::Spawn)?
1482 };
1483 execution
1484 .respond_sync_rpc_success(request.id, json!(written))
1485 .map_err(map_javascript_error)?;
1486 return Ok(true);
1487 }
1488
1489 if request.method == "fs.readSync" {
1490 let Some(fd) = request.args.first().and_then(Value::as_u64) else {
1491 return Err(WasmExecutionError::RpcResponse(String::from(
1492 "missing fs.readSync fd",
1493 )));
1494 };
1495 let length = wasm_sync_read_length(request.args.get(1).and_then(Value::as_u64))?;
1496 let position = request.args.get(2).and_then(Value::as_u64);
1497 let Some(file) = internal_sync_rpc.open_files.get_mut(&(fd as u32)) else {
1498 return Ok(false);
1499 };
1500 let mut buffer = vec![0u8; length];
1501 let bytes_read = if let Some(position) = position {
1502 file.read_at(&mut buffer, position)
1503 .map_err(WasmExecutionError::Spawn)?
1504 } else {
1505 file.read(&mut buffer).map_err(WasmExecutionError::Spawn)?
1506 };
1507 buffer.truncate(bytes_read);
1508 execution
1509 .respond_sync_rpc_success(
1510 request.id,
1511 json!({
1512 "__agentOSType": "bytes",
1513 "base64": v8_runtime::base64_encode_pub(&buffer),
1514 }),
1515 )
1516 .map_err(map_javascript_error)?;
1517 return Ok(true);
1518 }
1519
1520 Ok(false)
1521}
1522
1523fn wasm_sync_rpc_method_routes_through_sidecar_kernel(
1524 request: &JavascriptSyncRpcRequest,
1525 internal_sync_rpc: &WasmInternalSyncRpc,
1526) -> bool {
1527 internal_sync_rpc.route_fs_through_sidecar
1528 && WASM_SIDECAR_ROUTED_FS_SYNC_METHODS.contains(&request.method.as_str())
1529}
1530
1531fn translate_wasm_guest_path(
1532 path: &str,
1533 internal_sync_rpc: &WasmInternalSyncRpc,
1534) -> Option<PathBuf> {
1535 if let Some(host_path) = translate_wasm_host_runtime_path(path, internal_sync_rpc) {
1536 return confine_wasm_host_path(host_path, internal_sync_rpc);
1537 }
1538
1539 let normalized_path = if path.starts_with('/') {
1540 normalize_guest_path(path)
1541 } else {
1542 join_guest_path(&internal_sync_rpc.guest_cwd, path)
1543 };
1544
1545 if normalized_path == internal_sync_rpc.module_host_path.to_string_lossy() {
1546 return Some(internal_sync_rpc.module_host_path.clone());
1547 }
1548 if internal_sync_rpc
1549 .module_guest_paths
1550 .iter()
1551 .any(|candidate| candidate == &normalized_path)
1552 {
1553 return Some(internal_sync_rpc.module_host_path.clone());
1554 }
1555 for mapping in &internal_sync_rpc.guest_path_mappings {
1556 if let Some(suffix) = strip_guest_prefix(&normalized_path, &mapping.guest_path) {
1557 return confine_wasm_host_path(
1558 join_host_path(&mapping.host_path, &suffix),
1559 internal_sync_rpc,
1560 );
1561 }
1562 }
1563 if let Some(suffix) = strip_guest_prefix(&normalized_path, &internal_sync_rpc.guest_cwd) {
1564 return confine_wasm_host_path(
1565 join_host_path(&internal_sync_rpc.host_cwd, &suffix),
1566 internal_sync_rpc,
1567 );
1568 }
1569 if normalized_path.starts_with('/') {
1570 let root_candidate = internal_sync_rpc
1571 .sandbox_root
1572 .as_ref()
1573 .map(|root| join_host_path(root, normalized_path.trim_start_matches('/')));
1574 if let Some(candidate) = root_candidate.as_ref() {
1575 if candidate.exists() {
1576 return confine_wasm_host_path(candidate.clone(), internal_sync_rpc);
1577 }
1578 }
1579
1580 if internal_sync_rpc.guest_cwd != "/" {
1584 let cwd_relative_guest_path = join_guest_path(
1585 &internal_sync_rpc.guest_cwd,
1586 normalized_path.trim_start_matches('/'),
1587 );
1588 for mapping in &internal_sync_rpc.guest_path_mappings {
1589 if let Some(suffix) =
1590 strip_guest_prefix(&cwd_relative_guest_path, &mapping.guest_path)
1591 {
1592 let candidate = join_host_path(&mapping.host_path, &suffix);
1593 if candidate.exists() {
1594 return confine_wasm_host_path(candidate, internal_sync_rpc);
1595 }
1596 }
1597 }
1598 if let Some(suffix) =
1599 strip_guest_prefix(&cwd_relative_guest_path, &internal_sync_rpc.guest_cwd)
1600 {
1601 let candidate = join_host_path(&internal_sync_rpc.host_cwd, &suffix);
1602 if candidate.exists() {
1603 return confine_wasm_host_path(candidate, internal_sync_rpc);
1604 }
1605 }
1606 }
1607
1608 return root_candidate.and_then(|path| confine_wasm_host_path(path, internal_sync_rpc));
1609 }
1610 None
1611}
1612
1613fn confine_wasm_host_path(
1614 host_path: PathBuf,
1615 internal_sync_rpc: &WasmInternalSyncRpc,
1616) -> Option<PathBuf> {
1617 if host_path == internal_sync_rpc.module_host_path {
1618 return Some(host_path);
1619 }
1620
1621 let allowed_roots = wasm_allowed_host_roots(internal_sync_rpc);
1622 if allowed_roots.is_empty() {
1623 return None;
1624 }
1625
1626 if let Ok(canonical_path) = fs::canonicalize(&host_path) {
1627 return wasm_canonical_path_is_allowed(&canonical_path, &allowed_roots)
1628 .then_some(host_path);
1629 }
1630
1631 let existing_ancestor = nearest_existing_wasm_host_ancestor(&host_path)?;
1632 let canonical_ancestor = fs::canonicalize(existing_ancestor).ok()?;
1633 wasm_canonical_path_is_allowed(&canonical_ancestor, &allowed_roots).then_some(host_path)
1634}
1635
1636fn wasm_allowed_host_roots(internal_sync_rpc: &WasmInternalSyncRpc) -> Vec<PathBuf> {
1637 let mut roots = Vec::new();
1638 for root in internal_sync_rpc
1639 .guest_path_mappings
1640 .iter()
1641 .map(|mapping| mapping.host_path.as_path())
1642 .chain(std::iter::once(internal_sync_rpc.host_cwd.as_path()))
1643 .chain(internal_sync_rpc.sandbox_root.as_deref())
1644 {
1645 if let Ok(canonical_root) = fs::canonicalize(root) {
1646 if !roots.iter().any(|existing| existing == &canonical_root) {
1647 roots.push(canonical_root);
1648 }
1649 }
1650 }
1651 roots
1652}
1653
1654fn wasm_canonical_path_is_allowed(path: &Path, allowed_roots: &[PathBuf]) -> bool {
1655 allowed_roots
1656 .iter()
1657 .any(|root| path == root || path.starts_with(root))
1658}
1659
1660fn nearest_existing_wasm_host_ancestor(path: &Path) -> Option<&Path> {
1661 let mut candidate = Some(path);
1662 while let Some(current) = candidate {
1663 if fs::symlink_metadata(current).is_ok() {
1664 return Some(current);
1665 }
1666 candidate = current.parent();
1667 }
1668 None
1669}
1670
1671fn translate_wasm_host_runtime_path(
1672 path: &str,
1673 internal_sync_rpc: &WasmInternalSyncRpc,
1674) -> Option<PathBuf> {
1675 let candidate = Path::new(path);
1676 if !candidate.is_absolute() {
1677 return None;
1678 }
1679
1680 if candidate == internal_sync_rpc.module_host_path {
1681 return Some(candidate.to_path_buf());
1682 }
1683
1684 let mapped_host_root = internal_sync_rpc
1685 .guest_path_mappings
1686 .iter()
1687 .map(|mapping| mapping.host_path.as_path())
1688 .find(|root| candidate == *root || candidate.starts_with(root));
1689 if let Some(root) = mapped_host_root {
1690 let _ = root;
1691 return Some(candidate.to_path_buf());
1692 }
1693
1694 if candidate == internal_sync_rpc.host_cwd || candidate.starts_with(&internal_sync_rpc.host_cwd)
1695 {
1696 return Some(candidate.to_path_buf());
1697 }
1698
1699 if let Some(sandbox_root) = internal_sync_rpc.sandbox_root.as_ref() {
1700 if candidate == sandbox_root || candidate.starts_with(sandbox_root) {
1701 return Some(candidate.to_path_buf());
1702 }
1703 }
1704
1705 None
1706}
1707
1708fn translate_wasm_host_symlink_target(
1709 target: &Path,
1710 internal_sync_rpc: &WasmInternalSyncRpc,
1711) -> Option<String> {
1712 if !target.is_absolute() {
1713 return None;
1714 }
1715
1716 for mapping in &internal_sync_rpc.guest_path_mappings {
1717 if let Ok(suffix) = target.strip_prefix(&mapping.host_path) {
1718 return Some(join_guest_path(
1719 &mapping.guest_path,
1720 &suffix.to_string_lossy().replace('\\', "/"),
1721 ));
1722 }
1723 }
1724
1725 if let Some(suffix) = target
1726 .strip_prefix(&internal_sync_rpc.host_cwd)
1727 .ok()
1728 .filter(|_| internal_sync_rpc.guest_cwd.starts_with('/'))
1729 {
1730 return Some(join_guest_path(
1731 &internal_sync_rpc.guest_cwd,
1732 &suffix.to_string_lossy().replace('\\', "/"),
1733 ));
1734 }
1735
1736 if let Some(sandbox_root) = internal_sync_rpc.sandbox_root.as_ref() {
1737 if let Ok(suffix) = target.strip_prefix(sandbox_root) {
1738 return Some(join_guest_path(
1739 "/",
1740 &suffix.to_string_lossy().replace('\\', "/"),
1741 ));
1742 }
1743 }
1744
1745 None
1746}
1747
1748fn wasm_host_path_is_read_only(host_path: &Path, internal_sync_rpc: &WasmInternalSyncRpc) -> bool {
1749 let canonical_path = fs::canonicalize(host_path)
1750 .ok()
1751 .or_else(|| {
1752 nearest_existing_wasm_host_ancestor(host_path)
1753 .and_then(|ancestor| fs::canonicalize(ancestor).ok())
1754 })
1755 .unwrap_or_else(|| host_path.to_path_buf());
1756
1757 internal_sync_rpc
1758 .guest_path_mappings
1759 .iter()
1760 .filter_map(|mapping| {
1761 let root = fs::canonicalize(&mapping.host_path).ok()?;
1762 (canonical_path == root || canonical_path.starts_with(&root))
1763 .then_some((root.components().count(), mapping.read_only))
1764 })
1765 .max_by_key(|(depth, _)| *depth)
1766 .is_some_and(|(_, read_only)| read_only)
1767}
1768
1769fn wasm_mutation_touches_read_only_mapping(
1770 source: &Path,
1771 destination: &Path,
1772 internal_sync_rpc: &WasmInternalSyncRpc,
1773) -> bool {
1774 wasm_host_path_is_read_only(source, internal_sync_rpc)
1775 || wasm_host_path_is_read_only(destination, internal_sync_rpc)
1776}
1777
1778fn wasm_open_flags_require_write(flags: &Value) -> bool {
1779 match flags.as_str() {
1780 Some(value) => value.contains('w') || value.contains('a') || value.contains('+'),
1781 None if flags.as_u64().unwrap_or(0) == 0 => false,
1782 _ => {
1783 let numeric = flags.as_u64().unwrap_or(0);
1784 (numeric & 0o1) != 0
1785 || (numeric & 0o2) != 0
1786 || (numeric & 0o100) != 0
1787 || (numeric & 0o1000) != 0
1788 || (numeric & 0o2000) != 0
1789 }
1790 }
1791}
1792
1793fn wasm_read_only_filesystem_error(path: &str) -> std::io::Error {
1794 let _ = path;
1795 std::io::Error::from_raw_os_error(30)
1796}
1797
1798fn respond_wasm_sync_rpc_metadata(
1799 execution: &mut JavascriptExecution,
1800 request: &JavascriptSyncRpcRequest,
1801 label: &str,
1802 metadata: Result<fs::Metadata, std::io::Error>,
1803) -> Result<(), WasmExecutionError> {
1804 respond_wasm_sync_rpc_value(
1805 execution,
1806 request,
1807 label,
1808 metadata.map(|value| wasm_host_stat_value(&value)),
1809 )
1810}
1811
1812fn respond_wasm_sync_rpc_unit(
1813 execution: &mut JavascriptExecution,
1814 request: &JavascriptSyncRpcRequest,
1815 label: &str,
1816 result: Result<(), std::io::Error>,
1817) -> Result<(), WasmExecutionError> {
1818 respond_wasm_sync_rpc_value(execution, request, label, result.map(|()| Value::Null))
1819}
1820
1821fn respond_wasm_sync_rpc_value(
1822 execution: &mut JavascriptExecution,
1823 request: &JavascriptSyncRpcRequest,
1824 label: &str,
1825 result: Result<Value, std::io::Error>,
1826) -> Result<(), WasmExecutionError> {
1827 match result {
1828 Ok(value) => execution
1829 .respond_sync_rpc_success(request.id, value)
1830 .map_err(map_javascript_error),
1831 Err(error) => execution
1832 .respond_sync_rpc_error(
1833 request.id,
1834 wasm_sync_rpc_error_code(&error),
1835 format!("{} {} failed: {error}", request.method, label),
1836 )
1837 .map_err(map_javascript_error),
1838 }
1839}
1840
1841fn wasm_sync_rpc_error_code(error: &std::io::Error) -> &'static str {
1842 use std::io::ErrorKind;
1843
1844 if error.raw_os_error() == Some(30) {
1845 return "EROFS";
1846 }
1847
1848 match error.kind() {
1849 ErrorKind::NotFound => "ENOENT",
1850 ErrorKind::PermissionDenied => "EACCES",
1851 ErrorKind::AlreadyExists => "EEXIST",
1852 ErrorKind::InvalidInput => "EINVAL",
1853 ErrorKind::IsADirectory => "EISDIR",
1854 ErrorKind::NotADirectory => "ENOTDIR",
1855 _ => "EIO",
1856 }
1857}
1858
1859fn wasm_host_stat_value(metadata: &fs::Metadata) -> Value {
1860 json!({
1861 "mode": metadata.mode(),
1862 "size": metadata.size(),
1863 "blocks": metadata.blocks(),
1864 "dev": metadata.dev(),
1865 "rdev": metadata.rdev(),
1866 "isDirectory": metadata.is_dir(),
1867 "isSymbolicLink": metadata.file_type().is_symlink(),
1868 "atimeMs": metadata.atime() * 1000 + (metadata.atime_nsec() / 1_000_000),
1869 "mtimeMs": metadata.mtime() * 1000 + (metadata.mtime_nsec() / 1_000_000),
1870 "ctimeMs": metadata.ctime() * 1000 + (metadata.ctime_nsec() / 1_000_000),
1871 "birthtimeMs": metadata.ctime() * 1000 + (metadata.ctime_nsec() / 1_000_000),
1872 "ino": metadata.ino(),
1873 "nlink": metadata.nlink(),
1874 "uid": metadata.uid(),
1875 "gid": metadata.gid(),
1876 })
1877}
1878
1879fn strip_guest_prefix(path: &str, prefix: &str) -> Option<String> {
1880 let normalized_path = normalize_guest_path(path);
1881 let normalized_prefix = normalize_guest_path(prefix);
1882 if normalized_path == normalized_prefix {
1883 return Some(String::new());
1884 }
1885 normalized_path
1886 .strip_prefix(&(normalized_prefix + "/"))
1887 .map(str::to_owned)
1888}
1889
1890fn join_host_path(base: &Path, suffix: &str) -> PathBuf {
1891 if suffix.is_empty() {
1892 return base.to_path_buf();
1893 }
1894 suffix
1895 .split('/')
1896 .filter(|segment| !segment.is_empty())
1897 .fold(base.to_path_buf(), |path, segment| path.join(segment))
1898}
1899
1900fn decode_wasm_bytes_arg(
1901 value: Option<&Value>,
1902 label: &'static str,
1903 limit: usize,
1904) -> Result<Vec<u8>, WasmExecutionError> {
1905 let base64 = value
1906 .and_then(Value::as_object)
1907 .and_then(|value| value.get("base64"))
1908 .and_then(Value::as_str)
1909 .ok_or_else(|| WasmExecutionError::RpcResponse(format!("missing {label}")))?;
1910 let decoded_len = base64_decoded_len(base64)
1911 .ok_or_else(|| WasmExecutionError::RpcResponse(format!("invalid {label} base64")))?;
1912 if decoded_len > limit {
1913 return Err(WasmExecutionError::OutputBufferExceeded {
1914 stream: label,
1915 limit,
1916 });
1917 }
1918 base64::engine::general_purpose::STANDARD
1919 .decode(base64)
1920 .map_err(|_| WasmExecutionError::RpcResponse(format!("invalid {label} base64")))
1921}
1922
1923fn base64_decoded_len(base64: &str) -> Option<usize> {
1924 let len = base64.len();
1925 let padding = base64
1926 .as_bytes()
1927 .iter()
1928 .rev()
1929 .take_while(|byte| **byte == b'=')
1930 .take(2)
1931 .count();
1932 let full_quads = len / 4;
1933 let remainder = len % 4;
1934 let base_len = full_quads.checked_mul(3)?.checked_sub(padding)?;
1935 match remainder {
1936 0 => Some(base_len),
1937 1 => None,
1938 2 => base_len.checked_add(1),
1939 3 => base_len.checked_add(2),
1940 _ => None,
1941 }
1942}
1943
1944fn append_wasm_captured_output(
1945 buffer: &mut Vec<u8>,
1946 chunk: &[u8],
1947 stream: &'static str,
1948) -> Result<(), WasmExecutionError> {
1949 ensure_wasm_output_capacity(buffer.len(), chunk.len(), stream)?;
1950 buffer.extend_from_slice(chunk);
1951 Ok(())
1952}
1953
1954fn ensure_wasm_output_capacity(
1955 current_len: usize,
1956 chunk_len: usize,
1957 stream: &'static str,
1958) -> Result<(), WasmExecutionError> {
1959 let Some(next_len) = current_len.checked_add(chunk_len) else {
1960 return Err(WasmExecutionError::OutputBufferExceeded {
1961 stream,
1962 limit: WASM_CAPTURED_OUTPUT_LIMIT_BYTES,
1963 });
1964 };
1965 if next_len > WASM_CAPTURED_OUTPUT_LIMIT_BYTES {
1966 return Err(WasmExecutionError::OutputBufferExceeded {
1967 stream,
1968 limit: WASM_CAPTURED_OUTPUT_LIMIT_BYTES,
1969 });
1970 }
1971 Ok(())
1972}
1973
1974fn wasm_sync_read_length(length: Option<u64>) -> Result<usize, WasmExecutionError> {
1975 let length = length.unwrap_or(0);
1976 let length = usize::try_from(length).map_err(|_| {
1977 WasmExecutionError::InvalidLimit(format!("fs.readSync length {length} exceeds host usize"))
1978 })?;
1979 if length > WASM_SYNC_READ_LIMIT_BYTES {
1980 return Err(WasmExecutionError::InvalidLimit(format!(
1981 "fs.readSync length {length} exceeds maximum {WASM_SYNC_READ_LIMIT_BYTES}"
1982 )));
1983 }
1984 Ok(length)
1985}
1986
1987fn open_wasm_guest_file(path: &Path, flags: &Value) -> std::io::Result<fs::File> {
1988 let mut options = OpenOptions::new();
1989 let flags_label = flags.to_string();
1990
1991 match flags.as_str() {
1992 Some("r") | None if flags.as_u64().unwrap_or(0) == 0 => {
1993 options.read(true);
1994 }
1995 Some("r+") => {
1996 options.read(true).write(true);
1997 }
1998 Some("w") => {
1999 options.write(true).create(true).truncate(true);
2000 }
2001 Some("w+") => {
2002 options.read(true).write(true).create(true).truncate(true);
2003 }
2004 Some("a") => {
2005 options.append(true).create(true);
2006 }
2007 Some("a+") => {
2008 options.read(true).append(true).create(true);
2009 }
2010 _ => {
2011 let numeric = flags.as_u64().ok_or_else(|| {
2012 std::io::Error::new(
2013 std::io::ErrorKind::InvalidInput,
2014 format!("unsupported fs.openSync flags: {flags_label}"),
2015 )
2016 })?;
2017 let write_only = (numeric & 0o1) != 0;
2018 let read_write = (numeric & 0o2) != 0;
2019 let create = (numeric & 0o100) != 0;
2020 let truncate = (numeric & 0o1000) != 0;
2021 let append = (numeric & 0o2000) != 0;
2022
2023 if read_write {
2024 options.read(true).write(true);
2025 } else if write_only {
2026 options.write(true);
2027 } else {
2028 options.read(true);
2029 }
2030 if create {
2031 options.create(true);
2032 }
2033 if truncate {
2034 options.truncate(true);
2035 }
2036 if append {
2037 options.append(true);
2038 }
2039 }
2040 }
2041
2042 options.open(path).map_err(|error| {
2043 std::io::Error::new(
2044 error.kind(),
2045 format!(
2046 "failed to open guest file {} with flags {}: {error}",
2047 path.display(),
2048 flags_label
2049 ),
2050 )
2051 })
2052}
2053
2054fn translate_wasm_signal_state_sync_rpc_request(
2055 execution: &mut JavascriptExecution,
2056 request: &JavascriptSyncRpcRequest,
2057) -> Result<Option<WasmExecutionEvent>, WasmExecutionError> {
2058 if request.method != "process.signal_state" {
2059 return Ok(None);
2060 }
2061
2062 let signal = request
2063 .args
2064 .first()
2065 .and_then(Value::as_u64)
2066 .ok_or_else(|| WasmExecutionError::RpcResponse(String::from("missing signal number")))?;
2067 let action = match request
2068 .args
2069 .get(1)
2070 .and_then(Value::as_str)
2071 .unwrap_or("default")
2072 {
2073 "ignore" => WasmSignalDispositionAction::Ignore,
2074 "user" => WasmSignalDispositionAction::User,
2075 _ => WasmSignalDispositionAction::Default,
2076 };
2077 let mask = request
2078 .args
2079 .get(2)
2080 .and_then(Value::as_str)
2081 .map(serde_json::from_str::<Vec<u32>>)
2082 .transpose()
2083 .map_err(|error| WasmExecutionError::RpcResponse(error.to_string()))?
2084 .unwrap_or_default();
2085 let flags = request
2086 .args
2087 .get(3)
2088 .and_then(Value::as_u64)
2089 .unwrap_or_default() as u32;
2090
2091 execution
2092 .respond_sync_rpc_success(request.id, Value::Null)
2093 .map_err(map_javascript_error)?;
2094
2095 Ok(Some(WasmExecutionEvent::SignalState {
2096 signal: signal as u32,
2097 registration: WasmSignalHandlerRegistration {
2098 action,
2099 mask,
2100 flags,
2101 },
2102 }))
2103}
2104
2105fn parse_wasm_signal_state_line(
2106 line: &[u8],
2107) -> Result<Option<WasmExecutionEvent>, WasmExecutionError> {
2108 let line = line.strip_suffix(b"\n").unwrap_or(line);
2109 let line = line.strip_suffix(b"\r").unwrap_or(line);
2110 let payload = match line.strip_prefix(WASM_SIGNAL_STATE_PREFIX.as_bytes()) {
2111 Some(payload) => payload,
2112 None => return Ok(None),
2113 };
2114 let payload = std::str::from_utf8(payload)
2115 .map_err(|error| WasmExecutionError::RpcResponse(error.to_string()))?;
2116 let message: Value = serde_json::from_str(payload)
2117 .map_err(|error| WasmExecutionError::RpcResponse(error.to_string()))?;
2118 let signal = message
2119 .get("signal")
2120 .and_then(Value::as_u64)
2121 .ok_or_else(|| WasmExecutionError::RpcResponse(String::from("missing signal number")))?;
2122 let registration = message
2123 .get("registration")
2124 .and_then(Value::as_object)
2125 .ok_or_else(|| {
2126 WasmExecutionError::RpcResponse(String::from("missing signal registration"))
2127 })?;
2128 let action = match registration
2129 .get("action")
2130 .and_then(Value::as_str)
2131 .unwrap_or("default")
2132 {
2133 "ignore" => WasmSignalDispositionAction::Ignore,
2134 "user" => WasmSignalDispositionAction::User,
2135 _ => WasmSignalDispositionAction::Default,
2136 };
2137 let mask = registration
2138 .get("mask")
2139 .and_then(Value::as_array)
2140 .map(|entries| {
2141 entries
2142 .iter()
2143 .filter_map(Value::as_u64)
2144 .map(|value| value as u32)
2145 .collect::<Vec<_>>()
2146 })
2147 .unwrap_or_default();
2148 let flags = registration
2149 .get("flags")
2150 .and_then(Value::as_u64)
2151 .unwrap_or_default() as u32;
2152
2153 Ok(Some(WasmExecutionEvent::SignalState {
2154 signal: signal as u32,
2155 registration: WasmSignalHandlerRegistration {
2156 action,
2157 mask,
2158 flags,
2159 },
2160 }))
2161}
2162
2163struct WasmJavascriptExecutionOptions<'a> {
2164 frozen_time_ms: u128,
2165 prewarm_only: bool,
2166 warmup_metrics: Option<&'a [u8]>,
2167}
2168
2169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2170enum WasmSnapshotRunnerMode {
2171 Auto,
2172 Block,
2173 Off,
2174}
2175
2176fn wasm_snapshot_runner_mode() -> WasmSnapshotRunnerMode {
2177 match std::env::var(WASM_SNAPSHOT_RUNNER_ENV) {
2178 Ok(value) if value.eq_ignore_ascii_case("block") => WasmSnapshotRunnerMode::Block,
2179 Ok(value) if value.eq_ignore_ascii_case("off") => WasmSnapshotRunnerMode::Off,
2180 Ok(value) if value.eq_ignore_ascii_case("auto") => WasmSnapshotRunnerMode::Auto,
2181 Ok(value) => {
2182 tracing::warn!(
2183 value,
2184 "{WASM_SNAPSHOT_RUNNER_ENV} must be auto, block, or off; using auto"
2185 );
2186 WasmSnapshotRunnerMode::Auto
2187 }
2188 Err(_) => WasmSnapshotRunnerMode::Auto,
2189 }
2190}
2191
2192fn start_wasm_javascript_execution(
2193 javascript_engine: &mut JavascriptExecutionEngine,
2194 import_cache: &NodeImportCache,
2195 javascript_context_id: &str,
2196 resolved_module: &ResolvedWasmModule,
2197 request: &StartWasmExecutionRequest,
2198 options: WasmJavascriptExecutionOptions<'_>,
2199) -> Result<JavascriptExecution, WasmExecutionError> {
2200 let wasm_module_bytes = cached_wasm_module_bytes(&resolved_module.resolved_path)?;
2201 let internal_env = build_wasm_internal_env(
2202 resolved_module,
2203 request,
2204 options.frozen_time_ms,
2205 options.prewarm_only,
2206 )?;
2207 let snapshot_mode = wasm_snapshot_runner_mode();
2208 let mut env = wasm_runner_base_env(request);
2209 let mut guest_runtime = request.guest_runtime.clone();
2210
2211 let inline_code = match snapshot_mode {
2212 WasmSnapshotRunnerMode::Off => {
2213 env.extend(
2214 internal_env
2215 .iter()
2216 .map(|(key, value)| (key.clone(), value.clone())),
2217 );
2218 build_wasm_runner_module_source(import_cache, &internal_env, options.warmup_metrics)?
2219 }
2220 WasmSnapshotRunnerMode::Auto | WasmSnapshotRunnerMode::Block => {
2221 let userland_bundle = build_wasm_runner_userland_bundle(import_cache)?;
2222 let runner_heap_limit_mb = wasm_runner_heap_limit_mb(request);
2223 V8RuntimeHost::warm_snapshot_async(userland_bundle.clone());
2224 let use_snapshot = match snapshot_mode {
2225 WasmSnapshotRunnerMode::Block => {
2226 if !javascript_engine
2227 .snapshot_userland_ready(&userland_bundle)
2228 .map_err(map_javascript_error)?
2229 {
2230 javascript_engine
2231 .pre_warm_snapshot(&userland_bundle)
2232 .map_err(map_javascript_error)?;
2233 }
2234 javascript_engine
2235 .pre_warm_workers(
2236 &userland_bundle,
2237 runner_heap_limit_mb,
2238 v8_warm_worker_count(),
2239 )
2240 .map_err(map_javascript_error)?;
2241 javascript_engine
2242 .pre_warm_workers("", 0, v8_warm_worker_count())
2243 .map_err(map_javascript_error)?;
2244 true
2245 }
2246 WasmSnapshotRunnerMode::Auto => javascript_engine
2247 .snapshot_userland_ready(&userland_bundle)
2248 .unwrap_or(false),
2249 WasmSnapshotRunnerMode::Off => false,
2250 };
2251
2252 if use_snapshot {
2253 env = wasm_snapshot_runner_base_env(request);
2254 env.extend(
2255 internal_env
2256 .iter()
2257 .map(|(key, value)| (key.clone(), value.clone())),
2258 );
2259 guest_runtime.snapshot_userland_code = Some(userland_bundle);
2260 build_wasm_snapshot_runner_inline_code(options.warmup_metrics)
2261 } else {
2262 env.extend(
2263 internal_env
2264 .iter()
2265 .map(|(key, value)| (key.clone(), value.clone())),
2266 );
2267 build_wasm_runner_module_source(
2268 import_cache,
2269 &internal_env,
2270 options.warmup_metrics,
2271 )?
2272 }
2273 }
2274 };
2275
2276 javascript_engine
2277 .start_execution(StartJavascriptExecutionRequest {
2278 vm_id: request.vm_id.clone(),
2279 context_id: javascript_context_id.to_owned(),
2280 argv: vec![String::from(WASM_INLINE_RUNNER_ENTRYPOINT)],
2281 env,
2282 cwd: request.cwd.clone(),
2283 limits: JavascriptExecutionLimits {
2290 v8_heap_limit_mb: Some(wasm_runner_heap_limit_mb(request)),
2291 ..JavascriptExecutionLimits::default()
2292 },
2293 guest_runtime,
2296 inline_code: Some(inline_code),
2297 wasm_module_bytes: Some(wasm_module_bytes),
2298 })
2299 .map_err(map_javascript_error)
2300}
2301
2302struct WasmModuleBytesCache {
2303 entries: HashMap<PathBuf, (String, Arc<Vec<u8>>)>,
2304}
2305
2306fn wasm_module_bytes_cache() -> &'static Mutex<WasmModuleBytesCache> {
2307 static CACHE: OnceLock<Mutex<WasmModuleBytesCache>> = OnceLock::new();
2308 CACHE.get_or_init(|| {
2309 Mutex::new(WasmModuleBytesCache {
2310 entries: HashMap::new(),
2311 })
2312 })
2313}
2314
2315fn cached_wasm_module_bytes(path: &Path) -> Result<Arc<Vec<u8>>, WasmExecutionError> {
2316 let current_fingerprint = file_fingerprint(path);
2317 {
2318 let cache = wasm_module_bytes_cache()
2319 .lock()
2320 .expect("wasm module bytes cache lock poisoned");
2321 if let Some((fingerprint, bytes)) = cache.entries.get(path) {
2322 if fingerprint == ¤t_fingerprint {
2323 return Ok(Arc::clone(bytes));
2324 }
2325 }
2326 }
2327
2328 let module_bytes = Arc::new(fs::read(path).map_err(WasmExecutionError::PrepareWarmPath)?);
2329 let fingerprint = file_fingerprint(path);
2330 let mut cache = wasm_module_bytes_cache()
2331 .lock()
2332 .expect("wasm module bytes cache lock poisoned");
2333 if !cache.entries.contains_key(path) && cache.entries.len() >= WASM_MODULE_BYTES_CACHE_CAPACITY
2334 {
2335 if let Some(evicted_path) = cache.entries.keys().next().cloned() {
2336 cache.entries.remove(&evicted_path);
2337 tracing::warn!(
2338 path = %evicted_path.display(),
2339 "evicting cached wasm module bytes entry"
2340 );
2341 }
2342 }
2343 cache
2344 .entries
2345 .insert(path.to_path_buf(), (fingerprint, Arc::clone(&module_bytes)));
2346 let cumulative_bytes: usize = cache.entries.values().map(|(_, bytes)| bytes.len()).sum();
2347 tracing::debug!(
2348 path = %path.display(),
2349 raw_bytes = module_bytes.len(),
2350 cumulative_bytes,
2351 "cached wasm module bytes entry"
2352 );
2353 Ok(module_bytes)
2354}
2355
2356fn build_wasm_internal_env(
2357 resolved_module: &ResolvedWasmModule,
2358 request: &StartWasmExecutionRequest,
2359 frozen_time_ms: u128,
2360 prewarm_only: bool,
2361) -> Result<BTreeMap<String, String>, WasmExecutionError> {
2362 let guest_path_mappings = wasm_guest_path_mappings(request);
2363 let mut internal_env = request
2364 .env
2365 .iter()
2366 .filter(|(key, _)| key.starts_with("AGENTOS_"))
2367 .map(|(key, value)| (key.clone(), value.clone()))
2368 .collect::<BTreeMap<_, _>>();
2369 if let Some(value) = request.env.get("AGENTOS_KEEP_STDIN_OPEN") {
2370 internal_env.insert(String::from("AGENTOS_KEEP_STDIN_OPEN"), value.clone());
2371 }
2372 scrub_migrated_wasm_limit_env(&mut internal_env);
2373 insert_optional_u64_env(
2374 &mut internal_env,
2375 WASM_MAX_MEMORY_BYTES_ENV,
2376 request.limits.max_memory_bytes,
2377 );
2378 internal_env.insert(
2379 WASM_MODULE_PATH_ENV.to_string(),
2380 resolved_module.specifier.clone(),
2381 );
2382 internal_env.insert(
2383 String::from("AGENTOS_FORWARD_KERNEL_STDIN_RPC"),
2384 String::from("1"),
2385 );
2386 internal_env.insert(
2387 WASM_GUEST_ARGV_ENV.to_string(),
2388 encode_json_string_array(&warmup_guest_argv(resolved_module, request)),
2389 );
2390 internal_env.insert(
2391 WASM_GUEST_ENV_ENV.to_string(),
2392 encode_json_string_map(&guest_visible_wasm_env(&request.env)),
2393 );
2394 insert_wasm_runner_identity_env(&mut internal_env, &request.guest_runtime);
2395 internal_env.insert(
2396 WASM_HOST_CWD_ENV.to_string(),
2397 request.cwd.to_string_lossy().into_owned(),
2398 );
2399 internal_env.insert(
2400 String::from("AGENTOS_GUEST_PATH_MAPPINGS"),
2401 encode_wasm_guest_path_mappings(&guest_path_mappings),
2402 );
2403 internal_env.insert(
2404 WASM_PERMISSION_TIER_ENV.to_string(),
2405 request.permission_tier.as_env_value().to_string(),
2406 );
2407 internal_env.insert(
2408 String::from("AGENTOS_FROZEN_TIME_MS"),
2409 frozen_time_ms.to_string(),
2410 );
2411
2412 if prewarm_only {
2413 internal_env.insert(WASM_PREWARM_ONLY_ENV.to_string(), String::from("1"));
2414 } else {
2415 internal_env.remove(WASM_PREWARM_ONLY_ENV);
2416 }
2417 Ok(internal_env)
2418}
2419
2420fn wasm_runner_base_env(request: &StartWasmExecutionRequest) -> BTreeMap<String, String> {
2421 let mut env = request.env.clone();
2422 scrub_migrated_wasm_limit_env(&mut env);
2423 env
2424}
2425
2426fn wasm_snapshot_runner_base_env(request: &StartWasmExecutionRequest) -> BTreeMap<String, String> {
2427 let mut env = request
2428 .env
2429 .iter()
2430 .filter(|(key, _)| !is_internal_wasm_guest_env_key(key))
2431 .map(|(key, value)| (key.clone(), value.clone()))
2432 .collect::<BTreeMap<_, _>>();
2433 scrub_migrated_wasm_limit_env(&mut env);
2434 env
2435}
2436
2437fn scrub_migrated_wasm_limit_env(env: &mut BTreeMap<String, String>) {
2438 for key in [
2439 WASM_MAX_FUEL_ENV,
2440 WASM_MAX_MEMORY_BYTES_ENV,
2441 WASM_MAX_STACK_BYTES_ENV,
2442 "AGENTOS_WASM_PREWARM_TIMEOUT_MS",
2443 "AGENTOS_WASM_RUNNER_HEAP_LIMIT_MB",
2444 ] {
2445 env.remove(key);
2446 }
2447}
2448
2449fn insert_optional_u64_env(env: &mut BTreeMap<String, String>, key: &str, value: Option<u64>) {
2450 if let Some(value) = value {
2451 env.insert(key.to_string(), value.to_string());
2452 } else {
2453 env.remove(key);
2454 }
2455}
2456
2457fn insert_wasm_runner_identity_env(
2458 env: &mut BTreeMap<String, String>,
2459 guest_runtime: &GuestRuntimeConfig,
2460) {
2461 insert_optional_u64_env(
2462 env,
2463 "AGENTOS_VIRTUAL_PROCESS_UID",
2464 guest_runtime.virtual_uid,
2465 );
2466 insert_optional_u64_env(
2467 env,
2468 "AGENTOS_VIRTUAL_PROCESS_GID",
2469 guest_runtime.virtual_gid,
2470 );
2471 insert_optional_u64_env(
2472 env,
2473 "AGENTOS_VIRTUAL_PROCESS_PID",
2474 guest_runtime.virtual_pid,
2475 );
2476 insert_optional_u64_env(
2477 env,
2478 "AGENTOS_VIRTUAL_PROCESS_PPID",
2479 guest_runtime.virtual_ppid,
2480 );
2481}
2482
2483fn build_wasm_runner_module_source(
2484 import_cache: &NodeImportCache,
2485 internal_env: &BTreeMap<String, String>,
2486 warmup_metrics: Option<&[u8]>,
2487) -> Result<String, WasmExecutionError> {
2488 let runner_source = transformed_wasm_runner_source(import_cache)?;
2489 let bootstrap = build_wasm_runner_bootstrap(internal_env, warmup_metrics);
2490 Ok(insert_wasm_runner_bootstrap(&runner_source, &bootstrap))
2491}
2492
2493fn transformed_wasm_runner_source(
2494 import_cache: &NodeImportCache,
2495) -> Result<String, WasmExecutionError> {
2496 if std::env::var(WASM_RUNNER_NO_CACHE_ENV).as_deref() == Ok("1") {
2497 return read_transformed_wasm_runner_source(import_cache);
2498 }
2499
2500 static RUNNER_SOURCE: OnceLock<Result<Arc<str>, Arc<str>>> = OnceLock::new();
2501 RUNNER_SOURCE
2502 .get_or_init(|| {
2503 read_transformed_wasm_runner_source(import_cache)
2504 .map(Arc::<str>::from)
2505 .map_err(|error| Arc::<str>::from(error.to_string()))
2506 })
2507 .as_ref()
2508 .map(|source| source.to_string())
2509 .map_err(|message| {
2510 WasmExecutionError::PrepareWarmPath(std::io::Error::other(message.to_string()))
2511 })
2512}
2513
2514fn read_transformed_wasm_runner_source(
2515 import_cache: &NodeImportCache,
2516) -> Result<String, WasmExecutionError> {
2517 let runner_source = fs::read_to_string(import_cache.wasm_runner_path())
2518 .map_err(WasmExecutionError::PrepareWarmPath)?;
2519 Ok(runner_source.replace(
2520 "import { WASI } from 'node:wasi';\n",
2521 "const { WASI } = globalThis.__agentOSWasiModule;\n",
2522 ))
2523}
2524
2525fn build_wasm_runner_userland_bundle(
2526 import_cache: &NodeImportCache,
2527) -> Result<String, WasmExecutionError> {
2528 if std::env::var(WASM_RUNNER_NO_CACHE_ENV).as_deref() == Ok("1") {
2529 return build_wasm_runner_userland_bundle_uncached(import_cache);
2530 }
2531
2532 static USERLAND_BUNDLE: OnceLock<Result<Arc<str>, Arc<str>>> = OnceLock::new();
2533 USERLAND_BUNDLE
2534 .get_or_init(|| {
2535 build_wasm_runner_userland_bundle_uncached(import_cache)
2536 .map(Arc::<str>::from)
2537 .map_err(|error| Arc::<str>::from(error.to_string()))
2538 })
2539 .as_ref()
2540 .map(|bundle| bundle.to_string())
2541 .map_err(|message| {
2542 WasmExecutionError::PrepareWarmPath(std::io::Error::other(message.to_string()))
2543 })
2544}
2545
2546fn build_wasm_runner_userland_bundle_uncached(
2547 import_cache: &NodeImportCache,
2548) -> Result<String, WasmExecutionError> {
2549 let runner_source = transformed_wasm_runner_source(import_cache)?;
2550 if runner_source
2551 .lines()
2552 .any(|line| line.trim_start().starts_with("import "))
2553 {
2554 return Err(WasmExecutionError::PrepareWarmPath(std::io::Error::other(
2555 "transformed wasm runner still contains an ESM import statement",
2556 )));
2557 }
2558
2559 let mut bundle = build_wasm_runner_snapshot_prelude();
2560 bundle.push_str("\nglobalThis.__agentOSWasmRunnerRun = async function () {\n");
2561 bundle.push_str(&runner_source);
2562 bundle.push_str("\n};\n");
2563 Ok(bundle)
2564}
2565
2566fn build_wasm_runner_snapshot_prelude() -> String {
2567 let bootstrap = build_wasm_runner_bootstrap(&BTreeMap::new(), None);
2568 let bootstrap = bootstrap
2569 .strip_prefix("const __agentOSWasmInternalEnv = {};\n")
2570 .unwrap_or(&bootstrap);
2571 bootstrap.replace(wasm_internal_env_merge_source(), "")
2572}
2573
2574fn build_wasm_snapshot_runner_inline_code(warmup_metrics: Option<&[u8]>) -> String {
2575 let warmup_emit = wasm_warmup_metrics_emit_source(warmup_metrics);
2576 format!(
2577 r#"{warmup_emit}if (typeof process !== "undefined" && typeof globalThis.__agentOSProcessConfigEnv === "object") {{
2578 process.env = {{ ...(process.env || {{}}), ...globalThis.__agentOSProcessConfigEnv }};
2579}}
2580await globalThis.__agentOSWasmRunnerRun();"#
2581 )
2582}
2583
2584fn build_wasm_runner_bootstrap(
2585 internal_env: &BTreeMap<String, String>,
2586 warmup_metrics: Option<&[u8]>,
2587) -> String {
2588 let internal_env_json =
2589 serde_json::to_string(internal_env).unwrap_or_else(|_| String::from("{}"));
2590 let warmup_emit = wasm_warmup_metrics_emit_source(warmup_metrics);
2591 let wasi_module_source = render_native_wasi_module_source();
2592 let env_merge_source = wasm_internal_env_merge_source();
2593
2594 format!(
2595 r#"const __agentOSWasmInternalEnv = {internal_env_json};
2596const __agentOSRequireBuiltin = (specifier) => {{
2597 if (typeof globalThis.require === "function") {{
2598 return globalThis.require(specifier);
2599 }}
2600 if (typeof process?.getBuiltinModule === "function") {{
2601 return process.getBuiltinModule(specifier);
2602 }}
2603 throw new Error(`secure-exec WASM bootstrap cannot load ${{specifier}}`);
2604}};
2605{wasi_module_source}
2606{env_merge_source}
2607if (typeof globalThis !== "undefined") {{
2608 const __agentOSNormalizeBytes = (value) => {{
2609 if (value == null) {{
2610 return value;
2611 }}
2612 if (typeof Buffer !== "undefined" && Buffer.isBuffer(value)) {{
2613 return value;
2614 }}
2615 if (value instanceof Uint8Array) {{
2616 return Buffer.from(value);
2617 }}
2618 if (ArrayBuffer.isView(value)) {{
2619 return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
2620 }}
2621 if (value instanceof ArrayBuffer) {{
2622 return Buffer.from(value);
2623 }}
2624 if (
2625 value &&
2626 typeof value === "object" &&
2627 value.__agentOSType === "bytes" &&
2628 typeof value.base64 === "string"
2629 ) {{
2630 return Buffer.from(value.base64, "base64");
2631 }}
2632 return value;
2633 }};
2634 const __agentOSWasmSyncRpc = {{
2635 callSync(method, args = []) {{
2636 switch (method) {{
2637 case "fs.fstatSync":
2638 return __agentOSRequireBuiltin("node:fs").fstatSync(...args);
2639 case "fs.lstatSync":
2640 return __agentOSRequireBuiltin("node:fs").lstatSync(...args);
2641 case "fs.statSync":
2642 return __agentOSRequireBuiltin("node:fs").statSync(...args);
2643 case "fs.chmodSync":
2644 return __agentOSRequireBuiltin("node:fs").chmodSync(...args);
2645 case "__kernel_stdio_write":
2646 if (typeof _kernelStdioWriteRaw === "undefined") {{
2647 throw new Error("secure-exec WASM kernel stdio bridge is unavailable");
2648 }}
2649 return _kernelStdioWriteRaw.applySync(void 0, args);
2650 case "__kernel_stdin_read":
2651 if (typeof _kernelStdinReadRaw === "undefined") {{
2652 throw new Error("secure-exec WASM kernel stdin bridge is unavailable");
2653 }}
2654 return _kernelStdinReadRaw.applySync(void 0, args);
2655 case "__kernel_poll":
2656 if (typeof _kernelPollRaw === "undefined") {{
2657 throw new Error("secure-exec WASM kernel poll bridge is unavailable");
2658 }}
2659 return _kernelPollRaw.applySync(void 0, args);
2660 case "__kernel_isatty":
2661 if (typeof _kernelIsattyRaw === "undefined") {{
2662 throw new Error("secure-exec WASM kernel isatty bridge is unavailable");
2663 }}
2664 return _kernelIsattyRaw.applySync(void 0, args);
2665 case "__kernel_tty_size":
2666 if (typeof _kernelTtySizeRaw === "undefined") {{
2667 throw new Error("secure-exec WASM kernel tty size bridge is unavailable");
2668 }}
2669 return _kernelTtySizeRaw.applySync(void 0, args);
2670 case "__pty_set_raw_mode":
2671 if (typeof _ptySetRawMode === "undefined") {{
2672 throw new Error("secure-exec WASM PTY raw-mode bridge is unavailable");
2673 }}
2674 return _ptySetRawMode.applySync(void 0, args);
2675 case "child_process.spawn": {{
2676 if (typeof _childProcessSpawnStart === "undefined") {{
2677 throw new Error("secure-exec WASM child_process bridge is unavailable");
2678 }}
2679 const [request] = args;
2680 return _childProcessSpawnStart.applySync(void 0, [
2681 request?.command ?? "",
2682 JSON.stringify(request?.args ?? []),
2683 JSON.stringify(request?.options ?? {{}}),
2684 ]);
2685 }}
2686 case "child_process.poll":
2687 if (typeof _childProcessPoll === "undefined") {{
2688 throw new Error("secure-exec WASM child_process poll bridge is unavailable");
2689 }}
2690 return _childProcessPoll.applySync(void 0, args);
2691 case "child_process.kill":
2692 if (typeof _childProcessKill === "undefined") {{
2693 throw new Error("secure-exec WASM child_process kill bridge is unavailable");
2694 }}
2695 return _childProcessKill.applySync(void 0, args);
2696 case "process.kill":
2697 if (typeof _processKill === "undefined") {{
2698 throw new Error("secure-exec WASM process kill bridge is unavailable");
2699 }}
2700 return _processKill.applySync(void 0, args);
2701 case "child_process.write_stdin": {{
2702 if (typeof _childProcessStdinWrite === "undefined") {{
2703 throw new Error("secure-exec WASM child_process stdin bridge is unavailable");
2704 }}
2705 const [childId, chunk] = args;
2706 return _childProcessStdinWrite.applySync(void 0, [
2707 childId,
2708 __agentOSNormalizeBytes(chunk),
2709 ]);
2710 }}
2711 case "child_process.close_stdin":
2712 if (typeof _childProcessStdinClose === "undefined") {{
2713 throw new Error("secure-exec WASM child_process stdin-close bridge is unavailable");
2714 }}
2715 return _childProcessStdinClose.applySync(void 0, args);
2716 case "net.connect":
2717 if (typeof _netSocketConnectRaw === "undefined") {{
2718 throw new Error("secure-exec WASM net.connect bridge is unavailable");
2719 }}
2720 return _netSocketConnectRaw.applySync(void 0, args);
2721 case "net.reserve_tcp_port":
2722 if (typeof _netReserveTcpPortRaw === "undefined") {{
2723 throw new Error("secure-exec WASM net.reserve_tcp_port bridge is unavailable");
2724 }}
2725 return _netReserveTcpPortRaw.applySync(void 0, args);
2726 case "net.release_tcp_port":
2727 if (typeof _netReleaseTcpPortRaw === "undefined") {{
2728 throw new Error("secure-exec WASM net.release_tcp_port bridge is unavailable");
2729 }}
2730 return _netReleaseTcpPortRaw.applySync(void 0, args);
2731 case "net.listen":
2732 if (typeof _netServerListenRaw === "undefined") {{
2733 throw new Error("secure-exec WASM net.listen bridge is unavailable");
2734 }}
2735 return _netServerListenRaw.applySync(void 0, args);
2736 case "net.server_accept":
2737 if (typeof _netServerAcceptRaw === "undefined") {{
2738 throw new Error("secure-exec WASM net.server_accept bridge is unavailable");
2739 }}
2740 return _netServerAcceptRaw.applySync(void 0, args);
2741 case "net.poll":
2742 if (typeof _netSocketPollRaw === "undefined") {{
2743 throw new Error("secure-exec WASM net.poll bridge is unavailable");
2744 }}
2745 return _netSocketPollRaw.applySync(void 0, args);
2746 case "net.write":
2747 if (typeof _netSocketWriteRaw === "undefined") {{
2748 throw new Error("secure-exec WASM net.write bridge is unavailable");
2749 }}
2750 return _netSocketWriteRaw.applySync(void 0, args);
2751 case "net.destroy":
2752 if (typeof _netSocketDestroyRaw === "undefined") {{
2753 throw new Error("secure-exec WASM net.destroy bridge is unavailable");
2754 }}
2755 return _netSocketDestroyRaw.applySync(void 0, args);
2756 case "net.socket_upgrade_tls":
2757 if (typeof _netSocketUpgradeTlsRaw === "undefined") {{
2758 throw new Error("secure-exec WASM TLS-upgrade bridge is unavailable");
2759 }}
2760 return _netSocketUpgradeTlsRaw.applySync(void 0, args);
2761 case "dgram.createSocket":
2762 if (typeof _dgramSocketCreateRaw === "undefined") {{
2763 throw new Error("secure-exec WASM dgram.createSocket bridge is unavailable");
2764 }}
2765 return _dgramSocketCreateRaw.applySync(void 0, args);
2766 case "dgram.bind":
2767 if (typeof _dgramSocketBindRaw === "undefined") {{
2768 throw new Error("secure-exec WASM dgram.bind bridge is unavailable");
2769 }}
2770 return _dgramSocketBindRaw.applySync(void 0, args);
2771 case "dgram.send": {{
2772 if (typeof _dgramSocketSendRaw === "undefined") {{
2773 throw new Error("secure-exec WASM dgram.send bridge is unavailable");
2774 }}
2775 const [socketId, chunk, options = {{}}] = args;
2776 return _dgramSocketSendRaw.applySync(void 0, [
2777 socketId,
2778 __agentOSNormalizeBytes(chunk),
2779 options,
2780 ]);
2781 }}
2782 case "dgram.poll":
2783 if (typeof _dgramSocketRecvRaw === "undefined") {{
2784 throw new Error("secure-exec WASM dgram.poll bridge is unavailable");
2785 }}
2786 const event = _dgramSocketRecvRaw.applySync(void 0, args);
2787 if (event && event.type === "message") {{
2788 const data = __agentOSNormalizeBytes(event.data);
2789 if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) {{
2790 return {{
2791 ...event,
2792 data: {{ base64: data.toString("base64") }},
2793 }};
2794 }}
2795 }}
2796 if (
2797 event &&
2798 event.type === "message" &&
2799 event.data &&
2800 typeof event.data === "object" &&
2801 typeof event.data.base64 === "string"
2802 ) {{
2803 return {{
2804 ...event,
2805 data: {{ base64: event.data.base64 }},
2806 }};
2807 }}
2808 return event;
2809 case "dgram.close":
2810 if (typeof _dgramSocketCloseRaw === "undefined") {{
2811 throw new Error("secure-exec WASM dgram.close bridge is unavailable");
2812 }}
2813 return _dgramSocketCloseRaw.applySync(void 0, args);
2814 case "dgram.address":
2815 if (typeof _dgramSocketAddressRaw === "undefined") {{
2816 throw new Error("secure-exec WASM dgram.address bridge is unavailable");
2817 }}
2818 return _dgramSocketAddressRaw.applySync(void 0, args);
2819 case "dgram.setBufferSize":
2820 if (typeof _dgramSocketSetBufferSizeRaw === "undefined") {{
2821 throw new Error("secure-exec WASM dgram.setBufferSize bridge is unavailable");
2822 }}
2823 return _dgramSocketSetBufferSizeRaw.applySync(void 0, args);
2824 case "dgram.getBufferSize":
2825 if (typeof _dgramSocketGetBufferSizeRaw === "undefined") {{
2826 throw new Error("secure-exec WASM dgram.getBufferSize bridge is unavailable");
2827 }}
2828 return _dgramSocketGetBufferSizeRaw.applySync(void 0, args);
2829 case "dns.lookup":
2830 if (typeof _networkDnsLookupSyncRaw === "undefined") {{
2831 throw new Error("secure-exec WASM dns.lookup bridge is unavailable");
2832 }}
2833 return _networkDnsLookupSyncRaw.applySync(void 0, args);
2834 case "process.signal_state": {{
2835 if (typeof _processSignalState === "undefined") {{
2836 throw new Error("secure-exec WASM signal-state bridge is unavailable");
2837 }}
2838 const [signal, action = "default", maskJson = "[]", flags = 0] = args;
2839 return _processSignalState.applySyncPromise(void 0, [
2840 signal,
2841 action,
2842 maskJson,
2843 flags,
2844 ]);
2845 }}
2846 default:
2847 throw new Error(`secure-exec WASM sync RPC method not implemented in V8 runtime: ${{method}}`);
2848 }}
2849 }},
2850 async call(method, args = []) {{
2851 return this.callSync(method, args);
2852 }},
2853 }};
2854 Object.defineProperty(globalThis, "__agentOSSyncRpc", {{
2855 configurable: true,
2856 enumerable: false,
2857 value: __agentOSWasmSyncRpc,
2858 writable: true,
2859 }});
2860}}
2861{warmup_emit}"#
2862 )
2863}
2864
2865fn wasm_warmup_metrics_emit_source(warmup_metrics: Option<&[u8]>) -> String {
2866 let warmup_metrics_json = warmup_metrics.map(|bytes| {
2867 serde_json::to_string(&String::from_utf8_lossy(bytes).to_string())
2868 .unwrap_or_else(|_| String::from("\"\""))
2869 });
2870 warmup_metrics_json
2871 .map(|metrics| {
2872 format!(
2873 "if (typeof process?.stderr?.write === \"function\") {{\n process.stderr.write({metrics});\n}}\n"
2874 )
2875 })
2876 .unwrap_or_default()
2877}
2878
2879fn wasm_internal_env_merge_source() -> &'static str {
2880 r#"if (typeof process !== "undefined") {
2881 process.env = { ...(process.env || {}), ...__agentOSWasmInternalEnv };
2882}
2883"#
2884}
2885
2886fn render_native_wasi_module_source() -> &'static str {
2887 static SOURCE: OnceLock<String> = OnceLock::new();
2888 SOURCE.get_or_init(|| {
2889 NODE_WASI_MODULE_SOURCE.replace(
2890 "__AGENTOS_WASM_SYNC_READ_LIMIT_BYTES__",
2891 &WASM_SYNC_READ_LIMIT_BYTES.to_string(),
2892 )
2893 })
2894}
2895
2896fn insert_wasm_runner_bootstrap(source: &str, bootstrap: &str) -> String {
2897 let mut insert_at = 0usize;
2898 let mut saw_import = false;
2899 for line in source.split_inclusive('\n') {
2900 let trimmed = line.trim_start();
2901 if trimmed.starts_with("import ") || (saw_import && trimmed.is_empty()) {
2902 insert_at += line.len();
2903 saw_import = saw_import || trimmed.starts_with("import ");
2904 continue;
2905 }
2906 break;
2907 }
2908
2909 format!(
2910 "{}{}{}",
2911 &source[..insert_at],
2912 bootstrap,
2913 &source[insert_at..]
2914 )
2915}
2916
2917fn prewarm_wasm_path(
2918 import_cache: &NodeImportCache,
2919 javascript_engine: &mut JavascriptExecutionEngine,
2920 javascript_context_id: &str,
2921 resolved_module: &ResolvedWasmModule,
2922 request: &StartWasmExecutionRequest,
2923 frozen_time_ms: u128,
2924 prewarm_timeout: Duration,
2925) -> Result<Option<Vec<u8>>, WasmExecutionError> {
2926 let debug_enabled = env_flag_enabled(&request.env, WASM_WARMUP_DEBUG_ENV);
2927 let marker_contents = warmup_marker_contents(resolved_module);
2928 let marker_path = warmup_marker_path(
2929 import_cache.prewarm_marker_dir(),
2930 "wasm-runner-prewarm",
2931 WASM_WARMUP_MARKER_VERSION,
2932 &marker_contents,
2933 );
2934
2935 if let Ok(metadata) = fs::metadata(&resolved_module.resolved_path) {
2936 if metadata.len() > MAX_SYNC_WASM_PREWARM_MODULE_BYTES {
2937 return Ok(warmup_metrics_line(
2938 debug_enabled,
2939 false,
2940 "skipped-large-module",
2941 import_cache,
2942 &resolved_module.specifier,
2943 ));
2944 }
2945 }
2946
2947 if marker_path.exists() {
2948 return Ok(warmup_metrics_line(
2949 debug_enabled,
2950 false,
2951 "cached",
2952 import_cache,
2953 &resolved_module.specifier,
2954 ));
2955 }
2956
2957 let mut prewarm_execution = start_wasm_javascript_execution(
2958 javascript_engine,
2959 import_cache,
2960 javascript_context_id,
2961 resolved_module,
2962 request,
2963 WasmJavascriptExecutionOptions {
2964 frozen_time_ms,
2965 prewarm_only: true,
2966 warmup_metrics: None,
2967 },
2968 )
2969 .map_err(|error| match error {
2970 WasmExecutionError::Spawn(err) => WasmExecutionError::WarmupSpawn(err),
2971 other => other,
2972 })?;
2973 let mut internal_sync_rpc = WasmInternalSyncRpc {
2974 module_guest_paths: wasm_guest_module_paths(&resolved_module.specifier, &request.env),
2975 module_host_path: resolved_module.resolved_path.clone(),
2976 guest_cwd: wasm_guest_cwd(&request.env),
2977 host_cwd: request.cwd.clone(),
2978 sandbox_root: wasm_sandbox_root(&request.env),
2979 guest_path_mappings: wasm_guest_path_mappings(request),
2980 route_fs_through_sidecar: false,
2981 next_fd: 64,
2982 open_files: BTreeMap::new(),
2983 pending_events: VecDeque::new(),
2984 };
2985 let mut stdout = Vec::new();
2986 let mut stderr = Vec::new();
2987 let started = Instant::now();
2988
2989 loop {
2990 let poll_timeout = prewarm_timeout.saturating_sub(started.elapsed());
2991 if poll_timeout.is_zero() {
2992 let _ = prewarm_execution.terminate();
2993 return Err(WasmExecutionError::WarmupTimeout(prewarm_timeout));
2994 }
2995
2996 match prewarm_execution
2997 .poll_event_blocking(poll_timeout)
2998 .map_err(map_javascript_error)?
2999 {
3000 Some(JavascriptExecutionEvent::Stdout(chunk)) => {
3001 append_wasm_captured_output(&mut stdout, &chunk, "stdout")?;
3002 }
3003 Some(JavascriptExecutionEvent::Stderr(chunk)) => {
3004 append_wasm_captured_output(&mut stderr, &chunk, "stderr")?;
3005 }
3006 Some(JavascriptExecutionEvent::Exited(exit_code)) => {
3007 if exit_code != 0 {
3008 return Err(WasmExecutionError::WarmupFailed {
3009 exit_code,
3010 stderr: String::from_utf8_lossy(&stderr).into_owned(),
3011 });
3012 }
3013 break;
3014 }
3015 Some(JavascriptExecutionEvent::SyncRpcRequest(sync_request)) => {
3016 let handled = handle_internal_wasm_sync_rpc_request(
3017 &mut prewarm_execution,
3018 &mut internal_sync_rpc,
3019 &sync_request,
3020 )?;
3021 if !handled {
3022 return Err(WasmExecutionError::WarmupFailed {
3023 exit_code: 1,
3024 stderr: format!(
3025 "unexpected WebAssembly prewarm sync RPC request {} {} {:?}",
3026 sync_request.id, sync_request.method, sync_request.args
3027 ),
3028 });
3029 }
3030 }
3031 Some(JavascriptExecutionEvent::SignalState { .. }) => {}
3032 None => {
3033 let _ = prewarm_execution.terminate();
3034 return Err(WasmExecutionError::WarmupTimeout(prewarm_timeout));
3035 }
3036 }
3037 }
3038
3039 let _ = stdout;
3040 fs::write(&marker_path, marker_contents).map_err(WasmExecutionError::PrepareWarmPath)?;
3041 Ok(warmup_metrics_line(
3042 debug_enabled,
3043 true,
3044 "executed",
3045 import_cache,
3046 &resolved_module.specifier,
3047 ))
3048}
3049
3050fn wasm_guest_module_paths(specifier: &str, env: &BTreeMap<String, String>) -> Vec<String> {
3051 let mut candidates = Vec::new();
3052 candidates.push(specifier.to_owned());
3053
3054 if specifier.starts_with('/') {
3055 candidates.push(normalize_guest_path(specifier));
3056 candidates.extend(mapped_guest_paths_for_host_path(Path::new(specifier), env));
3057 } else if !specifier.starts_with("file:") {
3058 let guest_cwd = wasm_guest_cwd(env);
3059 candidates.push(join_guest_path(&guest_cwd, specifier));
3060 }
3061
3062 candidates.sort();
3063 candidates.dedup();
3064 candidates
3065}
3066
3067fn wasm_guest_cwd(env: &BTreeMap<String, String>) -> String {
3068 env.get("PWD")
3069 .filter(|value| value.starts_with('/'))
3070 .cloned()
3071 .or_else(|| {
3072 env.get("HOME")
3073 .filter(|value| value.starts_with('/'))
3074 .cloned()
3075 })
3076 .unwrap_or_else(|| String::from(DEFAULT_WASM_GUEST_HOME))
3077}
3078
3079fn mapped_guest_paths_for_host_path(
3080 host_path: &Path,
3081 env: &BTreeMap<String, String>,
3082) -> Vec<String> {
3083 if !host_path.is_absolute() {
3084 return Vec::new();
3085 }
3086
3087 let mappings = env
3088 .get("AGENTOS_GUEST_PATH_MAPPINGS")
3089 .and_then(|value| serde_json::from_str::<Vec<Value>>(value).ok())
3090 .unwrap_or_default();
3091
3092 let mut candidates = Vec::new();
3093 for mapping in mappings {
3094 let Some(guest_root) = mapping.get("guestPath").and_then(Value::as_str) else {
3095 continue;
3096 };
3097 let Some(host_root) = mapping.get("hostPath").and_then(Value::as_str) else {
3098 continue;
3099 };
3100 let host_root = Path::new(host_root);
3101
3102 if let Ok(suffix) = host_path.strip_prefix(host_root) {
3103 candidates.push(join_guest_path(
3104 guest_root,
3105 &suffix.to_string_lossy().replace('\\', "/"),
3106 ));
3107 continue;
3108 }
3109
3110 let Ok(real_host_root) = host_root.canonicalize() else {
3111 continue;
3112 };
3113 if let Ok(suffix) = host_path.strip_prefix(&real_host_root) {
3114 candidates.push(join_guest_path(
3115 guest_root,
3116 &suffix.to_string_lossy().replace('\\', "/"),
3117 ));
3118 }
3119 }
3120
3121 candidates
3122}
3123
3124fn normalize_guest_path(path: &str) -> String {
3125 join_guest_path("/", path)
3126}
3127
3128fn join_guest_path(base: &str, suffix: &str) -> String {
3129 let mut segments = Vec::new();
3130 let mut absolute = false;
3131 for part in [base, suffix] {
3132 if part.starts_with('/') {
3133 absolute = true;
3134 }
3135 for segment in part.split('/') {
3136 match segment {
3137 "" | "." => {}
3138 ".." => {
3139 let _ = segments.pop();
3140 }
3141 value => segments.push(value),
3142 }
3143 }
3144 }
3145
3146 let joined = segments.join("/");
3147 if absolute {
3148 if joined.is_empty() {
3149 String::from("/")
3150 } else {
3151 format!("/{joined}")
3152 }
3153 } else if joined.is_empty() {
3154 String::from(".")
3155 } else {
3156 joined
3157 }
3158}
3159
3160fn module_path(
3161 context: &WasmContext,
3162 request: &StartWasmExecutionRequest,
3163) -> Result<String, WasmExecutionError> {
3164 match context.module_path.as_deref() {
3165 Some(module_path) => Ok(module_path.to_owned()),
3166 None => request
3167 .argv
3168 .first()
3169 .cloned()
3170 .ok_or(WasmExecutionError::MissingModulePath),
3171 }
3172}
3173
3174fn guest_visible_wasm_env(env: &BTreeMap<String, String>) -> BTreeMap<String, String> {
3175 let mut guest_env = env
3176 .iter()
3177 .filter(|(key, _)| !is_internal_wasm_guest_env_key(key))
3178 .map(|(key, value)| (key.clone(), value.clone()))
3179 .collect::<BTreeMap<_, _>>();
3180 let guest_cwd = wasm_guest_cwd(env);
3181 let guest_home = guest_env
3182 .get("HOME")
3183 .filter(|value| value.starts_with('/'))
3184 .cloned()
3185 .unwrap_or_else(|| guest_cwd.clone());
3186
3187 guest_env
3188 .entry(String::from("HOME"))
3189 .or_insert_with(|| guest_home.clone());
3190 guest_env
3191 .entry(String::from("PWD"))
3192 .or_insert_with(|| guest_cwd);
3193 guest_env
3194 .entry(String::from("USER"))
3195 .or_insert_with(|| String::from(DEFAULT_WASM_GUEST_USER));
3196 guest_env
3197 .entry(String::from("LOGNAME"))
3198 .or_insert_with(|| String::from(DEFAULT_WASM_GUEST_USER));
3199 guest_env
3200 .entry(String::from("SHELL"))
3201 .or_insert_with(|| String::from(DEFAULT_WASM_GUEST_SHELL));
3202 guest_env
3203 .entry(String::from("PATH"))
3204 .or_insert_with(|| String::from(DEFAULT_WASM_GUEST_PATH));
3205 guest_env
3206 .entry(String::from("TMPDIR"))
3207 .or_insert_with(|| String::from("/tmp"));
3208 guest_env
3209}
3210
3211fn wasm_guest_path_mappings(request: &StartWasmExecutionRequest) -> Vec<WasmGuestPathMapping> {
3212 let guest_cwd = wasm_guest_cwd(&request.env);
3213 let mut mappings = request
3214 .env
3215 .get("AGENTOS_GUEST_PATH_MAPPINGS")
3216 .and_then(|value| serde_json::from_str::<Vec<Value>>(value).ok())
3217 .unwrap_or_default()
3218 .into_iter()
3219 .filter_map(|mapping| {
3220 Some(WasmGuestPathMapping {
3221 guest_path: mapping.get("guestPath")?.as_str()?.to_owned(),
3222 host_path: PathBuf::from(mapping.get("hostPath")?.as_str()?),
3223 read_only: mapping
3224 .get("readOnly")
3225 .and_then(Value::as_bool)
3226 .unwrap_or(false),
3227 })
3228 })
3229 .collect::<Vec<_>>();
3230
3231 if let Some(sandbox_root) = wasm_sandbox_root(&request.env) {
3232 push_wasm_guest_path_mapping(&mut mappings, String::from("/"), sandbox_root);
3233 }
3234 push_wasm_guest_path_mapping(&mut mappings, guest_cwd, request.cwd.clone());
3235 push_wasm_guest_path_mapping(
3236 &mut mappings,
3237 String::from("/workspace"),
3238 request.cwd.clone(),
3239 );
3240 mappings.sort_by_key(|mapping| std::cmp::Reverse(mapping.guest_path.len()));
3241 mappings
3242}
3243
3244fn wasm_sandbox_root(env: &BTreeMap<String, String>) -> Option<PathBuf> {
3245 env.get(WASM_SANDBOX_ROOT_ENV)
3246 .filter(|value| Path::new(value.as_str()).is_absolute())
3247 .map(PathBuf::from)
3248}
3249
3250fn push_wasm_guest_path_mapping(
3251 mappings: &mut Vec<WasmGuestPathMapping>,
3252 guest_path: String,
3253 host_path: PathBuf,
3254) {
3255 if guest_path.is_empty() || !guest_path.starts_with('/') {
3256 return;
3257 }
3258 if mappings
3259 .iter()
3260 .any(|mapping| mapping.guest_path == guest_path)
3261 {
3262 return;
3263 }
3264 mappings.push(WasmGuestPathMapping {
3265 guest_path,
3266 host_path,
3267 read_only: false,
3268 });
3269}
3270
3271fn encode_wasm_guest_path_mappings(mappings: &[WasmGuestPathMapping]) -> String {
3272 serde_json::to_string(
3273 &mappings
3274 .iter()
3275 .map(|mapping| {
3276 json!({
3277 "guestPath": mapping.guest_path,
3278 "hostPath": mapping.host_path.to_string_lossy(),
3279 "readOnly": mapping.read_only,
3280 })
3281 })
3282 .collect::<Vec<_>>(),
3283 )
3284 .unwrap_or_else(|_| String::from("[]"))
3285}
3286
3287fn is_internal_wasm_guest_env_key(key: &str) -> bool {
3288 key.starts_with("AGENTOS_") || key.starts_with("NODE_SYNC_RPC_")
3289}
3290
3291fn warmup_marker_contents(resolved_module: &ResolvedWasmModule) -> String {
3292 let module_fingerprint = file_fingerprint(&resolved_module.resolved_path);
3293
3294 [
3295 env!("CARGO_PKG_NAME").to_string(),
3296 env!("CARGO_PKG_VERSION").to_string(),
3297 WASM_WARMUP_MARKER_VERSION.to_string(),
3298 resolved_module.specifier.clone(),
3299 resolved_module.resolved_path.display().to_string(),
3300 module_fingerprint,
3301 ]
3302 .join("\n")
3303}
3304
3305fn warmup_metrics_line(
3306 debug_enabled: bool,
3307 executed: bool,
3308 reason: &str,
3309 import_cache: &NodeImportCache,
3310 module_specifier: &str,
3311) -> Option<Vec<u8>> {
3312 if !debug_enabled {
3313 return None;
3314 }
3315
3316 Some(
3317 format!(
3318 "{WASM_WARMUP_METRICS_PREFIX}{{\"executed\":{},\"reason\":{},\"modulePath\":{},\"compileCacheDir\":{}}}\n",
3319 if executed { "true" } else { "false" },
3320 encode_json_string(reason),
3321 encode_json_string(module_specifier),
3322 encode_json_string(&import_cache.shared_compile_cache_dir().display().to_string()),
3323 )
3324 .into_bytes(),
3325 )
3326}
3327
3328fn resolve_wasm_execution_timeout(
3329 request: &StartWasmExecutionRequest,
3330) -> Result<Option<Duration>, WasmExecutionError> {
3331 Ok(request.limits.max_fuel.map(Duration::from_millis))
3344}
3345
3346fn resolve_wasm_stack_limit_bytes(
3352 request: &StartWasmExecutionRequest,
3353) -> Result<Option<u64>, WasmExecutionError> {
3354 match request.limits.max_stack_bytes {
3355 Some(0) => Err(WasmExecutionError::InvalidLimit(String::from(
3356 "wasm max stack bytes must be greater than zero",
3357 ))),
3358 other => Ok(other),
3359 }
3360}
3361
3362fn resolve_wasm_prewarm_timeout(
3363 request: &StartWasmExecutionRequest,
3364) -> Result<Duration, WasmExecutionError> {
3365 Ok(Duration::from_millis(
3366 request
3367 .limits
3368 .prewarm_timeout_ms
3369 .filter(|value| *value > 0)
3370 .unwrap_or(DEFAULT_WASM_PREWARM_TIMEOUT_MS),
3371 ))
3372}
3373
3374fn resolve_wasm_module(
3375 context: &WasmContext,
3376 request: &StartWasmExecutionRequest,
3377) -> Result<ResolvedWasmModule, WasmExecutionError> {
3378 let specifier = module_path(context, request)?;
3379 let resolved_path = resolved_module_path(&specifier, &request.cwd);
3380 Ok(ResolvedWasmModule {
3381 specifier,
3382 resolved_path,
3383 })
3384}
3385
3386fn resolved_module_path(specifier: &str, cwd: &Path) -> PathBuf {
3387 resolve_path_like_specifier(cwd, specifier)
3388 .map(|path| path.canonicalize().unwrap_or(path))
3389 .unwrap_or_else(|| PathBuf::from(specifier))
3390}
3391
3392fn verify_wasm_module_header(
3405 resolved_module: &ResolvedWasmModule,
3406) -> Result<(), WasmExecutionError> {
3407 let resolved_path = &resolved_module.resolved_path;
3408 let metadata = fs::metadata(resolved_path).map_err(|error| {
3409 WasmExecutionError::InvalidModule(format!(
3410 "failed to stat {}: {error}",
3411 resolved_path.display()
3412 ))
3413 })?;
3414 if metadata.len() > MAX_WASM_MODULE_FILE_BYTES {
3415 return Err(WasmExecutionError::InvalidModule(format!(
3416 "module file size of {} bytes exceeds the configured parser cap of {} bytes",
3417 metadata.len(),
3418 MAX_WASM_MODULE_FILE_BYTES
3419 )));
3420 }
3421
3422 let mut file = fs::File::open(resolved_path).map_err(|error| {
3423 WasmExecutionError::InvalidModule(format!(
3424 "failed to open {}: {error}",
3425 resolved_path.display()
3426 ))
3427 })?;
3428 let mut header = [0u8; 4];
3429 let bytes_read = file.read(&mut header).map_err(|error| {
3430 WasmExecutionError::InvalidModule(format!(
3431 "failed to read header of {}: {error}",
3432 resolved_path.display()
3433 ))
3434 })?;
3435 let header = &header[..bytes_read];
3436 if header == b"\0asm" {
3437 return Ok(());
3438 }
3439
3440 let shell_shim = header.len() >= 2 && &header[..2] == b"#!";
3441 if let Some(format) = detect_native_binary_format(header) {
3442 return Err(WasmExecutionError::NativeBinaryNotSupported {
3443 path: resolved_path.clone(),
3444 header: header.to_vec(),
3445 format,
3446 });
3447 }
3448
3449 Err(WasmExecutionError::NonWasmBinary {
3450 path: resolved_path.clone(),
3451 header: header.to_vec(),
3452 shell_shim,
3453 })
3454}
3455
3456fn detect_native_binary_format(header: &[u8]) -> Option<NativeBinaryFormat> {
3457 if header.len() >= 4 && &header[..4] == b"\x7fELF" {
3458 return Some(NativeBinaryFormat::Elf);
3459 }
3460
3461 if header.starts_with(b"MZ") {
3462 return Some(NativeBinaryFormat::PeCoff);
3463 }
3464
3465 const MACH_O_MAGICS: [&[u8; 4]; 6] = [
3466 b"\xfe\xed\xfa\xce",
3467 b"\xce\xfa\xed\xfe",
3468 b"\xfe\xed\xfa\xcf",
3469 b"\xcf\xfa\xed\xfe",
3470 b"\xca\xfe\xba\xbe",
3471 b"\xbe\xba\xfe\xca",
3472 ];
3473 if header.len() >= 4 && MACH_O_MAGICS.iter().any(|magic| header[..4] == magic[..]) {
3474 return Some(NativeBinaryFormat::MachO);
3475 }
3476
3477 None
3478}
3479
3480fn warmup_guest_argv(
3481 resolved_module: &ResolvedWasmModule,
3482 request: &StartWasmExecutionRequest,
3483) -> Vec<String> {
3484 if !request.argv.is_empty() {
3485 return request.argv.clone();
3486 }
3487
3488 vec![resolved_module.specifier.clone()]
3489}
3490
3491fn wasm_memory_limit_bytes(
3492 request: &StartWasmExecutionRequest,
3493) -> Result<Option<u64>, WasmExecutionError> {
3494 Ok(request.limits.max_memory_bytes)
3495}
3496
3497fn wasm_stack_limit_bytes(
3498 request: &StartWasmExecutionRequest,
3499) -> Result<Option<u64>, WasmExecutionError> {
3500 resolve_wasm_stack_limit_bytes(request)
3501}
3502
3503#[cfg(test)]
3504fn wasm_memory_limit_pages(memory_limit_bytes: u64) -> Result<u32, WasmExecutionError> {
3505 let pages = memory_limit_bytes / WASM_PAGE_BYTES;
3506 u32::try_from(pages).map_err(|_| {
3507 WasmExecutionError::InvalidLimit(format!(
3508 "{WASM_MAX_MEMORY_BYTES_ENV}={memory_limit_bytes}: exceeds V8's wasm page limit range"
3509 ))
3510 })
3511}
3512
3513fn wasm_runner_heap_limit_mb(request: &StartWasmExecutionRequest) -> u32 {
3516 request
3517 .limits
3518 .runner_heap_limit_mb
3519 .filter(|value| *value > 0)
3520 .unwrap_or(DEFAULT_WASM_RUNNER_HEAP_LIMIT_MB)
3521}
3522
3523fn v8_warm_worker_count() -> usize {
3524 std::env::var("AGENTOS_V8_WARM_ISOLATES")
3525 .ok()
3526 .and_then(|value| value.parse::<usize>().ok())
3527 .unwrap_or(2)
3528}
3529
3530fn validate_module_limits(
3531 resolved_module: &ResolvedWasmModule,
3532 request: &StartWasmExecutionRequest,
3533) -> Result<(), WasmExecutionError> {
3534 let _stack_limit = resolve_wasm_stack_limit_bytes(request)?;
3538
3539 let Some(memory_limit) = wasm_memory_limit_bytes(request)? else {
3540 return Ok(());
3541 };
3542
3543 let resolved_path = &resolved_module.resolved_path;
3544 let metadata = fs::metadata(resolved_path).map_err(|error| {
3545 WasmExecutionError::InvalidModule(format!(
3546 "failed to stat {}: {error}",
3547 resolved_path.display()
3548 ))
3549 })?;
3550 if metadata.len() > MAX_WASM_MODULE_FILE_BYTES {
3551 return Err(WasmExecutionError::InvalidModule(format!(
3552 "module file size of {} bytes exceeds the configured parser cap of {} bytes",
3553 metadata.len(),
3554 MAX_WASM_MODULE_FILE_BYTES
3555 )));
3556 }
3557 let bytes = fs::read(resolved_path).map_err(|error| {
3558 WasmExecutionError::InvalidModule(format!(
3559 "failed to read {}: {error}",
3560 resolved_path.display()
3561 ))
3562 })?;
3563 let module_limits = extract_wasm_module_limits(&bytes)?;
3564
3565 if module_limits.imports_memory {
3566 return Err(WasmExecutionError::InvalidModule(String::from(
3567 "configured WebAssembly memory limit does not support imported memories yet",
3568 )));
3569 }
3570
3571 if let Some(initial_bytes) = module_limits.initial_memory_bytes {
3572 if initial_bytes > memory_limit {
3573 warn_limit_exhausted(
3574 TrackedLimit::WasmMemoryBytes,
3575 usize_saturating_from_u64(initial_bytes),
3576 usize_saturating_from_u64(memory_limit),
3577 );
3578 return Err(WasmExecutionError::InvalidModule(format!(
3579 "initial WebAssembly memory of {initial_bytes} bytes exceeds the configured limit of {memory_limit} bytes"
3580 )));
3581 }
3582 }
3583
3584 match module_limits.maximum_memory_bytes {
3585 Some(maximum_bytes) if maximum_bytes > memory_limit => {
3586 warn_limit_exhausted(
3587 TrackedLimit::WasmMemoryBytes,
3588 usize_saturating_from_u64(maximum_bytes),
3589 usize_saturating_from_u64(memory_limit),
3590 );
3591 Err(WasmExecutionError::InvalidModule(format!(
3592 "WebAssembly memory maximum of {maximum_bytes} bytes exceeds the configured limit of {memory_limit} bytes"
3593 )))
3594 }
3595 Some(_) => Ok(()),
3596 None => Ok(()),
3597 }
3598}
3599
3600fn duration_millis_saturating_usize(duration: Duration) -> usize {
3601 usize::try_from(duration.as_millis()).unwrap_or(usize::MAX)
3602}
3603
3604fn usize_saturating_from_u64(value: u64) -> usize {
3605 usize::try_from(value).unwrap_or(usize::MAX)
3606}
3607
3608#[derive(Debug, Default)]
3609struct WasmModuleLimits {
3610 imports_memory: bool,
3611 initial_memory_bytes: Option<u64>,
3612 maximum_memory_bytes: Option<u64>,
3613}
3614
3615fn extract_wasm_module_limits(bytes: &[u8]) -> Result<WasmModuleLimits, WasmExecutionError> {
3616 if bytes.len() < 8 || &bytes[..4] != b"\0asm" {
3617 return Err(WasmExecutionError::InvalidModule(String::from(
3618 "module is not a valid WebAssembly binary",
3619 )));
3620 }
3621
3622 let mut offset = 8;
3623 let mut limits = WasmModuleLimits::default();
3624
3625 while offset < bytes.len() {
3626 let section_id = bytes[offset];
3627 offset += 1;
3628 let section_size = read_varuint_usize(bytes, &mut offset, "section size")?;
3629 let section_end = offset.checked_add(section_size).ok_or_else(|| {
3630 WasmExecutionError::InvalidModule(String::from("section size overflow"))
3631 })?;
3632 if section_end > bytes.len() {
3633 return Err(WasmExecutionError::InvalidModule(String::from(
3634 "section extends past end of module",
3635 )));
3636 }
3637
3638 match section_id {
3639 2 => {
3640 let mut cursor = offset;
3641 let import_count = read_varuint_usize(bytes, &mut cursor, "import count")?;
3642 if import_count > MAX_WASM_IMPORT_SECTION_ENTRIES {
3643 return Err(WasmExecutionError::InvalidModule(format!(
3644 "import section contains {import_count} entries, which exceeds the parser cap of {MAX_WASM_IMPORT_SECTION_ENTRIES}"
3645 )));
3646 }
3647 for _ in 0..import_count {
3648 skip_name(bytes, &mut cursor)?;
3649 skip_name(bytes, &mut cursor)?;
3650 let kind = read_byte(bytes, &mut cursor)?;
3651 match kind {
3652 0x02 => {
3653 let _ = read_memory_limits(bytes, &mut cursor)?;
3654 limits.imports_memory = true;
3655 }
3656 0x00 => {
3657 let _ = read_varuint(bytes, &mut cursor)?;
3658 }
3659 0x01 => {
3660 skip_table_type(bytes, &mut cursor)?;
3661 }
3662 0x03 => {
3663 let _ = read_byte(bytes, &mut cursor)?;
3664 let _ = read_byte(bytes, &mut cursor)?;
3665 }
3666 other => {
3667 return Err(WasmExecutionError::InvalidModule(format!(
3668 "unsupported import kind {other}"
3669 )));
3670 }
3671 }
3672 }
3673 }
3674 5 => {
3675 let mut cursor = offset;
3676 let memory_count = read_varuint_usize(bytes, &mut cursor, "memory count")?;
3677 if memory_count > MAX_WASM_MEMORY_SECTION_ENTRIES {
3678 return Err(WasmExecutionError::InvalidModule(format!(
3679 "memory section contains {memory_count} entries, which exceeds the parser cap of {MAX_WASM_MEMORY_SECTION_ENTRIES}"
3680 )));
3681 }
3682 if memory_count > 0 {
3683 let (initial_pages, maximum_pages) = read_memory_limits(bytes, &mut cursor)?;
3684 limits.initial_memory_bytes =
3685 Some(initial_pages.saturating_mul(WASM_PAGE_BYTES));
3686 limits.maximum_memory_bytes =
3687 maximum_pages.map(|pages| pages.saturating_mul(WASM_PAGE_BYTES));
3688 }
3689 }
3690 _ => {}
3691 }
3692
3693 offset = section_end;
3694 }
3695
3696 Ok(limits)
3697}
3698
3699fn read_memory_limits(
3700 bytes: &[u8],
3701 offset: &mut usize,
3702) -> Result<(u64, Option<u64>), WasmExecutionError> {
3703 let flags = read_varuint(bytes, offset)?;
3704 let initial = read_varuint(bytes, offset)?;
3705 let maximum = if flags & 0x01 != 0 {
3706 Some(read_varuint(bytes, offset)?)
3707 } else {
3708 None
3709 };
3710 Ok((initial, maximum))
3711}
3712
3713fn skip_name(bytes: &[u8], offset: &mut usize) -> Result<(), WasmExecutionError> {
3714 let length = read_varuint_usize(bytes, offset, "name length")?;
3715 let end = offset
3716 .checked_add(length)
3717 .ok_or_else(|| WasmExecutionError::InvalidModule(String::from("name length overflow")))?;
3718 if end > bytes.len() {
3719 return Err(WasmExecutionError::InvalidModule(String::from(
3720 "name extends past end of module",
3721 )));
3722 }
3723 *offset = end;
3724 Ok(())
3725}
3726
3727fn skip_table_type(bytes: &[u8], offset: &mut usize) -> Result<(), WasmExecutionError> {
3728 let _ = read_byte(bytes, offset)?;
3729 let flags = read_varuint(bytes, offset)?;
3730 let _ = read_varuint(bytes, offset)?;
3731 if flags & 0x01 != 0 {
3732 let _ = read_varuint(bytes, offset)?;
3733 }
3734 Ok(())
3735}
3736
3737fn read_byte(bytes: &[u8], offset: &mut usize) -> Result<u8, WasmExecutionError> {
3738 let Some(byte) = bytes.get(*offset).copied() else {
3739 return Err(WasmExecutionError::InvalidModule(String::from(
3740 "unexpected end of module",
3741 )));
3742 };
3743 *offset += 1;
3744 Ok(byte)
3745}
3746
3747fn read_varuint(bytes: &[u8], offset: &mut usize) -> Result<u64, WasmExecutionError> {
3748 let mut shift = 0_u32;
3749 let mut value = 0_u64;
3750 let mut encoded_bytes = 0_usize;
3751
3752 loop {
3753 let byte = read_byte(bytes, offset)?;
3754 encoded_bytes += 1;
3755 if encoded_bytes > MAX_WASM_VARUINT_BYTES {
3756 return Err(WasmExecutionError::InvalidModule(format!(
3757 "varuint exceeds the parser cap of {MAX_WASM_VARUINT_BYTES} bytes"
3758 )));
3759 }
3760 value |= u64::from(byte & 0x7f) << shift;
3761 if byte & 0x80 == 0 {
3762 return Ok(value);
3763 }
3764 if encoded_bytes == MAX_WASM_VARUINT_BYTES {
3765 return Err(WasmExecutionError::InvalidModule(format!(
3766 "varuint exceeds the parser cap of {MAX_WASM_VARUINT_BYTES} bytes"
3767 )));
3768 }
3769 shift = shift.saturating_add(7);
3770 if shift >= 64 {
3771 return Err(WasmExecutionError::InvalidModule(String::from(
3772 "varuint is too large",
3773 )));
3774 }
3775 }
3776}
3777
3778fn read_varuint_usize(
3779 bytes: &[u8],
3780 offset: &mut usize,
3781 label: &str,
3782) -> Result<usize, WasmExecutionError> {
3783 let value = read_varuint(bytes, offset)?;
3784 usize::try_from(value).map_err(|_| {
3785 WasmExecutionError::InvalidModule(format!(
3786 "{label} of {value} exceeds platform usize range"
3787 ))
3788 })
3789}
3790
3791impl From<NodeSignalDispositionAction> for WasmSignalDispositionAction {
3792 fn from(value: NodeSignalDispositionAction) -> Self {
3793 match value {
3794 NodeSignalDispositionAction::Default => Self::Default,
3795 NodeSignalDispositionAction::Ignore => Self::Ignore,
3796 NodeSignalDispositionAction::User => Self::User,
3797 }
3798 }
3799}
3800
3801impl From<NodeSignalHandlerRegistration> for WasmSignalHandlerRegistration {
3802 fn from(value: NodeSignalHandlerRegistration) -> Self {
3803 Self {
3804 action: value.action.into(),
3805 mask: value.mask,
3806 flags: value.flags,
3807 }
3808 }
3809}
3810
3811fn resolve_path_like_specifier(cwd: &Path, specifier: &str) -> Option<PathBuf> {
3812 if specifier.starts_with("file://") {
3813 return Some(PathBuf::from(specifier.trim_start_matches("file://")));
3814 }
3815 if specifier.starts_with("file:") {
3816 return Some(PathBuf::from(specifier.trim_start_matches("file:")));
3817 }
3818 if specifier.starts_with('/') {
3819 return Some(PathBuf::from(specifier));
3820 }
3821 if specifier.starts_with("./") || specifier.starts_with("../") {
3822 return Some(cwd.join(specifier));
3823 }
3824
3825 None
3826}
3827
3828#[cfg(test)]
3829mod tests {
3830 use super::{
3831 build_wasm_internal_env, build_wasm_runner_bootstrap, open_wasm_guest_file,
3832 resolve_wasm_execution_timeout, resolve_wasm_prewarm_timeout,
3833 resolve_wasm_stack_limit_bytes, resolved_module_path, translate_wasm_guest_path,
3834 translate_wasm_host_symlink_target, wasm_guest_module_paths, wasm_host_path_is_read_only,
3835 wasm_memory_limit_bytes, wasm_memory_limit_pages, wasm_mutation_touches_read_only_mapping,
3836 wasm_read_only_filesystem_error, wasm_runner_base_env, wasm_runner_heap_limit_mb,
3837 wasm_sandbox_root, wasm_snapshot_runner_base_env, wasm_sync_read_length,
3838 wasm_sync_rpc_error_code, wasm_sync_rpc_method_routes_through_sidecar_kernel,
3839 GuestRuntimeConfig, JavascriptSyncRpcRequest, ResolvedWasmModule,
3840 StartWasmExecutionRequest, Value, WasmExecutionError, WasmExecutionLimits,
3841 WasmInternalSyncRpc, WasmPermissionTier, DEFAULT_WASM_PREWARM_TIMEOUT_MS,
3842 DEFAULT_WASM_RUNNER_HEAP_LIMIT_MB, NODE_WASI_MODULE_SOURCE,
3843 WASM_CAPTURED_OUTPUT_LIMIT_BYTES, WASM_MAX_FUEL_ENV, WASM_MAX_MEMORY_BYTES_ENV,
3844 WASM_MAX_STACK_BYTES_ENV, WASM_PAGE_BYTES, WASM_SANDBOX_ROOT_ENV,
3845 WASM_SIDECAR_ROUTED_FS_SYNC_METHODS, WASM_SYNC_READ_LIMIT_BYTES,
3846 };
3847 use std::collections::{BTreeMap, BTreeSet, VecDeque};
3848 use std::fs;
3849 use std::os::unix::fs::symlink;
3850 use std::path::{Path, PathBuf};
3851 use std::time::Duration;
3852 use tempfile::tempdir;
3853
3854 fn request_with_env(cwd: &Path, env: BTreeMap<String, String>) -> StartWasmExecutionRequest {
3855 let parse = |key: &str| env.get(key).and_then(|value| value.parse::<u64>().ok());
3859 let limits = WasmExecutionLimits {
3860 max_fuel: parse(WASM_MAX_FUEL_ENV),
3861 max_memory_bytes: parse(WASM_MAX_MEMORY_BYTES_ENV),
3862 max_stack_bytes: parse(WASM_MAX_STACK_BYTES_ENV),
3863 prewarm_timeout_ms: None,
3864 runner_heap_limit_mb: None,
3865 };
3866 StartWasmExecutionRequest {
3867 limits,
3868 guest_runtime: GuestRuntimeConfig::default(),
3869 vm_id: String::from("vm-wasm"),
3870 context_id: String::from("ctx-wasm"),
3871 argv: Vec::new(),
3872 env,
3873 cwd: cwd.to_path_buf(),
3874 permission_tier: WasmPermissionTier::Full,
3875 }
3876 }
3877
3878 fn wasi_imports_from_source(source: &str) -> BTreeSet<String> {
3879 let table_start = source
3880 .find("this.wasiImport = {")
3881 .expect("WASI source should define a wasiImport table");
3882 let table_body = &source[table_start + "this.wasiImport = {".len()..];
3883 let table_end = table_body
3884 .find("\n };")
3885 .expect("WASI source should close the wasiImport table");
3886
3887 table_body[..table_end]
3888 .lines()
3889 .filter_map(|line| {
3890 let (name, _) = line.trim_start().split_once(':')?;
3891 name.chars()
3892 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
3893 .then(|| name.to_string())
3894 })
3895 .collect()
3896 }
3897
3898 fn wasm_sync_rpc_request(method: &str) -> JavascriptSyncRpcRequest {
3899 JavascriptSyncRpcRequest {
3900 id: 1,
3901 method: method.to_string(),
3902 args: Vec::new(),
3903 raw_bytes_args: Default::default(),
3904 }
3905 }
3906
3907 fn request_with_typed_limits_and_misleading_env(
3910 limits: WasmExecutionLimits,
3911 ) -> StartWasmExecutionRequest {
3912 StartWasmExecutionRequest {
3913 limits,
3914 guest_runtime: GuestRuntimeConfig::default(),
3915 vm_id: String::from("vm-wasm"),
3916 context_id: String::from("ctx-wasm"),
3917 argv: Vec::new(),
3918 env: BTreeMap::from([
3921 (String::from(WASM_MAX_FUEL_ENV), String::from("999999")),
3922 (
3923 String::from(WASM_MAX_MEMORY_BYTES_ENV),
3924 String::from("999999"),
3925 ),
3926 (
3927 String::from(WASM_MAX_STACK_BYTES_ENV),
3928 String::from("999999"),
3929 ),
3930 (
3931 String::from("AGENTOS_WASM_PREWARM_TIMEOUT_MS"),
3932 String::from("999999"),
3933 ),
3934 (
3935 String::from("AGENTOS_WASM_RUNNER_HEAP_LIMIT_MB"),
3936 String::from("999999"),
3937 ),
3938 ]),
3939 cwd: PathBuf::from("/tmp"),
3940 permission_tier: WasmPermissionTier::Full,
3941 }
3942 }
3943
3944 #[test]
3945 fn wasm_limits_are_read_from_typed_fields_and_env_is_inert() {
3946 let request = request_with_typed_limits_and_misleading_env(WasmExecutionLimits {
3947 max_fuel: Some(25),
3948 max_memory_bytes: Some(65_536),
3949 max_stack_bytes: Some(131_072),
3950 prewarm_timeout_ms: Some(750),
3951 runner_heap_limit_mb: Some(512),
3952 });
3953
3954 assert_eq!(
3955 resolve_wasm_execution_timeout(&request).expect("fuel timeout"),
3956 Some(Duration::from_millis(25)),
3957 "fuel must come from the typed wire limit, not AGENTOS_WASM_MAX_FUEL"
3958 );
3959 assert_eq!(
3960 wasm_memory_limit_bytes(&request).expect("memory limit"),
3961 Some(65_536),
3962 "memory must come from the typed wire limit, not AGENTOS_WASM_MAX_MEMORY_BYTES"
3963 );
3964 assert_eq!(
3965 resolve_wasm_stack_limit_bytes(&request).expect("stack limit"),
3966 Some(131_072),
3967 "stack must come from the typed wire limit (retiring the dead AGENTOS_WASM_MAX_STACK_BYTES knob)"
3968 );
3969 assert_eq!(
3970 resolve_wasm_prewarm_timeout(&request).expect("prewarm timeout"),
3971 Duration::from_millis(750),
3972 "prewarm timeout must come from the typed wire limit, not AGENTOS_WASM_PREWARM_TIMEOUT_MS"
3973 );
3974 assert_eq!(
3975 wasm_runner_heap_limit_mb(&request),
3976 512,
3977 "runner heap must come from the typed wire limit, not AGENTOS_WASM_RUNNER_HEAP_LIMIT_MB"
3978 );
3979 }
3980
3981 #[test]
3982 fn wasm_limits_default_to_bounded_timeout_when_unset_even_with_env_present() {
3983 let request = request_with_typed_limits_and_misleading_env(WasmExecutionLimits::default());
3987
3988 assert_eq!(
3989 resolve_wasm_execution_timeout(&request).expect("fuel"),
3990 None
3991 );
3992 assert_eq!(wasm_memory_limit_bytes(&request).expect("memory"), None);
3993 assert_eq!(
3994 resolve_wasm_stack_limit_bytes(&request).expect("stack"),
3995 None
3996 );
3997 assert_eq!(
3998 resolve_wasm_prewarm_timeout(&request).expect("prewarm"),
3999 Duration::from_millis(DEFAULT_WASM_PREWARM_TIMEOUT_MS)
4000 );
4001 assert_eq!(
4002 wasm_runner_heap_limit_mb(&request),
4003 DEFAULT_WASM_RUNNER_HEAP_LIMIT_MB
4004 );
4005 }
4006
4007 #[test]
4008 fn wasm_internal_env_scrubs_migrated_limit_env_keys() {
4009 let request = request_with_typed_limits_and_misleading_env(WasmExecutionLimits {
4010 max_fuel: Some(25),
4011 max_memory_bytes: Some(65_536),
4012 max_stack_bytes: Some(131_072),
4013 prewarm_timeout_ms: Some(750),
4014 runner_heap_limit_mb: Some(512),
4015 });
4016 let resolved_module = ResolvedWasmModule {
4017 specifier: String::from("./guest.wasm"),
4018 resolved_path: PathBuf::from("/tmp/guest.wasm"),
4019 };
4020
4021 let internal_env =
4022 build_wasm_internal_env(&resolved_module, &request, 1_234, false).expect("env");
4023
4024 assert_eq!(
4025 internal_env.get(WASM_MAX_MEMORY_BYTES_ENV),
4026 Some(&String::from("65536"))
4027 );
4028 assert!(!internal_env.contains_key(WASM_MAX_STACK_BYTES_ENV));
4029 assert!(!internal_env.contains_key(WASM_MAX_FUEL_ENV));
4030 assert!(!internal_env.contains_key("AGENTOS_WASM_PREWARM_TIMEOUT_MS"));
4031 assert!(!internal_env.contains_key("AGENTOS_WASM_RUNNER_HEAP_LIMIT_MB"));
4032 }
4033
4034 #[test]
4035 fn wasm_runner_base_env_scrubs_migrated_limit_env_keys() {
4036 let mut request = request_with_typed_limits_and_misleading_env(WasmExecutionLimits {
4037 max_fuel: Some(25),
4038 max_memory_bytes: Some(65_536),
4039 max_stack_bytes: Some(131_072),
4040 prewarm_timeout_ms: Some(750),
4041 runner_heap_limit_mb: Some(512),
4042 });
4043 request
4044 .env
4045 .insert(String::from("USER_VISIBLE"), String::from("kept"));
4046 request
4047 .env
4048 .insert(String::from("AGENTOS_TRACE_ID"), String::from("kept"));
4049
4050 let env = wasm_runner_base_env(&request);
4051
4052 assert_eq!(env.get("USER_VISIBLE"), Some(&String::from("kept")));
4053 assert_eq!(env.get("AGENTOS_TRACE_ID"), Some(&String::from("kept")));
4054 assert!(!env.contains_key(WASM_MAX_FUEL_ENV));
4055 assert!(!env.contains_key(WASM_MAX_MEMORY_BYTES_ENV));
4056 assert!(!env.contains_key(WASM_MAX_STACK_BYTES_ENV));
4057 assert!(!env.contains_key("AGENTOS_WASM_PREWARM_TIMEOUT_MS"));
4058 assert!(!env.contains_key("AGENTOS_WASM_RUNNER_HEAP_LIMIT_MB"));
4059 }
4060
4061 #[test]
4062 fn wasm_snapshot_runner_base_env_scrubs_internal_and_migrated_limit_env_keys() {
4063 let mut request = request_with_typed_limits_and_misleading_env(WasmExecutionLimits {
4064 max_fuel: Some(25),
4065 max_memory_bytes: Some(65_536),
4066 max_stack_bytes: Some(131_072),
4067 prewarm_timeout_ms: Some(750),
4068 runner_heap_limit_mb: Some(512),
4069 });
4070 request
4071 .env
4072 .insert(String::from("USER_VISIBLE"), String::from("kept"));
4073 request.env.insert(
4074 String::from("NODE_SYNC_RPC_WAIT_TIMEOUT_MS"),
4075 String::from("999"),
4076 );
4077
4078 let env = wasm_snapshot_runner_base_env(&request);
4079
4080 assert_eq!(env.get("USER_VISIBLE"), Some(&String::from("kept")));
4081 assert!(!env.contains_key("NODE_SYNC_RPC_WAIT_TIMEOUT_MS"));
4082 assert!(!env.contains_key(WASM_MAX_FUEL_ENV));
4083 assert!(!env.contains_key(WASM_MAX_MEMORY_BYTES_ENV));
4084 assert!(!env.contains_key(WASM_MAX_STACK_BYTES_ENV));
4085 assert!(!env.contains_key("AGENTOS_WASM_PREWARM_TIMEOUT_MS"));
4086 assert!(!env.contains_key("AGENTOS_WASM_RUNNER_HEAP_LIMIT_MB"));
4087 }
4088
4089 #[test]
4090 fn wasm_stack_limit_of_zero_is_rejected() {
4091 let request = request_with_typed_limits_and_misleading_env(WasmExecutionLimits {
4092 max_stack_bytes: Some(0),
4093 ..WasmExecutionLimits::default()
4094 });
4095
4096 assert!(
4097 resolve_wasm_stack_limit_bytes(&request).is_err(),
4098 "a zero stack cap must fail closed rather than be silently dropped"
4099 );
4100 }
4101
4102 #[test]
4103 fn resolved_module_path_canonicalizes_path_like_specifiers() {
4104 let temp = tempdir().expect("create temp dir");
4105 let real = temp.path().join("real.wasm");
4106 let alias = temp.path().join("alias.wasm");
4107 fs::write(&real, b"\0asm\x01\0\0\0").expect("write wasm file");
4108 symlink(&real, &alias).expect("create wasm symlink");
4109
4110 let resolved = resolved_module_path("./alias.wasm", temp.path());
4111
4112 assert_eq!(
4113 resolved,
4114 real.canonicalize().expect("canonicalize wasm target")
4115 );
4116 }
4117
4118 #[test]
4119 fn wasm_prewarm_timeout_is_separate_from_execution_timeout() {
4120 let temp = tempdir().expect("create temp dir");
4121 let mut request = request_with_env(
4122 temp.path(),
4123 BTreeMap::from([(String::from(WASM_MAX_FUEL_ENV), String::from("25"))]),
4124 );
4125 request.limits.prewarm_timeout_ms = Some(750);
4126
4127 assert_eq!(
4128 resolve_wasm_execution_timeout(&request).expect("execution timeout"),
4129 Some(Duration::from_millis(25))
4130 );
4131 assert_eq!(
4132 resolve_wasm_prewarm_timeout(&request).expect("prewarm timeout"),
4133 Duration::from_millis(750)
4134 );
4135 }
4136
4137 #[test]
4141 fn wasm_execution_timeout_is_unset_without_fuel_budget() {
4142 let temp = tempdir().expect("create temp dir");
4143 let request = request_with_env(temp.path(), BTreeMap::new());
4144
4145 let timeout = resolve_wasm_execution_timeout(&request)
4146 .expect("execution timeout resolves without fuel env");
4147
4148 assert_eq!(
4149 timeout, None,
4150 "no explicit fuel budget means no wall-clock timeout; the runner \
4151 isolate's TRUE-CPU budget (default 30s active CPU) is the bound \
4152 that terminates an infinite-loop module (F-004), so an idle \
4153 interactive guest is not killed on wall time"
4154 );
4155 }
4156
4157 #[test]
4158 fn wasm_captured_output_rejects_output_over_limit() {
4159 let mut stdout = vec![b'x'; WASM_CAPTURED_OUTPUT_LIMIT_BYTES - 1];
4160 super::append_wasm_captured_output(&mut stdout, b"y", "stdout").expect("fill to limit");
4161 assert_eq!(stdout.len(), WASM_CAPTURED_OUTPUT_LIMIT_BYTES);
4162
4163 let error = super::append_wasm_captured_output(&mut stdout, b"z", "stdout")
4164 .expect_err("captured output over limit should fail");
4165 assert!(matches!(
4166 error,
4167 WasmExecutionError::OutputBufferExceeded {
4168 stream: "stdout",
4169 limit: WASM_CAPTURED_OUTPUT_LIMIT_BYTES,
4170 }
4171 ));
4172 }
4173
4174 #[test]
4175 fn wasm_sync_read_length_rejects_oversized_guest_lengths() {
4176 assert_eq!(
4177 wasm_sync_read_length(Some(WASM_SYNC_READ_LIMIT_BYTES as u64))
4178 .expect("max read length should be accepted"),
4179 WASM_SYNC_READ_LIMIT_BYTES
4180 );
4181
4182 let error = wasm_sync_read_length(Some(WASM_SYNC_READ_LIMIT_BYTES as u64 + 1))
4183 .expect_err("oversized read length should fail before allocation");
4184 assert!(
4185 matches!(error, WasmExecutionError::InvalidLimit(message) if message.contains("fs.readSync length"))
4186 );
4187 }
4188
4189 #[test]
4190 fn wasm_bytes_arg_rejects_payloads_over_limit_before_decode() {
4191 let mut payload = serde_json::Map::new();
4192 payload.insert(
4193 String::from("base64"),
4194 Value::String(String::from("YWJjZA==")),
4195 );
4196
4197 let error =
4198 super::decode_wasm_bytes_arg(Some(&Value::Object(payload)), "fs.writeSync bytes", 3)
4199 .expect_err("decoded bytes over limit should fail before allocation");
4200
4201 assert!(matches!(
4202 error,
4203 WasmExecutionError::OutputBufferExceeded {
4204 stream: "fs.writeSync bytes",
4205 limit: 3,
4206 }
4207 ));
4208 }
4209
4210 #[test]
4211 fn wasm_runner_bootstrap_caps_wasi_iov_lengths_before_allocation() {
4212 let bootstrap = build_wasm_runner_bootstrap(&BTreeMap::new(), None);
4213
4214 assert!(bootstrap.contains("const __agentOSWasmSyncReadLimitBytes ="));
4218 assert!(bootstrap.contains(&format!(": {WASM_SYNC_READ_LIMIT_BYTES};")));
4219 assert!(!bootstrap.contains("__AGENTOS_WASM_SYNC_READ_LIMIT_BYTES__"));
4220 assert!(bootstrap.contains("_boundedIovLength(iovs, iovsLen)"));
4221 assert!(bootstrap.contains("const totalLength = this._boundedIovLength(iovs, iovsLen);\n const view = this._memoryView();"));
4222 assert!(bootstrap.contains("return Buffer.concat(chunks, totalLength);"));
4223 assert!(bootstrap.contains("const totalLength = this._boundedIovLength(iovs, iovsLen);"));
4224 assert!(!bootstrap.contains("const totalLength = (() => {"));
4225 }
4226
4227 #[test]
4228 fn wasi_preview1_import_manifest_matches_native_runner() {
4229 let expected: BTreeSet<String> = serde_json::from_str::<Vec<String>>(include_str!(
4230 "../assets/wasi-preview1-imports.json"
4231 ))
4232 .expect("parse WASI preview1 import manifest")
4233 .into_iter()
4234 .collect();
4235
4236 assert_eq!(expected, wasi_imports_from_source(NODE_WASI_MODULE_SOURCE));
4237 }
4238
4239 #[test]
4240 fn wasm_guest_module_paths_include_mapped_guest_paths_for_host_specifiers() {
4241 let temp = tempdir().expect("create temp dir");
4242 let command_root = temp.path().join("commands");
4243 let module = command_root.join("hello");
4244 fs::create_dir_all(&command_root).expect("create command root");
4245 fs::write(&module, b"\0asm\x01\0\0\0").expect("write wasm file");
4246
4247 let candidates = wasm_guest_module_paths(
4248 module.to_string_lossy().as_ref(),
4249 &BTreeMap::from([(
4250 String::from("AGENTOS_GUEST_PATH_MAPPINGS"),
4251 format!(
4252 "[{{\"guestPath\":\"/__secure_exec/commands/0\",\"hostPath\":\"{}\"}}]",
4253 command_root.display()
4254 ),
4255 )]),
4256 );
4257
4258 assert!(candidates.contains(&module.to_string_lossy().into_owned()));
4259 assert!(candidates.contains(&String::from("/__secure_exec/commands/0/hello")));
4260 }
4261
4262 #[test]
4263 fn translate_wasm_guest_path_uses_sandbox_root_for_absolute_paths() {
4264 let temp = tempdir().expect("create temp dir");
4265 let sandbox_root = temp.path().join("shadow-root");
4266 let cwd = sandbox_root.join("workspace");
4267 fs::create_dir_all(cwd.join("project")).expect("create host cwd");
4268
4269 let internal_sync_rpc = WasmInternalSyncRpc {
4270 module_guest_paths: Vec::new(),
4271 module_host_path: sandbox_root.join("module.wasm"),
4272 guest_cwd: String::from("/workspace"),
4273 host_cwd: cwd.clone(),
4274 sandbox_root: Some(sandbox_root.clone()),
4275 guest_path_mappings: Vec::new(),
4276 route_fs_through_sidecar: false,
4277 next_fd: 64,
4278 open_files: Default::default(),
4279 pending_events: VecDeque::new(),
4280 };
4281
4282 assert_eq!(
4283 translate_wasm_guest_path("/tmp/redir.txt", &internal_sync_rpc),
4284 Some(sandbox_root.join("tmp/redir.txt"))
4285 );
4286 assert_eq!(
4287 translate_wasm_guest_path("project/output.txt", &internal_sync_rpc),
4288 Some(cwd.join("project/output.txt"))
4289 );
4290 }
4291
4292 #[test]
4293 fn translate_wasm_host_symlink_target_returns_guest_path_for_mapped_targets() {
4294 let temp = tempdir().expect("create temp dir");
4295 let sandbox_root = temp.path().join("shadow-root");
4296 let cwd = sandbox_root.join("workspace");
4297 fs::create_dir_all(cwd.join("project")).expect("create host cwd");
4298
4299 let internal_sync_rpc = WasmInternalSyncRpc {
4300 module_guest_paths: Vec::new(),
4301 module_host_path: sandbox_root.join("module.wasm"),
4302 guest_cwd: String::from("/workspace"),
4303 host_cwd: cwd.clone(),
4304 sandbox_root: Some(sandbox_root.clone()),
4305 guest_path_mappings: vec![super::WasmGuestPathMapping {
4306 guest_path: String::from("/"),
4307 host_path: sandbox_root.clone(),
4308 read_only: false,
4309 }],
4310 route_fs_through_sidecar: false,
4311 next_fd: 64,
4312 open_files: Default::default(),
4313 pending_events: VecDeque::new(),
4314 };
4315
4316 assert_eq!(
4317 translate_wasm_host_symlink_target(
4318 &sandbox_root.join("tmp/sc/pdir/r.txt"),
4319 &internal_sync_rpc
4320 ),
4321 Some(String::from("/tmp/sc/pdir/r.txt"))
4322 );
4323 assert_eq!(
4324 translate_wasm_host_symlink_target(Path::new("relative-target"), &internal_sync_rpc),
4325 None
4326 );
4327 }
4328
4329 #[test]
4330 fn translate_wasm_guest_path_recovers_root_collapsed_relative_paths_from_guest_cwd() {
4331 let temp = tempdir().expect("create temp dir");
4332 let sandbox_root = temp.path().join("shadow-root");
4333 let cwd = temp.path().join("mounted-workspace");
4334 fs::create_dir_all(&sandbox_root).expect("create sandbox root");
4335 fs::create_dir_all(&cwd).expect("create mounted workspace");
4336 fs::write(cwd.join("note.txt"), b"hello").expect("write mounted file");
4337
4338 let internal_sync_rpc = WasmInternalSyncRpc {
4339 module_guest_paths: Vec::new(),
4340 module_host_path: sandbox_root.join("module.wasm"),
4341 guest_cwd: String::from("/workspace"),
4342 host_cwd: cwd.clone(),
4343 sandbox_root: Some(sandbox_root.clone()),
4344 guest_path_mappings: vec![super::WasmGuestPathMapping {
4345 guest_path: String::from("/workspace"),
4346 host_path: cwd.clone(),
4347 read_only: false,
4348 }],
4349 route_fs_through_sidecar: false,
4350 next_fd: 64,
4351 open_files: Default::default(),
4352 pending_events: VecDeque::new(),
4353 };
4354
4355 assert_eq!(
4356 translate_wasm_guest_path("/note.txt", &internal_sync_rpc),
4357 Some(cwd.join("note.txt"))
4358 );
4359 }
4360
4361 #[test]
4362 fn translate_wasm_guest_path_accepts_host_absolute_paths_within_known_roots() {
4363 let temp = tempdir().expect("create temp dir");
4364 let sandbox_root = temp.path().join("shadow-root");
4365 let cwd = temp.path().join("mounted-workspace");
4366 let mapped_root = temp.path().join("mounted-commands");
4367 fs::create_dir_all(&sandbox_root).expect("create sandbox root");
4368 fs::create_dir_all(cwd.join("subdir")).expect("create cwd");
4369 fs::create_dir_all(&mapped_root).expect("create mapped root");
4370
4371 let internal_sync_rpc = WasmInternalSyncRpc {
4372 module_guest_paths: vec![String::from("/workspace/guest.wasm")],
4373 module_host_path: cwd.join("guest.wasm"),
4374 guest_cwd: String::from("/workspace"),
4375 host_cwd: cwd.clone(),
4376 sandbox_root: Some(sandbox_root.clone()),
4377 guest_path_mappings: vec![
4378 super::WasmGuestPathMapping {
4379 guest_path: String::from("/workspace"),
4380 host_path: cwd.clone(),
4381 read_only: false,
4382 },
4383 super::WasmGuestPathMapping {
4384 guest_path: String::from("/__secure_exec/commands/0"),
4385 host_path: mapped_root.clone(),
4386 read_only: false,
4387 },
4388 ],
4389 route_fs_through_sidecar: false,
4390 next_fd: 64,
4391 open_files: Default::default(),
4392 pending_events: VecDeque::new(),
4393 };
4394
4395 assert_eq!(
4396 translate_wasm_guest_path(cwd.to_string_lossy().as_ref(), &internal_sync_rpc),
4397 Some(cwd.clone())
4398 );
4399 assert_eq!(
4400 translate_wasm_guest_path(
4401 cwd.join("subdir/output.txt").to_string_lossy().as_ref(),
4402 &internal_sync_rpc
4403 ),
4404 Some(cwd.join("subdir/output.txt"))
4405 );
4406 assert_eq!(
4407 translate_wasm_guest_path(
4408 mapped_root.join("tool.wasm").to_string_lossy().as_ref(),
4409 &internal_sync_rpc
4410 ),
4411 Some(mapped_root.join("tool.wasm"))
4412 );
4413 assert_eq!(
4414 translate_wasm_guest_path(
4415 sandbox_root
4416 .join("tmp/runtime.sock")
4417 .to_string_lossy()
4418 .as_ref(),
4419 &internal_sync_rpc
4420 ),
4421 Some(sandbox_root.join("tmp/runtime.sock"))
4422 );
4423 }
4424
4425 #[test]
4426 fn translate_wasm_guest_path_rejects_symlink_escape_from_sandbox_root() {
4427 let temp = tempdir().expect("create temp dir");
4428 let sandbox_root = temp.path().join("shadow-root");
4429 let outside = temp.path().join("outside");
4430 fs::create_dir_all(&sandbox_root).expect("create sandbox root");
4431 fs::create_dir_all(&outside).expect("create outside root");
4432 fs::write(outside.join("secret.txt"), b"host secret").expect("write outside file");
4433 symlink(&outside, sandbox_root.join("escape")).expect("create escape symlink");
4434
4435 let internal_sync_rpc = WasmInternalSyncRpc {
4436 module_guest_paths: Vec::new(),
4437 module_host_path: sandbox_root.join("module.wasm"),
4438 guest_cwd: String::from("/"),
4439 host_cwd: sandbox_root.clone(),
4440 sandbox_root: Some(sandbox_root.clone()),
4441 guest_path_mappings: vec![super::WasmGuestPathMapping {
4442 guest_path: String::from("/"),
4443 host_path: sandbox_root,
4444 read_only: false,
4445 }],
4446 route_fs_through_sidecar: false,
4447 next_fd: 64,
4448 open_files: Default::default(),
4449 pending_events: VecDeque::new(),
4450 };
4451
4452 assert_eq!(
4453 translate_wasm_guest_path("/escape/secret.txt", &internal_sync_rpc),
4454 None
4455 );
4456 assert_eq!(
4457 translate_wasm_guest_path("/escape/new.txt", &internal_sync_rpc),
4458 None
4459 );
4460 }
4461
4462 #[test]
4463 fn wasm_read_only_mapping_blocks_mutating_host_paths() {
4464 let temp = tempdir().expect("create temp dir");
4465 let sandbox_root = temp.path().join("shadow-root");
4466 let readonly_root = temp.path().join("readonly");
4467 fs::create_dir_all(&sandbox_root).expect("create sandbox root");
4468 fs::create_dir_all(&readonly_root).expect("create readonly root");
4469 fs::write(readonly_root.join("package.json"), b"{}").expect("write readonly file");
4470
4471 let internal_sync_rpc = WasmInternalSyncRpc {
4472 module_guest_paths: Vec::new(),
4473 module_host_path: sandbox_root.join("module.wasm"),
4474 guest_cwd: String::from("/workspace"),
4475 host_cwd: sandbox_root.clone(),
4476 sandbox_root: Some(sandbox_root),
4477 guest_path_mappings: vec![super::WasmGuestPathMapping {
4478 guest_path: String::from("/node_modules"),
4479 host_path: readonly_root.clone(),
4480 read_only: true,
4481 }],
4482 route_fs_through_sidecar: false,
4483 next_fd: 64,
4484 open_files: Default::default(),
4485 pending_events: VecDeque::new(),
4486 };
4487
4488 let host_path = translate_wasm_guest_path("/node_modules/package.json", &internal_sync_rpc)
4489 .expect("read path should resolve");
4490 assert_eq!(host_path, readonly_root.join("package.json"));
4491 assert!(wasm_host_path_is_read_only(&host_path, &internal_sync_rpc));
4492 assert!(wasm_host_path_is_read_only(
4493 &readonly_root.join("new-package.json"),
4494 &internal_sync_rpc
4495 ));
4496 assert_eq!(
4497 wasm_sync_rpc_error_code(&wasm_read_only_filesystem_error("/node_modules")),
4498 "EROFS"
4499 );
4500 }
4501
4502 #[test]
4503 fn wasm_open_guest_file_errors_remain_sync_rpc_errors() {
4504 let temp = tempdir().expect("create temp dir");
4505 let missing_path = temp.path().join("missing.txt");
4506
4507 let error = open_wasm_guest_file(&missing_path, &Value::from(0))
4508 .expect_err("missing file should return an open error");
4509
4510 assert_eq!(wasm_sync_rpc_error_code(&error), "ENOENT");
4511 }
4512
4513 #[test]
4514 fn wasm_hard_links_are_rejected_when_either_side_is_read_only() {
4515 let temp = tempdir().expect("create temp dir");
4516 let readonly_root = temp.path().join("readonly");
4517 let writable_root = temp.path().join("writable");
4518 fs::create_dir_all(&readonly_root).expect("create readonly root");
4519 fs::create_dir_all(&writable_root).expect("create writable root");
4520 let readonly_file = readonly_root.join("package.json");
4521 let writable_file = writable_root.join("source.txt");
4522 fs::write(&readonly_file, b"readonly").expect("write readonly source");
4523 fs::write(&writable_file, b"writable").expect("write writable source");
4524
4525 let internal_sync_rpc = WasmInternalSyncRpc {
4526 module_guest_paths: Vec::new(),
4527 module_host_path: writable_root.join("module.wasm"),
4528 guest_cwd: String::from("/workspace"),
4529 host_cwd: writable_root.clone(),
4530 sandbox_root: Some(writable_root.clone()),
4531 guest_path_mappings: vec![
4532 super::WasmGuestPathMapping {
4533 guest_path: String::from("/node_modules"),
4534 host_path: readonly_root.clone(),
4535 read_only: true,
4536 },
4537 super::WasmGuestPathMapping {
4538 guest_path: String::from("/workspace"),
4539 host_path: writable_root.clone(),
4540 read_only: false,
4541 },
4542 ],
4543 route_fs_through_sidecar: false,
4544 next_fd: 64,
4545 open_files: Default::default(),
4546 pending_events: VecDeque::new(),
4547 };
4548
4549 assert!(wasm_mutation_touches_read_only_mapping(
4550 &readonly_file,
4551 &writable_root.join("alias-from-readonly.json"),
4552 &internal_sync_rpc
4553 ));
4554 assert!(wasm_mutation_touches_read_only_mapping(
4555 &writable_file,
4556 &readonly_root.join("alias-into-readonly.txt"),
4557 &internal_sync_rpc
4558 ));
4559 assert!(!wasm_mutation_touches_read_only_mapping(
4560 &writable_file,
4561 &writable_root.join("alias.txt"),
4562 &internal_sync_rpc
4563 ));
4564
4565 let raw_alias = writable_root.join("raw-alias.json");
4566 fs::hard_link(&readonly_file, &raw_alias).expect("host hard link would otherwise succeed");
4567 fs::write(&raw_alias, b"mutated").expect("write through host hard link alias");
4568 assert_eq!(
4569 fs::read(&readonly_file).expect("read readonly source"),
4570 b"mutated"
4571 );
4572 }
4573
4574 #[test]
4575 fn translate_wasm_guest_path_preserves_real_root_paths_before_guest_cwd_fallback() {
4576 let temp = tempdir().expect("create temp dir");
4577 let sandbox_root = temp.path().join("shadow-root");
4578 let cwd = temp.path().join("mounted-workspace");
4579 fs::create_dir_all(&sandbox_root).expect("create sandbox root");
4580 fs::create_dir_all(&cwd).expect("create mounted workspace");
4581 fs::write(sandbox_root.join("note.txt"), b"root").expect("write root file");
4582 fs::write(cwd.join("note.txt"), b"cwd").expect("write cwd file");
4583
4584 let internal_sync_rpc = WasmInternalSyncRpc {
4585 module_guest_paths: Vec::new(),
4586 module_host_path: sandbox_root.join("module.wasm"),
4587 guest_cwd: String::from("/workspace"),
4588 host_cwd: cwd.clone(),
4589 sandbox_root: Some(sandbox_root.clone()),
4590 guest_path_mappings: vec![super::WasmGuestPathMapping {
4591 guest_path: String::from("/workspace"),
4592 host_path: cwd,
4593 read_only: false,
4594 }],
4595 route_fs_through_sidecar: false,
4596 next_fd: 64,
4597 open_files: Default::default(),
4598 pending_events: VecDeque::new(),
4599 };
4600
4601 assert_eq!(
4602 translate_wasm_guest_path("/note.txt", &internal_sync_rpc),
4603 Some(sandbox_root.join("note.txt"))
4604 );
4605 }
4606
4607 #[test]
4608 fn wasm_sandbox_root_reads_absolute_env_only() {
4609 let sandbox_root = wasm_sandbox_root(&BTreeMap::from([(
4610 String::from(WASM_SANDBOX_ROOT_ENV),
4611 String::from("/tmp/secure-exec-shadow"),
4612 )]));
4613 assert_eq!(sandbox_root, Some(PathBuf::from("/tmp/secure-exec-shadow")));
4614
4615 let relative = wasm_sandbox_root(&BTreeMap::from([(
4616 String::from(WASM_SANDBOX_ROOT_ENV),
4617 String::from("relative/shadow"),
4618 )]));
4619 assert_eq!(relative, None);
4620 }
4621
4622 #[test]
4623 fn wasm_sidecar_managed_fs_methods_route_to_kernel_sync_rpc() {
4624 let mut standalone = WasmInternalSyncRpc {
4625 module_guest_paths: Vec::new(),
4626 module_host_path: PathBuf::from("/tmp/module.wasm"),
4627 guest_cwd: String::from("/"),
4628 host_cwd: PathBuf::from("/tmp"),
4629 sandbox_root: None,
4630 guest_path_mappings: Vec::new(),
4631 route_fs_through_sidecar: false,
4632 next_fd: 64,
4633 open_files: Default::default(),
4634 pending_events: VecDeque::new(),
4635 };
4636 let sidecar_managed = WasmInternalSyncRpc {
4637 module_guest_paths: Vec::new(),
4638 module_host_path: PathBuf::from("/tmp/module.wasm"),
4639 guest_cwd: String::from("/"),
4640 host_cwd: PathBuf::from("/tmp"),
4641 sandbox_root: Some(PathBuf::from("/tmp/secure-exec-shadow")),
4642 guest_path_mappings: Vec::new(),
4643 route_fs_through_sidecar: true,
4644 next_fd: 64,
4645 open_files: Default::default(),
4646 pending_events: VecDeque::new(),
4647 };
4648
4649 for method in WASM_SIDECAR_ROUTED_FS_SYNC_METHODS {
4650 let request = wasm_sync_rpc_request(method);
4651 assert!(
4652 wasm_sync_rpc_method_routes_through_sidecar_kernel(&request, &sidecar_managed),
4653 "{method} should route through the sidecar kernel for managed WASI executions"
4654 );
4655 assert!(
4656 !wasm_sync_rpc_method_routes_through_sidecar_kernel(&request, &standalone),
4657 "{method} should stay host-direct for standalone/prewarm WASI execution"
4658 );
4659 }
4660
4661 standalone.route_fs_through_sidecar = true;
4662 let non_fs_request = wasm_sync_rpc_request("child_process.spawn");
4663 assert!(!wasm_sync_rpc_method_routes_through_sidecar_kernel(
4664 &non_fs_request,
4665 &standalone
4666 ));
4667 }
4668
4669 #[test]
4670 fn wasm_guest_path_mappings_mount_root_to_sandbox_root() {
4671 let temp = tempdir().expect("create temp dir");
4672 let sandbox_root = temp.path().join("shadow-root");
4673 let host_cwd = sandbox_root.join("workspace");
4674 fs::create_dir_all(&host_cwd).expect("create host cwd");
4675
4676 let mappings = super::wasm_guest_path_mappings(&request_with_env(
4677 &host_cwd,
4678 BTreeMap::from([
4679 (String::from("PWD"), String::from("/workspace")),
4680 (
4681 String::from(WASM_SANDBOX_ROOT_ENV),
4682 sandbox_root.to_string_lossy().into_owned(),
4683 ),
4684 ]),
4685 ));
4686
4687 assert!(mappings
4688 .iter()
4689 .any(|mapping| { mapping.guest_path == "/" && mapping.host_path == sandbox_root }));
4690 assert!(mappings.iter().any(|mapping| {
4691 mapping.guest_path == "/workspace" && mapping.host_path == host_cwd
4692 }));
4693 }
4694
4695 #[test]
4696 fn wasm_runner_bootstrap_keeps_root_preopens_rooted() {
4697 let bootstrap = build_wasm_runner_bootstrap(&BTreeMap::new(), None);
4698
4699 assert!(bootstrap.contains("if (guestPath === \".\") {"));
4700 assert!(!bootstrap.contains("if (guestPath === \".\" || guestPath === \"/\") {"));
4701 }
4702
4703 #[test]
4704 fn wasm_runner_bootstrap_reports_dot_preopen_to_wasi() {
4705 let bootstrap = build_wasm_runner_bootstrap(&BTreeMap::new(), None);
4706
4707 assert!(bootstrap.contains("_currentGuestCwd()"));
4711 assert!(!bootstrap.contains("preopens['.'] = createPreopen(HOST_CWD, cwdReadOnly);"));
4712 assert!(bootstrap.contains("_descriptorPreopenName(entry)"));
4713 assert!(bootstrap.contains(
4714 "if (guestPath === \".\") {\n return this._descriptorGuestPath(entry);"
4715 ));
4716 assert!(bootstrap.contains("const guestPath = this._descriptorPreopenName(entry);"));
4717 }
4718
4719 #[test]
4720 fn wasm_runner_path_open_uses_guest_mapping_for_absolute_paths() {
4721 let bootstrap = build_wasm_runner_bootstrap(&BTreeMap::new(), None);
4722
4723 assert!(bootstrap
4724 .contains("const resolved = this._resolveDescriptorPath(fd, pathPtr, pathLen, {"));
4725 assert!(
4726 !bootstrap.contains("const hostPath = __agentOSPath().resolve(baseHostPath, target);")
4727 );
4728 }
4729
4730 #[test]
4731 fn wasm_runner_root_preopen_relative_paths_preserve_cwd_fallback() {
4732 let bootstrap = build_wasm_runner_bootstrap(&BTreeMap::new(), None);
4733
4734 assert!(bootstrap
4735 .contains("const rootGuestPath = __agentOSPath().posix.resolve(\"/\", target);"));
4736 assert!(bootstrap.contains(
4737 "const cwdGuestTarget = __agentOSPath().posix.resolve(cwdGuestPath, target);"
4738 ));
4739 assert!(bootstrap.contains("_rootRelativeTargetPrefersCwd(target)"));
4740 assert!(bootstrap.contains("_rootRelativeTargetMatchesAbsoluteArg(target)"));
4741 assert!(bootstrap.contains("__agentOSPath().posix.normalize(arg) === rootGuestPath"));
4742 assert!(bootstrap.contains("_createParentExists(guestPath, hostPath)"));
4743 assert!(bootstrap.contains(
4744 "preferCreateParent &&\n !this._rootRelativeTargetIsWithinAbsoluteArg(target)"
4745 ));
4746 assert!(bootstrap.contains("this._createParentExists(cwdGuestTarget, cwdHostTarget)"));
4747 }
4748
4749 #[test]
4750 fn wasm_runner_readdir_uses_guest_preopen_path_in_sidecar() {
4751 let bootstrap = build_wasm_runner_bootstrap(&BTreeMap::new(), None);
4752
4753 assert!(bootstrap.contains("const fsPath = this._descriptorDirectoryFsPath(entry);"));
4754 assert!(
4755 bootstrap.contains("(entry?.kind === \"preopen\" || entry?.kind === \"directory\")")
4756 );
4757 }
4758
4759 #[test]
4760 fn wasm_runner_blocks_read_only_fd_write_paths() {
4761 let bootstrap = build_wasm_runner_bootstrap(&BTreeMap::new(), None);
4762
4763 assert!(bootstrap.contains("readOnly: entry.readOnly === true,"));
4764 assert!(bootstrap.contains(
4765 "if (handle.readOnly === true) {\n return __agentOSWasiErrnoRofs;\n }"
4766 ));
4767 assert!(bootstrap.contains(
4768 "if (entry.readOnly === true) {\n return __agentOSWasiErrnoRofs;\n }\n const written = __agentOSFs().writeSync("
4769 ));
4770 }
4771
4772 #[test]
4773 fn wasm_memory_limit_pages_floor_to_whole_wasm_pages() {
4774 assert_eq!(
4775 wasm_memory_limit_pages(WASM_PAGE_BYTES + 123).expect("page limit"),
4776 1
4777 );
4778 assert_eq!(
4779 wasm_memory_limit_pages(2 * WASM_PAGE_BYTES).expect("page limit"),
4780 2
4781 );
4782 }
4783
4784 #[test]
4785 fn wasm_memory_limit_no_longer_requires_declared_module_maximum() {
4786 let temp = tempdir().expect("create temp dir");
4787 let request = request_with_env(
4788 temp.path(),
4789 BTreeMap::from([(
4790 String::from(WASM_MAX_MEMORY_BYTES_ENV),
4791 (2 * WASM_PAGE_BYTES).to_string(),
4792 )]),
4793 );
4794
4795 assert!(
4796 super::validate_module_limits(
4797 &super::ResolvedWasmModule {
4798 specifier: String::from("./guest.wasm"),
4799 resolved_path: {
4800 let path = temp.path().join("guest.wasm");
4801 fs::write(
4802 &path,
4803 wat::parse_str(
4804 r#"
4805(module
4806 (memory (export "memory") 1)
4807 (func (export "_start"))
4808)
4809"#,
4810 )
4811 .expect("compile wasm fixture"),
4812 )
4813 .expect("write wasm fixture");
4814 path
4815 },
4816 },
4817 &request,
4818 )
4819 .is_ok(),
4820 "runtime memory cap should allow modules without a declared maximum"
4821 );
4822 }
4823}