Skip to main content

agentos_execution/
wasm.rs

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