1use 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
24pub 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 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 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 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 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 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
181fn 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
250fn 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
271fn 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
282fn 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 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 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 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 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 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}