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