1use std::collections::HashMap;
19use std::time::{Duration, Instant};
20
21use base64::engine::general_purpose::STANDARD as BASE64;
22use base64::Engine;
23use bytes::Bytes;
24use serde::Deserialize;
25
26use crate::config::{EdgeConfig, NodeConfig, PolicyConfig};
27use crate::context::{Context, GatewayRequest, GatewayResponse, Protocol};
28use crate::debug::render::render_trace;
29use crate::debug::{new_trace_id, TraceRecorder, TraceSource};
30use crate::graph::{compile_policy, prepare_policy};
31use crate::state::SharedState;
32
33#[derive(Debug, Clone, Deserialize)]
38#[serde(untagged)]
39pub enum StringOrList {
40 One(String),
41 Many(Vec<String>),
42}
43
44impl StringOrList {
45 fn into_vec(self) -> Vec<String> {
46 match self {
47 Self::One(s) => vec![s],
48 Self::Many(v) => v,
49 }
50 }
51}
52
53#[derive(Debug, Clone, Default, Deserialize)]
56#[serde(default, deny_unknown_fields)]
57pub struct ResponseInput {
58 pub status_code: Option<u16>,
59 pub headers: HashMap<String, StringOrList>,
60 pub body: Option<String>,
61}
62
63#[derive(Debug, Clone, Default, Deserialize)]
70#[serde(default, deny_unknown_fields)]
71pub struct SandboxContextInput {
72 pub method: Option<String>,
73 pub path: Option<String>,
74 pub host: Option<String>,
75 pub scheme: Option<String>,
76 pub headers: HashMap<String, StringOrList>,
77 pub query_params: HashMap<String, StringOrList>,
78 pub body: Option<String>,
80 pub body_base64: Option<String>,
82 pub remote_addr: Option<String>,
83 pub protocol: Option<Protocol>,
84 pub message: HashMap<String, serde_json::Value>,
85 pub response: Option<ResponseInput>,
86}
87
88impl SandboxContextInput {
89 pub fn into_context(self) -> Result<Context, String> {
91 if self.body.is_some() && self.body_base64.is_some() {
92 return Err("provide only one of 'body' or 'body_base64'".to_string());
93 }
94 let body = match (self.body, self.body_base64) {
95 (Some(text), _) => Bytes::from(text.into_bytes()),
96 (None, Some(b64)) => Bytes::from(
97 BASE64
98 .decode(b64.as_bytes())
99 .map_err(|e| format!("body_base64 is not valid base64: {e}"))?,
100 ),
101 (None, None) => Bytes::new(),
102 };
103
104 let to_map = |m: HashMap<String, StringOrList>| -> HashMap<String, Vec<String>> {
105 m.into_iter().map(|(k, v)| (k, v.into_vec())).collect()
106 };
107
108 let response = self.response.unwrap_or_default();
109 Ok(Context {
110 request: GatewayRequest {
111 method: self.method.unwrap_or_else(|| "GET".to_string()),
112 path: self.path.unwrap_or_else(|| "/".to_string()),
113 host: self.host.unwrap_or_else(|| "sandbox.local".to_string()),
114 scheme: self.scheme.unwrap_or_else(|| "http".to_string()),
115 headers: to_map(self.headers),
116 query_params: to_map(self.query_params),
117 body,
118 remote_addr: self
119 .remote_addr
120 .unwrap_or_else(|| "127.0.0.1:0".to_string()),
121 protocol: self.protocol.unwrap_or(Protocol::Http1),
122 },
123 response: GatewayResponse {
124 status_code: response.status_code.unwrap_or(0),
125 headers: to_map(response.headers),
126 body: response
127 .body
128 .map(|b| Bytes::from(b.into_bytes()))
129 .unwrap_or_default(),
130 stream: None,
131 },
132 message: self.message,
133 errors: Vec::new(),
134 })
135 }
136}
137
138#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq)]
140#[serde(rename_all = "snake_case")]
141pub enum OnError {
142 #[default]
147 Stop,
148 Client,
151}
152
153#[derive(Debug, Clone, Default, Deserialize)]
161#[serde(default, deny_unknown_fields)]
162pub struct SandboxRequest {
163 pub nodes: Option<Vec<NodeConfig>>,
164 pub policy: Option<String>,
165 pub on_error: OnError,
166 pub context: serde_json::Value,
167}
168
169pub fn materialize_context(raw: serde_json::Value) -> Result<Context, String> {
183 let normalized = normalize_context(raw);
184 if normalized.is_null() {
185 return SandboxContextInput::default().into_context();
186 }
187 let input: SandboxContextInput =
188 serde_json::from_value(normalized).map_err(|e| format!("invalid sandbox context: {e}"))?;
189 input.into_context()
190}
191
192fn normalize_context(v: serde_json::Value) -> serde_json::Value {
195 use serde_json::Value;
196 let Value::Object(mut obj) = v else { return v };
197
198 if let Some(Value::Object(req)) = obj.remove("request") {
201 for (k, val) in req {
202 if k == "body" {
203 let is_snapshot = val.as_object().is_some_and(|o| o.contains_key("len"));
204 if let Some(text) = body_text(&val) {
205 obj.insert("body".to_string(), Value::String(text));
206 } else if !is_snapshot {
207 obj.insert("body".to_string(), val);
211 }
212 } else {
213 obj.insert(k, val);
214 }
215 }
216 }
217 if let Some(resp) = obj.get_mut("response").and_then(Value::as_object_mut) {
221 if let Some(body) = resp.get("body").cloned() {
222 let is_snapshot = body.as_object().is_some_and(|o| o.contains_key("len"));
223 match body_text(&body) {
224 Some(text) => {
225 resp.insert("body".to_string(), Value::String(text));
226 }
227 None if is_snapshot => {
228 resp.remove("body");
229 }
230 None => {}
231 }
232 }
233 }
234 obj.remove("errors");
236
237 for (alias, canonical) in [("query", "query_params"), ("uri", "path")] {
240 if let Some(v) = obj.remove(alias) {
241 obj.entry(canonical.to_string()).or_insert(v);
242 }
243 }
244 if let Some(body) = obj.get("body") {
246 if let Some(text) = scalar_or_json_text(body) {
247 obj.insert("body".to_string(), Value::String(text));
248 }
249 }
250 for map_key in ["headers", "query_params"] {
252 if let Some(Value::Object(map)) = obj.get_mut(map_key) {
253 for v in map.values_mut() {
254 if matches!(v, Value::Number(_) | Value::Bool(_)) {
255 *v = Value::String(v.to_string());
256 }
257 }
258 }
259 }
260 if let Some(resp) = obj.get_mut("response").and_then(Value::as_object_mut) {
261 if let Some(v) = resp.remove("status") {
262 resp.entry("status_code".to_string()).or_insert(v);
263 }
264 if let Some(body) = resp.get("body") {
265 if let Some(text) = scalar_or_json_text(body) {
266 resp.insert("body".to_string(), Value::String(text));
267 }
268 }
269 }
270 Value::Object(obj)
271}
272
273fn scalar_or_json_text(v: &serde_json::Value) -> Option<String> {
277 use serde_json::Value;
278 match v {
279 Value::Number(_) | Value::Bool(_) => Some(v.to_string()),
280 Value::Object(_) | Value::Array(_) => Some(v.to_string()),
281 _ => None,
282 }
283}
284
285fn body_text(v: &serde_json::Value) -> Option<String> {
288 match v {
289 serde_json::Value::String(s) => Some(s.clone()),
290 serde_json::Value::Object(o) => o.get("text").and_then(|t| t.as_str()).map(String::from),
291 _ => None,
292 }
293}
294
295pub fn synthesize_policy(
300 nodes: Vec<NodeConfig>,
301 on_error: OnError,
302) -> Result<PolicyConfig, String> {
303 if nodes.is_empty() {
304 return Err("'nodes' must contain at least one node".to_string());
305 }
306
307 let mut seen = std::collections::HashSet::new();
308 let mut user_nodes = Vec::with_capacity(nodes.len());
309 for (i, mut n) in nodes.into_iter().enumerate() {
310 if n.node_type == "listener" || n.node_type == "client" {
311 return Err(format!(
312 "node '{}': '{}' nodes are added automatically and cannot be supplied",
313 n.id, n.node_type
314 ));
315 }
316 if n.id.trim().is_empty() {
317 n.id = format!("{}-{}", n.node_type, i);
318 }
319 if !seen.insert(n.id.clone()) {
320 return Err(format!("duplicate node id '{}'", n.id));
321 }
322 user_nodes.push(n);
323 }
324
325 let mut edges = vec![EdgeConfig {
326 from: "listener.out".to_string(),
327 to: format!("{}.in", user_nodes[0].id),
328 }];
329 for pair in user_nodes.windows(2) {
330 edges.push(EdgeConfig {
331 from: format!("{}.success", pair[0].id),
332 to: format!("{}.in", pair[1].id),
333 });
334 }
335 edges.push(EdgeConfig {
336 from: format!("{}.success", user_nodes[user_nodes.len() - 1].id),
337 to: "client.in".to_string(),
338 });
339 if on_error == OnError::Client {
340 for n in &user_nodes {
341 edges.push(EdgeConfig {
342 from: format!("{}.error", n.id),
343 to: "client.in".to_string(),
344 });
345 }
346 }
347
348 let already_wired: std::collections::HashSet<String> =
354 edges.iter().map(|e| e.from.clone()).collect();
355 for n in &user_nodes {
356 let spec =
357 crate::plugins::port_spec(&n.node_type).unwrap_or(&crate::plugins::ports::DEFAULT_SPEC);
358 for p in spec.outputs {
359 if matches!(p.kind, crate::plugins::ports::PortKind::Error) {
360 continue;
361 }
362 let from = format!("{}.{}", n.id, p.name);
363 if !already_wired.contains(&from) {
364 edges.push(EdgeConfig {
365 from,
366 to: "client.in".to_string(),
367 });
368 }
369 }
370 }
371
372 let mut all = Vec::with_capacity(user_nodes.len() + 2);
373 all.push(NodeConfig {
374 id: "listener".to_string(),
375 node_type: "listener".to_string(),
376 config: HashMap::new(),
377 config_ref: None,
378 position: None,
379 });
380 all.extend(user_nodes);
381 all.push(NodeConfig {
382 id: "client".to_string(),
383 node_type: "client".to_string(),
384 config: HashMap::new(),
385 config_ref: None,
386 position: None,
387 });
388
389 Ok(PolicyConfig {
390 name: "__sandbox".to_string(),
391 error_handler: None,
392 nodes: all,
393 edges,
394 })
395}
396
397#[derive(Debug)]
400pub enum SandboxError {
401 Disabled,
403 SandboxDisabled,
405 BadRequest(String),
408 UnknownPolicy(String),
410 Timeout(u64),
412}
413
414pub struct SandboxRun {
416 pub mode: &'static str,
418 pub policy: String,
420 pub stored_trace_id: String,
422 pub trace: serde_json::Value,
424}
425
426pub async fn run_sandbox(
430 state: &SharedState,
431 req: SandboxRequest,
432) -> Result<SandboxRun, SandboxError> {
433 if !state.debug.enabled {
434 return Err(SandboxError::Disabled);
435 }
436 if !state.debug.sandbox_enabled {
437 return Err(SandboxError::SandboxDisabled);
438 }
439
440 let (mode, policy_name, policy) = match (req.nodes, req.policy) {
441 (Some(_), Some(_)) | (None, None) => {
442 return Err(SandboxError::BadRequest(
443 "provide exactly one of 'nodes' or 'policy'".into(),
444 ))
445 }
446 (Some(nodes), None) => match synthesize_policy(nodes, req.on_error) {
447 Ok(p) => ("nodes", "__sandbox".to_string(), p),
448 Err(e) => return Err(SandboxError::BadRequest(e)),
449 },
450 (None, Some(name)) => {
451 let gw = state.gateway.read().await;
452 match gw.policies.iter().find(|p| p.name == name) {
453 Some(p) => ("policy", name.clone(), p.clone()),
456 None => return Err(SandboxError::UnknownPolicy(name)),
457 }
458 }
459 };
460
461 let (supernodes, plugin_configs) = {
466 let gw = state.gateway.read().await;
467 (gw.supernodes.clone(), gw.plugin_configs.clone())
468 };
469 let policy =
470 prepare_policy(policy, &supernodes, &plugin_configs).map_err(SandboxError::BadRequest)?;
471
472 let graph =
473 compile_policy(&policy, state.resources.clone()).map_err(SandboxError::BadRequest)?;
474
475 let ctx = materialize_context(req.context).map_err(SandboxError::BadRequest)?;
476
477 tracing::warn!(
478 "sandbox run ({}): plugins execute for real against live resources",
479 mode
480 );
481
482 let recorder = TraceRecorder::new(&ctx, state.debug.capture_options(), state.debug.max_steps);
483 let started = Instant::now();
484 let timeout = Duration::from_secs(state.debug.sandbox_timeout_seconds.max(1));
485
486 let run = graph.execute_traced(ctx, recorder);
487 let (out_ctx, recorder) = tokio::time::timeout(timeout, run)
488 .await
489 .map_err(|_| SandboxError::Timeout(state.debug.sandbox_timeout_seconds))?;
490
491 let id = new_trace_id();
492 let trace = recorder.finish(
493 id.clone(),
494 state.debug.next_seq(),
495 TraceSource::Sandbox,
496 None,
497 policy_name.clone(),
498 &out_ctx,
499 started.elapsed(),
500 );
501 let rendered = render_trace(&trace);
502 state.debug.record(trace);
505
506 Ok(SandboxRun {
507 mode,
508 policy: policy_name,
509 stored_trace_id: id,
510 trace: rendered,
511 })
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517
518 fn node(id: &str, node_type: &str) -> NodeConfig {
519 NodeConfig {
520 id: id.to_string(),
521 node_type: node_type.to_string(),
522 config: HashMap::new(),
523 config_ref: None,
524 position: None,
525 }
526 }
527
528 fn parse(json: serde_json::Value) -> Result<SandboxRequest, serde_json::Error> {
529 serde_json::from_value(json)
530 }
531
532 #[test]
535 fn test_empty_input_yields_sensible_defaults() {
536 let ctx = SandboxContextInput::default().into_context().unwrap();
537 assert_eq!(ctx.request.method, "GET");
538 assert_eq!(ctx.request.path, "/");
539 assert_eq!(ctx.request.host, "sandbox.local");
540 assert_eq!(ctx.request.scheme, "http");
541 assert_eq!(ctx.request.remote_addr, "127.0.0.1:0");
542 assert_eq!(ctx.request.protocol, Protocol::Http1);
543 assert!(ctx.request.body.is_empty());
544 assert_eq!(ctx.response.status_code, 0);
545 assert!(ctx.errors.is_empty());
546 }
547
548 #[test]
549 fn test_single_string_header_coerces_to_list() {
550 let req = parse(serde_json::json!({
551 "context": { "headers": { "apikey": "abc" }, "query_params": { "q": ["a", "b"] } }
552 }))
553 .unwrap();
554 let ctx = materialize_context(req.context).unwrap();
555 assert_eq!(ctx.request.headers["apikey"], vec!["abc"]);
556 assert_eq!(ctx.request.query_params["q"], vec!["a", "b"]);
557 }
558
559 #[test]
560 fn test_body_and_body_base64_are_mutually_exclusive() {
561 let input = SandboxContextInput {
562 body: Some("a".to_string()),
563 body_base64: Some("YQ==".to_string()),
564 ..Default::default()
565 };
566 assert!(input.into_context().is_err());
567 }
568
569 #[test]
570 fn test_body_base64_decoded() {
571 let input = SandboxContextInput {
572 body_base64: Some(BASE64.encode("binary")),
573 ..Default::default()
574 };
575 let ctx = input.into_context().unwrap();
576 assert_eq!(ctx.request.body, Bytes::from_static(b"binary"));
577 }
578
579 #[test]
580 fn test_response_seed_supports_response_phase_plugins() {
581 let req = parse(serde_json::json!({
582 "context": { "response": { "status_code": 200, "body": "hi", "headers": { "x": "1" } } }
583 }))
584 .unwrap();
585 let ctx = materialize_context(req.context).unwrap();
586 assert_eq!(ctx.response.status_code, 200);
587 assert_eq!(ctx.response.body, Bytes::from_static(b"hi"));
588 assert_eq!(ctx.response.headers["x"], vec!["1"]);
589 }
590
591 #[test]
595 fn test_unknown_context_field_is_rejected() {
596 let req = parse(serde_json::json!({ "context": { "paths": "/x" } })).unwrap();
597 assert!(materialize_context(req.context).is_err());
598 }
599
600 #[test]
603 fn test_accepts_trace_snapshot_shape() {
604 let snapshot = serde_json::json!({
605 "request": {
606 "method": "POST",
607 "path": "/api/items",
608 "host": "h",
609 "scheme": "http",
610 "headers": { "x-consumer": ["alice"] },
611 "query_params": { "page": ["2"] },
612 "body": { "len": 7, "text": "payload" }
613 },
614 "response": {
615 "status_code": 200,
616 "headers": { "x-powered-by": ["php"] },
617 "body": { "len": 2, "text": "ok" }
618 },
619 "message": { "user_id": "alice" },
620 "errors": [ { "node_id": "auth", "code": "X", "message": "y" } ]
621 });
622 let ctx = materialize_context(snapshot).unwrap();
623 assert_eq!(ctx.request.method, "POST");
624 assert_eq!(ctx.request.path, "/api/items");
625 assert_eq!(ctx.request.headers["x-consumer"], vec!["alice"]);
626 assert_eq!(ctx.request.query_params["page"], vec!["2"]);
627 assert_eq!(ctx.request.body, Bytes::from_static(b"payload"));
628 assert_eq!(ctx.response.status_code, 200);
629 assert_eq!(ctx.response.body, Bytes::from_static(b"ok"));
630 assert_eq!(ctx.message["user_id"], serde_json::json!("alice"));
631 assert!(ctx.errors.is_empty());
633 }
634
635 #[test]
638 fn test_snapshot_binary_or_uncaptured_body_becomes_empty() {
639 let snapshot = serde_json::json!({
640 "request": { "path": "/x", "body": { "len": 1024, "binary": true } }
641 });
642 let ctx = materialize_context(snapshot).unwrap();
643 assert_eq!(ctx.request.path, "/x");
644 assert!(ctx.request.body.is_empty());
645 }
646
647 #[test]
648 fn test_agent_friendly_aliases_and_coercions() {
649 let ctx = materialize_context(serde_json::json!({
652 "method": "POST",
653 "uri": "/orders",
654 "query": {"page": 2, "tags": ["a", "b"]},
655 "headers": {"x-retry": 3, "accept": ["text/plain", "application/json"]},
656 "body": {"order": {"id": 42}},
657 "response": {"status": 201, "body": {"ok": true}}
658 }))
659 .unwrap();
660 assert_eq!(ctx.request.path, "/orders");
661 assert_eq!(ctx.request.query_params["page"], vec!["2"]);
662 assert_eq!(ctx.request.query_params["tags"], vec!["a", "b"]);
663 assert_eq!(ctx.request.headers["x-retry"], vec!["3"]);
664 assert_eq!(ctx.request.headers["accept"].len(), 2);
665 assert_eq!(ctx.request.body.as_ref(), br#"{"order":{"id":42}}"#);
666 assert_eq!(ctx.response.status_code, 201);
667 assert_eq!(ctx.response.body.as_ref(), br#"{"ok":true}"#);
668 }
669
670 #[test]
671 fn test_nested_request_with_plain_object_body_is_kept() {
672 let ctx = materialize_context(serde_json::json!({
673 "request": {"path": "/x", "body": {"a": 1}}
674 }))
675 .unwrap();
676 assert_eq!(ctx.request.body.as_ref(), br#"{"a":1}"#);
677 let ctx = materialize_context(serde_json::json!({
679 "request": {"path": "/x", "body": {"len": 5, "binary": true}}
680 }))
681 .unwrap();
682 assert!(ctx.request.body.is_empty());
683 }
684
685 #[test]
686 fn test_synthesized_policy_chains_nodes() {
687 let p = synthesize_policy(
688 vec![node("a", "cors"), node("b", "proxy-rewrite")],
689 OnError::Stop,
690 )
691 .unwrap();
692 let ids: Vec<&str> = p.nodes.iter().map(|n| n.id.as_str()).collect();
693 assert_eq!(ids, vec!["listener", "a", "b", "client"]);
694
695 let edges: Vec<String> = p
696 .edges
697 .iter()
698 .map(|e| format!("{}->{}", e.from, e.to))
699 .collect();
700 assert_eq!(
701 edges,
702 vec![
703 "listener.out->a.in",
704 "a.success->b.in",
705 "b.success->client.in",
706 "a.preflight->client.in"
709 ]
710 );
711 assert!(crate::graph::validate_policy(&p).is_ok());
713 }
714
715 #[test]
718 fn test_on_error_client_wires_error_edges() {
719 let p = synthesize_policy(vec![node("a", "key-auth")], OnError::Client).unwrap();
720 assert!(p
721 .edges
722 .iter()
723 .any(|e| e.from == "a.error" && e.to == "client.in"));
724
725 let stop = synthesize_policy(vec![node("a", "key-auth")], OnError::Stop).unwrap();
726 assert!(!stop.edges.iter().any(|e| e.from == "a.error"));
727 }
728
729 #[test]
730 fn test_missing_id_is_defaulted_from_type() {
731 let p = synthesize_policy(vec![node("", "cors")], OnError::Stop).unwrap();
732 assert_eq!(p.nodes[1].id, "cors-0");
733 }
734
735 #[test]
736 fn test_duplicate_ids_rejected() {
737 let err = synthesize_policy(vec![node("a", "cors"), node("a", "csrf")], OnError::Stop)
738 .unwrap_err();
739 assert!(err.contains("duplicate node id"), "got: {err}");
740 }
741
742 #[test]
743 fn test_reserved_node_types_rejected() {
744 for t in ["listener", "client"] {
745 let err = synthesize_policy(vec![node("x", t)], OnError::Stop).unwrap_err();
746 assert!(err.contains("added automatically"), "got: {err}");
747 }
748 }
749
750 #[test]
751 fn test_empty_node_list_rejected() {
752 assert!(synthesize_policy(Vec::new(), OnError::Stop).is_err());
753 }
754
755 #[test]
756 fn test_on_error_defaults_to_stop() {
757 let req = parse(serde_json::json!({ "policy": "p" })).unwrap();
758 assert_eq!(req.on_error, OnError::Stop);
759 }
760
761 #[tokio::test]
762 async fn run_sandbox_reports_disabled_and_unknown_policy() {
763 use crate::config::{GatewayConfig, SystemConfig};
764 use crate::config_store::FileConfigStore;
765 use std::sync::Arc;
766
767 let off: SystemConfig = serde_yaml::from_str("{}").unwrap();
768 let gw: GatewayConfig = serde_yaml::from_str("{}").unwrap();
769 let store = Arc::new(FileConfigStore::new(std::path::PathBuf::from(
770 "gateway.yaml",
771 )));
772 let state = crate::state::SharedState::new(off, gw.clone(), None, store.clone()).unwrap();
773 let req = SandboxRequest {
774 policy: Some("p".into()),
775 ..Default::default()
776 };
777 assert!(matches!(
778 run_sandbox(&state, req).await,
779 Err(SandboxError::Disabled)
780 ));
781
782 let on: SystemConfig = serde_yaml::from_str("debug:\n enabled: true\n").unwrap();
783 let state = crate::state::SharedState::new(on, gw, None, store).unwrap();
784 let req = SandboxRequest {
785 policy: Some("missing".into()),
786 ..Default::default()
787 };
788 assert!(matches!(
789 run_sandbox(&state, req).await,
790 Err(SandboxError::UnknownPolicy(_))
791 ));
792 let req = SandboxRequest::default();
793 assert!(matches!(
794 run_sandbox(&state, req).await,
795 Err(SandboxError::BadRequest(_))
796 ));
797 }
798
799 #[tokio::test]
800 async fn run_sandbox_executes_nodes_mode() {
801 use crate::config::{GatewayConfig, SystemConfig};
802 use crate::config_store::FileConfigStore;
803 use std::sync::Arc;
804 let on: SystemConfig = serde_yaml::from_str("debug:\n enabled: true\n").unwrap();
805 let gw: GatewayConfig = serde_yaml::from_str("{}").unwrap();
806 let store = Arc::new(FileConfigStore::new(std::path::PathBuf::from(
807 "gateway.yaml",
808 )));
809 let state = crate::state::SharedState::new(on, gw, None, store).unwrap();
810 let req: SandboxRequest = serde_json::from_value(serde_json::json!({
814 "nodes": [{"id": "e", "type": "echo", "config": {"body": "hi"}}],
815 "context": {"method": "GET", "path": "/x"}
816 }))
817 .unwrap();
818 let run = run_sandbox(&state, req).await.unwrap();
819 assert_eq!(run.mode, "nodes");
820 assert_eq!(run.policy, "__sandbox");
821 assert!(state.debug.get(&run.stored_trace_id).is_some());
822 assert!(!run.trace["steps"].as_array().unwrap().is_empty());
823 }
824}