1use std::collections::BTreeMap;
28use std::fs;
29use std::path::{Path, PathBuf};
30use std::sync::Arc;
31use std::time::{Duration, Instant, SystemTime};
32
33use crate::cli::env_arg::CliEnvEntries;
34use crate::cli::volume_arg::CliVolume;
35use crate::error::{OutrigError, Result};
36use crate::llm;
37use crate::paths::{default_session_root, repo_root_from_config_path};
38use crate::session::{self, Session, SessionId, SessionStore};
39use outrig::config::{
40 Config, ImageConfig, McpServerSpec, MistralrsDeviceSpec, MountConfig, NetworkMode,
41};
42use outrig::container::{
43 Container, ContainerCapabilities, ContainerLaunchSpec, ContainerMount, ContainerWorkspace,
44 embedded,
45};
46use outrig::image::{self, ImageTag};
47use outrig::network::NetworkInterceptor;
48use outrig::{McpClient, Transcript};
49
50pub(crate) const STOP_GRACE: Duration = Duration::from_secs(2);
51
52pub(crate) struct ProgressSpan {
53 started: Instant,
54}
55
56impl ProgressSpan {
57 pub(crate) fn start(label: impl Into<String>) -> Self {
58 let label = label.into();
59 eprintln!("[outrig] {label}");
60 Self {
61 started: Instant::now(),
62 }
63 }
64
65 pub(crate) fn done(self, message: impl AsRef<str>) {
66 eprintln!(
67 "[outrig] {} ({})",
68 message.as_ref(),
69 format_elapsed(self.started.elapsed())
70 );
71 }
72}
73
74pub(crate) fn plural<'a>(count: usize, singular: &'a str, plural: &'a str) -> &'a str {
75 if count == 1 { singular } else { plural }
76}
77
78fn format_elapsed(duration: Duration) -> String {
79 let millis = duration.as_millis();
80 if millis < 1_000 {
81 return format!("{millis}ms");
82 }
83 let secs = duration.as_secs();
84 if secs < 60 {
85 return format!("{:.1}s", duration.as_secs_f64());
86 }
87 format!("{}m{:02}s", secs / 60, secs % 60)
88}
89
90fn resolve_workspace_host(repo_root: &Path, path: &Path) -> PathBuf {
91 if path.is_absolute() {
92 path.to_path_buf()
93 } else {
94 repo_root.join(path)
95 }
96}
97
98pub struct SessionSetupArgs<'a> {
101 pub repo_cfg_path: &'a Path,
102 pub global_cfg_path: &'a Path,
103 pub session_root_flag: Option<&'a Path>,
104 pub image_flag: Option<&'a str>,
105 pub attach_target: Option<&'a str>,
108 pub agent_flag: Option<&'a str>,
111 pub model_override: Option<&'a str>,
114 pub require_agent: bool,
122 pub explicit_session_dir: Option<&'a Path>,
123 pub network_mode_override: Option<NetworkMode>,
124 pub device_override: Option<MistralrsDeviceSpec>,
125 pub volumes: &'a [CliVolume],
128 pub verbose: u8,
129}
130
131pub struct SessionSetup {
135 pub cfg: Config,
136 pub image_cfg_name: String,
137 pub image_cfg: ImageConfig,
138 pub image_tag: ImageTag,
139 pub container: Container,
140 pub sid: SessionId,
141 pub session: Session,
142 pub session_dir: PathBuf,
143 pub log_dir: PathBuf,
144 pub store: SessionStore,
145 pub attached: bool,
146 pub network: Option<NetworkInterceptor>,
147}
148
149#[derive(Debug)]
150struct AttachResolution {
151 container_name: String,
152 image_cfg_name: String,
153}
154
155pub async fn setup(args: SessionSetupArgs<'_>) -> Result<SessionSetup> {
158 let repo_root = repo_root_from_config_path(args.repo_cfg_path);
159 let span = ProgressSpan::start("loading config");
160 let mut cfg = if args.require_agent {
161 Config::load_for_run(
162 &repo_root,
163 Some(args.global_cfg_path),
164 args.agent_flag,
165 args.model_override,
166 )?
167 } else {
168 Config::load(&repo_root, Some(args.global_cfg_path))?
169 };
170 span.done("config loaded");
171 if !args.repo_cfg_path.exists() {
172 eprintln!(
173 "[outrig] no repo config found; using current directory as workspace ({})",
174 repo_root.display()
175 );
176 }
177
178 let session_root =
179 session::resolve_session_root(args.session_root_flag, &cfg, &default_session_root());
180 let store = SessionStore::new(session_root);
181 let attach = match args.attach_target {
182 Some(target) if args.require_agent => {
183 return Err(OutrigError::Configuration(format!(
184 "--attach {target:?} is only supported by `outrig mcp`"
185 ))
186 .into());
187 }
188 Some(target) => Some(resolve_attach_target(target, args.image_flag, &store)?),
189 None => None,
190 };
191 let network_mode = args.network_mode_override.unwrap_or(cfg.network.mode);
192 if attach.is_some() && matches!(network_mode, NetworkMode::Audit | NetworkMode::Filter) {
193 return Err(OutrigError::Configuration(
194 "`--network audit` and `--network filter` cannot be used with \
195 `outrig mcp --attach`; start a fresh session to install network monitoring"
196 .to_string(),
197 )
198 .into());
199 }
200 if network_mode == NetworkMode::Filter && !cfg.network.has_policy_entries() {
201 return Err(OutrigError::Configuration(
202 "network filter mode requires at least one global [network] allow or deny entry"
203 .to_string(),
204 )
205 .into());
206 }
207
208 if !args.volumes.is_empty() {
212 if attach.is_some() {
213 return Err(OutrigError::Configuration(
214 "--volume cannot be combined with --attach; a borrowed container's \
215 mounts are fixed when it is created"
216 .to_string(),
217 )
218 .into());
219 }
220 for vol in args.volumes {
221 cfg.workspace.mounts.push(MountConfig {
222 host_path: vol.host.clone(),
223 container_path: vol.container.clone(),
224 access: vol.access,
225 });
226 }
227 cfg.validate_workspace_mounts(Some(&repo_root))?;
228 }
229
230 let span = ProgressSpan::start("resolving agent and container");
236 let (session_agent_name, agent_image) = if attach.is_some() {
237 (None, None)
238 } else if args.require_agent {
239 let agent_name = args
240 .agent_flag
241 .or(cfg.default_agent.as_deref())
242 .ok_or_else(|| {
243 OutrigError::Configuration("no --agent and no default-agent configured".to_string())
244 })?;
245 let resolved = llm::resolve_agent_with_overrides(
246 &cfg,
247 agent_name,
248 args.model_override,
249 args.device_override,
250 )?;
251 (Some(resolved.agent_name.clone()), resolved.image.clone())
252 } else {
253 (None, None)
254 };
255
256 let (image_cfg_name, allow_raw_image) = match &attach {
257 Some(attach) => (attach.image_cfg_name.clone(), true),
258 None => match args.image_flag {
259 Some(image) => (image.to_string(), true),
260 None => {
261 let image = agent_image
262 .as_deref()
263 .or(cfg.default_image.as_deref())
264 .ok_or_else(|| {
265 let msg = if args.require_agent {
266 "no --image, agent.image, or default-image configured"
267 } else {
268 "no --image or default-image configured"
269 };
270 OutrigError::Configuration(msg.to_string())
271 })?;
272 (image.to_string(), false)
273 }
274 },
275 };
276 let (image_cfg, raw_local_image) =
277 resolve_image_config(&cfg, &image_cfg_name, allow_raw_image)?;
278 if let Some(attach) = &attach {
279 span.done(format!(
280 "attach target resolved: container {}, image-config {}",
281 attach.container_name, image_cfg_name
282 ));
283 } else if let Some(agent) = &session_agent_name {
284 span.done(format!(
285 "agent/container resolved: agent {agent}, container {image_cfg_name}"
286 ));
287 } else {
288 span.done(format!("container resolved: {image_cfg_name}"));
289 }
290
291 let image_tag = if let Some(attach) = &attach {
292 let span = ProgressSpan::start(format!(
293 "inspecting attached container {}",
294 attach.container_name
295 ));
296 let inspect = Container::inspect_existing(&attach.container_name, None).await?;
297 if !inspect.running {
298 return Err(OutrigError::Configuration(format!(
299 "attached container {:?} is not running",
300 attach.container_name
301 ))
302 .into());
303 }
304 span.done(format!(
305 "attached container ready: {}",
306 attach.container_name
307 ));
308 inspect.image_tag
309 } else {
310 let span = ProgressSpan::start("computing image tag");
311 let image_tag = if raw_local_image {
312 ImageTag(image_cfg_name.clone())
313 } else {
314 image::compute_tag_for(&image_cfg_name, &image_cfg, &repo_root).await?
315 };
316 span.done(format!("image tag computed: {image_tag}"));
317 image_tag
318 };
319
320 let host_workspace = resolve_workspace_host(&repo_root, &cfg.workspace.host_path);
321 let container_workspace = cfg.workspace.container_path.clone();
322 let launch = ContainerLaunchSpec {
323 workspace: Some(ContainerWorkspace {
324 host: host_workspace.clone(),
325 container: container_workspace.clone(),
326 }),
327 mounts: cfg
328 .workspace
329 .mounts
330 .iter()
331 .map(|mount| ContainerMount {
332 host: resolve_workspace_host(&repo_root, &mount.host_path),
333 container: mount.container_path.clone(),
334 access: mount.access,
335 })
336 .collect(),
337 capabilities: ContainerCapabilities {
338 profile: image_cfg.security.capability_profile,
339 cap_drop: image_cfg.security.cap_drop.clone(),
340 cap_add: image_cfg.security.cap_add.clone(),
341 },
342 };
343
344 if let Some(p) = args.explicit_session_dir
345 && !p.is_dir()
346 {
347 return Err(OutrigError::Configuration(format!(
348 "--session-dir {} is not an existing directory (create it first or omit the flag)",
349 p.display()
350 ))
351 .into());
352 }
353
354 let sid = SessionId::new();
355 let container_name = attach
356 .as_ref()
357 .map(|attach| attach.container_name.clone())
358 .unwrap_or_else(|| format!("outrig-{sid}"));
359 let mut session = Session {
360 id: sid.clone(),
361 started_at: SystemTime::now(),
362 ended_at: None,
363 container_name: container_name.clone(),
364 image_tag: image_tag.to_string(),
365 image_config_name: image_cfg_name.clone(),
366 agent_name: session_agent_name,
367 working_dir: repo_root.clone(),
368 session_dir: PathBuf::new(), exit_code: None,
370 link_target: None,
371 };
372 let session_dir = store.create(&sid, args.explicit_session_dir, &mut session)?;
373 let log_dir = session_dir.join("logs");
374 if let Err(e) = tokio::fs::create_dir_all(&log_dir).await {
375 let _ = store.finalize(&sid, SystemTime::now(), 1);
376 return Err(e.into());
377 }
378
379 let transcript = if args.verbose > 0 {
380 match Transcript::create(&log_dir.join("container.log"), true).await {
381 Ok(t) => Some(t),
382 Err(e) => {
383 let _ = store.finalize(&sid, SystemTime::now(), 1);
384 return Err(e.into());
385 }
386 }
387 } else {
388 None
389 };
390
391 let mut container = if let Some(attach) = &attach {
392 let span = ProgressSpan::start(format!("attaching to container {}", attach.container_name));
393 match Container::is_running(&attach.container_name).await {
394 Ok(true) => {
395 span.done(format!("attached to container: {}", attach.container_name));
396 Container::attach(
397 attach.container_name.clone(),
398 image_tag.clone(),
399 Some((&host_workspace, &container_workspace)),
400 transcript,
401 )
402 }
403 Ok(false) => {
404 let _ = store.finalize(&sid, SystemTime::now(), 1);
405 return Err(OutrigError::Configuration(format!(
406 "attached container {:?} is not running",
407 attach.container_name
408 ))
409 .into());
410 }
411 Err(e) => {
412 let _ = store.finalize(&sid, SystemTime::now(), 1);
413 return Err(e.into());
414 }
415 }
416 } else {
417 let span = ProgressSpan::start(format!("ensuring image {image_tag}"));
418 let ensure = if raw_local_image {
419 image::ensure_local_image(&image_tag, transcript.as_ref()).await
420 } else {
421 image::ensure_tagged_image_for(
422 &image_cfg_name,
423 &image_cfg,
424 &repo_root,
425 &image_tag,
426 false,
427 transcript.as_ref(),
428 )
429 .await
430 };
431 let image_outcome = match ensure {
432 Ok(outcome) => outcome,
433 Err(e) => {
434 let _ = store.finalize(&sid, SystemTime::now(), 1);
435 return Err(e.into());
436 }
437 };
438 let cache_status = if raw_local_image {
439 "local image"
440 } else if image_outcome.cache_hit {
441 "cache hit"
442 } else {
443 "built"
444 };
445 span.done(format!(
446 "image ready: {} ({cache_status})",
447 image_outcome.tag
448 ));
449
450 let span = ProgressSpan::start(format!("starting container {container_name}"));
451 match Container::start_named(&image_tag, launch, container_name, transcript).await {
452 Ok(container) => {
453 span.done(format!("container ready: {}", container.name()));
454 container
455 }
456 Err(e) => {
457 let _ = store.finalize(&sid, SystemTime::now(), 1);
458 return Err(e.into());
459 }
460 }
461 };
462
463 let span = ProgressSpan::start("bootstrapping container user");
464 if let Err(e) = container.bootstrap_user().await {
465 let _ = container.stop(STOP_GRACE).await;
466 let _ = store.finalize(&sid, SystemTime::now(), 1);
467 return Err(e.into());
468 }
469 span.done("container user ready");
470
471 let network = match network_mode {
472 NetworkMode::Default => None,
473 NetworkMode::Audit => {
474 let span = ProgressSpan::start("starting network audit interceptor");
475 match NetworkInterceptor::start(&container, &log_dir, sid.as_str()).await {
476 Ok(interceptor) => {
477 span.done("network audit interceptor ready");
478 Some(interceptor)
479 }
480 Err(e) => {
481 let _ = container.stop(STOP_GRACE).await;
482 let _ = store.finalize(&sid, SystemTime::now(), 1);
483 return Err(e.into());
484 }
485 }
486 }
487 NetworkMode::Filter => {
488 let span = ProgressSpan::start("starting network filter interceptor");
489 match NetworkInterceptor::start_with_policy(
490 &container,
491 &log_dir,
492 sid.as_str(),
493 cfg.network.policy(),
494 )
495 .await
496 {
497 Ok(interceptor) => {
498 span.done("network filter interceptor ready");
499 Some(interceptor)
500 }
501 Err(e) => {
502 let _ = container.stop(STOP_GRACE).await;
503 let _ = store.finalize(&sid, SystemTime::now(), 1);
504 return Err(e.into());
505 }
506 }
507 }
508 };
509
510 Ok(SessionSetup {
511 cfg,
512 image_cfg_name,
513 image_cfg,
514 image_tag,
515 container,
516 sid,
517 session,
518 session_dir,
519 log_dir,
520 store,
521 attached: attach.is_some(),
522 network,
523 })
524}
525
526fn resolve_image_config(
534 cfg: &Config,
535 image_cfg_name: &str,
536 allow_raw_image: bool,
537) -> Result<(ImageConfig, bool)> {
538 if let Some(image_cfg) = cfg.images.get(image_cfg_name) {
539 return Ok((image_cfg.clone(), false));
540 }
541
542 if allow_raw_image && !image_cfg_name.trim().is_empty() {
543 let image_cfg = ImageConfig {
544 image_name: Some(image_cfg_name.to_string()),
545 dockerfile: None,
546 context: None,
547 build_args: BTreeMap::new(),
548 security: Default::default(),
549 mcp: BTreeMap::new(),
550 };
551 return Ok((image_cfg, true));
552 }
553
554 Err(OutrigError::Configuration(format!(
555 "image-config {image_cfg_name:?} does not match any [images.<name>]"
556 ))
557 .into())
558}
559
560fn resolve_attach_target(
561 raw: &str,
562 image_flag: Option<&str>,
563 store: &SessionStore,
564) -> Result<AttachResolution> {
565 let sid = SessionId::from(raw.to_string());
566 let session_entry = store.symlink_path(&sid);
567 match fs::symlink_metadata(&session_entry) {
568 Ok(_) => {
569 let (_, session) = store.get_by_id(&sid)?;
570 Ok(AttachResolution {
571 container_name: session.container_name,
572 image_cfg_name: image_flag.unwrap_or(&session.image_config_name).to_string(),
573 })
574 }
575 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
576 let image_cfg_name = image_flag.ok_or_else(|| {
577 OutrigError::Configuration(format!(
578 "--attach {raw:?} did not match a session; pass --image <name> \
579 to treat it as a podman container name"
580 ))
581 })?;
582 Ok(AttachResolution {
583 container_name: raw.to_string(),
584 image_cfg_name: image_cfg_name.to_string(),
585 })
586 }
587 Err(e) => Err(e.into()),
588 }
589}
590
591pub async fn merged_mcp(
593 container: &Container,
594 image_cfg: &ImageConfig,
595) -> Result<BTreeMap<String, McpServerSpec>> {
596 let span = ProgressSpan::start("reading and merging MCP configuration");
597 let mcp = embedded::merged_mcp(container, &image_cfg.mcp).await?;
598 let server_word = plural(mcp.len(), "server", "servers");
599 span.done(format!(
600 "MCP configuration ready: {} {server_word}",
601 mcp.len()
602 ));
603 Ok(mcp)
604}
605
606pub async fn connect_mcp_clients(
611 container: &Container,
612 mcp: &BTreeMap<String, McpServerSpec>,
613 log_dir: &Path,
614 cli_env: &CliEnvEntries,
615) -> Result<Vec<Arc<McpClient>>> {
616 let mut arcs = Vec::with_capacity(mcp.len());
617 for (mcp_name, spec) in mcp {
618 let span = ProgressSpan::start(format!("MCP {mcp_name}: initializing"));
619 let extra_env = cli_env.for_server(mcp_name);
620 let client =
621 McpClient::connect_via_podman_exec(container, spec, mcp_name, log_dir, &extra_env)
622 .await?;
623 span.done(format!("MCP {mcp_name}: initialized"));
624 arcs.push(Arc::new(client));
625 }
626 Ok(arcs)
627}
628
629pub async fn teardown(
638 mcp_arcs: Vec<Arc<McpClient>>,
639 network: Option<NetworkInterceptor>,
640 container: Container,
641 store: &SessionStore,
642 sid: &SessionId,
643 final_exit: i32,
644) {
645 for arc in mcp_arcs {
646 match Arc::try_unwrap(arc) {
647 Ok(client) => {
648 if let Err(e) = client.shutdown().await {
649 tracing::warn!(
650 target: "outrig::cli::session_setup",
651 "mcp shutdown failed: {e}"
652 );
653 }
654 }
655 Err(_) => {
656 tracing::warn!(
657 target: "outrig::cli::session_setup",
658 "mcp client still has outstanding refs at cleanup; relying on Drop"
659 );
660 }
661 }
662 }
663 if let Some(network) = network {
664 network.shutdown().await;
665 }
666 if let Err(e) = container.stop(STOP_GRACE).await {
667 tracing::warn!(
668 target: "outrig::cli::session_setup",
669 "container stop failed: {e}"
670 );
671 }
672 if let Err(e) = store.finalize(sid, SystemTime::now(), final_exit) {
673 tracing::warn!(
674 target: "outrig::cli::session_setup",
675 "session finalize failed: {e}"
676 );
677 }
678}
679
680#[cfg(test)]
681mod tests {
682 use super::*;
683
684 fn config_image(image_ref: &str) -> ImageConfig {
685 ImageConfig {
686 image_name: Some(image_ref.to_string()),
687 dockerfile: None,
688 context: None,
689 build_args: BTreeMap::new(),
690 security: Default::default(),
691 mcp: BTreeMap::new(),
692 }
693 }
694
695 #[test]
696 fn image_resolution_prefers_config_entry_over_raw_fallback() {
697 let mut cfg = Config::default();
698 cfg.images.insert(
699 "outrig-standard:53e082e721df8ecc".to_string(),
700 config_image("configured"),
701 );
702
703 let (image_cfg, raw_local) =
704 resolve_image_config(&cfg, "outrig-standard:53e082e721df8ecc", true).unwrap();
705
706 assert!(!raw_local);
707 assert_eq!(image_cfg.image_name.as_deref(), Some("configured"));
708 }
709
710 #[test]
711 fn image_resolution_allows_raw_fallback_for_explicit_values() {
712 let cfg = Config::default();
713
714 let (image_cfg, raw_local) =
715 resolve_image_config(&cfg, "outrig-standard:53e082e721df8ecc", true).unwrap();
716
717 assert!(raw_local);
718 assert_eq!(
719 image_cfg.image_name.as_deref(),
720 Some("outrig-standard:53e082e721df8ecc")
721 );
722 assert!(image_cfg.mcp.is_empty());
723 }
724
725 #[test]
726 fn image_resolution_rejects_config_only_missing_values() {
727 let cfg = Config::default();
728
729 let err = resolve_image_config(&cfg, "missing", false).unwrap_err();
730
731 assert!(
732 err.to_string()
733 .contains("image-config \"missing\" does not match any [images.<name>]"),
734 "unexpected error: {err}"
735 );
736 }
737
738 #[test]
739 fn image_resolution_rejects_empty_raw_value() {
740 let cfg = Config::default();
741
742 let err = resolve_image_config(&cfg, "", true).unwrap_err();
743
744 assert!(
745 err.to_string()
746 .contains("image-config \"\" does not match any [images.<name>]"),
747 "unexpected error: {err}"
748 );
749 }
750}