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