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