1use 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
39pub const PALACE_NAME_PREFIX: &str = "talon-palace";
41const 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#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct PalaceContainerConfig {
56 pub runtime_bin: PathBuf,
58 pub image: String,
60 pub project_root: PathBuf,
62 pub network: Option<String>,
64 pub timeout: Duration,
66 pub max_output_bytes: usize,
68 pub max_concurrency: usize,
70}
71
72pub struct ContainerPalaceRunner {
75 config: PalaceContainerConfig,
76 permits: Arc<Semaphore>,
77 seq: AtomicU64,
78 env: Vec<(OsString, OsString)>,
79 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 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 pub fn with_cleanup(mut self, runtime: Arc<ContainerRuntime>) -> Self {
123 self.cleanup = Some(runtime);
124 self
125 }
126
127 pub fn config(&self) -> &PalaceContainerConfig {
129 &self.config
130 }
131
132 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 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 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
165struct 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 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 Ok(_) | Err(PalaceError::Spawn(_)) => guard.disarm(),
217 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
233pub 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
291pub 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 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 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 assert_eq!(runner.mode(), "container");
439
440 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}