Skip to main content

mj_controller/controller/
reviewer.rs

1//! Staging the second-opinion reviewer's profile onto a session's target.
2//!
3//! The reviewer runs a different configured profile in the primary session's
4//! own target. Its harness home is a fresh copy of that profile, placed inside
5//! the primary worker root, so the reviewer never reads or writes the
6//! primary's home and nothing outside the worker root has to be provisioned.
7//!
8//! Nothing here creates a session record, a target, a checkout, or any target
9//! lifecycle operation. Staging a reviewer is a file copy into a directory the
10//! session's worker already owns.
11
12use std::path::Path;
13
14use anyhow::{Context, Result, bail, ensure};
15
16use super::worker_binary::{
17    apply_staged_execution_setting, bridge_launch, container_upload_ownership_args,
18    link_shared_login, shared_profile_login, stage_profile,
19};
20use super::{Controller, execute_checked};
21use crate::targets::{self, CommandExecutor, CommandSpec, ProcessExecutor};
22use mj_core::worker_launch::{
23    ReviewMcpDelivery, ReviewMcpServer, ReviewerLaunchConfig, reviewer_staging_profile_home,
24};
25
26/// Build the capability used by client-side chat views to stage reviewers.
27/// The blocking filesystem and target work remains implemented by the
28/// controller and is invoked from chat's supervised blocking task.
29pub fn reviewer_stager() -> mj_client::session::ReviewerStager {
30    mj_client::session::ReviewerStager::new(ControllerReviewerStager)
31}
32
33struct ControllerReviewerStager;
34
35impl mj_client::session::ReviewerStagerBackend for ControllerReviewerStager {
36    fn stage(
37        &self,
38        _config: mj_core::config::Config,
39        session: mj_core::state::SessionRecord,
40        profile_id: String,
41        generation: u64,
42        cancelled: std::sync::Arc<std::sync::atomic::AtomicBool>,
43    ) -> Result<ReviewerLaunchConfig> {
44        let session_id = session.id.clone();
45        let controller = Controller::load()?;
46        let executor = crate::targets::CancellableProcessExecutor::new(cancelled)
47            .with_deadline(std::time::Duration::from_secs(90));
48        controller.stage_reviewer_profile_controlled(
49            &session_id,
50            &profile_id,
51            generation,
52            &[],
53            &executor,
54        )
55    }
56}
57
58impl Controller {
59    /// Copy `profile_id`'s home into the session worker's reviewer directory
60    /// and describe how the worker should launch it.
61    ///
62    /// `generation` distinguishes reviewer lifetimes: bumping it tells the
63    /// worker to start a new conversation instead of reloading the last one.
64    pub fn stage_reviewer_profile(
65        &self,
66        session_id: &str,
67        profile_id: &str,
68        generation: u64,
69    ) -> Result<ReviewerLaunchConfig> {
70        self.stage_reviewer_profile_controlled(
71            session_id,
72            profile_id,
73            generation,
74            &[],
75            &ProcessExecutor,
76        )
77    }
78
79    pub fn stage_reviewer_profile_controlled(
80        &self,
81        session_id: &str,
82        profile_id: &str,
83        generation: u64,
84        mcp_servers: &[ReviewMcpServer],
85        executor: &impl CommandExecutor,
86    ) -> Result<ReviewerLaunchConfig> {
87        let profile = self
88            .config
89            .profiles
90            .get(profile_id)
91            .with_context(|| format!("unknown profile {profile_id:?}"))?;
92        ensure!(
93            profile.enabled,
94            "reviewer profile {profile_id:?} is disabled"
95        );
96        let session = self
97            .state
98            .sessions
99            .get(session_id)
100            .with_context(|| format!("unknown session {session_id}"))?;
101        let target = session.target_runtime_settings(&self.config)?;
102        let execution_policy = target.execution_policy;
103        let (backend, worker_root) = self.worker_placement(session_id)?;
104
105        let staging = tempfile::tempdir().context("create reviewer staging directory")?;
106        let local = staging.path().join("profile");
107        profile.ensure_ready(profile_id)?;
108        stage_profile(profile, &local).with_context(|| format!("stage profile {profile_id:?}"))?;
109        apply_staged_execution_setting(profile.kind, execution_policy, &local)
110            .with_context(|| format!("stage profile {profile_id:?}"))?;
111        // Harnesses that ignore MCP servers offered over ACP read their own
112        // configuration instead, so the servers are written into the copy
113        // being staged, before it is uploaded.
114        if !mcp_servers.is_empty()
115            && ReviewMcpDelivery::for_harness(profile.kind) == ReviewMcpDelivery::HarnessProfile
116        {
117            configure_staged_review_mcp(profile.kind, &local, mcp_servers)
118                .with_context(|| format!("configure reviewer MCP servers for {profile_id:?}"))?;
119        }
120        // `cp -R` copies the link as a link, and the worker keeps it when it
121        // copies the stage into each reviewer role's home.
122        if let Some(login) = shared_profile_login(profile, &backend) {
123            link_shared_login(&local, &login)?;
124        }
125        upload_reviewer_profile(executor, &backend, &worker_root, generation, &local)?;
126
127        let (bridge_command, bridge_args) = bridge_launch(profile.kind, execution_policy);
128        let mut environment = profile.environment.resolved().clone();
129        // The worker sets the harness home from the directory it staged, so
130        // sending one here could only point the reviewer somewhere it must not
131        // read.
132        environment.remove(profile.home_env());
133        // A reviewer on a ChatGPT Codex profile must not fall back to an API
134        // key any more than a session may.
135        let excluded_environment = profile.exclude_harness_environment(&mut environment);
136        // A Podman session runs as uid 0, where Claude Code rejects
137        // bypassPermissions unless told it is already sandboxed. The session's
138        // own launch sets this only when the session itself runs Claude.
139        if backend.container_engine() == Some("podman")
140            && profile.kind == mj_core::config::HarnessKind::Claude
141        {
142            environment.insert("IS_SANDBOX".into(), "1".into());
143        }
144        Ok(ReviewerLaunchConfig {
145            profile_id: profile_id.to_owned(),
146            harness: profile.kind,
147            bridge_command: bridge_command.into(),
148            bridge_args,
149            environment,
150            excluded_environment,
151            execution_policy,
152            model: None,
153            effort: None,
154            fast_mode: None,
155            generation,
156            // Keep ownership metadata for approval policy even when connection
157            // configuration lives in the staged profile. The worker owns ACP delivery.
158            mcp_servers: mcp_servers.to_vec(),
159        })
160    }
161}
162
163/// Writes `servers` into the staged profile of a harness that reads its MCP
164/// configuration from disk.
165///
166/// Claude Code reads `mcpServers` from `.claude.json` in its config directory;
167/// Kimi reads `mcpServers` from `mcp.json` in its home, and needs the runtime
168/// id its own schema carries. Both files are the reviewer's private copy, so
169/// nothing here can reach the user's own configuration.
170fn configure_staged_review_mcp(
171    harness: mj_core::config::HarnessKind,
172    profile_stage: &Path,
173    servers: &[ReviewMcpServer],
174) -> Result<()> {
175    let Some(file) = harness.mcp_config_file() else {
176        bail!("{harness:?} does not read MCP servers from its profile");
177    };
178    let kimi = harness == mj_core::config::HarnessKind::Kimi;
179    let path = profile_stage.join(file);
180    let mut document = match std::fs::read(&path) {
181        Ok(body) => serde_json::from_slice::<serde_json::Value>(&body)
182            .with_context(|| format!("parse staged reviewer configuration {}", path.display()))?,
183        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
184            serde_json::Value::Object(serde_json::Map::new())
185        }
186        Err(error) => {
187            return Err(error)
188                .with_context(|| format!("read staged reviewer configuration {}", path.display()));
189        }
190    };
191    let root = document.as_object_mut().with_context(|| {
192        format!(
193            "staged reviewer configuration {} must contain a JSON object",
194            path.display()
195        )
196    })?;
197    let configured = root
198        .entry("mcpServers")
199        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()))
200        .as_object_mut()
201        .with_context(|| {
202            format!(
203                "mcpServers in staged reviewer configuration {} must be a JSON object",
204                path.display()
205            )
206        })?;
207    for server in servers {
208        let mut entry = serde_json::json!({
209            "command": server.command,
210            "args": server.args,
211        });
212        if kimi {
213            let object = entry
214                .as_object_mut()
215                .expect("the server entry is a JSON object");
216            object.insert("transport".into(), "stdio".into());
217            object.insert("runtime_id".into(), "local".into());
218        } else {
219            let object = entry
220                .as_object_mut()
221                .expect("the server entry is a JSON object");
222            object.insert("type".into(), "stdio".into());
223        }
224        configured.insert(server.name.clone(), entry);
225    }
226    let mut body = serde_json::to_vec_pretty(&document)?;
227    body.push(b'\n');
228    mj_core::config::atomic_write(&path, &body)
229        .with_context(|| format!("write staged reviewer configuration {}", path.display()))
230}
231
232/// Where one immutable reviewer profile snapshot lives on the target.
233///
234/// The worker copies this source into each role's private harness home before
235/// launch. Keeping generations in separate directories means staging a lane
236/// cannot replace the profile a running role is using.
237fn reviewer_profile_home(worker_root: &str, generation: u64) -> String {
238    reviewer_staging_profile_home(Path::new(worker_root), generation)
239        .to_string_lossy()
240        .into_owned()
241}
242
243/// Replace this generation's staged profile with a fresh copy of `local`.
244///
245/// The previous snapshot for this generation is removed first: a reviewer
246/// profile is a snapshot of the user's configured home, and merging a new copy
247/// over an old one would leave credentials and skills the source no longer has.
248fn upload_reviewer_profile(
249    executor: &impl CommandExecutor,
250    locator: &targets::TargetLocator,
251    worker_root: &str,
252    generation: u64,
253    local: &Path,
254) -> Result<()> {
255    let home = reviewer_profile_home(worker_root, generation);
256    match locator {
257        targets::TargetLocator::LocalBare { .. } => {
258            for command in [
259                CommandSpec::new("rm", ["-rf", "--", &home])
260                    .purpose("clear the local reviewer profile"),
261                CommandSpec::new("mkdir", ["-p", &home])
262                    .purpose("create the local reviewer profile directory"),
263                CommandSpec::new(
264                    "cp",
265                    [
266                        "-R".to_owned(),
267                        format!("{}/.", local.display()),
268                        home.clone(),
269                    ],
270                )
271                .purpose("install the local reviewer profile"),
272                CommandSpec::new("chmod", ["-R", "go-rwx", &home])
273                    .purpose("restrict local reviewer profile permissions"),
274            ] {
275                execute_checked(executor, command)?;
276            }
277        }
278        targets::TargetLocator::LocalPodman { container_id, .. }
279        | targets::TargetLocator::LocalDocker { container_id, .. }
280        | targets::TargetLocator::AppleContainer { container_id, .. } => {
281            let engine = match locator {
282                targets::TargetLocator::LocalPodman { .. } => "podman",
283                targets::TargetLocator::LocalDocker { .. } => "docker",
284                targets::TargetLocator::AppleContainer { .. } => "container",
285                _ => unreachable!("matched local container target"),
286            };
287            for arguments in [
288                vec![
289                    "exec".to_owned(),
290                    container_id.clone(),
291                    "rm".to_owned(),
292                    "-rf".to_owned(),
293                    "--".to_owned(),
294                    home.clone(),
295                ],
296                vec![
297                    "exec".to_owned(),
298                    container_id.clone(),
299                    "mkdir".to_owned(),
300                    "-p".to_owned(),
301                    home.clone(),
302                ],
303                vec![
304                    "cp".to_owned(),
305                    format!("{}/.", local.display()),
306                    format!("{container_id}:{home}"),
307                ],
308                container_upload_ownership_args(engine, container_id, worker_root, &[&home]),
309                vec![
310                    "exec".to_owned(),
311                    container_id.clone(),
312                    "chmod".to_owned(),
313                    "-R".to_owned(),
314                    "go-rwx".to_owned(),
315                    home.clone(),
316                ],
317            ] {
318                execute_checked(
319                    executor,
320                    CommandSpec::new(engine, arguments).purpose("stage the reviewer profile"),
321                )?;
322            }
323        }
324        targets::TargetLocator::AwsEc2 { ssh, .. }
325        | targets::TargetLocator::SshBare { ssh, .. } => {
326            let incoming = format!("{home}.incoming");
327            execute_checked(
328                executor,
329                crate::targets::ssh_command(ssh, ["mkdir", "-p", worker_root])
330                    .purpose("create the reviewer directory"),
331            )?;
332            execute_checked(
333                executor,
334                crate::targets::ssh_command(ssh, ["rm", "-rf", "--", &incoming, &home])
335                    .purpose("clear the reviewer profile"),
336            )?;
337            execute_checked(
338                executor,
339                crate::targets::scp_upload(ssh, local, &incoming, true)
340                    .purpose("upload the reviewer profile"),
341            )?;
342            execute_checked(
343                executor,
344                crate::targets::ssh_command(ssh, ["mv", &incoming, &home])
345                    .purpose("install the reviewer profile"),
346            )?;
347            execute_checked(
348                executor,
349                crate::targets::ssh_command(ssh, ["chmod", "-R", "go-rwx", &home])
350                    .purpose("restrict reviewer profile permissions"),
351            )?;
352        }
353        targets::TargetLocator::SshPodman {
354            ssh, container_id, ..
355        }
356        | targets::TargetLocator::SshDocker {
357            ssh, container_id, ..
358        } => {
359            let engine = match locator {
360                targets::TargetLocator::SshPodman { .. } => "podman",
361                targets::TargetLocator::SshDocker { .. } => "docker",
362                _ => unreachable!("matched remote container target"),
363            };
364            // `worker_root` is a path inside the container, so it does not exist
365            // on the SSH host; stage on the host the way checkpoint uploads do.
366            let worker = Path::new(worker_root)
367                .file_name()
368                .map(|name| name.to_string_lossy().into_owned())
369                .filter(|name| !name.is_empty())
370                .ok_or_else(|| anyhow::anyhow!("worker root {worker_root:?} names no worker"))?;
371            let upload = format!(
372                "{}/{worker}-reviewer-{generation}",
373                targets::REMOTE_UPLOAD_STAGING
374            );
375            execute_checked(
376                executor,
377                crate::targets::ssh_command(ssh, ["mkdir", "-p", targets::REMOTE_UPLOAD_STAGING])
378                    .purpose("create remote reviewer staging"),
379            )?;
380            execute_checked(
381                executor,
382                crate::targets::ssh_command(ssh, ["rm", "-rf", "--", &upload])
383                    .purpose("clear remote reviewer staging"),
384            )?;
385            execute_checked(
386                executor,
387                crate::targets::scp_upload(ssh, local, &upload, true)
388                    .purpose("upload the remote reviewer profile"),
389            )?;
390            for arguments in [
391                vec![
392                    engine.to_owned(),
393                    "exec".to_owned(),
394                    container_id.clone(),
395                    "rm".to_owned(),
396                    "-rf".to_owned(),
397                    "--".to_owned(),
398                    home.clone(),
399                ],
400                vec![
401                    engine.to_owned(),
402                    "exec".to_owned(),
403                    container_id.clone(),
404                    "mkdir".to_owned(),
405                    "-p".to_owned(),
406                    home.clone(),
407                ],
408                vec![
409                    engine.to_owned(),
410                    "cp".to_owned(),
411                    format!("{upload}/."),
412                    format!("{container_id}:{home}"),
413                ],
414                std::iter::once(engine.to_owned())
415                    .chain(container_upload_ownership_args(
416                        engine,
417                        container_id,
418                        worker_root,
419                        &[&home],
420                    ))
421                    .collect(),
422                vec![
423                    engine.to_owned(),
424                    "exec".to_owned(),
425                    container_id.clone(),
426                    "chmod".to_owned(),
427                    "-R".to_owned(),
428                    "go-rwx".to_owned(),
429                    home.clone(),
430                ],
431            ] {
432                execute_checked(
433                    executor,
434                    crate::targets::ssh_command(ssh, arguments)
435                        .purpose("stage the remote reviewer profile"),
436                )?;
437            }
438            execute_checked(
439                executor,
440                crate::targets::ssh_command(ssh, ["rm", "-rf", "--", &upload])
441                    .purpose("remove remote reviewer staging"),
442            )?;
443        }
444    }
445    if home.trim().is_empty() {
446        bail!("the reviewer profile home resolved to an empty path");
447    }
448    Ok(())
449}
450
451#[cfg(test)]
452mod tests {
453    use std::cell::RefCell;
454    use std::collections::BTreeMap;
455
456    use super::*;
457    use crate::controller::test_support::checkpoint_test_session;
458    use mj_core::config::{Config, HarnessKind, HarnessProfile, TargetTemplate};
459    use mj_core::state::{SessionState, State};
460
461    use crate::targets::CommandOutput;
462
463    struct RecordingExecutor {
464        commands: RefCell<Vec<CommandSpec>>,
465    }
466
467    impl RecordingExecutor {
468        fn new() -> Self {
469            Self {
470                commands: RefCell::new(Vec::new()),
471            }
472        }
473
474        /// Every command as one line, for order-sensitive assertions.
475        fn script(&self) -> Vec<String> {
476            self.commands
477                .borrow()
478                .iter()
479                .map(|command| format!("{} {}", command.program, command.args.join(" ")))
480                .collect()
481        }
482    }
483
484    impl CommandExecutor for RecordingExecutor {
485        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
486            self.commands.borrow_mut().push(command.clone());
487            Ok(CommandOutput {
488                status: 0,
489                stdout: Vec::new(),
490                stderr: Vec::new(),
491            })
492        }
493    }
494
495    /// A controller with one running session and two configured profiles: the
496    /// session's own and a second one to review with.
497    const SESSION_ID: &str = "0123456789abcdef0123456789abcdef";
498
499    fn fixture(directory: &Path, locator: mj_core::state::TargetLocator) -> (Controller, String) {
500        let session_id = SESSION_ID;
501        let mut session = checkpoint_test_session(session_id);
502        session.target_template_id = "local".into();
503        session.state = SessionState::Running;
504        let template = match &locator {
505            mj_core::state::TargetLocator::LocalPodman { .. } => {
506                serde_json::from_str(r#"{"kind":"local-podman","image":"test"}"#).unwrap()
507            }
508            mj_core::state::TargetLocator::LocalDocker { .. } => {
509                serde_json::from_str(r#"{"kind":"local-docker","image":"test"}"#).unwrap()
510            }
511            mj_core::state::TargetLocator::SshPodman { .. } => serde_json::from_str(
512                r#"{"kind":"ssh-podman","host":"builder.test","image":"test"}"#,
513            )
514            .unwrap(),
515            _ => TargetTemplate::LocalBare,
516        };
517        session.target = Some(locator);
518        let mut config = Config::default();
519        config.targets.insert("local".into(), template);
520        for (id, kind) in [
521            ("codex", HarnessKind::Codex),
522            ("claude", HarnessKind::Claude),
523        ] {
524            let home = directory.join(id);
525            std::fs::create_dir_all(&home).unwrap();
526            config.profiles.insert(
527                id.to_owned(),
528                HarnessProfile {
529                    enabled: true,
530                    kind,
531                    home,
532                    environment: BTreeMap::from([("EXTRA".into(), "1".into())]).into(),
533                    context_window_bytes: None,
534                    legacy_subagents: Default::default(),
535                    guardian_review_model: None,
536                    codex_profile: None,
537                },
538            );
539        }
540        (
541            Controller {
542                config,
543                state: State {
544                    sessions: [(session_id.into(), session)].into_iter().collect(),
545                    ..State::default()
546                },
547            },
548            session_id.to_owned(),
549        )
550    }
551
552    #[test]
553    fn staging_copies_the_chosen_profile_into_the_worker_root() {
554        let directory = tempfile::tempdir().unwrap();
555        let worker_root = directory.path().join(SESSION_ID);
556        std::fs::create_dir_all(directory.path().join("claude")).unwrap();
557        // A file the allowlist copies, so the stage has something to move.
558        std::fs::write(directory.path().join("claude/CLAUDE.md"), b"reviewer").unwrap();
559        let (controller, session_id) = fixture(
560            directory.path(),
561            mj_core::state::TargetLocator::LocalBare {
562                worker_root: worker_root.clone(),
563            },
564        );
565        let executor = RecordingExecutor::new();
566
567        let config = controller
568            .stage_reviewer_profile_controlled(&session_id, "claude", 0, &[], &executor)
569            .unwrap();
570
571        assert_eq!(config.profile_id, "claude");
572        assert_eq!(config.harness, HarnessKind::Claude);
573        assert_eq!(config.generation, 0);
574        assert_eq!(config.model, None);
575        assert_eq!(config.effort, None);
576        // The worker owns the harness home, so the controller never sends one.
577        assert!(
578            !config
579                .environment
580                .contains_key(HarnessKind::Claude.home_env())
581        );
582        assert_eq!(
583            config.environment.get("EXTRA").map(String::as_str),
584            Some("1")
585        );
586
587        let home = format!("{}/reviewer/profile", worker_root.display());
588        let script = executor.script();
589        let cleared = script
590            .iter()
591            .position(|line| line.starts_with("rm ") && line.contains(&home))
592            .expect("the previous reviewer profile is cleared");
593        let copied = script
594            .iter()
595            .position(|line| line.starts_with("cp ") && line.ends_with(&home))
596            .expect("the staged profile is installed");
597        assert!(
598            cleared < copied,
599            "a stale profile must go before the new one lands: {script:?}"
600        );
601        assert!(
602            script
603                .iter()
604                .any(|line| line.contains("go-rwx") && line.contains(&home)),
605            "the reviewer profile must not be world readable: {script:?}"
606        );
607    }
608
609    // Hard-won: 251e812e42b5: SSH container uploads targeted a path that existed only inside the container.
610    #[test]
611    fn remote_container_targets_stage_the_reviewer_on_the_host_not_in_the_worker_root() {
612        // The worker root is a path inside the container. Uploading to it over
613        // scp failed with "No such file" on every remote podman session, so no
614        // turn review could start there (2026-10-02).
615        let directory = tempfile::tempdir().unwrap();
616        let container_id = crate::targets::resource_name(SESSION_ID).unwrap();
617        let (controller, session_id) = fixture(
618            directory.path(),
619            mj_core::state::TargetLocator::SshPodman {
620                host: "builder.test".into(),
621                container_id: container_id.clone(),
622                workspace_storage: Default::default(),
623                borrowed_from: None,
624            },
625        );
626        let executor = RecordingExecutor::new();
627
628        controller
629            .stage_reviewer_profile_controlled(&session_id, "codex", 3, &[], &executor)
630            .unwrap();
631
632        let script = executor.script();
633        let upload = script
634            .iter()
635            .find(|line| line.starts_with("scp "))
636            .expect("the profile is uploaded with scp")
637            .clone();
638        let staging = format!(
639            "{}/{session_id}-reviewer-3",
640            crate::targets::REMOTE_UPLOAD_STAGING
641        );
642        assert!(
643            upload.contains(&staging),
644            "the upload lands in host staging: {upload}"
645        );
646        assert!(
647            !upload.contains("/var/lib/hel/workers/"),
648            "the upload does not target the container's worker root: {upload}"
649        );
650        let copy = script
651            .iter()
652            .find(|line| line.contains("cp") && line.contains(&format!("{container_id}:")))
653            .unwrap_or_else(|| {
654                panic!("the engine copies the staged profile into the container: {script:?}")
655            });
656        assert!(
657            copy.contains(&format!("{staging}/.")) && copy.contains(&format!("{container_id}:")),
658            "the engine copies from host staging into the container: {copy}"
659        );
660        let last = script.last().unwrap();
661        assert!(
662            last.contains("'rm'")
663                && last.contains(&format!("'{staging}'"))
664                && !last.contains("podman"),
665            "host staging is removed afterwards: {script:?}"
666        );
667    }
668
669    #[test]
670
671    fn local_container_targets_stage_the_reviewer_through_their_engine() {
672        let directory = tempfile::tempdir().unwrap();
673        let container_id = crate::targets::resource_name(SESSION_ID).unwrap();
674        for (locator, engine) in [
675            (
676                mj_core::state::TargetLocator::LocalPodman {
677                    borrowed_from: None,
678                    container_id: container_id.clone(),
679                    workspace_storage: Default::default(),
680                },
681                "podman",
682            ),
683            (
684                mj_core::state::TargetLocator::LocalDocker {
685                    borrowed_from: None,
686                    container_id: container_id.clone(),
687                },
688                "docker",
689            ),
690        ] {
691            let (controller, session_id) = fixture(directory.path(), locator);
692            let executor = RecordingExecutor::new();
693
694            controller
695                .stage_reviewer_profile_controlled(&session_id, "codex", 3, &[], &executor)
696                .unwrap();
697
698            let script = executor.script();
699            assert!(
700                script
701                    .iter()
702                    .all(|line| line.starts_with(&format!("{engine} "))),
703                "a container target is reached only through its engine: {script:?}"
704            );
705            let home = script
706                .iter()
707                .find_map(|line| {
708                    line.split(' ')
709                        .find(|word| word.contains("/reviewer/profile"))
710                })
711                .expect("the reviewer profile is placed")
712                .to_owned();
713            assert!(
714                home.contains(&format!("/{session_id}")),
715                "the reviewer lives under this session's worker root: {home}"
716            );
717            // Nothing here provisions a target, a checkout, or another session.
718            assert!(
719                !script.iter().any(|line| {
720                    line.contains("run") || line.contains("git") || line.contains("create")
721                }),
722                "staging a reviewer provisions nothing: {script:?}"
723            );
724        }
725    }
726
727    #[test]
728    fn a_claude_reviewer_in_a_podman_session_is_told_it_is_sandboxed() {
729        let directory = tempfile::tempdir().unwrap();
730        let container_id = crate::targets::resource_name(SESSION_ID).unwrap();
731        let podman = mj_core::state::TargetLocator::LocalPodman {
732            borrowed_from: None,
733            container_id: container_id.clone(),
734            workspace_storage: Default::default(),
735        };
736        let docker = mj_core::state::TargetLocator::LocalDocker {
737            borrowed_from: None,
738            container_id,
739        };
740        for (locator, profile_id, sandboxed) in [
741            (podman.clone(), "claude", true),
742            (podman, "codex", false),
743            (docker, "claude", false),
744        ] {
745            let (controller, session_id) = fixture(directory.path(), locator);
746            let config = controller
747                .stage_reviewer_profile_controlled(
748                    &session_id,
749                    profile_id,
750                    0,
751                    &[],
752                    &RecordingExecutor::new(),
753                )
754                .unwrap();
755            assert_eq!(
756                config.environment.get("IS_SANDBOX").map(String::as_str),
757                sandboxed.then_some("1"),
758                "{profile_id}"
759            );
760        }
761    }
762
763    #[test]
764    fn a_muse_reviewer_is_staged_with_the_permission_profile_its_policy_enforces() {
765        let directory = tempfile::tempdir().unwrap();
766        let worker_root = directory.path().join(SESSION_ID);
767        let (mut controller, session_id) = fixture(
768            directory.path(),
769            mj_core::state::TargetLocator::LocalBare {
770                worker_root: worker_root.clone(),
771            },
772        );
773        // Muse reads its permission profile from this file, and nothing on the
774        // ACP wire overrides it.
775        let home = directory.path().join("muse");
776        std::fs::create_dir_all(&home).unwrap();
777        std::fs::write(
778            home.join("settings.json"),
779            br#"{"schema_version":1,"permissions":{"schema_version":1,"default_profile":":auto-review"}}"#,
780        )
781        .unwrap();
782        controller.config.profiles.insert(
783            "muse".into(),
784            HarnessProfile {
785                enabled: true,
786                kind: HarnessKind::Muse,
787                home,
788                environment: BTreeMap::new().into(),
789                context_window_bytes: None,
790                legacy_subagents: Default::default(),
791                guardian_review_model: None,
792                codex_profile: None,
793            },
794        );
795        controller
796            .stage_reviewer_profile_controlled(&session_id, "muse", 0, &[], &ProcessExecutor)
797            .unwrap();
798
799        let staged: serde_json::Value = serde_json::from_slice(
800            &std::fs::read(worker_root.join("reviewer/profile/settings.json")).unwrap(),
801        )
802        .unwrap();
803        // The fixture's bare target keeps configured approvals.
804        assert_eq!(staged["permissions"]["default_profile"], ":ask-me");
805    }
806
807    #[test]
808    fn a_new_generation_travels_to_the_worker_so_it_starts_a_fresh_reviewer() {
809        let directory = tempfile::tempdir().unwrap();
810        let (controller, session_id) = fixture(
811            directory.path(),
812            mj_core::state::TargetLocator::LocalBare {
813                worker_root: directory.path().join(SESSION_ID),
814            },
815        );
816        let executor = RecordingExecutor::new();
817
818        let first = controller
819            .stage_reviewer_profile_controlled(&session_id, "codex", 0, &[], &executor)
820            .unwrap();
821        let second = controller
822            .stage_reviewer_profile_controlled(&session_id, "codex", 1, &[], &executor)
823            .unwrap();
824
825        assert!(first.reusable_for(&first));
826        assert!(
827            !first.reusable_for(&second),
828            "a new generation must not reload the old conversation"
829        );
830    }
831}