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