1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7use crate::errors::{ErrorCode, TrackError};
8use crate::paths::{
9 collapse_home_path, get_config_path, get_managed_remote_agent_key_path,
10 get_managed_remote_agent_known_hosts_path, resolve_path_from_config_file,
11};
12use crate::types::{
13 ApiRuntimeConfig, LlamaCppModelSource, LlamaCppRuntimeConfig, RemoteAgentPreferredTool,
14 RemoteAgentReviewFollowUpRuntimeConfig, RemoteAgentRuntimeConfig, TrackRuntimeConfig,
15};
16
17pub const DEFAULT_API_PORT: u16 = 3210;
18pub const DEFAULT_REMOTE_AGENT_PORT: u16 = 22;
19pub const DEFAULT_REMOTE_AGENT_WORKSPACE_ROOT: &str = "~/workspace";
20pub const DEFAULT_REMOTE_PROJECTS_REGISTRY_PATH: &str = "~/track-projects.json";
21pub const DEFAULT_LLAMACPP_MODEL_HF_REPO: &str = "bartowski/Meta-Llama-3-8B-Instruct-GGUF";
22pub const DEFAULT_LLAMACPP_MODEL_HF_FILE: &str = "Meta-Llama-3-8B-Instruct-Q4_K_M.gguf";
23
24#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
32pub struct TrackConfigFile {
33 #[serde(rename = "projectRoots", default)]
34 pub project_roots: Vec<String>,
35 #[serde(rename = "projectAliases", default)]
36 pub project_aliases: BTreeMap<String, String>,
37 #[serde(default)]
38 pub api: ApiConfigFile,
39 #[serde(
40 rename = "llamaCpp",
41 default,
42 skip_serializing_if = "LlamaCppConfigFile::is_empty"
43 )]
44 pub llama_cpp: LlamaCppConfigFile,
45 #[serde(
46 rename = "remoteAgent",
47 default,
48 skip_serializing_if = "Option::is_none"
49 )]
50 pub remote_agent: Option<RemoteAgentConfigFile>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct ApiConfigFile {
55 #[serde(default = "default_api_port")]
56 pub port: u16,
57}
58
59impl Default for ApiConfigFile {
60 fn default() -> Self {
61 Self {
62 port: default_api_port(),
63 }
64 }
65}
66
67#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
68pub struct LlamaCppConfigFile {
69 #[serde(rename = "modelPath", default, skip_serializing_if = "Option::is_none")]
70 pub model_path: Option<String>,
71 #[serde(
72 rename = "modelHfRepo",
73 default,
74 skip_serializing_if = "Option::is_none"
75 )]
76 pub model_hf_repo: Option<String>,
77 #[serde(
78 rename = "modelHfFile",
79 default,
80 skip_serializing_if = "Option::is_none"
81 )]
82 pub model_hf_file: Option<String>,
83}
84
85impl LlamaCppConfigFile {
86 fn is_empty(&self) -> bool {
87 self.model_path.is_none() && self.model_hf_repo.is_none() && self.model_hf_file.is_none()
88 }
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct RemoteAgentConfigFile {
93 pub host: String,
94 pub user: String,
95 #[serde(default = "default_remote_agent_port")]
96 pub port: u16,
97 #[serde(
98 rename = "workspaceRoot",
99 default = "default_remote_agent_workspace_root"
100 )]
101 pub workspace_root: String,
102 #[serde(
103 rename = "projectsRegistryPath",
104 default = "default_remote_projects_registry_path"
105 )]
106 pub projects_registry_path: String,
107 #[serde(
108 rename = "preferredTool",
109 default,
110 skip_serializing_if = "RemoteAgentPreferredTool::is_codex"
111 )]
112 pub preferred_tool: RemoteAgentPreferredTool,
113 #[serde(
114 rename = "shellPrelude",
115 default,
116 skip_serializing_if = "Option::is_none"
117 )]
118 pub shell_prelude: Option<String>,
119 #[serde(
120 rename = "reviewFollowUp",
121 default,
122 skip_serializing_if = "Option::is_none"
123 )]
124 pub review_follow_up: Option<RemoteAgentReviewFollowUpConfigFile>,
125}
126
127#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
128pub struct RemoteAgentReviewFollowUpConfigFile {
129 #[serde(default)]
130 pub enabled: bool,
131 #[serde(rename = "mainUser", default, skip_serializing_if = "Option::is_none")]
132 pub main_user: Option<String>,
133 #[serde(
134 rename = "defaultReviewPrompt",
135 default,
136 skip_serializing_if = "Option::is_none"
137 )]
138 pub default_review_prompt: Option<String>,
139}
140
141fn default_api_port() -> u16 {
142 DEFAULT_API_PORT
143}
144
145fn default_remote_agent_port() -> u16 {
146 DEFAULT_REMOTE_AGENT_PORT
147}
148
149fn default_remote_agent_workspace_root() -> String {
150 DEFAULT_REMOTE_AGENT_WORKSPACE_ROOT.to_owned()
151}
152
153fn default_remote_projects_registry_path() -> String {
154 DEFAULT_REMOTE_PROJECTS_REGISTRY_PATH.to_owned()
155}
156
157fn default_llama_cpp_model_source() -> LlamaCppModelSource {
158 LlamaCppModelSource::HuggingFace {
159 repo: DEFAULT_LLAMACPP_MODEL_HF_REPO.to_owned(),
160 file: DEFAULT_LLAMACPP_MODEL_HF_FILE.to_owned(),
161 }
162}
163
164pub(crate) fn canonicalize_optional_multiline_value(value: Option<String>) -> Option<String> {
165 value
166 .map(|value| value.replace("\r\n", "\n").trim().to_owned())
167 .filter(|value| !value.is_empty())
168}
169
170pub(crate) fn canonicalize_remote_agent_config(
171 remote_agent: RemoteAgentConfigFile,
172) -> Result<RemoteAgentConfigFile, TrackError> {
173 let host = remote_agent.host.trim().to_owned();
174 let user = remote_agent.user.trim().to_owned();
175 let workspace_root = remote_agent.workspace_root.trim().to_owned();
176 let projects_registry_path = remote_agent.projects_registry_path.trim().to_owned();
177 let shell_prelude = canonicalize_optional_multiline_value(remote_agent.shell_prelude);
178 let review_follow_up = remote_agent
179 .review_follow_up
180 .map(|review_follow_up| {
181 let main_user = review_follow_up
182 .main_user
183 .map(|value| value.trim().to_owned())
184 .filter(|value| !value.is_empty());
185 let default_review_prompt =
186 canonicalize_optional_multiline_value(review_follow_up.default_review_prompt);
187
188 if review_follow_up.enabled && main_user.is_none() {
189 return Err(TrackError::new(
190 ErrorCode::InvalidRemoteAgentConfig,
191 "Remote review follow-up requires `mainUser` when the feature is enabled.",
192 ));
193 }
194
195 if !review_follow_up.enabled && main_user.is_none() && default_review_prompt.is_none() {
196 return Ok(None);
197 }
198
199 Ok(Some(RemoteAgentReviewFollowUpConfigFile {
200 enabled: review_follow_up.enabled,
201 main_user,
202 default_review_prompt,
203 }))
204 })
205 .transpose()?
206 .flatten();
207
208 if host.is_empty()
209 || user.is_empty()
210 || workspace_root.is_empty()
211 || projects_registry_path.is_empty()
212 || remote_agent.port == 0
213 {
214 return Err(TrackError::new(
215 ErrorCode::InvalidRemoteAgentConfig,
216 "Remote agent config requires host, user, workspace root, projects registry path, and a valid SSH port.",
217 ));
218 }
219
220 Ok(RemoteAgentConfigFile {
221 host,
222 user,
223 port: remote_agent.port,
224 workspace_root,
225 projects_registry_path,
226 preferred_tool: remote_agent.preferred_tool,
227 shell_prelude,
228 review_follow_up,
229 })
230}
231
232fn canonicalize_config_file(config: TrackConfigFile) -> Result<TrackConfigFile, TrackError> {
233 let project_roots = config
234 .project_roots
235 .into_iter()
236 .map(|value| value.trim().to_owned())
237 .filter(|value| !value.is_empty())
238 .collect::<Vec<_>>();
239
240 let project_aliases = config
241 .project_aliases
242 .into_iter()
243 .map(|(alias, canonical_name)| (alias.trim().to_owned(), canonical_name.trim().to_owned()))
244 .filter(|(alias, canonical_name)| !alias.is_empty() && !canonical_name.is_empty())
245 .collect::<BTreeMap<_, _>>();
246
247 let model_path = config
248 .llama_cpp
249 .model_path
250 .map(|value| value.trim().to_owned())
251 .filter(|value| !value.is_empty());
252 let model_hf_repo = config
253 .llama_cpp
254 .model_hf_repo
255 .map(|value| value.trim().to_owned())
256 .filter(|value| !value.is_empty());
257 let model_hf_file = config
258 .llama_cpp
259 .model_hf_file
260 .map(|value| value.trim().to_owned())
261 .filter(|value| !value.is_empty());
262 if model_hf_repo.is_some() != model_hf_file.is_some() {
263 return Err(TrackError::new(
264 ErrorCode::InvalidConfig,
265 "Config file requires both `llamaCpp.modelHfRepo` and `llamaCpp.modelHfFile` when using a Hugging Face model.",
266 ));
267 }
268
269 let api_port = config.api.port;
270 if api_port == 0 {
271 return Err(TrackError::new(
272 ErrorCode::InvalidConfig,
273 "Config file does not match the expected format.",
274 ));
275 }
276
277 let remote_agent = config
278 .remote_agent
279 .map(canonicalize_remote_agent_config)
280 .transpose()?;
281
282 Ok(TrackConfigFile {
283 project_roots,
284 project_aliases,
285 api: ApiConfigFile { port: api_port },
286 llama_cpp: LlamaCppConfigFile {
287 model_path,
288 model_hf_repo,
289 model_hf_file,
290 },
291 remote_agent,
292 })
293}
294
295pub struct ConfigService {
296 config_path: PathBuf,
297}
298
299impl ConfigService {
300 pub fn new(config_path: Option<PathBuf>) -> Result<Self, TrackError> {
301 Ok(Self {
302 config_path: match config_path {
303 Some(path) => path,
304 None => get_config_path()?,
305 },
306 })
307 }
308
309 pub fn resolved_path(&self) -> &Path {
310 &self.config_path
311 }
312
313 pub fn load_config_file(&self) -> Result<TrackConfigFile, TrackError> {
314 let raw_config = fs::read_to_string(&self.config_path).map_err(|error| {
315 if error.kind() == std::io::ErrorKind::NotFound {
316 return TrackError::new(
317 ErrorCode::ConfigNotFound,
318 format!(
319 "Config file not found at {}",
320 collapse_home_path(&self.config_path)
321 ),
322 );
323 }
324
325 TrackError::new(
326 ErrorCode::InvalidConfig,
327 format!("Could not read the track config file: {error}"),
328 )
329 })?;
330
331 let parsed = serde_json::from_str::<TrackConfigFile>(&raw_config).map_err(|error| {
332 TrackError::new(
333 ErrorCode::InvalidConfig,
334 format!("Config file is not valid JSON: {error}"),
335 )
336 })?;
337
338 canonicalize_config_file(parsed)
339 }
340
341 pub fn save_config_file(&self, config: &TrackConfigFile) -> Result<(), TrackError> {
342 let canonical = canonicalize_config_file(config.clone())?;
343 let serialized = serde_json::to_string_pretty(&canonical).map_err(|error| {
344 TrackError::new(
345 ErrorCode::InvalidConfig,
346 format!("Could not serialize the track config file: {error}"),
347 )
348 })?;
349
350 if let Some(parent) = self.config_path.parent() {
351 fs::create_dir_all(parent).map_err(|error| {
352 TrackError::new(
353 ErrorCode::InvalidConfig,
354 format!(
355 "Could not create the config directory for {}: {error}",
356 collapse_home_path(&self.config_path)
357 ),
358 )
359 })?;
360 }
361
362 fs::write(&self.config_path, format!("{serialized}\n")).map_err(|error| {
363 TrackError::new(
364 ErrorCode::InvalidConfig,
365 format!(
366 "Could not write the track config file at {}: {error}",
367 collapse_home_path(&self.config_path)
368 ),
369 )
370 })
371 }
372
373 pub fn load_runtime_config(&self) -> Result<TrackRuntimeConfig, TrackError> {
374 let config = self.load_config_file()?;
375
376 let project_roots = config
380 .project_roots
381 .iter()
382 .map(|value| resolve_path_from_config_file(value, &self.config_path))
383 .collect::<Result<Vec<_>, _>>()?;
384
385 let project_aliases = config.project_aliases;
386 let model_source = if let (Some(repo), Some(file)) = (
387 config.llama_cpp.model_hf_repo.clone(),
388 config.llama_cpp.model_hf_file.clone(),
389 ) {
390 LlamaCppModelSource::HuggingFace { repo, file }
391 } else if let Some(model_path) = config.llama_cpp.model_path.as_deref() {
392 LlamaCppModelSource::LocalPath(resolve_path_from_config_file(
393 model_path,
394 &self.config_path,
395 )?)
396 } else {
397 default_llama_cpp_model_source()
398 };
399 let remote_agent = config
400 .remote_agent
401 .map(|remote_agent| {
402 Ok(RemoteAgentRuntimeConfig {
403 host: remote_agent.host,
404 user: remote_agent.user,
405 port: remote_agent.port,
406 workspace_root: remote_agent.workspace_root,
407 projects_registry_path: remote_agent.projects_registry_path,
408 preferred_tool: remote_agent.preferred_tool,
409 shell_prelude: remote_agent.shell_prelude,
410 review_follow_up: remote_agent.review_follow_up.and_then(|review_follow_up| {
411 review_follow_up.main_user.map(|main_user| {
412 RemoteAgentReviewFollowUpRuntimeConfig {
413 enabled: review_follow_up.enabled,
414 main_user,
415 default_review_prompt: review_follow_up.default_review_prompt,
416 }
417 })
418 }),
419 managed_key_path: get_managed_remote_agent_key_path()?,
420 managed_known_hosts_path: get_managed_remote_agent_known_hosts_path()?,
421 })
422 })
423 .transpose()?;
424
425 Ok(TrackRuntimeConfig {
426 project_roots,
427 project_aliases,
428 api: ApiRuntimeConfig {
429 port: config.api.port,
430 },
431 llama_cpp: LlamaCppRuntimeConfig { model_source },
432 remote_agent,
433 })
434 }
435
436 pub fn load_remote_agent_config(&self) -> Result<Option<RemoteAgentConfigFile>, TrackError> {
437 Ok(self.load_config_file()?.remote_agent)
438 }
439
440 pub fn save_remote_agent_settings(
441 &self,
442 preferred_tool: RemoteAgentPreferredTool,
443 shell_prelude: Option<String>,
444 review_follow_up: Option<RemoteAgentReviewFollowUpConfigFile>,
445 ) -> Result<RemoteAgentConfigFile, TrackError> {
446 let mut config = self.load_config_file()?;
447 let Some(remote_agent) = config.remote_agent.as_mut() else {
448 return Err(TrackError::new(
449 ErrorCode::RemoteAgentNotConfigured,
450 "Remote dispatch is not configured yet. Re-run `track` and add a remote agent host plus SSH key.",
451 ));
452 };
453
454 remote_agent.preferred_tool = preferred_tool;
455 remote_agent.shell_prelude = canonicalize_optional_multiline_value(shell_prelude);
456 remote_agent.review_follow_up = review_follow_up;
457 self.save_config_file(&config)?;
458
459 self.load_config_file()?
460 .remote_agent
461 .ok_or_else(|| {
462 TrackError::new(
463 ErrorCode::RemoteAgentNotConfigured,
464 "Remote dispatch is not configured yet. Re-run `track` and add a remote agent host plus SSH key.",
465 )
466 })
467 }
468}
469
470#[cfg(test)]
471mod tests {
472 use std::collections::BTreeMap;
473 use std::fs;
474
475 use tempfile::TempDir;
476
477 use super::{
478 default_llama_cpp_model_source, ConfigService, RemoteAgentConfigFile,
479 RemoteAgentReviewFollowUpConfigFile, TrackConfigFile, DEFAULT_API_PORT,
480 DEFAULT_LLAMACPP_MODEL_HF_FILE, DEFAULT_LLAMACPP_MODEL_HF_REPO, DEFAULT_REMOTE_AGENT_PORT,
481 DEFAULT_REMOTE_AGENT_WORKSPACE_ROOT, DEFAULT_REMOTE_PROJECTS_REGISTRY_PATH,
482 };
483 use crate::errors::ErrorCode;
484 use crate::types::LlamaCppModelSource;
485 use crate::types::RemoteAgentPreferredTool;
486
487 fn temp_config_service() -> (TempDir, ConfigService) {
488 let directory = TempDir::new().expect("tempdir should be created");
489 let config_path = directory.path().join("config.json");
490 let service = ConfigService::new(Some(config_path)).expect("config service should resolve");
491 (directory, service)
492 }
493
494 #[test]
495 fn saves_current_local_only_shape() {
496 let (_directory, service) = temp_config_service();
497
498 service
499 .save_config_file(&TrackConfigFile {
500 project_roots: vec!["~/work".to_owned()],
501 project_aliases: BTreeMap::new(),
502 api: super::ApiConfigFile {
503 port: DEFAULT_API_PORT,
504 },
505 llama_cpp: super::LlamaCppConfigFile {
506 model_path: Some("~/.models/parser.gguf".to_owned()),
507 model_hf_repo: None,
508 model_hf_file: None,
509 },
510 remote_agent: Some(RemoteAgentConfigFile {
511 host: "192.0.2.25".to_owned(),
512 user: "builder".to_owned(),
513 port: DEFAULT_REMOTE_AGENT_PORT,
514 workspace_root: DEFAULT_REMOTE_AGENT_WORKSPACE_ROOT.to_owned(),
515 projects_registry_path: DEFAULT_REMOTE_PROJECTS_REGISTRY_PATH.to_owned(),
516 preferred_tool: RemoteAgentPreferredTool::Codex,
517 shell_prelude: Some("export PATH=\"$PATH:/opt/tools/bin\"".to_owned()),
518 review_follow_up: None,
519 }),
520 })
521 .expect("config should save");
522
523 let raw =
524 fs::read_to_string(service.resolved_path()).expect("saved config should be readable");
525 assert!(raw.contains("\"llamaCpp\""));
526 assert!(raw.contains("\"remoteAgent\""));
527 assert!(raw.contains("\"shellPrelude\""));
528 assert!(!raw.contains("\"preferredTool\""));
529 assert!(!raw.contains("\"modelHfRepo\""));
530 assert!(!raw.contains("\"ai\""));
531 }
532
533 #[test]
534 fn omits_llama_cpp_block_when_no_manual_override_is_configured() {
535 let (_directory, service) = temp_config_service();
536
537 service
538 .save_config_file(&TrackConfigFile {
539 project_roots: vec!["~/work".to_owned()],
540 project_aliases: BTreeMap::new(),
541 api: super::ApiConfigFile {
542 port: DEFAULT_API_PORT,
543 },
544 llama_cpp: super::LlamaCppConfigFile::default(),
545 remote_agent: None,
546 })
547 .expect("config should save");
548
549 let raw =
550 fs::read_to_string(service.resolved_path()).expect("saved config should be readable");
551 assert!(!raw.contains("\"llamaCpp\""));
552 }
553
554 #[test]
555 fn resolves_relative_runtime_paths_from_the_config_file_location() {
556 let directory = TempDir::new().expect("tempdir should be created");
557 let config_path = directory.path().join(".config/track/config.json");
558 let service =
559 ConfigService::new(Some(config_path.clone())).expect("config service should resolve");
560
561 service
562 .save_config_file(&TrackConfigFile {
563 project_roots: vec!["../work".to_owned()],
564 project_aliases: BTreeMap::new(),
565 api: super::ApiConfigFile { port: 4210 },
566 llama_cpp: super::LlamaCppConfigFile {
567 model_path: Some("./models/parser.gguf".to_owned()),
568 model_hf_repo: None,
569 model_hf_file: None,
570 },
571 remote_agent: Some(RemoteAgentConfigFile {
572 host: "192.0.2.25".to_owned(),
573 user: "builder".to_owned(),
574 port: 2222,
575 workspace_root: "~/workspace".to_owned(),
576 projects_registry_path: "~/track-projects.json".to_owned(),
577 preferred_tool: RemoteAgentPreferredTool::Codex,
578 shell_prelude: Some("export PATH=\"$PATH:/opt/tools/bin\"".to_owned()),
579 review_follow_up: None,
580 }),
581 })
582 .expect("config should save");
583
584 let runtime = service
585 .load_runtime_config()
586 .expect("runtime config should resolve");
587 let config_directory = config_path
588 .parent()
589 .expect("config path should have a parent");
590
591 assert_eq!(
592 runtime.project_roots,
593 vec![config_directory.join("../work")]
594 );
595 assert_eq!(runtime.api.port, 4210);
596 assert_eq!(
597 runtime.llama_cpp.model_source,
598 LlamaCppModelSource::LocalPath(config_directory.join("./models/parser.gguf"))
599 );
600 let remote_agent = runtime
601 .remote_agent
602 .expect("remote agent runtime config should resolve");
603 assert_eq!(remote_agent.host, "192.0.2.25");
604 assert_eq!(remote_agent.user, "builder");
605 assert_eq!(remote_agent.port, 2222);
606 assert_eq!(remote_agent.preferred_tool, RemoteAgentPreferredTool::Codex);
607 assert_eq!(
608 remote_agent.shell_prelude,
609 Some("export PATH=\"$PATH:/opt/tools/bin\"".to_owned())
610 );
611 }
612
613 #[test]
614 fn prefers_hugging_face_model_when_both_sources_are_configured() {
615 let (_directory, service) = temp_config_service();
616
617 service
618 .save_config_file(&TrackConfigFile {
619 project_roots: vec!["~/work".to_owned()],
620 project_aliases: BTreeMap::new(),
621 api: super::ApiConfigFile {
622 port: DEFAULT_API_PORT,
623 },
624 llama_cpp: super::LlamaCppConfigFile {
625 model_path: Some("~/.models/custom-parser.gguf".to_owned()),
626 model_hf_repo: Some(DEFAULT_LLAMACPP_MODEL_HF_REPO.to_owned()),
627 model_hf_file: Some(DEFAULT_LLAMACPP_MODEL_HF_FILE.to_owned()),
628 },
629 remote_agent: None,
630 })
631 .expect("config should save");
632
633 let runtime = service
634 .load_runtime_config()
635 .expect("runtime config should resolve");
636
637 assert_eq!(
638 runtime.llama_cpp.model_source,
639 LlamaCppModelSource::HuggingFace {
640 repo: DEFAULT_LLAMACPP_MODEL_HF_REPO.to_owned(),
641 file: DEFAULT_LLAMACPP_MODEL_HF_FILE.to_owned(),
642 }
643 );
644 }
645
646 #[test]
647 fn defaults_to_the_builtin_hugging_face_model_when_no_override_is_configured() {
648 let (_directory, service) = temp_config_service();
649
650 service
651 .save_config_file(&TrackConfigFile {
652 project_roots: vec!["~/work".to_owned()],
653 project_aliases: BTreeMap::new(),
654 api: super::ApiConfigFile {
655 port: DEFAULT_API_PORT,
656 },
657 llama_cpp: super::LlamaCppConfigFile::default(),
658 remote_agent: None,
659 })
660 .expect("config should save");
661
662 let runtime = service
663 .load_runtime_config()
664 .expect("runtime config should resolve");
665
666 assert_eq!(
667 runtime.llama_cpp.model_source,
668 default_llama_cpp_model_source()
669 );
670 }
671
672 #[test]
673 fn rejects_enabled_review_follow_up_without_a_main_user() {
674 let (_directory, service) = temp_config_service();
675
676 let error = service
677 .save_config_file(&TrackConfigFile {
678 project_roots: vec!["~/work".to_owned()],
679 project_aliases: BTreeMap::new(),
680 api: super::ApiConfigFile {
681 port: DEFAULT_API_PORT,
682 },
683 llama_cpp: super::LlamaCppConfigFile::default(),
684 remote_agent: Some(RemoteAgentConfigFile {
685 host: "192.0.2.25".to_owned(),
686 user: "builder".to_owned(),
687 port: DEFAULT_REMOTE_AGENT_PORT,
688 workspace_root: DEFAULT_REMOTE_AGENT_WORKSPACE_ROOT.to_owned(),
689 projects_registry_path: DEFAULT_REMOTE_PROJECTS_REGISTRY_PATH.to_owned(),
690 preferred_tool: RemoteAgentPreferredTool::Codex,
691 shell_prelude: Some("export PATH=\"$PATH:/opt/tools/bin\"".to_owned()),
692 review_follow_up: Some(RemoteAgentReviewFollowUpConfigFile {
693 enabled: true,
694 main_user: None,
695 default_review_prompt: None,
696 }),
697 }),
698 })
699 .expect_err("enabled review follow-up without a main user should fail");
700
701 assert_eq!(error.code, ErrorCode::InvalidRemoteAgentConfig);
702 }
703}