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