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