1use 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 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 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 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 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 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
144fn 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
214fn 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
235fn reviewer_profile_home(worker_root: &str) -> String {
237 format!("{worker_root}/{REVIEWER_DIR}/{REVIEWER_PROFILE_DIR}")
238}
239
240fn 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 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 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 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 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 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}