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