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