1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use serde::{Deserialize, Serialize};
6
7use crate::config::{ConfigPathOptions, get_config_dir};
8use crate::error::{CoreError, Result};
9
10const SERVICE_LOG_DIR_NAME: &str = "service-logs";
11const FILE_MODE: u32 = 0o600;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "lowercase")]
15pub enum ConnectorServicePlatform {
16 Launchd,
17 Systemd,
18}
19
20impl ConnectorServicePlatform {
21 pub fn as_str(self) -> &'static str {
23 match self {
24 Self::Launchd => "launchd",
25 Self::Systemd => "systemd",
26 }
27 }
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct ConnectorServiceInstallInput {
32 pub agent_name: String,
33 pub platform: Option<String>,
34 pub proxy_ws_url: Option<String>,
35 pub openclaw_base_url: Option<String>,
36 pub openclaw_hook_path: Option<String>,
37 pub openclaw_hook_token: Option<String>,
38 pub executable_path: Option<PathBuf>,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct ConnectorServiceUninstallInput {
43 pub agent_name: String,
44 pub platform: Option<String>,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "camelCase")]
49pub struct ConnectorServiceInstallResult {
50 pub platform: String,
51 pub service_name: String,
52 pub service_file_path: PathBuf,
53 pub output_log_path: PathBuf,
54 pub error_log_path: PathBuf,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "camelCase")]
59pub struct ConnectorServiceUninstallResult {
60 pub platform: String,
61 pub service_name: String,
62 pub service_file_path: PathBuf,
63}
64
65pub fn parse_connector_service_platform(value: Option<&str>) -> Result<ConnectorServicePlatform> {
67 let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
68 return detect_current_platform();
69 };
70
71 match value.to_ascii_lowercase().as_str() {
72 "auto" => detect_current_platform(),
73 "launchd" => Ok(ConnectorServicePlatform::Launchd),
74 "systemd" => Ok(ConnectorServicePlatform::Systemd),
75 _ => Err(CoreError::InvalidInput(
76 "platform must be one of: auto, launchd, systemd".to_string(),
77 )),
78 }
79}
80
81pub fn sanitize_service_segment(value: &str) -> String {
83 let mut output = String::with_capacity(value.len());
84 let mut previous_dash = false;
85 for character in value.chars() {
86 let is_allowed = character.is_ascii_alphanumeric() || character == '-' || character == '_';
87 if is_allowed {
88 output.push(character);
89 previous_dash = false;
90 } else if !previous_dash {
91 output.push('-');
92 previous_dash = true;
93 }
94 }
95
96 let trimmed = output.trim_matches('-').trim_matches('.');
97 if trimmed.is_empty() {
98 "connector".to_string()
99 } else {
100 trimmed.to_string()
101 }
102}
103
104fn detect_current_platform() -> Result<ConnectorServicePlatform> {
105 #[cfg(target_os = "macos")]
106 {
107 return Ok(ConnectorServicePlatform::Launchd);
108 }
109
110 #[cfg(target_os = "linux")]
111 {
112 return Ok(ConnectorServicePlatform::Systemd);
113 }
114
115 #[allow(unreachable_code)]
116 Err(CoreError::InvalidInput(
117 "connector service is only supported on macOS and Linux".to_string(),
118 ))
119}
120
121fn parse_agent_name(value: &str) -> Result<String> {
122 let candidate = value.trim();
123 if candidate.is_empty() {
124 return Err(CoreError::InvalidInput(
125 "agent name is required".to_string(),
126 ));
127 }
128 if candidate == "." || candidate == ".." {
129 return Err(CoreError::InvalidInput(
130 "agent name must not be . or ..".to_string(),
131 ));
132 }
133 if candidate.len() > 64 {
134 return Err(CoreError::InvalidInput(
135 "agent name must be <= 64 characters".to_string(),
136 ));
137 }
138 let valid = candidate
139 .chars()
140 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.');
141 if !valid {
142 return Err(CoreError::InvalidInput(
143 "agent name contains invalid characters".to_string(),
144 ));
145 }
146 Ok(candidate.to_string())
147}
148
149fn service_name_for_agent(agent_name: &str) -> Result<String> {
150 let agent_name = parse_agent_name(agent_name)?;
151 Ok(sanitize_service_segment(&format!(
152 "clawdentity-connector-{agent_name}"
153 )))
154}
155
156fn resolve_home_dir(options: &ConfigPathOptions) -> Result<PathBuf> {
157 if let Some(home_dir) = &options.home_dir {
158 return Ok(home_dir.clone());
159 }
160 dirs::home_dir().ok_or(CoreError::HomeDirectoryUnavailable)
161}
162
163fn resolve_executable_path(override_path: Option<PathBuf>) -> Result<PathBuf> {
164 if let Some(path) = override_path {
165 return Ok(path);
166 }
167 std::env::current_exe().map_err(|error| {
168 CoreError::InvalidInput(format!(
169 "unable to resolve current executable path: {}",
170 error
171 ))
172 })
173}
174
175fn build_connector_start_args(
176 input: &ConnectorServiceInstallInput,
177 home_dir: &Path,
178) -> Vec<String> {
179 let mut args = vec![
180 "connector".to_string(),
181 "start".to_string(),
182 input.agent_name.clone(),
183 "--home-dir".to_string(),
184 home_dir.display().to_string(),
185 ];
186 if let Some(proxy_ws_url) = input
187 .proxy_ws_url
188 .as_deref()
189 .map(str::trim)
190 .filter(|value| !value.is_empty())
191 {
192 args.push("--proxy-ws-url".to_string());
193 args.push(proxy_ws_url.to_string());
194 }
195 if let Some(openclaw_base_url) = input
196 .openclaw_base_url
197 .as_deref()
198 .map(str::trim)
199 .filter(|value| !value.is_empty())
200 {
201 args.push("--openclaw-base-url".to_string());
202 args.push(openclaw_base_url.to_string());
203 }
204 if let Some(openclaw_hook_path) = input
205 .openclaw_hook_path
206 .as_deref()
207 .map(str::trim)
208 .filter(|value| !value.is_empty())
209 {
210 args.push("--openclaw-hook-path".to_string());
211 args.push(openclaw_hook_path.to_string());
212 }
213 if let Some(openclaw_hook_token) = input
214 .openclaw_hook_token
215 .as_deref()
216 .map(str::trim)
217 .filter(|value| !value.is_empty())
218 {
219 args.push("--openclaw-hook-token".to_string());
220 args.push(openclaw_hook_token.to_string());
221 }
222 args
223}
224
225fn run_process(program: &str, args: &[String], ignore_failure: bool) -> Result<()> {
226 let output = Command::new(program).args(args).output().map_err(|error| {
227 CoreError::InvalidInput(format!("failed to run `{program}`: {}", error))
228 })?;
229 if output.status.success() || ignore_failure {
230 return Ok(());
231 }
232
233 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
234 let message = if stderr.is_empty() {
235 format!("`{program}` returned status {}", output.status)
236 } else {
237 format!("`{program}` failed: {stderr}")
238 };
239 Err(CoreError::InvalidInput(message))
240}
241
242fn quote_systemd_argument(value: &str) -> String {
243 format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
244}
245
246fn escape_xml(value: &str) -> String {
247 value
248 .replace('&', "&")
249 .replace('<', "<")
250 .replace('>', ">")
251 .replace('"', """)
252 .replace('\'', "'")
253}
254
255fn create_systemd_service_file_content(
256 command: &[String],
257 working_directory: &Path,
258 output_log_path: &Path,
259 error_log_path: &Path,
260 agent_name: &str,
261) -> String {
262 let exec_start = command
263 .iter()
264 .map(|arg| quote_systemd_argument(arg))
265 .collect::<Vec<_>>()
266 .join(" ");
267 [
268 "[Unit]".to_string(),
269 format!("Description=Clawdentity connector ({agent_name})"),
270 "After=network-online.target".to_string(),
271 "Wants=network-online.target".to_string(),
272 String::new(),
273 "[Service]".to_string(),
274 "Type=simple".to_string(),
275 format!("ExecStart={exec_start}"),
276 "Restart=always".to_string(),
277 "RestartSec=2".to_string(),
278 format!(
279 "WorkingDirectory={}",
280 quote_systemd_argument(&working_directory.display().to_string())
281 ),
282 format!("StandardOutput=append:{}", output_log_path.display()),
283 format!("StandardError=append:{}", error_log_path.display()),
284 String::new(),
285 "[Install]".to_string(),
286 "WantedBy=default.target".to_string(),
287 String::new(),
288 ]
289 .join("\n")
290}
291
292fn create_launchd_plist_content(
293 label: &str,
294 command: &[String],
295 working_directory: &Path,
296 output_log_path: &Path,
297 error_log_path: &Path,
298) -> String {
299 let command_items = command
300 .iter()
301 .map(|arg| format!(" <string>{}</string>", escape_xml(arg)))
302 .collect::<Vec<_>>()
303 .join("\n");
304 [
305 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>".to_string(),
306 "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">".to_string(),
307 "<plist version=\"1.0\">".to_string(),
308 "<dict>".to_string(),
309 " <key>Label</key>".to_string(),
310 format!(" <string>{}</string>", escape_xml(label)),
311 " <key>ProgramArguments</key>".to_string(),
312 " <array>".to_string(),
313 command_items,
314 " </array>".to_string(),
315 " <key>RunAtLoad</key>".to_string(),
316 " <true/>".to_string(),
317 " <key>KeepAlive</key>".to_string(),
318 " <true/>".to_string(),
319 " <key>WorkingDirectory</key>".to_string(),
320 format!(
321 " <string>{}</string>",
322 escape_xml(&working_directory.display().to_string())
323 ),
324 " <key>StandardOutPath</key>".to_string(),
325 format!(
326 " <string>{}</string>",
327 escape_xml(&output_log_path.display().to_string())
328 ),
329 " <key>StandardErrorPath</key>".to_string(),
330 format!(
331 " <string>{}</string>",
332 escape_xml(&error_log_path.display().to_string())
333 ),
334 "</dict>".to_string(),
335 "</plist>".to_string(),
336 String::new(),
337 ]
338 .join("\n")
339}
340
341fn write_service_file(path: &Path, contents: &str) -> Result<()> {
342 if let Some(parent) = path.parent() {
343 fs::create_dir_all(parent).map_err(|source| CoreError::Io {
344 path: parent.to_path_buf(),
345 source,
346 })?;
347 }
348 fs::write(path, contents).map_err(|source| CoreError::Io {
349 path: path.to_path_buf(),
350 source,
351 })?;
352 #[cfg(unix)]
353 {
354 use std::os::unix::fs::PermissionsExt;
355 fs::set_permissions(path, fs::Permissions::from_mode(FILE_MODE)).map_err(|source| {
356 CoreError::Io {
357 path: path.to_path_buf(),
358 source,
359 }
360 })?;
361 }
362 Ok(())
363}
364
365#[allow(clippy::too_many_lines)]
367pub fn install_connector_service(
368 options: &ConfigPathOptions,
369 input: ConnectorServiceInstallInput,
370) -> Result<ConnectorServiceInstallResult> {
371 let platform = parse_connector_service_platform(input.platform.as_deref())?;
372 let service_name = service_name_for_agent(&input.agent_name)?;
373 let config_dir = get_config_dir(options)?;
374 let home_dir = resolve_home_dir(options)?;
375 let logs_dir = config_dir.join(SERVICE_LOG_DIR_NAME);
376 fs::create_dir_all(&logs_dir).map_err(|source| CoreError::Io {
377 path: logs_dir.clone(),
378 source,
379 })?;
380
381 let output_log_path = logs_dir.join(format!("{service_name}.out.log"));
382 let error_log_path = logs_dir.join(format!("{service_name}.err.log"));
383 let executable = resolve_executable_path(input.executable_path.clone())?;
384 let mut command = vec![executable.display().to_string()];
385 command.extend(build_connector_start_args(&input, &home_dir));
386
387 match platform {
388 ConnectorServicePlatform::Systemd => {
389 let service_dir = home_dir.join(".config/systemd/user");
390 let service_file_path = service_dir.join(format!("{service_name}.service"));
391 let service_contents = create_systemd_service_file_content(
392 &command,
393 &home_dir,
394 &output_log_path,
395 &error_log_path,
396 &input.agent_name,
397 );
398 write_service_file(&service_file_path, &service_contents)?;
399 run_process(
400 "systemctl",
401 &["--user".to_string(), "daemon-reload".to_string()],
402 false,
403 )?;
404 run_process(
405 "systemctl",
406 &[
407 "--user".to_string(),
408 "enable".to_string(),
409 "--now".to_string(),
410 format!("{service_name}.service"),
411 ],
412 false,
413 )?;
414
415 Ok(ConnectorServiceInstallResult {
416 platform: platform.as_str().to_string(),
417 service_name,
418 service_file_path,
419 output_log_path,
420 error_log_path,
421 })
422 }
423 ConnectorServicePlatform::Launchd => {
424 let launch_agents_dir = home_dir.join("Library/LaunchAgents");
425 let label = format!("com.clawdentity.{service_name}");
426 let service_file_path = launch_agents_dir.join(format!("{label}.plist"));
427 let plist_contents = create_launchd_plist_content(
428 &label,
429 &command,
430 &home_dir,
431 &output_log_path,
432 &error_log_path,
433 );
434 write_service_file(&service_file_path, &plist_contents)?;
435
436 run_process(
437 "launchctl",
438 &[
439 "unload".to_string(),
440 "-w".to_string(),
441 service_file_path.display().to_string(),
442 ],
443 true,
444 )?;
445 run_process(
446 "launchctl",
447 &[
448 "load".to_string(),
449 "-w".to_string(),
450 service_file_path.display().to_string(),
451 ],
452 false,
453 )?;
454
455 Ok(ConnectorServiceInstallResult {
456 platform: platform.as_str().to_string(),
457 service_name,
458 service_file_path,
459 output_log_path,
460 error_log_path,
461 })
462 }
463 }
464}
465
466#[allow(clippy::too_many_lines)]
468pub fn uninstall_connector_service(
469 options: &ConfigPathOptions,
470 input: ConnectorServiceUninstallInput,
471) -> Result<ConnectorServiceUninstallResult> {
472 let platform = parse_connector_service_platform(input.platform.as_deref())?;
473 let service_name = service_name_for_agent(&input.agent_name)?;
474 let home_dir = resolve_home_dir(options)?;
475
476 let service_file_path = match platform {
477 ConnectorServicePlatform::Systemd => home_dir
478 .join(".config/systemd/user")
479 .join(format!("{service_name}.service")),
480 ConnectorServicePlatform::Launchd => {
481 let label = format!("com.clawdentity.{service_name}");
482 home_dir
483 .join("Library/LaunchAgents")
484 .join(format!("{label}.plist"))
485 }
486 };
487
488 match platform {
489 ConnectorServicePlatform::Systemd => {
490 let _ = run_process(
491 "systemctl",
492 &[
493 "--user".to_string(),
494 "disable".to_string(),
495 "--now".to_string(),
496 format!("{service_name}.service"),
497 ],
498 true,
499 );
500 let _ = fs::remove_file(&service_file_path);
501 let _ = run_process(
502 "systemctl",
503 &["--user".to_string(), "daemon-reload".to_string()],
504 true,
505 );
506 }
507 ConnectorServicePlatform::Launchd => {
508 let _ = run_process(
509 "launchctl",
510 &[
511 "unload".to_string(),
512 "-w".to_string(),
513 service_file_path.display().to_string(),
514 ],
515 true,
516 );
517 let _ = fs::remove_file(&service_file_path);
518 }
519 }
520
521 Ok(ConnectorServiceUninstallResult {
522 platform: platform.as_str().to_string(),
523 service_name,
524 service_file_path,
525 })
526}
527
528#[cfg(test)]
529mod tests {
530 use std::path::Path;
531
532 use super::{
533 ConnectorServiceInstallInput, create_launchd_plist_content,
534 create_systemd_service_file_content, parse_connector_service_platform,
535 sanitize_service_segment,
536 };
537
538 #[test]
539 fn sanitize_service_segment_replaces_non_alnum_sequences() {
540 let sanitized = sanitize_service_segment("clawdentity connector!!alpha");
541 assert_eq!(sanitized, "clawdentity-connector-alpha");
542 }
543
544 #[test]
545 fn parse_platform_allows_explicit_values() {
546 assert_eq!(
547 parse_connector_service_platform(Some("launchd")).expect("launchd"),
548 super::ConnectorServicePlatform::Launchd
549 );
550 assert_eq!(
551 parse_connector_service_platform(Some("systemd")).expect("systemd"),
552 super::ConnectorServicePlatform::Systemd
553 );
554 }
555
556 #[test]
557 fn generated_service_templates_include_connector_start_args() {
558 let input = ConnectorServiceInstallInput {
559 agent_name: "alpha".to_string(),
560 platform: Some("systemd".to_string()),
561 proxy_ws_url: Some("wss://proxy.example/v1/relay/connect".to_string()),
562 openclaw_base_url: Some("http://127.0.0.1:18789".to_string()),
563 openclaw_hook_path: Some("/hooks/agent".to_string()),
564 openclaw_hook_token: Some("token".to_string()),
565 executable_path: Some("/tmp/clawdentity".into()),
566 };
567 let command = {
568 let mut args = vec!["/tmp/clawdentity".to_string()];
569 args.extend(super::build_connector_start_args(
570 &input,
571 Path::new("/tmp/home"),
572 ));
573 args
574 };
575 let systemd = create_systemd_service_file_content(
576 &command,
577 Path::new("/tmp"),
578 Path::new("/tmp/out.log"),
579 Path::new("/tmp/err.log"),
580 "alpha",
581 );
582 assert!(systemd.contains("connector\" \"start\" \"alpha"));
583 assert!(systemd.contains("--home-dir"));
584 assert!(systemd.contains("--openclaw-hook-token"));
585
586 let launchd = create_launchd_plist_content(
587 "com.clawdentity.clawdentity-connector-alpha",
588 &command,
589 Path::new("/tmp"),
590 Path::new("/tmp/out.log"),
591 Path::new("/tmp/err.log"),
592 );
593 assert!(launchd.contains("<string>connector</string>"));
594 assert!(launchd.contains("<string>--home-dir</string>"));
595 assert!(launchd.contains("<string>--proxy-ws-url</string>"));
596 }
597}