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