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 agentos_runtime::RuntimeContext;
18use base64::Engine as _;
19use serde_json::{json, Value};
20use std::collections::{BTreeMap, HashMap, VecDeque};
21use std::fmt;
22use std::fs;
23use std::fs::OpenOptions;
24use std::io::{Read, Write};
25use std::os::unix::fs::{FileExt, MetadataExt, PermissionsExt};
26use std::path::{Path, PathBuf};
27use std::sync::{Arc, Mutex, OnceLock};
28use std::time::{Duration, Instant};
29use tokio::sync::Notify;
30
31const WASM_MODULE_PATH_ENV: &str = "AGENTOS_WASM_MODULE_PATH";
32const WASM_GUEST_ARGV_ENV: &str = "AGENTOS_GUEST_ARGV";
33const WASM_GUEST_ENV_ENV: &str = "AGENTOS_GUEST_ENV";
34const WASM_PERMISSION_TIER_ENV: &str = "AGENTOS_WASM_PERMISSION_TIER";
35const WASM_PREWARM_ONLY_ENV: &str = "AGENTOS_WASM_PREWARM_ONLY";
36const WASM_HOST_CWD_ENV: &str = "AGENTOS_WASM_HOST_CWD";
37const WASM_SANDBOX_ROOT_ENV: &str = "AGENTOS_SANDBOX_ROOT";
38const WASM_WARMUP_DEBUG_ENV: &str = "AGENTOS_WASM_WARMUP_DEBUG";
39pub const WASM_MAX_FUEL_ENV: &str = "AGENTOS_WASM_MAX_FUEL";
40pub const WASM_MAX_MEMORY_BYTES_ENV: &str = "AGENTOS_WASM_MAX_MEMORY_BYTES";
41pub const WASM_MAX_STACK_BYTES_ENV: &str = "AGENTOS_WASM_MAX_STACK_BYTES";
42pub const WASM_MAX_MODULE_FILE_BYTES_ENV: &str = "AGENTOS_WASM_MAX_MODULE_FILE_BYTES";
43const WASM_MAX_OPEN_FDS_ENV: &str = "AGENTOS_WASM_MAX_OPEN_FDS";
44const WASM_MAX_SPAWN_FILE_ACTIONS_ENV: &str = "AGENTOS_WASM_MAX_SPAWN_FILE_ACTIONS";
45const WASM_MAX_SPAWN_FILE_ACTION_BYTES_ENV: &str = "AGENTOS_WASM_MAX_SPAWN_FILE_ACTION_BYTES";
46const WASM_MAX_SOCKETS_ENV: &str = "AGENTOS_WASM_MAX_SOCKETS";
47const WASM_MAX_BLOCKING_READ_MS_ENV: &str = "AGENTOS_WASM_MAX_BLOCKING_READ_MS";
48const WASM_INTERNAL_MAX_STACK_BYTES_ENV: &str = "AGENTOS_INTERNAL_WASM_MAX_STACK_BYTES";
49const WASM_WARMUP_METRICS_PREFIX: &str = "__AGENTOS_WASM_WARMUP_METRICS__:";
50const WASM_SIGNAL_STATE_PREFIX: &str = "__AGENTOS_WASM_SIGNAL_STATE__:";
51const WASM_WARMUP_MARKER_VERSION: &str = "1";
52const WASM_PAGE_BYTES: u64 = 65_536;
53const WASM_TIMEOUT_EXIT_CODE: i32 = 124;
54const MAX_WASM_MODULE_FILE_BYTES: u64 = 256 * 1024 * 1024;
55const MAX_WASM_IMPORT_SECTION_ENTRIES: usize = 16_384;
56const MAX_WASM_MEMORY_SECTION_ENTRIES: usize = 1_024;
57const MAX_WASM_VARUINT_BYTES: usize = 10;
58const DEFAULT_WASM_GUEST_HOME: &str = "/root";
59const DEFAULT_WASM_GUEST_USER: &str = "root";
60const DEFAULT_WASM_GUEST_SHELL: &str = "/bin/sh";
61const DEFAULT_WASM_GUEST_PATH: &str =
62 "/usr/local/sbin:/usr/local/bin:/opt/agentos/bin:/usr/sbin:/usr/bin:/sbin:/bin";
63const DEFAULT_WASM_PREWARM_TIMEOUT_MS: u64 = 30_000;
66const DEFAULT_WASM_RUNNER_HEAP_LIMIT_MB: u32 = 2048;
86const _: () = assert!(DEFAULT_WASM_RUNNER_HEAP_LIMIT_MB > 128);
89const MAX_SYNC_WASM_PREWARM_MODULE_BYTES: u64 = 16 * 1024 * 1024;
90const WASM_CAPTURED_OUTPUT_LIMIT_BYTES: usize = 16 * 1024 * 1024;
91const WASM_SYNC_READ_LIMIT_BYTES: usize = 16 * 1024 * 1024;
92const WASM_PROCESS_SYNC_RPC_RESPONSE_BYTES: usize = 256 * 1024;
96const WASM_INLINE_RUNNER_ENTRYPOINT: &str = "./__agentos_wasm_runner__.mjs";
97const WASM_SNAPSHOT_RUNNER_ENV: &str = "AGENTOS_WASM_SNAPSHOT_RUNNER";
98const WASM_RUNNER_NO_CACHE_ENV: &str = "AGENTOS_WASM_RUNNER_NO_CACHE";
99const WASM_MODULE_BYTES_CACHE_CAPACITY: usize = 64;
100const NODE_WASI_MODULE_SOURCE: &str = include_str!("../assets/runners/wasi-module.js");
101const WASM_SIDECAR_ROUTED_FS_SYNC_METHODS: &[&str] = &[
102 "fs.accessSync",
103 "fs.blockingIoTimeoutMsSync",
104 "fs.chmodForProcessSync",
105 "fs.chmodSync",
106 "fs.chownSync",
107 "fs.closeSync",
108 "fs.collapseRangeSync",
109 "fs.existsSync",
110 "fs.fallocateSync",
111 "fs.fdatasyncSync",
112 "fs.fiemapSync",
113 "fs.fstatSync",
114 "fs.fsyncSync",
115 "fs.ftruncateSync",
116 "fs.getxattrSync",
117 "fs.insertRangeSync",
118 "fs.lchownSync",
119 "fs.linkFdSync",
120 "fs.linkSync",
121 "fs.listxattrSync",
122 "fs.lstatSync",
123 "fs.mkdirSync",
124 "fs.mknodSync",
125 "fs.namedFifoPeerReadySync",
126 "fs.openSync",
127 "fs.openTmpfileSync",
128 "fs.punchHoleSync",
129 "fs.readFileSync",
130 "fs.readSync",
131 "fs.readdirSync",
132 "fs.readlinkSync",
133 "fs.remountSync",
134 "fs.removexattrSync",
135 "fs.renameAt2Sync",
136 "fs.renameSync",
137 "fs.rmdirSync",
138 "fs.setxattrSync",
139 "fs.statfsSync",
140 "fs.statSync",
141 "fs.symlinkSync",
142 "fs.truncateForProcessSync",
143 "fs.unlinkSync",
144 "fs.zeroRangeSync",
145 "fs.writeFileSync",
146 "fs.writeSync",
147];
148const WASM_SIDECAR_ROUTED_KERNEL_SYNC_METHODS: &[&str] = &[
149 "__kernel_isatty",
150 "__kernel_poll",
151 "__kernel_stdin_read",
152 "__kernel_stdio_write",
153 "__kernel_tty_size",
154 "__pty_set_raw_mode",
155];
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum WasmSignalDispositionAction {
159 Default,
160 Ignore,
161 User,
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
165#[serde(rename_all = "kebab-case")]
166pub enum WasmPermissionTier {
167 Full,
168 ReadWrite,
169 ReadOnly,
170 Isolated,
171}
172
173impl WasmPermissionTier {
174 fn as_env_value(self) -> &'static str {
175 match self {
176 Self::Full => "full",
177 Self::ReadWrite => "read-write",
178 Self::ReadOnly => "read-only",
179 Self::Isolated => "isolated",
180 }
181 }
182}
183
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct WasmSignalHandlerRegistration {
186 pub action: WasmSignalDispositionAction,
187 pub mask: Vec<u32>,
188 pub flags: u32,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct CreateWasmContextRequest {
193 pub vm_id: String,
194 pub module_path: Option<String>,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct WasmContext {
199 pub context_id: String,
200 pub vm_id: String,
201 pub module_path: Option<String>,
202}
203
204#[derive(Debug, Clone, Default, PartialEq, Eq)]
210pub struct WasmExecutionLimits {
211 pub max_fuel: Option<u64>,
213 pub max_memory_bytes: Option<u64>,
216 pub max_stack_bytes: Option<u64>,
220 pub max_module_file_bytes: Option<u64>,
223 pub max_spawn_file_actions: Option<u64>,
225 pub max_spawn_file_action_bytes: Option<u64>,
227 pub max_open_fds: Option<u64>,
229 pub max_sockets: Option<u64>,
231 pub max_blocking_read_ms: Option<u64>,
233 pub prewarm_timeout_ms: Option<u64>,
235 pub runner_heap_limit_mb: Option<u32>,
237 pub runner_cpu_time_limit_ms: Option<u32>,
239 pub reactor_work_quantum: Option<usize>,
241 pub bridge_call_timeout_ms: Option<u64>,
243}
244
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct StartWasmExecutionRequest {
247 pub vm_id: String,
248 pub context_id: String,
249 pub argv: Vec<String>,
250 pub env: BTreeMap<String, String>,
251 pub cwd: PathBuf,
252 pub permission_tier: WasmPermissionTier,
253 pub limits: WasmExecutionLimits,
255 pub guest_runtime: GuestRuntimeConfig,
259}
260
261#[derive(Debug, Clone, PartialEq, Eq)]
262pub enum WasmExecutionEvent {
263 Stdout(Vec<u8>),
264 Stderr(Vec<u8>),
265 SyncRpcRequest(JavascriptSyncRpcRequest),
266 SignalState {
267 signal: u32,
268 registration: WasmSignalHandlerRegistration,
269 },
270 Exited(i32),
271}
272
273#[derive(Debug, Clone, PartialEq, Eq)]
274pub struct WasmExecutionResult {
275 pub execution_id: String,
276 pub exit_code: i32,
277 pub stdout: Vec<u8>,
278 pub stderr: Vec<u8>,
279}
280
281#[derive(Debug, Clone, PartialEq, Eq)]
282struct ResolvedWasmModule {
283 specifier: String,
284 resolved_path: PathBuf,
285}
286
287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
288pub enum NativeBinaryFormat {
289 Elf,
290 MachO,
291 PeCoff,
292}
293
294impl NativeBinaryFormat {
295 fn display_name(self) -> &'static str {
296 match self {
297 Self::Elf => "ELF",
298 Self::MachO => "Mach-O",
299 Self::PeCoff => "PE/COFF",
300 }
301 }
302}
303
304#[derive(Debug)]
305pub enum WasmExecutionError {
306 MissingContext(String),
307 VmMismatch {
308 expected: String,
309 found: String,
310 },
311 MissingModulePath,
312 InvalidLimit(String),
313 InvalidModule(String),
314 NativeBinaryNotSupported {
315 path: PathBuf,
316 header: Vec<u8>,
317 format: NativeBinaryFormat,
318 },
319 NonWasmBinary {
320 path: PathBuf,
321 header: Vec<u8>,
322 shell_shim: bool,
323 },
324 PrepareWarmPath(std::io::Error),
325 WarmupSpawn(std::io::Error),
326 WarmupTimeout(Duration),
327 WarmupFailed {
328 exit_code: i32,
329 stderr: String,
330 },
331 Spawn(std::io::Error),
332 Control(std::io::Error),
333 RpcResponse(String),
334 StdinClosed,
335 Stdin(std::io::Error),
336 OutputBufferExceeded {
337 stream: &'static str,
338 limit: usize,
339 },
340 EventChannelClosed,
341}
342
343impl fmt::Display for WasmExecutionError {
344 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
345 match self {
346 Self::MissingContext(context_id) => {
347 write!(f, "unknown guest WebAssembly context: {context_id}")
348 }
349 Self::VmMismatch { expected, found } => {
350 write!(
351 f,
352 "guest WebAssembly context belongs to vm {expected}, not {found}"
353 )
354 }
355 Self::MissingModulePath => {
356 f.write_str("guest WebAssembly execution requires a module path")
357 }
358 Self::InvalidLimit(message) => write!(f, "invalid WebAssembly limit: {message}"),
359 Self::InvalidModule(message) => write!(f, "invalid WebAssembly module: {message}"),
360 Self::NativeBinaryNotSupported {
361 path,
362 header,
363 format,
364 } => {
365 let header_hex = header
366 .iter()
367 .map(|byte| format!("{byte:02x}"))
368 .collect::<Vec<_>>()
369 .join(" ");
370 write!(
371 f,
372 "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}])",
373 format.display_name(),
374 path.display()
375 )
376 }
377 Self::NonWasmBinary {
378 path,
379 header,
380 shell_shim,
381 } => {
382 let header_hex = header
383 .iter()
384 .map(|byte| format!("{byte:02x}"))
385 .collect::<Vec<_>>()
386 .join(" ");
387 if *shell_shim {
388 write!(
389 f,
390 "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",
391 path.display()
392 )
393 } else {
394 write!(
395 f,
396 "refused to compile guest WebAssembly module at {}: first {} byte(s) [{header_hex}] do not match the \"\\0asm\" WebAssembly magic word",
397 path.display(),
398 header.len()
399 )
400 }
401 }
402 Self::PrepareWarmPath(err) => {
403 write!(f, "failed to prepare shared WebAssembly warm path: {err}")
404 }
405 Self::WarmupSpawn(err) => {
406 write!(f, "failed to start WebAssembly warmup runtime: {err}")
407 }
408 Self::WarmupTimeout(timeout) => {
409 write!(
410 f,
411 "WebAssembly warmup exceeded the configured timeout after {} ms",
412 timeout.as_millis()
413 )
414 }
415 Self::WarmupFailed { exit_code, stderr } => {
416 if stderr.trim().is_empty() {
417 write!(f, "WebAssembly warmup exited with status {exit_code}")
418 } else {
419 write!(
420 f,
421 "WebAssembly warmup exited with status {exit_code}: {}",
422 stderr.trim()
423 )
424 }
425 }
426 Self::Spawn(err) => write!(f, "failed to start guest WebAssembly runtime: {err}"),
427 Self::Control(err) => write!(f, "failed to control guest WebAssembly runtime: {err}"),
428 Self::RpcResponse(message) => {
429 write!(
430 f,
431 "failed to write guest WebAssembly sync RPC response: {message}"
432 )
433 }
434 Self::StdinClosed => f.write_str("guest WebAssembly stdin is already closed"),
435 Self::Stdin(err) => write!(f, "failed to write guest stdin: {err}"),
436 Self::OutputBufferExceeded { stream, limit } => {
437 write!(
438 f,
439 "guest WebAssembly {stream} exceeded the captured output limit of {limit} bytes"
440 )
441 }
442 Self::EventChannelClosed => {
443 f.write_str("guest WebAssembly event channel closed unexpectedly")
444 }
445 }
446 }
447}
448
449impl std::error::Error for WasmExecutionError {}
450
451#[derive(Debug)]
452pub struct WasmExecution {
453 execution_id: String,
454 child_pid: u32,
455 inner: JavascriptExecution,
456 execution_timeout: Option<Duration>,
457 execution_started_at: Instant,
458 timeout_reported: bool,
459 fuel_gauge: Option<Arc<QueueGauge>>,
460 internal_sync_rpc: WasmInternalSyncRpc,
461 pending_events: VecDeque<WasmExecutionEvent>,
462 stdout_stream_buffer: Vec<u8>,
463 stderr_stream_buffer: Vec<u8>,
464 max_stack_bytes: Option<u64>,
465 pending_v8_stack_overflow: Option<Vec<u8>>,
466}
467
468#[derive(Debug)]
469struct WasmInternalSyncRpc {
470 module_guest_paths: Vec<String>,
471 module_host_path: PathBuf,
472 guest_cwd: String,
473 host_cwd: PathBuf,
474 sandbox_root: Option<PathBuf>,
475 guest_path_mappings: Vec<WasmGuestPathMapping>,
476 route_fs_through_sidecar: bool,
477 next_fd: u32,
478 open_files: BTreeMap<u32, fs::File>,
479 pending_events: VecDeque<WasmExecutionEvent>,
480}
481
482#[derive(Debug, Clone)]
483struct WasmGuestPathMapping {
484 guest_path: String,
485 host_path: PathBuf,
486 read_only: bool,
487}
488
489impl WasmExecution {
490 pub fn execution_id(&self) -> &str {
491 &self.execution_id
492 }
493
494 pub fn child_pid(&self) -> u32 {
495 self.child_pid
496 }
497
498 pub fn v8_session_handle(&self) -> V8SessionHandle {
499 self.inner.v8_session_handle()
500 }
501
502 pub fn uses_shared_v8_runtime(&self) -> bool {
503 self.inner.uses_shared_v8_runtime()
504 }
505
506 pub fn has_pending_events(&self) -> bool {
507 !self.pending_events.is_empty()
508 || !self.internal_sync_rpc.pending_events.is_empty()
509 || self.inner.has_pending_events()
510 }
511
512 pub fn start_prepared(&mut self) -> Result<(), WasmExecutionError> {
513 self.inner.start_prepared().map_err(map_javascript_error)?;
514 self.execution_started_at = Instant::now();
515 Ok(())
516 }
517
518 #[doc(hidden)]
519 pub fn is_prepared_for_start(&self) -> bool {
520 self.inner.is_prepared_for_start()
521 }
522
523 pub fn write_stdin(&mut self, chunk: &[u8]) -> Result<(), WasmExecutionError> {
524 self.inner.write_stdin(chunk).map_err(map_javascript_error)
525 }
526
527 pub fn write_stdin_kernel_only(&mut self, chunk: &[u8]) -> Result<(), WasmExecutionError> {
534 self.inner
535 .write_kernel_stdin_only(chunk)
536 .map_err(map_javascript_error)
537 }
538
539 pub fn close_stdin(&mut self) -> Result<(), WasmExecutionError> {
540 self.inner.close_stdin().map_err(map_javascript_error)
541 }
542
543 pub fn send_stream_event(
544 &self,
545 event_type: &str,
546 payload: Value,
547 ) -> Result<(), WasmExecutionError> {
548 self.inner
549 .send_stream_event(event_type, payload)
550 .map_err(map_javascript_error)
551 }
552
553 pub fn terminate(&self) -> Result<(), WasmExecutionError> {
554 self.inner.terminate().map_err(map_javascript_error)
555 }
556
557 pub fn pause(&self) -> Result<(), WasmExecutionError> {
558 self.inner.pause().map_err(map_javascript_error)
559 }
560
561 pub fn resume(&self) -> Result<(), WasmExecutionError> {
562 self.inner.resume().map_err(map_javascript_error)
563 }
564
565 pub fn respond_sync_rpc_success(
566 &mut self,
567 id: u64,
568 result: Value,
569 ) -> Result<(), WasmExecutionError> {
570 self.inner
571 .respond_sync_rpc_success(id, result)
572 .map_err(map_javascript_error)
573 }
574
575 pub fn claim_sync_rpc_response(&mut self, id: u64) -> Result<bool, WasmExecutionError> {
576 self.inner
577 .claim_sync_rpc_response(id)
578 .map_err(map_javascript_error)
579 }
580
581 pub fn respond_claimed_sync_rpc_success(
582 &mut self,
583 id: u64,
584 result: Value,
585 ) -> Result<(), WasmExecutionError> {
586 self.inner
587 .respond_claimed_sync_rpc_success(id, result)
588 .map_err(map_javascript_error)
589 }
590
591 pub fn respond_sync_rpc_raw_success(
592 &mut self,
593 id: u64,
594 payload: Vec<u8>,
595 ) -> Result<(), WasmExecutionError> {
596 self.inner
597 .respond_sync_rpc_raw_success(id, payload)
598 .map_err(map_javascript_error)
599 }
600
601 pub fn respond_sync_rpc_error(
602 &mut self,
603 id: u64,
604 code: impl Into<String>,
605 message: impl Into<String>,
606 ) -> Result<(), WasmExecutionError> {
607 self.inner
608 .respond_sync_rpc_error(id, code, message)
609 .map_err(map_javascript_error)
610 }
611
612 pub fn respond_claimed_sync_rpc_error(
613 &mut self,
614 id: u64,
615 code: impl Into<String>,
616 message: impl Into<String>,
617 ) -> Result<(), WasmExecutionError> {
618 self.inner
619 .respond_claimed_sync_rpc_error(id, code, message)
620 .map_err(map_javascript_error)
621 }
622
623 pub async fn poll_event(
624 &mut self,
625 timeout: Duration,
626 ) -> Result<Option<WasmExecutionEvent>, WasmExecutionError> {
627 loop {
628 if let Some(event) = self.pending_events.pop_front() {
629 return Ok(Some(event));
630 }
631 if let Some(event) = self.internal_sync_rpc.pending_events.pop_front() {
632 self.enqueue_wasm_event(event)?;
633 continue;
634 }
635 if let Some(event) = self.timeout_event_if_expired()? {
636 return Ok(Some(event));
637 }
638 let poll_timeout = self.deadline_capped_timeout(timeout);
639 match self
640 .inner
641 .poll_event(poll_timeout)
642 .await
643 .map_err(map_javascript_error)?
644 {
645 Some(event) => {
646 if let JavascriptExecutionEvent::SyncRpcRequest(request) = &event {
647 if self.handle_internal_sync_rpc(request)? {
648 continue;
649 }
650 if let Some(signal_state) = self.handle_signal_state_sync_rpc(request)? {
651 return Ok(Some(signal_state));
652 }
653 }
654 self.enqueue_javascript_event(event)?;
655 }
656 None if poll_timeout < timeout => continue,
657 None => return Ok(None),
658 }
659 }
660 }
661
662 pub fn try_poll_event(&mut self) -> Result<Option<WasmExecutionEvent>, WasmExecutionError> {
663 loop {
664 if let Some(event) = self.pending_events.pop_front() {
665 return Ok(Some(event));
666 }
667 if let Some(event) = self.internal_sync_rpc.pending_events.pop_front() {
668 self.enqueue_wasm_event(event)?;
669 continue;
670 }
671 if let Some(event) = self.timeout_event_if_expired()? {
672 return Ok(Some(event));
673 }
674 let Some(event) = self.inner.try_poll_event().map_err(map_javascript_error)? else {
675 return Ok(None);
676 };
677 if let JavascriptExecutionEvent::SyncRpcRequest(request) = &event {
678 if self.handle_internal_sync_rpc(request)? {
679 continue;
680 }
681 if let Some(signal_state) = self.handle_signal_state_sync_rpc(request)? {
682 return Ok(Some(signal_state));
683 }
684 }
685 self.enqueue_javascript_event(event)?;
686 }
687 }
688
689 pub fn poll_event_blocking(
690 &mut self,
691 timeout: Duration,
692 ) -> Result<Option<WasmExecutionEvent>, WasmExecutionError> {
693 loop {
694 if let Some(event) = self.pending_events.pop_front() {
695 return Ok(Some(event));
696 }
697 if let Some(event) = self.internal_sync_rpc.pending_events.pop_front() {
698 self.enqueue_wasm_event(event)?;
699 continue;
700 }
701 if let Some(event) = self.timeout_event_if_expired()? {
702 return Ok(Some(event));
703 }
704 let poll_timeout = self.deadline_capped_timeout(timeout);
705 match self
706 .inner
707 .poll_event_blocking(poll_timeout)
708 .map_err(map_javascript_error)?
709 {
710 Some(event) => {
711 if let JavascriptExecutionEvent::SyncRpcRequest(request) = &event {
712 if self.handle_internal_sync_rpc(request)? {
713 continue;
714 }
715 if let Some(signal_state) = self.handle_signal_state_sync_rpc(request)? {
716 return Ok(Some(signal_state));
717 }
718 }
719 self.enqueue_javascript_event(event)?;
720 }
721 None if poll_timeout < timeout => continue,
722 None => return Ok(None),
723 }
724 }
725 }
726
727 pub fn wait(mut self) -> Result<WasmExecutionResult, WasmExecutionError> {
728 self.close_stdin()?;
729 let mut stdout = Vec::new();
730 let mut stderr = Vec::new();
731
732 loop {
733 match self.wait_event_blocking()? {
734 WasmExecutionEvent::Stdout(chunk) => {
735 append_wasm_captured_output(&mut stdout, &chunk, "stdout")?;
736 }
737 WasmExecutionEvent::Stderr(chunk) => {
738 append_wasm_captured_output(&mut stderr, &chunk, "stderr")?;
739 }
740 WasmExecutionEvent::SyncRpcRequest(request) => {
741 if self.handle_wait_sync_rpc_request(&request, &mut stdout, &mut stderr)? {
742 continue;
743 }
744 return Err(WasmExecutionError::RpcResponse(format!(
745 "unexpected guest WebAssembly sync RPC request {} while waiting",
746 request.method
747 )));
748 }
749 WasmExecutionEvent::SignalState { .. } => {}
750 WasmExecutionEvent::Exited(exit_code) => {
751 return Ok(WasmExecutionResult {
752 execution_id: self.execution_id,
753 exit_code,
754 stdout,
755 stderr,
756 });
757 }
758 }
759 }
760 }
761
762 fn wait_event_blocking(&mut self) -> Result<WasmExecutionEvent, WasmExecutionError> {
766 loop {
767 if let Some(event) = self.pending_events.pop_front() {
768 return Ok(event);
769 }
770 if let Some(event) = self.internal_sync_rpc.pending_events.pop_front() {
771 self.enqueue_wasm_event(event)?;
772 continue;
773 }
774 if let Some(event) = self.timeout_event_if_expired()? {
775 return Ok(event);
776 }
777
778 let event = if let Some(limit) = self.execution_timeout {
779 let remaining = limit.saturating_sub(self.execution_started_at.elapsed());
780 if remaining.is_zero() {
781 continue;
782 }
783 let Some(event) = self
784 .inner
785 .poll_event_blocking(remaining)
786 .map_err(map_javascript_error)?
787 else {
788 continue;
791 };
792 event
793 } else {
794 self.inner
795 .next_event_blocking()
796 .map_err(map_javascript_error)?
797 };
798
799 if let JavascriptExecutionEvent::SyncRpcRequest(request) = &event {
800 if self.handle_internal_sync_rpc(request)? {
801 continue;
802 }
803 if let Some(signal_state) = self.handle_signal_state_sync_rpc(request)? {
804 return Ok(signal_state);
805 }
806 }
807 self.enqueue_javascript_event(event)?;
808 }
809 }
810
811 fn deadline_capped_timeout(&self, timeout: Duration) -> Duration {
812 self.execution_timeout
813 .map(|limit| {
814 let elapsed = self.execution_started_at.elapsed();
815 if elapsed >= limit {
816 Duration::ZERO
817 } else {
818 timeout.min(limit.saturating_sub(elapsed))
819 }
820 })
821 .unwrap_or(timeout)
822 }
823
824 fn timeout_event_if_expired(
825 &mut self,
826 ) -> Result<Option<WasmExecutionEvent>, WasmExecutionError> {
827 if self.timeout_reported {
828 return Ok(None);
829 }
830 let Some(limit) = self.execution_timeout else {
831 return Ok(None);
832 };
833 let elapsed = self.execution_started_at.elapsed();
834 if let Some(gauge) = &self.fuel_gauge {
838 gauge.observe_depth(duration_millis_saturating_usize(elapsed));
839 }
840 if elapsed < limit {
841 return Ok(None);
842 }
843
844 self.inner.terminate().map_err(map_javascript_error)?;
845 self.timeout_reported = true;
846 let capacity = duration_millis_saturating_usize(limit);
847 warn_limit_exhausted(TrackedLimit::WasmFuelMs, capacity, capacity);
848 self.enqueue_wasm_event(WasmExecutionEvent::Stderr(
849 b"WebAssembly fuel budget exhausted\n".to_vec(),
850 ))?;
851 self.enqueue_wasm_event(WasmExecutionEvent::Exited(WASM_TIMEOUT_EXIT_CODE))?;
852 Ok(self.pending_events.pop_front())
853 }
854
855 fn handle_internal_sync_rpc(
856 &mut self,
857 request: &JavascriptSyncRpcRequest,
858 ) -> Result<bool, WasmExecutionError> {
859 handle_internal_wasm_sync_rpc_request(&mut self.inner, &mut self.internal_sync_rpc, request)
860 }
861
862 fn handle_signal_state_sync_rpc(
863 &mut self,
864 request: &JavascriptSyncRpcRequest,
865 ) -> Result<Option<WasmExecutionEvent>, WasmExecutionError> {
866 translate_wasm_signal_state_sync_rpc_request(&mut self.inner, request)
867 }
868
869 fn enqueue_javascript_event(
870 &mut self,
871 event: JavascriptExecutionEvent,
872 ) -> Result<(), WasmExecutionError> {
873 match event {
874 JavascriptExecutionEvent::Stdout(chunk) => {
875 self.enqueue_stream_chunk(StreamChannel::Stdout, chunk)?
876 }
877 JavascriptExecutionEvent::Stderr(chunk) => {
878 if self.max_stack_bytes.is_some() && is_v8_stack_overflow_stderr(&chunk) {
879 let pending = self.pending_v8_stack_overflow.get_or_insert_with(Vec::new);
880 ensure_wasm_output_capacity(
881 pending.len(),
882 chunk.len(),
883 "pending stack-overflow stderr",
884 )?;
885 pending.extend_from_slice(&chunk);
886 } else {
887 self.enqueue_stream_chunk(StreamChannel::Stderr, chunk)?
888 }
889 }
890 JavascriptExecutionEvent::SyncRpcRequest(request) => {
891 self.pending_events
892 .push_back(WasmExecutionEvent::SyncRpcRequest(request));
893 }
894 JavascriptExecutionEvent::SignalState {
895 signal,
896 registration,
897 } => {
898 self.pending_events
899 .push_back(WasmExecutionEvent::SignalState {
900 signal,
901 registration: registration.into(),
902 });
903 }
904 JavascriptExecutionEvent::LimitExceeded(_) => {}
906 JavascriptExecutionEvent::Exited(code) => {
907 if let Some(original) = self.pending_v8_stack_overflow.take() {
908 let chunk = if code != 0 {
909 configured_wasm_stack_limit_error(
910 self.max_stack_bytes
911 .expect("stack-overflow buffering requires a configured limit"),
912 )
913 .into_bytes()
914 } else {
915 original
916 };
917 self.enqueue_stream_chunk(StreamChannel::Stderr, chunk)?;
918 }
919 self.flush_stream_buffers();
920 self.pending_events
921 .push_back(WasmExecutionEvent::Exited(code));
922 }
923 }
924 Ok(())
925 }
926
927 fn enqueue_wasm_event(&mut self, event: WasmExecutionEvent) -> Result<(), WasmExecutionError> {
928 match event {
929 WasmExecutionEvent::Stdout(chunk) => {
930 self.enqueue_stream_chunk(StreamChannel::Stdout, chunk)?
931 }
932 WasmExecutionEvent::Stderr(chunk) => {
933 self.enqueue_stream_chunk(StreamChannel::Stderr, chunk)?
934 }
935 WasmExecutionEvent::Exited(code) => {
936 self.flush_stream_buffers();
937 self.pending_events
938 .push_back(WasmExecutionEvent::Exited(code));
939 }
940 other => self.pending_events.push_back(other),
941 }
942 Ok(())
943 }
944
945 fn enqueue_stream_chunk(
946 &mut self,
947 channel: StreamChannel,
948 chunk: Vec<u8>,
949 ) -> Result<(), WasmExecutionError> {
950 let buffer = match channel {
951 StreamChannel::Stdout => &mut self.stdout_stream_buffer,
952 StreamChannel::Stderr => &mut self.stderr_stream_buffer,
953 };
954 let stream = match channel {
955 StreamChannel::Stdout => "stdout",
956 StreamChannel::Stderr => "stderr",
957 };
958 ensure_wasm_output_capacity(buffer.len(), chunk.len(), stream)?;
959 buffer.extend_from_slice(&chunk);
960
961 let mut pending_stream_chunk = Vec::new();
962 while let Some(newline_index) = buffer.iter().position(|byte| *byte == b'\n') {
963 let line = buffer.drain(..=newline_index).collect::<Vec<_>>();
964 if let Some(signal_state) = parse_wasm_signal_state_line(&line)? {
965 if !pending_stream_chunk.is_empty() {
966 self.pending_events.push_back(match channel {
967 StreamChannel::Stdout => {
968 WasmExecutionEvent::Stdout(std::mem::take(&mut pending_stream_chunk))
969 }
970 StreamChannel::Stderr => {
971 WasmExecutionEvent::Stderr(std::mem::take(&mut pending_stream_chunk))
972 }
973 });
974 }
975 self.pending_events.push_back(signal_state);
976 continue;
977 }
978 pending_stream_chunk.extend_from_slice(&line);
979 }
980 if !pending_stream_chunk.is_empty() {
981 self.pending_events.push_back(match channel {
982 StreamChannel::Stdout => WasmExecutionEvent::Stdout(pending_stream_chunk),
983 StreamChannel::Stderr => WasmExecutionEvent::Stderr(pending_stream_chunk),
984 });
985 }
986
987 Ok(())
988 }
989
990 fn flush_stream_buffers(&mut self) {
991 if !self.stdout_stream_buffer.is_empty() {
992 self.pending_events
993 .push_back(WasmExecutionEvent::Stdout(std::mem::take(
994 &mut self.stdout_stream_buffer,
995 )));
996 }
997 if !self.stderr_stream_buffer.is_empty() {
998 self.pending_events
999 .push_back(WasmExecutionEvent::Stderr(std::mem::take(
1000 &mut self.stderr_stream_buffer,
1001 )));
1002 }
1003 }
1004
1005 fn handle_wait_sync_rpc_request(
1006 &mut self,
1007 request: &JavascriptSyncRpcRequest,
1008 stdout: &mut Vec<u8>,
1009 stderr: &mut Vec<u8>,
1010 ) -> Result<bool, WasmExecutionError> {
1011 if self
1012 .inner
1013 .handle_kernel_stdin_sync_rpc(request)
1014 .map_err(map_javascript_error)?
1015 {
1016 return Ok(true);
1017 }
1018
1019 if request.method != "__kernel_stdio_write" {
1020 return Ok(false);
1021 }
1022
1023 let Some(descriptor) = request.args.first().and_then(Value::as_u64) else {
1024 return Err(WasmExecutionError::RpcResponse(String::from(
1025 "missing __kernel_stdio_write descriptor",
1026 )));
1027 };
1028 let bytes = decode_wasm_bytes_arg(
1029 request.args.get(1),
1030 "__kernel_stdio_write payload bytes",
1031 WASM_CAPTURED_OUTPUT_LIMIT_BYTES,
1032 )?;
1033
1034 match descriptor {
1035 1 => append_wasm_captured_output(stdout, &bytes, "stdout")?,
1036 2 => append_wasm_captured_output(stderr, &bytes, "stderr")?,
1037 other => {
1038 return Err(WasmExecutionError::RpcResponse(format!(
1039 "unsupported __kernel_stdio_write descriptor {other}",
1040 )));
1041 }
1042 }
1043
1044 self.respond_sync_rpc_success(request.id, json!(bytes.len()))?;
1045 Ok(true)
1046 }
1047}
1048
1049#[derive(Clone, Copy)]
1050enum StreamChannel {
1051 Stdout,
1052 Stderr,
1053}
1054
1055#[derive(Debug)]
1056pub struct WasmExecutionEngine {
1057 runtime: Option<RuntimeContext>,
1058 next_context_id: usize,
1059 next_execution_id: usize,
1060 contexts: BTreeMap<String, WasmContext>,
1061 import_caches: BTreeMap<String, NodeImportCache>,
1062 javascript_context_ids: BTreeMap<String, String>,
1063 javascript_engine: JavascriptExecutionEngine,
1064}
1065
1066impl Default for WasmExecutionEngine {
1067 fn default() -> Self {
1068 let runtime = default_wasm_test_runtime_context();
1069 let javascript_engine = runtime
1070 .as_ref()
1071 .map_or_else(JavascriptExecutionEngine::default, |runtime| {
1072 JavascriptExecutionEngine::new(runtime.clone())
1073 });
1074 Self {
1075 runtime,
1076 next_context_id: 0,
1077 next_execution_id: 0,
1078 contexts: BTreeMap::new(),
1079 import_caches: BTreeMap::new(),
1080 javascript_context_ids: BTreeMap::new(),
1081 javascript_engine,
1082 }
1083 }
1084}
1085
1086#[cfg(test)]
1087fn default_wasm_test_runtime_context() -> Option<RuntimeContext> {
1088 agentos_runtime::SidecarRuntime::process(&agentos_runtime::RuntimeConfig::default())
1089 .ok()
1090 .map(agentos_runtime::SidecarRuntime::context)
1091}
1092
1093#[cfg(not(test))]
1094fn default_wasm_test_runtime_context() -> Option<RuntimeContext> {
1095 None
1096}
1097
1098impl WasmExecutionEngine {
1099 pub fn new(runtime: RuntimeContext) -> Self {
1100 Self {
1101 runtime: Some(runtime.clone()),
1102 next_context_id: 0,
1103 next_execution_id: 0,
1104 contexts: BTreeMap::new(),
1105 import_caches: BTreeMap::new(),
1106 javascript_context_ids: BTreeMap::new(),
1107 javascript_engine: JavascriptExecutionEngine::new(runtime),
1108 }
1109 }
1110
1111 pub fn set_runtime_context(&mut self, runtime: RuntimeContext) {
1112 self.javascript_engine.set_runtime_context(runtime.clone());
1113 self.runtime = Some(runtime);
1114 }
1115
1116 fn runtime_context(&self) -> Result<&RuntimeContext, WasmExecutionError> {
1117 self.runtime.as_ref().ok_or_else(|| {
1118 WasmExecutionError::Spawn(std::io::Error::other(
1119 "ERR_AGENTOS_RUNTIME_NOT_INJECTED: WasmExecutionEngine requires a process RuntimeContext; construct it with WasmExecutionEngine::new(runtime)",
1120 ))
1121 })
1122 }
1123
1124 pub fn set_event_notify(&mut self, notify: Option<Arc<Notify>>) {
1125 self.javascript_engine.set_event_notify(notify);
1126 }
1127
1128 pub fn create_context(&mut self, request: CreateWasmContextRequest) -> WasmContext {
1129 self.next_context_id += 1;
1130 self.import_caches.entry(request.vm_id.clone()).or_default();
1131 let javascript_context =
1132 self.javascript_engine
1133 .create_context(CreateJavascriptContextRequest {
1134 vm_id: request.vm_id.clone(),
1135 bootstrap_module: None,
1136 compile_cache_root: None,
1137 });
1138
1139 let context = WasmContext {
1140 context_id: format!("wasm-ctx-{}", self.next_context_id),
1141 vm_id: request.vm_id,
1142 module_path: request.module_path,
1143 };
1144 self.javascript_context_ids
1145 .insert(context.context_id.clone(), javascript_context.context_id);
1146 self.contexts
1147 .insert(context.context_id.clone(), context.clone());
1148 context
1149 }
1150
1151 pub fn dispose_context(&mut self, context_id: &str) -> bool {
1155 let removed = self.contexts.remove(context_id).is_some();
1156 if let Some(javascript_context_id) = self.javascript_context_ids.remove(context_id) {
1157 self.javascript_engine
1158 .dispose_context(&javascript_context_id);
1159 }
1160 removed
1161 }
1162
1163 #[doc(hidden)]
1164 pub fn context_count_for_test(&self) -> usize {
1165 self.contexts.len()
1166 }
1167
1168 #[doc(hidden)]
1169 pub fn javascript_context_count_for_test(&self) -> usize {
1170 self.javascript_engine.context_count_for_test()
1171 }
1172
1173 pub fn start_execution(
1174 &mut self,
1175 request: StartWasmExecutionRequest,
1176 ) -> Result<WasmExecution, WasmExecutionError> {
1177 let runtime = self.runtime_context()?.clone();
1178 self.create_execution_with_runtime(request, runtime, false)
1179 }
1180
1181 pub fn prepare_execution(
1182 &mut self,
1183 request: StartWasmExecutionRequest,
1184 ) -> Result<WasmExecution, WasmExecutionError> {
1185 let runtime = self.runtime_context()?.clone();
1186 self.create_execution_with_runtime(request, runtime, true)
1187 }
1188
1189 pub fn start_execution_with_runtime(
1190 &mut self,
1191 request: StartWasmExecutionRequest,
1192 runtime: RuntimeContext,
1193 ) -> Result<WasmExecution, WasmExecutionError> {
1194 self.create_execution_with_runtime(request, runtime, false)
1195 }
1196
1197 fn create_execution_with_runtime(
1198 &mut self,
1199 request: StartWasmExecutionRequest,
1200 runtime: RuntimeContext,
1201 defer_execute: bool,
1202 ) -> Result<WasmExecution, WasmExecutionError> {
1203 let context = self
1204 .contexts
1205 .get(&request.context_id)
1206 .cloned()
1207 .ok_or_else(|| WasmExecutionError::MissingContext(request.context_id.clone()))?;
1208
1209 if context.vm_id != request.vm_id {
1210 return Err(WasmExecutionError::VmMismatch {
1211 expected: context.vm_id,
1212 found: request.vm_id,
1213 });
1214 }
1215
1216 let resolved_module = resolve_wasm_module(&context, &request)?;
1217 verify_wasm_module_header(&resolved_module)?;
1218 let prewarm_timeout = resolve_wasm_prewarm_timeout(&request)?;
1219 let javascript_context_id = self
1220 .javascript_context_ids
1221 .get(&context.context_id)
1222 .cloned()
1223 .ok_or_else(|| WasmExecutionError::MissingContext(context.context_id.clone()))?;
1224 {
1225 let import_cache = self.import_caches.entry(context.vm_id.clone()).or_default();
1226 import_cache
1227 .ensure_materialized_with_timeout_and_runtime(&runtime, prewarm_timeout)
1228 .map_err(WasmExecutionError::PrepareWarmPath)?;
1229 }
1230 let frozen_time_ms = frozen_time_ms();
1231 validate_module_limits(&resolved_module, &request)?;
1232 wasm_stack_limit_bytes(&request)?;
1236 let execution_timeout = resolve_wasm_execution_timeout(&request)?;
1237 let import_cache = self
1238 .import_caches
1239 .get(&context.vm_id)
1240 .expect("vm import cache should exist after materialization");
1241 let warmup_metrics = match prewarm_wasm_path(
1242 import_cache,
1243 &mut self.javascript_engine,
1244 &javascript_context_id,
1245 &resolved_module,
1246 &request,
1247 WasmPrewarmOptions {
1248 frozen_time_ms,
1249 timeout: prewarm_timeout,
1250 runtime: &runtime,
1251 },
1252 ) {
1253 Ok(metrics) => metrics,
1254 Err(WasmExecutionError::WarmupTimeout(_)) => None,
1255 Err(error) => return Err(error),
1256 };
1257
1258 self.finish_start_execution(
1259 request,
1260 runtime,
1261 &context.vm_id,
1262 javascript_context_id,
1263 resolved_module,
1264 frozen_time_ms,
1265 execution_timeout,
1266 warmup_metrics,
1267 defer_execute,
1268 )
1269 }
1270
1271 pub async fn start_execution_with_runtime_async(
1275 &mut self,
1276 request: StartWasmExecutionRequest,
1277 runtime: RuntimeContext,
1278 ) -> Result<WasmExecution, WasmExecutionError> {
1279 let context = self
1280 .contexts
1281 .get(&request.context_id)
1282 .cloned()
1283 .ok_or_else(|| WasmExecutionError::MissingContext(request.context_id.clone()))?;
1284
1285 if context.vm_id != request.vm_id {
1286 return Err(WasmExecutionError::VmMismatch {
1287 expected: context.vm_id,
1288 found: request.vm_id,
1289 });
1290 }
1291
1292 let resolved_module = resolve_wasm_module(&context, &request)?;
1293 verify_wasm_module_header(&resolved_module)?;
1294 let prewarm_timeout = resolve_wasm_prewarm_timeout(&request)?;
1295 let javascript_context_id = self
1296 .javascript_context_ids
1297 .get(&context.context_id)
1298 .cloned()
1299 .ok_or_else(|| WasmExecutionError::MissingContext(context.context_id.clone()))?;
1300 {
1301 let import_cache = self.import_caches.entry(context.vm_id.clone()).or_default();
1302 import_cache
1303 .ensure_materialized_with_timeout_and_runtime_async(&runtime, prewarm_timeout)
1304 .await
1305 .map_err(WasmExecutionError::PrepareWarmPath)?;
1306 }
1307 let frozen_time_ms = frozen_time_ms();
1308 validate_module_limits(&resolved_module, &request)?;
1309 wasm_stack_limit_bytes(&request)?;
1310 let execution_timeout = resolve_wasm_execution_timeout(&request)?;
1311 let import_cache = self
1312 .import_caches
1313 .get(&context.vm_id)
1314 .expect("vm import cache should exist after materialization");
1315 let warmup_metrics = match prewarm_wasm_path_async(
1316 import_cache,
1317 &mut self.javascript_engine,
1318 &javascript_context_id,
1319 &resolved_module,
1320 &request,
1321 WasmPrewarmOptions {
1322 frozen_time_ms,
1323 timeout: prewarm_timeout,
1324 runtime: &runtime,
1325 },
1326 )
1327 .await
1328 {
1329 Ok(metrics) => metrics,
1330 Err(WasmExecutionError::WarmupTimeout(_)) => None,
1331 Err(error) => return Err(error),
1332 };
1333
1334 self.finish_start_execution(
1335 request,
1336 runtime,
1337 &context.vm_id,
1338 javascript_context_id,
1339 resolved_module,
1340 frozen_time_ms,
1341 execution_timeout,
1342 warmup_metrics,
1343 false,
1344 )
1345 }
1346
1347 #[allow(clippy::too_many_arguments)]
1348 fn finish_start_execution(
1349 &mut self,
1350 request: StartWasmExecutionRequest,
1351 runtime: RuntimeContext,
1352 vm_id: &str,
1353 javascript_context_id: String,
1354 resolved_module: ResolvedWasmModule,
1355 frozen_time_ms: u128,
1356 execution_timeout: Option<Duration>,
1357 warmup_metrics: Option<Vec<u8>>,
1358 defer_execute: bool,
1359 ) -> Result<WasmExecution, WasmExecutionError> {
1360 let import_cache = self
1361 .import_caches
1362 .get(vm_id)
1363 .expect("vm import cache should exist after materialization");
1364 self.next_execution_id += 1;
1365 let execution_id = format!("exec-{}", self.next_execution_id);
1366 let javascript_execution = start_wasm_javascript_execution(
1367 &mut self.javascript_engine,
1368 &runtime,
1369 import_cache,
1370 &javascript_context_id,
1371 &resolved_module,
1372 &request,
1373 WasmJavascriptExecutionOptions {
1374 frozen_time_ms,
1375 prewarm_only: false,
1376 warmup_metrics: warmup_metrics.as_deref(),
1377 defer_execute,
1378 },
1379 )?;
1380 let child_pid = javascript_execution.child_pid();
1381 let sandbox_root = wasm_sandbox_root(&request.env);
1382 let guest_path_mappings = wasm_guest_path_mappings(&request);
1383
1384 Ok(WasmExecution {
1385 execution_id,
1386 child_pid,
1387 inner: javascript_execution,
1388 execution_timeout,
1389 execution_started_at: Instant::now(),
1390 timeout_reported: false,
1391 fuel_gauge: execution_timeout.map(|limit| {
1394 register_limit(
1395 TrackedLimit::WasmFuelMs,
1396 duration_millis_saturating_usize(limit),
1397 )
1398 }),
1399 pending_events: VecDeque::new(),
1400 stdout_stream_buffer: Vec::new(),
1401 stderr_stream_buffer: Vec::new(),
1402 max_stack_bytes: request.limits.max_stack_bytes,
1403 pending_v8_stack_overflow: None,
1404 internal_sync_rpc: WasmInternalSyncRpc {
1405 module_guest_paths: wasm_guest_module_paths(
1406 &resolved_module.specifier,
1407 &request.env,
1408 ),
1409 module_host_path: resolved_module.resolved_path.clone(),
1410 guest_cwd: wasm_guest_cwd(&request.env),
1411 host_cwd: request.cwd.clone(),
1412 sandbox_root: sandbox_root.clone(),
1413 guest_path_mappings,
1414 route_fs_through_sidecar: sandbox_root.is_some(),
1415 next_fd: 64,
1416 open_files: BTreeMap::new(),
1417 pending_events: VecDeque::new(),
1418 },
1419 })
1420 }
1421
1422 pub fn dispose_vm(&mut self, vm_id: &str) {
1423 self.contexts.retain(|_, context| context.vm_id != vm_id);
1424 self.javascript_context_ids
1425 .retain(|wasm_context_id, _| self.contexts.contains_key(wasm_context_id));
1426 self.import_caches.remove(vm_id);
1427 self.javascript_engine.dispose_vm(vm_id);
1428 }
1429}
1430
1431fn map_javascript_error(error: JavascriptExecutionError) -> WasmExecutionError {
1432 match error {
1433 JavascriptExecutionError::EmptyArgv => WasmExecutionError::Spawn(std::io::Error::new(
1434 std::io::ErrorKind::InvalidInput,
1435 "guest WebAssembly bootstrap requires a JavaScript entrypoint",
1436 )),
1437 JavascriptExecutionError::InvalidLimit(message) => {
1438 WasmExecutionError::InvalidLimit(message)
1439 }
1440 JavascriptExecutionError::MissingContext(context_id) => {
1441 WasmExecutionError::MissingContext(context_id)
1442 }
1443 JavascriptExecutionError::VmMismatch { expected, found } => {
1444 WasmExecutionError::VmMismatch { expected, found }
1445 }
1446 JavascriptExecutionError::PrepareImportCache(error) => {
1447 WasmExecutionError::PrepareWarmPath(error)
1448 }
1449 JavascriptExecutionError::Spawn(error) => WasmExecutionError::Spawn(error),
1450 JavascriptExecutionError::PendingSyncRpcRequest(id) => WasmExecutionError::RpcResponse(
1451 format!("guest WebAssembly sync RPC request {id} is still pending"),
1452 ),
1453 JavascriptExecutionError::ExpiredSyncRpcRequest(id) => WasmExecutionError::RpcResponse(
1454 format!("guest WebAssembly sync RPC request {id} is no longer pending"),
1455 ),
1456 JavascriptExecutionError::RpcResponse(message) => WasmExecutionError::RpcResponse(message),
1457 JavascriptExecutionError::Terminate(error) => WasmExecutionError::Spawn(error),
1458 JavascriptExecutionError::Control(error) => WasmExecutionError::Control(error),
1459 JavascriptExecutionError::StdinClosed => WasmExecutionError::StdinClosed,
1460 JavascriptExecutionError::Stdin(error) => WasmExecutionError::Stdin(error),
1461 JavascriptExecutionError::OutputBufferExceeded { stream, limit } => {
1462 WasmExecutionError::OutputBufferExceeded { stream, limit }
1463 }
1464 JavascriptExecutionError::EventChannelClosed => WasmExecutionError::EventChannelClosed,
1465 }
1466}
1467
1468fn handle_internal_wasm_sync_rpc_request(
1469 execution: &mut JavascriptExecution,
1470 internal_sync_rpc: &mut WasmInternalSyncRpc,
1471 request: &JavascriptSyncRpcRequest,
1472) -> Result<bool, WasmExecutionError> {
1473 if execution
1477 .try_service_standalone_module_sync_rpc(request)
1478 .map_err(map_javascript_error)?
1479 {
1480 return Ok(true);
1481 }
1482
1483 if matches!(
1484 request.method.as_str(),
1485 "fs.promises.readFile" | "fs.readFileSync"
1486 ) && request
1487 .args
1488 .first()
1489 .and_then(Value::as_str)
1490 .is_some_and(|path| {
1491 internal_sync_rpc
1492 .module_guest_paths
1493 .iter()
1494 .any(|candidate| candidate == path)
1495 })
1496 {
1497 let module_bytes =
1498 fs::read(&internal_sync_rpc.module_host_path).map_err(WasmExecutionError::Spawn)?;
1499 execution
1500 .respond_sync_rpc_success(
1501 request.id,
1502 Value::String(v8_runtime::base64_encode_pub(&module_bytes)),
1503 )
1504 .map_err(map_javascript_error)?;
1505 return Ok(true);
1506 }
1507
1508 if wasm_sync_rpc_method_routes_through_sidecar_kernel(request, internal_sync_rpc) {
1509 return Ok(false);
1510 }
1511
1512 if request.method == "__kernel_isatty" {
1513 execution
1514 .respond_sync_rpc_success(request.id, Value::Bool(false))
1515 .map_err(map_javascript_error)?;
1516 return Ok(true);
1517 }
1518
1519 if request.method == "__kernel_tty_size" {
1520 execution
1521 .respond_sync_rpc_success(request.id, json!([80, 24]))
1522 .map_err(map_javascript_error)?;
1523 return Ok(true);
1524 }
1525
1526 if request.method == "fs.openSync" {
1527 let Some(path) = request.args.first().and_then(Value::as_str) else {
1528 return Err(WasmExecutionError::RpcResponse(String::from(
1529 "missing fs.openSync path",
1530 )));
1531 };
1532 let Some(host_path) = translate_wasm_guest_path(path, internal_sync_rpc) else {
1533 return Ok(false);
1534 };
1535 let flags = request.args.get(1).unwrap_or(&Value::Null);
1536 if wasm_open_flags_require_write(flags)
1537 && wasm_host_path_is_read_only(&host_path, internal_sync_rpc)
1538 {
1539 return respond_wasm_sync_rpc_value(
1540 execution,
1541 request,
1542 path,
1543 Err(wasm_read_only_filesystem_error(path)),
1544 )
1545 .map(|()| true);
1546 }
1547 let file = match open_wasm_guest_file(&host_path, flags) {
1548 Ok(file) => file,
1549 Err(error) => {
1550 return respond_wasm_sync_rpc_value(execution, request, path, Err(error))
1551 .map(|()| true);
1552 }
1553 };
1554 let fd = internal_sync_rpc.next_fd;
1555 internal_sync_rpc.next_fd += 1;
1556 internal_sync_rpc.open_files.insert(fd, file);
1557 execution
1558 .respond_sync_rpc_success(request.id, json!(fd))
1559 .map_err(map_javascript_error)?;
1560 return Ok(true);
1561 }
1562
1563 if matches!(request.method.as_str(), "fs.statSync" | "fs.lstatSync") {
1564 let Some(path) = request.args.first().and_then(Value::as_str) else {
1565 return Err(WasmExecutionError::RpcResponse(format!(
1566 "missing {} path",
1567 request.method
1568 )));
1569 };
1570 let Some(host_path) = translate_wasm_guest_path(path, internal_sync_rpc) else {
1571 return Ok(false);
1572 };
1573 let metadata = if request.method == "fs.lstatSync" {
1574 fs::symlink_metadata(&host_path)
1575 } else {
1576 fs::metadata(&host_path)
1577 };
1578 return respond_wasm_sync_rpc_metadata(execution, request, path, metadata).map(|()| true);
1579 }
1580
1581 if request.method == "fs.fstatSync" {
1582 let Some(fd) = request.args.first().and_then(Value::as_u64) else {
1583 return Err(WasmExecutionError::RpcResponse(String::from(
1584 "missing fs.fstatSync fd",
1585 )));
1586 };
1587 let Some(file) = internal_sync_rpc.open_files.get(&(fd as u32)) else {
1588 return Ok(false);
1589 };
1590 return respond_wasm_sync_rpc_metadata(
1591 execution,
1592 request,
1593 &fd.to_string(),
1594 file.metadata(),
1595 )
1596 .map(|()| true);
1597 }
1598
1599 if request.method == "fs.ftruncateSync" {
1600 let Some(fd) = request.args.first().and_then(Value::as_u64) else {
1601 return Err(WasmExecutionError::RpcResponse(String::from(
1602 "missing fs.ftruncateSync fd",
1603 )));
1604 };
1605 let length = request.args.get(1).and_then(Value::as_u64).unwrap_or(0);
1606 let Some(file) = internal_sync_rpc.open_files.get_mut(&(fd as u32)) else {
1607 return Ok(false);
1608 };
1609 let result = file.set_len(length);
1610 return respond_wasm_sync_rpc_unit(execution, request, &fd.to_string(), result)
1611 .map(|()| true);
1612 }
1613
1614 if request.method == "fs.closeSync" {
1615 let Some(fd) = request.args.first().and_then(Value::as_u64) else {
1616 return Err(WasmExecutionError::RpcResponse(String::from(
1617 "missing fs.closeSync fd",
1618 )));
1619 };
1620 if internal_sync_rpc.open_files.remove(&(fd as u32)).is_none() {
1621 return Ok(false);
1622 }
1623 execution
1624 .respond_sync_rpc_success(request.id, Value::Null)
1625 .map_err(map_javascript_error)?;
1626 return Ok(true);
1627 }
1628
1629 if request.method == "fs.chmodSync" {
1630 let Some(path) = request.args.first().and_then(Value::as_str) else {
1631 return Err(WasmExecutionError::RpcResponse(String::from(
1632 "missing fs.chmodSync path",
1633 )));
1634 };
1635 let Some(host_path) = translate_wasm_guest_path(path, internal_sync_rpc) else {
1636 return Ok(false);
1637 };
1638 if wasm_host_path_is_read_only(&host_path, internal_sync_rpc) {
1639 return respond_wasm_sync_rpc_unit(
1640 execution,
1641 request,
1642 path,
1643 Err(wasm_read_only_filesystem_error(path)),
1644 )
1645 .map(|()| true);
1646 }
1647 let mode = request.args.get(1).and_then(Value::as_u64).unwrap_or(0) as u32;
1648 let result = (|| -> Result<(), std::io::Error> {
1649 let mut permissions = fs::metadata(&host_path)?.permissions();
1650 permissions.set_mode(mode);
1651 fs::set_permissions(&host_path, permissions)
1652 })();
1653 return respond_wasm_sync_rpc_unit(execution, request, path, result).map(|()| true);
1654 }
1655
1656 if request.method == "fs.mkdirSync" {
1657 let Some(path) = request.args.first().and_then(Value::as_str) else {
1658 return Err(WasmExecutionError::RpcResponse(String::from(
1659 "missing fs.mkdirSync path",
1660 )));
1661 };
1662 let Some(host_path) = translate_wasm_guest_path(path, internal_sync_rpc) else {
1663 return Ok(false);
1664 };
1665 if wasm_host_path_is_read_only(&host_path, internal_sync_rpc) {
1666 return respond_wasm_sync_rpc_unit(
1667 execution,
1668 request,
1669 path,
1670 Err(wasm_read_only_filesystem_error(path)),
1671 )
1672 .map(|()| true);
1673 }
1674 let recursive = request
1675 .args
1676 .get(1)
1677 .map(|value| match value {
1678 Value::Bool(flag) => *flag,
1679 Value::Object(options) => options
1680 .get("recursive")
1681 .and_then(Value::as_bool)
1682 .unwrap_or(false),
1683 _ => false,
1684 })
1685 .unwrap_or(false);
1686 let result = if recursive {
1687 fs::create_dir_all(&host_path)
1688 } else {
1689 fs::create_dir(&host_path)
1690 };
1691 return respond_wasm_sync_rpc_unit(execution, request, path, result).map(|()| true);
1692 }
1693
1694 if request.method == "fs.rmdirSync" {
1695 let Some(path) = request.args.first().and_then(Value::as_str) else {
1696 return Err(WasmExecutionError::RpcResponse(String::from(
1697 "missing fs.rmdirSync path",
1698 )));
1699 };
1700 let Some(host_path) = translate_wasm_guest_path(path, internal_sync_rpc) else {
1701 return Ok(false);
1702 };
1703 if wasm_host_path_is_read_only(&host_path, internal_sync_rpc) {
1704 return respond_wasm_sync_rpc_unit(
1705 execution,
1706 request,
1707 path,
1708 Err(wasm_read_only_filesystem_error(path)),
1709 )
1710 .map(|()| true);
1711 }
1712 return respond_wasm_sync_rpc_unit(execution, request, path, fs::remove_dir(&host_path))
1713 .map(|()| true);
1714 }
1715
1716 if request.method == "fs.unlinkSync" {
1717 let Some(path) = request.args.first().and_then(Value::as_str) else {
1718 return Err(WasmExecutionError::RpcResponse(String::from(
1719 "missing fs.unlinkSync path",
1720 )));
1721 };
1722 let Some(host_path) = translate_wasm_guest_path(path, internal_sync_rpc) else {
1723 return Ok(false);
1724 };
1725 if wasm_host_path_is_read_only(&host_path, internal_sync_rpc) {
1726 return respond_wasm_sync_rpc_unit(
1727 execution,
1728 request,
1729 path,
1730 Err(wasm_read_only_filesystem_error(path)),
1731 )
1732 .map(|()| true);
1733 }
1734 return respond_wasm_sync_rpc_unit(execution, request, path, fs::remove_file(&host_path))
1735 .map(|()| true);
1736 }
1737
1738 if request.method == "fs.renameSync" {
1739 let Some(source) = request.args.first().and_then(Value::as_str) else {
1740 return Err(WasmExecutionError::RpcResponse(String::from(
1741 "missing fs.renameSync source",
1742 )));
1743 };
1744 let Some(destination) = request.args.get(1).and_then(Value::as_str) else {
1745 return Err(WasmExecutionError::RpcResponse(String::from(
1746 "missing fs.renameSync destination",
1747 )));
1748 };
1749 let Some(host_source) = translate_wasm_guest_path(source, internal_sync_rpc) else {
1750 return Ok(false);
1751 };
1752 let Some(host_destination) = translate_wasm_guest_path(destination, internal_sync_rpc)
1753 else {
1754 return Ok(false);
1755 };
1756 if wasm_mutation_touches_read_only_mapping(
1757 &host_source,
1758 &host_destination,
1759 internal_sync_rpc,
1760 ) {
1761 return respond_wasm_sync_rpc_unit(
1762 execution,
1763 request,
1764 source,
1765 Err(wasm_read_only_filesystem_error(source)),
1766 )
1767 .map(|()| true);
1768 }
1769 return respond_wasm_sync_rpc_unit(
1770 execution,
1771 request,
1772 source,
1773 fs::rename(&host_source, &host_destination),
1774 )
1775 .map(|()| true);
1776 }
1777
1778 if request.method == "fs.linkSync" {
1779 let Some(source) = request.args.first().and_then(Value::as_str) else {
1780 return Err(WasmExecutionError::RpcResponse(String::from(
1781 "missing fs.linkSync source",
1782 )));
1783 };
1784 let Some(destination) = request.args.get(1).and_then(Value::as_str) else {
1785 return Err(WasmExecutionError::RpcResponse(String::from(
1786 "missing fs.linkSync destination",
1787 )));
1788 };
1789 let Some(host_source) = translate_wasm_guest_path(source, internal_sync_rpc) else {
1790 return Ok(false);
1791 };
1792 let Some(host_destination) = translate_wasm_guest_path(destination, internal_sync_rpc)
1793 else {
1794 return Ok(false);
1795 };
1796 if wasm_host_path_is_read_only(&host_source, internal_sync_rpc)
1797 || wasm_host_path_is_read_only(&host_destination, internal_sync_rpc)
1798 {
1799 return respond_wasm_sync_rpc_unit(
1800 execution,
1801 request,
1802 source,
1803 Err(wasm_read_only_filesystem_error(source)),
1804 )
1805 .map(|()| true);
1806 }
1807 return respond_wasm_sync_rpc_unit(
1808 execution,
1809 request,
1810 source,
1811 fs::hard_link(&host_source, &host_destination),
1812 )
1813 .map(|()| true);
1814 }
1815
1816 if request.method == "fs.symlinkSync" {
1817 let Some(target) = request.args.first().and_then(Value::as_str) else {
1818 return Err(WasmExecutionError::RpcResponse(String::from(
1819 "missing fs.symlinkSync target",
1820 )));
1821 };
1822 let Some(link_path) = request.args.get(1).and_then(Value::as_str) else {
1823 return Err(WasmExecutionError::RpcResponse(String::from(
1824 "missing fs.symlinkSync path",
1825 )));
1826 };
1827 let target_path = if target.starts_with('/') {
1828 let Some(path) = translate_wasm_guest_path(target, internal_sync_rpc) else {
1829 return Ok(false);
1830 };
1831 path
1832 } else {
1833 PathBuf::from(target)
1834 };
1835 let Some(host_link_path) = translate_wasm_guest_path(link_path, internal_sync_rpc) else {
1836 return Ok(false);
1837 };
1838 if wasm_host_path_is_read_only(&host_link_path, internal_sync_rpc) {
1839 return respond_wasm_sync_rpc_unit(
1840 execution,
1841 request,
1842 link_path,
1843 Err(wasm_read_only_filesystem_error(link_path)),
1844 )
1845 .map(|()| true);
1846 }
1847 return respond_wasm_sync_rpc_unit(
1848 execution,
1849 request,
1850 link_path,
1851 std::os::unix::fs::symlink(&target_path, &host_link_path),
1852 )
1853 .map(|()| true);
1854 }
1855
1856 if request.method == "fs.readdirSync" {
1857 let Some(path) = request.args.first().and_then(Value::as_str) else {
1858 return Err(WasmExecutionError::RpcResponse(String::from(
1859 "missing fs.readdirSync path",
1860 )));
1861 };
1862 let Some(host_path) = translate_wasm_guest_path(path, internal_sync_rpc) else {
1863 return Ok(false);
1864 };
1865 let entries = fs::read_dir(&host_path)
1866 .and_then(|entries| {
1867 entries
1868 .map(|entry| {
1869 entry.map(|value| value.file_name().to_string_lossy().into_owned())
1870 })
1871 .collect::<Result<Vec<_>, _>>()
1872 })
1873 .map(|entries| json!(entries));
1874 return respond_wasm_sync_rpc_value(execution, request, path, entries).map(|()| true);
1875 }
1876
1877 if request.method == "fs.readlinkSync" {
1878 let Some(path) = request.args.first().and_then(Value::as_str) else {
1879 return Err(WasmExecutionError::RpcResponse(String::from(
1880 "missing fs.readlinkSync path",
1881 )));
1882 };
1883 let Some(host_path) = translate_wasm_guest_path(path, internal_sync_rpc) else {
1884 return Ok(false);
1885 };
1886 let target = fs::read_link(&host_path).map(|target| {
1887 Value::String(
1888 translate_wasm_host_symlink_target(&target, internal_sync_rpc)
1889 .unwrap_or_else(|| target.to_string_lossy().into_owned()),
1890 )
1891 });
1892 return respond_wasm_sync_rpc_value(execution, request, path, target).map(|()| true);
1893 }
1894
1895 if request.method == "fs.writeSync" {
1896 let Some(fd) = request.args.first().and_then(Value::as_u64) else {
1897 return Err(WasmExecutionError::RpcResponse(String::from(
1898 "missing fs.writeSync fd",
1899 )));
1900 };
1901 let bytes = decode_wasm_bytes_arg(
1902 request.args.get(1),
1903 "fs.writeSync bytes",
1904 WASM_CAPTURED_OUTPUT_LIMIT_BYTES,
1905 )?;
1906 if fd == 1 || fd == 2 {
1907 let bytes_len = bytes.len();
1908 internal_sync_rpc.pending_events.push_back(if fd == 1 {
1909 WasmExecutionEvent::Stdout(bytes)
1910 } else {
1911 WasmExecutionEvent::Stderr(bytes)
1912 });
1913 execution
1914 .respond_sync_rpc_success(request.id, json!(bytes_len))
1915 .map_err(map_javascript_error)?;
1916 return Ok(true);
1917 }
1918 let position = request.args.get(2).and_then(Value::as_u64);
1919 let Some(file) = internal_sync_rpc.open_files.get_mut(&(fd as u32)) else {
1920 return Ok(false);
1921 };
1922 let written = if let Some(position) = position {
1923 file.write_at(&bytes, position)
1924 .map_err(WasmExecutionError::Spawn)?
1925 } else {
1926 file.write(&bytes).map_err(WasmExecutionError::Spawn)?
1927 };
1928 execution
1929 .respond_sync_rpc_success(request.id, json!(written))
1930 .map_err(map_javascript_error)?;
1931 return Ok(true);
1932 }
1933
1934 if request.method == "fs.readSync" {
1935 let Some(fd) = request.args.first().and_then(Value::as_u64) else {
1936 return Err(WasmExecutionError::RpcResponse(String::from(
1937 "missing fs.readSync fd",
1938 )));
1939 };
1940 let length = wasm_sync_read_length(request.args.get(1).and_then(Value::as_u64))?;
1941 let position = request.args.get(2).and_then(Value::as_u64);
1942 let Some(file) = internal_sync_rpc.open_files.get_mut(&(fd as u32)) else {
1943 return Ok(false);
1944 };
1945 let mut buffer = vec![0u8; length];
1946 let bytes_read = if let Some(position) = position {
1947 file.read_at(&mut buffer, position)
1948 .map_err(WasmExecutionError::Spawn)?
1949 } else {
1950 file.read(&mut buffer).map_err(WasmExecutionError::Spawn)?
1951 };
1952 buffer.truncate(bytes_read);
1953 execution
1954 .respond_sync_rpc_success(
1955 request.id,
1956 json!({
1957 "__agentOSType": "bytes",
1958 "base64": v8_runtime::base64_encode_pub(&buffer),
1959 }),
1960 )
1961 .map_err(map_javascript_error)?;
1962 return Ok(true);
1963 }
1964
1965 Ok(false)
1966}
1967
1968fn wasm_sync_rpc_method_routes_through_sidecar_kernel(
1969 request: &JavascriptSyncRpcRequest,
1970 internal_sync_rpc: &WasmInternalSyncRpc,
1971) -> bool {
1972 internal_sync_rpc.route_fs_through_sidecar
1973 && (WASM_SIDECAR_ROUTED_FS_SYNC_METHODS.contains(&request.method.as_str())
1974 || WASM_SIDECAR_ROUTED_KERNEL_SYNC_METHODS.contains(&request.method.as_str()))
1975}
1976
1977fn translate_wasm_guest_path(
1978 path: &str,
1979 internal_sync_rpc: &WasmInternalSyncRpc,
1980) -> Option<PathBuf> {
1981 if let Some(host_path) = translate_wasm_host_runtime_path(path, internal_sync_rpc) {
1982 return confine_wasm_host_path(host_path, internal_sync_rpc);
1983 }
1984
1985 let normalized_path = if path.starts_with('/') {
1986 normalize_guest_path(path)
1987 } else {
1988 join_guest_path(&internal_sync_rpc.guest_cwd, path)
1989 };
1990
1991 if normalized_path == internal_sync_rpc.module_host_path.to_string_lossy() {
1992 return Some(internal_sync_rpc.module_host_path.clone());
1993 }
1994 if internal_sync_rpc
1995 .module_guest_paths
1996 .iter()
1997 .any(|candidate| candidate == &normalized_path)
1998 {
1999 return Some(internal_sync_rpc.module_host_path.clone());
2000 }
2001 for mapping in &internal_sync_rpc.guest_path_mappings {
2002 if let Some(suffix) = strip_guest_prefix(&normalized_path, &mapping.guest_path) {
2003 return confine_wasm_host_path(
2004 join_host_path(&mapping.host_path, &suffix),
2005 internal_sync_rpc,
2006 );
2007 }
2008 }
2009 if let Some(suffix) = strip_guest_prefix(&normalized_path, &internal_sync_rpc.guest_cwd) {
2010 return confine_wasm_host_path(
2011 join_host_path(&internal_sync_rpc.host_cwd, &suffix),
2012 internal_sync_rpc,
2013 );
2014 }
2015 if normalized_path.starts_with('/') {
2016 let root_candidate = internal_sync_rpc
2017 .sandbox_root
2018 .as_ref()
2019 .map(|root| join_host_path(root, normalized_path.trim_start_matches('/')));
2020 if let Some(candidate) = root_candidate.as_ref() {
2021 if candidate.exists() {
2022 return confine_wasm_host_path(candidate.clone(), internal_sync_rpc);
2023 }
2024 }
2025
2026 if internal_sync_rpc.guest_cwd != "/" {
2030 let cwd_relative_guest_path = join_guest_path(
2031 &internal_sync_rpc.guest_cwd,
2032 normalized_path.trim_start_matches('/'),
2033 );
2034 for mapping in &internal_sync_rpc.guest_path_mappings {
2035 if let Some(suffix) =
2036 strip_guest_prefix(&cwd_relative_guest_path, &mapping.guest_path)
2037 {
2038 let candidate = join_host_path(&mapping.host_path, &suffix);
2039 if candidate.exists() {
2040 return confine_wasm_host_path(candidate, internal_sync_rpc);
2041 }
2042 }
2043 }
2044 if let Some(suffix) =
2045 strip_guest_prefix(&cwd_relative_guest_path, &internal_sync_rpc.guest_cwd)
2046 {
2047 let candidate = join_host_path(&internal_sync_rpc.host_cwd, &suffix);
2048 if candidate.exists() {
2049 return confine_wasm_host_path(candidate, internal_sync_rpc);
2050 }
2051 }
2052 }
2053
2054 return root_candidate.and_then(|path| confine_wasm_host_path(path, internal_sync_rpc));
2055 }
2056 None
2057}
2058
2059fn confine_wasm_host_path(
2060 host_path: PathBuf,
2061 internal_sync_rpc: &WasmInternalSyncRpc,
2062) -> Option<PathBuf> {
2063 if host_path == internal_sync_rpc.module_host_path {
2064 return Some(host_path);
2065 }
2066
2067 let allowed_roots = wasm_allowed_host_roots(internal_sync_rpc);
2068 if allowed_roots.is_empty() {
2069 return None;
2070 }
2071
2072 if let Ok(canonical_path) = fs::canonicalize(&host_path) {
2073 return wasm_canonical_path_is_allowed(&canonical_path, &allowed_roots)
2074 .then_some(host_path);
2075 }
2076
2077 let existing_ancestor = nearest_existing_wasm_host_ancestor(&host_path)?;
2078 let canonical_ancestor = fs::canonicalize(existing_ancestor).ok()?;
2079 wasm_canonical_path_is_allowed(&canonical_ancestor, &allowed_roots).then_some(host_path)
2080}
2081
2082fn wasm_allowed_host_roots(internal_sync_rpc: &WasmInternalSyncRpc) -> Vec<PathBuf> {
2083 let mut roots = Vec::new();
2084 for root in internal_sync_rpc
2085 .guest_path_mappings
2086 .iter()
2087 .map(|mapping| mapping.host_path.as_path())
2088 .chain(std::iter::once(internal_sync_rpc.host_cwd.as_path()))
2089 .chain(internal_sync_rpc.sandbox_root.as_deref())
2090 {
2091 if let Ok(canonical_root) = fs::canonicalize(root) {
2092 if !roots.iter().any(|existing| existing == &canonical_root) {
2093 roots.push(canonical_root);
2094 }
2095 }
2096 }
2097 roots
2098}
2099
2100fn wasm_canonical_path_is_allowed(path: &Path, allowed_roots: &[PathBuf]) -> bool {
2101 allowed_roots
2102 .iter()
2103 .any(|root| path == root || path.starts_with(root))
2104}
2105
2106fn nearest_existing_wasm_host_ancestor(path: &Path) -> Option<&Path> {
2107 let mut candidate = Some(path);
2108 while let Some(current) = candidate {
2109 if fs::symlink_metadata(current).is_ok() {
2110 return Some(current);
2111 }
2112 candidate = current.parent();
2113 }
2114 None
2115}
2116
2117fn translate_wasm_host_runtime_path(
2118 path: &str,
2119 internal_sync_rpc: &WasmInternalSyncRpc,
2120) -> Option<PathBuf> {
2121 let candidate = Path::new(path);
2122 if !candidate.is_absolute() {
2123 return None;
2124 }
2125
2126 if candidate == internal_sync_rpc.module_host_path {
2127 return Some(candidate.to_path_buf());
2128 }
2129
2130 let mapped_host_root = internal_sync_rpc
2131 .guest_path_mappings
2132 .iter()
2133 .map(|mapping| mapping.host_path.as_path())
2134 .find(|root| candidate == *root || candidate.starts_with(root));
2135 if let Some(root) = mapped_host_root {
2136 let _ = root;
2137 return Some(candidate.to_path_buf());
2138 }
2139
2140 if candidate == internal_sync_rpc.host_cwd || candidate.starts_with(&internal_sync_rpc.host_cwd)
2141 {
2142 return Some(candidate.to_path_buf());
2143 }
2144
2145 if let Some(sandbox_root) = internal_sync_rpc.sandbox_root.as_ref() {
2146 if candidate == sandbox_root || candidate.starts_with(sandbox_root) {
2147 return Some(candidate.to_path_buf());
2148 }
2149 }
2150
2151 None
2152}
2153
2154fn translate_wasm_host_symlink_target(
2155 target: &Path,
2156 internal_sync_rpc: &WasmInternalSyncRpc,
2157) -> Option<String> {
2158 if !target.is_absolute() {
2159 return None;
2160 }
2161
2162 for mapping in &internal_sync_rpc.guest_path_mappings {
2163 if let Ok(suffix) = target.strip_prefix(&mapping.host_path) {
2164 return Some(join_guest_path(
2165 &mapping.guest_path,
2166 &suffix.to_string_lossy().replace('\\', "/"),
2167 ));
2168 }
2169 }
2170
2171 if let Some(suffix) = target
2172 .strip_prefix(&internal_sync_rpc.host_cwd)
2173 .ok()
2174 .filter(|_| internal_sync_rpc.guest_cwd.starts_with('/'))
2175 {
2176 return Some(join_guest_path(
2177 &internal_sync_rpc.guest_cwd,
2178 &suffix.to_string_lossy().replace('\\', "/"),
2179 ));
2180 }
2181
2182 if let Some(sandbox_root) = internal_sync_rpc.sandbox_root.as_ref() {
2183 if let Ok(suffix) = target.strip_prefix(sandbox_root) {
2184 return Some(join_guest_path(
2185 "/",
2186 &suffix.to_string_lossy().replace('\\', "/"),
2187 ));
2188 }
2189 }
2190
2191 None
2192}
2193
2194fn wasm_host_path_is_read_only(host_path: &Path, internal_sync_rpc: &WasmInternalSyncRpc) -> bool {
2195 let canonical_path = fs::canonicalize(host_path)
2196 .ok()
2197 .or_else(|| {
2198 nearest_existing_wasm_host_ancestor(host_path)
2199 .and_then(|ancestor| fs::canonicalize(ancestor).ok())
2200 })
2201 .unwrap_or_else(|| host_path.to_path_buf());
2202
2203 internal_sync_rpc
2204 .guest_path_mappings
2205 .iter()
2206 .filter_map(|mapping| {
2207 let root = fs::canonicalize(&mapping.host_path).ok()?;
2208 (canonical_path == root || canonical_path.starts_with(&root))
2209 .then_some((root.components().count(), mapping.read_only))
2210 })
2211 .max_by_key(|(depth, _)| *depth)
2212 .is_some_and(|(_, read_only)| read_only)
2213}
2214
2215fn wasm_mutation_touches_read_only_mapping(
2216 source: &Path,
2217 destination: &Path,
2218 internal_sync_rpc: &WasmInternalSyncRpc,
2219) -> bool {
2220 wasm_host_path_is_read_only(source, internal_sync_rpc)
2221 || wasm_host_path_is_read_only(destination, internal_sync_rpc)
2222}
2223
2224fn wasm_open_flags_require_write(flags: &Value) -> bool {
2225 match flags.as_str() {
2226 Some(value) => value.contains('w') || value.contains('a') || value.contains('+'),
2227 None if flags.as_u64().unwrap_or(0) == 0 => false,
2228 _ => {
2229 let numeric = flags.as_u64().unwrap_or(0);
2230 (numeric & 0o1) != 0
2231 || (numeric & 0o2) != 0
2232 || (numeric & 0o100) != 0
2233 || (numeric & 0o1000) != 0
2234 || (numeric & 0o2000) != 0
2235 }
2236 }
2237}
2238
2239fn wasm_read_only_filesystem_error(path: &str) -> std::io::Error {
2240 let _ = path;
2241 std::io::Error::from_raw_os_error(30)
2242}
2243
2244fn respond_wasm_sync_rpc_metadata(
2245 execution: &mut JavascriptExecution,
2246 request: &JavascriptSyncRpcRequest,
2247 label: &str,
2248 metadata: Result<fs::Metadata, std::io::Error>,
2249) -> Result<(), WasmExecutionError> {
2250 respond_wasm_sync_rpc_value(
2251 execution,
2252 request,
2253 label,
2254 metadata.map(|value| wasm_host_stat_value(&value)),
2255 )
2256}
2257
2258fn respond_wasm_sync_rpc_unit(
2259 execution: &mut JavascriptExecution,
2260 request: &JavascriptSyncRpcRequest,
2261 label: &str,
2262 result: Result<(), std::io::Error>,
2263) -> Result<(), WasmExecutionError> {
2264 respond_wasm_sync_rpc_value(execution, request, label, result.map(|()| Value::Null))
2265}
2266
2267fn respond_wasm_sync_rpc_value(
2268 execution: &mut JavascriptExecution,
2269 request: &JavascriptSyncRpcRequest,
2270 label: &str,
2271 result: Result<Value, std::io::Error>,
2272) -> Result<(), WasmExecutionError> {
2273 match result {
2274 Ok(value) => execution
2275 .respond_sync_rpc_success(request.id, value)
2276 .map_err(map_javascript_error),
2277 Err(error) => execution
2278 .respond_sync_rpc_error(
2279 request.id,
2280 wasm_sync_rpc_error_code(&error),
2281 format!("{} {} failed: {error}", request.method, label),
2282 )
2283 .map_err(map_javascript_error),
2284 }
2285}
2286
2287fn wasm_sync_rpc_error_code(error: &std::io::Error) -> &'static str {
2288 use std::io::ErrorKind;
2289
2290 if error.raw_os_error() == Some(30) {
2291 return "EROFS";
2292 }
2293
2294 match error.kind() {
2295 ErrorKind::NotFound => "ENOENT",
2296 ErrorKind::PermissionDenied => "EACCES",
2297 ErrorKind::AlreadyExists => "EEXIST",
2298 ErrorKind::InvalidInput => "EINVAL",
2299 ErrorKind::IsADirectory => "EISDIR",
2300 ErrorKind::NotADirectory => "ENOTDIR",
2301 _ => "EIO",
2302 }
2303}
2304
2305fn wasm_host_stat_value(metadata: &fs::Metadata) -> Value {
2306 json!({
2307 "mode": metadata.mode(),
2308 "size": metadata.size(),
2309 "blocks": metadata.blocks(),
2310 "dev": metadata.dev(),
2311 "rdev": metadata.rdev(),
2312 "isDirectory": metadata.is_dir(),
2313 "isSymbolicLink": metadata.file_type().is_symlink(),
2314 "atimeMs": metadata.atime() * 1000 + (metadata.atime_nsec() / 1_000_000),
2315 "mtimeMs": metadata.mtime() * 1000 + (metadata.mtime_nsec() / 1_000_000),
2316 "ctimeMs": metadata.ctime() * 1000 + (metadata.ctime_nsec() / 1_000_000),
2317 "birthtimeMs": metadata.ctime() * 1000 + (metadata.ctime_nsec() / 1_000_000),
2318 "ino": metadata.ino(),
2319 "nlink": metadata.nlink(),
2320 "uid": metadata.uid(),
2321 "gid": metadata.gid(),
2322 })
2323}
2324
2325fn strip_guest_prefix(path: &str, prefix: &str) -> Option<String> {
2326 let normalized_path = normalize_guest_path(path);
2327 let normalized_prefix = normalize_guest_path(prefix);
2328 if normalized_path == normalized_prefix {
2329 return Some(String::new());
2330 }
2331 normalized_path
2332 .strip_prefix(&(normalized_prefix + "/"))
2333 .map(str::to_owned)
2334}
2335
2336fn join_host_path(base: &Path, suffix: &str) -> PathBuf {
2337 if suffix.is_empty() {
2338 return base.to_path_buf();
2339 }
2340 suffix
2341 .split('/')
2342 .filter(|segment| !segment.is_empty())
2343 .fold(base.to_path_buf(), |path, segment| path.join(segment))
2344}
2345
2346fn decode_wasm_bytes_arg(
2347 value: Option<&Value>,
2348 label: &'static str,
2349 limit: usize,
2350) -> Result<Vec<u8>, WasmExecutionError> {
2351 let base64 = value
2352 .and_then(Value::as_object)
2353 .and_then(|value| value.get("base64"))
2354 .and_then(Value::as_str)
2355 .ok_or_else(|| WasmExecutionError::RpcResponse(format!("missing {label}")))?;
2356 let decoded_len = base64_decoded_len(base64)
2357 .ok_or_else(|| WasmExecutionError::RpcResponse(format!("invalid {label} base64")))?;
2358 if decoded_len > limit {
2359 return Err(WasmExecutionError::OutputBufferExceeded {
2360 stream: label,
2361 limit,
2362 });
2363 }
2364 base64::engine::general_purpose::STANDARD
2365 .decode(base64)
2366 .map_err(|_| WasmExecutionError::RpcResponse(format!("invalid {label} base64")))
2367}
2368
2369fn base64_decoded_len(base64: &str) -> Option<usize> {
2370 let len = base64.len();
2371 let padding = base64
2372 .as_bytes()
2373 .iter()
2374 .rev()
2375 .take_while(|byte| **byte == b'=')
2376 .take(2)
2377 .count();
2378 let full_quads = len / 4;
2379 let remainder = len % 4;
2380 let base_len = full_quads.checked_mul(3)?.checked_sub(padding)?;
2381 match remainder {
2382 0 => Some(base_len),
2383 1 => None,
2384 2 => base_len.checked_add(1),
2385 3 => base_len.checked_add(2),
2386 _ => None,
2387 }
2388}
2389
2390fn is_v8_stack_overflow_stderr(chunk: &[u8]) -> bool {
2391 std::str::from_utf8(chunk).is_ok_and(|message| {
2392 message.starts_with("RangeError: Maximum call stack size exceeded")
2393 && message.contains("wasm-function")
2394 })
2395}
2396
2397fn configured_wasm_stack_limit_error(limit: u64) -> String {
2398 format!(
2399 "WebAssembly guest exhausted its configured stack budget ({limit} bytes); \
2400raise limits.resources.maxWasmStackBytes to allow deeper guest call stacks.\n"
2401 )
2402}
2403
2404fn append_wasm_captured_output(
2405 buffer: &mut Vec<u8>,
2406 chunk: &[u8],
2407 stream: &'static str,
2408) -> Result<(), WasmExecutionError> {
2409 ensure_wasm_output_capacity(buffer.len(), chunk.len(), stream)?;
2410 buffer.extend_from_slice(chunk);
2411 Ok(())
2412}
2413
2414fn ensure_wasm_output_capacity(
2415 current_len: usize,
2416 chunk_len: usize,
2417 stream: &'static str,
2418) -> Result<(), WasmExecutionError> {
2419 let Some(next_len) = current_len.checked_add(chunk_len) else {
2420 return Err(WasmExecutionError::OutputBufferExceeded {
2421 stream,
2422 limit: WASM_CAPTURED_OUTPUT_LIMIT_BYTES,
2423 });
2424 };
2425 if next_len > WASM_CAPTURED_OUTPUT_LIMIT_BYTES {
2426 return Err(WasmExecutionError::OutputBufferExceeded {
2427 stream,
2428 limit: WASM_CAPTURED_OUTPUT_LIMIT_BYTES,
2429 });
2430 }
2431 Ok(())
2432}
2433
2434fn wasm_sync_read_length(length: Option<u64>) -> Result<usize, WasmExecutionError> {
2435 let length = length.unwrap_or(0);
2436 let length = usize::try_from(length).map_err(|_| {
2437 WasmExecutionError::InvalidLimit(format!("fs.readSync length {length} exceeds host usize"))
2438 })?;
2439 if length > WASM_SYNC_READ_LIMIT_BYTES {
2440 return Err(WasmExecutionError::InvalidLimit(format!(
2441 "fs.readSync length {length} exceeds maximum {WASM_SYNC_READ_LIMIT_BYTES}"
2442 )));
2443 }
2444 Ok(length)
2445}
2446
2447fn open_wasm_guest_file(path: &Path, flags: &Value) -> std::io::Result<fs::File> {
2448 let mut options = OpenOptions::new();
2449 let flags_label = flags.to_string();
2450
2451 match flags.as_str() {
2452 Some("r") | None if flags.as_u64().unwrap_or(0) == 0 => {
2453 options.read(true);
2454 }
2455 Some("r+") => {
2456 options.read(true).write(true);
2457 }
2458 Some("w") => {
2459 options.write(true).create(true).truncate(true);
2460 }
2461 Some("w+") => {
2462 options.read(true).write(true).create(true).truncate(true);
2463 }
2464 Some("a") => {
2465 options.append(true).create(true);
2466 }
2467 Some("a+") => {
2468 options.read(true).append(true).create(true);
2469 }
2470 _ => {
2471 let numeric = flags.as_u64().ok_or_else(|| {
2472 std::io::Error::new(
2473 std::io::ErrorKind::InvalidInput,
2474 format!("unsupported fs.openSync flags: {flags_label}"),
2475 )
2476 })?;
2477 let write_only = (numeric & 0o1) != 0;
2478 let read_write = (numeric & 0o2) != 0;
2479 let create = (numeric & 0o100) != 0;
2480 let truncate = (numeric & 0o1000) != 0;
2481 let append = (numeric & 0o2000) != 0;
2482
2483 if read_write {
2484 options.read(true).write(true);
2485 } else if write_only {
2486 options.write(true);
2487 } else {
2488 options.read(true);
2489 }
2490 if create {
2491 options.create(true);
2492 }
2493 if truncate {
2494 options.truncate(true);
2495 }
2496 if append {
2497 options.append(true);
2498 }
2499 }
2500 }
2501
2502 options.open(path).map_err(|error| {
2503 std::io::Error::new(
2504 error.kind(),
2505 format!(
2506 "failed to open guest file {} with flags {}: {error}",
2507 path.display(),
2508 flags_label
2509 ),
2510 )
2511 })
2512}
2513
2514fn translate_wasm_signal_state_sync_rpc_request(
2515 execution: &mut JavascriptExecution,
2516 request: &JavascriptSyncRpcRequest,
2517) -> Result<Option<WasmExecutionEvent>, WasmExecutionError> {
2518 if request.method != "process.signal_state" {
2519 return Ok(None);
2520 }
2521
2522 let signal = request
2523 .args
2524 .first()
2525 .and_then(Value::as_u64)
2526 .ok_or_else(|| WasmExecutionError::RpcResponse(String::from("missing signal number")))?;
2527 let action = match request
2528 .args
2529 .get(1)
2530 .and_then(Value::as_str)
2531 .unwrap_or("default")
2532 {
2533 "ignore" => WasmSignalDispositionAction::Ignore,
2534 "user" => WasmSignalDispositionAction::User,
2535 _ => WasmSignalDispositionAction::Default,
2536 };
2537 let mask = request
2538 .args
2539 .get(2)
2540 .and_then(Value::as_str)
2541 .map(serde_json::from_str::<Vec<u32>>)
2542 .transpose()
2543 .map_err(|error| WasmExecutionError::RpcResponse(error.to_string()))?
2544 .unwrap_or_default();
2545 let flags = request
2546 .args
2547 .get(3)
2548 .and_then(|value| {
2549 value
2550 .as_u64()
2551 .or_else(|| value.as_i64().map(|signed| signed as u64))
2552 })
2553 .unwrap_or_default() as u32;
2554
2555 execution
2556 .respond_sync_rpc_success(request.id, Value::Null)
2557 .map_err(map_javascript_error)?;
2558
2559 Ok(Some(WasmExecutionEvent::SignalState {
2560 signal: signal as u32,
2561 registration: WasmSignalHandlerRegistration {
2562 action,
2563 mask,
2564 flags,
2565 },
2566 }))
2567}
2568
2569fn parse_wasm_signal_state_line(
2570 line: &[u8],
2571) -> Result<Option<WasmExecutionEvent>, WasmExecutionError> {
2572 let line = line.strip_suffix(b"\n").unwrap_or(line);
2573 let line = line.strip_suffix(b"\r").unwrap_or(line);
2574 let payload = match line.strip_prefix(WASM_SIGNAL_STATE_PREFIX.as_bytes()) {
2575 Some(payload) => payload,
2576 None => return Ok(None),
2577 };
2578 let payload = std::str::from_utf8(payload)
2579 .map_err(|error| WasmExecutionError::RpcResponse(error.to_string()))?;
2580 let message: Value = serde_json::from_str(payload)
2581 .map_err(|error| WasmExecutionError::RpcResponse(error.to_string()))?;
2582 let signal = message
2583 .get("signal")
2584 .and_then(Value::as_u64)
2585 .ok_or_else(|| WasmExecutionError::RpcResponse(String::from("missing signal number")))?;
2586 let registration = message
2587 .get("registration")
2588 .and_then(Value::as_object)
2589 .ok_or_else(|| {
2590 WasmExecutionError::RpcResponse(String::from("missing signal registration"))
2591 })?;
2592 let action = match registration
2593 .get("action")
2594 .and_then(Value::as_str)
2595 .unwrap_or("default")
2596 {
2597 "ignore" => WasmSignalDispositionAction::Ignore,
2598 "user" => WasmSignalDispositionAction::User,
2599 _ => WasmSignalDispositionAction::Default,
2600 };
2601 let mask = registration
2602 .get("mask")
2603 .and_then(Value::as_array)
2604 .map(|entries| {
2605 entries
2606 .iter()
2607 .filter_map(Value::as_u64)
2608 .map(|value| value as u32)
2609 .collect::<Vec<_>>()
2610 })
2611 .unwrap_or_default();
2612 let flags = registration
2613 .get("flags")
2614 .and_then(Value::as_u64)
2615 .unwrap_or_default() as u32;
2616
2617 Ok(Some(WasmExecutionEvent::SignalState {
2618 signal: signal as u32,
2619 registration: WasmSignalHandlerRegistration {
2620 action,
2621 mask,
2622 flags,
2623 },
2624 }))
2625}
2626
2627struct WasmJavascriptExecutionOptions<'a> {
2628 frozen_time_ms: u128,
2629 prewarm_only: bool,
2630 warmup_metrics: Option<&'a [u8]>,
2631 defer_execute: bool,
2632}
2633
2634#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2635enum WasmSnapshotRunnerMode {
2636 Auto,
2637 Block,
2638 Off,
2639}
2640
2641fn wasm_snapshot_runner_mode() -> WasmSnapshotRunnerMode {
2642 match std::env::var(WASM_SNAPSHOT_RUNNER_ENV) {
2643 Ok(value) if value.eq_ignore_ascii_case("block") => WasmSnapshotRunnerMode::Block,
2644 Ok(value) if value.eq_ignore_ascii_case("off") => WasmSnapshotRunnerMode::Off,
2645 Ok(value) if value.eq_ignore_ascii_case("auto") => WasmSnapshotRunnerMode::Auto,
2646 Ok(value) => {
2647 tracing::warn!(
2648 value,
2649 "{WASM_SNAPSHOT_RUNNER_ENV} must be auto, block, or off; using auto"
2650 );
2651 WasmSnapshotRunnerMode::Auto
2652 }
2653 Err(_) => WasmSnapshotRunnerMode::Auto,
2654 }
2655}
2656
2657fn start_wasm_javascript_execution(
2658 javascript_engine: &mut JavascriptExecutionEngine,
2659 runtime: &RuntimeContext,
2660 import_cache: &NodeImportCache,
2661 javascript_context_id: &str,
2662 resolved_module: &ResolvedWasmModule,
2663 request: &StartWasmExecutionRequest,
2664 options: WasmJavascriptExecutionOptions<'_>,
2665) -> Result<JavascriptExecution, WasmExecutionError> {
2666 let wasm_module_bytes = cached_wasm_module_bytes(&resolved_module.resolved_path)?;
2667 let internal_env = build_wasm_internal_env(
2668 resolved_module,
2669 request,
2670 options.frozen_time_ms,
2671 options.prewarm_only,
2672 )?;
2673 let snapshot_mode = wasm_snapshot_runner_mode();
2674 let mut env = wasm_runner_base_env(request);
2675 let mut guest_runtime = request.guest_runtime.clone();
2676
2677 let inline_code = match snapshot_mode {
2678 WasmSnapshotRunnerMode::Off => {
2679 env.extend(
2680 internal_env
2681 .iter()
2682 .map(|(key, value)| (key.clone(), value.clone())),
2683 );
2684 build_wasm_runner_module_source(import_cache, &internal_env, options.warmup_metrics)?
2685 }
2686 WasmSnapshotRunnerMode::Auto | WasmSnapshotRunnerMode::Block => {
2687 let userland_bundle = build_wasm_runner_userland_bundle(import_cache)?;
2688 let runner_heap_limit_mb = wasm_runner_heap_limit_mb(request);
2689 let runtime = javascript_engine
2690 .runtime_context()
2691 .map_err(map_javascript_error)?;
2692 V8RuntimeHost::warm_snapshot_async(runtime, userland_bundle.clone());
2693 let use_snapshot = match snapshot_mode {
2694 WasmSnapshotRunnerMode::Block => {
2695 if !javascript_engine
2696 .snapshot_userland_ready(&userland_bundle)
2697 .map_err(map_javascript_error)?
2698 {
2699 javascript_engine
2700 .pre_warm_snapshot(&userland_bundle)
2701 .map_err(map_javascript_error)?;
2702 }
2703 javascript_engine
2704 .pre_warm_workers(
2705 &userland_bundle,
2706 runner_heap_limit_mb,
2707 v8_warm_worker_count(),
2708 )
2709 .map_err(map_javascript_error)?;
2710 javascript_engine
2711 .pre_warm_workers("", 0, v8_warm_worker_count())
2712 .map_err(map_javascript_error)?;
2713 true
2714 }
2715 WasmSnapshotRunnerMode::Auto => {
2716 let snapshot_ready = javascript_engine
2717 .snapshot_userland_ready(&userland_bundle)
2718 .unwrap_or(false);
2719 if snapshot_ready {
2720 javascript_engine
2721 .pre_warm_workers(
2722 &userland_bundle,
2723 runner_heap_limit_mb,
2724 v8_warm_worker_count(),
2725 )
2726 .map_err(map_javascript_error)?;
2727 }
2728 snapshot_ready
2729 }
2730 WasmSnapshotRunnerMode::Off => false,
2731 };
2732
2733 if use_snapshot {
2734 env = wasm_snapshot_runner_base_env(request);
2735 env.extend(
2736 internal_env
2737 .iter()
2738 .map(|(key, value)| (key.clone(), value.clone())),
2739 );
2740 guest_runtime.snapshot_userland_code = Some(userland_bundle);
2741 build_wasm_snapshot_runner_inline_code(options.warmup_metrics)
2742 } else {
2743 env.extend(
2744 internal_env
2745 .iter()
2746 .map(|(key, value)| (key.clone(), value.clone())),
2747 );
2748 build_wasm_runner_module_source(
2749 import_cache,
2750 &internal_env,
2751 options.warmup_metrics,
2752 )?
2753 }
2754 }
2755 };
2756
2757 let javascript_request = StartJavascriptExecutionRequest {
2758 vm_id: request.vm_id.clone(),
2759 context_id: javascript_context_id.to_owned(),
2760 argv: vec![String::from(WASM_INLINE_RUNNER_ENTRYPOINT)],
2761 argv0: None,
2762 env,
2763 cwd: request.cwd.clone(),
2764 limits: wasm_runner_javascript_limits(&request.limits, wasm_runner_heap_limit_mb(request)),
2771 guest_runtime,
2774 inline_code: Some(inline_code),
2775 wasm_module_bytes: Some(wasm_module_bytes),
2776 };
2777 if options.defer_execute {
2778 javascript_engine.prepare_execution_with_runtime(javascript_request, runtime.clone())
2779 } else {
2780 javascript_engine.start_execution_with_runtime(javascript_request, runtime.clone())
2781 }
2782 .map_err(map_javascript_error)
2783}
2784
2785fn wasm_runner_javascript_limits(
2786 limits: &WasmExecutionLimits,
2787 runner_heap_limit_mb: u32,
2788) -> JavascriptExecutionLimits {
2789 JavascriptExecutionLimits {
2790 v8_heap_limit_mb: Some(runner_heap_limit_mb),
2791 cpu_time_limit_ms: limits.runner_cpu_time_limit_ms,
2792 reactor_work_quantum: limits.reactor_work_quantum,
2793 bridge_call_timeout_ms: limits.bridge_call_timeout_ms,
2794 ..JavascriptExecutionLimits::default()
2795 }
2796}
2797
2798struct WasmModuleBytesCache {
2799 entries: HashMap<PathBuf, (String, Arc<Vec<u8>>)>,
2800}
2801
2802fn wasm_module_bytes_cache() -> &'static Mutex<WasmModuleBytesCache> {
2803 static CACHE: OnceLock<Mutex<WasmModuleBytesCache>> = OnceLock::new();
2804 CACHE.get_or_init(|| {
2805 Mutex::new(WasmModuleBytesCache {
2806 entries: HashMap::new(),
2807 })
2808 })
2809}
2810
2811fn cached_wasm_module_bytes(path: &Path) -> Result<Arc<Vec<u8>>, WasmExecutionError> {
2812 let current_fingerprint = file_fingerprint(path);
2813 {
2814 let cache = wasm_module_bytes_cache()
2815 .lock()
2816 .expect("wasm module bytes cache lock poisoned");
2817 if let Some((fingerprint, bytes)) = cache.entries.get(path) {
2818 if fingerprint == ¤t_fingerprint {
2819 return Ok(Arc::clone(bytes));
2820 }
2821 }
2822 }
2823
2824 let module_bytes = Arc::new(fs::read(path).map_err(WasmExecutionError::PrepareWarmPath)?);
2825 let fingerprint = file_fingerprint(path);
2826 let mut cache = wasm_module_bytes_cache()
2827 .lock()
2828 .expect("wasm module bytes cache lock poisoned");
2829 if !cache.entries.contains_key(path) && cache.entries.len() >= WASM_MODULE_BYTES_CACHE_CAPACITY
2830 {
2831 if let Some(evicted_path) = cache.entries.keys().next().cloned() {
2832 cache.entries.remove(&evicted_path);
2833 tracing::warn!(
2834 path = %evicted_path.display(),
2835 "evicting cached wasm module bytes entry"
2836 );
2837 }
2838 }
2839 cache
2840 .entries
2841 .insert(path.to_path_buf(), (fingerprint, Arc::clone(&module_bytes)));
2842 let cumulative_bytes: usize = cache.entries.values().map(|(_, bytes)| bytes.len()).sum();
2843 tracing::debug!(
2844 path = %path.display(),
2845 raw_bytes = module_bytes.len(),
2846 cumulative_bytes,
2847 "cached wasm module bytes entry"
2848 );
2849 Ok(module_bytes)
2850}
2851
2852fn build_wasm_internal_env(
2853 resolved_module: &ResolvedWasmModule,
2854 request: &StartWasmExecutionRequest,
2855 frozen_time_ms: u128,
2856 prewarm_only: bool,
2857) -> Result<BTreeMap<String, String>, WasmExecutionError> {
2858 let guest_path_mappings = wasm_guest_path_mappings(request);
2859 let mut internal_env = request
2860 .env
2861 .iter()
2862 .filter(|(key, _)| key.starts_with("AGENTOS_"))
2863 .map(|(key, value)| (key.clone(), value.clone()))
2864 .collect::<BTreeMap<_, _>>();
2865 if let Some(value) = request.env.get("AGENTOS_KEEP_STDIN_OPEN") {
2866 internal_env.insert(String::from("AGENTOS_KEEP_STDIN_OPEN"), value.clone());
2867 }
2868 scrub_migrated_wasm_limit_env(&mut internal_env);
2869 insert_optional_u64_env(
2870 &mut internal_env,
2871 WASM_MAX_MEMORY_BYTES_ENV,
2872 request.limits.max_memory_bytes,
2873 );
2874 insert_optional_u64_env(
2875 &mut internal_env,
2876 WASM_MAX_MODULE_FILE_BYTES_ENV,
2877 request.limits.max_module_file_bytes,
2878 );
2879 insert_optional_u64_env(
2880 &mut internal_env,
2881 WASM_MAX_OPEN_FDS_ENV,
2882 request.limits.max_open_fds,
2883 );
2884 insert_optional_u64_env(
2885 &mut internal_env,
2886 WASM_MAX_SPAWN_FILE_ACTIONS_ENV,
2887 request.limits.max_spawn_file_actions,
2888 );
2889 insert_optional_u64_env(
2890 &mut internal_env,
2891 WASM_MAX_SPAWN_FILE_ACTION_BYTES_ENV,
2892 request.limits.max_spawn_file_action_bytes,
2893 );
2894 insert_optional_u64_env(
2895 &mut internal_env,
2896 WASM_MAX_SOCKETS_ENV,
2897 request.limits.max_sockets,
2898 );
2899 insert_optional_u64_env(
2900 &mut internal_env,
2901 WASM_MAX_BLOCKING_READ_MS_ENV,
2902 request.limits.max_blocking_read_ms,
2903 );
2904 insert_optional_u64_env(
2905 &mut internal_env,
2906 WASM_INTERNAL_MAX_STACK_BYTES_ENV,
2907 request.limits.max_stack_bytes,
2908 );
2909 internal_env.insert(
2910 WASM_MODULE_PATH_ENV.to_string(),
2911 resolved_module.specifier.clone(),
2912 );
2913 internal_env.insert(
2914 String::from("AGENTOS_FORWARD_KERNEL_STDIN_RPC"),
2915 String::from("1"),
2916 );
2917 internal_env.insert(
2918 WASM_GUEST_ARGV_ENV.to_string(),
2919 encode_json_string_array(&warmup_guest_argv(resolved_module, request)),
2920 );
2921 internal_env.insert(
2922 WASM_GUEST_ENV_ENV.to_string(),
2923 encode_json_string_map(&guest_visible_wasm_env(&request.env)),
2924 );
2925 insert_wasm_runner_identity_env(&mut internal_env, &request.guest_runtime);
2926 internal_env.insert(
2927 WASM_HOST_CWD_ENV.to_string(),
2928 request.cwd.to_string_lossy().into_owned(),
2929 );
2930 internal_env.insert(
2931 String::from("AGENTOS_GUEST_PATH_MAPPINGS"),
2932 encode_wasm_guest_path_mappings(&guest_path_mappings),
2933 );
2934 internal_env.insert(
2935 WASM_PERMISSION_TIER_ENV.to_string(),
2936 request.permission_tier.as_env_value().to_string(),
2937 );
2938 internal_env.insert(
2939 String::from("AGENTOS_FROZEN_TIME_MS"),
2940 frozen_time_ms.to_string(),
2941 );
2942
2943 if prewarm_only {
2944 internal_env.insert(WASM_PREWARM_ONLY_ENV.to_string(), String::from("1"));
2945 } else {
2946 internal_env.remove(WASM_PREWARM_ONLY_ENV);
2947 }
2948 Ok(internal_env)
2949}
2950
2951fn wasm_runner_base_env(request: &StartWasmExecutionRequest) -> BTreeMap<String, String> {
2952 let mut env = request.env.clone();
2953 scrub_migrated_wasm_limit_env(&mut env);
2954 env
2955}
2956
2957fn wasm_snapshot_runner_base_env(request: &StartWasmExecutionRequest) -> BTreeMap<String, String> {
2958 let mut env = request
2959 .env
2960 .iter()
2961 .filter(|(key, _)| !is_internal_wasm_guest_env_key(key))
2962 .map(|(key, value)| (key.clone(), value.clone()))
2963 .collect::<BTreeMap<_, _>>();
2964 scrub_migrated_wasm_limit_env(&mut env);
2965 env
2966}
2967
2968fn scrub_migrated_wasm_limit_env(env: &mut BTreeMap<String, String>) {
2969 for key in [
2970 WASM_MAX_FUEL_ENV,
2971 WASM_MAX_MEMORY_BYTES_ENV,
2972 WASM_MAX_STACK_BYTES_ENV,
2973 WASM_MAX_MODULE_FILE_BYTES_ENV,
2974 WASM_MAX_OPEN_FDS_ENV,
2975 WASM_MAX_SPAWN_FILE_ACTIONS_ENV,
2976 WASM_MAX_SPAWN_FILE_ACTION_BYTES_ENV,
2977 WASM_MAX_SOCKETS_ENV,
2978 WASM_MAX_BLOCKING_READ_MS_ENV,
2979 "AGENTOS_WASM_PREWARM_TIMEOUT_MS",
2980 "AGENTOS_WASM_RUNNER_HEAP_LIMIT_MB",
2981 ] {
2982 env.remove(key);
2983 }
2984}
2985
2986fn insert_optional_u64_env(env: &mut BTreeMap<String, String>, key: &str, value: Option<u64>) {
2987 if let Some(value) = value {
2988 env.insert(key.to_string(), value.to_string());
2989 } else {
2990 env.remove(key);
2991 }
2992}
2993
2994fn insert_wasm_runner_identity_env(
2995 env: &mut BTreeMap<String, String>,
2996 guest_runtime: &GuestRuntimeConfig,
2997) {
2998 insert_optional_u64_env(
2999 env,
3000 "AGENTOS_VIRTUAL_PROCESS_UID",
3001 guest_runtime.virtual_uid,
3002 );
3003 insert_optional_u64_env(
3004 env,
3005 "AGENTOS_VIRTUAL_PROCESS_GID",
3006 guest_runtime.virtual_gid,
3007 );
3008 insert_optional_u64_env(
3009 env,
3010 "AGENTOS_VIRTUAL_PROCESS_PID",
3011 guest_runtime.virtual_pid,
3012 );
3013 insert_optional_u64_env(
3014 env,
3015 "AGENTOS_VIRTUAL_PROCESS_PPID",
3016 guest_runtime.virtual_ppid,
3017 );
3018}
3019
3020fn build_wasm_runner_module_source(
3021 import_cache: &NodeImportCache,
3022 internal_env: &BTreeMap<String, String>,
3023 warmup_metrics: Option<&[u8]>,
3024) -> Result<String, WasmExecutionError> {
3025 let runner_source = transformed_wasm_runner_source(import_cache)?;
3026 let bootstrap = build_wasm_runner_bootstrap(internal_env, warmup_metrics);
3027 Ok(insert_wasm_runner_bootstrap(&runner_source, &bootstrap))
3028}
3029
3030fn transformed_wasm_runner_source(
3031 import_cache: &NodeImportCache,
3032) -> Result<String, WasmExecutionError> {
3033 if std::env::var(WASM_RUNNER_NO_CACHE_ENV).as_deref() == Ok("1") {
3034 return read_transformed_wasm_runner_source(import_cache);
3035 }
3036
3037 static RUNNER_SOURCE: OnceLock<Result<Arc<str>, Arc<str>>> = OnceLock::new();
3038 RUNNER_SOURCE
3039 .get_or_init(|| {
3040 read_transformed_wasm_runner_source(import_cache)
3041 .map(Arc::<str>::from)
3042 .map_err(|error| Arc::<str>::from(error.to_string()))
3043 })
3044 .as_ref()
3045 .map(|source| source.to_string())
3046 .map_err(|message| {
3047 WasmExecutionError::PrepareWarmPath(std::io::Error::other(message.to_string()))
3048 })
3049}
3050
3051fn read_transformed_wasm_runner_source(
3052 import_cache: &NodeImportCache,
3053) -> Result<String, WasmExecutionError> {
3054 let runner_source = fs::read_to_string(import_cache.wasm_runner_path())
3055 .map_err(WasmExecutionError::PrepareWarmPath)?;
3056 Ok(runner_source.replace(
3057 "import { WASI } from 'node:wasi';\n",
3058 "const { WASI } = globalThis.__agentOSWasiModule;\n",
3059 ))
3060}
3061
3062fn build_wasm_runner_userland_bundle(
3063 import_cache: &NodeImportCache,
3064) -> Result<String, WasmExecutionError> {
3065 if std::env::var(WASM_RUNNER_NO_CACHE_ENV).as_deref() == Ok("1") {
3066 return build_wasm_runner_userland_bundle_uncached(import_cache);
3067 }
3068
3069 static USERLAND_BUNDLE: OnceLock<Result<Arc<str>, Arc<str>>> = OnceLock::new();
3070 USERLAND_BUNDLE
3071 .get_or_init(|| {
3072 build_wasm_runner_userland_bundle_uncached(import_cache)
3073 .map(Arc::<str>::from)
3074 .map_err(|error| Arc::<str>::from(error.to_string()))
3075 })
3076 .as_ref()
3077 .map(|bundle| bundle.to_string())
3078 .map_err(|message| {
3079 WasmExecutionError::PrepareWarmPath(std::io::Error::other(message.to_string()))
3080 })
3081}
3082
3083fn build_wasm_runner_userland_bundle_uncached(
3084 import_cache: &NodeImportCache,
3085) -> Result<String, WasmExecutionError> {
3086 let runner_source = transformed_wasm_runner_source(import_cache)?;
3087 if runner_source
3088 .lines()
3089 .any(|line| line.trim_start().starts_with("import "))
3090 {
3091 return Err(WasmExecutionError::PrepareWarmPath(std::io::Error::other(
3092 "transformed wasm runner still contains an ESM import statement",
3093 )));
3094 }
3095
3096 let mut bundle = build_wasm_runner_snapshot_prelude();
3097 bundle.push_str("\nglobalThis.__agentOSWasmRunnerRun = async function () {\n");
3098 bundle.push_str(&runner_source);
3099 bundle.push_str("\n};\n");
3100 Ok(bundle)
3101}
3102
3103fn build_wasm_runner_snapshot_prelude() -> String {
3104 let bootstrap = build_wasm_runner_bootstrap(&BTreeMap::new(), None);
3105 let bootstrap = bootstrap
3106 .strip_prefix("const __agentOSWasmInternalEnv = {};\n")
3107 .unwrap_or(&bootstrap);
3108 bootstrap.replace(wasm_internal_env_merge_source(), "")
3109}
3110
3111fn build_wasm_snapshot_runner_inline_code(warmup_metrics: Option<&[u8]>) -> String {
3112 let warmup_emit = wasm_warmup_metrics_emit_source(warmup_metrics);
3113 format!(
3114 r#"{warmup_emit}if (typeof process !== "undefined" && typeof globalThis.__agentOSProcessConfigEnv === "object") {{
3115 process.env = {{ ...(process.env || {{}}), ...globalThis.__agentOSProcessConfigEnv }};
3116}}
3117await globalThis.__agentOSWasmRunnerRun();"#
3118 )
3119}
3120
3121fn build_wasm_runner_bootstrap(
3122 internal_env: &BTreeMap<String, String>,
3123 warmup_metrics: Option<&[u8]>,
3124) -> String {
3125 let internal_env_json =
3126 serde_json::to_string(internal_env).unwrap_or_else(|_| String::from("{}"));
3127 let warmup_emit = wasm_warmup_metrics_emit_source(warmup_metrics);
3128 let wasi_module_source = render_native_wasi_module_source();
3129 let env_merge_source = wasm_internal_env_merge_source();
3130 let wasm_sync_rpc_read_payload_bytes =
3131 max_cbor_byte_string_payload_bytes(WASM_PROCESS_SYNC_RPC_RESPONSE_BYTES);
3132
3133 format!(
3134 r#"const __agentOSWasmInternalEnv = {internal_env_json};
3135const __agentOSWasmSyncRpcReadPayloadBytes = {wasm_sync_rpc_read_payload_bytes};
3136const __agentOSRequireBuiltin = (specifier) => {{
3137 if (typeof globalThis.require === "function") {{
3138 return globalThis.require(specifier);
3139 }}
3140 if (typeof process?.getBuiltinModule === "function") {{
3141 return process.getBuiltinModule(specifier);
3142 }}
3143 throw new Error(`agentos WASM bootstrap cannot load ${{specifier}}`);
3144}};
3145{wasi_module_source}
3146{env_merge_source}
3147if (typeof globalThis !== "undefined") {{
3148 const __agentOSNormalizeBytes = (value) => {{
3149 if (value == null) {{
3150 return value;
3151 }}
3152 if (typeof Buffer !== "undefined" && Buffer.isBuffer(value)) {{
3153 return value;
3154 }}
3155 if (value instanceof Uint8Array) {{
3156 return Buffer.from(value);
3157 }}
3158 if (ArrayBuffer.isView(value)) {{
3159 return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
3160 }}
3161 if (value instanceof ArrayBuffer) {{
3162 return Buffer.from(value);
3163 }}
3164 if (
3165 value &&
3166 typeof value === "object" &&
3167 value.__agentOSType === "bytes" &&
3168 typeof value.base64 === "string"
3169 ) {{
3170 return Buffer.from(value.base64, "base64");
3171 }}
3172 return value;
3173 }};
3174 const __agentOSWasmSyncRpc = {{
3175 callSync(method, args = []) {{
3176 switch (method) {{
3177 case "fs.fstatSync":
3178 return __agentOSRequireBuiltin("node:fs").fstatSync(...args);
3179 case "fs.lstatSync":
3180 return __agentOSRequireBuiltin("node:fs").lstatSync(...args);
3181 case "fs.statSync":
3182 return __agentOSRequireBuiltin("node:fs").statSync(...args);
3183 case "fs.chmodSync":
3184 return __agentOSRequireBuiltin("node:fs").chmodSync(...args);
3185 case "__kernel_stdio_write":
3186 if (typeof _kernelStdioWriteRaw === "undefined") {{
3187 throw new Error("agentos WASM kernel stdio bridge is unavailable");
3188 }}
3189 return _kernelStdioWriteRaw.applySync(void 0, args);
3190 case "__kernel_stdin_read":
3191 if (typeof _kernelStdinReadRaw === "undefined") {{
3192 throw new Error("agentos WASM kernel stdin bridge is unavailable");
3193 }}
3194 return _kernelStdinReadRaw.applySync(void 0, args);
3195 case "__kernel_poll":
3196 if (typeof _kernelPollRaw === "undefined") {{
3197 throw new Error("agentos WASM kernel poll bridge is unavailable");
3198 }}
3199 return _kernelPollRaw.applySync(void 0, args);
3200 case "__kernel_isatty":
3201 if (typeof _kernelIsattyRaw === "undefined") {{
3202 throw new Error("agentos WASM kernel isatty bridge is unavailable");
3203 }}
3204 return _kernelIsattyRaw.applySync(void 0, args);
3205 case "__kernel_flock_path":
3206 if (typeof _kernelFlockRaw === "undefined") {{
3207 throw new Error("agentos WASM kernel file-lock bridge is unavailable");
3208 }}
3209 return _kernelFlockRaw.applySync(void 0, args);
3210 case "__kernel_tty_size":
3211 if (typeof _kernelTtySizeRaw === "undefined") {{
3212 throw new Error("agentos WASM kernel tty size bridge is unavailable");
3213 }}
3214 return _kernelTtySizeRaw.applySync(void 0, args);
3215 case "__pty_set_raw_mode":
3216 if (typeof _ptySetRawMode === "undefined") {{
3217 throw new Error("agentos WASM PTY raw-mode bridge is unavailable");
3218 }}
3219 return _ptySetRawMode.applySync(void 0, args);
3220 case "child_process.spawn": {{
3221 if (typeof _childProcessSpawnStart === "undefined") {{
3222 throw new Error("agentos WASM child_process bridge is unavailable");
3223 }}
3224 const [request] = args;
3225 return _childProcessSpawnStart.applySync(void 0, [
3226 request?.command ?? "",
3227 JSON.stringify(request?.args ?? []),
3228 JSON.stringify(request?.options ?? {{}}),
3229 ]);
3230 }}
3231 case "child_process.poll":
3232 if (typeof _childProcessPoll === "undefined") {{
3233 throw new Error("agentos WASM child_process poll bridge is unavailable");
3234 }}
3235 return _childProcessPoll.applySync(void 0, args);
3236 case "child_process.kill":
3237 if (typeof _childProcessKill === "undefined") {{
3238 throw new Error("agentos WASM child_process kill bridge is unavailable");
3239 }}
3240 return _childProcessKill.applySync(void 0, args);
3241 case "process.kill":
3242 if (typeof _processKill === "undefined") {{
3243 throw new Error("agentos WASM process kill bridge is unavailable");
3244 }}
3245 return _processKill.applySync(void 0, args);
3246 case "process.exec":
3247 if (typeof _processExec === "undefined") {{
3248 throw new Error("agentos WASM process exec bridge is unavailable");
3249 }}
3250 return _processExec.applySync(void 0, args);
3251 case "process.exec_fd_image_commit":
3252 if (typeof _processExecFdImageCommit === "undefined") {{
3253 throw new Error("agentos WASM process fd image commit bridge is unavailable");
3254 }}
3255 return _processExecFdImageCommit.applySync(void 0, args);
3256 case "child_process.write_stdin": {{
3257 if (typeof _childProcessStdinWrite === "undefined") {{
3258 throw new Error("agentos WASM child_process stdin bridge is unavailable");
3259 }}
3260 const [childId, chunk] = args;
3261 return _childProcessStdinWrite.applySync(void 0, [
3262 childId,
3263 __agentOSNormalizeBytes(chunk),
3264 ]);
3265 }}
3266 case "child_process.close_stdin":
3267 if (typeof _childProcessStdinClose === "undefined") {{
3268 throw new Error("agentos WASM child_process stdin-close bridge is unavailable");
3269 }}
3270 return _childProcessStdinClose.applySync(void 0, args);
3271 case "net.connect":
3272 if (typeof _netSocketConnectRaw === "undefined") {{
3273 throw new Error("agentos WASM net.connect bridge is unavailable");
3274 }}
3275 return _netSocketConnectRaw.applySync(void 0, args);
3276 case "net.bind_unix":
3277 if (typeof _netBindUnixRaw === "undefined") {{
3278 throw new Error("agentos WASM net.bind_unix bridge is unavailable");
3279 }}
3280 return _netBindUnixRaw.applySync(void 0, args);
3281 case "net.bind_connected_unix":
3282 if (typeof _netBindConnectedUnixRaw === "undefined") {{
3283 throw new Error("agentos WASM net.bind_connected_unix bridge is unavailable");
3284 }}
3285 return _netBindConnectedUnixRaw.applySync(void 0, args);
3286 case "net.reserve_tcp_port":
3287 if (typeof _netReserveTcpPortRaw === "undefined") {{
3288 throw new Error("agentos WASM net.reserve_tcp_port bridge is unavailable");
3289 }}
3290 return _netReserveTcpPortRaw.applySync(void 0, args);
3291 case "net.release_tcp_port":
3292 if (typeof _netReleaseTcpPortRaw === "undefined") {{
3293 throw new Error("agentos WASM net.release_tcp_port bridge is unavailable");
3294 }}
3295 return _netReleaseTcpPortRaw.applySync(void 0, args);
3296 case "net.listen":
3297 if (typeof _netServerListenRaw === "undefined") {{
3298 throw new Error("agentos WASM net.listen bridge is unavailable");
3299 }}
3300 return _netServerListenRaw.applySync(void 0, args);
3301 case "net.server_accept":
3302 if (typeof _netServerAcceptRaw === "undefined") {{
3303 throw new Error("agentos WASM net.server_accept bridge is unavailable");
3304 }}
3305 return _netServerAcceptRaw.applySync(void 0, args);
3306 case "net.server_close":
3307 if (typeof _netServerCloseSyncRaw === "undefined") {{
3308 throw new Error("agentos WASM net.server_close bridge is unavailable");
3309 }}
3310 return _netServerCloseSyncRaw.applySync(void 0, args);
3311 case "net.poll":
3312 if (typeof _netSocketPollRaw === "undefined") {{
3313 throw new Error("agentos WASM net.poll bridge is unavailable");
3314 }}
3315 return _netSocketPollRaw.applySync(void 0, args);
3316 case "net.socket_read":
3317 if (typeof _netSocketReadRaw === "undefined") {{
3318 throw new Error("agentos WASM net.socket_read bridge is unavailable");
3319 }}
3320 return _netSocketReadRaw.applySync(void 0, args);
3321 case "net.socket_wait_connect":
3322 if (typeof _netSocketWaitConnectSyncRaw === "undefined") {{
3323 throw new Error("agentos WASM net.socket_wait_connect bridge is unavailable");
3324 }}
3325 return _netSocketWaitConnectSyncRaw.applySync(void 0, args);
3326 case "net.write":
3327 if (typeof _netSocketWriteSyncRaw === "undefined") {{
3328 throw new Error("agentos WASM net.write bridge is unavailable");
3329 }}
3330 return _netSocketWriteSyncRaw.applySync(void 0, [
3331 args[0],
3332 __agentOSNormalizeBytes(args[1]),
3333 args[2],
3334 ]);
3335 case "net.destroy":
3336 if (typeof _netSocketDestroyRaw === "undefined") {{
3337 throw new Error("agentos WASM net.destroy bridge is unavailable");
3338 }}
3339 return _netSocketDestroyRaw.applySync(void 0, args);
3340 case "net.socket_upgrade_tls":
3341 if (typeof _netSocketUpgradeTlsRaw === "undefined") {{
3342 throw new Error("agentos WASM TLS-upgrade bridge is unavailable");
3343 }}
3344 return _netSocketUpgradeTlsRaw.applySync(void 0, args);
3345 case "dgram.createSocket":
3346 if (typeof _dgramSocketCreateRaw === "undefined") {{
3347 throw new Error("agentos WASM dgram.createSocket bridge is unavailable");
3348 }}
3349 return _dgramSocketCreateRaw.applySync(void 0, args);
3350 case "dgram.bind":
3351 if (typeof _dgramSocketBindRaw === "undefined") {{
3352 throw new Error("agentos WASM dgram.bind bridge is unavailable");
3353 }}
3354 return _dgramSocketBindRaw.applySync(void 0, args);
3355 case "dgram.send": {{
3356 if (typeof _dgramSocketSendRaw === "undefined") {{
3357 throw new Error("agentos WASM dgram.send bridge is unavailable");
3358 }}
3359 const [socketId, chunk, options = {{}}] = args;
3360 return _dgramSocketSendRaw.applySync(void 0, [
3361 socketId,
3362 __agentOSNormalizeBytes(chunk),
3363 options,
3364 ]);
3365 }}
3366 case "dgram.poll":
3367 if (typeof _dgramSocketRecvRaw === "undefined") {{
3368 throw new Error("agentos WASM dgram.poll bridge is unavailable");
3369 }}
3370 const event = _dgramSocketRecvRaw.applySync(void 0, args);
3371 if (event && event.type === "message") {{
3372 const data = __agentOSNormalizeBytes(event.data);
3373 if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) {{
3374 return {{
3375 ...event,
3376 data: {{ base64: data.toString("base64") }},
3377 }};
3378 }}
3379 }}
3380 if (
3381 event &&
3382 event.type === "message" &&
3383 event.data &&
3384 typeof event.data === "object" &&
3385 typeof event.data.base64 === "string"
3386 ) {{
3387 return {{
3388 ...event,
3389 data: {{ base64: event.data.base64 }},
3390 }};
3391 }}
3392 return event;
3393 case "dgram.close":
3394 if (typeof _dgramSocketCloseRaw === "undefined") {{
3395 throw new Error("agentos WASM dgram.close bridge is unavailable");
3396 }}
3397 return _dgramSocketCloseRaw.applySync(void 0, args);
3398 case "dgram.address":
3399 if (typeof _dgramSocketAddressRaw === "undefined") {{
3400 throw new Error("agentos WASM dgram.address bridge is unavailable");
3401 }}
3402 return _dgramSocketAddressRaw.applySync(void 0, args);
3403 case "dgram.setBufferSize":
3404 if (typeof _dgramSocketSetBufferSizeRaw === "undefined") {{
3405 throw new Error("agentos WASM dgram.setBufferSize bridge is unavailable");
3406 }}
3407 return _dgramSocketSetBufferSizeRaw.applySync(void 0, args);
3408 case "dgram.getBufferSize":
3409 if (typeof _dgramSocketGetBufferSizeRaw === "undefined") {{
3410 throw new Error("agentos WASM dgram.getBufferSize bridge is unavailable");
3411 }}
3412 return _dgramSocketGetBufferSizeRaw.applySync(void 0, args);
3413 case "dns.lookup":
3414 if (typeof _networkDnsLookupSyncRaw === "undefined") {{
3415 throw new Error("agentos WASM dns.lookup bridge is unavailable");
3416 }}
3417 return _networkDnsLookupSyncRaw.applySync(void 0, args);
3418 case "process.signal_state": {{
3419 if (typeof _processSignalState === "undefined") {{
3420 throw new Error("agentos WASM signal-state bridge is unavailable");
3421 }}
3422 const [signal, action = "default", maskJson = "[]", flags = 0] = args;
3423 return _processSignalState.applySyncPromise(void 0, [
3424 signal,
3425 action,
3426 maskJson,
3427 flags,
3428 ]);
3429 }}
3430 case "process.take_signal":
3431 if (typeof _processTakeSignal === "undefined") {{
3432 throw new Error("agentos WASM signal-drain bridge is unavailable");
3433 }}
3434 return _processTakeSignal.applySync(void 0, args);
3435 case "process.getpgid":
3436 case "process.getuid":
3437 case "process.getgid":
3438 case "process.geteuid":
3439 case "process.getegid":
3440 case "process.getresuid":
3441 case "process.getresgid":
3442 case "process.getgroups":
3443 case "process.getpwuid":
3444 case "process.getpwnam":
3445 case "process.getpwent":
3446 case "process.getgrgid":
3447 case "process.getgrnam":
3448 case "process.getgrent":
3449 case "process.setuid":
3450 case "process.seteuid":
3451 case "process.setreuid":
3452 case "process.setresuid":
3453 case "process.setgid":
3454 case "process.setegid":
3455 case "process.setregid":
3456 case "process.setresgid":
3457 case "process.setgroups":
3458 case "process.umask":
3459 case "fs.accessSync":
3460 case "fs.blockingIoTimeoutMsSync":
3461 case "fs.chmodForProcessSync":
3462 case "fs.chownSync":
3463 case "fs.collapseRangeSync":
3464 case "fs.fallocateSync":
3465 case "fs.fiemapSync":
3466 case "fs.getxattrSync":
3467 case "fs.insertRangeSync":
3468 case "fs.lchownSync":
3469 case "fs.linkFdSync":
3470 case "fs.listxattrSync":
3471 case "fs.mknodSync":
3472 case "fs.namedFifoPeerReadySync":
3473 case "fs.openTmpfileSync":
3474 case "fs.punchHoleSync":
3475 case "fs.remountSync":
3476 case "fs.removexattrSync":
3477 case "fs.renameAt2Sync":
3478 case "fs.setxattrSync":
3479 case "fs.statfsSync":
3480 case "fs.truncateForProcessSync":
3481 case "fs.zeroRangeSync":
3482 case "process.setpgid":
3483 case "process.waitpid_transition":
3484 case "process.itimer_real":
3485 case "process.fd_pipe":
3486 case "process.fd_open":
3487 case "process.path_open_at":
3488 case "process.path_mkdir_at":
3489 case "process.path_stat_at":
3490 case "process.path_utimes_at":
3491 case "process.path_chown_at":
3492 case "process.path_link_at":
3493 case "process.path_readlink_at":
3494 case "process.path_remove_dir_at":
3495 case "process.path_rename_at":
3496 case "process.path_symlink_at":
3497 case "process.path_unlink_at":
3498 case "process.fd_snapshot":
3499 case "process.fd_read":
3500 case "process.fd_pread":
3501 case "process.fd_write":
3502 case "process.fd_pwrite":
3503 case "process.fd_sync":
3504 case "process.fd_datasync":
3505 case "process.fd_readdir":
3506 case "process.fd_close":
3507 case "process.fd_stat":
3508 case "process.fd_filestat":
3509 case "process.fd_chmod":
3510 case "process.fd_chown":
3511 case "process.fd_truncate":
3512 case "process.fd_set_flags":
3513 case "process.fd_getfd":
3514 case "process.fd_setfd":
3515 case "process.fd_flock":
3516 case "process.fd_record_lock":
3517 case "process.fd_record_lock_cancel":
3518 case "process.fd_dup":
3519 case "process.fd_dup2":
3520 case "process.fd_dup_min":
3521 case "process.fd_seek":
3522 case "process.fd_chdir_path":
3523 case "process.fd_socketpair":
3524 case "process.fd_sendmsg_rights":
3525 case "process.fd_recvmsg_rights":
3526 case "process.fd_socket_shutdown":
3527 if (typeof _processWasmSyncRpc === "undefined") {{
3528 throw new Error("agentos WASM process-syscall bridge is unavailable");
3529 }}
3530 return _processWasmSyncRpc.applySync(void 0, [method, ...args]);
3531 default:
3532 throw new Error(`agentos WASM sync RPC method not implemented in V8 runtime: ${{method}}`);
3533 }}
3534 }},
3535 async call(method, args = []) {{
3536 return this.callSync(method, args);
3537 }},
3538 }};
3539 Object.defineProperty(globalThis, "__agentOSSyncRpc", {{
3540 configurable: true,
3541 enumerable: false,
3542 value: __agentOSWasmSyncRpc,
3543 writable: true,
3544 }});
3545}}
3546{warmup_emit}"#
3547 )
3548}
3549
3550fn max_cbor_byte_string_payload_bytes(encoded_limit: usize) -> usize {
3551 for payload_bytes in (encoded_limit.saturating_sub(9)..=encoded_limit).rev() {
3555 let header_bytes = if payload_bytes <= 23 {
3556 1
3557 } else if u8::try_from(payload_bytes).is_ok() {
3558 2
3559 } else if u16::try_from(payload_bytes).is_ok() {
3560 3
3561 } else if u32::try_from(payload_bytes).is_ok() {
3562 5
3563 } else {
3564 9
3565 };
3566 if payload_bytes
3567 .checked_add(header_bytes)
3568 .is_some_and(|encoded_bytes| encoded_bytes <= encoded_limit)
3569 {
3570 return payload_bytes;
3571 }
3572 }
3573 0
3574}
3575
3576fn wasm_warmup_metrics_emit_source(warmup_metrics: Option<&[u8]>) -> String {
3577 let warmup_metrics_json = warmup_metrics.map(|bytes| {
3578 serde_json::to_string(&String::from_utf8_lossy(bytes).to_string())
3579 .unwrap_or_else(|_| String::from("\"\""))
3580 });
3581 warmup_metrics_json
3582 .map(|metrics| {
3583 format!(
3584 "if (typeof process?.stderr?.write === \"function\") {{\n process.stderr.write({metrics});\n}}\n"
3585 )
3586 })
3587 .unwrap_or_default()
3588}
3589
3590fn wasm_internal_env_merge_source() -> &'static str {
3591 r#"if (typeof process !== "undefined") {
3592 process.env = { ...(process.env || {}), ...__agentOSWasmInternalEnv };
3593}
3594"#
3595}
3596
3597fn render_native_wasi_module_source() -> &'static str {
3598 static SOURCE: OnceLock<String> = OnceLock::new();
3599 SOURCE.get_or_init(|| {
3600 NODE_WASI_MODULE_SOURCE.replace(
3601 "__AGENTOS_WASM_SYNC_READ_LIMIT_BYTES__",
3602 &WASM_SYNC_READ_LIMIT_BYTES.to_string(),
3603 )
3604 })
3605}
3606
3607fn insert_wasm_runner_bootstrap(source: &str, bootstrap: &str) -> String {
3608 let mut insert_at = 0usize;
3609 let mut saw_import = false;
3610 for line in source.split_inclusive('\n') {
3611 let trimmed = line.trim_start();
3612 if trimmed.starts_with("import ") || (saw_import && trimmed.is_empty()) {
3613 insert_at += line.len();
3614 saw_import = saw_import || trimmed.starts_with("import ");
3615 continue;
3616 }
3617 break;
3618 }
3619
3620 format!(
3621 "{}{}{}",
3622 &source[..insert_at],
3623 bootstrap,
3624 &source[insert_at..]
3625 )
3626}
3627
3628struct WasmPrewarmOptions<'a> {
3629 frozen_time_ms: u128,
3630 timeout: Duration,
3631 runtime: &'a RuntimeContext,
3632}
3633
3634fn prewarm_wasm_path(
3635 import_cache: &NodeImportCache,
3636 javascript_engine: &mut JavascriptExecutionEngine,
3637 javascript_context_id: &str,
3638 resolved_module: &ResolvedWasmModule,
3639 request: &StartWasmExecutionRequest,
3640 options: WasmPrewarmOptions<'_>,
3641) -> Result<Option<Vec<u8>>, WasmExecutionError> {
3642 let debug_enabled = env_flag_enabled(&request.env, WASM_WARMUP_DEBUG_ENV);
3643 let marker_contents = warmup_marker_contents(resolved_module);
3644 let marker_path = warmup_marker_path(
3645 import_cache.prewarm_marker_dir(),
3646 "wasm-runner-prewarm",
3647 WASM_WARMUP_MARKER_VERSION,
3648 &marker_contents,
3649 );
3650
3651 if let Ok(metadata) = fs::metadata(&resolved_module.resolved_path) {
3652 if metadata.len() > MAX_SYNC_WASM_PREWARM_MODULE_BYTES {
3653 return Ok(warmup_metrics_line(
3654 debug_enabled,
3655 false,
3656 "skipped-large-module",
3657 import_cache,
3658 &resolved_module.specifier,
3659 ));
3660 }
3661 }
3662
3663 if marker_path.exists() {
3664 return Ok(warmup_metrics_line(
3665 debug_enabled,
3666 false,
3667 "cached",
3668 import_cache,
3669 &resolved_module.specifier,
3670 ));
3671 }
3672
3673 let mut prewarm_execution = start_wasm_javascript_execution(
3674 javascript_engine,
3675 options.runtime,
3676 import_cache,
3677 javascript_context_id,
3678 resolved_module,
3679 request,
3680 WasmJavascriptExecutionOptions {
3681 frozen_time_ms: options.frozen_time_ms,
3682 prewarm_only: true,
3683 warmup_metrics: None,
3684 defer_execute: false,
3685 },
3686 )
3687 .map_err(|error| match error {
3688 WasmExecutionError::Spawn(err) => WasmExecutionError::WarmupSpawn(err),
3689 other => other,
3690 })?;
3691 let mut internal_sync_rpc = WasmInternalSyncRpc {
3692 module_guest_paths: wasm_guest_module_paths(&resolved_module.specifier, &request.env),
3693 module_host_path: resolved_module.resolved_path.clone(),
3694 guest_cwd: wasm_guest_cwd(&request.env),
3695 host_cwd: request.cwd.clone(),
3696 sandbox_root: wasm_sandbox_root(&request.env),
3697 guest_path_mappings: wasm_guest_path_mappings(request),
3698 route_fs_through_sidecar: false,
3699 next_fd: 64,
3700 open_files: BTreeMap::new(),
3701 pending_events: VecDeque::new(),
3702 };
3703 let mut stdout = Vec::new();
3704 let mut stderr = Vec::new();
3705 let started = Instant::now();
3706
3707 loop {
3708 let poll_timeout = options.timeout.saturating_sub(started.elapsed());
3709 if poll_timeout.is_zero() {
3710 if let Err(error) = prewarm_execution.terminate() {
3711 eprintln!(
3712 "ERR_AGENTOS_WASM_PREWARM_TERMINATE: timed-out prewarm did not terminate cleanly: {error}"
3713 );
3714 }
3715 return Err(WasmExecutionError::WarmupTimeout(options.timeout));
3716 }
3717
3718 match prewarm_execution
3719 .poll_event_blocking(poll_timeout)
3720 .map_err(map_javascript_error)?
3721 {
3722 Some(JavascriptExecutionEvent::Stdout(chunk)) => {
3723 append_wasm_captured_output(&mut stdout, &chunk, "stdout")?;
3724 }
3725 Some(JavascriptExecutionEvent::Stderr(chunk)) => {
3726 append_wasm_captured_output(&mut stderr, &chunk, "stderr")?;
3727 }
3728 Some(JavascriptExecutionEvent::Exited(exit_code)) => {
3729 if exit_code != 0 {
3730 return Err(WasmExecutionError::WarmupFailed {
3731 exit_code,
3732 stderr: String::from_utf8_lossy(&stderr).into_owned(),
3733 });
3734 }
3735 break;
3736 }
3737 Some(JavascriptExecutionEvent::SyncRpcRequest(sync_request)) => {
3738 let handled = handle_internal_wasm_sync_rpc_request(
3739 &mut prewarm_execution,
3740 &mut internal_sync_rpc,
3741 &sync_request,
3742 )?;
3743 if !handled {
3744 return Err(WasmExecutionError::WarmupFailed {
3745 exit_code: 1,
3746 stderr: format!(
3747 "unexpected WebAssembly prewarm sync RPC request {} {} {:?}",
3748 sync_request.id, sync_request.method, sync_request.args
3749 ),
3750 });
3751 }
3752 }
3753 Some(JavascriptExecutionEvent::SignalState { .. })
3754 | Some(JavascriptExecutionEvent::LimitExceeded(_)) => {}
3755 None => {
3756 if let Err(error) = prewarm_execution.terminate() {
3757 eprintln!(
3758 "ERR_AGENTOS_WASM_PREWARM_TERMINATE: timed-out prewarm did not terminate cleanly: {error}"
3759 );
3760 }
3761 return Err(WasmExecutionError::WarmupTimeout(options.timeout));
3762 }
3763 }
3764 }
3765
3766 let _ = stdout;
3767 fs::write(&marker_path, marker_contents).map_err(WasmExecutionError::PrepareWarmPath)?;
3768 Ok(warmup_metrics_line(
3769 debug_enabled,
3770 true,
3771 "executed",
3772 import_cache,
3773 &resolved_module.specifier,
3774 ))
3775}
3776
3777async fn prewarm_wasm_path_async(
3778 import_cache: &NodeImportCache,
3779 javascript_engine: &mut JavascriptExecutionEngine,
3780 javascript_context_id: &str,
3781 resolved_module: &ResolvedWasmModule,
3782 request: &StartWasmExecutionRequest,
3783 options: WasmPrewarmOptions<'_>,
3784) -> Result<Option<Vec<u8>>, WasmExecutionError> {
3785 let debug_enabled = env_flag_enabled(&request.env, WASM_WARMUP_DEBUG_ENV);
3786 let marker_contents = warmup_marker_contents(resolved_module);
3787 let marker_path = warmup_marker_path(
3788 import_cache.prewarm_marker_dir(),
3789 "wasm-runner-prewarm",
3790 WASM_WARMUP_MARKER_VERSION,
3791 &marker_contents,
3792 );
3793
3794 if let Ok(metadata) = fs::metadata(&resolved_module.resolved_path) {
3795 if metadata.len() > MAX_SYNC_WASM_PREWARM_MODULE_BYTES {
3796 return Ok(warmup_metrics_line(
3797 debug_enabled,
3798 false,
3799 "skipped-large-module",
3800 import_cache,
3801 &resolved_module.specifier,
3802 ));
3803 }
3804 }
3805
3806 if marker_path.exists() {
3807 return Ok(warmup_metrics_line(
3808 debug_enabled,
3809 false,
3810 "cached",
3811 import_cache,
3812 &resolved_module.specifier,
3813 ));
3814 }
3815
3816 let mut prewarm_execution = start_wasm_javascript_execution(
3817 javascript_engine,
3818 options.runtime,
3819 import_cache,
3820 javascript_context_id,
3821 resolved_module,
3822 request,
3823 WasmJavascriptExecutionOptions {
3824 frozen_time_ms: options.frozen_time_ms,
3825 prewarm_only: true,
3826 warmup_metrics: None,
3827 defer_execute: false,
3828 },
3829 )
3830 .map_err(|error| match error {
3831 WasmExecutionError::Spawn(err) => WasmExecutionError::WarmupSpawn(err),
3832 other => other,
3833 })?;
3834 let mut internal_sync_rpc = WasmInternalSyncRpc {
3835 module_guest_paths: wasm_guest_module_paths(&resolved_module.specifier, &request.env),
3836 module_host_path: resolved_module.resolved_path.clone(),
3837 guest_cwd: wasm_guest_cwd(&request.env),
3838 host_cwd: request.cwd.clone(),
3839 sandbox_root: wasm_sandbox_root(&request.env),
3840 guest_path_mappings: wasm_guest_path_mappings(request),
3841 route_fs_through_sidecar: false,
3842 next_fd: 64,
3843 open_files: BTreeMap::new(),
3844 pending_events: VecDeque::new(),
3845 };
3846 let mut stdout = Vec::new();
3847 let mut stderr = Vec::new();
3848 let started = Instant::now();
3849
3850 loop {
3851 let poll_timeout = options.timeout.saturating_sub(started.elapsed());
3852 if poll_timeout.is_zero() {
3853 if let Err(error) = prewarm_execution.terminate() {
3854 eprintln!(
3855 "ERR_AGENTOS_WASM_PREWARM_TERMINATE: timed-out prewarm did not terminate cleanly: {error}"
3856 );
3857 }
3858 return Err(WasmExecutionError::WarmupTimeout(options.timeout));
3859 }
3860
3861 match prewarm_execution
3862 .poll_event(poll_timeout)
3863 .await
3864 .map_err(map_javascript_error)?
3865 {
3866 Some(JavascriptExecutionEvent::Stdout(chunk)) => {
3867 append_wasm_captured_output(&mut stdout, &chunk, "stdout")?;
3868 }
3869 Some(JavascriptExecutionEvent::Stderr(chunk)) => {
3870 append_wasm_captured_output(&mut stderr, &chunk, "stderr")?;
3871 }
3872 Some(JavascriptExecutionEvent::Exited(exit_code)) => {
3873 if exit_code != 0 {
3874 return Err(WasmExecutionError::WarmupFailed {
3875 exit_code,
3876 stderr: String::from_utf8_lossy(&stderr).into_owned(),
3877 });
3878 }
3879 break;
3880 }
3881 Some(JavascriptExecutionEvent::SyncRpcRequest(sync_request)) => {
3882 let handled = handle_internal_wasm_sync_rpc_request(
3883 &mut prewarm_execution,
3884 &mut internal_sync_rpc,
3885 &sync_request,
3886 )?;
3887 if !handled {
3888 return Err(WasmExecutionError::WarmupFailed {
3889 exit_code: 1,
3890 stderr: format!(
3891 "unexpected WebAssembly prewarm sync RPC request {} {} {:?}",
3892 sync_request.id, sync_request.method, sync_request.args
3893 ),
3894 });
3895 }
3896 }
3897 Some(JavascriptExecutionEvent::SignalState { .. })
3898 | Some(JavascriptExecutionEvent::LimitExceeded(_)) => {}
3899 None => {
3900 if let Err(error) = prewarm_execution.terminate() {
3901 eprintln!(
3902 "ERR_AGENTOS_WASM_PREWARM_TERMINATE: timed-out prewarm did not terminate cleanly: {error}"
3903 );
3904 }
3905 return Err(WasmExecutionError::WarmupTimeout(options.timeout));
3906 }
3907 }
3908 }
3909
3910 let _ = stdout;
3911 fs::write(&marker_path, marker_contents).map_err(WasmExecutionError::PrepareWarmPath)?;
3912 Ok(warmup_metrics_line(
3913 debug_enabled,
3914 true,
3915 "executed",
3916 import_cache,
3917 &resolved_module.specifier,
3918 ))
3919}
3920
3921fn wasm_guest_module_paths(specifier: &str, env: &BTreeMap<String, String>) -> Vec<String> {
3922 let mut candidates = Vec::new();
3923 candidates.push(specifier.to_owned());
3924
3925 if specifier.starts_with('/') {
3926 candidates.push(normalize_guest_path(specifier));
3927 candidates.extend(mapped_guest_paths_for_host_path(Path::new(specifier), env));
3928 } else if !specifier.starts_with("file:") {
3929 let guest_cwd = wasm_guest_cwd(env);
3930 candidates.push(join_guest_path(&guest_cwd, specifier));
3931 }
3932
3933 candidates.sort();
3934 candidates.dedup();
3935 candidates
3936}
3937
3938fn wasm_guest_cwd(env: &BTreeMap<String, String>) -> String {
3939 env.get("PWD")
3940 .filter(|value| value.starts_with('/'))
3941 .cloned()
3942 .or_else(|| {
3943 env.get("HOME")
3944 .filter(|value| value.starts_with('/'))
3945 .cloned()
3946 })
3947 .unwrap_or_else(|| String::from(DEFAULT_WASM_GUEST_HOME))
3948}
3949
3950fn mapped_guest_paths_for_host_path(
3951 host_path: &Path,
3952 env: &BTreeMap<String, String>,
3953) -> Vec<String> {
3954 if !host_path.is_absolute() {
3955 return Vec::new();
3956 }
3957
3958 let mappings = env
3959 .get("AGENTOS_GUEST_PATH_MAPPINGS")
3960 .and_then(|value| serde_json::from_str::<Vec<Value>>(value).ok())
3961 .unwrap_or_default();
3962
3963 let mut candidates = Vec::new();
3964 for mapping in mappings {
3965 let Some(guest_root) = mapping.get("guestPath").and_then(Value::as_str) else {
3966 continue;
3967 };
3968 let Some(host_root) = mapping.get("hostPath").and_then(Value::as_str) else {
3969 continue;
3970 };
3971 let host_root = Path::new(host_root);
3972
3973 if let Ok(suffix) = host_path.strip_prefix(host_root) {
3974 candidates.push(join_guest_path(
3975 guest_root,
3976 &suffix.to_string_lossy().replace('\\', "/"),
3977 ));
3978 continue;
3979 }
3980
3981 let Ok(real_host_root) = host_root.canonicalize() else {
3982 continue;
3983 };
3984 if let Ok(suffix) = host_path.strip_prefix(&real_host_root) {
3985 candidates.push(join_guest_path(
3986 guest_root,
3987 &suffix.to_string_lossy().replace('\\', "/"),
3988 ));
3989 }
3990 }
3991
3992 candidates
3993}
3994
3995fn normalize_guest_path(path: &str) -> String {
3996 join_guest_path("/", path)
3997}
3998
3999fn join_guest_path(base: &str, suffix: &str) -> String {
4000 let mut segments = Vec::new();
4001 let mut absolute = false;
4002 for part in [base, suffix] {
4003 if part.starts_with('/') {
4004 absolute = true;
4005 }
4006 for segment in part.split('/') {
4007 match segment {
4008 "" | "." => {}
4009 ".." => {
4010 let _ = segments.pop();
4011 }
4012 value => segments.push(value),
4013 }
4014 }
4015 }
4016
4017 let joined = segments.join("/");
4018 if absolute {
4019 if joined.is_empty() {
4020 String::from("/")
4021 } else {
4022 format!("/{joined}")
4023 }
4024 } else if joined.is_empty() {
4025 String::from(".")
4026 } else {
4027 joined
4028 }
4029}
4030
4031fn module_path(
4032 context: &WasmContext,
4033 request: &StartWasmExecutionRequest,
4034) -> Result<String, WasmExecutionError> {
4035 match context.module_path.as_deref() {
4036 Some(module_path) => Ok(module_path.to_owned()),
4037 None => request
4038 .argv
4039 .first()
4040 .cloned()
4041 .ok_or(WasmExecutionError::MissingModulePath),
4042 }
4043}
4044
4045fn guest_visible_wasm_env(env: &BTreeMap<String, String>) -> BTreeMap<String, String> {
4046 let mut guest_env = env
4047 .iter()
4048 .filter(|(key, _)| !is_internal_wasm_guest_env_key(key))
4049 .map(|(key, value)| (key.clone(), value.clone()))
4050 .collect::<BTreeMap<_, _>>();
4051 let guest_cwd = wasm_guest_cwd(env);
4052 let guest_home = guest_env
4053 .get("HOME")
4054 .filter(|value| value.starts_with('/'))
4055 .cloned()
4056 .unwrap_or_else(|| guest_cwd.clone());
4057
4058 guest_env
4059 .entry(String::from("HOME"))
4060 .or_insert_with(|| guest_home.clone());
4061 guest_env
4062 .entry(String::from("PWD"))
4063 .or_insert_with(|| guest_cwd);
4064 guest_env
4065 .entry(String::from("USER"))
4066 .or_insert_with(|| String::from(DEFAULT_WASM_GUEST_USER));
4067 guest_env
4068 .entry(String::from("LOGNAME"))
4069 .or_insert_with(|| String::from(DEFAULT_WASM_GUEST_USER));
4070 guest_env
4071 .entry(String::from("SHELL"))
4072 .or_insert_with(|| String::from(DEFAULT_WASM_GUEST_SHELL));
4073 guest_env
4074 .entry(String::from("PATH"))
4075 .or_insert_with(|| String::from(DEFAULT_WASM_GUEST_PATH));
4076 guest_env
4077 .entry(String::from("TMPDIR"))
4078 .or_insert_with(|| String::from("/tmp"));
4079 guest_env
4080}
4081
4082fn wasm_guest_path_mappings(request: &StartWasmExecutionRequest) -> Vec<WasmGuestPathMapping> {
4083 let guest_cwd = wasm_guest_cwd(&request.env);
4084 let mut mappings = request
4085 .env
4086 .get("AGENTOS_GUEST_PATH_MAPPINGS")
4087 .and_then(|value| serde_json::from_str::<Vec<Value>>(value).ok())
4088 .unwrap_or_default()
4089 .into_iter()
4090 .filter_map(|mapping| {
4091 Some(WasmGuestPathMapping {
4092 guest_path: mapping.get("guestPath")?.as_str()?.to_owned(),
4093 host_path: PathBuf::from(mapping.get("hostPath")?.as_str()?),
4094 read_only: mapping
4095 .get("readOnly")
4096 .and_then(Value::as_bool)
4097 .unwrap_or(false),
4098 })
4099 })
4100 .collect::<Vec<_>>();
4101
4102 if let Some(sandbox_root) = wasm_sandbox_root(&request.env) {
4103 push_wasm_guest_path_mapping(&mut mappings, String::from("/"), sandbox_root);
4104 }
4105 push_wasm_guest_path_mapping(&mut mappings, guest_cwd, request.cwd.clone());
4106 push_wasm_guest_path_mapping(
4107 &mut mappings,
4108 String::from("/workspace"),
4109 request.cwd.clone(),
4110 );
4111 mappings.sort_by_key(|mapping| std::cmp::Reverse(mapping.guest_path.len()));
4112 mappings
4113}
4114
4115fn wasm_sandbox_root(env: &BTreeMap<String, String>) -> Option<PathBuf> {
4116 env.get(WASM_SANDBOX_ROOT_ENV)
4117 .filter(|value| Path::new(value.as_str()).is_absolute())
4118 .map(PathBuf::from)
4119}
4120
4121fn push_wasm_guest_path_mapping(
4122 mappings: &mut Vec<WasmGuestPathMapping>,
4123 guest_path: String,
4124 host_path: PathBuf,
4125) {
4126 if guest_path.is_empty() || !guest_path.starts_with('/') {
4127 return;
4128 }
4129 if mappings
4130 .iter()
4131 .any(|mapping| mapping.guest_path == guest_path)
4132 {
4133 return;
4134 }
4135 mappings.push(WasmGuestPathMapping {
4136 guest_path,
4137 host_path,
4138 read_only: false,
4139 });
4140}
4141
4142fn encode_wasm_guest_path_mappings(mappings: &[WasmGuestPathMapping]) -> String {
4143 serde_json::to_string(
4144 &mappings
4145 .iter()
4146 .map(|mapping| {
4147 json!({
4148 "guestPath": mapping.guest_path,
4149 "hostPath": mapping.host_path.to_string_lossy(),
4150 "readOnly": mapping.read_only,
4151 })
4152 })
4153 .collect::<Vec<_>>(),
4154 )
4155 .unwrap_or_else(|_| String::from("[]"))
4156}
4157
4158fn is_internal_wasm_guest_env_key(key: &str) -> bool {
4159 key.starts_with("AGENTOS_") || key.starts_with("NODE_SYNC_RPC_")
4160}
4161
4162fn warmup_marker_contents(resolved_module: &ResolvedWasmModule) -> String {
4163 let module_fingerprint = file_fingerprint(&resolved_module.resolved_path);
4164
4165 [
4166 env!("CARGO_PKG_NAME").to_string(),
4167 env!("CARGO_PKG_VERSION").to_string(),
4168 WASM_WARMUP_MARKER_VERSION.to_string(),
4169 resolved_module.specifier.clone(),
4170 resolved_module.resolved_path.display().to_string(),
4171 module_fingerprint,
4172 ]
4173 .join("\n")
4174}
4175
4176fn warmup_metrics_line(
4177 debug_enabled: bool,
4178 executed: bool,
4179 reason: &str,
4180 import_cache: &NodeImportCache,
4181 module_specifier: &str,
4182) -> Option<Vec<u8>> {
4183 if !debug_enabled {
4184 return None;
4185 }
4186
4187 Some(
4188 format!(
4189 "{WASM_WARMUP_METRICS_PREFIX}{{\"executed\":{},\"reason\":{},\"modulePath\":{},\"compileCacheDir\":{}}}\n",
4190 if executed { "true" } else { "false" },
4191 encode_json_string(reason),
4192 encode_json_string(module_specifier),
4193 encode_json_string(&import_cache.shared_compile_cache_dir().display().to_string()),
4194 )
4195 .into_bytes(),
4196 )
4197}
4198
4199fn resolve_wasm_execution_timeout(
4200 request: &StartWasmExecutionRequest,
4201) -> Result<Option<Duration>, WasmExecutionError> {
4202 Ok(request.limits.max_fuel.map(Duration::from_millis))
4215}
4216
4217fn resolve_wasm_stack_limit_bytes(
4221 request: &StartWasmExecutionRequest,
4222) -> Result<Option<u64>, WasmExecutionError> {
4223 match request.limits.max_stack_bytes {
4224 Some(0) => Err(WasmExecutionError::InvalidLimit(String::from(
4225 "wasm max stack bytes must be greater than zero",
4226 ))),
4227 Some(limit) => Err(WasmExecutionError::InvalidLimit(format!(
4228 "configured wasm max stack byte limit {limit} cannot be enforced by the V8 runner"
4229 ))),
4230 None => Ok(None),
4231 }
4232}
4233
4234fn resolve_wasm_prewarm_timeout(
4235 request: &StartWasmExecutionRequest,
4236) -> Result<Duration, WasmExecutionError> {
4237 Ok(Duration::from_millis(
4238 request
4239 .limits
4240 .prewarm_timeout_ms
4241 .filter(|value| *value > 0)
4242 .unwrap_or(DEFAULT_WASM_PREWARM_TIMEOUT_MS),
4243 ))
4244}
4245
4246fn resolve_wasm_module(
4247 context: &WasmContext,
4248 request: &StartWasmExecutionRequest,
4249) -> Result<ResolvedWasmModule, WasmExecutionError> {
4250 let specifier = module_path(context, request)?;
4251 let resolved_path = resolved_module_path(&specifier, &request.cwd);
4252 Ok(ResolvedWasmModule {
4253 specifier,
4254 resolved_path,
4255 })
4256}
4257
4258fn resolved_module_path(specifier: &str, cwd: &Path) -> PathBuf {
4259 resolve_path_like_specifier(cwd, specifier)
4260 .map(|path| path.canonicalize().unwrap_or(path))
4261 .unwrap_or_else(|| PathBuf::from(specifier))
4262}
4263
4264fn verify_wasm_module_header(
4277 resolved_module: &ResolvedWasmModule,
4278) -> Result<(), WasmExecutionError> {
4279 let resolved_path = &resolved_module.resolved_path;
4280 let metadata = fs::metadata(resolved_path).map_err(|error| {
4281 WasmExecutionError::InvalidModule(format!(
4282 "failed to stat {}: {error}",
4283 resolved_path.display()
4284 ))
4285 })?;
4286 if metadata.len() > MAX_WASM_MODULE_FILE_BYTES {
4287 return Err(WasmExecutionError::InvalidModule(format!(
4288 "module file size of {} bytes exceeds the configured parser cap of {} bytes",
4289 metadata.len(),
4290 MAX_WASM_MODULE_FILE_BYTES
4291 )));
4292 }
4293
4294 let mut file = fs::File::open(resolved_path).map_err(|error| {
4295 WasmExecutionError::InvalidModule(format!(
4296 "failed to open {}: {error}",
4297 resolved_path.display()
4298 ))
4299 })?;
4300 let mut header = [0u8; 4];
4301 let bytes_read = file.read(&mut header).map_err(|error| {
4302 WasmExecutionError::InvalidModule(format!(
4303 "failed to read header of {}: {error}",
4304 resolved_path.display()
4305 ))
4306 })?;
4307 let header = &header[..bytes_read];
4308 if header == b"\0asm" {
4309 return Ok(());
4310 }
4311
4312 let shell_shim = header.len() >= 2 && &header[..2] == b"#!";
4313 if let Some(format) = detect_native_binary_format(header) {
4314 return Err(WasmExecutionError::NativeBinaryNotSupported {
4315 path: resolved_path.clone(),
4316 header: header.to_vec(),
4317 format,
4318 });
4319 }
4320
4321 Err(WasmExecutionError::NonWasmBinary {
4322 path: resolved_path.clone(),
4323 header: header.to_vec(),
4324 shell_shim,
4325 })
4326}
4327
4328fn detect_native_binary_format(header: &[u8]) -> Option<NativeBinaryFormat> {
4329 if header.len() >= 4 && &header[..4] == b"\x7fELF" {
4330 return Some(NativeBinaryFormat::Elf);
4331 }
4332
4333 if header.starts_with(b"MZ") {
4334 return Some(NativeBinaryFormat::PeCoff);
4335 }
4336
4337 const MACH_O_MAGICS: [&[u8; 4]; 6] = [
4338 b"\xfe\xed\xfa\xce",
4339 b"\xce\xfa\xed\xfe",
4340 b"\xfe\xed\xfa\xcf",
4341 b"\xcf\xfa\xed\xfe",
4342 b"\xca\xfe\xba\xbe",
4343 b"\xbe\xba\xfe\xca",
4344 ];
4345 if header.len() >= 4 && MACH_O_MAGICS.iter().any(|magic| header[..4] == magic[..]) {
4346 return Some(NativeBinaryFormat::MachO);
4347 }
4348
4349 None
4350}
4351
4352fn warmup_guest_argv(
4353 resolved_module: &ResolvedWasmModule,
4354 request: &StartWasmExecutionRequest,
4355) -> Vec<String> {
4356 if !request.argv.is_empty() {
4357 return request.argv.clone();
4358 }
4359
4360 vec![resolved_module.specifier.clone()]
4361}
4362
4363fn wasm_memory_limit_bytes(
4364 request: &StartWasmExecutionRequest,
4365) -> Result<Option<u64>, WasmExecutionError> {
4366 Ok(request.limits.max_memory_bytes)
4367}
4368
4369fn wasm_stack_limit_bytes(
4370 request: &StartWasmExecutionRequest,
4371) -> Result<Option<u64>, WasmExecutionError> {
4372 resolve_wasm_stack_limit_bytes(request)
4373}
4374
4375#[cfg(test)]
4376fn wasm_memory_limit_pages(memory_limit_bytes: u64) -> Result<u32, WasmExecutionError> {
4377 let pages = memory_limit_bytes / WASM_PAGE_BYTES;
4378 u32::try_from(pages).map_err(|_| {
4379 WasmExecutionError::InvalidLimit(format!(
4380 "{WASM_MAX_MEMORY_BYTES_ENV}={memory_limit_bytes}: exceeds V8's wasm page limit range"
4381 ))
4382 })
4383}
4384
4385fn wasm_runner_heap_limit_mb(request: &StartWasmExecutionRequest) -> u32 {
4388 request
4389 .limits
4390 .runner_heap_limit_mb
4391 .filter(|value| *value > 0)
4392 .unwrap_or(DEFAULT_WASM_RUNNER_HEAP_LIMIT_MB)
4393}
4394
4395fn v8_warm_worker_count() -> usize {
4396 std::env::var("AGENTOS_V8_WARM_ISOLATES")
4397 .ok()
4398 .and_then(|value| value.parse::<usize>().ok())
4399 .unwrap_or(4)
4400}
4401
4402fn validate_module_limits(
4403 resolved_module: &ResolvedWasmModule,
4404 request: &StartWasmExecutionRequest,
4405) -> Result<(), WasmExecutionError> {
4406 let _stack_limit = resolve_wasm_stack_limit_bytes(request)?;
4409
4410 let Some(memory_limit) = wasm_memory_limit_bytes(request)? else {
4411 return Ok(());
4412 };
4413
4414 let resolved_path = &resolved_module.resolved_path;
4415 let metadata = fs::metadata(resolved_path).map_err(|error| {
4416 WasmExecutionError::InvalidModule(format!(
4417 "failed to stat {}: {error}",
4418 resolved_path.display()
4419 ))
4420 })?;
4421 if metadata.len() > MAX_WASM_MODULE_FILE_BYTES {
4422 return Err(WasmExecutionError::InvalidModule(format!(
4423 "module file size of {} bytes exceeds the configured parser cap of {} bytes",
4424 metadata.len(),
4425 MAX_WASM_MODULE_FILE_BYTES
4426 )));
4427 }
4428 let bytes = fs::read(resolved_path).map_err(|error| {
4429 WasmExecutionError::InvalidModule(format!(
4430 "failed to read {}: {error}",
4431 resolved_path.display()
4432 ))
4433 })?;
4434 let module_limits = extract_wasm_module_limits(&bytes)?;
4435
4436 if module_limits.imports_memory {
4437 return Err(WasmExecutionError::InvalidModule(String::from(
4438 "configured WebAssembly memory limit does not support imported memories yet",
4439 )));
4440 }
4441
4442 if let Some(initial_bytes) = module_limits.initial_memory_bytes {
4443 if initial_bytes > memory_limit {
4444 warn_limit_exhausted(
4445 TrackedLimit::WasmMemoryBytes,
4446 usize_saturating_from_u64(initial_bytes),
4447 usize_saturating_from_u64(memory_limit),
4448 );
4449 return Err(WasmExecutionError::InvalidModule(format!(
4450 "initial WebAssembly memory of {initial_bytes} bytes exceeds the configured limit of {memory_limit} bytes"
4451 )));
4452 }
4453 }
4454
4455 match module_limits.maximum_memory_bytes {
4456 Some(maximum_bytes) if maximum_bytes > memory_limit => {
4457 warn_limit_exhausted(
4458 TrackedLimit::WasmMemoryBytes,
4459 usize_saturating_from_u64(maximum_bytes),
4460 usize_saturating_from_u64(memory_limit),
4461 );
4462 Err(WasmExecutionError::InvalidModule(format!(
4463 "WebAssembly memory maximum of {maximum_bytes} bytes exceeds the configured limit of {memory_limit} bytes"
4464 )))
4465 }
4466 Some(_) => Ok(()),
4467 None => Ok(()),
4468 }
4469}
4470
4471fn duration_millis_saturating_usize(duration: Duration) -> usize {
4472 usize::try_from(duration.as_millis()).unwrap_or(usize::MAX)
4473}
4474
4475fn usize_saturating_from_u64(value: u64) -> usize {
4476 usize::try_from(value).unwrap_or(usize::MAX)
4477}
4478
4479#[derive(Debug, Default)]
4480struct WasmModuleLimits {
4481 imports_memory: bool,
4482 initial_memory_bytes: Option<u64>,
4483 maximum_memory_bytes: Option<u64>,
4484}
4485
4486fn extract_wasm_module_limits(bytes: &[u8]) -> Result<WasmModuleLimits, WasmExecutionError> {
4487 if bytes.len() < 8 || &bytes[..4] != b"\0asm" {
4488 return Err(WasmExecutionError::InvalidModule(String::from(
4489 "module is not a valid WebAssembly binary",
4490 )));
4491 }
4492
4493 let mut offset = 8;
4494 let mut limits = WasmModuleLimits::default();
4495
4496 while offset < bytes.len() {
4497 let section_id = bytes[offset];
4498 offset += 1;
4499 let section_size = read_varuint_usize(bytes, &mut offset, "section size")?;
4500 let section_end = offset.checked_add(section_size).ok_or_else(|| {
4501 WasmExecutionError::InvalidModule(String::from("section size overflow"))
4502 })?;
4503 if section_end > bytes.len() {
4504 return Err(WasmExecutionError::InvalidModule(String::from(
4505 "section extends past end of module",
4506 )));
4507 }
4508
4509 match section_id {
4510 2 => {
4511 let mut cursor = offset;
4512 let import_count = read_varuint_usize(bytes, &mut cursor, "import count")?;
4513 if import_count > MAX_WASM_IMPORT_SECTION_ENTRIES {
4514 return Err(WasmExecutionError::InvalidModule(format!(
4515 "import section contains {import_count} entries, which exceeds the parser cap of {MAX_WASM_IMPORT_SECTION_ENTRIES}"
4516 )));
4517 }
4518 for _ in 0..import_count {
4519 skip_name(bytes, &mut cursor)?;
4520 skip_name(bytes, &mut cursor)?;
4521 let kind = read_byte(bytes, &mut cursor)?;
4522 match kind {
4523 0x02 => {
4524 let _ = read_memory_limits(bytes, &mut cursor)?;
4525 limits.imports_memory = true;
4526 }
4527 0x00 => {
4528 let _ = read_varuint(bytes, &mut cursor)?;
4529 }
4530 0x01 => {
4531 skip_table_type(bytes, &mut cursor)?;
4532 }
4533 0x03 => {
4534 let _ = read_byte(bytes, &mut cursor)?;
4535 let _ = read_byte(bytes, &mut cursor)?;
4536 }
4537 other => {
4538 return Err(WasmExecutionError::InvalidModule(format!(
4539 "unsupported import kind {other}"
4540 )));
4541 }
4542 }
4543 }
4544 }
4545 5 => {
4546 let mut cursor = offset;
4547 let memory_count = read_varuint_usize(bytes, &mut cursor, "memory count")?;
4548 if memory_count > MAX_WASM_MEMORY_SECTION_ENTRIES {
4549 return Err(WasmExecutionError::InvalidModule(format!(
4550 "memory section contains {memory_count} entries, which exceeds the parser cap of {MAX_WASM_MEMORY_SECTION_ENTRIES}"
4551 )));
4552 }
4553 if memory_count > 0 {
4554 let (initial_pages, maximum_pages) = read_memory_limits(bytes, &mut cursor)?;
4555 limits.initial_memory_bytes =
4556 Some(initial_pages.saturating_mul(WASM_PAGE_BYTES));
4557 limits.maximum_memory_bytes =
4558 maximum_pages.map(|pages| pages.saturating_mul(WASM_PAGE_BYTES));
4559 }
4560 }
4561 _ => {}
4562 }
4563
4564 offset = section_end;
4565 }
4566
4567 Ok(limits)
4568}
4569
4570fn read_memory_limits(
4571 bytes: &[u8],
4572 offset: &mut usize,
4573) -> Result<(u64, Option<u64>), WasmExecutionError> {
4574 let flags = read_varuint(bytes, offset)?;
4575 let initial = read_varuint(bytes, offset)?;
4576 let maximum = if flags & 0x01 != 0 {
4577 Some(read_varuint(bytes, offset)?)
4578 } else {
4579 None
4580 };
4581 Ok((initial, maximum))
4582}
4583
4584fn skip_name(bytes: &[u8], offset: &mut usize) -> Result<(), WasmExecutionError> {
4585 let length = read_varuint_usize(bytes, offset, "name length")?;
4586 let end = offset
4587 .checked_add(length)
4588 .ok_or_else(|| WasmExecutionError::InvalidModule(String::from("name length overflow")))?;
4589 if end > bytes.len() {
4590 return Err(WasmExecutionError::InvalidModule(String::from(
4591 "name extends past end of module",
4592 )));
4593 }
4594 *offset = end;
4595 Ok(())
4596}
4597
4598fn skip_table_type(bytes: &[u8], offset: &mut usize) -> Result<(), WasmExecutionError> {
4599 let _ = read_byte(bytes, offset)?;
4600 let flags = read_varuint(bytes, offset)?;
4601 let _ = read_varuint(bytes, offset)?;
4602 if flags & 0x01 != 0 {
4603 let _ = read_varuint(bytes, offset)?;
4604 }
4605 Ok(())
4606}
4607
4608fn read_byte(bytes: &[u8], offset: &mut usize) -> Result<u8, WasmExecutionError> {
4609 let Some(byte) = bytes.get(*offset).copied() else {
4610 return Err(WasmExecutionError::InvalidModule(String::from(
4611 "unexpected end of module",
4612 )));
4613 };
4614 *offset += 1;
4615 Ok(byte)
4616}
4617
4618fn read_varuint(bytes: &[u8], offset: &mut usize) -> Result<u64, WasmExecutionError> {
4619 let mut shift = 0_u32;
4620 let mut value = 0_u64;
4621 let mut encoded_bytes = 0_usize;
4622
4623 loop {
4624 let byte = read_byte(bytes, offset)?;
4625 encoded_bytes += 1;
4626 if encoded_bytes > MAX_WASM_VARUINT_BYTES {
4627 return Err(WasmExecutionError::InvalidModule(format!(
4628 "varuint exceeds the parser cap of {MAX_WASM_VARUINT_BYTES} bytes"
4629 )));
4630 }
4631 value |= u64::from(byte & 0x7f) << shift;
4632 if byte & 0x80 == 0 {
4633 return Ok(value);
4634 }
4635 if encoded_bytes == MAX_WASM_VARUINT_BYTES {
4636 return Err(WasmExecutionError::InvalidModule(format!(
4637 "varuint exceeds the parser cap of {MAX_WASM_VARUINT_BYTES} bytes"
4638 )));
4639 }
4640 shift = shift.saturating_add(7);
4641 if shift >= 64 {
4642 return Err(WasmExecutionError::InvalidModule(String::from(
4643 "varuint is too large",
4644 )));
4645 }
4646 }
4647}
4648
4649fn read_varuint_usize(
4650 bytes: &[u8],
4651 offset: &mut usize,
4652 label: &str,
4653) -> Result<usize, WasmExecutionError> {
4654 let value = read_varuint(bytes, offset)?;
4655 usize::try_from(value).map_err(|_| {
4656 WasmExecutionError::InvalidModule(format!(
4657 "{label} of {value} exceeds platform usize range"
4658 ))
4659 })
4660}
4661
4662impl From<NodeSignalDispositionAction> for WasmSignalDispositionAction {
4663 fn from(value: NodeSignalDispositionAction) -> Self {
4664 match value {
4665 NodeSignalDispositionAction::Default => Self::Default,
4666 NodeSignalDispositionAction::Ignore => Self::Ignore,
4667 NodeSignalDispositionAction::User => Self::User,
4668 }
4669 }
4670}
4671
4672impl From<NodeSignalHandlerRegistration> for WasmSignalHandlerRegistration {
4673 fn from(value: NodeSignalHandlerRegistration) -> Self {
4674 Self {
4675 action: value.action.into(),
4676 mask: value.mask,
4677 flags: value.flags,
4678 }
4679 }
4680}
4681
4682fn resolve_path_like_specifier(cwd: &Path, specifier: &str) -> Option<PathBuf> {
4683 if specifier.starts_with("file://") {
4684 return Some(PathBuf::from(specifier.trim_start_matches("file://")));
4685 }
4686 if specifier.starts_with("file:") {
4687 return Some(PathBuf::from(specifier.trim_start_matches("file:")));
4688 }
4689 if specifier.starts_with('/') {
4690 return Some(PathBuf::from(specifier));
4691 }
4692 if specifier.starts_with("./") || specifier.starts_with("../") {
4693 return Some(cwd.join(specifier));
4694 }
4695
4696 None
4697}
4698
4699#[cfg(test)]
4700mod tests {
4701 use super::{
4702 build_wasm_internal_env, build_wasm_runner_bootstrap, max_cbor_byte_string_payload_bytes,
4703 open_wasm_guest_file, resolve_wasm_execution_timeout, resolve_wasm_prewarm_timeout,
4704 resolve_wasm_stack_limit_bytes, resolved_module_path, translate_wasm_guest_path,
4705 translate_wasm_host_symlink_target, wasm_guest_module_paths, wasm_host_path_is_read_only,
4706 wasm_memory_limit_bytes, wasm_memory_limit_pages, wasm_mutation_touches_read_only_mapping,
4707 wasm_read_only_filesystem_error, wasm_runner_base_env, wasm_runner_heap_limit_mb,
4708 wasm_runner_javascript_limits, wasm_sandbox_root, wasm_snapshot_runner_base_env,
4709 wasm_sync_read_length, wasm_sync_rpc_error_code,
4710 wasm_sync_rpc_method_routes_through_sidecar_kernel, CreateWasmContextRequest,
4711 GuestRuntimeConfig, JavascriptSyncRpcRequest, ResolvedWasmModule,
4712 StartWasmExecutionRequest, Value, WasmExecutionEngine, WasmExecutionError,
4713 WasmExecutionLimits, WasmInternalSyncRpc, WasmPermissionTier,
4714 DEFAULT_WASM_PREWARM_TIMEOUT_MS, DEFAULT_WASM_RUNNER_HEAP_LIMIT_MB,
4715 NODE_WASI_MODULE_SOURCE, WASM_CAPTURED_OUTPUT_LIMIT_BYTES,
4716 WASM_INTERNAL_MAX_STACK_BYTES_ENV, WASM_MAX_FUEL_ENV, WASM_MAX_MEMORY_BYTES_ENV,
4717 WASM_MAX_MODULE_FILE_BYTES_ENV, WASM_MAX_SPAWN_FILE_ACTIONS_ENV,
4718 WASM_MAX_SPAWN_FILE_ACTION_BYTES_ENV, WASM_MAX_STACK_BYTES_ENV, WASM_PAGE_BYTES,
4719 WASM_PROCESS_SYNC_RPC_RESPONSE_BYTES, WASM_SANDBOX_ROOT_ENV,
4720 WASM_SIDECAR_ROUTED_FS_SYNC_METHODS, WASM_SYNC_READ_LIMIT_BYTES,
4721 };
4722 use std::collections::{BTreeMap, BTreeSet, VecDeque};
4723 use std::fs;
4724 use std::os::unix::fs::symlink;
4725 use std::path::{Path, PathBuf};
4726 use std::time::Duration;
4727 use tempfile::tempdir;
4728
4729 #[test]
4730 fn wasm_runner_forwards_vm_reactor_limits_to_javascript() {
4731 let limits = WasmExecutionLimits {
4732 reactor_work_quantum: Some(17),
4733 bridge_call_timeout_ms: Some(12_345),
4734 ..WasmExecutionLimits::default()
4735 };
4736 let javascript = wasm_runner_javascript_limits(&limits, 192);
4737
4738 assert_eq!(javascript.v8_heap_limit_mb, Some(192));
4739 assert_eq!(javascript.reactor_work_quantum, Some(17));
4740 assert_eq!(javascript.bridge_call_timeout_ms, Some(12_345));
4741 }
4742
4743 #[test]
4744 fn wasm_process_reads_fit_the_encoded_bridge_response_budget() {
4745 let raw_limit = max_cbor_byte_string_payload_bytes(WASM_PROCESS_SYNC_RPC_RESPONSE_BYTES);
4746 assert_eq!(raw_limit, 256 * 1024 - 5);
4747 assert_eq!(
4748 agentos_bridge::bridge_contract()
4749 .response_max_bytes
4750 .get("_processWasmSyncRpc")
4751 .copied(),
4752 Some(WASM_PROCESS_SYNC_RPC_RESPONSE_BYTES)
4753 );
4754
4755 let bootstrap = build_wasm_runner_bootstrap(&BTreeMap::new(), None);
4756 assert!(bootstrap.contains(&format!(
4757 "const __agentOSWasmSyncRpcReadPayloadBytes = {raw_limit};"
4758 )));
4759 let runner = include_str!("../assets/runners/wasm-runner.mjs");
4760 assert!(runner.contains("boundedWasmSyncRpcReadLength("));
4761 assert!(runner.contains("callSyncRpc('process.fd_read'"));
4762 assert!(runner.contains("callSyncRpc('process.fd_pread'"));
4763 }
4764
4765 #[test]
4766 fn dispose_context_reclaims_wasm_and_nested_javascript_metadata() {
4767 let mut engine = WasmExecutionEngine::default();
4768 let baseline = (
4769 engine.context_count_for_test(),
4770 engine.javascript_context_count_for_test(),
4771 );
4772 let context = engine.create_context(CreateWasmContextRequest {
4773 vm_id: String::from("vm-wasm-context-dispose"),
4774 module_path: None,
4775 });
4776 assert_eq!(engine.context_count_for_test(), baseline.0 + 1);
4777 assert_eq!(engine.javascript_context_count_for_test(), baseline.1 + 1);
4778
4779 assert!(engine.dispose_context(&context.context_id));
4780 assert_eq!(
4781 (
4782 engine.context_count_for_test(),
4783 engine.javascript_context_count_for_test(),
4784 ),
4785 baseline
4786 );
4787 }
4788
4789 fn request_with_env(cwd: &Path, env: BTreeMap<String, String>) -> StartWasmExecutionRequest {
4790 let parse = |key: &str| env.get(key).and_then(|value| value.parse::<u64>().ok());
4794 let limits = WasmExecutionLimits {
4795 max_fuel: parse(WASM_MAX_FUEL_ENV),
4796 max_memory_bytes: parse(WASM_MAX_MEMORY_BYTES_ENV),
4797 max_stack_bytes: parse(WASM_MAX_STACK_BYTES_ENV),
4798 max_module_file_bytes: None,
4799 max_spawn_file_actions: None,
4800 max_spawn_file_action_bytes: None,
4801 prewarm_timeout_ms: None,
4802 max_open_fds: None,
4803 max_sockets: None,
4804 max_blocking_read_ms: None,
4805 runner_heap_limit_mb: None,
4806 runner_cpu_time_limit_ms: None,
4807 reactor_work_quantum: None,
4808 bridge_call_timeout_ms: None,
4809 };
4810 StartWasmExecutionRequest {
4811 limits,
4812 guest_runtime: GuestRuntimeConfig::default(),
4813 vm_id: String::from("vm-wasm"),
4814 context_id: String::from("ctx-wasm"),
4815 argv: Vec::new(),
4816 env,
4817 cwd: cwd.to_path_buf(),
4818 permission_tier: WasmPermissionTier::Full,
4819 }
4820 }
4821
4822 fn wasi_imports_from_source(source: &str) -> BTreeSet<String> {
4823 let table_start = source
4824 .find("this.wasiImport = {")
4825 .expect("WASI source should define a wasiImport table");
4826 let table_body = &source[table_start + "this.wasiImport = {".len()..];
4827 let table_end = table_body
4828 .find("\n };")
4829 .expect("WASI source should close the wasiImport table");
4830
4831 table_body[..table_end]
4832 .lines()
4833 .filter_map(|line| {
4834 let (name, _) = line.trim_start().split_once(':')?;
4835 name.chars()
4836 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
4837 .then(|| name.to_string())
4838 })
4839 .collect()
4840 }
4841
4842 fn wasm_sync_rpc_request(method: &str) -> JavascriptSyncRpcRequest {
4843 JavascriptSyncRpcRequest {
4844 id: 1,
4845 method: method.to_string(),
4846 args: Vec::new(),
4847 raw_bytes_args: Default::default(),
4848 }
4849 }
4850
4851 fn request_with_typed_limits_and_misleading_env(
4854 limits: WasmExecutionLimits,
4855 ) -> StartWasmExecutionRequest {
4856 StartWasmExecutionRequest {
4857 limits,
4858 guest_runtime: GuestRuntimeConfig::default(),
4859 vm_id: String::from("vm-wasm"),
4860 context_id: String::from("ctx-wasm"),
4861 argv: Vec::new(),
4862 env: BTreeMap::from([
4865 (String::from(WASM_MAX_FUEL_ENV), String::from("999999")),
4866 (
4867 String::from(WASM_MAX_MEMORY_BYTES_ENV),
4868 String::from("999999"),
4869 ),
4870 (
4871 String::from(WASM_MAX_STACK_BYTES_ENV),
4872 String::from("999999"),
4873 ),
4874 (
4875 String::from("AGENTOS_WASM_PREWARM_TIMEOUT_MS"),
4876 String::from("999999"),
4877 ),
4878 (
4879 String::from("AGENTOS_WASM_RUNNER_HEAP_LIMIT_MB"),
4880 String::from("999999"),
4881 ),
4882 (
4883 String::from(WASM_MAX_SPAWN_FILE_ACTIONS_ENV),
4884 String::from("999999"),
4885 ),
4886 (
4887 String::from(WASM_MAX_SPAWN_FILE_ACTION_BYTES_ENV),
4888 String::from("999999"),
4889 ),
4890 ]),
4891 cwd: PathBuf::from("/tmp"),
4892 permission_tier: WasmPermissionTier::Full,
4893 }
4894 }
4895
4896 #[test]
4897 fn wasm_limits_are_read_from_typed_fields_and_env_is_inert() {
4898 let request = request_with_typed_limits_and_misleading_env(WasmExecutionLimits {
4899 max_fuel: Some(25),
4900 max_memory_bytes: Some(65_536),
4901 max_stack_bytes: Some(131_072),
4902 max_module_file_bytes: Some(262_144),
4903 max_spawn_file_actions: Some(7),
4904 max_spawn_file_action_bytes: Some(321),
4905 prewarm_timeout_ms: Some(750),
4906 max_open_fds: None,
4907 max_sockets: None,
4908 max_blocking_read_ms: None,
4909 runner_heap_limit_mb: Some(512),
4910 runner_cpu_time_limit_ms: Some(1_234),
4911 reactor_work_quantum: Some(64),
4912 bridge_call_timeout_ms: Some(30_000),
4913 });
4914
4915 assert_eq!(
4916 resolve_wasm_execution_timeout(&request).expect("fuel timeout"),
4917 Some(Duration::from_millis(25)),
4918 "fuel must come from the typed wire limit, not AGENTOS_WASM_MAX_FUEL"
4919 );
4920 assert_eq!(
4921 wasm_memory_limit_bytes(&request).expect("memory limit"),
4922 Some(65_536),
4923 "memory must come from the typed wire limit, not AGENTOS_WASM_MAX_MEMORY_BYTES"
4924 );
4925 let stack_error = resolve_wasm_stack_limit_bytes(&request)
4926 .expect_err("an unenforceable stack limit must fail closed");
4927 assert!(
4928 stack_error.to_string().contains("131072"),
4929 "the typed error must name the configured stack limit: {stack_error}"
4930 );
4931 assert_eq!(
4932 resolve_wasm_prewarm_timeout(&request).expect("prewarm timeout"),
4933 Duration::from_millis(750),
4934 "prewarm timeout must come from the typed wire limit, not AGENTOS_WASM_PREWARM_TIMEOUT_MS"
4935 );
4936 assert_eq!(
4937 wasm_runner_heap_limit_mb(&request),
4938 512,
4939 "runner heap must come from the typed wire limit, not AGENTOS_WASM_RUNNER_HEAP_LIMIT_MB"
4940 );
4941 assert_eq!(
4942 wasm_runner_javascript_limits(&request.limits, wasm_runner_heap_limit_mb(&request))
4943 .cpu_time_limit_ms,
4944 Some(1_234),
4945 );
4946 }
4947
4948 #[test]
4949 fn wasm_limits_default_to_bounded_timeout_when_unset_even_with_env_present() {
4950 let request = request_with_typed_limits_and_misleading_env(WasmExecutionLimits::default());
4954
4955 assert_eq!(
4956 resolve_wasm_execution_timeout(&request).expect("fuel"),
4957 None
4958 );
4959 assert_eq!(wasm_memory_limit_bytes(&request).expect("memory"), None);
4960 assert_eq!(
4961 resolve_wasm_stack_limit_bytes(&request).expect("stack"),
4962 None
4963 );
4964 assert_eq!(
4965 resolve_wasm_prewarm_timeout(&request).expect("prewarm"),
4966 Duration::from_millis(DEFAULT_WASM_PREWARM_TIMEOUT_MS)
4967 );
4968 assert_eq!(
4969 wasm_runner_heap_limit_mb(&request),
4970 DEFAULT_WASM_RUNNER_HEAP_LIMIT_MB
4971 );
4972 }
4973
4974 #[test]
4975 fn wasm_internal_env_scrubs_migrated_limit_env_keys() {
4976 let request = request_with_typed_limits_and_misleading_env(WasmExecutionLimits {
4977 max_fuel: Some(25),
4978 max_memory_bytes: Some(65_536),
4979 max_stack_bytes: Some(131_072),
4980 max_module_file_bytes: Some(262_144),
4981 max_spawn_file_actions: Some(7),
4982 max_spawn_file_action_bytes: Some(321),
4983 prewarm_timeout_ms: Some(750),
4984 max_open_fds: None,
4985 max_sockets: None,
4986 max_blocking_read_ms: None,
4987 runner_heap_limit_mb: Some(512),
4988 runner_cpu_time_limit_ms: Some(1_234),
4989 reactor_work_quantum: Some(64),
4990 bridge_call_timeout_ms: Some(30_000),
4991 });
4992 let resolved_module = ResolvedWasmModule {
4993 specifier: String::from("./guest.wasm"),
4994 resolved_path: PathBuf::from("/tmp/guest.wasm"),
4995 };
4996
4997 let internal_env =
4998 build_wasm_internal_env(&resolved_module, &request, 1_234, false).expect("env");
4999
5000 assert_eq!(
5001 internal_env.get(WASM_MAX_MEMORY_BYTES_ENV),
5002 Some(&String::from("65536"))
5003 );
5004 assert_eq!(
5005 internal_env.get(WASM_MAX_MODULE_FILE_BYTES_ENV),
5006 Some(&String::from("262144"))
5007 );
5008 assert_eq!(
5009 internal_env.get(WASM_MAX_SPAWN_FILE_ACTIONS_ENV),
5010 Some(&String::from("7"))
5011 );
5012 assert_eq!(
5013 internal_env.get(WASM_MAX_SPAWN_FILE_ACTION_BYTES_ENV),
5014 Some(&String::from("321"))
5015 );
5016 assert_eq!(
5017 internal_env.get(WASM_INTERNAL_MAX_STACK_BYTES_ENV),
5018 Some(&String::from("131072"))
5019 );
5020 assert!(!internal_env.contains_key(WASM_MAX_STACK_BYTES_ENV));
5021 assert!(!internal_env.contains_key(WASM_MAX_FUEL_ENV));
5022 assert!(!internal_env.contains_key("AGENTOS_WASM_PREWARM_TIMEOUT_MS"));
5023 assert!(!internal_env.contains_key("AGENTOS_WASM_RUNNER_HEAP_LIMIT_MB"));
5024 }
5025
5026 #[test]
5027 fn wasm_runner_base_env_scrubs_migrated_limit_env_keys() {
5028 let mut request = request_with_typed_limits_and_misleading_env(WasmExecutionLimits {
5029 max_fuel: Some(25),
5030 max_memory_bytes: Some(65_536),
5031 max_stack_bytes: Some(131_072),
5032 max_module_file_bytes: Some(262_144),
5033 max_spawn_file_actions: Some(7),
5034 max_spawn_file_action_bytes: Some(321),
5035 prewarm_timeout_ms: Some(750),
5036 max_open_fds: None,
5037 max_sockets: None,
5038 max_blocking_read_ms: None,
5039 runner_heap_limit_mb: Some(512),
5040 runner_cpu_time_limit_ms: Some(1_234),
5041 reactor_work_quantum: Some(64),
5042 bridge_call_timeout_ms: Some(30_000),
5043 });
5044 request
5045 .env
5046 .insert(String::from("USER_VISIBLE"), String::from("kept"));
5047 request
5048 .env
5049 .insert(String::from("AGENTOS_TRACE_ID"), String::from("kept"));
5050
5051 let env = wasm_runner_base_env(&request);
5052
5053 assert_eq!(env.get("USER_VISIBLE"), Some(&String::from("kept")));
5054 assert_eq!(env.get("AGENTOS_TRACE_ID"), Some(&String::from("kept")));
5055 assert!(!env.contains_key(WASM_MAX_FUEL_ENV));
5056 assert!(!env.contains_key(WASM_MAX_MEMORY_BYTES_ENV));
5057 assert!(!env.contains_key(WASM_MAX_MODULE_FILE_BYTES_ENV));
5058 assert!(!env.contains_key(WASM_MAX_STACK_BYTES_ENV));
5059 assert!(!env.contains_key("AGENTOS_WASM_PREWARM_TIMEOUT_MS"));
5060 assert!(!env.contains_key("AGENTOS_WASM_RUNNER_HEAP_LIMIT_MB"));
5061 }
5062
5063 #[test]
5064 fn wasm_snapshot_runner_base_env_scrubs_internal_and_migrated_limit_env_keys() {
5065 let mut request = request_with_typed_limits_and_misleading_env(WasmExecutionLimits {
5066 max_fuel: Some(25),
5067 max_memory_bytes: Some(65_536),
5068 max_stack_bytes: Some(131_072),
5069 max_module_file_bytes: Some(262_144),
5070 max_spawn_file_actions: Some(7),
5071 max_spawn_file_action_bytes: Some(321),
5072 prewarm_timeout_ms: Some(750),
5073 max_open_fds: None,
5074 max_sockets: None,
5075 max_blocking_read_ms: None,
5076 runner_heap_limit_mb: Some(512),
5077 runner_cpu_time_limit_ms: Some(1_234),
5078 reactor_work_quantum: Some(64),
5079 bridge_call_timeout_ms: Some(30_000),
5080 });
5081 request
5082 .env
5083 .insert(String::from("USER_VISIBLE"), String::from("kept"));
5084 request.env.insert(
5085 String::from("NODE_SYNC_RPC_WAIT_TIMEOUT_MS"),
5086 String::from("999"),
5087 );
5088
5089 let env = wasm_snapshot_runner_base_env(&request);
5090
5091 assert_eq!(env.get("USER_VISIBLE"), Some(&String::from("kept")));
5092 assert!(!env.contains_key("NODE_SYNC_RPC_WAIT_TIMEOUT_MS"));
5093 assert!(!env.contains_key(WASM_MAX_FUEL_ENV));
5094 assert!(!env.contains_key(WASM_MAX_MEMORY_BYTES_ENV));
5095 assert!(!env.contains_key(WASM_MAX_STACK_BYTES_ENV));
5096 assert!(!env.contains_key("AGENTOS_WASM_PREWARM_TIMEOUT_MS"));
5097 assert!(!env.contains_key("AGENTOS_WASM_RUNNER_HEAP_LIMIT_MB"));
5098 }
5099
5100 #[test]
5101 fn wasm_stack_limit_of_zero_is_rejected() {
5102 let request = request_with_typed_limits_and_misleading_env(WasmExecutionLimits {
5103 max_stack_bytes: Some(0),
5104 ..WasmExecutionLimits::default()
5105 });
5106
5107 assert!(
5108 resolve_wasm_stack_limit_bytes(&request).is_err(),
5109 "a zero stack cap must fail closed rather than be silently dropped"
5110 );
5111 }
5112
5113 #[test]
5114 fn resolved_module_path_canonicalizes_path_like_specifiers() {
5115 let temp = tempdir().expect("create temp dir");
5116 let real = temp.path().join("real.wasm");
5117 let alias = temp.path().join("alias.wasm");
5118 fs::write(&real, b"\0asm\x01\0\0\0").expect("write wasm file");
5119 symlink(&real, &alias).expect("create wasm symlink");
5120
5121 let resolved = resolved_module_path("./alias.wasm", temp.path());
5122
5123 assert_eq!(
5124 resolved,
5125 real.canonicalize().expect("canonicalize wasm target")
5126 );
5127 }
5128
5129 #[test]
5130 fn wasm_prewarm_timeout_is_separate_from_execution_timeout() {
5131 let temp = tempdir().expect("create temp dir");
5132 let mut request = request_with_env(
5133 temp.path(),
5134 BTreeMap::from([(String::from(WASM_MAX_FUEL_ENV), String::from("25"))]),
5135 );
5136 request.limits.prewarm_timeout_ms = Some(750);
5137
5138 assert_eq!(
5139 resolve_wasm_execution_timeout(&request).expect("execution timeout"),
5140 Some(Duration::from_millis(25))
5141 );
5142 assert_eq!(
5143 resolve_wasm_prewarm_timeout(&request).expect("prewarm timeout"),
5144 Duration::from_millis(750)
5145 );
5146 }
5147
5148 #[test]
5152 fn wasm_execution_timeout_is_unset_without_fuel_budget() {
5153 let temp = tempdir().expect("create temp dir");
5154 let request = request_with_env(temp.path(), BTreeMap::new());
5155
5156 let timeout = resolve_wasm_execution_timeout(&request)
5157 .expect("execution timeout resolves without fuel env");
5158
5159 assert_eq!(
5160 timeout, None,
5161 "no explicit fuel budget means no wall-clock timeout; the runner \
5162 isolate's TRUE-CPU budget (default 30s active CPU) is the bound \
5163 that terminates an infinite-loop module (F-004), so an idle \
5164 interactive guest is not killed on wall time"
5165 );
5166 }
5167
5168 #[test]
5169 fn wasm_captured_output_rejects_output_over_limit() {
5170 let mut stdout = vec![b'x'; WASM_CAPTURED_OUTPUT_LIMIT_BYTES - 1];
5171 super::append_wasm_captured_output(&mut stdout, b"y", "stdout").expect("fill to limit");
5172 assert_eq!(stdout.len(), WASM_CAPTURED_OUTPUT_LIMIT_BYTES);
5173
5174 let error = super::append_wasm_captured_output(&mut stdout, b"z", "stdout")
5175 .expect_err("captured output over limit should fail");
5176 assert!(matches!(
5177 error,
5178 WasmExecutionError::OutputBufferExceeded {
5179 stream: "stdout",
5180 limit: WASM_CAPTURED_OUTPUT_LIMIT_BYTES,
5181 }
5182 ));
5183 }
5184
5185 #[test]
5186 fn wasm_sync_read_length_rejects_oversized_guest_lengths() {
5187 assert_eq!(
5188 wasm_sync_read_length(Some(WASM_SYNC_READ_LIMIT_BYTES as u64))
5189 .expect("max read length should be accepted"),
5190 WASM_SYNC_READ_LIMIT_BYTES
5191 );
5192
5193 let error = wasm_sync_read_length(Some(WASM_SYNC_READ_LIMIT_BYTES as u64 + 1))
5194 .expect_err("oversized read length should fail before allocation");
5195 assert!(
5196 matches!(error, WasmExecutionError::InvalidLimit(message) if message.contains("fs.readSync length"))
5197 );
5198 }
5199
5200 #[test]
5201 fn wasm_bytes_arg_rejects_payloads_over_limit_before_decode() {
5202 let mut payload = serde_json::Map::new();
5203 payload.insert(
5204 String::from("base64"),
5205 Value::String(String::from("YWJjZA==")),
5206 );
5207
5208 let error =
5209 super::decode_wasm_bytes_arg(Some(&Value::Object(payload)), "fs.writeSync bytes", 3)
5210 .expect_err("decoded bytes over limit should fail before allocation");
5211
5212 assert!(matches!(
5213 error,
5214 WasmExecutionError::OutputBufferExceeded {
5215 stream: "fs.writeSync bytes",
5216 limit: 3,
5217 }
5218 ));
5219 }
5220
5221 #[test]
5222 fn wasm_runner_bootstrap_caps_wasi_iov_lengths_before_allocation() {
5223 let bootstrap = build_wasm_runner_bootstrap(&BTreeMap::new(), None);
5224
5225 assert!(bootstrap.contains("const __agentOSWasmSyncReadLimitBytes ="));
5229 assert!(bootstrap.contains(&format!(": {WASM_SYNC_READ_LIMIT_BYTES};")));
5230 assert!(!bootstrap.contains("__AGENTOS_WASM_SYNC_READ_LIMIT_BYTES__"));
5231 assert!(bootstrap.contains("_boundedIovLength(iovs, iovsLen)"));
5232 assert!(bootstrap.contains("const totalLength = this._boundedIovLength(iovs, iovsLen);\n const view = this._memoryView();"));
5233 assert!(bootstrap.contains("return Buffer.concat(chunks, totalLength);"));
5234 assert!(bootstrap.contains("const totalLength = this._boundedIovLength(iovs, iovsLen);"));
5235 assert!(!bootstrap.contains("const totalLength = (() => {"));
5236 }
5237
5238 #[test]
5239 fn wasi_preview1_import_manifest_matches_native_runner() {
5240 let expected: BTreeSet<String> = serde_json::from_str::<Vec<String>>(include_str!(
5241 "../assets/wasi-preview1-imports.json"
5242 ))
5243 .expect("parse WASI preview1 import manifest")
5244 .into_iter()
5245 .collect();
5246
5247 assert_eq!(expected, wasi_imports_from_source(NODE_WASI_MODULE_SOURCE));
5248 }
5249
5250 #[test]
5251 fn wasm_guest_module_paths_include_mapped_guest_paths_for_host_specifiers() {
5252 let temp = tempdir().expect("create temp dir");
5253 let command_root = temp.path().join("commands");
5254 let module = command_root.join("hello");
5255 fs::create_dir_all(&command_root).expect("create command root");
5256 fs::write(&module, b"\0asm\x01\0\0\0").expect("write wasm file");
5257
5258 let candidates = wasm_guest_module_paths(
5259 module.to_string_lossy().as_ref(),
5260 &BTreeMap::from([(
5261 String::from("AGENTOS_GUEST_PATH_MAPPINGS"),
5262 format!(
5263 "[{{\"guestPath\":\"/__agentos/commands/0\",\"hostPath\":\"{}\"}}]",
5264 command_root.display()
5265 ),
5266 )]),
5267 );
5268
5269 assert!(candidates.contains(&module.to_string_lossy().into_owned()));
5270 assert!(candidates.contains(&String::from("/__agentos/commands/0/hello")));
5271 }
5272
5273 #[test]
5274 fn translate_wasm_guest_path_uses_sandbox_root_for_absolute_paths() {
5275 let temp = tempdir().expect("create temp dir");
5276 let sandbox_root = temp.path().join("shadow-root");
5277 let cwd = sandbox_root.join("workspace");
5278 fs::create_dir_all(cwd.join("project")).expect("create host cwd");
5279
5280 let internal_sync_rpc = WasmInternalSyncRpc {
5281 module_guest_paths: Vec::new(),
5282 module_host_path: sandbox_root.join("module.wasm"),
5283 guest_cwd: String::from("/workspace"),
5284 host_cwd: cwd.clone(),
5285 sandbox_root: Some(sandbox_root.clone()),
5286 guest_path_mappings: Vec::new(),
5287 route_fs_through_sidecar: false,
5288 next_fd: 64,
5289 open_files: Default::default(),
5290 pending_events: VecDeque::new(),
5291 };
5292
5293 assert_eq!(
5294 translate_wasm_guest_path("/tmp/redir.txt", &internal_sync_rpc),
5295 Some(sandbox_root.join("tmp/redir.txt"))
5296 );
5297 assert_eq!(
5298 translate_wasm_guest_path("project/output.txt", &internal_sync_rpc),
5299 Some(cwd.join("project/output.txt"))
5300 );
5301 }
5302
5303 #[test]
5304 fn translate_wasm_host_symlink_target_returns_guest_path_for_mapped_targets() {
5305 let temp = tempdir().expect("create temp dir");
5306 let sandbox_root = temp.path().join("shadow-root");
5307 let cwd = sandbox_root.join("workspace");
5308 fs::create_dir_all(cwd.join("project")).expect("create host cwd");
5309
5310 let internal_sync_rpc = WasmInternalSyncRpc {
5311 module_guest_paths: Vec::new(),
5312 module_host_path: sandbox_root.join("module.wasm"),
5313 guest_cwd: String::from("/workspace"),
5314 host_cwd: cwd.clone(),
5315 sandbox_root: Some(sandbox_root.clone()),
5316 guest_path_mappings: vec![super::WasmGuestPathMapping {
5317 guest_path: String::from("/"),
5318 host_path: sandbox_root.clone(),
5319 read_only: false,
5320 }],
5321 route_fs_through_sidecar: false,
5322 next_fd: 64,
5323 open_files: Default::default(),
5324 pending_events: VecDeque::new(),
5325 };
5326
5327 assert_eq!(
5328 translate_wasm_host_symlink_target(
5329 &sandbox_root.join("tmp/sc/pdir/r.txt"),
5330 &internal_sync_rpc
5331 ),
5332 Some(String::from("/tmp/sc/pdir/r.txt"))
5333 );
5334 assert_eq!(
5335 translate_wasm_host_symlink_target(Path::new("relative-target"), &internal_sync_rpc),
5336 None
5337 );
5338 }
5339
5340 #[test]
5341 fn translate_wasm_guest_path_recovers_root_collapsed_relative_paths_from_guest_cwd() {
5342 let temp = tempdir().expect("create temp dir");
5343 let sandbox_root = temp.path().join("shadow-root");
5344 let cwd = temp.path().join("mounted-workspace");
5345 fs::create_dir_all(&sandbox_root).expect("create sandbox root");
5346 fs::create_dir_all(&cwd).expect("create mounted workspace");
5347 fs::write(cwd.join("note.txt"), b"hello").expect("write mounted file");
5348
5349 let internal_sync_rpc = WasmInternalSyncRpc {
5350 module_guest_paths: Vec::new(),
5351 module_host_path: sandbox_root.join("module.wasm"),
5352 guest_cwd: String::from("/workspace"),
5353 host_cwd: cwd.clone(),
5354 sandbox_root: Some(sandbox_root.clone()),
5355 guest_path_mappings: vec![super::WasmGuestPathMapping {
5356 guest_path: String::from("/workspace"),
5357 host_path: cwd.clone(),
5358 read_only: false,
5359 }],
5360 route_fs_through_sidecar: false,
5361 next_fd: 64,
5362 open_files: Default::default(),
5363 pending_events: VecDeque::new(),
5364 };
5365
5366 assert_eq!(
5367 translate_wasm_guest_path("/note.txt", &internal_sync_rpc),
5368 Some(cwd.join("note.txt"))
5369 );
5370 }
5371
5372 #[test]
5373 fn translate_wasm_guest_path_accepts_host_absolute_paths_within_known_roots() {
5374 let temp = tempdir().expect("create temp dir");
5375 let sandbox_root = temp.path().join("shadow-root");
5376 let cwd = temp.path().join("mounted-workspace");
5377 let mapped_root = temp.path().join("mounted-commands");
5378 fs::create_dir_all(&sandbox_root).expect("create sandbox root");
5379 fs::create_dir_all(cwd.join("subdir")).expect("create cwd");
5380 fs::create_dir_all(&mapped_root).expect("create mapped root");
5381
5382 let internal_sync_rpc = WasmInternalSyncRpc {
5383 module_guest_paths: vec![String::from("/workspace/guest.wasm")],
5384 module_host_path: cwd.join("guest.wasm"),
5385 guest_cwd: String::from("/workspace"),
5386 host_cwd: cwd.clone(),
5387 sandbox_root: Some(sandbox_root.clone()),
5388 guest_path_mappings: vec![
5389 super::WasmGuestPathMapping {
5390 guest_path: String::from("/workspace"),
5391 host_path: cwd.clone(),
5392 read_only: false,
5393 },
5394 super::WasmGuestPathMapping {
5395 guest_path: String::from("/__agentos/commands/0"),
5396 host_path: mapped_root.clone(),
5397 read_only: false,
5398 },
5399 ],
5400 route_fs_through_sidecar: false,
5401 next_fd: 64,
5402 open_files: Default::default(),
5403 pending_events: VecDeque::new(),
5404 };
5405
5406 assert_eq!(
5407 translate_wasm_guest_path(cwd.to_string_lossy().as_ref(), &internal_sync_rpc),
5408 Some(cwd.clone())
5409 );
5410 assert_eq!(
5411 translate_wasm_guest_path(
5412 cwd.join("subdir/output.txt").to_string_lossy().as_ref(),
5413 &internal_sync_rpc
5414 ),
5415 Some(cwd.join("subdir/output.txt"))
5416 );
5417 assert_eq!(
5418 translate_wasm_guest_path(
5419 mapped_root.join("tool.wasm").to_string_lossy().as_ref(),
5420 &internal_sync_rpc
5421 ),
5422 Some(mapped_root.join("tool.wasm"))
5423 );
5424 assert_eq!(
5425 translate_wasm_guest_path(
5426 sandbox_root
5427 .join("tmp/runtime.sock")
5428 .to_string_lossy()
5429 .as_ref(),
5430 &internal_sync_rpc
5431 ),
5432 Some(sandbox_root.join("tmp/runtime.sock"))
5433 );
5434 }
5435
5436 #[test]
5437 fn translate_wasm_guest_path_rejects_symlink_escape_from_sandbox_root() {
5438 let temp = tempdir().expect("create temp dir");
5439 let sandbox_root = temp.path().join("shadow-root");
5440 let outside = temp.path().join("outside");
5441 fs::create_dir_all(&sandbox_root).expect("create sandbox root");
5442 fs::create_dir_all(&outside).expect("create outside root");
5443 fs::write(outside.join("secret.txt"), b"host secret").expect("write outside file");
5444 symlink(&outside, sandbox_root.join("escape")).expect("create escape symlink");
5445
5446 let internal_sync_rpc = WasmInternalSyncRpc {
5447 module_guest_paths: Vec::new(),
5448 module_host_path: sandbox_root.join("module.wasm"),
5449 guest_cwd: String::from("/"),
5450 host_cwd: sandbox_root.clone(),
5451 sandbox_root: Some(sandbox_root.clone()),
5452 guest_path_mappings: vec![super::WasmGuestPathMapping {
5453 guest_path: String::from("/"),
5454 host_path: sandbox_root,
5455 read_only: false,
5456 }],
5457 route_fs_through_sidecar: false,
5458 next_fd: 64,
5459 open_files: Default::default(),
5460 pending_events: VecDeque::new(),
5461 };
5462
5463 assert_eq!(
5464 translate_wasm_guest_path("/escape/secret.txt", &internal_sync_rpc),
5465 None
5466 );
5467 assert_eq!(
5468 translate_wasm_guest_path("/escape/new.txt", &internal_sync_rpc),
5469 None
5470 );
5471 }
5472
5473 #[test]
5474 fn wasm_read_only_mapping_blocks_mutating_host_paths() {
5475 let temp = tempdir().expect("create temp dir");
5476 let sandbox_root = temp.path().join("shadow-root");
5477 let readonly_root = temp.path().join("readonly");
5478 fs::create_dir_all(&sandbox_root).expect("create sandbox root");
5479 fs::create_dir_all(&readonly_root).expect("create readonly root");
5480 fs::write(readonly_root.join("package.json"), b"{}").expect("write readonly file");
5481
5482 let internal_sync_rpc = WasmInternalSyncRpc {
5483 module_guest_paths: Vec::new(),
5484 module_host_path: sandbox_root.join("module.wasm"),
5485 guest_cwd: String::from("/workspace"),
5486 host_cwd: sandbox_root.clone(),
5487 sandbox_root: Some(sandbox_root),
5488 guest_path_mappings: vec![super::WasmGuestPathMapping {
5489 guest_path: String::from("/node_modules"),
5490 host_path: readonly_root.clone(),
5491 read_only: true,
5492 }],
5493 route_fs_through_sidecar: false,
5494 next_fd: 64,
5495 open_files: Default::default(),
5496 pending_events: VecDeque::new(),
5497 };
5498
5499 let host_path = translate_wasm_guest_path("/node_modules/package.json", &internal_sync_rpc)
5500 .expect("read path should resolve");
5501 assert_eq!(host_path, readonly_root.join("package.json"));
5502 assert!(wasm_host_path_is_read_only(&host_path, &internal_sync_rpc));
5503 assert!(wasm_host_path_is_read_only(
5504 &readonly_root.join("new-package.json"),
5505 &internal_sync_rpc
5506 ));
5507 assert_eq!(
5508 wasm_sync_rpc_error_code(&wasm_read_only_filesystem_error("/node_modules")),
5509 "EROFS"
5510 );
5511 }
5512
5513 #[test]
5514 fn wasm_open_guest_file_errors_remain_sync_rpc_errors() {
5515 let temp = tempdir().expect("create temp dir");
5516 let missing_path = temp.path().join("missing.txt");
5517
5518 let error = open_wasm_guest_file(&missing_path, &Value::from(0))
5519 .expect_err("missing file should return an open error");
5520
5521 assert_eq!(wasm_sync_rpc_error_code(&error), "ENOENT");
5522 }
5523
5524 #[test]
5525 fn wasm_hard_links_are_rejected_when_either_side_is_read_only() {
5526 let temp = tempdir().expect("create temp dir");
5527 let readonly_root = temp.path().join("readonly");
5528 let writable_root = temp.path().join("writable");
5529 fs::create_dir_all(&readonly_root).expect("create readonly root");
5530 fs::create_dir_all(&writable_root).expect("create writable root");
5531 let readonly_file = readonly_root.join("package.json");
5532 let writable_file = writable_root.join("source.txt");
5533 fs::write(&readonly_file, b"readonly").expect("write readonly source");
5534 fs::write(&writable_file, b"writable").expect("write writable source");
5535
5536 let internal_sync_rpc = WasmInternalSyncRpc {
5537 module_guest_paths: Vec::new(),
5538 module_host_path: writable_root.join("module.wasm"),
5539 guest_cwd: String::from("/workspace"),
5540 host_cwd: writable_root.clone(),
5541 sandbox_root: Some(writable_root.clone()),
5542 guest_path_mappings: vec![
5543 super::WasmGuestPathMapping {
5544 guest_path: String::from("/node_modules"),
5545 host_path: readonly_root.clone(),
5546 read_only: true,
5547 },
5548 super::WasmGuestPathMapping {
5549 guest_path: String::from("/workspace"),
5550 host_path: writable_root.clone(),
5551 read_only: false,
5552 },
5553 ],
5554 route_fs_through_sidecar: false,
5555 next_fd: 64,
5556 open_files: Default::default(),
5557 pending_events: VecDeque::new(),
5558 };
5559
5560 assert!(wasm_mutation_touches_read_only_mapping(
5561 &readonly_file,
5562 &writable_root.join("alias-from-readonly.json"),
5563 &internal_sync_rpc
5564 ));
5565 assert!(wasm_mutation_touches_read_only_mapping(
5566 &writable_file,
5567 &readonly_root.join("alias-into-readonly.txt"),
5568 &internal_sync_rpc
5569 ));
5570 assert!(!wasm_mutation_touches_read_only_mapping(
5571 &writable_file,
5572 &writable_root.join("alias.txt"),
5573 &internal_sync_rpc
5574 ));
5575
5576 let raw_alias = writable_root.join("raw-alias.json");
5577 fs::hard_link(&readonly_file, &raw_alias).expect("host hard link would otherwise succeed");
5578 fs::write(&raw_alias, b"mutated").expect("write through host hard link alias");
5579 assert_eq!(
5580 fs::read(&readonly_file).expect("read readonly source"),
5581 b"mutated"
5582 );
5583 }
5584
5585 #[test]
5586 fn translate_wasm_guest_path_preserves_real_root_paths_before_guest_cwd_fallback() {
5587 let temp = tempdir().expect("create temp dir");
5588 let sandbox_root = temp.path().join("shadow-root");
5589 let cwd = temp.path().join("mounted-workspace");
5590 fs::create_dir_all(&sandbox_root).expect("create sandbox root");
5591 fs::create_dir_all(&cwd).expect("create mounted workspace");
5592 fs::write(sandbox_root.join("note.txt"), b"root").expect("write root file");
5593 fs::write(cwd.join("note.txt"), b"cwd").expect("write cwd file");
5594
5595 let internal_sync_rpc = WasmInternalSyncRpc {
5596 module_guest_paths: Vec::new(),
5597 module_host_path: sandbox_root.join("module.wasm"),
5598 guest_cwd: String::from("/workspace"),
5599 host_cwd: cwd.clone(),
5600 sandbox_root: Some(sandbox_root.clone()),
5601 guest_path_mappings: vec![super::WasmGuestPathMapping {
5602 guest_path: String::from("/workspace"),
5603 host_path: cwd,
5604 read_only: false,
5605 }],
5606 route_fs_through_sidecar: false,
5607 next_fd: 64,
5608 open_files: Default::default(),
5609 pending_events: VecDeque::new(),
5610 };
5611
5612 assert_eq!(
5613 translate_wasm_guest_path("/note.txt", &internal_sync_rpc),
5614 Some(sandbox_root.join("note.txt"))
5615 );
5616 }
5617
5618 #[test]
5619 fn wasm_sandbox_root_reads_absolute_env_only() {
5620 let sandbox_root = wasm_sandbox_root(&BTreeMap::from([(
5621 String::from(WASM_SANDBOX_ROOT_ENV),
5622 String::from("/tmp/agentos-shadow"),
5623 )]));
5624 assert_eq!(sandbox_root, Some(PathBuf::from("/tmp/agentos-shadow")));
5625
5626 let relative = wasm_sandbox_root(&BTreeMap::from([(
5627 String::from(WASM_SANDBOX_ROOT_ENV),
5628 String::from("relative/shadow"),
5629 )]));
5630 assert_eq!(relative, None);
5631 }
5632
5633 #[test]
5634 fn wasm_sidecar_managed_fs_methods_route_to_kernel_sync_rpc() {
5635 let mut standalone = WasmInternalSyncRpc {
5636 module_guest_paths: Vec::new(),
5637 module_host_path: PathBuf::from("/tmp/module.wasm"),
5638 guest_cwd: String::from("/"),
5639 host_cwd: PathBuf::from("/tmp"),
5640 sandbox_root: None,
5641 guest_path_mappings: Vec::new(),
5642 route_fs_through_sidecar: false,
5643 next_fd: 64,
5644 open_files: Default::default(),
5645 pending_events: VecDeque::new(),
5646 };
5647 let sidecar_managed = WasmInternalSyncRpc {
5648 module_guest_paths: Vec::new(),
5649 module_host_path: PathBuf::from("/tmp/module.wasm"),
5650 guest_cwd: String::from("/"),
5651 host_cwd: PathBuf::from("/tmp"),
5652 sandbox_root: Some(PathBuf::from("/tmp/agentos-shadow")),
5653 guest_path_mappings: Vec::new(),
5654 route_fs_through_sidecar: true,
5655 next_fd: 64,
5656 open_files: Default::default(),
5657 pending_events: VecDeque::new(),
5658 };
5659
5660 for method in WASM_SIDECAR_ROUTED_FS_SYNC_METHODS {
5661 let request = wasm_sync_rpc_request(method);
5662 assert!(
5663 wasm_sync_rpc_method_routes_through_sidecar_kernel(&request, &sidecar_managed),
5664 "{method} should route through the sidecar kernel for managed WASI executions"
5665 );
5666 assert!(
5667 !wasm_sync_rpc_method_routes_through_sidecar_kernel(&request, &standalone),
5668 "{method} should stay host-direct for standalone/prewarm WASI execution"
5669 );
5670 }
5671
5672 standalone.route_fs_through_sidecar = true;
5673 let non_fs_request = wasm_sync_rpc_request("child_process.spawn");
5674 assert!(!wasm_sync_rpc_method_routes_through_sidecar_kernel(
5675 &non_fs_request,
5676 &standalone
5677 ));
5678 }
5679
5680 #[test]
5681 fn wasm_guest_path_mappings_mount_root_to_sandbox_root() {
5682 let temp = tempdir().expect("create temp dir");
5683 let sandbox_root = temp.path().join("shadow-root");
5684 let host_cwd = sandbox_root.join("workspace");
5685 fs::create_dir_all(&host_cwd).expect("create host cwd");
5686
5687 let mappings = super::wasm_guest_path_mappings(&request_with_env(
5688 &host_cwd,
5689 BTreeMap::from([
5690 (String::from("PWD"), String::from("/workspace")),
5691 (
5692 String::from(WASM_SANDBOX_ROOT_ENV),
5693 sandbox_root.to_string_lossy().into_owned(),
5694 ),
5695 ]),
5696 ));
5697
5698 assert!(mappings
5699 .iter()
5700 .any(|mapping| { mapping.guest_path == "/" && mapping.host_path == sandbox_root }));
5701 assert!(mappings.iter().any(|mapping| {
5702 mapping.guest_path == "/workspace" && mapping.host_path == host_cwd
5703 }));
5704 }
5705
5706 #[test]
5707 fn wasm_runner_bootstrap_keeps_root_preopens_rooted() {
5708 let bootstrap = build_wasm_runner_bootstrap(&BTreeMap::new(), None);
5709
5710 assert!(bootstrap.contains("if (guestPath === \".\") {"));
5711 assert!(!bootstrap.contains("if (guestPath === \".\" || guestPath === \"/\") {"));
5712 }
5713
5714 #[test]
5715 fn wasm_runner_bootstrap_exposes_unix_socket_sync_rpcs() {
5716 let bootstrap = build_wasm_runner_bootstrap(&BTreeMap::new(), None);
5717
5718 for (method, bridge) in [
5719 ("net.bind_unix", "_netBindUnixRaw.applySync"),
5720 (
5721 "net.bind_connected_unix",
5722 "_netBindConnectedUnixRaw.applySync",
5723 ),
5724 ("net.server_close", "_netServerCloseSyncRaw.applySync"),
5725 (
5726 "net.socket_wait_connect",
5727 "_netSocketWaitConnectSyncRaw.applySync",
5728 ),
5729 ("net.write", "_netSocketWriteSyncRaw.applySync"),
5730 ] {
5731 assert!(
5732 bootstrap.contains(&format!("case \"{method}\":")),
5733 "missing WASM sync RPC case for {method}"
5734 );
5735 assert!(
5736 bootstrap.contains(bridge),
5737 "missing synchronous V8 bridge call for {method}"
5738 );
5739 }
5740 }
5741
5742 #[test]
5743 fn wasm_runner_bootstrap_reports_dot_preopen_to_wasi() {
5744 let bootstrap = build_wasm_runner_bootstrap(&BTreeMap::new(), None);
5745
5746 assert!(bootstrap.contains("_currentGuestCwd()"));
5750 assert!(!bootstrap.contains("preopens['.'] = createPreopen(HOST_CWD, cwdReadOnly);"));
5751 assert!(bootstrap.contains("_descriptorPreopenName(entry)"));
5752 assert!(bootstrap.contains(
5753 "if (guestPath === \".\") {\n return this._descriptorGuestPath(entry);"
5754 ));
5755 assert!(bootstrap.contains("const guestPath = this._descriptorPreopenName(entry);"));
5756 }
5757
5758 #[test]
5759 fn wasm_runner_path_open_uses_guest_mapping_for_absolute_paths() {
5760 let bootstrap = build_wasm_runner_bootstrap(&BTreeMap::new(), None);
5761
5762 assert!(bootstrap
5763 .contains("const resolved = this._resolveDescriptorPath(fd, pathPtr, pathLen, {"));
5764 assert!(
5765 !bootstrap.contains("const hostPath = __agentOSPath().resolve(baseHostPath, target);")
5766 );
5767 }
5768
5769 #[test]
5770 fn wasm_runner_root_preopen_relative_paths_preserve_cwd_fallback() {
5771 let bootstrap = build_wasm_runner_bootstrap(&BTreeMap::new(), None);
5772
5773 assert!(bootstrap
5774 .contains("const rootGuestPath = __agentOSPath().posix.resolve(\"/\", target);"));
5775 assert!(bootstrap.contains(
5776 "const cwdGuestTarget = __agentOSPath().posix.resolve(cwdGuestPath, target);"
5777 ));
5778 assert!(bootstrap.contains("_rootRelativeTargetPrefersCwd(target)"));
5779 assert!(bootstrap.contains("_mappedPathExists(cwdGuestTarget, cwdHostTarget)"));
5780 assert!(bootstrap.contains("_mappedPathExists(rootGuestPath, rootHostPath)"));
5781 assert!(bootstrap
5782 .contains("__agentOSWasiSyncRpc().callSync(\"fs.statSync\", [sidecarGuestPath])"));
5783 assert!(bootstrap.contains("_rootRelativeTargetMatchesAbsoluteArg(target)"));
5784 assert!(bootstrap.contains("__agentOSPath().posix.normalize(arg) === rootGuestPath"));
5785 assert!(bootstrap.contains("_createParentExists(guestPath, hostPath)"));
5786 assert!(bootstrap.contains(
5787 "preferCreateParent &&\n !this._rootRelativeTargetIsWithinAbsoluteArg(target)"
5788 ));
5789 assert!(bootstrap.contains("this._createParentExists(cwdGuestTarget, cwdHostTarget)"));
5790 }
5791
5792 #[test]
5793 fn wasm_runner_readdir_uses_guest_preopen_path_in_sidecar() {
5794 let bootstrap = build_wasm_runner_bootstrap(&BTreeMap::new(), None);
5795
5796 assert!(bootstrap.contains("const fsPath = this._descriptorDirectoryFsPath(entry);"));
5797 assert!(
5798 bootstrap.contains("(entry?.kind === \"preopen\" || entry?.kind === \"directory\")")
5799 );
5800 }
5801
5802 #[test]
5803 fn wasm_runner_blocks_read_only_fd_write_paths() {
5804 let bootstrap = build_wasm_runner_bootstrap(&BTreeMap::new(), None);
5805
5806 assert!(bootstrap.contains("readOnly: entry.readOnly === true,"));
5807 assert!(bootstrap.contains(
5808 "if (handle.readOnly === true) {\n return __agentOSWasiErrnoRofs;\n }"
5809 ));
5810 assert!(bootstrap.contains(
5811 "if (entry.readOnly === true) {\n return __agentOSWasiErrnoRofs;\n }\n const written = __agentOSFs().writeSync("
5812 ));
5813 }
5814
5815 #[test]
5816 fn wasm_memory_limit_pages_floor_to_whole_wasm_pages() {
5817 assert_eq!(
5818 wasm_memory_limit_pages(WASM_PAGE_BYTES + 123).expect("page limit"),
5819 1
5820 );
5821 assert_eq!(
5822 wasm_memory_limit_pages(2 * WASM_PAGE_BYTES).expect("page limit"),
5823 2
5824 );
5825 }
5826
5827 #[test]
5828 fn wasm_memory_limit_no_longer_requires_declared_module_maximum() {
5829 let temp = tempdir().expect("create temp dir");
5830 let request = request_with_env(
5831 temp.path(),
5832 BTreeMap::from([(
5833 String::from(WASM_MAX_MEMORY_BYTES_ENV),
5834 (2 * WASM_PAGE_BYTES).to_string(),
5835 )]),
5836 );
5837
5838 assert!(
5839 super::validate_module_limits(
5840 &super::ResolvedWasmModule {
5841 specifier: String::from("./guest.wasm"),
5842 resolved_path: {
5843 let path = temp.path().join("guest.wasm");
5844 fs::write(
5845 &path,
5846 wat::parse_str(
5847 r#"
5848(module
5849 (memory (export "memory") 1)
5850 (func (export "_start"))
5851)
5852"#,
5853 )
5854 .expect("compile wasm fixture"),
5855 )
5856 .expect("write wasm fixture");
5857 path
5858 },
5859 },
5860 &request,
5861 )
5862 .is_ok(),
5863 "runtime memory cap should allow modules without a declared maximum"
5864 );
5865 }
5866}