Skip to main content

outrig_cli/cli/
session_setup.rs

1//! Shared bootstrap for `outrig run` and (future) `outrig mcp`. Lifts the
2//! sequence of "load + merge config -> resolve container -> ensure image ->
3//! start + bootstrap container -> session row + log dir" out of [`run`]
4//! so both subcommands hit the same code path.
5//!
6//! Three pieces:
7//!
8//! - [`setup`] -- everything from config-load through "container started +
9//!   bootstrapped + session row + log dir created", returning a populated
10//!   [`SessionSetup`]. Stops *before* MCP children connect.
11//! - [`merged_mcp`] + [`connect_mcp_clients`] -- reads any image-embedded MCP
12//!   config, applies repo-config overrides, and spawns one [`McpClient`] per
13//!   merged backing MCP in `BTreeMap` (key-sorted, deterministic) iteration
14//!   order. Adapter construction stays in the caller because only the REPL
15//!   path consumes adapters.
16//! - [`teardown`] -- mirror of the cleanup tail: graceful MCP shutdowns
17//!   (drop adapters first so [`Arc::try_unwrap`] succeeds), then stop the
18//!   container, then finalize the session row. Errors are logged and never
19//!   override the caller's outcome.
20//!
21//! The MCP children are `podman exec` processes whose stdio rides through
22//! the container; tearing the container down before shutting them down
23//! races their pipes, so the order in [`teardown`] is load-bearing.
24//!
25//! [`run`]: crate::cli::run
26
27use 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
98/// Inputs to [`setup`]. Borrowed to keep the call site cheap; the lifetime
99/// is the caller's stack frame.
100pub 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    /// Existing session id or podman container name to attach to instead of
106    /// starting a fresh container. Used by `outrig mcp --attach`.
107    pub attach_target: Option<&'a str>,
108    /// Raw `--agent` flag. Read only when `require_agent = true`; ignored
109    /// otherwise (and `outrig mcp` always passes `None`).
110    pub agent_flag: Option<&'a str>,
111    /// Raw `--model` flag. Read only when `require_agent = true`; ignored
112    /// otherwise (and `outrig mcp` always passes `None`).
113    pub model_override: Option<&'a str>,
114    /// `true` for `outrig run`: [`setup`] resolves an agent from
115    /// `agent_flag.or(cfg.default_agent)` (errors if neither) and lets
116    /// `agent.image` participate in the container fallback.
117    /// `false` for `outrig mcp`: no agent at all -- `llm::resolve_agent` is
118    /// not called, `cfg.default_agent` is not consulted, the resulting
119    /// [`Session::agent_name`] is `None`, and the container cascade is
120    /// `image_flag -> default_image` only.
121    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    /// Extra `--volume HOST:CONTAINER[:ro|rw]` mounts appended to the
126    /// container's workspace mounts. Rejected with `--attach`.
127    pub volumes: &'a [CliVolume],
128    pub verbose: u8,
129}
130
131/// Output of [`setup`]: every long-lived value the post-setup pipeline
132/// needs (REPL build, MCP children, teardown). The container is already
133/// started + bootstrapped; the session row is already on disk.
134pub 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
155/// Run the shared bootstrap. Returns once the container is up, the runtime
156/// user is bootstrapped, and the session directory + log dir exist.
157pub 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    // Extra `--volume` mounts append to the workspace mounts and are validated
209    // with the same rules as config `[workspace.mounts]`. A borrowed container
210    // (`--attach`) has fixed mounts, so reject `--volume` there.
211    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    // Agent presence is checked before any container work so the failure
231    // mode is identical for `outrig run` regardless of which container
232    // would have been picked. `outrig mcp` opts out via `require_agent =
233    // false` -- it has no agent concept, so `agent_flag` and
234    // `cfg.default_agent` are not consulted at all.
235    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(), // set by `create` below
369        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
526/// Resolve an image-config name to its [`ImageConfig`] and whether it is a
527/// *raw local image* -- a ref used verbatim that is never built or pulled.
528///
529/// A name matching a `[images.<name>]` block uses that config (not raw).
530/// Otherwise, when `allow_raw_image` is set and the name is non-empty, a
531/// minimal image-only config naming the ref itself is synthesized (raw).
532/// Otherwise it is an error.
533fn 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
591/// Read image-embedded MCP config and overlay explicit `config.toml` entries.
592pub 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
606/// Spawn one [`McpClient`] per backing MCP declared in `mcp`, in key-sorted
607/// (`BTreeMap`) iteration order. `cli_env` provides any `--env` overlay
608/// entries to merge per server. Adapter construction is the caller's job
609/// because only the REPL path consumes adapters.
610pub 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
629/// Cleanup tail. Order: MCP shutdowns (so their `podman exec` pipes drain
630/// before the container goes away) -> container stop -> session finalize.
631/// Each step's failure is logged but never propagated; the caller's outcome
632/// owns the process exit code.
633///
634/// Callers must drop any `Arc<McpClient>` clones (e.g. tool adapters) and
635/// the agent before invoking this -- otherwise [`Arc::try_unwrap`] returns
636/// `Err` and the explicit `shutdown` is skipped in favor of `Drop`.
637pub 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}