1use crate::frames::{reject, DispatchResult};
2use agentos_sidecar_protocol::protocol::{
3 AuthenticateRequest, BootstrapRootFilesystemRequest, CloseStdinRequest, ConfigureVmRequest,
4 CreateLayerRequest, CreateOverlayRequest, CreateVmRequest, DisposeVmRequest, ExecuteRequest,
5 ExportSnapshotRequest, ExtEnvelope, FindBoundUdpRequest, FindListenerRequest,
6 GetProcessSnapshotRequest, GetResourceSnapshotRequest, GetSignalStateRequest,
7 GetZombieTimerCountRequest, GuestFilesystemCallRequest, GuestKernelCallRequest,
8 ImportSnapshotRequest, KillProcessRequest, LinkPackageRequest, OpenSessionRequest,
9 OwnershipScope, ProvidedCommandsRequest, RegisterHostCallbacksRequest, RequestFrame,
10 RequestPayload, ResizePtyRequest, SealLayerRequest, SnapshotRootFilesystemRequest,
11 VmFetchRequest, WriteStdinRequest,
12};
13use agentos_sidecar_protocol::wire as generated_wire;
14
15pub const UNSUPPORTED_HOST_CALLBACK_DIRECTION_CODE: &str = "unsupported_direction";
16pub const UNSUPPORTED_HOST_CALLBACK_DIRECTION_MESSAGE: &str =
17 "host callback request categories are sidecar-to-host only in this scaffold";
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum RequestDispatchMode {
21 Immediate,
22 Async,
23}
24
25#[allow(clippy::large_enum_variant)]
28#[derive(Debug, Clone)]
29pub enum RequestRoute {
30 Authenticate(AuthenticateRequest),
31 OpenSession(OpenSessionRequest),
32 CreateVm(CreateVmRequest),
33 DisposeVm(DisposeVmRequest),
34 BootstrapRootFilesystem(BootstrapRootFilesystemRequest),
35 ConfigureVm(ConfigureVmRequest),
36 RegisterHostCallbacks(RegisterHostCallbacksRequest),
37 CreateLayer(CreateLayerRequest),
38 SealLayer(SealLayerRequest),
39 ImportSnapshot(ImportSnapshotRequest),
40 ExportSnapshot(ExportSnapshotRequest),
41 CreateOverlay(CreateOverlayRequest),
42 GuestFilesystemCall(GuestFilesystemCallRequest),
43 GuestKernelCall(GuestKernelCallRequest),
44 SnapshotRootFilesystem(SnapshotRootFilesystemRequest),
45 Execute(ExecuteRequest),
46 WriteStdin(WriteStdinRequest),
47 ResizePty(ResizePtyRequest),
48 CloseStdin(CloseStdinRequest),
49 KillProcess(KillProcessRequest),
50 GetProcessSnapshot(GetProcessSnapshotRequest),
51 GetResourceSnapshot(GetResourceSnapshotRequest),
52 FindListener(FindListenerRequest),
53 FindBoundUdp(FindBoundUdpRequest),
54 VmFetch(VmFetchRequest),
55 GetSignalState(GetSignalStateRequest),
56 GetZombieTimerCount(GetZombieTimerCountRequest),
57 LinkPackage(LinkPackageRequest),
58 ProvidedCommands(ProvidedCommandsRequest),
59 Ext(ExtEnvelope),
60 UnsupportedHostCallbackDirection,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum BlockingExtensionInterrupt<'a> {
65 ExtensionPayload(&'a [u8]),
66 KillProcess,
67}
68
69pub fn route_request_payload(request: &RequestFrame) -> RequestRoute {
70 match request.payload.clone() {
71 RequestPayload::Authenticate(payload) => RequestRoute::Authenticate(payload),
72 RequestPayload::OpenSession(payload) => RequestRoute::OpenSession(payload),
73 RequestPayload::CreateVm(payload) => RequestRoute::CreateVm(payload),
74 RequestPayload::DisposeVm(payload) => RequestRoute::DisposeVm(payload),
75 RequestPayload::BootstrapRootFilesystem(payload) => {
76 RequestRoute::BootstrapRootFilesystem(payload)
77 }
78 RequestPayload::ConfigureVm(payload) => RequestRoute::ConfigureVm(payload),
79 RequestPayload::RegisterHostCallbacks(payload) => {
80 RequestRoute::RegisterHostCallbacks(payload)
81 }
82 RequestPayload::CreateLayer(payload) => RequestRoute::CreateLayer(payload),
83 RequestPayload::SealLayer(payload) => RequestRoute::SealLayer(payload),
84 RequestPayload::ImportSnapshot(payload) => RequestRoute::ImportSnapshot(payload),
85 RequestPayload::ExportSnapshot(payload) => RequestRoute::ExportSnapshot(payload),
86 RequestPayload::CreateOverlay(payload) => RequestRoute::CreateOverlay(payload),
87 RequestPayload::GuestFilesystemCall(payload) => RequestRoute::GuestFilesystemCall(payload),
88 RequestPayload::GuestKernelCall(payload) => RequestRoute::GuestKernelCall(payload),
89 RequestPayload::SnapshotRootFilesystem(payload) => {
90 RequestRoute::SnapshotRootFilesystem(payload)
91 }
92 RequestPayload::Execute(payload) => RequestRoute::Execute(payload),
93 RequestPayload::WriteStdin(payload) => RequestRoute::WriteStdin(payload),
94 RequestPayload::ResizePty(payload) => RequestRoute::ResizePty(payload),
95 RequestPayload::CloseStdin(payload) => RequestRoute::CloseStdin(payload),
96 RequestPayload::KillProcess(payload) => RequestRoute::KillProcess(payload),
97 RequestPayload::GetProcessSnapshot(payload) => RequestRoute::GetProcessSnapshot(payload),
98 RequestPayload::GetResourceSnapshot(payload) => RequestRoute::GetResourceSnapshot(payload),
99 RequestPayload::FindListener(payload) => RequestRoute::FindListener(payload),
100 RequestPayload::FindBoundUdp(payload) => RequestRoute::FindBoundUdp(payload),
101 RequestPayload::VmFetch(payload) => RequestRoute::VmFetch(payload),
102 RequestPayload::GetSignalState(payload) => RequestRoute::GetSignalState(payload),
103 RequestPayload::GetZombieTimerCount(payload) => RequestRoute::GetZombieTimerCount(payload),
104 RequestPayload::LinkPackage(payload) => RequestRoute::LinkPackage(payload),
105 RequestPayload::ProvidedCommands(payload) => RequestRoute::ProvidedCommands(payload),
106 RequestPayload::HostFilesystemCall(_)
107 | RequestPayload::PersistenceLoad(_)
108 | RequestPayload::PersistenceFlush(_) => RequestRoute::UnsupportedHostCallbackDirection,
109 RequestPayload::Ext(payload) => RequestRoute::Ext(payload),
110 }
111}
112
113pub fn generated_wire_blocking_extension_interrupt<'a>(
114 active_request: &generated_wire::RequestFrame,
115 blocking_namespace: &str,
116 interrupting_request: &'a generated_wire::RequestFrame,
117) -> Option<BlockingExtensionInterrupt<'a>> {
118 if interrupting_request.ownership != active_request.ownership {
119 return None;
120 }
121
122 match &interrupting_request.payload {
123 generated_wire::RequestPayload::ExtEnvelope(envelope)
124 if envelope.namespace == blocking_namespace =>
125 {
126 Some(BlockingExtensionInterrupt::ExtensionPayload(
127 &envelope.payload,
128 ))
129 }
130 generated_wire::RequestPayload::ExtEnvelope(_) => None,
131 generated_wire::RequestPayload::KillProcessRequest(_) => {
132 Some(BlockingExtensionInterrupt::KillProcess)
133 }
134 _ => None,
135 }
136}
137
138pub fn request_dispatch_mode(request: &RequestFrame) -> RequestDispatchMode {
139 match request.payload {
140 RequestPayload::DisposeVm(_) | RequestPayload::Ext(_) => RequestDispatchMode::Async,
141 RequestPayload::Authenticate(_)
142 | RequestPayload::OpenSession(_)
143 | RequestPayload::CreateVm(_)
144 | RequestPayload::BootstrapRootFilesystem(_)
145 | RequestPayload::ConfigureVm(_)
146 | RequestPayload::RegisterHostCallbacks(_)
147 | RequestPayload::CreateLayer(_)
148 | RequestPayload::SealLayer(_)
149 | RequestPayload::ImportSnapshot(_)
150 | RequestPayload::ExportSnapshot(_)
151 | RequestPayload::CreateOverlay(_)
152 | RequestPayload::GuestFilesystemCall(_)
153 | RequestPayload::GuestKernelCall(_)
154 | RequestPayload::SnapshotRootFilesystem(_)
155 | RequestPayload::Execute(_)
156 | RequestPayload::WriteStdin(_)
157 | RequestPayload::ResizePty(_)
158 | RequestPayload::CloseStdin(_)
159 | RequestPayload::KillProcess(_)
160 | RequestPayload::GetProcessSnapshot(_)
161 | RequestPayload::GetResourceSnapshot(_)
162 | RequestPayload::FindListener(_)
163 | RequestPayload::FindBoundUdp(_)
164 | RequestPayload::VmFetch(_)
165 | RequestPayload::GetSignalState(_)
166 | RequestPayload::GetZombieTimerCount(_)
167 | RequestPayload::LinkPackage(_)
168 | RequestPayload::ProvidedCommands(_)
169 | RequestPayload::HostFilesystemCall(_)
170 | RequestPayload::PersistenceLoad(_)
171 | RequestPayload::PersistenceFlush(_) => RequestDispatchMode::Immediate,
172 }
173}
174
175pub fn request_is_unsupported_host_callback_direction(request: &RequestFrame) -> bool {
176 matches!(
177 request.payload,
178 RequestPayload::HostFilesystemCall(_)
179 | RequestPayload::PersistenceLoad(_)
180 | RequestPayload::PersistenceFlush(_)
181 )
182}
183
184pub fn unsupported_host_callback_direction_dispatch(request: &RequestFrame) -> DispatchResult {
185 debug_assert!(request_is_unsupported_host_callback_direction(request));
186 DispatchResult {
187 response: reject(
188 request,
189 UNSUPPORTED_HOST_CALLBACK_DIRECTION_CODE,
190 UNSUPPORTED_HOST_CALLBACK_DIRECTION_MESSAGE,
191 ),
192 events: Vec::new(),
193 }
194}
195
196pub fn connection_id_of(ownership: &OwnershipScope) -> Option<String> {
197 match ownership {
198 OwnershipScope::ConnectionOwnership(ownership) => Some(ownership.connection_id.clone()),
199 OwnershipScope::SessionOwnership(ownership) => Some(ownership.connection_id.clone()),
200 OwnershipScope::VmOwnership(ownership) => Some(ownership.connection_id.clone()),
201 }
202}
203
204pub fn session_scope_of(ownership: &OwnershipScope) -> Option<(String, String)> {
205 match ownership {
206 OwnershipScope::SessionOwnership(ownership) => Some((
207 ownership.connection_id.clone(),
208 ownership.session_id.clone(),
209 )),
210 OwnershipScope::VmOwnership(ownership) => Some((
211 ownership.connection_id.clone(),
212 ownership.session_id.clone(),
213 )),
214 OwnershipScope::ConnectionOwnership(_) => None,
215 }
216}
217
218pub fn vm_id_of(ownership: &OwnershipScope) -> Option<String> {
219 match ownership {
220 OwnershipScope::VmOwnership(ownership) => Some(ownership.vm_id.clone()),
221 OwnershipScope::ConnectionOwnership(_) | OwnershipScope::SessionOwnership(_) => None,
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use agentos_sidecar_protocol::protocol::{
229 AuthenticateRequest, ExtEnvelope, FilesystemOperation, HostFilesystemCallRequest,
230 OwnershipScope, PersistenceFlushRequest, PersistenceLoadRequest, ResponsePayload,
231 PROTOCOL_VERSION,
232 };
233 use agentos_sidecar_protocol::wire as generated_wire;
234
235 fn request(payload: RequestPayload) -> RequestFrame {
236 RequestFrame::new(7, OwnershipScope::connection("conn"), payload)
237 }
238
239 fn generated_request(
240 request_id: i64,
241 ownership: generated_wire::OwnershipScope,
242 payload: generated_wire::RequestPayload,
243 ) -> generated_wire::RequestFrame {
244 generated_wire::RequestFrame {
245 schema: generated_wire::protocol_schema(),
246 request_id,
247 ownership,
248 payload,
249 }
250 }
251
252 fn reverse_host_callback_payloads() -> Vec<RequestPayload> {
253 vec![
254 RequestPayload::HostFilesystemCall(HostFilesystemCallRequest {
255 operation: FilesystemOperation::Read,
256 path: String::from("/state"),
257 payload_size_bytes: 0,
258 }),
259 RequestPayload::PersistenceLoad(PersistenceLoadRequest {
260 key: String::from("state"),
261 }),
262 RequestPayload::PersistenceFlush(PersistenceFlushRequest {
263 key: String::from("state"),
264 payload_size_bytes: 0,
265 }),
266 ]
267 }
268
269 #[test]
270 fn dispose_and_ext_requests_are_async() {
271 let ext = request(RequestPayload::Ext(ExtEnvelope {
272 namespace: String::from("test"),
273 payload: Vec::new(),
274 }));
275 assert_eq!(request_dispatch_mode(&ext), RequestDispatchMode::Async);
276 }
277
278 #[test]
279 fn normal_requests_are_immediate() {
280 let authenticate = request(RequestPayload::Authenticate(AuthenticateRequest {
281 client_name: String::from("test"),
282 auth_token: String::from("token"),
283 protocol_version: PROTOCOL_VERSION,
284 bridge_version: 1,
285 }));
286 assert_eq!(
287 request_dispatch_mode(&authenticate),
288 RequestDispatchMode::Immediate
289 );
290 }
291
292 #[test]
293 fn host_callback_requests_are_identified_as_reverse_direction_only() {
294 for payload in reverse_host_callback_payloads() {
295 let host_call = request(payload);
296
297 assert!(request_is_unsupported_host_callback_direction(&host_call));
298 assert_eq!(
299 request_dispatch_mode(&host_call),
300 RequestDispatchMode::Immediate
301 );
302 }
303 }
304
305 #[test]
306 fn routes_protocol_payloads_through_shared_enum() {
307 let authenticate = request(RequestPayload::Authenticate(AuthenticateRequest {
308 client_name: String::from("test"),
309 auth_token: String::from("token"),
310 protocol_version: PROTOCOL_VERSION,
311 bridge_version: 1,
312 }));
313 assert!(matches!(
314 route_request_payload(&authenticate),
315 RequestRoute::Authenticate(_)
316 ));
317
318 let extension = request(RequestPayload::Ext(ExtEnvelope {
319 namespace: String::from("test"),
320 payload: vec![1, 2, 3],
321 }));
322 assert!(matches!(
323 route_request_payload(&extension),
324 RequestRoute::Ext(_)
325 ));
326
327 for payload in reverse_host_callback_payloads() {
328 let host_call = request(payload);
329 assert!(matches!(
330 route_request_payload(&host_call),
331 RequestRoute::UnsupportedHostCallbackDirection
332 ));
333 }
334 }
335
336 #[test]
337 fn unsupported_host_callback_dispatch_rejects_with_shared_code() {
338 for payload in reverse_host_callback_payloads() {
339 let host_call = request(payload);
340
341 let dispatch = unsupported_host_callback_direction_dispatch(&host_call);
342
343 assert!(dispatch.events.is_empty());
344 assert_eq!(dispatch.response.request_id, host_call.request_id);
345 assert_eq!(dispatch.response.ownership, host_call.ownership);
346 match dispatch.response.payload {
347 ResponsePayload::Rejected(rejected) => {
348 assert_eq!(rejected.code, UNSUPPORTED_HOST_CALLBACK_DIRECTION_CODE);
349 assert_eq!(
350 rejected.message,
351 UNSUPPORTED_HOST_CALLBACK_DIRECTION_MESSAGE
352 );
353 }
354 other => panic!("unexpected response payload: {other:?}"),
355 }
356 }
357 }
358
359 #[test]
360 fn generated_wire_prompt_interrupt_classifier_matches_only_same_scope_interrupts() {
361 let ownership = generated_wire::OwnershipScope::VmOwnership(generated_wire::VmOwnership {
362 connection_id: String::from("conn"),
363 session_id: String::from("session"),
364 vm_id: String::from("vm"),
365 });
366 let active = generated_request(
367 1,
368 ownership.clone(),
369 generated_wire::RequestPayload::ExtEnvelope(generated_wire::ExtEnvelope {
370 namespace: String::from("prompt"),
371 payload: b"active".to_vec(),
372 }),
373 );
374
375 let same_namespace = generated_request(
376 2,
377 ownership.clone(),
378 generated_wire::RequestPayload::ExtEnvelope(generated_wire::ExtEnvelope {
379 namespace: String::from("prompt"),
380 payload: b"cancel".to_vec(),
381 }),
382 );
383 assert_eq!(
384 generated_wire_blocking_extension_interrupt(&active, "prompt", &same_namespace),
385 Some(BlockingExtensionInterrupt::ExtensionPayload(b"cancel"))
386 );
387
388 let kill = generated_request(
389 3,
390 ownership.clone(),
391 generated_wire::RequestPayload::KillProcessRequest(
392 generated_wire::KillProcessRequest {
393 process_id: String::from("proc"),
394 signal: String::from("SIGTERM"),
395 },
396 ),
397 );
398 assert_eq!(
399 generated_wire_blocking_extension_interrupt(&active, "prompt", &kill),
400 Some(BlockingExtensionInterrupt::KillProcess)
401 );
402
403 let other_namespace = generated_request(
404 4,
405 ownership.clone(),
406 generated_wire::RequestPayload::ExtEnvelope(generated_wire::ExtEnvelope {
407 namespace: String::from("other"),
408 payload: b"cancel".to_vec(),
409 }),
410 );
411 assert_eq!(
412 generated_wire_blocking_extension_interrupt(&active, "prompt", &other_namespace),
413 None
414 );
415
416 let other_scope = generated_request(
417 5,
418 generated_wire::OwnershipScope::VmOwnership(generated_wire::VmOwnership {
419 connection_id: String::from("conn"),
420 session_id: String::from("session"),
421 vm_id: String::from("other-vm"),
422 }),
423 generated_wire::RequestPayload::KillProcessRequest(
424 generated_wire::KillProcessRequest {
425 process_id: String::from("proc"),
426 signal: String::from("SIGTERM"),
427 },
428 ),
429 );
430 assert_eq!(
431 generated_wire_blocking_extension_interrupt(&active, "prompt", &other_scope),
432 None
433 );
434 }
435
436 #[test]
437 fn ownership_scope_helpers_extract_shared_ids() {
438 let connection = OwnershipScope::connection("conn-1");
439 let session = OwnershipScope::session("conn-1", "session-1");
440 let vm = OwnershipScope::vm("conn-1", "session-1", "vm-1");
441
442 assert_eq!(connection_id_of(&connection).as_deref(), Some("conn-1"));
443 assert_eq!(connection_id_of(&session).as_deref(), Some("conn-1"));
444 assert_eq!(connection_id_of(&vm).as_deref(), Some("conn-1"));
445 assert_eq!(
446 session_scope_of(&session),
447 Some((String::from("conn-1"), String::from("session-1")))
448 );
449 assert_eq!(
450 session_scope_of(&vm),
451 Some((String::from("conn-1"), String::from("session-1")))
452 );
453 assert_eq!(session_scope_of(&connection), None);
454 assert_eq!(vm_id_of(&vm).as_deref(), Some("vm-1"));
455 assert_eq!(vm_id_of(&session), None);
456 }
457}