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