Skip to main content

agentos_vm_config/
lib.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4use ts_rs::TS;
5
6/// Canonical Rust-side VM config. Unknown fields must stay rejected here and in
7/// the TS preflight schema at
8/// `packages/core/src/node-runtime-options-schema.ts`; update both when a
9/// public `NodeRuntime.create(...)` option changes the generated VM config.
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
11#[serde(rename_all = "camelCase", deny_unknown_fields)]
12#[ts(export, export_to = "../../../packages/core/src/generated/")]
13#[derive(Default)]
14pub struct CreateVmConfig {
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    #[ts(optional)]
17    pub cwd: Option<String>,
18    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
19    #[ts(type = "Record<string, string>")]
20    pub env: BTreeMap<String, String>,
21    #[serde(default, rename = "rootFilesystem")]
22    pub root_filesystem: RootFilesystemConfig,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    #[ts(optional)]
25    pub permissions: Option<PermissionsPolicy>,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    #[ts(optional)]
28    pub limits: Option<VmLimitsConfig>,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    #[ts(optional)]
31    pub dns: Option<VmDnsConfig>,
32    #[serde(
33        default,
34        rename = "nativeRoot",
35        skip_serializing_if = "Option::is_none"
36    )]
37    #[ts(optional)]
38    pub native_root: Option<NativeRootFilesystemConfig>,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    #[ts(optional)]
41    pub listen: Option<VmListenPolicyConfig>,
42    #[serde(
43        default,
44        rename = "loopbackExemptPorts",
45        skip_serializing_if = "Vec::is_empty"
46    )]
47    pub loopback_exempt_ports: Vec<u16>,
48    #[serde(default, rename = "jsRuntime", skip_serializing_if = "Option::is_none")]
49    #[ts(optional)]
50    pub js_runtime: Option<JsRuntimeConfig>,
51    #[serde(
52        default,
53        rename = "bootstrapCommands",
54        skip_serializing_if = "Option::is_none"
55    )]
56    #[ts(optional)]
57    pub bootstrap_commands: Option<Vec<String>>,
58}
59
60impl CreateVmConfig {
61    pub fn validate(&self, max_frame_bytes: usize) -> Result<(), VmConfigError> {
62        if let Some(cwd) = self.cwd.as_deref() {
63            validate_guest_path("cwd", cwd)?;
64        }
65        self.root_filesystem.validate()?;
66        if let Some(native_root) = &self.native_root {
67            native_root.validate()?;
68        }
69        if self.native_root.is_some() && !self.root_filesystem.bootstrap_entries.is_empty() {
70            return Err(VmConfigError::new(
71                "nativeRoot does not support rootFilesystem.bootstrapEntries",
72            ));
73        }
74        if let Some(dns) = &self.dns {
75            dns.validate()?;
76        }
77        if let Some(listen) = &self.listen {
78            listen.validate()?;
79        }
80        if let Some(limits) = &self.limits {
81            limits.validate(max_frame_bytes)?;
82        }
83        if let Some(js_runtime) = &self.js_runtime {
84            js_runtime.validate()?;
85        }
86        if let Some(bootstrap_commands) = &self.bootstrap_commands {
87            validate_command_names("bootstrapCommands", bootstrap_commands)?;
88        }
89        Ok(())
90    }
91}
92
93/// Guest JavaScript host-environment configuration.
94///
95/// Selects which globals/builtins/module-resolution surface guest JS sees,
96/// modeled on esbuild's `platform`. Omitting this preserves full Node.js
97/// emulation (`platform = node`).
98#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, TS)]
99#[serde(rename_all = "camelCase", deny_unknown_fields)]
100#[ts(export, export_to = "../../../packages/core/src/generated/")]
101pub struct JsRuntimeConfig {
102    /// Which host environment to emulate for guest JS. Default `node`.
103    #[serde(default)]
104    pub platform: JsRuntimePlatform,
105    /// How bare import specifiers resolve. Independent of `platform`.
106    /// Default `node`.
107    #[serde(default, rename = "moduleResolution")]
108    pub module_resolution: JsModuleResolution,
109    /// Node builtin-module allow-list. Only valid when `platform = node`.
110    /// `None` => engine default allow-list. `Some([])` => deny all builtins.
111    /// `Some([..])` => exactly those.
112    #[serde(
113        default,
114        rename = "allowedBuiltins",
115        skip_serializing_if = "Option::is_none"
116    )]
117    #[ts(optional)]
118    pub allowed_builtins: Option<Vec<String>>,
119    /// Opt in to a high-resolution monotonic guest clock. Default false keeps
120    /// the security-oriented 1ms timer resolution.
121    #[serde(
122        default,
123        rename = "highResolutionTime",
124        skip_serializing_if = "Option::is_none"
125    )]
126    #[ts(optional)]
127    pub high_resolution_time: Option<bool>,
128}
129
130impl JsRuntimeConfig {
131    fn validate(&self) -> Result<(), VmConfigError> {
132        if let Some(allowed) = &self.allowed_builtins {
133            if self.platform != JsRuntimePlatform::Node {
134                return Err(VmConfigError::new(
135                    "jsRuntime.allowedBuiltins is only valid when jsRuntime.platform is \"node\"",
136                ));
137            }
138            for name in allowed {
139                if !is_known_node_builtin(name) {
140                    return Err(VmConfigError::new(format!(
141                        "jsRuntime.allowedBuiltins contains unknown builtin {name:?}"
142                    )));
143                }
144            }
145        }
146        Ok(())
147    }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
151#[serde(rename_all = "lowercase")]
152#[ts(export, export_to = "../../../packages/core/src/generated/")]
153#[derive(Default)]
154pub enum JsRuntimePlatform {
155    /// Full Node.js host surface (process/Buffer/require, `node:*`, npm
156    /// resolution, virtual Node identity). Default.
157    #[default]
158    Node,
159    /// Web-platform globals (fetch/URL/WebCrypto/...), no Node surface.
160    Browser,
161    /// Universal primitives only (console, timers, queueMicrotask) — no web
162    /// platform, no Node surface.
163    Neutral,
164    /// Language-only: ECMAScript spec globals + WebAssembly. Nothing host-provided.
165    Bare,
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
169#[serde(rename_all = "lowercase")]
170#[ts(export, export_to = "../../../packages/core/src/generated/")]
171#[derive(Default)]
172pub enum JsModuleResolution {
173    /// node_modules ancestor-walk + exports/imports/conditions + realpath. Default.
174    #[default]
175    Node,
176    /// Relative/absolute ESM from the VFS only; bare specifiers do not resolve.
177    Relative,
178    /// No resolution: any import/require (even relative) fails.
179    None,
180}
181
182/// Canonical set of recognized Node builtin module names (without the `node:`
183/// prefix), kept in sync with `normalize_builtin_specifier` in
184/// `crates/execution/src/javascript.rs`. Used to validate
185/// `jsRuntime.allowedBuiltins` entries.
186const KNOWN_NODE_BUILTINS: &[&str] = &[
187    "assert",
188    "async_hooks",
189    "buffer",
190    "child_process",
191    "cluster",
192    "console",
193    "constants",
194    "crypto",
195    "dgram",
196    "diagnostics_channel",
197    "dns",
198    "dns/promises",
199    "domain",
200    "events",
201    "fs",
202    "fs/promises",
203    "http",
204    "http2",
205    "https",
206    "inspector",
207    "module",
208    "net",
209    "os",
210    "path",
211    "path/posix",
212    "path/win32",
213    "perf_hooks",
214    "process",
215    "punycode",
216    "querystring",
217    "readline",
218    "repl",
219    "sqlite",
220    "stream",
221    "stream/consumers",
222    "stream/promises",
223    "stream/web",
224    "string_decoder",
225    "sys",
226    "timers",
227    "timers/promises",
228    "tls",
229    "trace_events",
230    "tty",
231    "url",
232    "util",
233    "util/types",
234    "v8",
235    "vm",
236    "wasi",
237    "worker_threads",
238    "zlib",
239];
240
241fn is_known_node_builtin(name: &str) -> bool {
242    let bare = name.strip_prefix("node:").unwrap_or(name);
243    KNOWN_NODE_BUILTINS.contains(&bare)
244}
245
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
247#[serde(rename_all = "camelCase", deny_unknown_fields)]
248#[ts(export, export_to = "../../../packages/core/src/generated/")]
249pub struct RootFilesystemConfig {
250    #[serde(default)]
251    pub mode: RootFilesystemMode,
252    #[serde(default, rename = "disableDefaultBaseLayer")]
253    pub disable_default_base_layer: bool,
254    #[serde(default, skip_serializing_if = "Vec::is_empty")]
255    pub lowers: Vec<RootFilesystemLowerDescriptor>,
256    #[serde(
257        default,
258        rename = "bootstrapEntries",
259        skip_serializing_if = "Vec::is_empty"
260    )]
261    pub bootstrap_entries: Vec<RootFilesystemEntry>,
262}
263
264impl Default for RootFilesystemConfig {
265    fn default() -> Self {
266        Self {
267            mode: RootFilesystemMode::Ephemeral,
268            disable_default_base_layer: false,
269            lowers: Vec::new(),
270            bootstrap_entries: Vec::new(),
271        }
272    }
273}
274
275impl RootFilesystemConfig {
276    fn validate(&self) -> Result<(), VmConfigError> {
277        for lower in &self.lowers {
278            if let RootFilesystemLowerDescriptor::Snapshot { entries } = lower {
279                for entry in entries {
280                    entry.validate()?;
281                }
282            }
283        }
284        for entry in &self.bootstrap_entries {
285            entry.validate()?;
286        }
287        Ok(())
288    }
289}
290
291#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
292#[serde(rename_all = "kebab-case")]
293#[ts(export, export_to = "../../../packages/core/src/generated/")]
294#[derive(Default)]
295pub enum RootFilesystemMode {
296    #[default]
297    Ephemeral,
298    ReadOnly,
299}
300
301#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
302#[serde(tag = "kind", rename_all = "camelCase")]
303#[ts(export, export_to = "../../../packages/core/src/generated/")]
304pub enum RootFilesystemLowerDescriptor {
305    Snapshot {
306        #[serde(default)]
307        entries: Vec<RootFilesystemEntry>,
308    },
309    BundledBaseFilesystem,
310}
311
312#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
313#[serde(rename_all = "camelCase", deny_unknown_fields)]
314#[ts(export, export_to = "../../../packages/core/src/generated/")]
315pub struct RootFilesystemEntry {
316    pub path: String,
317    pub kind: RootFilesystemEntryKind,
318    #[serde(default, skip_serializing_if = "Option::is_none")]
319    #[ts(optional)]
320    pub mode: Option<u32>,
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    #[ts(optional)]
323    pub uid: Option<u32>,
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    #[ts(optional)]
326    pub gid: Option<u32>,
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    #[ts(optional)]
329    pub content: Option<String>,
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    #[ts(optional)]
332    pub encoding: Option<RootFilesystemEntryEncoding>,
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    #[ts(optional)]
335    pub target: Option<String>,
336    #[serde(default)]
337    pub executable: bool,
338}
339
340impl RootFilesystemEntry {
341    fn validate(&self) -> Result<(), VmConfigError> {
342        validate_guest_path("root filesystem entry path", &self.path)?;
343        match self.kind {
344            RootFilesystemEntryKind::File => {
345                if self.target.is_some() {
346                    return Err(VmConfigError::new(format!(
347                        "file entry {} must not include target",
348                        self.path
349                    )));
350                }
351            }
352            RootFilesystemEntryKind::Directory => {
353                if self.content.is_some() || self.encoding.is_some() || self.target.is_some() {
354                    return Err(VmConfigError::new(format!(
355                        "directory entry {} must not include content, encoding, or target",
356                        self.path
357                    )));
358                }
359            }
360            RootFilesystemEntryKind::Symlink => {
361                if self.target.as_deref().unwrap_or("").is_empty() {
362                    return Err(VmConfigError::new(format!(
363                        "symlink entry {} requires target",
364                        self.path
365                    )));
366                }
367                if self.content.is_some() || self.encoding.is_some() {
368                    return Err(VmConfigError::new(format!(
369                        "symlink entry {} must not include content or encoding",
370                        self.path
371                    )));
372                }
373            }
374        }
375        Ok(())
376    }
377}
378
379#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
380#[serde(rename_all = "lowercase")]
381#[ts(export, export_to = "../../../packages/core/src/generated/")]
382pub enum RootFilesystemEntryKind {
383    File,
384    Directory,
385    Symlink,
386}
387
388#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
389#[serde(rename_all = "lowercase")]
390#[ts(export, export_to = "../../../packages/core/src/generated/")]
391pub enum RootFilesystemEntryEncoding {
392    Utf8,
393    Base64,
394}
395
396#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
397#[serde(rename_all = "camelCase", deny_unknown_fields)]
398#[ts(export, export_to = "../../../packages/core/src/generated/")]
399pub struct NativeRootFilesystemConfig {
400    pub plugin: MountPluginDescriptor,
401    #[serde(default, rename = "readOnly")]
402    pub read_only: bool,
403}
404
405impl NativeRootFilesystemConfig {
406    fn validate(&self) -> Result<(), VmConfigError> {
407        if self.plugin.id.trim().is_empty() {
408            return Err(VmConfigError::new("nativeRoot.plugin.id is required"));
409        }
410        Ok(())
411    }
412}
413
414#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
415#[serde(rename_all = "camelCase", deny_unknown_fields)]
416#[ts(export, export_to = "../../../packages/core/src/generated/")]
417pub struct MountPluginDescriptor {
418    pub id: String,
419    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
420    #[ts(type = "import(\"@rivet-dev/agentos-runtime-core/descriptors\").MountConfigJsonValue")]
421    pub config: serde_json::Value,
422}
423
424#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
425#[serde(rename_all = "lowercase")]
426#[ts(export, export_to = "../../../packages/core/src/generated/")]
427pub enum PermissionMode {
428    Allow,
429    Ask,
430    Deny,
431}
432
433#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
434#[serde(untagged)]
435#[ts(export, export_to = "../../../packages/core/src/generated/")]
436pub enum FsPermissionScope {
437    Mode(PermissionMode),
438    Rules(FsPermissionRuleSet),
439}
440
441#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
442#[serde(untagged)]
443#[ts(export, export_to = "../../../packages/core/src/generated/")]
444pub enum PatternPermissionScope {
445    Mode(PermissionMode),
446    Rules(PatternPermissionRuleSet),
447}
448
449#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
450#[serde(rename_all = "camelCase", deny_unknown_fields)]
451#[ts(export, export_to = "../../../packages/core/src/generated/")]
452pub struct FsPermissionRuleSet {
453    #[serde(default, skip_serializing_if = "Option::is_none")]
454    #[ts(optional)]
455    pub default: Option<PermissionMode>,
456    #[serde(default)]
457    pub rules: Vec<FsPermissionRule>,
458}
459
460#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
461#[serde(rename_all = "camelCase", deny_unknown_fields)]
462#[ts(export, export_to = "../../../packages/core/src/generated/")]
463pub struct PatternPermissionRuleSet {
464    #[serde(default, skip_serializing_if = "Option::is_none")]
465    #[ts(optional)]
466    pub default: Option<PermissionMode>,
467    #[serde(default)]
468    pub rules: Vec<PatternPermissionRule>,
469}
470
471#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
472#[serde(rename_all = "camelCase", deny_unknown_fields)]
473#[ts(export, export_to = "../../../packages/core/src/generated/")]
474pub struct FsPermissionRule {
475    pub mode: PermissionMode,
476    #[serde(default, skip_serializing_if = "Vec::is_empty")]
477    pub operations: Vec<String>,
478    #[serde(default, skip_serializing_if = "Vec::is_empty")]
479    pub paths: Vec<String>,
480}
481
482#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
483#[serde(rename_all = "camelCase", deny_unknown_fields)]
484#[ts(export, export_to = "../../../packages/core/src/generated/")]
485pub struct PatternPermissionRule {
486    pub mode: PermissionMode,
487    #[serde(default, skip_serializing_if = "Vec::is_empty")]
488    pub operations: Vec<String>,
489    #[serde(default, skip_serializing_if = "Vec::is_empty")]
490    pub patterns: Vec<String>,
491}
492
493#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
494#[serde(rename_all = "camelCase", deny_unknown_fields)]
495#[ts(export, export_to = "../../../packages/core/src/generated/")]
496pub struct PermissionsPolicy {
497    #[serde(default, skip_serializing_if = "Option::is_none")]
498    #[ts(optional)]
499    pub fs: Option<FsPermissionScope>,
500    #[serde(default, skip_serializing_if = "Option::is_none")]
501    #[ts(optional)]
502    pub network: Option<PatternPermissionScope>,
503    #[serde(
504        default,
505        rename = "childProcess",
506        skip_serializing_if = "Option::is_none"
507    )]
508    #[ts(optional)]
509    pub child_process: Option<PatternPermissionScope>,
510    #[serde(default, skip_serializing_if = "Option::is_none")]
511    #[ts(optional)]
512    pub process: Option<PatternPermissionScope>,
513    #[serde(default, skip_serializing_if = "Option::is_none")]
514    #[ts(optional)]
515    pub env: Option<PatternPermissionScope>,
516    #[serde(default, skip_serializing_if = "Option::is_none")]
517    #[ts(optional)]
518    pub binding: Option<PatternPermissionScope>,
519}
520
521#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
522#[serde(rename_all = "camelCase", deny_unknown_fields)]
523#[ts(export, export_to = "../../../packages/core/src/generated/")]
524pub struct VmLimitsConfig {
525    #[serde(default, skip_serializing_if = "Option::is_none")]
526    #[ts(optional)]
527    pub resources: Option<ResourceLimitsConfig>,
528    #[serde(default, skip_serializing_if = "Option::is_none")]
529    #[ts(optional)]
530    pub http: Option<HttpLimitsConfig>,
531    #[serde(default, skip_serializing_if = "Option::is_none")]
532    #[ts(optional)]
533    pub tools: Option<ToolLimitsConfig>,
534    #[serde(default, skip_serializing_if = "Option::is_none")]
535    #[ts(optional)]
536    pub plugins: Option<PluginLimitsConfig>,
537    #[serde(default, skip_serializing_if = "Option::is_none")]
538    #[ts(optional)]
539    pub acp: Option<AcpLimitsConfig>,
540    #[serde(default, rename = "jsRuntime", skip_serializing_if = "Option::is_none")]
541    #[ts(optional)]
542    pub js_runtime: Option<JsRuntimeLimitsConfig>,
543    #[serde(default, skip_serializing_if = "Option::is_none")]
544    #[ts(optional)]
545    pub python: Option<PythonLimitsConfig>,
546    #[serde(default, skip_serializing_if = "Option::is_none")]
547    #[ts(optional)]
548    pub wasm: Option<WasmLimitsConfig>,
549}
550
551impl VmLimitsConfig {
552    fn validate(&self, max_frame_bytes: usize) -> Result<(), VmConfigError> {
553        if let Some(http) = &self.http {
554            if let Some(max_fetch_response_bytes) = http.max_fetch_response_bytes {
555                if max_fetch_response_bytes == 0 {
556                    return Err(VmConfigError::new(
557                        "limits.http.maxFetchResponseBytes must be greater than zero",
558                    ));
559                }
560                if max_fetch_response_bytes as usize > max_frame_bytes {
561                    return Err(VmConfigError::new(format!(
562                        "limits.http.maxFetchResponseBytes ({max_fetch_response_bytes}) must be <= the sidecar wire frame cap ({max_frame_bytes})"
563                    )));
564                }
565            }
566        }
567        if let Some(tools) = &self.tools {
568            if let (Some(default), Some(max)) =
569                (tools.default_tool_timeout_ms, tools.max_tool_timeout_ms)
570            {
571                if default > max {
572                    return Err(VmConfigError::new(
573                        "limits.tools.defaultToolTimeoutMs must be <= limits.tools.maxToolTimeoutMs",
574                    ));
575                }
576            }
577        }
578        Ok(())
579    }
580}
581
582macro_rules! limits_struct {
583    ($name:ident { $($field:ident),* $(,)? }) => {
584        #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
585        #[serde(rename_all = "camelCase", deny_unknown_fields)]
586        #[ts(export, export_to = "../../../packages/core/src/generated/")]
587        pub struct $name {
588            $(
589                #[serde(default, skip_serializing_if = "Option::is_none")]
590                #[ts(optional)]
591                #[ts(type = "number")]
592                pub $field: Option<u64>,
593            )*
594        }
595    };
596}
597
598limits_struct!(ResourceLimitsConfig {
599    cpu_count,
600    max_processes,
601    max_open_fds,
602    max_pipes,
603    max_ptys,
604    max_sockets,
605    max_connections,
606    max_socket_buffered_bytes,
607    max_socket_datagram_queue_len,
608    max_filesystem_bytes,
609    max_inode_count,
610    max_blocking_read_ms,
611    max_pread_bytes,
612    max_fd_write_bytes,
613    max_process_argv_bytes,
614    max_process_env_bytes,
615    max_readdir_entries,
616    max_recursive_fs_depth,
617    max_recursive_fs_entries,
618    max_wasm_fuel,
619    max_wasm_memory_bytes,
620    max_wasm_stack_bytes,
621});
622
623limits_struct!(HttpLimitsConfig {
624    max_fetch_response_bytes,
625});
626
627limits_struct!(ToolLimitsConfig {
628    default_tool_timeout_ms,
629    max_tool_timeout_ms,
630    max_registered_toolkits,
631    max_registered_tools_per_vm,
632    max_tools_per_toolkit,
633    max_tool_schema_bytes,
634    max_tool_examples_per_tool,
635    max_tool_example_input_bytes,
636});
637
638limits_struct!(PluginLimitsConfig {
639    max_persisted_manifest_bytes,
640    max_persisted_manifest_file_bytes,
641});
642
643limits_struct!(AcpLimitsConfig {
644    max_read_line_bytes,
645    stdout_buffer_byte_limit,
646});
647
648limits_struct!(JsRuntimeLimitsConfig {
649    v8_heap_limit_mb,
650    sync_rpc_wait_timeout_ms,
651    cpu_time_limit_ms,
652    wall_clock_limit_ms,
653    import_cache_materialize_timeout_ms,
654    captured_output_limit_bytes,
655    stdin_buffer_limit_bytes,
656    event_payload_limit_bytes,
657    v8_ipc_max_frame_bytes,
658});
659
660limits_struct!(PythonLimitsConfig {
661    output_buffer_max_bytes,
662    execution_timeout_ms,
663    max_old_space_mb,
664    vfs_rpc_timeout_ms,
665});
666
667limits_struct!(WasmLimitsConfig {
668    max_module_file_bytes,
669    captured_output_limit_bytes,
670    sync_read_limit_bytes,
671    prewarm_timeout_ms,
672    runner_heap_limit_mb,
673});
674
675#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
676#[serde(rename_all = "camelCase", deny_unknown_fields)]
677#[ts(export, export_to = "../../../packages/core/src/generated/")]
678pub struct VmDnsConfig {
679    #[serde(default, rename = "nameServers", skip_serializing_if = "Vec::is_empty")]
680    pub name_servers: Vec<String>,
681    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
682    pub overrides: BTreeMap<String, Vec<String>>,
683}
684
685impl VmDnsConfig {
686    fn validate(&self) -> Result<(), VmConfigError> {
687        for entry in &self.name_servers {
688            if entry.trim().is_empty() {
689                return Err(VmConfigError::new(
690                    "dns.nameServers entries must not be empty",
691                ));
692            }
693        }
694        for (host, addresses) in &self.overrides {
695            if host.trim().is_empty() {
696                return Err(VmConfigError::new("dns.overrides keys must not be empty"));
697            }
698            if addresses.is_empty() {
699                return Err(VmConfigError::new(format!(
700                    "dns.overrides.{host} must contain at least one address"
701                )));
702            }
703        }
704        Ok(())
705    }
706}
707
708#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
709#[serde(rename_all = "camelCase", deny_unknown_fields)]
710#[ts(export, export_to = "../../../packages/core/src/generated/")]
711pub struct VmListenPolicyConfig {
712    #[serde(default, rename = "portMin", skip_serializing_if = "Option::is_none")]
713    #[ts(optional)]
714    pub port_min: Option<u16>,
715    #[serde(default, rename = "portMax", skip_serializing_if = "Option::is_none")]
716    #[ts(optional)]
717    pub port_max: Option<u16>,
718    #[serde(
719        default,
720        rename = "allowPrivileged",
721        skip_serializing_if = "Option::is_none"
722    )]
723    #[ts(optional)]
724    pub allow_privileged: Option<bool>,
725}
726
727impl VmListenPolicyConfig {
728    fn validate(&self) -> Result<(), VmConfigError> {
729        if self.port_min == Some(0) {
730            return Err(VmConfigError::new(
731                "listen.portMin must be between 1 and 65535",
732            ));
733        }
734        if self.port_max == Some(0) {
735            return Err(VmConfigError::new(
736                "listen.portMax must be between 1 and 65535",
737            ));
738        }
739        if let (Some(min), Some(max)) = (self.port_min, self.port_max) {
740            if min > max {
741                return Err(VmConfigError::new(
742                    "listen.portMin must be <= listen.portMax",
743                ));
744            }
745        }
746        Ok(())
747    }
748}
749
750#[derive(Debug, Clone, PartialEq, Eq)]
751pub struct VmConfigError {
752    message: String,
753}
754
755impl VmConfigError {
756    pub fn new(message: impl Into<String>) -> Self {
757        Self {
758            message: message.into(),
759        }
760    }
761}
762
763impl std::fmt::Display for VmConfigError {
764    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
765        f.write_str(&self.message)
766    }
767}
768
769impl std::error::Error for VmConfigError {}
770
771fn validate_guest_path(label: &str, path: &str) -> Result<(), VmConfigError> {
772    if !path.starts_with('/') {
773        return Err(VmConfigError::new(format!("{label} must be absolute")));
774    }
775    if path.split('/').any(|part| part == "..") {
776        return Err(VmConfigError::new(format!("{label} must not contain '..'")));
777    }
778    Ok(())
779}
780
781fn validate_command_names(label: &str, commands: &[String]) -> Result<(), VmConfigError> {
782    for command in commands {
783        if command.is_empty()
784            || command == "."
785            || command == ".."
786            || command.contains('/')
787            || command.contains('\0')
788        {
789            return Err(VmConfigError::new(format!(
790                "{label} contains invalid command name {command:?}"
791            )));
792        }
793    }
794
795    Ok(())
796}
797
798#[cfg(test)]
799mod tests {
800    use super::*;
801
802    #[test]
803    fn default_config_round_trips() {
804        let config = CreateVmConfig::default();
805        let json = serde_json::to_string(&config).expect("serialize config");
806        let decoded: CreateVmConfig = serde_json::from_str(&json).expect("decode config");
807        assert_eq!(decoded, config);
808    }
809
810    #[test]
811    fn unknown_fields_are_rejected() {
812        let error =
813            serde_json::from_str::<CreateVmConfig>(r#"{"rootFilesystem":{},"surprise":true}"#)
814                .expect_err("unknown fields should fail");
815        assert!(error.to_string().contains("unknown field"));
816    }
817
818    #[test]
819    fn validate_rejects_fetch_limit_above_frame_cap() {
820        let config = CreateVmConfig {
821            limits: Some(VmLimitsConfig {
822                http: Some(HttpLimitsConfig {
823                    max_fetch_response_bytes: Some(2048),
824                }),
825                ..VmLimitsConfig::default()
826            }),
827            ..CreateVmConfig::default()
828        };
829        assert!(config.validate(1024).is_err());
830    }
831
832    fn js_runtime_config(value: serde_json::Value) -> Result<CreateVmConfig, serde_json::Error> {
833        serde_json::from_value(serde_json::json!({ "jsRuntime": value }))
834    }
835
836    #[test]
837    fn js_runtime_defaults_to_node() {
838        let config: CreateVmConfig =
839            serde_json::from_value(serde_json::json!({ "jsRuntime": {} })).expect("decode");
840        let js = config.js_runtime.expect("jsRuntime present");
841        assert_eq!(js.platform, JsRuntimePlatform::Node);
842        assert_eq!(js.module_resolution, JsModuleResolution::Node);
843        assert!(js.allowed_builtins.is_none());
844        assert!(js.high_resolution_time.is_none());
845    }
846
847    #[test]
848    fn js_runtime_high_resolution_time_defaults_off_and_round_trips() {
849        let defaulted = js_runtime_config(serde_json::json!({})).unwrap();
850        assert!(defaulted.js_runtime.unwrap().high_resolution_time.is_none());
851
852        let enabled = js_runtime_config(serde_json::json!({
853            "highResolutionTime": true,
854        }))
855        .unwrap();
856        assert_eq!(
857            enabled.js_runtime.as_ref().unwrap().high_resolution_time,
858            Some(true)
859        );
860        let json = serde_json::to_string(&enabled).expect("serialize");
861        assert!(json.contains("highResolutionTime"));
862        let decoded: CreateVmConfig = serde_json::from_str(&json).expect("re-decode");
863        assert_eq!(decoded, enabled);
864    }
865
866    #[test]
867    fn js_runtime_all_platform_resolution_combos_round_trip() {
868        for platform in ["node", "browser", "neutral", "bare"] {
869            for resolution in ["node", "relative", "none"] {
870                let config = js_runtime_config(serde_json::json!({
871                    "platform": platform,
872                    "moduleResolution": resolution,
873                }))
874                .unwrap_or_else(|err| panic!("decode {platform}/{resolution}: {err}"));
875                let json = serde_json::to_string(&config).expect("serialize");
876                let decoded: CreateVmConfig = serde_json::from_str(&json).expect("re-decode");
877                assert_eq!(decoded, config);
878                assert!(config.validate(usize::MAX).is_ok());
879            }
880        }
881    }
882
883    #[test]
884    fn js_runtime_allowed_builtins_tri_state() {
885        // None => omitted.
886        let none = js_runtime_config(serde_json::json!({ "platform": "node" })).unwrap();
887        assert!(none.js_runtime.unwrap().allowed_builtins.is_none());
888        // Some([]) => deny all (representable, distinct from None).
889        let empty = js_runtime_config(serde_json::json!({ "allowedBuiltins": [] })).unwrap();
890        assert_eq!(empty.js_runtime.unwrap().allowed_builtins, Some(Vec::new()));
891        // Some([..]) => explicit.
892        let some = js_runtime_config(serde_json::json!({ "allowedBuiltins": ["path", "node:fs"] }))
893            .unwrap();
894        assert_eq!(
895            some.js_runtime.unwrap().allowed_builtins,
896            Some(vec!["path".to_owned(), "node:fs".to_owned()])
897        );
898    }
899
900    #[test]
901    fn js_runtime_rejects_allowed_builtins_under_non_node_platform() {
902        for platform in ["browser", "neutral", "bare"] {
903            let config = js_runtime_config(serde_json::json!({
904                "platform": platform,
905                "allowedBuiltins": ["path"],
906            }))
907            .unwrap();
908            let error = config
909                .validate(usize::MAX)
910                .expect_err("allowedBuiltins under non-node must reject");
911            assert!(error.to_string().contains("allowedBuiltins"));
912        }
913    }
914
915    #[test]
916    fn js_runtime_rejects_unknown_builtin_names() {
917        let config = js_runtime_config(serde_json::json!({
918            "platform": "node",
919            "allowedBuiltins": ["path", "totally_not_a_builtin"],
920        }))
921        .unwrap();
922        let error = config
923            .validate(usize::MAX)
924            .expect_err("unknown builtin must reject");
925        assert!(error.to_string().contains("unknown builtin"));
926    }
927
928    #[test]
929    fn js_runtime_accepts_empty_allow_list_under_node() {
930        let config =
931            js_runtime_config(serde_json::json!({ "platform": "node", "allowedBuiltins": [] }))
932                .unwrap();
933        assert!(config.validate(usize::MAX).is_ok());
934    }
935
936    #[test]
937    fn js_runtime_rejects_unknown_fields() {
938        let error = js_runtime_config(serde_json::json!({ "surprise": true }))
939            .expect_err("unknown jsRuntime field should fail");
940        assert!(error.to_string().contains("unknown field"));
941    }
942}