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