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