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