Skip to main content

talon_http/
palace.rs

1//! MemPalace bridge.
2//!
3//! CoPilot never talks to MemPalace directly; the HTTP channel runs one
4//! palace process per request, writes one JSON request to its stdin and
5//! reads one JSON response from stdout.
6//!
7//! ## Where palace code runs (MYTH-01)
8//! MemPalace keeps its data (ChromaDB, including pickled index metadata) in
9//! `mempalace-data/`, a directory agent containers mount. Running the Python
10//! palace code *on the host* against that directory would unpickle data an
11//! agent may have written: host code execution as the Talahon user. So:
12//!
13//! - the default runner is [`DisabledPalaceRunner`]: every `/palace/*` call
14//!   answers `503 {"error":"palace not configured"}`;
15//! - production wiring supplies a [`CommandPalaceRunner`], a generic
16//!   "spawn this program" runner the host builds as `docker run <hardened
17//!   palace args>`, so palace code only ever runs inside a locked-down
18//!   container as `mcp-palace`;
19//! - [`ScriptPalaceRunner`] (host execution of `mempalace/palace-call.sh`)
20//!   is only selected when the operator sets
21//!   `TALAHON_PALACE_HOST_EXEC=unsafe` ([`HOST_EXEC_ENV`]). **Dangerous**: any
22//!   agent that can write `mempalace-data/` can then run code on the host.
23//!
24//! Every process-backed runner keeps the same safeguards: an allowlisted
25//! environment (no inherited secrets), stdout capped (1 MiB by default) with
26//! recovery from non-JSON noise, stderr logged at debug level and never
27//! returned to callers, bounded concurrency (4 by default), and the child
28//! killed on timeout or when the request future is dropped.
29
30use 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
42/// Env var that opts into running `palace-call.sh` directly on the host.
43pub const HOST_EXEC_ENV: &str = "TALAHON_PALACE_HOST_EXEC";
44/// The only value of [`HOST_EXEC_ENV`] that enables host execution.
45pub const HOST_EXEC_UNSAFE: &str = "unsafe";
46/// Default cap on a palace call's stdout.
47pub const DEFAULT_MAX_OUTPUT_BYTES: usize = 1024 * 1024;
48/// Default number of concurrently running palace calls.
49pub const DEFAULT_MAX_CONCURRENCY: usize = 4;
50/// Max bytes of stderr kept for logging.
51const MAX_STDERR_BYTES: usize = 64 * 1024;
52/// Process env vars a [`CommandPalaceRunner`] child inherits.
53const COMMAND_ENV_ALLOWLIST: &[&str] = &["PATH", "LANG", "TZ"];
54/// `PATH` used when the host process has none.
55const DEFAULT_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
56/// Process env vars passed through to a host-executed `palace-call.sh`.
57const SCRIPT_ENV_ALLOWLIST: &[&str] = &[
58    "PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "TMPDIR", "USER", "LOGNAME",
59];
60/// Env var prefixes passed through to a host-executed `palace-call.sh`
61/// (palace path, model caches).
62const SCRIPT_ENV_PREFIX_ALLOWLIST: &[&str] = &[
63    "MEMPALACE_",
64    "HF_",
65    "TRANSFORMERS_",
66    "SENTENCE_TRANSFORMERS_",
67    "XDG_",
68];
69
70/// Why a palace call failed. Messages are safe to return to callers (no
71/// stderr/stdout contents).
72#[derive(Debug, thiserror::Error)]
73pub enum PalaceError {
74    /// No palace runner is configured (the default; see the module docs).
75    #[error("palace not configured")]
76    Disabled,
77    /// The palace process could not be started.
78    #[error("palace call could not be started: {0}")]
79    Spawn(String),
80    /// The call exceeded its timeout and was killed.
81    #[error("palace call timed out after {0:?}")]
82    Timeout(Duration),
83    /// Non-zero exit status.
84    #[error("palace call exited with status {0}")]
85    Exit(String),
86    /// Stdout held no parseable JSON object.
87    #[error("palace call returned non-JSON output")]
88    NonJson,
89    /// Stdout exceeded the size cap.
90    #[error("palace call output exceeded {0} bytes")]
91    OutputTooLarge(usize),
92    /// Too many palace calls in flight.
93    #[error("too many concurrent palace calls")]
94    Busy,
95}
96
97/// Executes one palace request.
98#[async_trait]
99pub trait PalaceRunner: Send + Sync {
100    /// Run `request` (`{"op": ...}`) and return the JSON response.
101    async fn call(&self, request: Value) -> Result<Value, PalaceError>;
102
103    /// Short label for startup logs (`disabled`, `command`, ...).
104    fn mode(&self) -> &'static str {
105        "custom"
106    }
107}
108
109/// `add_drawer` request for a teach-the-palace lesson.
110pub 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
126/// `search` request. `room: None` searches every room.
127pub 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
140/// `delete_drawer` request that forgets a lesson.
141pub fn forget_request(drawer_id: &str) -> Value {
142    json!({ "op": "delete_drawer", "drawer_id": drawer_id })
143}
144
145/// Parse the palace's stdout. The python wrapper always writes the response
146/// as the final line, but mempalace/chromadb internals occasionally print
147/// to stdout first: try the whole output, then scan from the bottom for the
148/// last line that is a JSON object.
149pub 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// ---------------------------------------------------------------------------
162// Disabled (default)
163// ---------------------------------------------------------------------------
164
165/// The default runner: palace calls fail with [`PalaceError::Disabled`]
166/// (`503 {"error":"palace not configured"}`). Nothing is executed.
167#[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
181// ---------------------------------------------------------------------------
182// Shared process plumbing
183// ---------------------------------------------------------------------------
184
185/// One child process per call: program, arguments, a fixed environment and
186/// the limits every process-backed runner enforces.
187struct 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        // Env values are never printed, only their names.
201        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
214/// Snapshot the allowlisted part of the process environment.
215fn 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
358/// Read up to `cap` bytes; keep draining (and discarding) past the cap so the
359/// child never blocks on a full pipe. Returns the bytes and whether the cap
360/// was exceeded.
361async 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// ---------------------------------------------------------------------------
381// Command runner (production: palace inside a hardened container)
382// ---------------------------------------------------------------------------
383
384/// Runs `program args...` per request: the JSON request goes to its stdin,
385/// the JSON response is read from its stdout.
386///
387/// Intended to be built by the host as `docker <hardened palace run args>`,
388/// so palace code runs only inside a locked-down container as `mcp-palace`.
389/// The child gets a cleared environment holding only `PATH` (a standard
390/// default when the host has none), `LANG` and `TZ`, plus whatever the
391/// caller adds explicitly with [`Self::with_env`].
392///
393/// Defaults: stdout capped at [`DEFAULT_MAX_OUTPUT_BYTES`], at most
394/// [`DEFAULT_MAX_CONCURRENCY`] calls in flight (excess callers wait up to
395/// `timeout`, then get [`PalaceError::Busy`]).
396///
397/// Killing the client process on timeout does not necessarily stop a
398/// container it started; the container side must bound its own runtime.
399#[derive(Debug)]
400pub struct CommandPalaceRunner {
401    spec: ProcessSpec,
402}
403
404impl CommandPalaceRunner {
405    /// Runner for `program args...` with a per-call `timeout`.
406    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    /// Add (or replace) one environment variable of the child, e.g.
417    /// `DOCKER_HOST` for a rootless daemon. Never pass secrets this way.
418    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    /// Override the stdout cap (bytes, at least 1).
424    pub fn with_max_output(mut self, bytes: usize) -> Self {
425        self.spec.set_max_output(bytes);
426        self
427    }
428
429    /// Override the concurrency limit (at least 1).
430    pub fn with_max_concurrency(mut self, n: usize) -> Self {
431        self.spec.set_max_concurrency(n);
432        self
433    }
434
435    /// The program spawned per call.
436    pub fn program(&self) -> &Path {
437        &self.spec.program
438    }
439
440    /// The arguments passed to [`Self::program`].
441    pub fn args(&self) -> &[String] {
442        &self.spec.args
443    }
444
445    /// The per-call timeout.
446    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// ---------------------------------------------------------------------------
463// Host script runner (explicit, dangerous opt-in)
464// ---------------------------------------------------------------------------
465
466/// Spawns `mempalace/palace-call.sh` **on the host** per request.
467///
468/// **Dangerous** (MYTH-01): the script loads ChromaDB data from
469/// `mempalace-data/`, which agent containers can write, so a compromised
470/// agent can get code executed as the host user. Only selected when the
471/// operator sets `TALAHON_PALACE_HOST_EXEC=unsafe`; prefer
472/// [`CommandPalaceRunner`].
473#[derive(Debug)]
474pub struct ScriptPalaceRunner {
475    spec: ProcessSpec,
476}
477
478impl ScriptPalaceRunner {
479    /// New runner for `script` with a per-call `timeout`, a stdout cap and a
480    /// concurrency limit.
481    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    /// A `CommandPalaceRunner` running `/bin/sh -c <body> palace <extra...>`
527    /// (a stand-in for `docker run ...`).
528    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    // ---- DisabledPalaceRunner ------------------------------------------------
571
572    #[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    // ---- CommandPalaceRunner -------------------------------------------------
584
585    #[tokio::test]
586    async fn command_runner_passes_args_and_round_trips_json() {
587        let dir = tempfile::tempdir().unwrap();
588        // Fake `docker`: echo the argv and the request back.
589        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        // cargo sets CARGO_MANIFEST_DIR for test binaries; it must not leak.
609        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        // ~1.2 MiB of JSON lines.
669        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        // One permit: A holds it until its timeout, B (FIFO) inherits it and
694        // holds it for another timeout, so C's wait (one timeout) expires.
695        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    // ---- ScriptPalaceRunner (unsafe opt-in) ----------------------------------
732
733    #[tokio::test]
734    async fn round_trips_json_over_stdio() {
735        let dir = tempfile::tempdir().unwrap();
736        // Echo the request back inside a response object.
737        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$TALAHON_VLLM_API_KEY" ]; then echo '{"leak":true}'; else echo '{"leak":false}'; fi"#,
809        );
810        // The test process may or may not have these set; the child must
811        // never see them either way.
812        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}