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