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