Skip to main content

talon/
palace.rs

1//! Containerized MemPalace runner for the CoPilot `/palace/*` endpoints
2//! (MYTH-01).
3//!
4//! Palace data (`mempalace-data/`, ChromaDB with pickled index metadata) has
5//! been writable by agent containers, so palace code must never run on the
6//! host against it. Every `/palace/*` call therefore starts a throw-away,
7//! hardened container (`talon_container::build_palace_run_args`: no
8//! capabilities, read-only root, `mcp-palace` uid, tight limits, palace
9//! code mounted read-only) through `talon_http::CommandPalaceRunner`.
10//!
11//! `CommandPalaceRunner` takes a fixed argument vector, but every container
12//! needs its own `--name` (concurrent calls would collide, and the name is
13//! what lets the host kill a container whose client timed out). So
14//! [`ContainerPalaceRunner`] builds fresh arguments with a unique name per
15//! call and runs them through a single-use `CommandPalaceRunner`, keeping
16//! the concurrency limit, output cap and timeout itself. When a call does
17//! not succeed (timeout, error exit, oversized or non-JSON output, request
18//! dropped), the container is killed by name, best effort and
19//! asynchronously (`--rm` then removes it), because killing the
20//! `docker run` client does not stop the container.
21//!
22//! The host-executed `ScriptPalaceRunner` is never used.
23
24use std::ffi::OsString;
25use std::path::{Path, PathBuf};
26use std::sync::Arc;
27use std::sync::atomic::{AtomicU64, Ordering};
28use std::time::Duration;
29
30use async_trait::async_trait;
31use serde_json::Value;
32use talon_container::{ContainerRuntime, PalaceRunSpec, build_palace_run_args};
33use talon_core::config::TalonConfig;
34use talon_http::{
35    CommandPalaceRunner, DisabledPalaceRunner, HttpConfig, PalaceError, PalaceRunner,
36};
37use tokio::sync::Semaphore;
38
39/// Name prefix of palace one-shot containers.
40pub const PALACE_NAME_PREFIX: &str = "talon-palace";
41/// Process environment passed through to the runtime client (socket
42/// location, rootless engines, CLI contexts). None of these are secrets.
43const RUNTIME_ENV_PASSTHROUGH: &[&str] = &[
44    "HOME",
45    "XDG_RUNTIME_DIR",
46    "DOCKER_HOST",
47    "DOCKER_CONTEXT",
48    "DOCKER_CONFIG",
49    "CONTAINER_HOST",
50    "CONTAINER_CONNECTION",
51];
52
53/// Settings of a [`ContainerPalaceRunner`].
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct PalaceContainerConfig {
56    /// Absolute path of the container runtime binary (`docker` / `podman`).
57    pub runtime_bin: PathBuf,
58    /// Agent image (holds the palace venv).
59    pub image: String,
60    /// Absolute Talahon checkout holding `mempalace/` and `mempalace-data/`.
61    pub project_root: PathBuf,
62    /// `--network` of the palace containers (`None` = runtime default).
63    pub network: Option<String>,
64    /// Per-call timeout (container start included).
65    pub timeout: Duration,
66    /// Stdout cap per call (bytes).
67    pub max_output_bytes: usize,
68    /// Concurrent calls; excess callers wait up to `timeout`.
69    pub max_concurrency: usize,
70}
71
72/// A palace runner that runs every call in its own hardened container (see
73/// the module docs).
74pub struct ContainerPalaceRunner {
75    config: PalaceContainerConfig,
76    permits: Arc<Semaphore>,
77    seq: AtomicU64,
78    env: Vec<(OsString, OsString)>,
79    /// Used to kill containers whose client did not finish on its own.
80    cleanup: Option<Arc<ContainerRuntime>>,
81}
82
83impl std::fmt::Debug for ContainerPalaceRunner {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        f.debug_struct("ContainerPalaceRunner")
86            .field("config", &self.config)
87            .field("cleanup", &self.cleanup.is_some())
88            .finish_non_exhaustive()
89    }
90}
91
92impl ContainerPalaceRunner {
93    /// Build the runner. Validates the configuration by building the run
94    /// arguments once (image, network, `mempalace/palace-call.sh`,
95    /// `mempalace-data/`).
96    ///
97    /// # Errors
98    /// A relative runtime path or project root, or arguments that
99    /// `build_palace_run_args` refuses (e.g. MemPalace not installed).
100    pub fn new(config: PalaceContainerConfig) -> talon_core::Result<Self> {
101        if !config.runtime_bin.is_absolute() {
102            return Err(talon_core::Error::Config(format!(
103                "container runtime path must be absolute: {}",
104                config.runtime_bin.display()
105            )));
106        }
107        let runner = Self {
108            permits: Arc::new(Semaphore::new(config.max_concurrency.max(1))),
109            seq: AtomicU64::new(0),
110            env: RUNTIME_ENV_PASSTHROUGH
111                .iter()
112                .filter_map(|k| std::env::var_os(k).map(|v| (OsString::from(k), v)))
113                .collect(),
114            cleanup: None,
115            config,
116        };
117        runner.args_for(&runner.next_name())?;
118        Ok(runner)
119    }
120
121    /// Kill containers through `runtime` when a call is abandoned.
122    pub fn with_cleanup(mut self, runtime: Arc<ContainerRuntime>) -> Self {
123        self.cleanup = Some(runtime);
124        self
125    }
126
127    /// The configuration.
128    pub fn config(&self) -> &PalaceContainerConfig {
129        &self.config
130    }
131
132    /// A fresh container name: `talon-palace-<millis>-<seq>`.
133    pub fn next_name(&self) -> String {
134        let millis = chrono::Utc::now().timestamp_millis();
135        let seq = self.seq.fetch_add(1, Ordering::Relaxed);
136        format!("{PALACE_NAME_PREFIX}-{millis}-{seq}")
137    }
138
139    /// The `run` arguments for a container called `name`.
140    ///
141    /// # Errors
142    /// See [`build_palace_run_args`].
143    pub fn args_for(&self, name: &str) -> talon_core::Result<Vec<String>> {
144        build_palace_run_args(&PalaceRunSpec {
145            image: self.config.image.clone(),
146            project_root: self.config.project_root.clone(),
147            name: name.to_string(),
148            network: self.config.network.clone(),
149        })
150    }
151
152    /// The single-use client runner for one call.
153    fn command(&self, args: Vec<String>) -> CommandPalaceRunner {
154        let mut runner =
155            CommandPalaceRunner::new(self.config.runtime_bin.clone(), args, self.config.timeout)
156                .with_max_output(self.config.max_output_bytes)
157                .with_max_concurrency(1);
158        for (k, v) in &self.env {
159            runner = runner.with_env(k.clone(), v.clone());
160        }
161        runner
162    }
163}
164
165/// Kills the named container on drop unless disarmed (abandoned calls).
166struct KillOnDrop {
167    runtime: Option<Arc<ContainerRuntime>>,
168    name: String,
169}
170
171impl KillOnDrop {
172    fn disarm(&mut self) {
173        self.runtime = None;
174    }
175}
176
177impl Drop for KillOnDrop {
178    fn drop(&mut self) {
179        let Some(runtime) = self.runtime.take() else {
180            return;
181        };
182        let Ok(handle) = tokio::runtime::Handle::try_current() else {
183            return;
184        };
185        let name = std::mem::take(&mut self.name);
186        handle.spawn(async move {
187            // Fails harmlessly when the container already exited (--rm).
188            match runtime.kill(&name).await {
189                Ok(()) => tracing::info!(container = %name, "killed abandoned palace container"),
190                Err(e) => tracing::debug!(container = %name, error = %e, "palace container kill"),
191            }
192        });
193    }
194}
195
196#[async_trait]
197impl PalaceRunner for ContainerPalaceRunner {
198    async fn call(&self, request: Value) -> Result<Value, PalaceError> {
199        let permit =
200            tokio::time::timeout(self.config.timeout, self.permits.clone().acquire_owned())
201                .await
202                .map_err(|_| PalaceError::Busy)?
203                .map_err(|_| PalaceError::Busy)?;
204        let name = self.next_name();
205        let args = self.args_for(&name).map_err(|e| {
206            tracing::error!(error = %e, "cannot build palace container arguments");
207            PalaceError::Spawn("palace container configuration is invalid".into())
208        })?;
209        let mut guard = KillOnDrop {
210            runtime: self.cleanup.clone(),
211            name: name.clone(),
212        };
213        let result = self.command(args).call(request).await;
214        match &result {
215            // Success, or the client never started: nothing left running.
216            Ok(_) | Err(PalaceError::Spawn(_)) => guard.disarm(),
217            // Any other failure may leave the container behind (a killed
218            // or failed client does not stop it): kill it, best effort.
219            Err(e) => {
220                tracing::warn!(container = %name, error = %e, "palace call failed; killing its container")
221            }
222        }
223        drop(guard);
224        drop(permit);
225        result
226    }
227
228    fn mode(&self) -> &'static str {
229        "container"
230    }
231}
232
233/// The palace runner `talahon run` hands to the CoPilot HTTP channel: a
234/// [`ContainerPalaceRunner`] on `network` (the agent network) using the
235/// agent image, the HTTP config's timeout / output cap / concurrency, and
236/// `runtime` to kill abandoned containers. When it cannot be built (e.g.
237/// MemPalace is not installed) the palace is disabled (`503`) — the
238/// host-executed script runner is never used, whatever
239/// `TALAHON_PALACE_HOST_EXEC` says.
240pub fn host_palace_runner(
241    config: &TalonConfig,
242    http: &HttpConfig,
243    runtime: &Arc<ContainerRuntime>,
244    network: Option<String>,
245) -> Arc<dyn PalaceRunner> {
246    if http.palace_host_exec {
247        tracing::warn!(
248            "TALAHON_PALACE_HOST_EXEC=unsafe is ignored: MemPalace calls always run in a hardened \
249             container (host execution would unpickle agent-writable data on the host)"
250        );
251    }
252    let Some(runtime_bin) = absolute_runtime_bin(runtime.bin()) else {
253        tracing::warn!(
254            bin = runtime.bin(),
255            "container runtime path cannot be resolved; MemPalace endpoints disabled"
256        );
257        return Arc::new(DisabledPalaceRunner);
258    };
259    let project_root = match std::fs::canonicalize(&config.project_root) {
260        Ok(p) => p,
261        Err(e) => {
262            tracing::warn!(error = %e, "cannot resolve the project root; MemPalace endpoints disabled");
263            return Arc::new(DisabledPalaceRunner);
264        }
265    };
266    let built = ContainerPalaceRunner::new(PalaceContainerConfig {
267        runtime_bin,
268        image: config.container_image.clone(),
269        project_root,
270        network,
271        timeout: http.palace_timeout,
272        max_output_bytes: http.palace_max_output_bytes,
273        max_concurrency: http.palace_max_concurrency,
274    });
275    match built {
276        Ok(runner) => {
277            tracing::info!(
278                image = %runner.config().image,
279                network = runner.config().network.as_deref().unwrap_or("default"),
280                "MemPalace calls run in hardened one-shot containers"
281            );
282            Arc::new(runner.with_cleanup(runtime.clone()))
283        }
284        Err(e) => {
285            tracing::warn!(error = %e, "MemPalace endpoints disabled (palace container cannot be configured)");
286            Arc::new(DisabledPalaceRunner)
287        }
288    }
289}
290
291/// Absolute path of the runtime binary `bin` (as resolved by
292/// `ContainerRuntime::detect`, or looked up on `PATH`).
293pub fn absolute_runtime_bin(bin: &str) -> Option<PathBuf> {
294    let path = Path::new(bin);
295    if path.is_absolute() {
296        return Some(path.to_path_buf());
297    }
298    talon_container::runtime::resolve_executable(bin, std::env::var_os("PATH"))
299        .filter(|p| p.is_absolute())
300}
301
302#[cfg(test)]
303mod tests {
304    use std::os::unix::fs::PermissionsExt;
305
306    use serde_json::json;
307
308    use super::*;
309
310    struct Fx {
311        _tmp: tempfile::TempDir,
312        root: PathBuf,
313        runtime: PathBuf,
314    }
315
316    /// A project with MemPalace installed and a fake runtime binary that
317    /// echoes its arguments as JSON.
318    fn fixture(fake_runtime_body: &str) -> Fx {
319        let tmp = tempfile::tempdir().unwrap();
320        let root = std::fs::canonicalize(tmp.path()).unwrap();
321        std::fs::create_dir_all(root.join("mempalace-data")).unwrap();
322        std::fs::create_dir_all(root.join("mempalace")).unwrap();
323        std::fs::write(root.join("mempalace/palace-call.sh"), "#!/bin/sh\n").unwrap();
324        let runtime = root.join("fake-docker");
325        std::fs::write(&runtime, format!("#!/bin/sh\n{fake_runtime_body}\n")).unwrap();
326        let mut perm = std::fs::metadata(&runtime).unwrap().permissions();
327        perm.set_mode(0o755);
328        std::fs::set_permissions(&runtime, perm).unwrap();
329        Fx {
330            _tmp: tmp,
331            root,
332            runtime,
333        }
334    }
335
336    fn config(fx: &Fx) -> PalaceContainerConfig {
337        PalaceContainerConfig {
338            runtime_bin: fx.runtime.clone(),
339            image: "talahon-agent:latest".into(),
340            project_root: fx.root.clone(),
341            network: Some("talon-agents".into()),
342            timeout: Duration::from_secs(10),
343            max_output_bytes: 64 * 1024,
344            max_concurrency: 2,
345        }
346    }
347
348    const ECHO_ARGS: &str = r#"read -r line; printf '{"argv":"%s","request":%s}\n' "$*" "$line""#;
349
350    #[test]
351    fn construction_validates_and_builds_hardened_args() {
352        let fx = fixture(ECHO_ARGS);
353        let runner = ContainerPalaceRunner::new(config(&fx)).unwrap();
354        assert_eq!(runner.mode(), "container");
355        let name = runner.next_name();
356        assert!(name.starts_with("talon-palace-"));
357        let args = runner.args_for(&name).unwrap();
358        let joined = args.join(" ");
359        assert_eq!(args[0], "run");
360        assert!(joined.contains(&format!("--name {name}")));
361        assert!(joined.contains("--network talon-agents"));
362        assert!(joined.contains("--cap-drop=ALL"));
363        assert!(joined.contains("--read-only"));
364        assert!(joined.contains("--user 61008:61008"));
365        assert_eq!(
366            args.last().map(String::as_str),
367            Some("talahon-agent:latest")
368        );
369        // Names are unique per call.
370        assert_ne!(runner.next_name(), runner.next_name());
371    }
372
373    #[test]
374    fn construction_refuses_bad_configuration() {
375        let fx = fixture(ECHO_ARGS);
376        let mut relative = config(&fx);
377        relative.runtime_bin = PathBuf::from("docker");
378        assert!(ContainerPalaceRunner::new(relative).is_err());
379
380        let mut bad_image = config(&fx);
381        bad_image.image = "--privileged".into();
382        assert!(ContainerPalaceRunner::new(bad_image).is_err());
383
384        let mut bad_net = config(&fx);
385        bad_net.network = Some("host".into());
386        assert!(ContainerPalaceRunner::new(bad_net).is_err());
387
388        std::fs::remove_file(fx.root.join("mempalace/palace-call.sh")).unwrap();
389        assert!(ContainerPalaceRunner::new(config(&fx)).is_err());
390    }
391
392    #[tokio::test]
393    async fn every_call_runs_the_runtime_with_a_fresh_container_name() {
394        let fx = fixture(ECHO_ARGS);
395        let runner = ContainerPalaceRunner::new(config(&fx)).unwrap();
396        let first = runner.call(json!({"op": "status"})).await.unwrap();
397        let second = runner.call(json!({"op": "status"})).await.unwrap();
398        assert_eq!(first["request"]["op"], "status");
399        let argv1 = first["argv"].as_str().unwrap();
400        let argv2 = second["argv"].as_str().unwrap();
401        assert!(argv1.starts_with("run "), "{argv1}");
402        assert!(argv1.contains("--name talon-palace-"));
403        assert!(argv1.contains("--entrypoint /workspace/extra/mempalace/palace-call.sh"));
404        assert_ne!(argv1, argv2, "container names must differ per call");
405    }
406
407    #[tokio::test]
408    async fn runtime_failures_and_timeouts_are_reported() {
409        let fx = fixture("cat >/dev/null; exit 3");
410        let runner = ContainerPalaceRunner::new(config(&fx)).unwrap();
411        assert!(matches!(
412            runner.call(json!({"op": "status"})).await,
413            Err(PalaceError::Exit(_))
414        ));
415
416        let fx = fixture("exec sleep 5");
417        let mut cfg = config(&fx);
418        cfg.timeout = Duration::from_millis(200);
419        let runner = ContainerPalaceRunner::new(cfg).unwrap();
420        assert!(matches!(
421            runner.call(json!({"op": "status"})).await,
422            Err(PalaceError::Timeout(_))
423        ));
424    }
425
426    #[test]
427    fn host_wiring_uses_the_container_runner_or_disables_the_palace() {
428        let fx = fixture(ECHO_ARGS);
429        let config = TalonConfig::from_env(
430            &fx.root,
431            talon_core::env::EnvFile::from_str_content("TALAHON_PALACE_HOST_EXEC=unsafe\n"),
432        );
433        let mut http = HttpConfig::new(&fx.root);
434        http.palace_host_exec = true;
435        let runtime = Arc::new(ContainerRuntime::new(fx.runtime.to_string_lossy()).unwrap());
436        let runner = host_palace_runner(&config, &http, &runtime, Some("talon-agents".into()));
437        // Never the host-script runner, even with the unsafe opt-in.
438        assert_eq!(runner.mode(), "container");
439
440        // MemPalace not installed: disabled, not host execution.
441        std::fs::remove_dir_all(fx.root.join("mempalace")).unwrap();
442        let runner = host_palace_runner(&config, &http, &runtime, None);
443        assert_eq!(runner.mode(), DisabledPalaceRunner.mode());
444    }
445
446    #[test]
447    fn runtime_bin_resolution() {
448        assert_eq!(
449            absolute_runtime_bin("/usr/bin/docker"),
450            Some(PathBuf::from("/usr/bin/docker"))
451        );
452        if let Some(sh) = absolute_runtime_bin("sh") {
453            assert!(sh.is_absolute());
454        }
455        assert_eq!(absolute_runtime_bin("definitely-not-a-runtime-xyz"), None);
456    }
457}