1use std::ffi::OsString;
31use std::fmt;
32use std::path::{Path, PathBuf};
33use std::process::Stdio;
34use std::time::Duration;
35
36use async_trait::async_trait;
37use serde_json::{Value, json};
38use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
39use tokio::process::Command;
40use tokio::sync::Semaphore;
41
42pub const HOST_EXEC_ENV: &str = "TALON_PALACE_HOST_EXEC";
44pub const HOST_EXEC_UNSAFE: &str = "unsafe";
46pub const DEFAULT_MAX_OUTPUT_BYTES: usize = 1024 * 1024;
48pub const DEFAULT_MAX_CONCURRENCY: usize = 4;
50const MAX_STDERR_BYTES: usize = 64 * 1024;
52const COMMAND_ENV_ALLOWLIST: &[&str] = &["PATH", "LANG", "TZ"];
54const DEFAULT_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
56const SCRIPT_ENV_ALLOWLIST: &[&str] = &[
58 "PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "TMPDIR", "USER", "LOGNAME",
59];
60const SCRIPT_ENV_PREFIX_ALLOWLIST: &[&str] = &[
63 "MEMPALACE_",
64 "HF_",
65 "TRANSFORMERS_",
66 "SENTENCE_TRANSFORMERS_",
67 "XDG_",
68];
69
70#[derive(Debug, thiserror::Error)]
73pub enum PalaceError {
74 #[error("palace not configured")]
76 Disabled,
77 #[error("palace call could not be started: {0}")]
79 Spawn(String),
80 #[error("palace call timed out after {0:?}")]
82 Timeout(Duration),
83 #[error("palace call exited with status {0}")]
85 Exit(String),
86 #[error("palace call returned non-JSON output")]
88 NonJson,
89 #[error("palace call output exceeded {0} bytes")]
91 OutputTooLarge(usize),
92 #[error("too many concurrent palace calls")]
94 Busy,
95}
96
97#[async_trait]
99pub trait PalaceRunner: Send + Sync {
100 async fn call(&self, request: Value) -> Result<Value, PalaceError>;
102
103 fn mode(&self) -> &'static str {
105 "custom"
106 }
107}
108
109pub fn add_lesson_request(
111 customer_code: &str,
112 lesson_type: &str,
113 lesson_text: &str,
114 durability: &str,
115) -> Value {
116 json!({
117 "op": "add_drawer",
118 "wing": customer_code,
119 "room": lesson_type,
120 "content": lesson_text,
121 "source_file": format!("review_lesson:{durability}"),
122 "added_by": "copilot-review",
123 })
124}
125
126pub fn search_request(customer_code: &str, room: Option<&str>, query: &str, limit: u32) -> Value {
128 let mut req = json!({
129 "op": "search",
130 "query": query,
131 "wing": customer_code,
132 "limit": limit,
133 });
134 if let (Some(room), Some(obj)) = (room, req.as_object_mut()) {
135 obj.insert("room".into(), Value::String(room.to_string()));
136 }
137 req
138}
139
140pub fn forget_request(drawer_id: &str) -> Value {
142 json!({ "op": "delete_drawer", "drawer_id": drawer_id })
143}
144
145pub fn parse_palace_stdout(stdout: &str) -> Option<Value> {
150 if let Ok(v) = serde_json::from_str::<Value>(stdout.trim()) {
151 return Some(v);
152 }
153 stdout
154 .lines()
155 .map(str::trim)
156 .filter(|l| l.starts_with('{') && l.ends_with('}'))
157 .rev()
158 .find_map(|l| serde_json::from_str::<Value>(l).ok())
159}
160
161#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
168pub struct DisabledPalaceRunner;
169
170#[async_trait]
171impl PalaceRunner for DisabledPalaceRunner {
172 async fn call(&self, _request: Value) -> Result<Value, PalaceError> {
173 Err(PalaceError::Disabled)
174 }
175
176 fn mode(&self) -> &'static str {
177 "disabled"
178 }
179}
180
181struct ProcessSpec {
188 program: PathBuf,
189 args: Vec<String>,
190 env: Vec<(OsString, OsString)>,
191 current_dir: Option<PathBuf>,
192 timeout: Duration,
193 max_output: usize,
194 max_concurrency: usize,
195 permits: Semaphore,
196}
197
198impl fmt::Debug for ProcessSpec {
199 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
200 let env_keys: Vec<_> = self.env.iter().map(|(k, _)| k).collect();
202 f.debug_struct("ProcessSpec")
203 .field("program", &self.program)
204 .field("args", &self.args)
205 .field("env_keys", &env_keys)
206 .field("current_dir", &self.current_dir)
207 .field("timeout", &self.timeout)
208 .field("max_output", &self.max_output)
209 .field("max_concurrency", &self.max_concurrency)
210 .finish()
211 }
212}
213
214fn allowlisted_env(exact: &[&str], prefixes: &[&str]) -> Vec<(OsString, OsString)> {
216 std::env::vars_os()
217 .filter(|(key, _)| {
218 key.to_str()
219 .is_some_and(|k| exact.contains(&k) || prefixes.iter().any(|p| k.starts_with(p)))
220 })
221 .collect()
222}
223
224impl ProcessSpec {
225 fn new(
226 program: PathBuf,
227 args: Vec<String>,
228 env: Vec<(OsString, OsString)>,
229 timeout: Duration,
230 ) -> Self {
231 Self {
232 program,
233 args,
234 env,
235 current_dir: None,
236 timeout,
237 max_output: DEFAULT_MAX_OUTPUT_BYTES,
238 max_concurrency: DEFAULT_MAX_CONCURRENCY,
239 permits: Semaphore::new(DEFAULT_MAX_CONCURRENCY),
240 }
241 }
242
243 fn set_max_output(&mut self, bytes: usize) {
244 self.max_output = bytes.max(1);
245 }
246
247 fn set_max_concurrency(&mut self, n: usize) {
248 self.max_concurrency = n.max(1);
249 self.permits = Semaphore::new(self.max_concurrency);
250 }
251
252 fn set_env(&mut self, key: OsString, value: OsString) {
253 self.env.retain(|(k, _)| *k != key);
254 self.env.push((key, value));
255 }
256
257 fn command(&self) -> Command {
258 let mut cmd = Command::new(&self.program);
259 cmd.args(&self.args)
260 .stdin(Stdio::piped())
261 .stdout(Stdio::piped())
262 .stderr(Stdio::piped())
263 .kill_on_drop(true)
264 .env_clear()
265 .envs(self.env.iter().map(|(k, v)| (k, v)));
266 if let Some(dir) = &self.current_dir {
267 cmd.current_dir(dir);
268 }
269 cmd
270 }
271
272 async fn call(&self, request: Value) -> Result<Value, PalaceError> {
273 let _permit = tokio::time::timeout(self.timeout, self.permits.acquire())
274 .await
275 .map_err(|_| PalaceError::Busy)?
276 .map_err(|_| PalaceError::Busy)?;
277
278 let mut child = self
279 .command()
280 .spawn()
281 .map_err(|e| PalaceError::Spawn(e.kind().to_string()))?;
282 let stdin = child.stdin.take();
283 let stdout = child.stdout.take();
284 let stderr = child.stderr.take();
285 let payload = request.to_string().into_bytes();
286 let max_output = self.max_output;
287
288 let io = async {
289 let write = async move {
290 if let Some(mut stdin) = stdin {
291 let _ = stdin.write_all(&payload).await;
292 let _ = stdin.shutdown().await;
293 }
294 };
295 let out = async move {
296 match stdout {
297 Some(s) => read_capped(s, max_output).await,
298 None => (Vec::new(), false),
299 }
300 };
301 let err = async move {
302 match stderr {
303 Some(s) => read_capped(s, MAX_STDERR_BYTES).await,
304 None => (Vec::new(), false),
305 }
306 };
307 let ((), out, err) = tokio::join!(write, out, err);
308 (out, err)
309 };
310
311 let result = tokio::time::timeout(self.timeout, async {
312 let (out, err) = io.await;
313 let status = child.wait().await;
314 (out, err, status)
315 })
316 .await;
317
318 let ((stdout, stdout_overflow), (stderr, _), status) = match result {
319 Ok(r) => r,
320 Err(_) => {
321 let _ = child.start_kill();
322 let _ = child.wait().await;
323 tracing::warn!(timeout = ?self.timeout, "palace call timed out; killed");
324 return Err(PalaceError::Timeout(self.timeout));
325 }
326 };
327
328 let stderr = String::from_utf8_lossy(&stderr);
329 if !stderr.trim().is_empty() {
330 tracing::debug!(stderr = %stderr.trim(), "palace call stderr");
331 }
332 let status = status.map_err(|e| PalaceError::Spawn(e.kind().to_string()))?;
333 if !status.success() {
334 let code = status
335 .code()
336 .map_or_else(|| "signal".to_string(), |c| c.to_string());
337 tracing::warn!(%code, "palace call failed");
338 return Err(PalaceError::Exit(code));
339 }
340 if stdout_overflow {
341 return Err(PalaceError::OutputTooLarge(max_output));
342 }
343 let text = String::from_utf8_lossy(&stdout);
344 match parse_palace_stdout(&text) {
345 Some(v) => {
346 if serde_json::from_str::<Value>(text.trim()).is_err() {
347 tracing::warn!(
348 "palace call emitted non-JSON noise on stdout; recovered last JSON line"
349 );
350 }
351 Ok(v)
352 }
353 None => Err(PalaceError::NonJson),
354 }
355 }
356}
357
358async fn read_capped<R: AsyncRead + Unpin>(mut reader: R, cap: usize) -> (Vec<u8>, bool) {
362 let mut out = Vec::new();
363 let mut buf = [0u8; 8192];
364 let mut overflow = false;
365 loop {
366 match reader.read(&mut buf).await {
367 Ok(0) | Err(_) => break,
368 Ok(n) => {
369 let room = cap.saturating_sub(out.len());
370 if n > room {
371 overflow = true;
372 }
373 out.extend_from_slice(&buf[..n.min(room)]);
374 }
375 }
376 }
377 (out, overflow)
378}
379
380#[derive(Debug)]
400pub struct CommandPalaceRunner {
401 spec: ProcessSpec,
402}
403
404impl CommandPalaceRunner {
405 pub fn new(program: PathBuf, args: Vec<String>, timeout: Duration) -> Self {
407 let mut env = allowlisted_env(COMMAND_ENV_ALLOWLIST, &[]);
408 if !env.iter().any(|(k, _)| k == "PATH") {
409 env.push(("PATH".into(), DEFAULT_PATH.into()));
410 }
411 Self {
412 spec: ProcessSpec::new(program, args, env, timeout),
413 }
414 }
415
416 pub fn with_env(mut self, key: impl Into<OsString>, value: impl Into<OsString>) -> Self {
419 self.spec.set_env(key.into(), value.into());
420 self
421 }
422
423 pub fn with_max_output(mut self, bytes: usize) -> Self {
425 self.spec.set_max_output(bytes);
426 self
427 }
428
429 pub fn with_max_concurrency(mut self, n: usize) -> Self {
431 self.spec.set_max_concurrency(n);
432 self
433 }
434
435 pub fn program(&self) -> &Path {
437 &self.spec.program
438 }
439
440 pub fn args(&self) -> &[String] {
442 &self.spec.args
443 }
444
445 pub fn timeout(&self) -> Duration {
447 self.spec.timeout
448 }
449}
450
451#[async_trait]
452impl PalaceRunner for CommandPalaceRunner {
453 async fn call(&self, request: Value) -> Result<Value, PalaceError> {
454 self.spec.call(request).await
455 }
456
457 fn mode(&self) -> &'static str {
458 "command"
459 }
460}
461
462#[derive(Debug)]
474pub struct ScriptPalaceRunner {
475 spec: ProcessSpec,
476}
477
478impl ScriptPalaceRunner {
479 pub fn new(
482 script: impl Into<PathBuf>,
483 timeout: Duration,
484 max_output: usize,
485 max_concurrency: usize,
486 ) -> Self {
487 let script = script.into();
488 let env = allowlisted_env(SCRIPT_ENV_ALLOWLIST, SCRIPT_ENV_PREFIX_ALLOWLIST);
489 let mut spec = ProcessSpec::new(script, Vec::new(), env, timeout);
490 spec.current_dir = spec.program.parent().map(Path::to_path_buf);
491 spec.set_max_output(max_output);
492 spec.set_max_concurrency(max_concurrency);
493 Self { spec }
494 }
495}
496
497#[async_trait]
498impl PalaceRunner for ScriptPalaceRunner {
499 async fn call(&self, request: Value) -> Result<Value, PalaceError> {
500 self.spec.call(request).await
501 }
502
503 fn mode(&self) -> &'static str {
504 "host-script (UNSAFE)"
505 }
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511 use std::os::unix::fs::PermissionsExt;
512
513 fn script(dir: &tempfile::TempDir, body: &str) -> PathBuf {
514 let path = dir.path().join("palace-call.sh");
515 std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
516 let mut perm = std::fs::metadata(&path).unwrap().permissions();
517 perm.set_mode(0o755);
518 std::fs::set_permissions(&path, perm).unwrap();
519 path
520 }
521
522 fn runner(path: PathBuf, timeout_ms: u64) -> ScriptPalaceRunner {
523 ScriptPalaceRunner::new(path, Duration::from_millis(timeout_ms), 4096, 2)
524 }
525
526 fn sh(body: &str, timeout_ms: u64) -> CommandPalaceRunner {
529 CommandPalaceRunner::new(
530 PathBuf::from("/bin/sh"),
531 vec!["-c".into(), body.into(), "palace".into()],
532 Duration::from_millis(timeout_ms),
533 )
534 }
535
536 #[test]
537 fn request_shapes_match_the_ts_client() {
538 let add = add_lesson_request("acme", "environment", "DC01 patches at 02:00", "durable");
539 assert_eq!(add["op"], "add_drawer");
540 assert_eq!(add["wing"], "acme");
541 assert_eq!(add["room"], "environment");
542 assert_eq!(add["content"], "DC01 patches at 02:00");
543 assert_eq!(add["source_file"], "review_lesson:durable");
544 assert_eq!(add["added_by"], "copilot-review");
545
546 let s = search_request("acme", None, "svchost", 5);
547 assert_eq!(s["op"], "search");
548 assert_eq!(s["limit"], 5);
549 assert!(s.get("room").is_none());
550 let s = search_request("acme", Some("assets"), "q", 7);
551 assert_eq!(s["room"], "assets");
552
553 assert_eq!(
554 forget_request("d1"),
555 json!({"op": "delete_drawer", "drawer_id": "d1"})
556 );
557 }
558
559 #[test]
560 fn stdout_parsing_recovers_the_last_json_line() {
561 assert_eq!(parse_palace_stdout(" {\"a\":1} \n"), Some(json!({"a": 1})));
562 assert_eq!(
563 parse_palace_stdout("Filed drawer: x\n{\"a\":1}\nnoise\n{\"b\":2}\n"),
564 Some(json!({"b": 2}))
565 );
566 assert_eq!(parse_palace_stdout("no json here"), None);
567 assert_eq!(parse_palace_stdout(""), None);
568 }
569
570 #[tokio::test]
573 async fn disabled_runner_never_executes_anything() {
574 let err = DisabledPalaceRunner
575 .call(forget_request("d"))
576 .await
577 .unwrap_err();
578 assert!(matches!(err, PalaceError::Disabled));
579 assert_eq!(err.to_string(), "palace not configured");
580 assert_eq!(DisabledPalaceRunner.mode(), "disabled");
581 }
582
583 #[tokio::test]
586 async fn command_runner_passes_args_and_round_trips_json() {
587 let dir = tempfile::tempdir().unwrap();
588 let fake = script(
590 &dir,
591 r#"req=$(cat); printf '{"args":"%s","req":%s}\n' "$*" "$req""#,
592 );
593 let runner = CommandPalaceRunner::new(
594 fake.clone(),
595 vec!["run".into(), "--rm".into(), "-i".into(), "palace".into()],
596 Duration::from_secs(5),
597 );
598 assert_eq!(runner.program(), fake.as_path());
599 assert_eq!(runner.args().len(), 4);
600 assert_eq!(runner.mode(), "command");
601 let out = runner.call(forget_request("drawer-1")).await.unwrap();
602 assert_eq!(out["args"], "run --rm -i palace");
603 assert_eq!(out["req"]["drawer_id"], "drawer-1");
604 }
605
606 #[tokio::test]
607 async fn command_runner_env_is_only_path_lang_tz() {
608 assert!(std::env::var_os("CARGO_MANIFEST_DIR").is_some());
610 let runner = sh(
611 r#"cat >/dev/null; names=$(env | cut -d= -f1 | grep -v -x -e PWD -e SHLVL -e _ | sort | tr '\n' ' '); printf '{"names":"%s"}\n' "$names""#,
612 5_000,
613 );
614 let out = runner.call(json!({})).await.unwrap();
615 let names: Vec<&str> = out["names"].as_str().unwrap().split_whitespace().collect();
616 assert!(names.contains(&"PATH"), "{names:?}");
617 for n in &names {
618 assert!(["PATH", "LANG", "TZ"].contains(n), "unexpected env var {n}");
619 }
620 }
621
622 #[tokio::test]
623 async fn command_runner_explicit_env_is_added() {
624 let runner = sh(
625 r#"cat >/dev/null; printf '{"host":"%s"}\n' "$DOCKER_HOST""#,
626 5_000,
627 )
628 .with_env("DOCKER_HOST", "unix:///run/user/1000/docker.sock");
629 let out = runner.call(json!({})).await.unwrap();
630 assert_eq!(out["host"], "unix:///run/user/1000/docker.sock");
631 }
632
633 #[tokio::test]
634 async fn command_runner_recovers_from_stdout_noise() {
635 let out = sh(
636 r#"cat >/dev/null; echo 'Fixed 3 BLOB seq_ids'; echo '{"success":true}'"#,
637 5_000,
638 )
639 .call(json!({"op": "x"}))
640 .await
641 .unwrap();
642 assert_eq!(out, json!({"success": true}));
643 }
644
645 #[tokio::test]
646 async fn command_runner_failure_never_leaks_stderr() {
647 let err = sh(
648 "cat >/dev/null; echo 'secret-ish detail' >&2; exit 3",
649 5_000,
650 )
651 .call(json!({}))
652 .await
653 .unwrap_err();
654 assert!(matches!(err, PalaceError::Exit(ref c) if c == "3"));
655 assert!(!err.to_string().contains("secret-ish"));
656 }
657
658 #[tokio::test]
659 async fn command_runner_times_out_and_kills_the_child() {
660 let started = std::time::Instant::now();
661 let err = sh("exec sleep 30", 300).call(json!({})).await.unwrap_err();
662 assert!(matches!(err, PalaceError::Timeout(_)));
663 assert!(started.elapsed() < Duration::from_secs(10));
664 }
665
666 #[tokio::test]
667 async fn command_runner_caps_output_at_one_mib_by_default() {
668 let err = sh(
670 r#"cat >/dev/null; i=0; while [ $i -lt 40000 ]; do echo '{"padding":"xxxxxxxxxxxxxxxxx"}'; i=$((i+1)); done"#,
671 30_000,
672 )
673 .call(json!({}))
674 .await
675 .unwrap_err();
676 assert!(matches!(
677 err,
678 PalaceError::OutputTooLarge(DEFAULT_MAX_OUTPUT_BYTES)
679 ));
680 let err = sh(
681 r#"cat >/dev/null; echo '{"padding":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}'"#,
682 5_000,
683 )
684 .with_max_output(16)
685 .call(json!({}))
686 .await
687 .unwrap_err();
688 assert!(matches!(err, PalaceError::OutputTooLarge(16)));
689 }
690
691 #[tokio::test]
692 async fn command_runner_bounds_concurrency() {
693 let runner =
696 std::sync::Arc::new(sh("cat >/dev/null; exec sleep 5", 500).with_max_concurrency(1));
697 let spawn = |r: std::sync::Arc<CommandPalaceRunner>| {
698 tokio::spawn(async move { r.call(json!({})).await })
699 };
700 let a = spawn(runner.clone());
701 tokio::time::sleep(Duration::from_millis(50)).await;
702 let b = spawn(runner.clone());
703 tokio::time::sleep(Duration::from_millis(50)).await;
704 let c = runner.call(json!({})).await.unwrap_err();
705 assert!(matches!(c, PalaceError::Busy), "{c:?}");
706 assert!(matches!(a.await.unwrap(), Err(PalaceError::Timeout(_))));
707 assert!(matches!(b.await.unwrap(), Err(PalaceError::Timeout(_))));
708 }
709
710 #[tokio::test]
711 async fn command_runner_missing_program_is_a_spawn_error() {
712 let err = CommandPalaceRunner::new(
713 PathBuf::from("/nonexistent/docker"),
714 vec![],
715 Duration::from_secs(1),
716 )
717 .call(json!({}))
718 .await
719 .unwrap_err();
720 assert!(matches!(err, PalaceError::Spawn(_)));
721 }
722
723 #[test]
724 fn command_runner_debug_never_prints_env_values() {
725 let r = sh("true", 1_000).with_env("DOCKER_HOST", "tcp://secret-host:2376");
726 let dbg = format!("{r:?}");
727 assert!(dbg.contains("DOCKER_HOST"), "{dbg}");
728 assert!(!dbg.contains("secret-host"), "{dbg}");
729 }
730
731 #[tokio::test]
734 async fn round_trips_json_over_stdio() {
735 let dir = tempfile::tempdir().unwrap();
736 let path = script(&dir, r#"read -r line; printf '{"echo":%s}\n' "$line""#);
738 let out = runner(path, 5_000)
739 .call(forget_request("drawer-1"))
740 .await
741 .unwrap();
742 assert_eq!(out["echo"]["drawer_id"], "drawer-1");
743 }
744
745 #[tokio::test]
746 async fn recovers_from_stdout_noise() {
747 let dir = tempfile::tempdir().unwrap();
748 let path = script(
749 &dir,
750 r#"cat >/dev/null; echo 'Fixed 3 BLOB seq_ids'; echo '{"success":true}'"#,
751 );
752 let out = runner(path, 5_000).call(json!({"op": "x"})).await.unwrap();
753 assert_eq!(out, json!({"success": true}));
754 }
755
756 #[tokio::test]
757 async fn non_zero_exit_is_an_error_without_leaking_stderr() {
758 let dir = tempfile::tempdir().unwrap();
759 let path = script(&dir, "cat >/dev/null; echo 'secret-ish detail' >&2; exit 3");
760 let err = runner(path, 5_000).call(json!({})).await.unwrap_err();
761 assert!(matches!(err, PalaceError::Exit(ref c) if c == "3"));
762 assert!(!err.to_string().contains("secret-ish"));
763 }
764
765 #[tokio::test]
766 async fn non_json_output_is_an_error() {
767 let dir = tempfile::tempdir().unwrap();
768 let path = script(&dir, "cat >/dev/null; echo hello");
769 let err = runner(path, 5_000).call(json!({})).await.unwrap_err();
770 assert!(matches!(err, PalaceError::NonJson));
771 }
772
773 #[tokio::test]
774 async fn times_out_and_kills_the_child() {
775 let dir = tempfile::tempdir().unwrap();
776 let path = script(&dir, "exec sleep 30");
777 let started = std::time::Instant::now();
778 let err = runner(path, 300).call(json!({})).await.unwrap_err();
779 assert!(matches!(err, PalaceError::Timeout(_)));
780 assert!(started.elapsed() < Duration::from_secs(10));
781 }
782
783 #[tokio::test]
784 async fn oversized_output_is_rejected() {
785 let dir = tempfile::tempdir().unwrap();
786 let path = script(
787 &dir,
788 r#"cat >/dev/null; i=0; while [ $i -lt 2000 ]; do echo '{"padding":"xxxxxxxxxxxxxxxxxxxxxxxx"}'; i=$((i+1)); done"#,
789 );
790 let err = runner(path, 10_000).call(json!({})).await.unwrap_err();
791 assert!(matches!(err, PalaceError::OutputTooLarge(4096)));
792 }
793
794 #[tokio::test]
795 async fn missing_script_is_a_spawn_error() {
796 let err = runner(PathBuf::from("/nonexistent/palace-call.sh"), 1_000)
797 .call(json!({}))
798 .await
799 .unwrap_err();
800 assert!(matches!(err, PalaceError::Spawn(_)));
801 }
802
803 #[tokio::test]
804 async fn child_env_is_scrubbed() {
805 let dir = tempfile::tempdir().unwrap();
806 let path = script(
807 &dir,
808 r#"cat >/dev/null; if [ -n "$HTTP_API_KEY$TALON_VLLM_API_KEY" ]; then echo '{"leak":true}'; else echo '{"leak":false}'; fi"#,
809 );
810 let out = runner(path, 5_000).call(json!({})).await.unwrap();
813 assert_eq!(out, json!({"leak": false}));
814 assert_eq!(
815 runner(PathBuf::from("/x"), 1).mode(),
816 "host-script (UNSAFE)"
817 );
818 }
819}