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, scp_command_spec, ssh_command_spec};
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 (file, kimi) = match harness {
194 mj_core::config::HarnessKind::Claude => (".claude.json", false),
195 mj_core::config::HarnessKind::Kimi => ("mcp.json", true),
196 other => bail!("{other:?} does not read MCP servers from its profile"),
197 };
198 let path = profile_stage.join(file);
199 let mut document = match std::fs::read(&path) {
200 Ok(body) => serde_json::from_slice::<serde_json::Value>(&body)
201 .with_context(|| format!("parse staged reviewer configuration {}", path.display()))?,
202 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
203 serde_json::Value::Object(serde_json::Map::new())
204 }
205 Err(error) => {
206 return Err(error)
207 .with_context(|| format!("read staged reviewer configuration {}", path.display()));
208 }
209 };
210 let root = document.as_object_mut().with_context(|| {
211 format!(
212 "staged reviewer configuration {} must contain a JSON object",
213 path.display()
214 )
215 })?;
216 let configured = root
217 .entry("mcpServers")
218 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()))
219 .as_object_mut()
220 .with_context(|| {
221 format!(
222 "mcpServers in staged reviewer configuration {} must be a JSON object",
223 path.display()
224 )
225 })?;
226 for server in servers {
227 let mut entry = serde_json::json!({
228 "command": server.command,
229 "args": server.args,
230 });
231 if kimi {
232 let object = entry
233 .as_object_mut()
234 .expect("the server entry is a JSON object");
235 object.insert("transport".into(), "stdio".into());
236 object.insert("runtime_id".into(), "local".into());
237 } else {
238 let object = entry
239 .as_object_mut()
240 .expect("the server entry is a JSON object");
241 object.insert("type".into(), "stdio".into());
242 }
243 configured.insert(server.name.clone(), entry);
244 }
245 let mut body = serde_json::to_vec_pretty(&document)?;
246 body.push(b'\n');
247 mj_core::config::atomic_write(&path, &body)
248 .with_context(|| format!("write staged reviewer configuration {}", path.display()))
249}
250
251fn review_dispatch_server(worker_root: &str) -> ReviewMcpServer {
255 let socket = format!(
256 "{worker_root}/{}/{}",
257 REVIEWER_DIR,
258 mj_core::review::mcp::REVIEW_DISPATCH_SOCKET
259 );
260 ReviewMcpServer {
261 name: mj_core::review::mcp::REVIEW_MCP_SERVER_NAME.to_owned(),
262 command: Path::new(worker_root).join("hel"),
263 args: vec![
264 "worker".to_owned(),
265 "review-mcp".to_owned(),
266 "--socket".to_owned(),
267 socket,
268 ],
269 }
270}
271
272fn reviewer_profile_home(worker_root: &str, generation: u64) -> String {
278 reviewer_staging_profile_home(Path::new(worker_root), generation)
279 .to_string_lossy()
280 .into_owned()
281}
282
283fn upload_reviewer_profile(
289 executor: &impl CommandExecutor,
290 locator: &targets::TargetLocator,
291 worker_root: &str,
292 generation: u64,
293 local: &Path,
294) -> Result<()> {
295 let home = reviewer_profile_home(worker_root, generation);
296 match locator {
297 targets::TargetLocator::LocalBare { .. } => {
298 for command in [
299 CommandSpec::new("rm", ["-rf", "--", &home])
300 .purpose("clear the local reviewer profile"),
301 CommandSpec::new("mkdir", ["-p", &home])
302 .purpose("create the local reviewer profile directory"),
303 CommandSpec::new(
304 "cp",
305 [
306 "-R".to_owned(),
307 format!("{}/.", local.display()),
308 home.clone(),
309 ],
310 )
311 .purpose("install the local reviewer profile"),
312 CommandSpec::new("chmod", ["-R", "go-rwx", &home])
313 .purpose("restrict local reviewer profile permissions"),
314 ] {
315 execute_checked(executor, command)?;
316 }
317 }
318 targets::TargetLocator::LocalPodman { container_id, .. }
319 | targets::TargetLocator::LocalDocker { container_id }
320 | targets::TargetLocator::AppleContainer { container_id } => {
321 let engine = match locator {
322 targets::TargetLocator::LocalPodman { .. } => "podman",
323 targets::TargetLocator::LocalDocker { .. } => "docker",
324 targets::TargetLocator::AppleContainer { .. } => "container",
325 _ => unreachable!("matched local container target"),
326 };
327 for arguments in [
328 vec![
329 "exec".to_owned(),
330 container_id.clone(),
331 "rm".to_owned(),
332 "-rf".to_owned(),
333 "--".to_owned(),
334 home.clone(),
335 ],
336 vec![
337 "exec".to_owned(),
338 container_id.clone(),
339 "mkdir".to_owned(),
340 "-p".to_owned(),
341 home.clone(),
342 ],
343 vec![
344 "cp".to_owned(),
345 format!("{}/.", local.display()),
346 format!("{container_id}:{home}"),
347 ],
348 container_upload_ownership_args(container_id, worker_root, &[&home]),
349 vec![
350 "exec".to_owned(),
351 container_id.clone(),
352 "chmod".to_owned(),
353 "-R".to_owned(),
354 "go-rwx".to_owned(),
355 home.clone(),
356 ],
357 ] {
358 execute_checked(
359 executor,
360 CommandSpec::new(engine, arguments).purpose("stage the reviewer profile"),
361 )?;
362 }
363 }
364 targets::TargetLocator::AwsEc2 { ssh, .. }
365 | targets::TargetLocator::SshBare { ssh, .. } => {
366 let incoming = format!("{home}.incoming");
367 execute_checked(
368 executor,
369 ssh_command_spec(ssh, ["mkdir", "-p", worker_root])
370 .purpose("create the reviewer directory"),
371 )?;
372 execute_checked(
373 executor,
374 ssh_command_spec(ssh, ["rm", "-rf", "--", &incoming, &home])
375 .purpose("clear the reviewer profile"),
376 )?;
377 execute_checked(
378 executor,
379 scp_command_spec(ssh, local, &incoming, true)
380 .purpose("upload the reviewer profile"),
381 )?;
382 execute_checked(
383 executor,
384 ssh_command_spec(ssh, ["mv", &incoming, &home])
385 .purpose("install the reviewer profile"),
386 )?;
387 execute_checked(
388 executor,
389 ssh_command_spec(ssh, ["chmod", "-R", "go-rwx", &home])
390 .purpose("restrict reviewer profile permissions"),
391 )?;
392 }
393 targets::TargetLocator::SshPodman {
394 ssh, container_id, ..
395 }
396 | targets::TargetLocator::SshDocker { ssh, container_id } => {
397 let engine = match locator {
398 targets::TargetLocator::SshPodman { .. } => "podman",
399 targets::TargetLocator::SshDocker { .. } => "docker",
400 _ => unreachable!("matched remote container target"),
401 };
402 let upload = format!("{worker_root}/.reviewer-upload-{generation}");
403 execute_checked(
404 executor,
405 ssh_command_spec(ssh, ["rm", "-rf", "--", &upload])
406 .purpose("clear remote reviewer staging"),
407 )?;
408 execute_checked(
409 executor,
410 scp_command_spec(ssh, local, &upload, true)
411 .purpose("upload the remote reviewer profile"),
412 )?;
413 for arguments in [
414 vec![
415 engine.to_owned(),
416 "exec".to_owned(),
417 container_id.clone(),
418 "rm".to_owned(),
419 "-rf".to_owned(),
420 "--".to_owned(),
421 home.clone(),
422 ],
423 vec![
424 engine.to_owned(),
425 "exec".to_owned(),
426 container_id.clone(),
427 "mkdir".to_owned(),
428 "-p".to_owned(),
429 home.clone(),
430 ],
431 vec![
432 engine.to_owned(),
433 "cp".to_owned(),
434 format!("{upload}/."),
435 format!("{container_id}:{home}"),
436 ],
437 std::iter::once(engine.to_owned())
438 .chain(container_upload_ownership_args(
439 container_id,
440 worker_root,
441 &[&home],
442 ))
443 .collect(),
444 vec![
445 engine.to_owned(),
446 "exec".to_owned(),
447 container_id.clone(),
448 "chmod".to_owned(),
449 "-R".to_owned(),
450 "go-rwx".to_owned(),
451 home.clone(),
452 ],
453 ] {
454 execute_checked(
455 executor,
456 ssh_command_spec(ssh, arguments).purpose("stage the remote reviewer profile"),
457 )?;
458 }
459 execute_checked(
460 executor,
461 ssh_command_spec(ssh, ["rm", "-rf", "--", &upload])
462 .purpose("remove remote reviewer staging"),
463 )?;
464 }
465 }
466 if home.trim().is_empty() {
467 bail!("the reviewer profile home resolved to an empty path");
468 }
469 Ok(())
470}
471
472#[cfg(test)]
473mod tests {
474 use std::cell::RefCell;
475 use std::collections::BTreeMap;
476
477 use super::*;
478 use crate::controller::test_support::checkpoint_test_session;
479 use mj_core::config::{Config, HarnessKind, HarnessProfile, TargetTemplate};
480 use mj_core::state::{SessionState, State};
481
482 use crate::targets::CommandOutput;
483
484 struct RecordingExecutor {
485 commands: RefCell<Vec<CommandSpec>>,
486 }
487
488 impl RecordingExecutor {
489 fn new() -> Self {
490 Self {
491 commands: RefCell::new(Vec::new()),
492 }
493 }
494
495 fn script(&self) -> Vec<String> {
497 self.commands
498 .borrow()
499 .iter()
500 .map(|command| format!("{} {}", command.program, command.args.join(" ")))
501 .collect()
502 }
503 }
504
505 impl CommandExecutor for RecordingExecutor {
506 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
507 self.commands.borrow_mut().push(command.clone());
508 Ok(CommandOutput {
509 status: 0,
510 stdout: Vec::new(),
511 stderr: Vec::new(),
512 })
513 }
514 }
515
516 const SESSION_ID: &str = "0123456789abcdef0123456789abcdef";
519
520 fn fixture(directory: &Path, locator: mj_core::state::TargetLocator) -> (Controller, String) {
521 let session_id = SESSION_ID;
522 let mut session = checkpoint_test_session(session_id);
523 session.target_template_id = "local".into();
524 session.state = SessionState::Running;
525 session.target = Some(locator);
526 let mut config = Config::default();
527 config
528 .targets
529 .insert("local".into(), TargetTemplate::LocalBare);
530 for (id, kind) in [
531 ("codex", HarnessKind::Codex),
532 ("claude", HarnessKind::Claude),
533 ] {
534 let home = directory.join(id);
535 std::fs::create_dir_all(&home).unwrap();
536 config.profiles.insert(
537 id.to_owned(),
538 HarnessProfile {
539 enabled: true,
540 kind,
541 home,
542 environment: BTreeMap::from([("EXTRA".into(), "1".into())]),
543 context_window_bytes: None,
544 },
545 );
546 }
547 (
548 Controller {
549 config,
550 state: State {
551 sessions: BTreeMap::from([(session_id.into(), session)]),
552 ..State::default()
553 },
554 },
555 session_id.to_owned(),
556 )
557 }
558
559 #[test]
560 fn staging_copies_the_chosen_profile_into_the_worker_root() {
561 let directory = tempfile::tempdir().unwrap();
562 let worker_root = directory.path().join(SESSION_ID);
563 std::fs::create_dir_all(directory.path().join("claude")).unwrap();
564 std::fs::write(directory.path().join("claude/CLAUDE.md"), b"reviewer").unwrap();
566 let (controller, session_id) = fixture(
567 directory.path(),
568 mj_core::state::TargetLocator::LocalBare {
569 worker_root: worker_root.clone(),
570 },
571 );
572 let executor = RecordingExecutor::new();
573
574 let config = controller
575 .stage_reviewer_profile_controlled(&session_id, "claude", 0, &[], &executor)
576 .unwrap();
577
578 assert_eq!(config.profile_id, "claude");
579 assert_eq!(config.harness, HarnessKind::Claude);
580 assert_eq!(config.generation, 0);
581 assert_eq!(config.model, None);
582 assert_eq!(config.effort, None);
583 assert!(
585 !config
586 .environment
587 .contains_key(HarnessKind::Claude.home_env())
588 );
589 assert_eq!(
590 config.environment.get("EXTRA").map(String::as_str),
591 Some("1")
592 );
593
594 let home = format!("{}/reviewer/profile", worker_root.display());
595 let script = executor.script();
596 let cleared = script
597 .iter()
598 .position(|line| line.starts_with("rm ") && line.contains(&home))
599 .expect("the previous reviewer profile is cleared");
600 let copied = script
601 .iter()
602 .position(|line| line.starts_with("cp ") && line.ends_with(&home))
603 .expect("the staged profile is installed");
604 assert!(
605 cleared < copied,
606 "a stale profile must go before the new one lands: {script:?}"
607 );
608 assert!(
609 script
610 .iter()
611 .any(|line| line.contains("go-rwx") && line.contains(&home)),
612 "the reviewer profile must not be world readable: {script:?}"
613 );
614 }
615
616 #[test]
617 fn local_container_targets_stage_the_reviewer_through_their_engine() {
618 let directory = tempfile::tempdir().unwrap();
619 let container_id = crate::targets::resource_name(SESSION_ID).unwrap();
620 for (locator, engine) in [
621 (
622 mj_core::state::TargetLocator::LocalPodman {
623 container_id: container_id.clone(),
624 workspace_storage: Default::default(),
625 },
626 "podman",
627 ),
628 (
629 mj_core::state::TargetLocator::LocalDocker {
630 container_id: container_id.clone(),
631 },
632 "docker",
633 ),
634 ] {
635 let (controller, session_id) = fixture(directory.path(), locator);
636 let executor = RecordingExecutor::new();
637
638 controller
639 .stage_reviewer_profile_controlled(&session_id, "codex", 3, &[], &executor)
640 .unwrap();
641
642 let script = executor.script();
643 assert!(
644 script
645 .iter()
646 .all(|line| line.starts_with(&format!("{engine} "))),
647 "a container target is reached only through its engine: {script:?}"
648 );
649 let home = script
650 .iter()
651 .find_map(|line| {
652 line.split(' ')
653 .find(|word| word.contains("/reviewer/profile"))
654 })
655 .expect("the reviewer profile is placed")
656 .to_owned();
657 assert!(
658 home.contains(&format!("/{session_id}")),
659 "the reviewer lives under this session's worker root: {home}"
660 );
661 assert!(
663 !script.iter().any(|line| {
664 line.contains("run") || line.contains("git") || line.contains("create")
665 }),
666 "staging a reviewer provisions nothing: {script:?}"
667 );
668 }
669 }
670
671 #[test]
672 fn an_unknown_profile_is_refused_before_anything_is_copied() {
673 let directory = tempfile::tempdir().unwrap();
674 let (controller, session_id) = fixture(
675 directory.path(),
676 mj_core::state::TargetLocator::LocalBare {
677 worker_root: directory.path().join(SESSION_ID),
678 },
679 );
680 let executor = RecordingExecutor::new();
681
682 let error = controller
683 .stage_reviewer_profile_controlled(&session_id, "missing", 0, &[], &executor)
684 .unwrap_err();
685
686 assert!(format!("{error:#}").contains("unknown profile"));
687 assert!(executor.commands.borrow().is_empty());
688 }
689
690 #[test]
691 fn a_new_generation_travels_to_the_worker_so_it_starts_a_fresh_reviewer() {
692 let directory = tempfile::tempdir().unwrap();
693 let (controller, session_id) = fixture(
694 directory.path(),
695 mj_core::state::TargetLocator::LocalBare {
696 worker_root: directory.path().join(SESSION_ID),
697 },
698 );
699 let executor = RecordingExecutor::new();
700
701 let first = controller
702 .stage_reviewer_profile_controlled(&session_id, "codex", 0, &[], &executor)
703 .unwrap();
704 let second = controller
705 .stage_reviewer_profile_controlled(&session_id, "codex", 1, &[], &executor)
706 .unwrap();
707
708 assert!(first.reusable_for(&first));
709 assert!(
710 !first.reusable_for(&second),
711 "a new generation must not reload the old conversation"
712 );
713 }
714}