Skip to main content

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