Skip to main content

agentos_sidecar_protocol/
wire.rs

1//! Generated AgentOS language execution sidecar wire protocol surface.
2//!
3//! This module is the public generated protocol entrypoint. The hand-written
4//! `protocol` module remains an internal compatibility layer while callers move
5//! to generated wire frames.
6
7use std::error::Error;
8use std::fmt;
9
10pub use crate::generated_protocol::v1::*;
11
12// The generated BARE types intentionally omit `Copy`/`Default`; restore them on the
13// crate-local generated types so the wider sidecar keeps the ergonomics it relies on
14// after the hand-written protocol types were replaced with these aliases. These live in
15// `wire` (not `protocol`) because `protocol.rs` is `#[path]`-included by integration
16// tests, where the generated types would be foreign and the impls would break the orphan rule.
17impl Copy for crate::generated_protocol::v1::GuestFilesystemOperation {}
18impl Copy for crate::generated_protocol::v1::RootFilesystemMode {}
19impl Copy for crate::generated_protocol::v1::WasmPermissionTier {}
20
21// `derive(Default)` cannot be added: these are foreign generated types, so the
22// `Default` impl must be written by hand here (orphan rule).
23#[allow(clippy::derivable_impls)]
24impl Default for crate::generated_protocol::v1::RootFilesystemEntryKind {
25    fn default() -> Self {
26        Self::File
27    }
28}
29
30impl Default for crate::generated_protocol::v1::RootFilesystemEntry {
31    fn default() -> Self {
32        Self {
33            path: String::new(),
34            kind: crate::generated_protocol::v1::RootFilesystemEntryKind::File,
35            mode: None,
36            uid: None,
37            gid: None,
38            content: None,
39            encoding: None,
40            target: None,
41            executable: false,
42        }
43    }
44}
45
46#[allow(clippy::derivable_impls)]
47impl Default for crate::generated_protocol::v1::RootFilesystemMode {
48    fn default() -> Self {
49        Self::Ephemeral
50    }
51}
52
53#[allow(clippy::derivable_impls)]
54impl Default for crate::generated_protocol::v1::RootFilesystemDescriptor {
55    fn default() -> Self {
56        Self {
57            mode: crate::generated_protocol::v1::RootFilesystemMode::default(),
58            disable_default_base_layer: false,
59            lowers: Vec::new(),
60            bootstrap_entries: Vec::new(),
61        }
62    }
63}
64
65impl crate::generated_protocol::v1::PermissionsPolicy {
66    pub fn deny_all() -> Self {
67        use crate::generated_protocol::v1::{
68            FsPermissionScope, PatternPermissionScope, PermissionMode,
69        };
70        Self {
71            fs: Some(FsPermissionScope::PermissionMode(PermissionMode::Deny)),
72            network: Some(PatternPermissionScope::PermissionMode(PermissionMode::Deny)),
73            child_process: Some(PatternPermissionScope::PermissionMode(PermissionMode::Deny)),
74            process: Some(PatternPermissionScope::PermissionMode(PermissionMode::Deny)),
75            env: Some(PatternPermissionScope::PermissionMode(PermissionMode::Deny)),
76            host_function: Some(PatternPermissionScope::PermissionMode(PermissionMode::Deny)),
77        }
78    }
79
80    pub fn allow_all() -> Self {
81        use crate::generated_protocol::v1::{
82            FsPermissionScope, PatternPermissionScope, PermissionMode,
83        };
84        Self {
85            fs: Some(FsPermissionScope::PermissionMode(PermissionMode::Allow)),
86            network: Some(PatternPermissionScope::PermissionMode(
87                PermissionMode::Allow,
88            )),
89            child_process: Some(PatternPermissionScope::PermissionMode(
90                PermissionMode::Allow,
91            )),
92            process: Some(PatternPermissionScope::PermissionMode(
93                PermissionMode::Allow,
94            )),
95            env: Some(PatternPermissionScope::PermissionMode(
96                PermissionMode::Allow,
97            )),
98            host_function: Some(PatternPermissionScope::PermissionMode(
99                PermissionMode::Allow,
100            )),
101        }
102    }
103}
104
105impl Default for crate::generated_protocol::v1::PermissionsPolicy {
106    fn default() -> Self {
107        Self::deny_all()
108    }
109}
110
111impl crate::generated_protocol::v1::CreateVmRequest {
112    pub fn json_config(
113        runtime: crate::generated_protocol::v1::GuestRuntimeKind,
114        config: agentos_vm_config::CreateVmConfig,
115    ) -> Self {
116        Self {
117            runtime,
118            config: serde_json::to_string(&config).expect("serialize create VM config"),
119        }
120    }
121
122    pub fn legacy_test_config(
123        runtime: crate::generated_protocol::v1::GuestRuntimeKind,
124        metadata: std::collections::HashMap<String, String>,
125        root_filesystem: crate::generated_protocol::v1::RootFilesystemDescriptor,
126        permissions: Option<crate::generated_protocol::v1::PermissionsPolicy>,
127    ) -> Self {
128        let metadata: std::collections::BTreeMap<_, _> = metadata.into_iter().collect();
129        let mut config = agentos_vm_config::CreateVmConfig {
130            cwd: metadata.get("cwd").cloned(),
131            env: Some(legacy_env_config(&metadata)),
132            root_filesystem: legacy_root_filesystem_config(root_filesystem),
133            permissions: permissions.map(permissions_policy_config_from_wire),
134            limits: legacy_limits_config(&metadata),
135            dns: legacy_dns_config(&metadata),
136            native_root: legacy_native_root_config(&metadata),
137            listen: legacy_listen_config(&metadata),
138            ..Default::default()
139        };
140        config.loopback_exempt_ports =
141            legacy_loopback_exempt_ports(config.env.as_ref().expect("legacy env is present"));
142        Self::json_config(runtime, config)
143    }
144}
145
146fn legacy_env_config(
147    metadata: &std::collections::BTreeMap<String, String>,
148) -> std::collections::BTreeMap<String, String> {
149    metadata
150        .iter()
151        .filter_map(|(key, value)| {
152            key.strip_prefix("env.")
153                .map(|name| (name.to_string(), value.clone()))
154        })
155        .collect()
156}
157
158fn legacy_root_filesystem_config(
159    descriptor: crate::generated_protocol::v1::RootFilesystemDescriptor,
160) -> agentos_vm_config::RootFilesystemConfig {
161    agentos_vm_config::RootFilesystemConfig {
162        mode: match descriptor.mode {
163            crate::generated_protocol::v1::RootFilesystemMode::Ephemeral => {
164                agentos_vm_config::RootFilesystemMode::Ephemeral
165            }
166            crate::generated_protocol::v1::RootFilesystemMode::ReadOnly => {
167                agentos_vm_config::RootFilesystemMode::ReadOnly
168            }
169        },
170        disable_default_base_layer: descriptor.disable_default_base_layer,
171        lowers: descriptor
172            .lowers
173            .into_iter()
174            .map(legacy_root_lower_config)
175            .collect(),
176        bootstrap_entries: descriptor
177            .bootstrap_entries
178            .into_iter()
179            .map(legacy_root_entry_config)
180            .collect(),
181    }
182}
183
184fn legacy_root_lower_config(
185    lower: crate::generated_protocol::v1::RootFilesystemLowerDescriptor,
186) -> agentos_vm_config::RootFilesystemLowerDescriptor {
187    match lower {
188        crate::generated_protocol::v1::RootFilesystemLowerDescriptor::SnapshotRootFilesystemLower(
189            snapshot,
190        ) => agentos_vm_config::RootFilesystemLowerDescriptor::Snapshot {
191            entries: snapshot
192                .entries
193                .into_iter()
194                .map(legacy_root_entry_config)
195                .collect(),
196        },
197        crate::generated_protocol::v1::RootFilesystemLowerDescriptor::BundledBaseFilesystemLower => {
198            agentos_vm_config::RootFilesystemLowerDescriptor::BundledBaseFilesystem
199        }
200    }
201}
202
203fn legacy_root_entry_config(
204    entry: crate::generated_protocol::v1::RootFilesystemEntry,
205) -> agentos_vm_config::RootFilesystemEntry {
206    agentos_vm_config::RootFilesystemEntry {
207        path: entry.path,
208        kind: match entry.kind {
209            crate::generated_protocol::v1::RootFilesystemEntryKind::File => {
210                agentos_vm_config::RootFilesystemEntryKind::File
211            }
212            crate::generated_protocol::v1::RootFilesystemEntryKind::Directory => {
213                agentos_vm_config::RootFilesystemEntryKind::Directory
214            }
215            crate::generated_protocol::v1::RootFilesystemEntryKind::Symlink => {
216                agentos_vm_config::RootFilesystemEntryKind::Symlink
217            }
218        },
219        mode: entry.mode,
220        uid: entry.uid,
221        gid: entry.gid,
222        content: entry.content,
223        encoding: entry.encoding.map(|encoding| match encoding {
224            crate::generated_protocol::v1::RootFilesystemEntryEncoding::Utf8 => {
225                agentos_vm_config::RootFilesystemEntryEncoding::Utf8
226            }
227            crate::generated_protocol::v1::RootFilesystemEntryEncoding::Base64 => {
228                agentos_vm_config::RootFilesystemEntryEncoding::Base64
229            }
230        }),
231        target: entry.target,
232        executable: entry.executable,
233    }
234}
235
236pub fn permissions_policy_config_from_wire(
237    permissions: crate::generated_protocol::v1::PermissionsPolicy,
238) -> agentos_vm_config::PermissionsPolicy {
239    agentos_vm_config::PermissionsPolicy {
240        fs: permissions.fs.map(legacy_fs_permission_scope_config),
241        network: permissions
242            .network
243            .map(legacy_pattern_permission_scope_config),
244        child_process: permissions
245            .child_process
246            .map(legacy_pattern_permission_scope_config),
247        process: permissions
248            .process
249            .map(legacy_pattern_permission_scope_config),
250        env: permissions.env.map(legacy_pattern_permission_scope_config),
251        host_function: permissions
252            .host_function
253            .map(legacy_pattern_permission_scope_config),
254    }
255}
256
257fn legacy_permission_mode_config(
258    mode: crate::generated_protocol::v1::PermissionMode,
259) -> agentos_vm_config::PermissionMode {
260    match mode {
261        crate::generated_protocol::v1::PermissionMode::Allow => {
262            agentos_vm_config::PermissionMode::Allow
263        }
264        crate::generated_protocol::v1::PermissionMode::Ask => {
265            agentos_vm_config::PermissionMode::Ask
266        }
267        crate::generated_protocol::v1::PermissionMode::Deny => {
268            agentos_vm_config::PermissionMode::Deny
269        }
270    }
271}
272
273fn legacy_fs_permission_scope_config(
274    scope: crate::generated_protocol::v1::FsPermissionScope,
275) -> agentos_vm_config::FsPermissionScope {
276    match scope {
277        crate::generated_protocol::v1::FsPermissionScope::PermissionMode(mode) => {
278            agentos_vm_config::FsPermissionScope::Mode(legacy_permission_mode_config(mode))
279        }
280        crate::generated_protocol::v1::FsPermissionScope::FsPermissionRuleSet(rules) => {
281            agentos_vm_config::FsPermissionScope::Rules(agentos_vm_config::FsPermissionRuleSet {
282                default: rules.default.map(legacy_permission_mode_config),
283                rules: rules
284                    .rules
285                    .into_iter()
286                    .map(|rule| agentos_vm_config::FsPermissionRule {
287                        mode: legacy_permission_mode_config(rule.mode),
288                        operations: rule.operations,
289                        paths: rule.paths,
290                    })
291                    .collect(),
292            })
293        }
294    }
295}
296
297fn legacy_pattern_permission_scope_config(
298    scope: crate::generated_protocol::v1::PatternPermissionScope,
299) -> agentos_vm_config::PatternPermissionScope {
300    match scope {
301        crate::generated_protocol::v1::PatternPermissionScope::PermissionMode(mode) => {
302            agentos_vm_config::PatternPermissionScope::Mode(legacy_permission_mode_config(mode))
303        }
304        crate::generated_protocol::v1::PatternPermissionScope::PatternPermissionRuleSet(rules) => {
305            agentos_vm_config::PatternPermissionScope::Rules(
306                agentos_vm_config::PatternPermissionRuleSet {
307                    default: rules.default.map(legacy_permission_mode_config),
308                    rules: rules
309                        .rules
310                        .into_iter()
311                        .map(|rule| agentos_vm_config::PatternPermissionRule {
312                            mode: legacy_permission_mode_config(rule.mode),
313                            operations: rule.operations,
314                            patterns: rule.patterns,
315                        })
316                        .collect(),
317                },
318            )
319        }
320    }
321}
322
323fn legacy_dns_config(
324    metadata: &std::collections::BTreeMap<String, String>,
325) -> Option<agentos_vm_config::VmDnsConfig> {
326    let mut dns = agentos_vm_config::VmDnsConfig::default();
327    if let Some(value) = metadata.get("network.dns.servers") {
328        dns.name_servers = value
329            .split(',')
330            .map(str::trim)
331            .filter(|entry| !entry.is_empty())
332            .map(str::to_string)
333            .collect();
334    }
335    for (key, value) in metadata {
336        let Some(hostname) = key.strip_prefix("network.dns.override.") else {
337            continue;
338        };
339        dns.overrides.insert(
340            hostname.to_string(),
341            value
342                .split(',')
343                .map(str::trim)
344                .filter(|entry| !entry.is_empty())
345                .map(str::to_string)
346                .collect(),
347        );
348    }
349    if dns.name_servers.is_empty() && dns.overrides.is_empty() {
350        None
351    } else {
352        Some(dns)
353    }
354}
355
356fn legacy_native_root_config(
357    metadata: &std::collections::BTreeMap<String, String>,
358) -> Option<agentos_vm_config::NativeRootFilesystemConfig> {
359    let id = metadata.get("rootFilesystem.nativePlugin.id")?;
360    let config = metadata
361        .get("rootFilesystem.nativePlugin.config")
362        .map(|value| serde_json::from_str(value).expect("parse native root plugin config"))
363        .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
364    let read_only = metadata
365        .get("rootFilesystem.nativePlugin.readOnly")
366        .map(|value| value.parse::<bool>().expect("parse native root readOnly"))
367        .unwrap_or(false);
368    Some(agentos_vm_config::NativeRootFilesystemConfig {
369        plugin: agentos_vm_config::MountPluginDescriptor {
370            id: id.clone(),
371            config,
372        },
373        read_only,
374    })
375}
376
377fn legacy_listen_config(
378    metadata: &std::collections::BTreeMap<String, String>,
379) -> Option<agentos_vm_config::VmListenPolicyConfig> {
380    let listen = agentos_vm_config::VmListenPolicyConfig {
381        port_min: metadata
382            .get("network.listen.port_min")
383            .map(|value| value.parse::<u16>().expect("parse network.listen.port_min")),
384        port_max: metadata
385            .get("network.listen.port_max")
386            .map(|value| value.parse::<u16>().expect("parse network.listen.port_max")),
387        allow_privileged: metadata
388            .get("network.listen.allow_privileged")
389            .map(|value| {
390                value
391                    .parse::<bool>()
392                    .expect("parse network.listen.allow_privileged")
393            }),
394    };
395    if listen.port_min.is_none() && listen.port_max.is_none() && listen.allow_privileged.is_none() {
396        None
397    } else {
398        Some(listen)
399    }
400}
401
402fn legacy_loopback_exempt_ports(env: &std::collections::BTreeMap<String, String>) -> Vec<u16> {
403    let Some(value) = env.get("AGENTOS_LOOPBACK_EXEMPT_PORTS") else {
404        return Vec::new();
405    };
406    serde_json::from_str::<Vec<serde_json::Value>>(value)
407        .unwrap_or_default()
408        .into_iter()
409        .filter_map(|value| match value {
410            serde_json::Value::Number(number) => number.as_u64(),
411            serde_json::Value::String(value) => value.parse::<u64>().ok(),
412            _ => None,
413        })
414        .filter_map(|port| u16::try_from(port).ok())
415        .collect()
416}
417
418fn legacy_limits_config(
419    metadata: &std::collections::BTreeMap<String, String>,
420) -> Option<agentos_vm_config::VmLimitsConfig> {
421    let resources = agentos_vm_config::ResourceLimitsConfig {
422        cpu_count: legacy_u64(metadata, "resource.cpu_count"),
423        max_processes: legacy_u64(metadata, "resource.max_processes"),
424        max_open_fds: legacy_u64(metadata, "resource.max_open_fds"),
425        max_pipes: legacy_u64(metadata, "resource.max_pipes"),
426        max_ptys: legacy_u64(metadata, "resource.max_ptys"),
427        max_sockets: legacy_u64(metadata, "resource.max_sockets"),
428        max_connections: legacy_u64(metadata, "resource.max_connections"),
429        max_socket_buffered_bytes: legacy_u64(metadata, "resource.max_socket_buffered_bytes"),
430        max_socket_datagram_queue_len: legacy_u64(
431            metadata,
432            "resource.max_socket_datagram_queue_len",
433        ),
434        max_filesystem_bytes: legacy_u64(metadata, "resource.max_filesystem_bytes"),
435        max_inode_count: legacy_u64(metadata, "resource.max_inode_count"),
436        max_blocking_read_ms: legacy_u64(metadata, "resource.max_blocking_read_ms"),
437        max_pread_bytes: legacy_u64(metadata, "resource.max_pread_bytes"),
438        max_fd_write_bytes: legacy_u64(metadata, "resource.max_fd_write_bytes"),
439        max_process_argv_bytes: legacy_u64(metadata, "resource.max_process_argv_bytes"),
440        max_process_env_bytes: legacy_u64(metadata, "resource.max_process_env_bytes"),
441        max_readdir_entries: legacy_u64(metadata, "resource.max_readdir_entries"),
442        max_recursive_fs_depth: legacy_u64(metadata, "resource.max_recursive_fs_depth"),
443        max_recursive_fs_entries: legacy_u64(metadata, "resource.max_recursive_fs_entries"),
444        max_wasm_fuel: legacy_u64(metadata, "resource.max_wasm_fuel"),
445        max_wasm_memory_bytes: legacy_u64(metadata, "resource.max_wasm_memory_bytes"),
446        max_wasm_stack_bytes: legacy_u64(metadata, "resource.max_wasm_stack_bytes"),
447    };
448    let http = agentos_vm_config::HttpLimitsConfig {
449        max_fetch_response_bytes: legacy_u64(metadata, "limits.http.max_fetch_response_bytes"),
450    };
451    let host_functions = agentos_vm_config::HostFunctionLimitsConfig {
452        default_timeout_ms: legacy_u64(metadata, "limits.host_functions.default_timeout_ms"),
453        max_timeout_ms: legacy_u64(metadata, "limits.host_functions.max_timeout_ms"),
454        max_registered_collections: legacy_u64(
455            metadata,
456            "limits.host_functions.max_registered_collections",
457        ),
458        max_registered_functions_per_vm: legacy_u64(
459            metadata,
460            "limits.host_functions.max_registered_functions_per_vm",
461        ),
462        max_functions_per_collection: legacy_u64(
463            metadata,
464            "limits.host_functions.max_functions_per_collection",
465        ),
466        max_schema_bytes: legacy_u64(metadata, "limits.host_functions.max_schema_bytes"),
467        max_examples_per_function: legacy_u64(
468            metadata,
469            "limits.host_functions.max_examples_per_function",
470        ),
471        max_example_input_bytes: legacy_u64(
472            metadata,
473            "limits.host_functions.max_example_input_bytes",
474        ),
475    };
476    let plugins = agentos_vm_config::PluginLimitsConfig {
477        max_persisted_manifest_bytes: legacy_u64(
478            metadata,
479            "limits.plugins.max_persisted_manifest_bytes",
480        ),
481        max_persisted_manifest_file_bytes: legacy_u64(
482            metadata,
483            "limits.plugins.max_persisted_manifest_file_bytes",
484        ),
485    };
486    let sqlite = agentos_vm_config::SqliteLimitsConfig {
487        max_result_bytes: legacy_u64(metadata, "limits.sqlite.max_result_bytes"),
488    };
489    let js_runtime = agentos_vm_config::JsRuntimeLimitsConfig {
490        v8_heap_limit_mb: legacy_u64(metadata, "limits.js_runtime.v8_heap_limit_mb"),
491        sync_rpc_wait_timeout_ms: legacy_u64(
492            metadata,
493            "limits.js_runtime.sync_rpc_wait_timeout_ms",
494        ),
495        cpu_time_limit_ms: legacy_u64(metadata, "limits.js_runtime.cpu_time_limit_ms"),
496        wall_clock_limit_ms: legacy_u64(metadata, "limits.js_runtime.wall_clock_limit_ms"),
497        import_cache_materialize_timeout_ms: legacy_u64(
498            metadata,
499            "limits.js_runtime.import_cache_materialize_timeout_ms",
500        ),
501        captured_output_limit_bytes: legacy_u64(
502            metadata,
503            "limits.js_runtime.captured_output_limit_bytes",
504        ),
505        stdin_buffer_limit_bytes: legacy_u64(
506            metadata,
507            "limits.js_runtime.stdin_buffer_limit_bytes",
508        ),
509        event_payload_limit_bytes: legacy_u64(
510            metadata,
511            "limits.js_runtime.event_payload_limit_bytes",
512        ),
513        max_timers: legacy_u64(metadata, "limits.js_runtime.max_timers"),
514        v8_ipc_max_frame_bytes: legacy_u64(metadata, "limits.js_runtime.v8_ipc_max_frame_bytes"),
515    };
516    let python = agentos_vm_config::PythonLimitsConfig {
517        output_buffer_max_bytes: legacy_u64(metadata, "limits.python.output_buffer_max_bytes"),
518        execution_timeout_ms: legacy_u64(metadata, "limits.python.execution_timeout_ms"),
519        max_old_space_mb: legacy_u64(metadata, "limits.python.max_old_space_mb"),
520        vfs_rpc_timeout_ms: legacy_u64(metadata, "limits.python.vfs_rpc_timeout_ms"),
521    };
522    let wasm = agentos_vm_config::WasmLimitsConfig {
523        max_module_file_bytes: legacy_u64(metadata, "limits.wasm.max_module_file_bytes"),
524        captured_output_limit_bytes: legacy_u64(
525            metadata,
526            "limits.wasm.captured_output_limit_bytes",
527        ),
528        sync_read_limit_bytes: legacy_u64(metadata, "limits.wasm.sync_read_limit_bytes"),
529        prewarm_timeout_ms: legacy_u64(metadata, "limits.wasm.prewarm_timeout_ms"),
530        runner_heap_limit_mb: legacy_u64(metadata, "limits.wasm.runner_heap_limit_mb"),
531        runner_cpu_time_limit_ms: legacy_u64(metadata, "limits.wasm.runner_cpu_time_limit_ms"),
532    };
533    let execution = agentos_vm_config::ExecutionLimitsConfig {
534        completed_ttl_ms: legacy_u64(metadata, "limits.execution.completed_ttl_ms"),
535        max_completed_executions: legacy_u64(metadata, "limits.execution.max_completed_executions"),
536        live_execution_warning_threshold: legacy_u64(
537            metadata,
538            "limits.execution.live_execution_warning_threshold",
539        ),
540    };
541    let process = agentos_vm_config::ProcessLimitsConfig {
542        max_spawn_file_actions: legacy_u64(metadata, "limits.process.max_spawn_file_actions")
543            .or_else(|| legacy_u64(metadata, "limits.wasm.max_spawn_file_actions")),
544        max_spawn_file_action_bytes: legacy_u64(
545            metadata,
546            "limits.process.max_spawn_file_action_bytes",
547        )
548        .or_else(|| legacy_u64(metadata, "limits.wasm.max_spawn_file_action_bytes")),
549        pending_stdin_bytes: legacy_u64(metadata, "limits.process.pending_stdin_bytes"),
550        pending_event_count: legacy_u64(metadata, "limits.process.pending_event_count"),
551        pending_event_bytes: legacy_u64(metadata, "limits.process.pending_event_bytes"),
552        output_replay_events: legacy_u64(metadata, "limits.process.output_replay_events"),
553        output_replay_bytes: legacy_u64(metadata, "limits.process.output_replay_bytes"),
554        output_replay_page_events: legacy_u64(metadata, "limits.process.output_replay_page_events"),
555        output_replay_page_bytes: legacy_u64(metadata, "limits.process.output_replay_page_bytes"),
556        max_output_replays: legacy_u64(metadata, "limits.process.max_output_replays"),
557    };
558
559    let config = agentos_vm_config::VmLimitsConfig {
560        reactor: None,
561        resources: legacy_has_resource_limits(&resources).then_some(resources),
562        http: http.max_fetch_response_bytes.is_some().then_some(http),
563        udp: None,
564        tls: None,
565        http2: None,
566        host_functions: legacy_has_host_function_limits(&host_functions).then_some(host_functions),
567        plugins: legacy_has_plugin_limits(&plugins).then_some(plugins),
568        sqlite: sqlite.max_result_bytes.is_some().then_some(sqlite),
569        js_runtime: legacy_has_js_runtime_limits(&js_runtime).then_some(js_runtime),
570        python: legacy_has_python_limits(&python).then_some(python),
571        wasm: legacy_has_wasm_limits(&wasm).then_some(wasm),
572        execution: (execution.completed_ttl_ms.is_some()
573            || execution.max_completed_executions.is_some()
574            || execution.live_execution_warning_threshold.is_some())
575        .then_some(execution),
576        process: legacy_has_process_limits(&process).then_some(process),
577        agentos_packages: None,
578    };
579
580    if config.resources.is_none()
581        && config.http.is_none()
582        && config.host_functions.is_none()
583        && config.plugins.is_none()
584        && config.sqlite.is_none()
585        && config.js_runtime.is_none()
586        && config.python.is_none()
587        && config.wasm.is_none()
588        && config.execution.is_none()
589        && config.process.is_none()
590    {
591        None
592    } else {
593        Some(config)
594    }
595}
596
597fn legacy_u64(metadata: &std::collections::BTreeMap<String, String>, key: &str) -> Option<u64> {
598    metadata.get(key).map(|value| {
599        value
600            .parse::<u64>()
601            .unwrap_or_else(|error| panic!("parse {key}: {error}"))
602    })
603}
604
605fn legacy_has_resource_limits(config: &agentos_vm_config::ResourceLimitsConfig) -> bool {
606    config.cpu_count.is_some()
607        || config.max_processes.is_some()
608        || config.max_open_fds.is_some()
609        || config.max_pipes.is_some()
610        || config.max_ptys.is_some()
611        || config.max_sockets.is_some()
612        || config.max_connections.is_some()
613        || config.max_socket_buffered_bytes.is_some()
614        || config.max_socket_datagram_queue_len.is_some()
615        || config.max_filesystem_bytes.is_some()
616        || config.max_inode_count.is_some()
617        || config.max_blocking_read_ms.is_some()
618        || config.max_pread_bytes.is_some()
619        || config.max_fd_write_bytes.is_some()
620        || config.max_process_argv_bytes.is_some()
621        || config.max_process_env_bytes.is_some()
622        || config.max_readdir_entries.is_some()
623        || config.max_wasm_fuel.is_some()
624        || config.max_wasm_memory_bytes.is_some()
625        || config.max_wasm_stack_bytes.is_some()
626}
627
628fn legacy_has_host_function_limits(config: &agentos_vm_config::HostFunctionLimitsConfig) -> bool {
629    config.default_timeout_ms.is_some()
630        || config.max_timeout_ms.is_some()
631        || config.max_registered_collections.is_some()
632        || config.max_registered_functions_per_vm.is_some()
633        || config.max_functions_per_collection.is_some()
634        || config.max_schema_bytes.is_some()
635        || config.max_examples_per_function.is_some()
636        || config.max_example_input_bytes.is_some()
637}
638
639fn legacy_has_plugin_limits(config: &agentos_vm_config::PluginLimitsConfig) -> bool {
640    config.max_persisted_manifest_bytes.is_some()
641        || config.max_persisted_manifest_file_bytes.is_some()
642}
643
644fn legacy_has_js_runtime_limits(config: &agentos_vm_config::JsRuntimeLimitsConfig) -> bool {
645    config.v8_heap_limit_mb.is_some()
646        || config.sync_rpc_wait_timeout_ms.is_some()
647        || config.cpu_time_limit_ms.is_some()
648        || config.wall_clock_limit_ms.is_some()
649        || config.import_cache_materialize_timeout_ms.is_some()
650        || config.captured_output_limit_bytes.is_some()
651        || config.stdin_buffer_limit_bytes.is_some()
652        || config.event_payload_limit_bytes.is_some()
653        || config.max_timers.is_some()
654        || config.v8_ipc_max_frame_bytes.is_some()
655}
656
657fn legacy_has_python_limits(config: &agentos_vm_config::PythonLimitsConfig) -> bool {
658    config.output_buffer_max_bytes.is_some()
659        || config.execution_timeout_ms.is_some()
660        || config.max_old_space_mb.is_some()
661        || config.vfs_rpc_timeout_ms.is_some()
662}
663
664fn legacy_has_wasm_limits(config: &agentos_vm_config::WasmLimitsConfig) -> bool {
665    config.max_module_file_bytes.is_some()
666        || config.captured_output_limit_bytes.is_some()
667        || config.sync_read_limit_bytes.is_some()
668        || config.prewarm_timeout_ms.is_some()
669        || config.runner_heap_limit_mb.is_some()
670        || config.runner_cpu_time_limit_ms.is_some()
671}
672
673fn legacy_has_process_limits(config: &agentos_vm_config::ProcessLimitsConfig) -> bool {
674    config.max_spawn_file_actions.is_some()
675        || config.max_spawn_file_action_bytes.is_some()
676        || config.pending_stdin_bytes.is_some()
677        || config.pending_event_count.is_some()
678        || config.pending_event_bytes.is_some()
679}
680
681// Ownership-scope constructor ergonomics. The generated BARE union exposes only the
682// tuple-wrapped variants (`ConnectionOwnership`/`SessionOwnership`/`VmOwnership`); restore
683// the hand-written `connection`/`session`/`vm` helpers the sidecar relies on. These live in
684// `wire` (not `protocol`) for the same orphan-rule reason as the impls above: `protocol.rs`
685// is `#[path]`-included by integration tests where the generated type is foreign.
686impl crate::generated_protocol::v1::OwnershipScope {
687    pub fn connection(connection_id: impl Into<String>) -> Self {
688        Self::ConnectionOwnership(crate::generated_protocol::v1::ConnectionOwnership {
689            connection_id: connection_id.into(),
690        })
691    }
692
693    pub fn session(connection_id: impl Into<String>, session_id: impl Into<String>) -> Self {
694        Self::SessionOwnership(crate::generated_protocol::v1::SessionOwnership {
695            connection_id: connection_id.into(),
696            session_id: session_id.into(),
697        })
698    }
699
700    pub fn vm(
701        connection_id: impl Into<String>,
702        session_id: impl Into<String>,
703        vm_id: impl Into<String>,
704    ) -> Self {
705        Self::VmOwnership(crate::generated_protocol::v1::VmOwnership {
706            connection_id: connection_id.into(),
707            session_id: session_id.into(),
708            vm_id: vm_id.into(),
709        })
710    }
711}
712
713pub const PROTOCOL_NAME: &str = "agentos-native-sidecar";
714pub const PROTOCOL_VERSION: u16 = 11;
715// 16 MiB: large enough to carry a trusted-client CreateVm config that inlines an
716// entire base-filesystem snapshot, while still bounding a single frame.
717pub const DEFAULT_MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;
718
719/// Most entries one `GuestFilesystemWriteFilesRequest` may carry. The sidecar
720/// advertises it as `AuthenticatedResponse.max_write_files_entries`; clients
721/// split by that value. Total bytes are bounded by the negotiated `max_frame_bytes`.
722/// A batch runs in one critical section on the shared sidecar task set, so this
723/// stays small: a larger batch would stall every other VM on the sidecar until it ends.
724pub const MAX_GUEST_FILESYSTEM_WRITE_FILES_ENTRIES: usize = 16;
725
726#[derive(Debug, Clone, PartialEq, Eq)]
727pub enum ProtocolCodecError {
728    TruncatedFrame {
729        actual: usize,
730    },
731    LengthPrefixMismatch {
732        declared: usize,
733        actual: usize,
734    },
735    FrameTooLarge {
736        size: usize,
737        max: usize,
738    },
739    UnsupportedSchema {
740        name: String,
741        version: u16,
742    },
743    InvalidRequestId,
744    InvalidRequestDirection {
745        request_id: RequestId,
746        expected: RequestDirection,
747    },
748    EmptyOwnershipField {
749        field: &'static str,
750    },
751    EmptyAuthToken,
752    InvalidOwnershipScope {
753        required: OwnershipRequirement,
754        actual: OwnershipRequirement,
755    },
756    SerializeFailure(String),
757    DeserializeFailure(String),
758}
759
760impl fmt::Display for ProtocolCodecError {
761    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
762        match self {
763            Self::TruncatedFrame { actual } => {
764                write!(
765                    f,
766                    "protocol frame is truncated: only {actual} bytes provided"
767                )
768            }
769            Self::LengthPrefixMismatch { declared, actual } => write!(
770                f,
771                "protocol frame length prefix mismatch: declared {declared} bytes, got {actual}",
772            ),
773            Self::FrameTooLarge { size, max } => {
774                write!(f, "protocol frame is {size} bytes, limit is {max}")
775            }
776            Self::UnsupportedSchema { name, version } => write!(
777                f,
778                "unsupported protocol schema {name}@{version}; expected {PROTOCOL_NAME}@{PROTOCOL_VERSION}",
779            ),
780            Self::InvalidRequestId => write!(f, "protocol request identifiers must be non-zero"),
781            Self::InvalidRequestDirection {
782                request_id,
783                expected,
784            } => write!(f, "protocol request id {request_id} must be {expected}",),
785            Self::EmptyOwnershipField { field } => {
786                write!(f, "protocol ownership field `{field}` cannot be empty")
787            }
788            Self::EmptyAuthToken => {
789                write!(f, "authenticate requests require a non-empty auth token")
790            }
791            Self::InvalidOwnershipScope { required, actual } => write!(
792                f,
793                "protocol frame requires {required} ownership but carried {actual}",
794            ),
795            Self::SerializeFailure(message) => {
796                write!(f, "protocol frame serialization failed: {message}")
797            }
798            Self::DeserializeFailure(message) => {
799                write!(f, "protocol frame deserialization failed: {message}")
800            }
801        }
802    }
803}
804
805impl Error for ProtocolCodecError {}
806
807#[derive(Debug, Clone, Copy, PartialEq, Eq)]
808pub enum OwnershipRequirement {
809    Any,
810    Connection,
811    Session,
812    Vm,
813    SessionOrVm,
814}
815
816impl fmt::Display for OwnershipRequirement {
817    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
818        match self {
819            Self::Any => write!(f, "any"),
820            Self::Connection => write!(f, "connection"),
821            Self::Session => write!(f, "session"),
822            Self::Vm => write!(f, "vm"),
823            Self::SessionOrVm => write!(f, "session-or-vm"),
824        }
825    }
826}
827
828#[derive(Debug, Clone, Copy, PartialEq, Eq)]
829pub enum RequestDirection {
830    Host,
831    Sidecar,
832}
833
834impl fmt::Display for RequestDirection {
835    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
836        match self {
837            Self::Host => write!(f, "positive"),
838            Self::Sidecar => write!(f, "negative"),
839        }
840    }
841}
842
843#[derive(Debug, Clone, PartialEq, Eq)]
844pub struct WireDispatchResult {
845    pub response: ResponseFrame,
846    pub events: Vec<EventFrame>,
847}
848
849#[derive(Debug, Clone, PartialEq, Eq)]
850pub struct CompatDispatchResult {
851    pub response: crate::protocol::ResponseFrame,
852    pub events: Vec<crate::protocol::EventFrame>,
853}
854
855#[derive(Debug, Clone)]
856pub struct WireFrameCodec {
857    max_frame_bytes: usize,
858}
859
860impl WireFrameCodec {
861    pub fn new(max_frame_bytes: usize) -> Self {
862        Self { max_frame_bytes }
863    }
864
865    pub fn max_frame_bytes(&self) -> usize {
866        self.max_frame_bytes
867    }
868
869    pub fn encode(&self, frame: &ProtocolFrame) -> Result<Vec<u8>, ProtocolCodecError> {
870        validate_frame(frame)?;
871
872        let payload = serde_bare::to_vec(frame)
873            .map_err(|error| ProtocolCodecError::SerializeFailure(error.to_string()))?;
874        if payload.len() > self.max_frame_bytes {
875            return Err(ProtocolCodecError::FrameTooLarge {
876                size: payload.len(),
877                max: self.max_frame_bytes,
878            });
879        }
880
881        let length =
882            u32::try_from(payload.len()).map_err(|_| ProtocolCodecError::FrameTooLarge {
883                size: payload.len(),
884                max: u32::MAX as usize,
885            })?;
886
887        let mut encoded = Vec::with_capacity(4 + payload.len());
888        encoded.extend_from_slice(&length.to_be_bytes());
889        encoded.extend_from_slice(&payload);
890        Ok(encoded)
891    }
892
893    pub fn decode(&self, bytes: &[u8]) -> Result<ProtocolFrame, ProtocolCodecError> {
894        let payload = self.checked_payload(bytes)?;
895        let frame = serde_bare::from_slice(payload)
896            .map_err(|error| ProtocolCodecError::DeserializeFailure(error.to_string()))?;
897        validate_frame(&frame)?;
898        Ok(frame)
899    }
900
901    /// Encode a frame as a bare message WITHOUT the 4-byte length prefix.
902    ///
903    /// Stream transports (stdio) use [`encode`] so frames can be delimited in a
904    /// byte stream. Message transports where the boundary is the call itself
905    /// (the browser `pushFrame` / `postMessage` path) use this so the on-wire
906    /// bytes match the TypeScript `encodeProtocolFramePayload(frame, "bare")`,
907    /// which emits the raw bare frame with no prefix.
908    pub fn encode_message(&self, frame: &ProtocolFrame) -> Result<Vec<u8>, ProtocolCodecError> {
909        validate_frame(frame)?;
910        let payload = serde_bare::to_vec(frame)
911            .map_err(|error| ProtocolCodecError::SerializeFailure(error.to_string()))?;
912        if payload.len() > self.max_frame_bytes {
913            return Err(ProtocolCodecError::FrameTooLarge {
914                size: payload.len(),
915                max: self.max_frame_bytes,
916            });
917        }
918        Ok(payload)
919    }
920
921    /// Decode a bare message produced by [`encode_message`] (no length prefix).
922    pub fn decode_message(&self, bytes: &[u8]) -> Result<ProtocolFrame, ProtocolCodecError> {
923        if bytes.len() > self.max_frame_bytes {
924            return Err(ProtocolCodecError::FrameTooLarge {
925                size: bytes.len(),
926                max: self.max_frame_bytes,
927            });
928        }
929        let frame = serde_bare::from_slice(bytes)
930            .map_err(|error| ProtocolCodecError::DeserializeFailure(error.to_string()))?;
931        validate_frame(&frame)?;
932        Ok(frame)
933    }
934
935    fn checked_payload<'a>(&self, bytes: &'a [u8]) -> Result<&'a [u8], ProtocolCodecError> {
936        if bytes.len() < 4 {
937            return Err(ProtocolCodecError::TruncatedFrame {
938                actual: bytes.len(),
939            });
940        }
941
942        let declared =
943            u32::from_be_bytes(bytes[..4].try_into().expect("length prefix is four bytes"))
944                as usize;
945        if declared > self.max_frame_bytes {
946            return Err(ProtocolCodecError::FrameTooLarge {
947                size: declared,
948                max: self.max_frame_bytes,
949            });
950        }
951
952        let actual = bytes.len() - 4;
953        if declared != actual {
954            return Err(ProtocolCodecError::LengthPrefixMismatch { declared, actual });
955        }
956
957        Ok(&bytes[4..])
958    }
959}
960
961impl Default for WireFrameCodec {
962    fn default() -> Self {
963        Self::new(DEFAULT_MAX_FRAME_BYTES)
964    }
965}
966
967pub fn protocol_schema() -> ProtocolSchema {
968    ProtocolSchema::current()
969}
970
971impl ProtocolSchema {
972    pub fn current() -> Self {
973        Self {
974            name: PROTOCOL_NAME.to_string(),
975            version: PROTOCOL_VERSION,
976        }
977    }
978}
979
980impl Default for ProtocolSchema {
981    fn default() -> Self {
982        Self::current()
983    }
984}
985
986pub fn request_frame_to_compat(
987    request: RequestFrame,
988) -> Result<crate::protocol::RequestFrame, ProtocolCodecError> {
989    match crate::protocol::from_generated_protocol_frame(ProtocolFrame::RequestFrame(request))? {
990        crate::protocol::ProtocolFrame::Request(request) => Ok(request),
991        crate::protocol::ProtocolFrame::Response(_)
992        | crate::protocol::ProtocolFrame::Event(_)
993        | crate::protocol::ProtocolFrame::SidecarRequest(_)
994        | crate::protocol::ProtocolFrame::SidecarResponse(_)
995        | crate::protocol::ProtocolFrame::Control(_) => {
996            Err(ProtocolCodecError::DeserializeFailure(String::from(
997                "wire request frame converted to non-request compatibility frame",
998            )))
999        }
1000    }
1001}
1002
1003pub fn ownership_scope_to_compat(ownership: OwnershipScope) -> crate::protocol::OwnershipScope {
1004    crate::protocol::from_generated_ownership_scope(ownership)
1005}
1006
1007pub fn request_payload_to_compat(
1008    ownership: &crate::protocol::OwnershipScope,
1009    payload: RequestPayload,
1010) -> Result<crate::protocol::RequestPayload, ProtocolCodecError> {
1011    match crate::protocol::from_generated_protocol_frame(ProtocolFrame::RequestFrame(
1012        RequestFrame {
1013            schema: protocol_schema(),
1014            request_id: 1,
1015            ownership: crate::protocol::to_generated_ownership_scope(ownership),
1016            payload,
1017        },
1018    ))? {
1019        crate::protocol::ProtocolFrame::Request(request) => Ok(request.payload),
1020        crate::protocol::ProtocolFrame::Response(_)
1021        | crate::protocol::ProtocolFrame::Event(_)
1022        | crate::protocol::ProtocolFrame::SidecarRequest(_)
1023        | crate::protocol::ProtocolFrame::SidecarResponse(_)
1024        | crate::protocol::ProtocolFrame::Control(_) => {
1025            Err(ProtocolCodecError::DeserializeFailure(String::from(
1026                "wire request payload converted to non-request compatibility frame",
1027            )))
1028        }
1029    }
1030}
1031
1032pub fn response_payload_from_compat(
1033    ownership: &crate::protocol::OwnershipScope,
1034    payload: crate::protocol::ResponsePayload,
1035) -> Result<ResponsePayload, ProtocolCodecError> {
1036    match crate::protocol::to_generated_protocol_frame(&crate::protocol::ProtocolFrame::Response(
1037        crate::protocol::ResponseFrame::new(1, ownership.clone(), payload),
1038    ))? {
1039        ProtocolFrame::ResponseFrame(response) => Ok(response.payload),
1040        ProtocolFrame::RequestFrame(_)
1041        | ProtocolFrame::EventFrame(_)
1042        | ProtocolFrame::SidecarRequestFrame(_)
1043        | ProtocolFrame::SidecarResponseFrame(_)
1044        | ProtocolFrame::ControlFrame(_) => Err(ProtocolCodecError::SerializeFailure(
1045            String::from("compatibility response payload converted to non-response wire frame"),
1046        )),
1047    }
1048}
1049
1050pub fn event_frame_from_compat(
1051    event: crate::protocol::EventFrame,
1052) -> Result<EventFrame, ProtocolCodecError> {
1053    match crate::protocol::to_generated_protocol_frame(&crate::protocol::ProtocolFrame::Event(
1054        event,
1055    ))? {
1056        ProtocolFrame::EventFrame(event) => Ok(event),
1057        ProtocolFrame::RequestFrame(_)
1058        | ProtocolFrame::ResponseFrame(_)
1059        | ProtocolFrame::SidecarRequestFrame(_)
1060        | ProtocolFrame::SidecarResponseFrame(_)
1061        | ProtocolFrame::ControlFrame(_) => Err(ProtocolCodecError::SerializeFailure(
1062            String::from("compatibility event converted to non-event wire frame"),
1063        )),
1064    }
1065}
1066
1067pub fn event_frame_to_compat(
1068    event: EventFrame,
1069) -> Result<crate::protocol::EventFrame, ProtocolCodecError> {
1070    match crate::protocol::from_generated_protocol_frame(ProtocolFrame::EventFrame(event))? {
1071        crate::protocol::ProtocolFrame::Event(event) => Ok(event),
1072        crate::protocol::ProtocolFrame::Request(_)
1073        | crate::protocol::ProtocolFrame::Response(_)
1074        | crate::protocol::ProtocolFrame::SidecarRequest(_)
1075        | crate::protocol::ProtocolFrame::SidecarResponse(_)
1076        | crate::protocol::ProtocolFrame::Control(_) => {
1077            Err(ProtocolCodecError::DeserializeFailure(String::from(
1078                "wire event converted to non-event compatibility frame",
1079            )))
1080        }
1081    }
1082}
1083
1084pub fn sidecar_request_frame_from_compat(
1085    request: crate::protocol::SidecarRequestFrame,
1086) -> Result<SidecarRequestFrame, ProtocolCodecError> {
1087    match crate::protocol::to_generated_protocol_frame(
1088        &crate::protocol::ProtocolFrame::SidecarRequest(request),
1089    )? {
1090        ProtocolFrame::SidecarRequestFrame(request) => Ok(request),
1091        ProtocolFrame::RequestFrame(_)
1092        | ProtocolFrame::ResponseFrame(_)
1093        | ProtocolFrame::EventFrame(_)
1094        | ProtocolFrame::SidecarResponseFrame(_)
1095        | ProtocolFrame::ControlFrame(_) => {
1096            Err(ProtocolCodecError::SerializeFailure(String::from(
1097                "compatibility sidecar request converted to non-sidecar-request wire frame",
1098            )))
1099        }
1100    }
1101}
1102
1103pub fn sidecar_request_payload_to_compat(
1104    ownership: &crate::protocol::OwnershipScope,
1105    payload: SidecarRequestPayload,
1106) -> Result<crate::protocol::SidecarRequestPayload, ProtocolCodecError> {
1107    match crate::protocol::from_generated_protocol_frame(ProtocolFrame::SidecarRequestFrame(
1108        SidecarRequestFrame {
1109            schema: protocol_schema(),
1110            request_id: -1,
1111            ownership: crate::protocol::to_generated_ownership_scope(ownership),
1112            payload,
1113        },
1114    ))? {
1115        crate::protocol::ProtocolFrame::SidecarRequest(request) => Ok(request.payload),
1116        crate::protocol::ProtocolFrame::Request(_)
1117        | crate::protocol::ProtocolFrame::Response(_)
1118        | crate::protocol::ProtocolFrame::Event(_)
1119        | crate::protocol::ProtocolFrame::SidecarResponse(_)
1120        | crate::protocol::ProtocolFrame::Control(_) => {
1121            Err(ProtocolCodecError::DeserializeFailure(String::from(
1122                "wire sidecar request payload converted to non-sidecar-request compatibility frame",
1123            )))
1124        }
1125    }
1126}
1127
1128pub fn sidecar_response_frame_to_compat(
1129    response: SidecarResponseFrame,
1130) -> Result<crate::protocol::SidecarResponseFrame, ProtocolCodecError> {
1131    match crate::protocol::from_generated_protocol_frame(ProtocolFrame::SidecarResponseFrame(
1132        response,
1133    ))? {
1134        crate::protocol::ProtocolFrame::SidecarResponse(response) => Ok(response),
1135        crate::protocol::ProtocolFrame::Request(_)
1136        | crate::protocol::ProtocolFrame::Response(_)
1137        | crate::protocol::ProtocolFrame::Event(_)
1138        | crate::protocol::ProtocolFrame::SidecarRequest(_)
1139        | crate::protocol::ProtocolFrame::Control(_) => {
1140            Err(ProtocolCodecError::DeserializeFailure(String::from(
1141                "wire sidecar response converted to non-sidecar-response compatibility frame",
1142            )))
1143        }
1144    }
1145}
1146
1147pub fn sidecar_response_frame_from_compat(
1148    response: crate::protocol::SidecarResponseFrame,
1149) -> Result<SidecarResponseFrame, ProtocolCodecError> {
1150    match crate::protocol::to_generated_protocol_frame(
1151        &crate::protocol::ProtocolFrame::SidecarResponse(response),
1152    )? {
1153        ProtocolFrame::SidecarResponseFrame(response) => Ok(response),
1154        ProtocolFrame::RequestFrame(_)
1155        | ProtocolFrame::ResponseFrame(_)
1156        | ProtocolFrame::EventFrame(_)
1157        | ProtocolFrame::SidecarRequestFrame(_)
1158        | ProtocolFrame::ControlFrame(_) => {
1159            Err(ProtocolCodecError::SerializeFailure(String::from(
1160                "compatibility sidecar response converted to non-sidecar-response wire frame",
1161            )))
1162        }
1163    }
1164}
1165
1166pub fn dispatch_result_from_compat(
1167    result: CompatDispatchResult,
1168) -> Result<WireDispatchResult, ProtocolCodecError> {
1169    let response = match crate::protocol::to_generated_protocol_frame(
1170        &crate::protocol::ProtocolFrame::Response(result.response),
1171    )? {
1172        ProtocolFrame::ResponseFrame(response) => response,
1173        ProtocolFrame::RequestFrame(_)
1174        | ProtocolFrame::EventFrame(_)
1175        | ProtocolFrame::SidecarRequestFrame(_)
1176        | ProtocolFrame::SidecarResponseFrame(_)
1177        | ProtocolFrame::ControlFrame(_) => {
1178            return Err(ProtocolCodecError::SerializeFailure(String::from(
1179                "compatibility dispatch response converted to non-response wire frame",
1180            )));
1181        }
1182    };
1183
1184    let events = result
1185        .events
1186        .into_iter()
1187        .map(|event| {
1188            match crate::protocol::to_generated_protocol_frame(
1189                &crate::protocol::ProtocolFrame::Event(event),
1190            )? {
1191                ProtocolFrame::EventFrame(event) => Ok(event),
1192                ProtocolFrame::RequestFrame(_)
1193                | ProtocolFrame::ResponseFrame(_)
1194                | ProtocolFrame::SidecarRequestFrame(_)
1195                | ProtocolFrame::SidecarResponseFrame(_)
1196                | ProtocolFrame::ControlFrame(_) => Err(ProtocolCodecError::SerializeFailure(
1197                    String::from("compatibility dispatch event converted to non-event wire frame"),
1198                )),
1199            }
1200        })
1201        .collect::<Result<Vec<_>, _>>()?;
1202
1203    Ok(WireDispatchResult { response, events })
1204}
1205
1206fn validate_frame(frame: &ProtocolFrame) -> Result<(), ProtocolCodecError> {
1207    match frame {
1208        ProtocolFrame::RequestFrame(frame) => {
1209            validate_schema(&frame.schema)?;
1210            validate_request_id(frame.request_id)
1211        }
1212        ProtocolFrame::ResponseFrame(frame) => {
1213            validate_schema(&frame.schema)?;
1214            validate_request_id(frame.request_id)
1215        }
1216        ProtocolFrame::EventFrame(frame) => validate_schema(&frame.schema),
1217        ProtocolFrame::SidecarRequestFrame(frame) => {
1218            validate_schema(&frame.schema)?;
1219            validate_request_id(frame.request_id)
1220        }
1221        ProtocolFrame::SidecarResponseFrame(frame) => {
1222            validate_schema(&frame.schema)?;
1223            validate_request_id(frame.request_id)
1224        }
1225        ProtocolFrame::ControlFrame(frame) => validate_schema(&frame.schema),
1226    }
1227}
1228
1229fn validate_schema(schema: &ProtocolSchema) -> Result<(), ProtocolCodecError> {
1230    if schema.name != PROTOCOL_NAME || schema.version != PROTOCOL_VERSION {
1231        return Err(ProtocolCodecError::UnsupportedSchema {
1232            name: schema.name.clone(),
1233            version: schema.version,
1234        });
1235    }
1236    Ok(())
1237}
1238
1239fn validate_request_id(request_id: RequestId) -> Result<(), ProtocolCodecError> {
1240    if request_id == 0 {
1241        return Err(ProtocolCodecError::InvalidRequestId);
1242    }
1243    Ok(())
1244}
1245
1246#[cfg(test)]
1247mod tests {
1248    use super::*;
1249    use crate::generated_protocol::v1::{
1250        FsPermissionScope, PatternPermissionScope, PermissionMode,
1251    };
1252    use std::collections::BTreeMap;
1253
1254    #[test]
1255    fn legacy_metadata_preserves_js_runtime_limits_with_only_new_fields() {
1256        let metadata = BTreeMap::from([(
1257            String::from("limits.js_runtime.cpu_time_limit_ms"),
1258            String::from("123"),
1259        )]);
1260
1261        let config = legacy_limits_config(&metadata).expect("limits config");
1262        let js_runtime = config.js_runtime.expect("js runtime limits");
1263
1264        assert_eq!(js_runtime.cpu_time_limit_ms, Some(123));
1265    }
1266
1267    #[test]
1268    fn legacy_metadata_preserves_wasm_limits_with_only_new_fields() {
1269        let metadata = BTreeMap::from([(
1270            String::from("limits.wasm.prewarm_timeout_ms"),
1271            String::from("456"),
1272        )]);
1273
1274        let config = legacy_limits_config(&metadata).expect("limits config");
1275        let wasm = config.wasm.expect("wasm limits");
1276
1277        assert_eq!(wasm.prewarm_timeout_ms, Some(456));
1278    }
1279
1280    #[test]
1281    fn legacy_metadata_preserves_wasm_runner_heap_limit_as_only_new_field() {
1282        let metadata = BTreeMap::from([(
1283            String::from("limits.wasm.runner_heap_limit_mb"),
1284            String::from("789"),
1285        )]);
1286
1287        let config = legacy_limits_config(&metadata).expect("limits config");
1288        let wasm = config.wasm.expect("wasm limits");
1289
1290        assert_eq!(wasm.runner_heap_limit_mb, Some(789));
1291    }
1292
1293    #[test]
1294    fn legacy_metadata_preserves_wasm_runner_cpu_limit_as_only_new_field() {
1295        let metadata = BTreeMap::from([(
1296            String::from("limits.wasm.runner_cpu_time_limit_ms"),
1297            String::from("987"),
1298        )]);
1299
1300        let config = legacy_limits_config(&metadata).expect("limits config");
1301        let wasm = config.wasm.expect("wasm limits");
1302
1303        assert_eq!(wasm.runner_cpu_time_limit_ms, Some(987));
1304    }
1305
1306    #[test]
1307    fn permissions_policy_default_matches_no_policy_deny_all() {
1308        let policy = PermissionsPolicy::default();
1309
1310        assert!(matches!(
1311            policy.fs,
1312            Some(FsPermissionScope::PermissionMode(PermissionMode::Deny))
1313        ));
1314        for scope in [
1315            policy.network,
1316            policy.child_process,
1317            policy.process,
1318            policy.env,
1319            policy.host_function,
1320        ] {
1321            assert!(matches!(
1322                scope,
1323                Some(PatternPermissionScope::PermissionMode(PermissionMode::Deny))
1324            ));
1325        }
1326    }
1327
1328    #[test]
1329    fn permissions_policy_allow_all_remains_explicit() {
1330        let policy = PermissionsPolicy::allow_all();
1331
1332        assert!(matches!(
1333            policy.fs,
1334            Some(FsPermissionScope::PermissionMode(PermissionMode::Allow))
1335        ));
1336        for scope in [
1337            policy.network,
1338            policy.child_process,
1339            policy.process,
1340            policy.env,
1341            policy.host_function,
1342        ] {
1343            assert!(matches!(
1344                scope,
1345                Some(PatternPermissionScope::PermissionMode(
1346                    PermissionMode::Allow
1347                ))
1348            ));
1349        }
1350    }
1351
1352    #[test]
1353    fn package_unlink_frames_round_trip() {
1354        let codec = WireFrameCodec::default();
1355        let ownership = OwnershipScope::VmOwnership(VmOwnership {
1356            connection_id: String::from("connection"),
1357            session_id: String::from("session"),
1358            vm_id: String::from("vm"),
1359        });
1360        let request = ProtocolFrame::RequestFrame(RequestFrame {
1361            schema: protocol_schema(),
1362            request_id: 1,
1363            ownership: ownership.clone(),
1364            payload: RequestPayload::UnlinkPackageRequest(UnlinkPackageRequest {
1365                package_id: String::from("sha256:package"),
1366            }),
1367        });
1368        let response = ProtocolFrame::ResponseFrame(ResponseFrame {
1369            schema: protocol_schema(),
1370            request_id: 1,
1371            ownership,
1372            payload: ResponsePayload::PackageUnlinkedResponse(PackageUnlinkedResponse {
1373                removed_commands: vec![String::from("tool")],
1374            }),
1375        });
1376
1377        for frame in [request, response] {
1378            let encoded = codec.encode(&frame).expect("encode frame");
1379            assert_eq!(codec.decode(&encoded).expect("decode frame"), frame);
1380        }
1381    }
1382
1383    #[test]
1384    fn session_package_acquisition_frames_round_trip_without_host_path() {
1385        let codec = WireFrameCodec::default();
1386        let ownership = OwnershipScope::SessionOwnership(SessionOwnership {
1387            connection_id: String::from("connection"),
1388            session_id: String::from("session"),
1389        });
1390        let request = ProtocolFrame::RequestFrame(RequestFrame {
1391            schema: protocol_schema(),
1392            request_id: 2,
1393            ownership: ownership.clone(),
1394            payload: RequestPayload::AcquirePackageRequest(AcquirePackageRequest {
1395                source: PackageAcquisitionSource::PackageUrlSource(PackageUrlSource {
1396                    url: String::from("https://packages.example/tool.aospkg"),
1397                    expected_digest: Some(String::from("sha256:abc")),
1398                }),
1399                advisory: true,
1400                timeout_ms: Some(250),
1401                max_package_bytes: None,
1402                download_timeout_ms: None,
1403                connect_timeout_ms: None,
1404                max_redirects: None,
1405                allow_insecure_local_http: false,
1406            }),
1407        });
1408        let response = ProtocolFrame::ResponseFrame(ResponseFrame {
1409            schema: protocol_schema(),
1410            request_id: 2,
1411            ownership,
1412            payload: ResponsePayload::PackageAcquiredResponse(PackageAcquiredResponse {
1413                package_id: String::from("sha256:abc"),
1414                digest: String::from("sha256:abc"),
1415                size: 123,
1416                package_name: String::from("tool"),
1417                version: String::from("1"),
1418                commands: vec![String::from("tool")],
1419            }),
1420        });
1421        for frame in [request, response] {
1422            let encoded = codec.encode(&frame).expect("encode frame");
1423            assert_eq!(codec.decode(&encoded).expect("decode frame"), frame);
1424        }
1425    }
1426
1427    #[test]
1428    fn vm_package_install_frames_round_trip_without_host_path_in_response() {
1429        let codec = WireFrameCodec::default();
1430        let ownership = OwnershipScope::VmOwnership(VmOwnership {
1431            connection_id: "connection".into(),
1432            session_id: "session".into(),
1433            vm_id: "vm".into(),
1434        });
1435        let package = PackageAcquiredResponse {
1436            package_id: "sha256:abc".into(),
1437            digest: "sha256:abc".into(),
1438            size: 123,
1439            package_name: "tool".into(),
1440            version: "1".into(),
1441            commands: vec!["tool".into()],
1442        };
1443        let frames = [
1444            ProtocolFrame::RequestFrame(RequestFrame {
1445                schema: protocol_schema(),
1446                request_id: 3,
1447                ownership: ownership.clone(),
1448                payload: RequestPayload::InstallPackageRequest(InstallPackageRequest {
1449                    acquisition: AcquirePackageRequest {
1450                        source: PackageAcquisitionSource::PackageUrlSource(PackageUrlSource {
1451                            url: "https://packages.example/tool.aospkg".into(),
1452                            expected_digest: Some("sha256:abc".into()),
1453                        }),
1454                        advisory: false,
1455                        timeout_ms: Some(250),
1456                        max_package_bytes: None,
1457                        download_timeout_ms: None,
1458                        connect_timeout_ms: None,
1459                        max_redirects: None,
1460                        allow_insecure_local_http: false,
1461                    },
1462                }),
1463            }),
1464            ProtocolFrame::ResponseFrame(ResponseFrame {
1465                schema: protocol_schema(),
1466                request_id: 3,
1467                ownership,
1468                payload: ResponsePayload::PackageInstalledResponse(PackageInstalledResponse {
1469                    package,
1470                    projected_commands: vec![ProjectedCommand {
1471                        name: "tool".into(),
1472                        guest_path: "/opt/agentos/bin/tool".into(),
1473                    }],
1474                }),
1475            }),
1476        ];
1477        for frame in frames {
1478            let encoded = codec.encode(&frame).expect("encode frame");
1479            assert_eq!(codec.decode(&encoded).expect("decode frame"), frame);
1480        }
1481    }
1482}