1use std::fs;
2
3use crate::config::{
4 canonicalize_remote_agent_config, RemoteAgentConfigFile, RemoteAgentReviewFollowUpConfigFile,
5};
6use crate::errors::{ErrorCode, TrackError};
7use crate::migration::{MigrationStatus, MIGRATION_STATUS_SETTING_KEY};
8use crate::paths::{
9 collapse_home_path, get_backend_managed_remote_agent_key_path,
10 get_backend_managed_remote_agent_known_hosts_path,
11};
12use crate::settings_repository::SettingsRepository;
13use crate::types::{
14 RemoteAgentPreferredTool, RemoteAgentReviewFollowUpRuntimeConfig, RemoteAgentRuntimeConfig,
15};
16
17pub(crate) const REMOTE_AGENT_SETTING_KEY: &str = "remote_agent_config";
18
19#[derive(Debug, Clone)]
20pub struct BackendConfigRepository {
21 settings: SettingsRepository,
22}
23
24impl BackendConfigRepository {
25 pub fn new(settings: Option<SettingsRepository>) -> Result<Self, TrackError> {
26 let settings = match settings {
27 Some(settings) => settings,
28 None => SettingsRepository::new(None)?,
29 };
30
31 Ok(Self { settings })
32 }
33
34 pub fn load_remote_agent_config(&self) -> Result<Option<RemoteAgentConfigFile>, TrackError> {
35 self.settings.load_json(REMOTE_AGENT_SETTING_KEY)
36 }
37
38 pub fn save_remote_agent_config(
39 &self,
40 config: Option<&RemoteAgentConfigFile>,
41 ) -> Result<(), TrackError> {
42 match config {
43 Some(config) => {
44 let canonical = canonicalize_remote_agent_config(config.clone())?;
45 self.settings
46 .save_json(REMOTE_AGENT_SETTING_KEY, &canonical)
47 }
48 None => self.settings.delete(REMOTE_AGENT_SETTING_KEY),
49 }
50 }
51
52 pub fn replace_remote_agent_config(
53 &self,
54 config: RemoteAgentConfigFile,
55 ssh_private_key: &str,
56 known_hosts: Option<&str>,
57 ) -> Result<RemoteAgentConfigFile, TrackError> {
58 let canonical = canonicalize_remote_agent_config(config)?;
59 install_backend_remote_agent_secrets(ssh_private_key, known_hosts)?;
60 self.settings
61 .save_json(REMOTE_AGENT_SETTING_KEY, &canonical)?;
62 Ok(canonical)
63 }
64
65 pub fn save_remote_agent_settings(
66 &self,
67 preferred_tool: RemoteAgentPreferredTool,
68 shell_prelude: Option<String>,
69 review_follow_up: Option<RemoteAgentReviewFollowUpConfigFile>,
70 ) -> Result<RemoteAgentConfigFile, TrackError> {
71 let mut config = self.load_remote_agent_config()?.ok_or_else(|| {
72 TrackError::new(
73 ErrorCode::RemoteAgentNotConfigured,
74 "Remote dispatch is not configured yet. Import legacy data or register remote-agent settings first.",
75 )
76 })?;
77
78 config.preferred_tool = preferred_tool;
79 config.shell_prelude = shell_prelude
80 .map(|value| value.replace("\r\n", "\n").trim().to_owned())
81 .filter(|value| !value.is_empty());
82 config.review_follow_up = review_follow_up;
83 self.save_remote_agent_config(Some(&config))?;
84
85 Ok(config)
86 }
87
88 pub fn load_migration_status(&self) -> Result<MigrationStatus, TrackError> {
89 Ok(self
90 .settings
91 .load_json(MIGRATION_STATUS_SETTING_KEY)?
92 .unwrap_or_else(MigrationStatus::ready))
93 }
94
95 pub fn save_migration_status(&self, status: &MigrationStatus) -> Result<(), TrackError> {
96 self.settings
97 .save_json(MIGRATION_STATUS_SETTING_KEY, status)
98 }
99}
100
101#[derive(Debug, Clone)]
102pub struct RemoteAgentConfigService {
103 repository: BackendConfigRepository,
104}
105
106impl RemoteAgentConfigService {
107 pub fn new(repository: Option<BackendConfigRepository>) -> Result<Self, TrackError> {
108 let repository = match repository {
109 Some(repository) => repository,
110 None => BackendConfigRepository::new(None)?,
111 };
112
113 Ok(Self { repository })
114 }
115
116 pub fn load_remote_agent_config(&self) -> Result<Option<RemoteAgentConfigFile>, TrackError> {
117 self.repository.load_remote_agent_config()
118 }
119
120 pub fn save_remote_agent_config(
121 &self,
122 config: Option<&RemoteAgentConfigFile>,
123 ) -> Result<(), TrackError> {
124 self.repository.save_remote_agent_config(config)
125 }
126
127 pub fn replace_remote_agent_config(
128 &self,
129 config: RemoteAgentConfigFile,
130 ssh_private_key: &str,
131 known_hosts: Option<&str>,
132 ) -> Result<RemoteAgentConfigFile, TrackError> {
133 self.repository
134 .replace_remote_agent_config(config, ssh_private_key, known_hosts)
135 }
136
137 pub fn save_remote_agent_settings(
138 &self,
139 preferred_tool: RemoteAgentPreferredTool,
140 shell_prelude: Option<String>,
141 review_follow_up: Option<RemoteAgentReviewFollowUpConfigFile>,
142 ) -> Result<RemoteAgentConfigFile, TrackError> {
143 self.repository
144 .save_remote_agent_settings(preferred_tool, shell_prelude, review_follow_up)
145 }
146
147 pub fn load_remote_agent_runtime_config(
148 &self,
149 ) -> Result<Option<RemoteAgentRuntimeConfig>, TrackError> {
150 Ok(self
151 .load_remote_agent_config()?
152 .map(build_remote_agent_runtime_config)
153 .transpose()?)
154 }
155
156 pub fn load_migration_status(&self) -> Result<MigrationStatus, TrackError> {
157 self.repository.load_migration_status()
158 }
159
160 pub fn save_migration_status(&self, status: &MigrationStatus) -> Result<(), TrackError> {
161 self.repository.save_migration_status(status)
162 }
163}
164
165fn build_remote_agent_runtime_config(
166 config: RemoteAgentConfigFile,
167) -> Result<RemoteAgentRuntimeConfig, TrackError> {
168 Ok(RemoteAgentRuntimeConfig {
169 host: config.host,
170 user: config.user,
171 port: config.port,
172 workspace_root: config.workspace_root,
173 projects_registry_path: config.projects_registry_path,
174 preferred_tool: config.preferred_tool,
175 shell_prelude: config.shell_prelude,
176 review_follow_up: config.review_follow_up.and_then(|review_follow_up| {
177 review_follow_up
178 .main_user
179 .map(|main_user| RemoteAgentReviewFollowUpRuntimeConfig {
180 enabled: review_follow_up.enabled,
181 main_user,
182 default_review_prompt: review_follow_up.default_review_prompt,
183 })
184 }),
185 managed_key_path: get_backend_managed_remote_agent_key_path()?,
186 managed_known_hosts_path: get_backend_managed_remote_agent_known_hosts_path()?,
187 })
188}
189
190fn install_backend_remote_agent_secrets(
191 ssh_private_key: &str,
192 known_hosts: Option<&str>,
193) -> Result<(), TrackError> {
194 let managed_key_path = get_backend_managed_remote_agent_key_path()?;
195 let known_hosts_path = get_backend_managed_remote_agent_known_hosts_path()?;
196 let Some(parent_directory) = managed_key_path.parent() else {
197 return Err(TrackError::new(
198 ErrorCode::InvalidRemoteAgentConfig,
199 "Could not determine the backend remote-agent secrets directory.",
200 ));
201 };
202
203 fs::create_dir_all(parent_directory).map_err(|error| {
204 TrackError::new(
205 ErrorCode::InvalidRemoteAgentConfig,
206 format!(
207 "Could not create the backend remote-agent secrets directory at {}: {error}",
208 collapse_home_path(parent_directory)
209 ),
210 )
211 })?;
212
213 let normalized_private_key = ssh_private_key.replace("\r\n", "\n");
214 if normalized_private_key.trim().is_empty() {
215 return Err(TrackError::new(
216 ErrorCode::InvalidRemoteAgentConfig,
217 "Remote agent setup requires a non-empty SSH private key.",
218 ));
219 }
220
221 fs::write(&managed_key_path, normalized_private_key).map_err(|error| {
222 TrackError::new(
223 ErrorCode::InvalidRemoteAgentConfig,
224 format!(
225 "Could not write the managed SSH private key at {}: {error}",
226 collapse_home_path(&managed_key_path)
227 ),
228 )
229 })?;
230
231 #[cfg(unix)]
232 {
233 use std::os::unix::fs::PermissionsExt;
234
235 fs::set_permissions(&managed_key_path, fs::Permissions::from_mode(0o600)).map_err(
236 |error| {
237 TrackError::new(
238 ErrorCode::InvalidRemoteAgentConfig,
239 format!(
240 "Could not set permissions on the managed SSH private key at {}: {error}",
241 collapse_home_path(&managed_key_path)
242 ),
243 )
244 },
245 )?;
246 }
247
248 match known_hosts {
249 Some(known_hosts) => {
250 fs::write(&known_hosts_path, known_hosts.replace("\r\n", "\n")).map_err(|error| {
251 TrackError::new(
252 ErrorCode::InvalidRemoteAgentConfig,
253 format!(
254 "Could not write the managed known_hosts file at {}: {error}",
255 collapse_home_path(&known_hosts_path)
256 ),
257 )
258 })?;
259 }
260 None if !known_hosts_path.exists() => {
261 fs::write(&known_hosts_path, "").map_err(|error| {
262 TrackError::new(
263 ErrorCode::InvalidRemoteAgentConfig,
264 format!(
265 "Could not create the managed known_hosts file at {}: {error}",
266 collapse_home_path(&known_hosts_path)
267 ),
268 )
269 })?;
270 }
271 None => {}
272 }
273
274 Ok(())
275}
276
277#[cfg(test)]
278mod tests {
279 use tempfile::TempDir;
280
281 use super::BackendConfigRepository;
282 use crate::database::DatabaseContext;
283 use crate::migration::{LegacyScanSummary, MigrationState, MigrationStatus};
284 use crate::settings_repository::SettingsRepository;
285
286 fn repository() -> (TempDir, BackendConfigRepository) {
287 let directory = TempDir::new().expect("tempdir should be created");
288 let database = DatabaseContext::new(Some(directory.path().join("track.sqlite")))
289 .expect("database should resolve");
290 let settings =
291 SettingsRepository::new(Some(database)).expect("settings repository should resolve");
292
293 (
294 directory,
295 BackendConfigRepository::new(Some(settings))
296 .expect("backend config repository should resolve"),
297 )
298 }
299
300 fn status(state: MigrationState) -> MigrationStatus {
301 let requires_migration = matches!(state, MigrationState::ImportRequired);
302 MigrationStatus {
303 state,
304 requires_migration,
305 can_import: requires_migration,
306 legacy_detected: true,
307 summary: LegacyScanSummary::default(),
308 skipped_records: Vec::new(),
309 cleanup_candidates: Vec::new(),
310 }
311 }
312
313 #[test]
314 fn saves_and_loads_imported_status() {
315 let (_directory, repository) = repository();
316 repository
317 .save_migration_status(&status(MigrationState::Imported))
318 .expect("migration status should save");
319
320 let loaded = repository
321 .load_migration_status()
322 .expect("migration status should load");
323
324 assert_eq!(loaded.state, MigrationState::Imported);
325 assert!(!loaded.requires_migration);
326 assert!(!loaded.can_import);
327 }
328}