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#[derive(Debug, Clone, PartialEq, Eq)]
720pub enum ProtocolCodecError {
721    TruncatedFrame {
722        actual: usize,
723    },
724    LengthPrefixMismatch {
725        declared: usize,
726        actual: usize,
727    },
728    FrameTooLarge {
729        size: usize,
730        max: usize,
731    },
732    UnsupportedSchema {
733        name: String,
734        version: u16,
735    },
736    InvalidRequestId,
737    InvalidRequestDirection {
738        request_id: RequestId,
739        expected: RequestDirection,
740    },
741    EmptyOwnershipField {
742        field: &'static str,
743    },
744    EmptyAuthToken,
745    InvalidOwnershipScope {
746        required: OwnershipRequirement,
747        actual: OwnershipRequirement,
748    },
749    SerializeFailure(String),
750    DeserializeFailure(String),
751}
752
753impl fmt::Display for ProtocolCodecError {
754    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
755        match self {
756            Self::TruncatedFrame { actual } => {
757                write!(
758                    f,
759                    "protocol frame is truncated: only {actual} bytes provided"
760                )
761            }
762            Self::LengthPrefixMismatch { declared, actual } => write!(
763                f,
764                "protocol frame length prefix mismatch: declared {declared} bytes, got {actual}",
765            ),
766            Self::FrameTooLarge { size, max } => {
767                write!(f, "protocol frame is {size} bytes, limit is {max}")
768            }
769            Self::UnsupportedSchema { name, version } => write!(
770                f,
771                "unsupported protocol schema {name}@{version}; expected {PROTOCOL_NAME}@{PROTOCOL_VERSION}",
772            ),
773            Self::InvalidRequestId => write!(f, "protocol request identifiers must be non-zero"),
774            Self::InvalidRequestDirection {
775                request_id,
776                expected,
777            } => write!(f, "protocol request id {request_id} must be {expected}",),
778            Self::EmptyOwnershipField { field } => {
779                write!(f, "protocol ownership field `{field}` cannot be empty")
780            }
781            Self::EmptyAuthToken => {
782                write!(f, "authenticate requests require a non-empty auth token")
783            }
784            Self::InvalidOwnershipScope { required, actual } => write!(
785                f,
786                "protocol frame requires {required} ownership but carried {actual}",
787            ),
788            Self::SerializeFailure(message) => {
789                write!(f, "protocol frame serialization failed: {message}")
790            }
791            Self::DeserializeFailure(message) => {
792                write!(f, "protocol frame deserialization failed: {message}")
793            }
794        }
795    }
796}
797
798impl Error for ProtocolCodecError {}
799
800#[derive(Debug, Clone, Copy, PartialEq, Eq)]
801pub enum OwnershipRequirement {
802    Any,
803    Connection,
804    Session,
805    Vm,
806    SessionOrVm,
807}
808
809impl fmt::Display for OwnershipRequirement {
810    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
811        match self {
812            Self::Any => write!(f, "any"),
813            Self::Connection => write!(f, "connection"),
814            Self::Session => write!(f, "session"),
815            Self::Vm => write!(f, "vm"),
816            Self::SessionOrVm => write!(f, "session-or-vm"),
817        }
818    }
819}
820
821#[derive(Debug, Clone, Copy, PartialEq, Eq)]
822pub enum RequestDirection {
823    Host,
824    Sidecar,
825}
826
827impl fmt::Display for RequestDirection {
828    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
829        match self {
830            Self::Host => write!(f, "positive"),
831            Self::Sidecar => write!(f, "negative"),
832        }
833    }
834}
835
836#[derive(Debug, Clone, PartialEq, Eq)]
837pub struct WireDispatchResult {
838    pub response: ResponseFrame,
839    pub events: Vec<EventFrame>,
840}
841
842#[derive(Debug, Clone, PartialEq, Eq)]
843pub struct CompatDispatchResult {
844    pub response: crate::protocol::ResponseFrame,
845    pub events: Vec<crate::protocol::EventFrame>,
846}
847
848#[derive(Debug, Clone)]
849pub struct WireFrameCodec {
850    max_frame_bytes: usize,
851}
852
853impl WireFrameCodec {
854    pub fn new(max_frame_bytes: usize) -> Self {
855        Self { max_frame_bytes }
856    }
857
858    pub fn max_frame_bytes(&self) -> usize {
859        self.max_frame_bytes
860    }
861
862    pub fn encode(&self, frame: &ProtocolFrame) -> Result<Vec<u8>, ProtocolCodecError> {
863        validate_frame(frame)?;
864
865        let payload = serde_bare::to_vec(frame)
866            .map_err(|error| ProtocolCodecError::SerializeFailure(error.to_string()))?;
867        if payload.len() > self.max_frame_bytes {
868            return Err(ProtocolCodecError::FrameTooLarge {
869                size: payload.len(),
870                max: self.max_frame_bytes,
871            });
872        }
873
874        let length =
875            u32::try_from(payload.len()).map_err(|_| ProtocolCodecError::FrameTooLarge {
876                size: payload.len(),
877                max: u32::MAX as usize,
878            })?;
879
880        let mut encoded = Vec::with_capacity(4 + payload.len());
881        encoded.extend_from_slice(&length.to_be_bytes());
882        encoded.extend_from_slice(&payload);
883        Ok(encoded)
884    }
885
886    pub fn decode(&self, bytes: &[u8]) -> Result<ProtocolFrame, ProtocolCodecError> {
887        let payload = self.checked_payload(bytes)?;
888        let frame = serde_bare::from_slice(payload)
889            .map_err(|error| ProtocolCodecError::DeserializeFailure(error.to_string()))?;
890        validate_frame(&frame)?;
891        Ok(frame)
892    }
893
894    /// Encode a frame as a bare message WITHOUT the 4-byte length prefix.
895    ///
896    /// Stream transports (stdio) use [`encode`] so frames can be delimited in a
897    /// byte stream. Message transports where the boundary is the call itself
898    /// (the browser `pushFrame` / `postMessage` path) use this so the on-wire
899    /// bytes match the TypeScript `encodeProtocolFramePayload(frame, "bare")`,
900    /// which emits the raw bare frame with no prefix.
901    pub fn encode_message(&self, frame: &ProtocolFrame) -> Result<Vec<u8>, ProtocolCodecError> {
902        validate_frame(frame)?;
903        let payload = serde_bare::to_vec(frame)
904            .map_err(|error| ProtocolCodecError::SerializeFailure(error.to_string()))?;
905        if payload.len() > self.max_frame_bytes {
906            return Err(ProtocolCodecError::FrameTooLarge {
907                size: payload.len(),
908                max: self.max_frame_bytes,
909            });
910        }
911        Ok(payload)
912    }
913
914    /// Decode a bare message produced by [`encode_message`] (no length prefix).
915    pub fn decode_message(&self, bytes: &[u8]) -> Result<ProtocolFrame, ProtocolCodecError> {
916        if bytes.len() > self.max_frame_bytes {
917            return Err(ProtocolCodecError::FrameTooLarge {
918                size: bytes.len(),
919                max: self.max_frame_bytes,
920            });
921        }
922        let frame = serde_bare::from_slice(bytes)
923            .map_err(|error| ProtocolCodecError::DeserializeFailure(error.to_string()))?;
924        validate_frame(&frame)?;
925        Ok(frame)
926    }
927
928    fn checked_payload<'a>(&self, bytes: &'a [u8]) -> Result<&'a [u8], ProtocolCodecError> {
929        if bytes.len() < 4 {
930            return Err(ProtocolCodecError::TruncatedFrame {
931                actual: bytes.len(),
932            });
933        }
934
935        let declared =
936            u32::from_be_bytes(bytes[..4].try_into().expect("length prefix is four bytes"))
937                as usize;
938        if declared > self.max_frame_bytes {
939            return Err(ProtocolCodecError::FrameTooLarge {
940                size: declared,
941                max: self.max_frame_bytes,
942            });
943        }
944
945        let actual = bytes.len() - 4;
946        if declared != actual {
947            return Err(ProtocolCodecError::LengthPrefixMismatch { declared, actual });
948        }
949
950        Ok(&bytes[4..])
951    }
952}
953
954impl Default for WireFrameCodec {
955    fn default() -> Self {
956        Self::new(DEFAULT_MAX_FRAME_BYTES)
957    }
958}
959
960pub fn protocol_schema() -> ProtocolSchema {
961    ProtocolSchema::current()
962}
963
964impl ProtocolSchema {
965    pub fn current() -> Self {
966        Self {
967            name: PROTOCOL_NAME.to_string(),
968            version: PROTOCOL_VERSION,
969        }
970    }
971}
972
973impl Default for ProtocolSchema {
974    fn default() -> Self {
975        Self::current()
976    }
977}
978
979pub fn request_frame_to_compat(
980    request: RequestFrame,
981) -> Result<crate::protocol::RequestFrame, ProtocolCodecError> {
982    match crate::protocol::from_generated_protocol_frame(ProtocolFrame::RequestFrame(request))? {
983        crate::protocol::ProtocolFrame::Request(request) => Ok(request),
984        crate::protocol::ProtocolFrame::Response(_)
985        | crate::protocol::ProtocolFrame::Event(_)
986        | crate::protocol::ProtocolFrame::SidecarRequest(_)
987        | crate::protocol::ProtocolFrame::SidecarResponse(_)
988        | crate::protocol::ProtocolFrame::Control(_) => {
989            Err(ProtocolCodecError::DeserializeFailure(String::from(
990                "wire request frame converted to non-request compatibility frame",
991            )))
992        }
993    }
994}
995
996pub fn ownership_scope_to_compat(ownership: OwnershipScope) -> crate::protocol::OwnershipScope {
997    crate::protocol::from_generated_ownership_scope(ownership)
998}
999
1000pub fn request_payload_to_compat(
1001    ownership: &crate::protocol::OwnershipScope,
1002    payload: RequestPayload,
1003) -> Result<crate::protocol::RequestPayload, ProtocolCodecError> {
1004    match crate::protocol::from_generated_protocol_frame(ProtocolFrame::RequestFrame(
1005        RequestFrame {
1006            schema: protocol_schema(),
1007            request_id: 1,
1008            ownership: crate::protocol::to_generated_ownership_scope(ownership),
1009            payload,
1010        },
1011    ))? {
1012        crate::protocol::ProtocolFrame::Request(request) => Ok(request.payload),
1013        crate::protocol::ProtocolFrame::Response(_)
1014        | crate::protocol::ProtocolFrame::Event(_)
1015        | crate::protocol::ProtocolFrame::SidecarRequest(_)
1016        | crate::protocol::ProtocolFrame::SidecarResponse(_)
1017        | crate::protocol::ProtocolFrame::Control(_) => {
1018            Err(ProtocolCodecError::DeserializeFailure(String::from(
1019                "wire request payload converted to non-request compatibility frame",
1020            )))
1021        }
1022    }
1023}
1024
1025pub fn response_payload_from_compat(
1026    ownership: &crate::protocol::OwnershipScope,
1027    payload: crate::protocol::ResponsePayload,
1028) -> Result<ResponsePayload, ProtocolCodecError> {
1029    match crate::protocol::to_generated_protocol_frame(&crate::protocol::ProtocolFrame::Response(
1030        crate::protocol::ResponseFrame::new(1, ownership.clone(), payload),
1031    ))? {
1032        ProtocolFrame::ResponseFrame(response) => Ok(response.payload),
1033        ProtocolFrame::RequestFrame(_)
1034        | ProtocolFrame::EventFrame(_)
1035        | ProtocolFrame::SidecarRequestFrame(_)
1036        | ProtocolFrame::SidecarResponseFrame(_)
1037        | ProtocolFrame::ControlFrame(_) => Err(ProtocolCodecError::SerializeFailure(
1038            String::from("compatibility response payload converted to non-response wire frame"),
1039        )),
1040    }
1041}
1042
1043pub fn event_frame_from_compat(
1044    event: crate::protocol::EventFrame,
1045) -> Result<EventFrame, ProtocolCodecError> {
1046    match crate::protocol::to_generated_protocol_frame(&crate::protocol::ProtocolFrame::Event(
1047        event,
1048    ))? {
1049        ProtocolFrame::EventFrame(event) => Ok(event),
1050        ProtocolFrame::RequestFrame(_)
1051        | ProtocolFrame::ResponseFrame(_)
1052        | ProtocolFrame::SidecarRequestFrame(_)
1053        | ProtocolFrame::SidecarResponseFrame(_)
1054        | ProtocolFrame::ControlFrame(_) => Err(ProtocolCodecError::SerializeFailure(
1055            String::from("compatibility event converted to non-event wire frame"),
1056        )),
1057    }
1058}
1059
1060pub fn event_frame_to_compat(
1061    event: EventFrame,
1062) -> Result<crate::protocol::EventFrame, ProtocolCodecError> {
1063    match crate::protocol::from_generated_protocol_frame(ProtocolFrame::EventFrame(event))? {
1064        crate::protocol::ProtocolFrame::Event(event) => Ok(event),
1065        crate::protocol::ProtocolFrame::Request(_)
1066        | crate::protocol::ProtocolFrame::Response(_)
1067        | crate::protocol::ProtocolFrame::SidecarRequest(_)
1068        | crate::protocol::ProtocolFrame::SidecarResponse(_)
1069        | crate::protocol::ProtocolFrame::Control(_) => {
1070            Err(ProtocolCodecError::DeserializeFailure(String::from(
1071                "wire event converted to non-event compatibility frame",
1072            )))
1073        }
1074    }
1075}
1076
1077pub fn sidecar_request_frame_from_compat(
1078    request: crate::protocol::SidecarRequestFrame,
1079) -> Result<SidecarRequestFrame, ProtocolCodecError> {
1080    match crate::protocol::to_generated_protocol_frame(
1081        &crate::protocol::ProtocolFrame::SidecarRequest(request),
1082    )? {
1083        ProtocolFrame::SidecarRequestFrame(request) => Ok(request),
1084        ProtocolFrame::RequestFrame(_)
1085        | ProtocolFrame::ResponseFrame(_)
1086        | ProtocolFrame::EventFrame(_)
1087        | ProtocolFrame::SidecarResponseFrame(_)
1088        | ProtocolFrame::ControlFrame(_) => {
1089            Err(ProtocolCodecError::SerializeFailure(String::from(
1090                "compatibility sidecar request converted to non-sidecar-request wire frame",
1091            )))
1092        }
1093    }
1094}
1095
1096pub fn sidecar_request_payload_to_compat(
1097    ownership: &crate::protocol::OwnershipScope,
1098    payload: SidecarRequestPayload,
1099) -> Result<crate::protocol::SidecarRequestPayload, ProtocolCodecError> {
1100    match crate::protocol::from_generated_protocol_frame(ProtocolFrame::SidecarRequestFrame(
1101        SidecarRequestFrame {
1102            schema: protocol_schema(),
1103            request_id: -1,
1104            ownership: crate::protocol::to_generated_ownership_scope(ownership),
1105            payload,
1106        },
1107    ))? {
1108        crate::protocol::ProtocolFrame::SidecarRequest(request) => Ok(request.payload),
1109        crate::protocol::ProtocolFrame::Request(_)
1110        | crate::protocol::ProtocolFrame::Response(_)
1111        | crate::protocol::ProtocolFrame::Event(_)
1112        | crate::protocol::ProtocolFrame::SidecarResponse(_)
1113        | crate::protocol::ProtocolFrame::Control(_) => {
1114            Err(ProtocolCodecError::DeserializeFailure(String::from(
1115                "wire sidecar request payload converted to non-sidecar-request compatibility frame",
1116            )))
1117        }
1118    }
1119}
1120
1121pub fn sidecar_response_frame_to_compat(
1122    response: SidecarResponseFrame,
1123) -> Result<crate::protocol::SidecarResponseFrame, ProtocolCodecError> {
1124    match crate::protocol::from_generated_protocol_frame(ProtocolFrame::SidecarResponseFrame(
1125        response,
1126    ))? {
1127        crate::protocol::ProtocolFrame::SidecarResponse(response) => Ok(response),
1128        crate::protocol::ProtocolFrame::Request(_)
1129        | crate::protocol::ProtocolFrame::Response(_)
1130        | crate::protocol::ProtocolFrame::Event(_)
1131        | crate::protocol::ProtocolFrame::SidecarRequest(_)
1132        | crate::protocol::ProtocolFrame::Control(_) => {
1133            Err(ProtocolCodecError::DeserializeFailure(String::from(
1134                "wire sidecar response converted to non-sidecar-response compatibility frame",
1135            )))
1136        }
1137    }
1138}
1139
1140pub fn sidecar_response_frame_from_compat(
1141    response: crate::protocol::SidecarResponseFrame,
1142) -> Result<SidecarResponseFrame, ProtocolCodecError> {
1143    match crate::protocol::to_generated_protocol_frame(
1144        &crate::protocol::ProtocolFrame::SidecarResponse(response),
1145    )? {
1146        ProtocolFrame::SidecarResponseFrame(response) => Ok(response),
1147        ProtocolFrame::RequestFrame(_)
1148        | ProtocolFrame::ResponseFrame(_)
1149        | ProtocolFrame::EventFrame(_)
1150        | ProtocolFrame::SidecarRequestFrame(_)
1151        | ProtocolFrame::ControlFrame(_) => {
1152            Err(ProtocolCodecError::SerializeFailure(String::from(
1153                "compatibility sidecar response converted to non-sidecar-response wire frame",
1154            )))
1155        }
1156    }
1157}
1158
1159pub fn dispatch_result_from_compat(
1160    result: CompatDispatchResult,
1161) -> Result<WireDispatchResult, ProtocolCodecError> {
1162    let response = match crate::protocol::to_generated_protocol_frame(
1163        &crate::protocol::ProtocolFrame::Response(result.response),
1164    )? {
1165        ProtocolFrame::ResponseFrame(response) => response,
1166        ProtocolFrame::RequestFrame(_)
1167        | ProtocolFrame::EventFrame(_)
1168        | ProtocolFrame::SidecarRequestFrame(_)
1169        | ProtocolFrame::SidecarResponseFrame(_)
1170        | ProtocolFrame::ControlFrame(_) => {
1171            return Err(ProtocolCodecError::SerializeFailure(String::from(
1172                "compatibility dispatch response converted to non-response wire frame",
1173            )));
1174        }
1175    };
1176
1177    let events = result
1178        .events
1179        .into_iter()
1180        .map(|event| {
1181            match crate::protocol::to_generated_protocol_frame(
1182                &crate::protocol::ProtocolFrame::Event(event),
1183            )? {
1184                ProtocolFrame::EventFrame(event) => Ok(event),
1185                ProtocolFrame::RequestFrame(_)
1186                | ProtocolFrame::ResponseFrame(_)
1187                | ProtocolFrame::SidecarRequestFrame(_)
1188                | ProtocolFrame::SidecarResponseFrame(_)
1189                | ProtocolFrame::ControlFrame(_) => Err(ProtocolCodecError::SerializeFailure(
1190                    String::from("compatibility dispatch event converted to non-event wire frame"),
1191                )),
1192            }
1193        })
1194        .collect::<Result<Vec<_>, _>>()?;
1195
1196    Ok(WireDispatchResult { response, events })
1197}
1198
1199fn validate_frame(frame: &ProtocolFrame) -> Result<(), ProtocolCodecError> {
1200    match frame {
1201        ProtocolFrame::RequestFrame(frame) => {
1202            validate_schema(&frame.schema)?;
1203            validate_request_id(frame.request_id)
1204        }
1205        ProtocolFrame::ResponseFrame(frame) => {
1206            validate_schema(&frame.schema)?;
1207            validate_request_id(frame.request_id)
1208        }
1209        ProtocolFrame::EventFrame(frame) => validate_schema(&frame.schema),
1210        ProtocolFrame::SidecarRequestFrame(frame) => {
1211            validate_schema(&frame.schema)?;
1212            validate_request_id(frame.request_id)
1213        }
1214        ProtocolFrame::SidecarResponseFrame(frame) => {
1215            validate_schema(&frame.schema)?;
1216            validate_request_id(frame.request_id)
1217        }
1218        ProtocolFrame::ControlFrame(frame) => validate_schema(&frame.schema),
1219    }
1220}
1221
1222fn validate_schema(schema: &ProtocolSchema) -> Result<(), ProtocolCodecError> {
1223    if schema.name != PROTOCOL_NAME || schema.version != PROTOCOL_VERSION {
1224        return Err(ProtocolCodecError::UnsupportedSchema {
1225            name: schema.name.clone(),
1226            version: schema.version,
1227        });
1228    }
1229    Ok(())
1230}
1231
1232fn validate_request_id(request_id: RequestId) -> Result<(), ProtocolCodecError> {
1233    if request_id == 0 {
1234        return Err(ProtocolCodecError::InvalidRequestId);
1235    }
1236    Ok(())
1237}
1238
1239#[cfg(test)]
1240mod tests {
1241    use super::*;
1242    use crate::generated_protocol::v1::{
1243        FsPermissionScope, PatternPermissionScope, PermissionMode,
1244    };
1245    use std::collections::BTreeMap;
1246
1247    #[test]
1248    fn legacy_metadata_preserves_js_runtime_limits_with_only_new_fields() {
1249        let metadata = BTreeMap::from([(
1250            String::from("limits.js_runtime.cpu_time_limit_ms"),
1251            String::from("123"),
1252        )]);
1253
1254        let config = legacy_limits_config(&metadata).expect("limits config");
1255        let js_runtime = config.js_runtime.expect("js runtime limits");
1256
1257        assert_eq!(js_runtime.cpu_time_limit_ms, Some(123));
1258    }
1259
1260    #[test]
1261    fn legacy_metadata_preserves_wasm_limits_with_only_new_fields() {
1262        let metadata = BTreeMap::from([(
1263            String::from("limits.wasm.prewarm_timeout_ms"),
1264            String::from("456"),
1265        )]);
1266
1267        let config = legacy_limits_config(&metadata).expect("limits config");
1268        let wasm = config.wasm.expect("wasm limits");
1269
1270        assert_eq!(wasm.prewarm_timeout_ms, Some(456));
1271    }
1272
1273    #[test]
1274    fn legacy_metadata_preserves_wasm_runner_heap_limit_as_only_new_field() {
1275        let metadata = BTreeMap::from([(
1276            String::from("limits.wasm.runner_heap_limit_mb"),
1277            String::from("789"),
1278        )]);
1279
1280        let config = legacy_limits_config(&metadata).expect("limits config");
1281        let wasm = config.wasm.expect("wasm limits");
1282
1283        assert_eq!(wasm.runner_heap_limit_mb, Some(789));
1284    }
1285
1286    #[test]
1287    fn legacy_metadata_preserves_wasm_runner_cpu_limit_as_only_new_field() {
1288        let metadata = BTreeMap::from([(
1289            String::from("limits.wasm.runner_cpu_time_limit_ms"),
1290            String::from("987"),
1291        )]);
1292
1293        let config = legacy_limits_config(&metadata).expect("limits config");
1294        let wasm = config.wasm.expect("wasm limits");
1295
1296        assert_eq!(wasm.runner_cpu_time_limit_ms, Some(987));
1297    }
1298
1299    #[test]
1300    fn permissions_policy_default_matches_no_policy_deny_all() {
1301        let policy = PermissionsPolicy::default();
1302
1303        assert!(matches!(
1304            policy.fs,
1305            Some(FsPermissionScope::PermissionMode(PermissionMode::Deny))
1306        ));
1307        for scope in [
1308            policy.network,
1309            policy.child_process,
1310            policy.process,
1311            policy.env,
1312            policy.host_function,
1313        ] {
1314            assert!(matches!(
1315                scope,
1316                Some(PatternPermissionScope::PermissionMode(PermissionMode::Deny))
1317            ));
1318        }
1319    }
1320
1321    #[test]
1322    fn permissions_policy_allow_all_remains_explicit() {
1323        let policy = PermissionsPolicy::allow_all();
1324
1325        assert!(matches!(
1326            policy.fs,
1327            Some(FsPermissionScope::PermissionMode(PermissionMode::Allow))
1328        ));
1329        for scope in [
1330            policy.network,
1331            policy.child_process,
1332            policy.process,
1333            policy.env,
1334            policy.host_function,
1335        ] {
1336            assert!(matches!(
1337                scope,
1338                Some(PatternPermissionScope::PermissionMode(
1339                    PermissionMode::Allow
1340                ))
1341            ));
1342        }
1343    }
1344
1345    #[test]
1346    fn package_unlink_frames_round_trip() {
1347        let codec = WireFrameCodec::default();
1348        let ownership = OwnershipScope::VmOwnership(VmOwnership {
1349            connection_id: String::from("connection"),
1350            session_id: String::from("session"),
1351            vm_id: String::from("vm"),
1352        });
1353        let request = ProtocolFrame::RequestFrame(RequestFrame {
1354            schema: protocol_schema(),
1355            request_id: 1,
1356            ownership: ownership.clone(),
1357            payload: RequestPayload::UnlinkPackageRequest(UnlinkPackageRequest {
1358                package_id: String::from("sha256:package"),
1359            }),
1360        });
1361        let response = ProtocolFrame::ResponseFrame(ResponseFrame {
1362            schema: protocol_schema(),
1363            request_id: 1,
1364            ownership,
1365            payload: ResponsePayload::PackageUnlinkedResponse(PackageUnlinkedResponse {
1366                removed_commands: vec![String::from("tool")],
1367            }),
1368        });
1369
1370        for frame in [request, response] {
1371            let encoded = codec.encode(&frame).expect("encode frame");
1372            assert_eq!(codec.decode(&encoded).expect("decode frame"), frame);
1373        }
1374    }
1375
1376    #[test]
1377    fn session_package_acquisition_frames_round_trip_without_host_path() {
1378        let codec = WireFrameCodec::default();
1379        let ownership = OwnershipScope::SessionOwnership(SessionOwnership {
1380            connection_id: String::from("connection"),
1381            session_id: String::from("session"),
1382        });
1383        let request = ProtocolFrame::RequestFrame(RequestFrame {
1384            schema: protocol_schema(),
1385            request_id: 2,
1386            ownership: ownership.clone(),
1387            payload: RequestPayload::AcquirePackageRequest(AcquirePackageRequest {
1388                source: PackageAcquisitionSource::PackageUrlSource(PackageUrlSource {
1389                    url: String::from("https://packages.example/tool.aospkg"),
1390                    expected_digest: Some(String::from("sha256:abc")),
1391                }),
1392                advisory: true,
1393                timeout_ms: Some(250),
1394                max_package_bytes: None,
1395                download_timeout_ms: None,
1396                connect_timeout_ms: None,
1397                max_redirects: None,
1398                allow_insecure_local_http: false,
1399            }),
1400        });
1401        let response = ProtocolFrame::ResponseFrame(ResponseFrame {
1402            schema: protocol_schema(),
1403            request_id: 2,
1404            ownership,
1405            payload: ResponsePayload::PackageAcquiredResponse(PackageAcquiredResponse {
1406                package_id: String::from("sha256:abc"),
1407                digest: String::from("sha256:abc"),
1408                size: 123,
1409                package_name: String::from("tool"),
1410                version: String::from("1"),
1411                commands: vec![String::from("tool")],
1412            }),
1413        });
1414        for frame in [request, response] {
1415            let encoded = codec.encode(&frame).expect("encode frame");
1416            assert_eq!(codec.decode(&encoded).expect("decode frame"), frame);
1417        }
1418    }
1419
1420    #[test]
1421    fn vm_package_install_frames_round_trip_without_host_path_in_response() {
1422        let codec = WireFrameCodec::default();
1423        let ownership = OwnershipScope::VmOwnership(VmOwnership {
1424            connection_id: "connection".into(),
1425            session_id: "session".into(),
1426            vm_id: "vm".into(),
1427        });
1428        let package = PackageAcquiredResponse {
1429            package_id: "sha256:abc".into(),
1430            digest: "sha256:abc".into(),
1431            size: 123,
1432            package_name: "tool".into(),
1433            version: "1".into(),
1434            commands: vec!["tool".into()],
1435        };
1436        let frames = [
1437            ProtocolFrame::RequestFrame(RequestFrame {
1438                schema: protocol_schema(),
1439                request_id: 3,
1440                ownership: ownership.clone(),
1441                payload: RequestPayload::InstallPackageRequest(InstallPackageRequest {
1442                    acquisition: AcquirePackageRequest {
1443                        source: PackageAcquisitionSource::PackageUrlSource(PackageUrlSource {
1444                            url: "https://packages.example/tool.aospkg".into(),
1445                            expected_digest: Some("sha256:abc".into()),
1446                        }),
1447                        advisory: false,
1448                        timeout_ms: Some(250),
1449                        max_package_bytes: None,
1450                        download_timeout_ms: None,
1451                        connect_timeout_ms: None,
1452                        max_redirects: None,
1453                        allow_insecure_local_http: false,
1454                    },
1455                }),
1456            }),
1457            ProtocolFrame::ResponseFrame(ResponseFrame {
1458                schema: protocol_schema(),
1459                request_id: 3,
1460                ownership,
1461                payload: ResponsePayload::PackageInstalledResponse(PackageInstalledResponse {
1462                    package,
1463                    projected_commands: vec![ProjectedCommand {
1464                        name: "tool".into(),
1465                        guest_path: "/opt/agentos/bin/tool".into(),
1466                    }],
1467                }),
1468            }),
1469        ];
1470        for frame in frames {
1471            let encoded = codec.encode(&frame).expect("encode frame");
1472            assert_eq!(codec.decode(&encoded).expect("decode frame"), frame);
1473        }
1474    }
1475}