Skip to main content

agentos_vm_config/
lib.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4use ts_rs::TS;
5
6/// Selects the sidecar-owned defaults applied when a create field is omitted.
7/// Low-level callers keep the secure profile; agentOS clients select the
8/// product profile instead of copying its environment and permission policy.
9#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
10#[serde(rename_all = "snake_case")]
11#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
12pub enum VmDefaultsProfile {
13    #[default]
14    Secure,
15    AgentOs,
16}
17
18/// Canonical Rust-side VM config. Unknown fields must stay rejected here and in
19/// the TS preflight schema at
20/// `packages/core/src/node-runtime-options-schema.ts`; update both when a
21/// public `NodeRuntime.create(...)` option changes the generated VM config.
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
23#[serde(rename_all = "camelCase", deny_unknown_fields)]
24#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
25#[derive(Default)]
26pub struct CreateVmConfig {
27    #[serde(
28        default,
29        rename = "defaultsProfile",
30        skip_serializing_if = "Option::is_none"
31    )]
32    #[ts(optional)]
33    pub defaults_profile: Option<VmDefaultsProfile>,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    #[ts(optional)]
36    pub cwd: Option<String>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    #[ts(optional, type = "Record<string, string>")]
39    pub env: Option<BTreeMap<String, String>>,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    #[ts(optional)]
42    pub database: Option<VmSqliteDescriptor>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    #[ts(optional)]
45    pub user: Option<VmUserConfig>,
46    #[serde(default, rename = "rootFilesystem")]
47    pub root_filesystem: RootFilesystemConfig,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    #[ts(optional)]
50    pub permissions: Option<PermissionsPolicy>,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    #[ts(optional)]
53    pub limits: Option<VmLimitsConfig>,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    #[ts(optional)]
56    pub dns: Option<VmDnsConfig>,
57    #[serde(
58        default,
59        rename = "nativeRoot",
60        skip_serializing_if = "Option::is_none"
61    )]
62    #[ts(optional)]
63    pub native_root: Option<NativeRootFilesystemConfig>,
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    #[ts(optional)]
66    pub listen: Option<VmListenPolicyConfig>,
67    #[serde(
68        default,
69        rename = "loopbackExemptPorts",
70        skip_serializing_if = "Vec::is_empty"
71    )]
72    pub loopback_exempt_ports: Vec<u16>,
73    #[serde(default, rename = "jsRuntime", skip_serializing_if = "Option::is_none")]
74    #[ts(optional)]
75    pub js_runtime: Option<JsRuntimeConfig>,
76    #[serde(
77        default,
78        rename = "bootstrapCommands",
79        skip_serializing_if = "Option::is_none"
80    )]
81    #[ts(optional)]
82    pub bootstrap_commands: Option<Vec<String>>,
83}
84
85impl CreateVmConfig {
86    pub fn defaults_profile(&self) -> VmDefaultsProfile {
87        self.defaults_profile.unwrap_or_default()
88    }
89
90    /// Canonicalize set-like VM overrides before storing or applying them.
91    /// Validation also checks the raw collection size, so duplicate-heavy
92    /// input cannot bypass the admission bound by being deduplicated first.
93    pub fn normalize(&mut self) -> Result<(), VmConfigError> {
94        validate_loopback_exempt_ports(&self.loopback_exempt_ports, "loopbackExemptPorts")?;
95        if let Some(allowed) = self
96            .js_runtime
97            .as_ref()
98            .and_then(|runtime| runtime.allowed_builtins.as_ref())
99        {
100            validate_allowed_node_builtins(allowed, "jsRuntime.allowedBuiltins")?;
101        }
102        self.loopback_exempt_ports = normalize_loopback_exempt_ports(
103            std::mem::take(&mut self.loopback_exempt_ports),
104            "loopbackExemptPorts",
105        )?;
106        if let Some(allowed) = self
107            .js_runtime
108            .as_mut()
109            .and_then(|runtime| runtime.allowed_builtins.as_mut())
110        {
111            *allowed = normalize_allowed_node_builtins(
112                std::mem::take(allowed),
113                "jsRuntime.allowedBuiltins",
114            )?;
115        }
116        Ok(())
117    }
118
119    pub fn validate(&self, max_frame_bytes: usize) -> Result<(), VmConfigError> {
120        validate_loopback_exempt_ports(&self.loopback_exempt_ports, "loopbackExemptPorts")?;
121        if let Some(cwd) = self.cwd.as_deref() {
122            validate_guest_path("cwd", cwd)?;
123        }
124        if let Some(database) = &self.database {
125            database.validate()?;
126        }
127        if let Some(user) = &self.user {
128            user.validate()?;
129        }
130        self.root_filesystem.validate()?;
131        if let Some(native_root) = &self.native_root {
132            native_root.validate()?;
133        }
134        if self.native_root.is_some() && !self.root_filesystem.bootstrap_entries.is_empty() {
135            return Err(VmConfigError::new(
136                "nativeRoot does not support rootFilesystem.bootstrapEntries",
137            ));
138        }
139        if let Some(dns) = &self.dns {
140            dns.validate()?;
141        }
142        if let Some(listen) = &self.listen {
143            listen.validate()?;
144        }
145        if let Some(limits) = &self.limits {
146            limits.validate(max_frame_bytes)?;
147        }
148        if let Some(js_runtime) = &self.js_runtime {
149            js_runtime.validate()?;
150        }
151        if let Some(bootstrap_commands) = &self.bootstrap_commands {
152            validate_command_names("bootstrapCommands", bootstrap_commands)?;
153        }
154        Ok(())
155    }
156}
157
158pub const MAX_VM_ALLOWED_NODE_BUILTINS: usize = 256;
159pub const MAX_VM_ALLOWED_NODE_BUILTIN_BYTES: usize = 128;
160pub const MAX_VM_LOOPBACK_EXEMPT_PORTS: usize = 256;
161
162pub fn normalize_allowed_node_builtins(
163    mut values: Vec<String>,
164    field: &str,
165) -> Result<Vec<String>, VmConfigError> {
166    validate_allowed_node_builtins(&values, field)?;
167    values.sort();
168    values.dedup();
169    Ok(values)
170}
171
172pub fn normalize_loopback_exempt_ports(
173    mut values: Vec<u16>,
174    field: &str,
175) -> Result<Vec<u16>, VmConfigError> {
176    validate_loopback_exempt_ports(&values, field)?;
177    values.sort_unstable();
178    values.dedup();
179    Ok(values)
180}
181
182fn validate_allowed_node_builtins(values: &[String], field: &str) -> Result<(), VmConfigError> {
183    if values.len() > MAX_VM_ALLOWED_NODE_BUILTINS {
184        return Err(VmConfigError::new(format!(
185            "limit_exceeded: {field} has {} entries; maximum is {MAX_VM_ALLOWED_NODE_BUILTINS}; reduce the list or raise MAX_VM_ALLOWED_NODE_BUILTINS",
186            values.len()
187        )));
188    }
189    if let Some(value) = values
190        .iter()
191        .find(|value| value.is_empty() || value.len() > MAX_VM_ALLOWED_NODE_BUILTIN_BYTES)
192    {
193        return Err(VmConfigError::new(format!(
194            "invalid_input: {field} entry {value:?} must contain 1..={MAX_VM_ALLOWED_NODE_BUILTIN_BYTES} bytes"
195        )));
196    }
197    Ok(())
198}
199
200fn validate_loopback_exempt_ports(values: &[u16], field: &str) -> Result<(), VmConfigError> {
201    if values.len() > MAX_VM_LOOPBACK_EXEMPT_PORTS {
202        return Err(VmConfigError::new(format!(
203            "limit_exceeded: {field} has {} entries; maximum is {MAX_VM_LOOPBACK_EXEMPT_PORTS}; reduce the list or raise MAX_VM_LOOPBACK_EXEMPT_PORTS",
204            values.len()
205        )));
206    }
207    Ok(())
208}
209
210/// Transport used by the VM-scoped SQLite substrate.
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
212#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
213#[ts(tag = "type", rename_all = "snake_case")]
214#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
215pub enum VmSqliteDescriptor {
216    /// Local SQLite database for trusted embedded callers.
217    SqliteFile { path: String },
218    /// SQLite operations are delegated to the trusted host over the sidecar
219    /// extension callback lane. Hosted actors use this to bind the VM to their
220    /// Rivet SQLite database without exposing a host path.
221    HostCallback { namespace: String },
222}
223
224impl VmSqliteDescriptor {
225    fn validate(&self) -> Result<(), VmConfigError> {
226        match self {
227            Self::SqliteFile { path } => validate_absolute_host_path("database.path", path)?,
228            Self::HostCallback { namespace } => {
229                if namespace.is_empty()
230                    || namespace.len() > 128
231                    || !namespace.bytes().all(|byte| {
232                        byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')
233                    })
234                {
235                    return Err(VmConfigError::new(
236                        "database.namespace must contain 1..=128 ASCII letters, digits, '.', '-', or '_'",
237                    ));
238                }
239            }
240        }
241        Ok(())
242    }
243}
244
245pub const VM_SQLITE_CALLBACK_NAMESPACE: &str = "dev.agentos.sqlite.v1";
246
247#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
248#[serde(rename_all = "snake_case")]
249pub enum VmSqliteValue {
250    Null,
251    Integer(i64),
252    Real(f64),
253    Text(String),
254    Blob(Vec<u8>),
255}
256
257#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
258#[serde(rename_all = "camelCase", deny_unknown_fields)]
259pub struct VmSqliteStatement {
260    pub sql: String,
261    #[serde(default)]
262    pub params: Vec<VmSqliteValue>,
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub expected_changes: Option<i64>,
265}
266
267#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
268#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
269pub enum VmSqliteCallbackRequest {
270    Query {
271        database: String,
272        statement: VmSqliteStatement,
273    },
274    Transaction {
275        database: String,
276        statements: Vec<VmSqliteStatement>,
277    },
278    Close {
279        database: String,
280    },
281}
282
283#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
284#[serde(rename_all = "camelCase", deny_unknown_fields)]
285pub struct VmSqliteQueryResult {
286    pub columns: Vec<String>,
287    pub rows: Vec<Vec<VmSqliteValue>>,
288    pub changes: i64,
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub last_insert_row_id: Option<i64>,
291}
292
293#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
294#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
295pub enum VmSqliteCallbackResponse {
296    Query { result: VmSqliteQueryResult },
297    Transaction { results: Vec<VmSqliteQueryResult> },
298    Closed,
299    Error { message: String },
300}
301
302fn validate_absolute_host_path(field: &str, path: &str) -> Result<(), VmConfigError> {
303    if path.is_empty() || !path.starts_with('/') || path.as_bytes().contains(&0) {
304        return Err(VmConfigError::new(format!(
305            "{field} must be a non-empty absolute path without NUL bytes"
306        )));
307    }
308    Ok(())
309}
310
311/// Initial Linux-style credentials and account record for processes in a VM.
312#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, TS)]
313#[serde(rename_all = "camelCase", deny_unknown_fields)]
314#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
315pub struct VmUserConfig {
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    #[ts(optional)]
318    pub uid: Option<u32>,
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    #[ts(optional)]
321    pub gid: Option<u32>,
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    #[ts(optional)]
324    pub euid: Option<u32>,
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    #[ts(optional)]
327    pub egid: Option<u32>,
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    #[ts(optional)]
330    pub username: Option<String>,
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    #[ts(optional)]
333    pub homedir: Option<String>,
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    #[ts(optional)]
336    pub shell: Option<String>,
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    #[ts(optional)]
339    pub gecos: Option<String>,
340    #[serde(default, rename = "groupName", skip_serializing_if = "Option::is_none")]
341    #[ts(optional)]
342    pub group_name: Option<String>,
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    #[ts(optional)]
345    pub supplementary_gids: Option<Vec<u32>>,
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    #[ts(optional)]
348    pub accounts: Option<Vec<VmUserAccountConfig>>,
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    #[ts(optional)]
351    pub groups: Option<Vec<VmGroupConfig>>,
352}
353
354#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
355#[serde(rename_all = "camelCase", deny_unknown_fields)]
356#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
357pub struct VmUserAccountConfig {
358    pub uid: u32,
359    pub gid: u32,
360    pub username: String,
361    pub homedir: String,
362    pub shell: String,
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    #[ts(optional)]
365    pub gecos: Option<String>,
366    #[serde(default, skip_serializing_if = "Vec::is_empty")]
367    pub supplementary_gids: Vec<u32>,
368}
369
370#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
371#[serde(rename_all = "camelCase", deny_unknown_fields)]
372#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
373pub struct VmGroupConfig {
374    pub gid: u32,
375    pub name: String,
376    #[serde(default, skip_serializing_if = "Vec::is_empty")]
377    pub members: Vec<String>,
378}
379
380impl VmUserConfig {
381    fn validate(&self) -> Result<(), VmConfigError> {
382        const MAX_SUPPLEMENTARY_GIDS: usize = 64;
383        const MAX_ACCOUNTS: usize = 64;
384        const MAX_GROUPS: usize = 128;
385        if self
386            .supplementary_gids
387            .as_ref()
388            .is_some_and(|groups| groups.len() > MAX_SUPPLEMENTARY_GIDS)
389        {
390            return Err(VmConfigError::new(format!(
391                "user.supplementaryGids exceeds limit of {MAX_SUPPLEMENTARY_GIDS}"
392            )));
393        }
394        for (label, value) in [
395            ("user.username", self.username.as_deref()),
396            ("user.groupName", self.group_name.as_deref()),
397        ] {
398            if value.is_some_and(|value| {
399                value.is_empty()
400                    || value.contains([':', '\n', '\r', '\0'])
401                    || value.chars().any(char::is_whitespace)
402            }) {
403                return Err(VmConfigError::new(format!("{label} is invalid")));
404            }
405        }
406        if self
407            .gecos
408            .as_deref()
409            .is_some_and(|value| value.contains([':', '\n', '\r', '\0']))
410        {
411            return Err(VmConfigError::new("user.gecos is invalid"));
412        }
413        let accounts = self.accounts.as_deref().unwrap_or_default();
414        let groups = self.groups.as_deref().unwrap_or_default();
415        if accounts.len() > MAX_ACCOUNTS {
416            return Err(VmConfigError::new(format!(
417                "user.accounts exceeds limit of {MAX_ACCOUNTS}"
418            )));
419        }
420        if groups.len() > MAX_GROUPS {
421            return Err(VmConfigError::new(format!(
422                "user.groups exceeds limit of {MAX_GROUPS}"
423            )));
424        }
425        let mut account_uids = std::collections::BTreeSet::new();
426        let mut account_names = std::collections::BTreeSet::new();
427        for account in accounts {
428            validate_account_name("user.accounts[].username", &account.username)?;
429            validate_guest_path("user.accounts[].homedir", &account.homedir)?;
430            validate_guest_path("user.accounts[].shell", &account.shell)?;
431            if account
432                .gecos
433                .as_deref()
434                .is_some_and(|value| value.contains([':', '\n', '\r', '\0']))
435            {
436                return Err(VmConfigError::new("user.accounts[].gecos is invalid"));
437            }
438            if account.supplementary_gids.len() > MAX_SUPPLEMENTARY_GIDS {
439                return Err(VmConfigError::new(format!(
440                    "user.accounts[].supplementaryGids exceeds limit of {MAX_SUPPLEMENTARY_GIDS}"
441                )));
442            }
443            if !account_uids.insert(account.uid) {
444                return Err(VmConfigError::new(format!(
445                    "duplicate user account uid {}",
446                    account.uid
447                )));
448            }
449            if !account_names.insert(account.username.as_str()) {
450                return Err(VmConfigError::new(format!(
451                    "duplicate user account name {}",
452                    account.username
453                )));
454            }
455        }
456        let mut group_gids = std::collections::BTreeSet::new();
457        let mut group_names = std::collections::BTreeSet::new();
458        for group in groups {
459            validate_account_name("user.groups[].name", &group.name)?;
460            for member in &group.members {
461                validate_account_name("user.groups[].members[]", member)?;
462            }
463            if !group_gids.insert(group.gid) {
464                return Err(VmConfigError::new(format!(
465                    "duplicate user group gid {}",
466                    group.gid
467                )));
468            }
469            if !group_names.insert(group.name.as_str()) {
470                return Err(VmConfigError::new(format!(
471                    "duplicate user group name {}",
472                    group.name
473                )));
474            }
475        }
476        if let Some(homedir) = self.homedir.as_deref() {
477            validate_guest_path("user.homedir", homedir)?;
478        }
479        if let Some(shell) = self.shell.as_deref() {
480            validate_guest_path("user.shell", shell)?;
481        }
482        Ok(())
483    }
484}
485
486fn validate_account_name(label: &str, value: &str) -> Result<(), VmConfigError> {
487    if value.is_empty()
488        || value.contains([':', '\n', '\r', '\0'])
489        || value.chars().any(char::is_whitespace)
490    {
491        return Err(VmConfigError::new(format!("{label} is invalid")));
492    }
493    Ok(())
494}
495
496/// Guest JavaScript host-environment configuration.
497///
498/// Selects which globals/builtins/module-resolution surface guest JS sees,
499/// modeled on esbuild's `platform`. Omitting this preserves full Node.js
500/// emulation (`platform = node`).
501#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, TS)]
502#[serde(rename_all = "camelCase", deny_unknown_fields)]
503#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
504pub struct JsRuntimeConfig {
505    /// Which host environment to emulate for guest JS. Default `node`.
506    #[serde(default)]
507    pub platform: JsRuntimePlatform,
508    /// How bare import specifiers resolve. Independent of `platform`.
509    /// Default `node`.
510    #[serde(default, rename = "moduleResolution")]
511    pub module_resolution: JsModuleResolution,
512    /// Node builtin-module allow-list. Only valid when `platform = node`.
513    /// `None` => engine default allow-list. `Some([])` => deny all builtins.
514    /// `Some([..])` => exactly those.
515    #[serde(
516        default,
517        rename = "allowedBuiltins",
518        skip_serializing_if = "Option::is_none"
519    )]
520    #[ts(optional)]
521    pub allowed_builtins: Option<Vec<String>>,
522    /// Opt in to a high-resolution monotonic guest clock. Default false keeps
523    /// the security-oriented 1ms timer resolution.
524    #[serde(
525        default,
526        rename = "highResolutionTime",
527        skip_serializing_if = "Option::is_none"
528    )]
529    #[ts(optional)]
530    pub high_resolution_time: Option<bool>,
531}
532
533impl JsRuntimeConfig {
534    fn validate(&self) -> Result<(), VmConfigError> {
535        if let Some(allowed) = &self.allowed_builtins {
536            validate_allowed_node_builtins(allowed, "jsRuntime.allowedBuiltins")?;
537            if self.platform != JsRuntimePlatform::Node {
538                return Err(VmConfigError::new(
539                    "jsRuntime.allowedBuiltins is only valid when jsRuntime.platform is \"node\"",
540                ));
541            }
542            for name in allowed {
543                if !is_known_node_builtin(name) {
544                    return Err(VmConfigError::new(format!(
545                        "jsRuntime.allowedBuiltins contains unknown builtin {name:?}"
546                    )));
547                }
548            }
549        }
550        Ok(())
551    }
552}
553
554#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
555#[serde(rename_all = "lowercase")]
556#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
557#[derive(Default)]
558pub enum JsRuntimePlatform {
559    /// Full Node.js host surface (process/Buffer/require, `node:*`, npm
560    /// resolution, virtual Node identity). Default.
561    #[default]
562    Node,
563    /// Web-platform globals (fetch/URL/WebCrypto/...), no Node surface.
564    Browser,
565    /// Universal primitives only (console, timers, queueMicrotask) — no web
566    /// platform, no Node surface.
567    Neutral,
568    /// Language-only: ECMAScript spec globals + WebAssembly. Nothing host-provided.
569    Bare,
570}
571
572#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
573#[serde(rename_all = "lowercase")]
574#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
575#[derive(Default)]
576pub enum JsModuleResolution {
577    /// node_modules ancestor-walk + exports/imports/conditions + realpath. Default.
578    #[default]
579    Node,
580    /// Relative/absolute ESM from the VFS only; bare specifiers do not resolve.
581    Relative,
582    /// No resolution: any import/require (even relative) fails.
583    None,
584}
585
586/// Canonical set of recognized Node builtin module names (without the `node:`
587/// prefix), kept in sync with `normalize_builtin_specifier` in
588/// `crates/execution/src/javascript.rs`. Used to validate
589/// `jsRuntime.allowedBuiltins` entries.
590const KNOWN_NODE_BUILTINS: &[&str] = &[
591    "assert",
592    "async_hooks",
593    "buffer",
594    "child_process",
595    "cluster",
596    "console",
597    "constants",
598    "crypto",
599    "dgram",
600    "diagnostics_channel",
601    "dns",
602    "dns/promises",
603    "domain",
604    "events",
605    "fs",
606    "fs/promises",
607    "http",
608    "http2",
609    "https",
610    "inspector",
611    "module",
612    "net",
613    "os",
614    "path",
615    "path/posix",
616    "path/win32",
617    "perf_hooks",
618    "process",
619    "punycode",
620    "querystring",
621    "readline",
622    "repl",
623    "sqlite",
624    "stream",
625    "stream/consumers",
626    "stream/promises",
627    "stream/web",
628    "string_decoder",
629    "sys",
630    "timers",
631    "timers/promises",
632    "tls",
633    "trace_events",
634    "tty",
635    "url",
636    "util",
637    "util/types",
638    "v8",
639    "vm",
640    "wasi",
641    "worker_threads",
642    "zlib",
643];
644
645fn is_known_node_builtin(name: &str) -> bool {
646    let bare = name.strip_prefix("node:").unwrap_or(name);
647    KNOWN_NODE_BUILTINS.contains(&bare)
648}
649
650#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
651#[serde(rename_all = "camelCase", deny_unknown_fields)]
652#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
653pub struct RootFilesystemConfig {
654    #[serde(default)]
655    pub mode: RootFilesystemMode,
656    #[serde(default, rename = "disableDefaultBaseLayer")]
657    pub disable_default_base_layer: bool,
658    #[serde(default, skip_serializing_if = "Vec::is_empty")]
659    pub lowers: Vec<RootFilesystemLowerDescriptor>,
660    #[serde(
661        default,
662        rename = "bootstrapEntries",
663        skip_serializing_if = "Vec::is_empty"
664    )]
665    pub bootstrap_entries: Vec<RootFilesystemEntry>,
666}
667
668impl Default for RootFilesystemConfig {
669    fn default() -> Self {
670        Self {
671            mode: RootFilesystemMode::Ephemeral,
672            disable_default_base_layer: false,
673            lowers: Vec::new(),
674            bootstrap_entries: Vec::new(),
675        }
676    }
677}
678
679impl RootFilesystemConfig {
680    fn validate(&self) -> Result<(), VmConfigError> {
681        for lower in &self.lowers {
682            if let RootFilesystemLowerDescriptor::Snapshot { entries } = lower {
683                for entry in entries {
684                    entry.validate()?;
685                }
686            }
687        }
688        for entry in &self.bootstrap_entries {
689            entry.validate()?;
690        }
691        Ok(())
692    }
693}
694
695#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
696#[serde(rename_all = "kebab-case")]
697#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
698#[derive(Default)]
699pub enum RootFilesystemMode {
700    #[default]
701    Ephemeral,
702    ReadOnly,
703}
704
705#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
706#[serde(tag = "kind", rename_all = "camelCase")]
707#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
708pub enum RootFilesystemLowerDescriptor {
709    Snapshot {
710        #[serde(default)]
711        entries: Vec<RootFilesystemEntry>,
712    },
713    BundledBaseFilesystem,
714}
715
716#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
717#[serde(rename_all = "camelCase", deny_unknown_fields)]
718#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
719pub struct RootFilesystemEntry {
720    pub path: String,
721    pub kind: RootFilesystemEntryKind,
722    #[serde(default, skip_serializing_if = "Option::is_none")]
723    #[ts(optional)]
724    pub mode: Option<u32>,
725    #[serde(default, skip_serializing_if = "Option::is_none")]
726    #[ts(optional)]
727    pub uid: Option<u32>,
728    #[serde(default, skip_serializing_if = "Option::is_none")]
729    #[ts(optional)]
730    pub gid: Option<u32>,
731    #[serde(default, skip_serializing_if = "Option::is_none")]
732    #[ts(optional)]
733    pub content: Option<String>,
734    #[serde(default, skip_serializing_if = "Option::is_none")]
735    #[ts(optional)]
736    pub encoding: Option<RootFilesystemEntryEncoding>,
737    #[serde(default, skip_serializing_if = "Option::is_none")]
738    #[ts(optional)]
739    pub target: Option<String>,
740    #[serde(default)]
741    pub executable: bool,
742}
743
744impl RootFilesystemEntry {
745    fn validate(&self) -> Result<(), VmConfigError> {
746        validate_guest_path("root filesystem entry path", &self.path)?;
747        match self.kind {
748            RootFilesystemEntryKind::File => {
749                if self.target.is_some() {
750                    return Err(VmConfigError::new(format!(
751                        "file entry {} must not include target",
752                        self.path
753                    )));
754                }
755            }
756            RootFilesystemEntryKind::Directory => {
757                if self.content.is_some() || self.encoding.is_some() || self.target.is_some() {
758                    return Err(VmConfigError::new(format!(
759                        "directory entry {} must not include content, encoding, or target",
760                        self.path
761                    )));
762                }
763            }
764            RootFilesystemEntryKind::Symlink => {
765                if self.target.as_deref().unwrap_or("").is_empty() {
766                    return Err(VmConfigError::new(format!(
767                        "symlink entry {} requires target",
768                        self.path
769                    )));
770                }
771                if self.content.is_some() || self.encoding.is_some() {
772                    return Err(VmConfigError::new(format!(
773                        "symlink entry {} must not include content or encoding",
774                        self.path
775                    )));
776                }
777            }
778        }
779        Ok(())
780    }
781}
782
783#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
784#[serde(rename_all = "lowercase")]
785#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
786pub enum RootFilesystemEntryKind {
787    File,
788    Directory,
789    Symlink,
790}
791
792#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
793#[serde(rename_all = "lowercase")]
794#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
795pub enum RootFilesystemEntryEncoding {
796    Utf8,
797    Base64,
798}
799
800#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
801#[serde(rename_all = "camelCase", deny_unknown_fields)]
802#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
803pub struct NativeRootFilesystemConfig {
804    pub plugin: MountPluginDescriptor,
805    #[serde(default, rename = "readOnly")]
806    pub read_only: bool,
807}
808
809impl NativeRootFilesystemConfig {
810    fn validate(&self) -> Result<(), VmConfigError> {
811        if self.plugin.id.trim().is_empty() {
812            return Err(VmConfigError::new("nativeRoot.plugin.id is required"));
813        }
814        Ok(())
815    }
816}
817
818#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
819#[serde(rename_all = "camelCase", deny_unknown_fields)]
820#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
821pub struct MountPluginDescriptor {
822    pub id: String,
823    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
824    #[ts(type = "import(\"@rivet-dev/agentos-runtime-core/descriptors\").MountConfigJsonValue")]
825    pub config: serde_json::Value,
826}
827
828#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
829#[serde(rename_all = "lowercase")]
830#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
831pub enum PermissionMode {
832    Allow,
833    Ask,
834    Deny,
835}
836
837#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
838#[serde(untagged)]
839#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
840pub enum FsPermissionScope {
841    Mode(PermissionMode),
842    Rules(FsPermissionRuleSet),
843}
844
845#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
846#[serde(untagged)]
847#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
848pub enum PatternPermissionScope {
849    Mode(PermissionMode),
850    Rules(PatternPermissionRuleSet),
851}
852
853#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
854#[serde(rename_all = "camelCase", deny_unknown_fields)]
855#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
856pub struct FsPermissionRuleSet {
857    #[serde(default, skip_serializing_if = "Option::is_none")]
858    #[ts(optional)]
859    pub default: Option<PermissionMode>,
860    #[serde(default)]
861    pub rules: Vec<FsPermissionRule>,
862}
863
864#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
865#[serde(rename_all = "camelCase", deny_unknown_fields)]
866#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
867pub struct PatternPermissionRuleSet {
868    #[serde(default, skip_serializing_if = "Option::is_none")]
869    #[ts(optional)]
870    pub default: Option<PermissionMode>,
871    #[serde(default)]
872    pub rules: Vec<PatternPermissionRule>,
873}
874
875#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
876#[serde(rename_all = "camelCase", deny_unknown_fields)]
877#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
878pub struct FsPermissionRule {
879    pub mode: PermissionMode,
880    #[serde(default, skip_serializing_if = "Vec::is_empty")]
881    pub operations: Vec<String>,
882    #[serde(default, skip_serializing_if = "Vec::is_empty")]
883    pub paths: Vec<String>,
884}
885
886#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
887#[serde(rename_all = "camelCase", deny_unknown_fields)]
888#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
889pub struct PatternPermissionRule {
890    pub mode: PermissionMode,
891    #[serde(default, skip_serializing_if = "Vec::is_empty")]
892    pub operations: Vec<String>,
893    #[serde(default, skip_serializing_if = "Vec::is_empty")]
894    pub patterns: Vec<String>,
895}
896
897#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
898#[serde(rename_all = "camelCase", deny_unknown_fields)]
899#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
900pub struct PermissionsPolicy {
901    #[serde(default, skip_serializing_if = "Option::is_none")]
902    #[ts(optional)]
903    pub fs: Option<FsPermissionScope>,
904    #[serde(default, skip_serializing_if = "Option::is_none")]
905    #[ts(optional)]
906    pub network: Option<PatternPermissionScope>,
907    #[serde(
908        default,
909        rename = "childProcess",
910        skip_serializing_if = "Option::is_none"
911    )]
912    #[ts(optional)]
913    pub child_process: Option<PatternPermissionScope>,
914    #[serde(default, skip_serializing_if = "Option::is_none")]
915    #[ts(optional)]
916    pub process: Option<PatternPermissionScope>,
917    #[serde(default, skip_serializing_if = "Option::is_none")]
918    #[ts(optional)]
919    pub env: Option<PatternPermissionScope>,
920    #[serde(default, skip_serializing_if = "Option::is_none")]
921    #[ts(optional)]
922    pub host_function: Option<PatternPermissionScope>,
923}
924
925#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
926#[serde(rename_all = "camelCase", deny_unknown_fields)]
927#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
928pub struct VmLimitsConfig {
929    #[serde(default, skip_serializing_if = "Option::is_none")]
930    #[ts(optional)]
931    pub reactor: Option<ReactorLimitsConfig>,
932    #[serde(default, skip_serializing_if = "Option::is_none")]
933    #[ts(optional)]
934    pub resources: Option<ResourceLimitsConfig>,
935    #[serde(default, skip_serializing_if = "Option::is_none")]
936    #[ts(optional)]
937    pub http: Option<HttpLimitsConfig>,
938    #[serde(default, skip_serializing_if = "Option::is_none")]
939    #[ts(optional)]
940    pub udp: Option<UdpLimitsConfig>,
941    #[serde(default, skip_serializing_if = "Option::is_none")]
942    #[ts(optional)]
943    pub tls: Option<TlsLimitsConfig>,
944    #[serde(default, skip_serializing_if = "Option::is_none")]
945    #[ts(optional)]
946    pub http2: Option<Http2LimitsConfig>,
947    #[serde(default, skip_serializing_if = "Option::is_none")]
948    #[ts(optional)]
949    pub host_functions: Option<HostFunctionLimitsConfig>,
950    #[serde(default, skip_serializing_if = "Option::is_none")]
951    #[ts(optional)]
952    pub plugins: Option<PluginLimitsConfig>,
953    #[serde(default, skip_serializing_if = "Option::is_none")]
954    #[ts(optional)]
955    pub sqlite: Option<SqliteLimitsConfig>,
956    #[serde(default, rename = "jsRuntime", skip_serializing_if = "Option::is_none")]
957    #[ts(optional)]
958    pub js_runtime: Option<JsRuntimeLimitsConfig>,
959    #[serde(default, skip_serializing_if = "Option::is_none")]
960    #[ts(optional)]
961    pub python: Option<PythonLimitsConfig>,
962    #[serde(default, skip_serializing_if = "Option::is_none")]
963    #[ts(optional)]
964    pub wasm: Option<WasmLimitsConfig>,
965    #[serde(default, skip_serializing_if = "Option::is_none")]
966    #[ts(optional)]
967    pub execution: Option<ExecutionLimitsConfig>,
968    #[serde(default, skip_serializing_if = "Option::is_none")]
969    #[ts(optional)]
970    pub process: Option<ProcessLimitsConfig>,
971    #[serde(
972        default,
973        rename = "agentosPackages",
974        skip_serializing_if = "Option::is_none"
975    )]
976    #[ts(optional)]
977    pub agentos_packages: Option<AgentOsPackageLimitsConfig>,
978}
979
980impl VmLimitsConfig {
981    fn validate(&self, max_frame_bytes: usize) -> Result<(), VmConfigError> {
982        if let Some(packages) = &self.agentos_packages {
983            validate_nonzero_options([("limits.agentosPackages.maxMounts", packages.max_mounts)])?;
984        }
985        if let Some(reactor) = &self.reactor {
986            validate_nonzero_options([
987                ("limits.reactor.maxCapabilities", reactor.max_capabilities),
988                ("limits.reactor.maxReadyHandles", reactor.max_ready_handles),
989                ("limits.reactor.maxTasks", reactor.max_tasks),
990                ("limits.reactor.workQuantum", reactor.work_quantum),
991                ("limits.reactor.byteQuantum", reactor.byte_quantum),
992                (
993                    "limits.reactor.maxHandleCommands",
994                    reactor.max_handle_commands,
995                ),
996                (
997                    "limits.reactor.maxHandleCommandBytes",
998                    reactor.max_handle_command_bytes,
999                ),
1000                ("limits.reactor.maxBridgeCalls", reactor.max_bridge_calls),
1001                (
1002                    "limits.reactor.maxBridgeRequestBytes",
1003                    reactor.max_bridge_request_bytes,
1004                ),
1005                (
1006                    "limits.reactor.maxBridgeResponseBytes",
1007                    reactor.max_bridge_response_bytes,
1008                ),
1009                (
1010                    "limits.reactor.maxAsyncCompletions",
1011                    reactor.max_async_completions,
1012                ),
1013                (
1014                    "limits.reactor.maxAsyncCompletionBytes",
1015                    reactor.max_async_completion_bytes,
1016                ),
1017                ("limits.reactor.maxBlockingJobs", reactor.max_blocking_jobs),
1018                (
1019                    "limits.reactor.maxBlockingBytes",
1020                    reactor.max_blocking_bytes,
1021                ),
1022                (
1023                    "limits.reactor.perHandleOperationQuantum",
1024                    reactor.per_handle_operation_quantum,
1025                ),
1026                ("limits.reactor.acceptQuantum", reactor.accept_quantum),
1027                ("limits.reactor.datagramQuantum", reactor.datagram_quantum),
1028                (
1029                    "limits.reactor.completionQuantum",
1030                    reactor.completion_quantum,
1031                ),
1032                ("limits.reactor.signalQuantum", reactor.signal_quantum),
1033                (
1034                    "limits.reactor.shutdownDeadlineMs",
1035                    reactor.shutdown_deadline_ms,
1036                ),
1037                (
1038                    "limits.reactor.operationDeadlineMs",
1039                    reactor.operation_deadline_ms,
1040                ),
1041            ])?;
1042            validate_optional_parent(
1043                "limits.reactor.maxCapabilities",
1044                reactor.max_capabilities,
1045                "limits.reactor.maxReadyHandles",
1046                reactor.max_ready_handles,
1047            )?;
1048            validate_optional_parent(
1049                "limits.reactor.perHandleOperationQuantum",
1050                reactor.per_handle_operation_quantum,
1051                "limits.reactor.maxHandleCommands",
1052                reactor.max_handle_commands,
1053            )?;
1054            validate_optional_parent(
1055                "limits.reactor.acceptQuantum",
1056                reactor.accept_quantum,
1057                "limits.reactor.maxCapabilities",
1058                reactor.max_capabilities,
1059            )?;
1060            validate_optional_parent(
1061                "limits.reactor.completionQuantum",
1062                reactor.completion_quantum,
1063                "limits.reactor.maxAsyncCompletions",
1064                reactor.max_async_completions,
1065            )?;
1066            if let Some(max_bridge_request_bytes) = reactor.max_bridge_request_bytes {
1067                if max_bridge_request_bytes > max_frame_bytes as u64 {
1068                    return Err(VmConfigError::new(format!(
1069                        "limits.reactor.maxBridgeRequestBytes ({max_bridge_request_bytes}) must \
1070                         be <= the sidecar wire frame cap ({max_frame_bytes})"
1071                    )));
1072                }
1073            }
1074            if let Some(max_bridge_response_bytes) = reactor.max_bridge_response_bytes {
1075                if max_bridge_response_bytes > max_frame_bytes as u64 {
1076                    return Err(VmConfigError::new(format!(
1077                        "limits.reactor.maxBridgeResponseBytes ({max_bridge_response_bytes}) must \
1078                         be <= the sidecar wire frame cap ({max_frame_bytes})"
1079                    )));
1080                }
1081            }
1082        }
1083        if let Some(http) = &self.http {
1084            if let Some(max_fetch_response_bytes) = http.max_fetch_response_bytes {
1085                if max_fetch_response_bytes == 0 {
1086                    return Err(VmConfigError::new(
1087                        "limits.http.maxFetchResponseBytes must be greater than zero",
1088                    ));
1089                }
1090                if max_fetch_response_bytes as usize > max_frame_bytes {
1091                    return Err(VmConfigError::new(format!(
1092                        "limits.http.maxFetchResponseBytes ({max_fetch_response_bytes}) must be <= the sidecar wire frame cap ({max_frame_bytes})"
1093                    )));
1094                }
1095            }
1096        }
1097        if let Some(udp) = &self.udp {
1098            validate_nonzero_options([
1099                (
1100                    "limits.udp.maxBufferedDatagrams",
1101                    udp.max_buffered_datagrams,
1102                ),
1103                ("limits.udp.maxBufferedBytes", udp.max_buffered_bytes),
1104            ])?;
1105        }
1106        if let Some(tls) = &self.tls {
1107            validate_nonzero_options([("limits.tls.maxBufferedBytes", tls.max_buffered_bytes)])?;
1108        }
1109        if let Some(http2) = &self.http2 {
1110            validate_nonzero_options([
1111                ("limits.http2.maxConnections", http2.max_connections),
1112                ("limits.http2.maxStreams", http2.max_streams),
1113                (
1114                    "limits.http2.maxStreamsPerConnection",
1115                    http2.max_streams_per_connection,
1116                ),
1117                ("limits.http2.maxBufferedBytes", http2.max_buffered_bytes),
1118                ("limits.http2.maxHeaderBytes", http2.max_header_bytes),
1119                ("limits.http2.maxDataBytes", http2.max_data_bytes),
1120                (
1121                    "limits.http2.maxPendingCommands",
1122                    http2.max_pending_commands,
1123                ),
1124                (
1125                    "limits.http2.maxPendingCommandBytes",
1126                    http2.max_pending_command_bytes,
1127                ),
1128                ("limits.http2.maxPendingEvents", http2.max_pending_events),
1129                (
1130                    "limits.http2.maxPendingEventBytes",
1131                    http2.max_pending_event_bytes,
1132                ),
1133            ])?;
1134            validate_optional_parent(
1135                "limits.http2.maxStreamsPerConnection",
1136                http2.max_streams_per_connection,
1137                "limits.http2.maxStreams",
1138                http2.max_streams,
1139            )?;
1140            for (path, value) in [
1141                ("limits.http2.maxHeaderBytes", http2.max_header_bytes),
1142                ("limits.http2.maxDataBytes", http2.max_data_bytes),
1143                (
1144                    "limits.http2.maxPendingCommandBytes",
1145                    http2.max_pending_command_bytes,
1146                ),
1147                (
1148                    "limits.http2.maxPendingEventBytes",
1149                    http2.max_pending_event_bytes,
1150                ),
1151            ] {
1152                validate_optional_parent(
1153                    path,
1154                    value,
1155                    "limits.http2.maxBufferedBytes",
1156                    http2.max_buffered_bytes,
1157                )?;
1158            }
1159        }
1160        if let Some(resources) = &self.resources {
1161            let aggregate_socket_bytes = resources.max_socket_buffered_bytes;
1162            for (path, value) in [
1163                (
1164                    "limits.reactor.maxHandleCommandBytes",
1165                    self.reactor
1166                        .as_ref()
1167                        .and_then(|limits| limits.max_handle_command_bytes),
1168                ),
1169                (
1170                    "limits.http.maxFetchResponseBytes",
1171                    self.http
1172                        .as_ref()
1173                        .and_then(|limits| limits.max_fetch_response_bytes),
1174                ),
1175                (
1176                    "limits.udp.maxBufferedBytes",
1177                    self.udp
1178                        .as_ref()
1179                        .and_then(|limits| limits.max_buffered_bytes),
1180                ),
1181                (
1182                    "limits.tls.maxBufferedBytes",
1183                    self.tls
1184                        .as_ref()
1185                        .and_then(|limits| limits.max_buffered_bytes),
1186                ),
1187                (
1188                    "limits.http2.maxBufferedBytes",
1189                    self.http2
1190                        .as_ref()
1191                        .and_then(|limits| limits.max_buffered_bytes),
1192                ),
1193            ] {
1194                validate_optional_parent(
1195                    path,
1196                    value,
1197                    "limits.resources.maxSocketBufferedBytes",
1198                    aggregate_socket_bytes,
1199                )?;
1200            }
1201            validate_optional_parent(
1202                "limits.udp.maxBufferedDatagrams",
1203                self.udp
1204                    .as_ref()
1205                    .and_then(|limits| limits.max_buffered_datagrams),
1206                "limits.resources.maxSocketDatagramQueueLen",
1207                resources.max_socket_datagram_queue_len,
1208            )?;
1209            validate_optional_parent(
1210                "limits.http2.maxConnections",
1211                self.http2
1212                    .as_ref()
1213                    .and_then(|limits| limits.max_connections),
1214                "limits.resources.maxConnections",
1215                resources.max_connections,
1216            )?;
1217        }
1218        if let (Some(reactor), Some(udp)) = (&self.reactor, &self.udp) {
1219            validate_optional_parent(
1220                "limits.reactor.datagramQuantum",
1221                reactor.datagram_quantum,
1222                "limits.udp.maxBufferedDatagrams",
1223                udp.max_buffered_datagrams,
1224            )?;
1225        }
1226        if let Some(host_functions) = &self.host_functions {
1227            if let (Some(default), Some(max)) = (
1228                host_functions.default_timeout_ms,
1229                host_functions.max_timeout_ms,
1230            ) {
1231                if default > max {
1232                    return Err(VmConfigError::new(
1233                        "limits.hostFunctions.defaultTimeoutMs must be <= limits.hostFunctions.maxTimeoutMs",
1234                    ));
1235                }
1236            }
1237        }
1238        if let Some(js_runtime) = &self.js_runtime {
1239            validate_nonzero_options([("limits.jsRuntime.maxTimers", js_runtime.max_timers)])?;
1240        }
1241        if let Some(execution) = &self.execution {
1242            validate_nonzero_options([
1243                (
1244                    "limits.execution.completedTtlMs",
1245                    execution.completed_ttl_ms,
1246                ),
1247                (
1248                    "limits.execution.maxCompletedExecutions",
1249                    execution.max_completed_executions,
1250                ),
1251                (
1252                    "limits.execution.liveExecutionWarningThreshold",
1253                    execution.live_execution_warning_threshold,
1254                ),
1255            ])?;
1256            const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
1257            for (path, value) in [
1258                (
1259                    "limits.execution.completedTtlMs",
1260                    execution.completed_ttl_ms,
1261                ),
1262                (
1263                    "limits.execution.maxCompletedExecutions",
1264                    execution.max_completed_executions,
1265                ),
1266                (
1267                    "limits.execution.liveExecutionWarningThreshold",
1268                    execution.live_execution_warning_threshold,
1269                ),
1270            ] {
1271                if value.is_some_and(|value| value > MAX_SAFE_INTEGER) {
1272                    return Err(VmConfigError::new(format!("{path} must be a safe integer")));
1273                }
1274            }
1275        }
1276        if let Some(process) = &self.process {
1277            validate_nonzero_options([
1278                (
1279                    "limits.process.maxSpawnFileActions",
1280                    process.max_spawn_file_actions,
1281                ),
1282                (
1283                    "limits.process.maxSpawnFileActionBytes",
1284                    process.max_spawn_file_action_bytes,
1285                ),
1286                (
1287                    "limits.process.pendingStdinBytes",
1288                    process.pending_stdin_bytes,
1289                ),
1290                (
1291                    "limits.process.pendingEventCount",
1292                    process.pending_event_count,
1293                ),
1294                (
1295                    "limits.process.pendingEventBytes",
1296                    process.pending_event_bytes,
1297                ),
1298                (
1299                    "limits.process.outputReplayEvents",
1300                    process.output_replay_events,
1301                ),
1302                (
1303                    "limits.process.outputReplayBytes",
1304                    process.output_replay_bytes,
1305                ),
1306                (
1307                    "limits.process.outputReplayPageEvents",
1308                    process.output_replay_page_events,
1309                ),
1310                (
1311                    "limits.process.outputReplayPageBytes",
1312                    process.output_replay_page_bytes,
1313                ),
1314                (
1315                    "limits.process.maxOutputReplays",
1316                    process.max_output_replays,
1317                ),
1318            ])?;
1319        }
1320        Ok(())
1321    }
1322}
1323
1324fn validate_nonzero_options<const N: usize>(
1325    values: [(&str, Option<u64>); N],
1326) -> Result<(), VmConfigError> {
1327    for (path, value) in values {
1328        if value == Some(0) {
1329            return Err(VmConfigError::new(format!(
1330                "{path} must be greater than zero"
1331            )));
1332        }
1333    }
1334    Ok(())
1335}
1336
1337fn validate_optional_parent(
1338    child_path: &str,
1339    child: Option<u64>,
1340    parent_path: &str,
1341    parent: Option<u64>,
1342) -> Result<(), VmConfigError> {
1343    if let (Some(child), Some(parent)) = (child, parent) {
1344        if child > parent {
1345            return Err(VmConfigError::new(format!(
1346                "{child_path} ({child}) must be <= {parent_path} ({parent})"
1347            )));
1348        }
1349    }
1350    Ok(())
1351}
1352
1353macro_rules! limits_struct {
1354    ($name:ident { $($field:ident),* $(,)? }) => {
1355        #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
1356        #[serde(rename_all = "camelCase", deny_unknown_fields)]
1357        #[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
1358        pub struct $name {
1359            $(
1360                #[serde(default, skip_serializing_if = "Option::is_none")]
1361                #[ts(optional)]
1362                #[ts(type = "number")]
1363                pub $field: Option<u64>,
1364            )*
1365        }
1366    };
1367}
1368
1369limits_struct!(ResourceLimitsConfig {
1370    cpu_count,
1371    max_processes,
1372    max_open_fds,
1373    max_pipes,
1374    max_ptys,
1375    max_sockets,
1376    max_connections,
1377    max_socket_buffered_bytes,
1378    max_socket_datagram_queue_len,
1379    max_filesystem_bytes,
1380    max_inode_count,
1381    max_blocking_read_ms,
1382    max_pread_bytes,
1383    max_fd_write_bytes,
1384    max_process_argv_bytes,
1385    max_process_env_bytes,
1386    max_readdir_entries,
1387    max_recursive_fs_depth,
1388    max_recursive_fs_entries,
1389    max_wasm_fuel,
1390    max_wasm_memory_bytes,
1391    max_wasm_stack_bytes,
1392});
1393
1394limits_struct!(ReactorLimitsConfig {
1395    max_capabilities,
1396    max_ready_handles,
1397    max_tasks,
1398    work_quantum,
1399    byte_quantum,
1400    max_handle_commands,
1401    max_handle_command_bytes,
1402    max_bridge_calls,
1403    max_bridge_request_bytes,
1404    max_bridge_response_bytes,
1405    max_async_completions,
1406    max_async_completion_bytes,
1407    max_blocking_jobs,
1408    max_blocking_bytes,
1409    per_handle_operation_quantum,
1410    accept_quantum,
1411    datagram_quantum,
1412    completion_quantum,
1413    signal_quantum,
1414    shutdown_deadline_ms,
1415    operation_deadline_ms,
1416});
1417
1418limits_struct!(HttpLimitsConfig {
1419    max_fetch_response_bytes,
1420});
1421
1422limits_struct!(UdpLimitsConfig {
1423    max_buffered_datagrams,
1424    max_buffered_bytes,
1425});
1426
1427limits_struct!(TlsLimitsConfig { max_buffered_bytes });
1428
1429limits_struct!(Http2LimitsConfig {
1430    max_connections,
1431    max_streams,
1432    max_streams_per_connection,
1433    max_buffered_bytes,
1434    max_header_bytes,
1435    max_data_bytes,
1436    max_pending_commands,
1437    max_pending_command_bytes,
1438    max_pending_events,
1439    max_pending_event_bytes,
1440});
1441
1442limits_struct!(HostFunctionLimitsConfig {
1443    default_timeout_ms,
1444    max_timeout_ms,
1445    max_registered_collections,
1446    max_registered_functions_per_vm,
1447    max_functions_per_collection,
1448    max_schema_bytes,
1449    max_examples_per_function,
1450    max_example_input_bytes,
1451});
1452
1453limits_struct!(PluginLimitsConfig {
1454    max_persisted_manifest_bytes,
1455    max_persisted_manifest_file_bytes,
1456});
1457
1458limits_struct!(SqliteLimitsConfig { max_result_bytes });
1459
1460limits_struct!(AgentOsPackageLimitsConfig { max_mounts });
1461
1462limits_struct!(JsRuntimeLimitsConfig {
1463    v8_heap_limit_mb,
1464    sync_rpc_wait_timeout_ms,
1465    cpu_time_limit_ms,
1466    wall_clock_limit_ms,
1467    import_cache_materialize_timeout_ms,
1468    captured_output_limit_bytes,
1469    stdin_buffer_limit_bytes,
1470    event_payload_limit_bytes,
1471    max_timers,
1472    v8_ipc_max_frame_bytes,
1473});
1474
1475limits_struct!(PythonLimitsConfig {
1476    output_buffer_max_bytes,
1477    execution_timeout_ms,
1478    max_old_space_mb,
1479    vfs_rpc_timeout_ms,
1480});
1481
1482limits_struct!(WasmLimitsConfig {
1483    max_module_file_bytes,
1484    captured_output_limit_bytes,
1485    sync_read_limit_bytes,
1486    prewarm_timeout_ms,
1487    runner_heap_limit_mb,
1488    runner_cpu_time_limit_ms,
1489});
1490
1491limits_struct!(ExecutionLimitsConfig {
1492    completed_ttl_ms,
1493    max_completed_executions,
1494    live_execution_warning_threshold,
1495});
1496
1497limits_struct!(ProcessLimitsConfig {
1498    max_spawn_file_actions,
1499    max_spawn_file_action_bytes,
1500    pending_stdin_bytes,
1501    pending_event_count,
1502    pending_event_bytes,
1503    output_replay_events,
1504    output_replay_bytes,
1505    output_replay_page_events,
1506    output_replay_page_bytes,
1507    max_output_replays,
1508});
1509
1510#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
1511#[serde(rename_all = "camelCase", deny_unknown_fields)]
1512#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
1513pub struct VmDnsConfig {
1514    #[serde(default, rename = "nameServers", skip_serializing_if = "Vec::is_empty")]
1515    pub name_servers: Vec<String>,
1516    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1517    pub overrides: BTreeMap<String, Vec<String>>,
1518}
1519
1520impl VmDnsConfig {
1521    fn validate(&self) -> Result<(), VmConfigError> {
1522        for entry in &self.name_servers {
1523            if entry.trim().is_empty() {
1524                return Err(VmConfigError::new(
1525                    "dns.nameServers entries must not be empty",
1526                ));
1527            }
1528        }
1529        for (host, addresses) in &self.overrides {
1530            if host.trim().is_empty() {
1531                return Err(VmConfigError::new("dns.overrides keys must not be empty"));
1532            }
1533            if addresses.is_empty() {
1534                return Err(VmConfigError::new(format!(
1535                    "dns.overrides.{host} must contain at least one address"
1536                )));
1537            }
1538        }
1539        Ok(())
1540    }
1541}
1542
1543#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
1544#[serde(rename_all = "camelCase", deny_unknown_fields)]
1545#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
1546pub struct VmListenPolicyConfig {
1547    #[serde(default, rename = "portMin", skip_serializing_if = "Option::is_none")]
1548    #[ts(optional)]
1549    pub port_min: Option<u16>,
1550    #[serde(default, rename = "portMax", skip_serializing_if = "Option::is_none")]
1551    #[ts(optional)]
1552    pub port_max: Option<u16>,
1553    #[serde(
1554        default,
1555        rename = "allowPrivileged",
1556        skip_serializing_if = "Option::is_none"
1557    )]
1558    #[ts(optional)]
1559    pub allow_privileged: Option<bool>,
1560}
1561
1562impl VmListenPolicyConfig {
1563    fn validate(&self) -> Result<(), VmConfigError> {
1564        if self.port_min == Some(0) {
1565            return Err(VmConfigError::new(
1566                "listen.portMin must be between 1 and 65535",
1567            ));
1568        }
1569        if self.port_max == Some(0) {
1570            return Err(VmConfigError::new(
1571                "listen.portMax must be between 1 and 65535",
1572            ));
1573        }
1574        if let (Some(min), Some(max)) = (self.port_min, self.port_max) {
1575            if min > max {
1576                return Err(VmConfigError::new(
1577                    "listen.portMin must be <= listen.portMax",
1578                ));
1579            }
1580        }
1581        Ok(())
1582    }
1583}
1584
1585#[derive(Debug, Clone, PartialEq, Eq)]
1586pub struct VmConfigError {
1587    message: String,
1588}
1589
1590impl VmConfigError {
1591    pub fn new(message: impl Into<String>) -> Self {
1592        Self {
1593            message: message.into(),
1594        }
1595    }
1596}
1597
1598impl std::fmt::Display for VmConfigError {
1599    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1600        f.write_str(&self.message)
1601    }
1602}
1603
1604impl std::error::Error for VmConfigError {}
1605
1606fn validate_guest_path(label: &str, path: &str) -> Result<(), VmConfigError> {
1607    if !path.starts_with('/') {
1608        return Err(VmConfigError::new(format!("{label} must be absolute")));
1609    }
1610    if path.split('/').any(|part| part == "..") {
1611        return Err(VmConfigError::new(format!("{label} must not contain '..'")));
1612    }
1613    Ok(())
1614}
1615
1616fn validate_command_names(label: &str, commands: &[String]) -> Result<(), VmConfigError> {
1617    for command in commands {
1618        if command.is_empty()
1619            || command == "."
1620            || command == ".."
1621            || command.contains('/')
1622            || command.contains('\0')
1623        {
1624            return Err(VmConfigError::new(format!(
1625                "{label} contains invalid command name {command:?}"
1626            )));
1627        }
1628    }
1629
1630    Ok(())
1631}
1632
1633#[cfg(test)]
1634mod tests {
1635    use super::*;
1636
1637    #[test]
1638    fn default_config_round_trips() {
1639        let config = CreateVmConfig::default();
1640        assert_eq!(config.defaults_profile(), VmDefaultsProfile::Secure);
1641        let json = serde_json::to_string(&config).expect("serialize config");
1642        let decoded: CreateVmConfig = serde_json::from_str(&json).expect("decode config");
1643        assert_eq!(decoded, config);
1644        assert_eq!(decoded.defaults_profile(), VmDefaultsProfile::Secure);
1645
1646        let product: CreateVmConfig =
1647            serde_json::from_str(r#"{"defaultsProfile":"agent_os","rootFilesystem":{}}"#)
1648                .expect("decode explicit agentOS profile");
1649        assert_eq!(product.defaults_profile(), VmDefaultsProfile::AgentOs);
1650    }
1651
1652    #[test]
1653    fn create_vm_normalizes_set_like_overrides() {
1654        let mut config = CreateVmConfig {
1655            loopback_exempt_ports: vec![8080, 80, 8080],
1656            js_runtime: Some(JsRuntimeConfig {
1657                allowed_builtins: Some(vec!["path".into(), "fs".into(), "path".into()]),
1658                ..JsRuntimeConfig::default()
1659            }),
1660            ..CreateVmConfig::default()
1661        };
1662        config.normalize().expect("normalize overrides");
1663        assert_eq!(config.loopback_exempt_ports, vec![80, 8080]);
1664        assert_eq!(
1665            config.js_runtime.unwrap().allowed_builtins,
1666            Some(vec!["fs".into(), "path".into()])
1667        );
1668    }
1669
1670    #[test]
1671    fn create_vm_rejects_duplicate_heavy_unbounded_overrides() {
1672        let mut ports = CreateVmConfig {
1673            loopback_exempt_ports: vec![80; MAX_VM_LOOPBACK_EXEMPT_PORTS + 1],
1674            ..CreateVmConfig::default()
1675        };
1676        assert!(ports
1677            .normalize()
1678            .unwrap_err()
1679            .to_string()
1680            .contains("limit_exceeded"));
1681        assert!(ports
1682            .validate(usize::MAX)
1683            .unwrap_err()
1684            .to_string()
1685            .contains("limit_exceeded"));
1686
1687        let mut builtins = CreateVmConfig {
1688            js_runtime: Some(JsRuntimeConfig {
1689                allowed_builtins: Some(vec!["fs".into(); MAX_VM_ALLOWED_NODE_BUILTINS + 1]),
1690                ..JsRuntimeConfig::default()
1691            }),
1692            ..CreateVmConfig::default()
1693        };
1694        assert!(builtins
1695            .normalize()
1696            .unwrap_err()
1697            .to_string()
1698            .contains("limit_exceeded"));
1699        assert!(builtins
1700            .validate(usize::MAX)
1701            .unwrap_err()
1702            .to_string()
1703            .contains("limit_exceeded"));
1704    }
1705
1706    #[test]
1707    fn unknown_fields_are_rejected() {
1708        let error =
1709            serde_json::from_str::<CreateVmConfig>(r#"{"rootFilesystem":{},"surprise":true}"#)
1710                .expect_err("unknown fields should fail");
1711        assert!(error.to_string().contains("unknown field"));
1712    }
1713
1714    #[test]
1715    fn root_user_config_round_trips_and_validates() {
1716        let config = CreateVmConfig {
1717            user: Some(VmUserConfig {
1718                uid: Some(0),
1719                gid: Some(0),
1720                username: Some(String::from("root")),
1721                homedir: Some(String::from("/root")),
1722                shell: Some(String::from("/bin/sh")),
1723                supplementary_gids: Some(vec![0, 10]),
1724                ..VmUserConfig::default()
1725            }),
1726            ..CreateVmConfig::default()
1727        };
1728        config.validate(usize::MAX).expect("valid root account");
1729        let json = serde_json::to_string(&config).expect("serialize root user");
1730        let decoded: CreateVmConfig = serde_json::from_str(&json).expect("decode root user");
1731        assert_eq!(decoded, config);
1732    }
1733
1734    #[test]
1735    fn user_config_rejects_unbounded_groups_and_invalid_records() {
1736        let too_many_groups = CreateVmConfig {
1737            user: Some(VmUserConfig {
1738                supplementary_gids: Some((0..65).collect()),
1739                ..VmUserConfig::default()
1740            }),
1741            ..CreateVmConfig::default()
1742        };
1743        assert!(too_many_groups.validate(usize::MAX).is_err());
1744
1745        let invalid_name = CreateVmConfig {
1746            user: Some(VmUserConfig {
1747                username: Some(String::from("bad:name")),
1748                ..VmUserConfig::default()
1749            }),
1750            ..CreateVmConfig::default()
1751        };
1752        assert!(invalid_name.validate(usize::MAX).is_err());
1753    }
1754
1755    #[test]
1756    fn validate_rejects_fetch_limit_above_frame_cap() {
1757        let config = CreateVmConfig {
1758            limits: Some(VmLimitsConfig {
1759                http: Some(HttpLimitsConfig {
1760                    max_fetch_response_bytes: Some(2048),
1761                }),
1762                ..VmLimitsConfig::default()
1763            }),
1764            ..CreateVmConfig::default()
1765        };
1766        assert!(config.validate(1024).is_err());
1767    }
1768
1769    #[test]
1770    fn canonical_reactor_and_protocol_limits_round_trip() {
1771        let config: CreateVmConfig = serde_json::from_value(serde_json::json!({
1772            "limits": {
1773                "resources": {
1774                    "maxConnections": 16,
1775                    "maxSocketBufferedBytes": 8192,
1776                    "maxSocketDatagramQueueLen": 128
1777                },
1778                "reactor": {
1779                    "maxCapabilities": 128,
1780                    "maxReadyHandles": 256,
1781                    "maxTasks": 512,
1782                    "workQuantum": 32,
1783                    "byteQuantum": 4096,
1784                    "maxHandleCommands": 64,
1785                    "maxHandleCommandBytes": 1024,
1786                    "maxBridgeCalls": 32,
1787                    "maxBridgeResponseBytes": 4096,
1788                    "maxAsyncCompletions": 64,
1789                    "maxAsyncCompletionBytes": 2048,
1790                    "maxBlockingJobs": 32,
1791                    "maxBlockingBytes": 4096,
1792                    "perHandleOperationQuantum": 8,
1793                    "acceptQuantum": 16,
1794                    "datagramQuantum": 8,
1795                    "completionQuantum": 16,
1796                    "signalQuantum": 16,
1797                    "shutdownDeadlineMs": 5000,
1798                    "operationDeadlineMs": 30000
1799                },
1800                "http": { "maxFetchResponseBytes": 4096 },
1801                "udp": {
1802                    "maxBufferedDatagrams": 64,
1803                    "maxBufferedBytes": 4096
1804                },
1805                "tls": { "maxBufferedBytes": 2048 },
1806                "http2": {
1807                    "maxConnections": 8,
1808                    "maxStreams": 64,
1809                    "maxStreamsPerConnection": 16,
1810                    "maxBufferedBytes": 8192,
1811                    "maxHeaderBytes": 1024,
1812                    "maxDataBytes": 4096,
1813                    "maxPendingCommands": 32,
1814                    "maxPendingCommandBytes": 1024,
1815                    "maxPendingEvents": 32,
1816                    "maxPendingEventBytes": 2048
1817                }
1818            }
1819        }))
1820        .expect("decode canonical limits");
1821        config.validate(16 * 1024).expect("valid relationships");
1822
1823        let json = serde_json::to_string(&config).expect("serialize canonical limits");
1824        let decoded: CreateVmConfig = serde_json::from_str(&json).expect("decode round trip");
1825        assert_eq!(decoded, config);
1826        assert!(json.contains("maxHandleCommandBytes"));
1827        assert!(json.contains("maxBufferedDatagrams"));
1828        assert!(json.contains("maxStreamsPerConnection"));
1829        assert!(json.contains("shutdownDeadlineMs"));
1830    }
1831
1832    #[test]
1833    fn canonical_limits_reject_zero_and_invalid_parent_relationships() {
1834        let cases = [
1835            (
1836                serde_json::json!({
1837                    "reactor": { "maxHandleCommands": 0 }
1838                }),
1839                "limits.reactor.maxHandleCommands",
1840            ),
1841            (
1842                serde_json::json!({
1843                    "reactor": { "maxBlockingJobs": 0 }
1844                }),
1845                "limits.reactor.maxBlockingJobs",
1846            ),
1847            (
1848                serde_json::json!({
1849                    "udp": { "maxBufferedBytes": 0 }
1850                }),
1851                "limits.udp.maxBufferedBytes",
1852            ),
1853            (
1854                serde_json::json!({
1855                    "reactor": { "maxCapabilities": 8, "maxReadyHandles": 4 }
1856                }),
1857                "limits.reactor.maxCapabilities",
1858            ),
1859            (
1860                serde_json::json!({
1861                    "resources": { "maxSocketBufferedBytes": 1024 },
1862                    "tls": { "maxBufferedBytes": 2048 }
1863                }),
1864                "limits.tls.maxBufferedBytes",
1865            ),
1866            (
1867                serde_json::json!({
1868                    "http2": {
1869                        "maxBufferedBytes": 1024,
1870                        "maxPendingEventBytes": 2048
1871                    }
1872                }),
1873                "limits.http2.maxPendingEventBytes",
1874            ),
1875            (
1876                serde_json::json!({
1877                    "resources": { "maxSocketDatagramQueueLen": 16 },
1878                    "udp": { "maxBufferedDatagrams": 32 }
1879                }),
1880                "limits.udp.maxBufferedDatagrams",
1881            ),
1882        ];
1883
1884        for (limits, expected_path) in cases {
1885            let config: CreateVmConfig = serde_json::from_value(serde_json::json!({
1886                "limits": limits
1887            }))
1888            .expect("decode invalid relationship fixture");
1889            let error = config
1890                .validate(16 * 1024)
1891                .expect_err("invalid relationship must fail");
1892            assert!(
1893                error.to_string().contains(expected_path),
1894                "expected {expected_path} in {error}"
1895            );
1896        }
1897    }
1898
1899    fn js_runtime_config(value: serde_json::Value) -> Result<CreateVmConfig, serde_json::Error> {
1900        serde_json::from_value(serde_json::json!({ "jsRuntime": value }))
1901    }
1902
1903    #[test]
1904    fn js_runtime_defaults_to_node() {
1905        let config: CreateVmConfig =
1906            serde_json::from_value(serde_json::json!({ "jsRuntime": {} })).expect("decode");
1907        let js = config.js_runtime.expect("jsRuntime present");
1908        assert_eq!(js.platform, JsRuntimePlatform::Node);
1909        assert_eq!(js.module_resolution, JsModuleResolution::Node);
1910        assert!(js.allowed_builtins.is_none());
1911        assert!(js.high_resolution_time.is_none());
1912    }
1913
1914    #[test]
1915    fn js_runtime_high_resolution_time_defaults_off_and_round_trips() {
1916        let defaulted = js_runtime_config(serde_json::json!({})).unwrap();
1917        assert!(defaulted.js_runtime.unwrap().high_resolution_time.is_none());
1918
1919        let enabled = js_runtime_config(serde_json::json!({
1920            "highResolutionTime": true,
1921        }))
1922        .unwrap();
1923        assert_eq!(
1924            enabled.js_runtime.as_ref().unwrap().high_resolution_time,
1925            Some(true)
1926        );
1927        let json = serde_json::to_string(&enabled).expect("serialize");
1928        assert!(json.contains("highResolutionTime"));
1929        let decoded: CreateVmConfig = serde_json::from_str(&json).expect("re-decode");
1930        assert_eq!(decoded, enabled);
1931    }
1932
1933    #[test]
1934    fn js_runtime_all_platform_resolution_combos_round_trip() {
1935        for platform in ["node", "browser", "neutral", "bare"] {
1936            for resolution in ["node", "relative", "none"] {
1937                let config = js_runtime_config(serde_json::json!({
1938                    "platform": platform,
1939                    "moduleResolution": resolution,
1940                }))
1941                .unwrap_or_else(|err| panic!("decode {platform}/{resolution}: {err}"));
1942                let json = serde_json::to_string(&config).expect("serialize");
1943                let decoded: CreateVmConfig = serde_json::from_str(&json).expect("re-decode");
1944                assert_eq!(decoded, config);
1945                assert!(config.validate(usize::MAX).is_ok());
1946            }
1947        }
1948    }
1949
1950    #[test]
1951    fn js_runtime_allowed_builtins_tri_state() {
1952        // None => omitted.
1953        let none = js_runtime_config(serde_json::json!({ "platform": "node" })).unwrap();
1954        assert!(none.js_runtime.unwrap().allowed_builtins.is_none());
1955        // Some([]) => deny all (representable, distinct from None).
1956        let empty = js_runtime_config(serde_json::json!({ "allowedBuiltins": [] })).unwrap();
1957        assert_eq!(empty.js_runtime.unwrap().allowed_builtins, Some(Vec::new()));
1958        // Some([..]) => explicit.
1959        let some = js_runtime_config(serde_json::json!({ "allowedBuiltins": ["path", "node:fs"] }))
1960            .unwrap();
1961        assert_eq!(
1962            some.js_runtime.unwrap().allowed_builtins,
1963            Some(vec!["path".to_owned(), "node:fs".to_owned()])
1964        );
1965    }
1966
1967    #[test]
1968    fn js_runtime_rejects_allowed_builtins_under_non_node_platform() {
1969        for platform in ["browser", "neutral", "bare"] {
1970            let config = js_runtime_config(serde_json::json!({
1971                "platform": platform,
1972                "allowedBuiltins": ["path"],
1973            }))
1974            .unwrap();
1975            let error = config
1976                .validate(usize::MAX)
1977                .expect_err("allowedBuiltins under non-node must reject");
1978            assert!(error.to_string().contains("allowedBuiltins"));
1979        }
1980    }
1981
1982    #[test]
1983    fn js_runtime_rejects_unknown_builtin_names() {
1984        let config = js_runtime_config(serde_json::json!({
1985            "platform": "node",
1986            "allowedBuiltins": ["path", "totally_not_a_builtin"],
1987        }))
1988        .unwrap();
1989        let error = config
1990            .validate(usize::MAX)
1991            .expect_err("unknown builtin must reject");
1992        assert!(error.to_string().contains("unknown builtin"));
1993    }
1994
1995    #[test]
1996    fn js_runtime_accepts_empty_allow_list_under_node() {
1997        let config =
1998            js_runtime_config(serde_json::json!({ "platform": "node", "allowedBuiltins": [] }))
1999                .unwrap();
2000        assert!(config.validate(usize::MAX).is_ok());
2001    }
2002
2003    #[test]
2004    fn js_runtime_rejects_unknown_fields() {
2005        let error = js_runtime_config(serde_json::json!({ "surprise": true }))
2006            .expect_err("unknown jsRuntime field should fail");
2007        assert!(error.to_string().contains("unknown field"));
2008    }
2009}