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