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()
168 .map_err(|error| CoreError::InvalidInput(format!("unable to resolve current executable path: {error}")))
169}
170
171fn build_connector_start_args(
172 input: &ConnectorServiceInstallInput,
173 home_dir: &Path,
174) -> Vec<String> {
175 let mut args = vec![
176 "connector".to_string(),
177 "start".to_string(),
178 input.agent_name.clone(),
179 "--home-dir".to_string(),
180 home_dir.display().to_string(),
181 ];
182 if let Some(proxy_ws_url) = input
183 .proxy_ws_url
184 .as_deref()
185 .map(str::trim)
186 .filter(|value| !value.is_empty())
187 {
188 args.push("--proxy-ws-url".to_string());
189 args.push(proxy_ws_url.to_string());
190 }
191 if let Some(openclaw_base_url) = input
192 .openclaw_base_url
193 .as_deref()
194 .map(str::trim)
195 .filter(|value| !value.is_empty())
196 {
197 args.push("--openclaw-base-url".to_string());
198 args.push(openclaw_base_url.to_string());
199 }
200 if let Some(openclaw_hook_path) = input
201 .openclaw_hook_path
202 .as_deref()
203 .map(str::trim)
204 .filter(|value| !value.is_empty())
205 {
206 args.push("--openclaw-hook-path".to_string());
207 args.push(openclaw_hook_path.to_string());
208 }
209 if let Some(openclaw_hook_token) = input
210 .openclaw_hook_token
211 .as_deref()
212 .map(str::trim)
213 .filter(|value| !value.is_empty())
214 {
215 args.push("--openclaw-hook-token".to_string());
216 args.push(openclaw_hook_token.to_string());
217 }
218 args
219}
220
221fn run_process(program: &str, args: &[String], ignore_failure: bool) -> Result<()> {
222 let output = Command::new(program).args(args).output().map_err(|error| {
223 CoreError::InvalidInput(format!("failed to run `{program}`: {error}"))
224 })?;
225 if output.status.success() || ignore_failure {
226 return Ok(());
227 }
228
229 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
230 let message = if stderr.is_empty() {
231 format!("`{program}` returned status {}", output.status)
232 } else {
233 format!("`{program}` failed: {stderr}")
234 };
235 Err(CoreError::InvalidInput(message))
236}
237
238fn quote_systemd_argument(value: &str) -> String {
239 format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
240}
241
242fn escape_xml(value: &str) -> String {
243 value
244 .replace('&', "&")
245 .replace('<', "<")
246 .replace('>', ">")
247 .replace('"', """)
248 .replace('\'', "'")
249}
250
251fn create_systemd_service_file_content(
252 command: &[String],
253 working_directory: &Path,
254 output_log_path: &Path,
255 error_log_path: &Path,
256 agent_name: &str,
257) -> String {
258 let exec_start = command
259 .iter()
260 .map(|arg| quote_systemd_argument(arg))
261 .collect::<Vec<_>>()
262 .join(" ");
263 [
264 "[Unit]".to_string(),
265 format!("Description=Clawdentity connector ({agent_name})"),
266 "After=network-online.target".to_string(),
267 "Wants=network-online.target".to_string(),
268 String::new(),
269 "[Service]".to_string(),
270 "Type=simple".to_string(),
271 format!("ExecStart={exec_start}"),
272 "Restart=always".to_string(),
273 "RestartSec=2".to_string(),
274 format!(
275 "WorkingDirectory={}",
276 quote_systemd_argument(&working_directory.display().to_string())
277 ),
278 format!("StandardOutput=append:{}", output_log_path.display()),
279 format!("StandardError=append:{}", error_log_path.display()),
280 String::new(),
281 "[Install]".to_string(),
282 "WantedBy=default.target".to_string(),
283 String::new(),
284 ]
285 .join("\n")
286}
287
288fn create_launchd_plist_content(
289 label: &str,
290 command: &[String],
291 working_directory: &Path,
292 output_log_path: &Path,
293 error_log_path: &Path,
294) -> String {
295 let command_items = command
296 .iter()
297 .map(|arg| format!(" <string>{}</string>", escape_xml(arg)))
298 .collect::<Vec<_>>()
299 .join("\n");
300 [
301 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>".to_string(),
302 "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">".to_string(),
303 "<plist version=\"1.0\">".to_string(),
304 "<dict>".to_string(),
305 " <key>Label</key>".to_string(),
306 format!(" <string>{}</string>", escape_xml(label)),
307 " <key>ProgramArguments</key>".to_string(),
308 " <array>".to_string(),
309 command_items,
310 " </array>".to_string(),
311 " <key>RunAtLoad</key>".to_string(),
312 " <true/>".to_string(),
313 " <key>KeepAlive</key>".to_string(),
314 " <true/>".to_string(),
315 " <key>WorkingDirectory</key>".to_string(),
316 format!(
317 " <string>{}</string>",
318 escape_xml(&working_directory.display().to_string())
319 ),
320 " <key>StandardOutPath</key>".to_string(),
321 format!(
322 " <string>{}</string>",
323 escape_xml(&output_log_path.display().to_string())
324 ),
325 " <key>StandardErrorPath</key>".to_string(),
326 format!(
327 " <string>{}</string>",
328 escape_xml(&error_log_path.display().to_string())
329 ),
330 "</dict>".to_string(),
331 "</plist>".to_string(),
332 String::new(),
333 ]
334 .join("\n")
335}
336
337fn write_service_file(path: &Path, contents: &str) -> Result<()> {
338 if let Some(parent) = path.parent() {
339 fs::create_dir_all(parent).map_err(|source| CoreError::Io {
340 path: parent.to_path_buf(),
341 source,
342 })?;
343 }
344 fs::write(path, contents).map_err(|source| CoreError::Io {
345 path: path.to_path_buf(),
346 source,
347 })?;
348 #[cfg(unix)]
349 {
350 use std::os::unix::fs::PermissionsExt;
351 fs::set_permissions(path, fs::Permissions::from_mode(FILE_MODE)).map_err(|source| {
352 CoreError::Io {
353 path: path.to_path_buf(),
354 source,
355 }
356 })?;
357 }
358 Ok(())
359}
360
361#[allow(clippy::too_many_lines)]
363pub fn install_connector_service(
364 options: &ConfigPathOptions,
365 input: ConnectorServiceInstallInput,
366) -> Result<ConnectorServiceInstallResult> {
367 let platform = parse_connector_service_platform(input.platform.as_deref())?;
368 let service_name = service_name_for_agent(&input.agent_name)?;
369 let config_dir = get_config_dir(options)?;
370 let home_dir = resolve_home_dir(options)?;
371 let logs_dir = config_dir.join(SERVICE_LOG_DIR_NAME);
372 fs::create_dir_all(&logs_dir).map_err(|source| CoreError::Io {
373 path: logs_dir.clone(),
374 source,
375 })?;
376
377 let output_log_path = logs_dir.join(format!("{service_name}.out.log"));
378 let error_log_path = logs_dir.join(format!("{service_name}.err.log"));
379 let executable = resolve_executable_path(input.executable_path.clone())?;
380 let mut command = vec![executable.display().to_string()];
381 command.extend(build_connector_start_args(&input, &home_dir));
382
383 match platform {
384 ConnectorServicePlatform::Systemd => {
385 let service_dir = home_dir.join(".config/systemd/user");
386 let service_file_path = service_dir.join(format!("{service_name}.service"));
387 let service_contents = create_systemd_service_file_content(
388 &command,
389 &home_dir,
390 &output_log_path,
391 &error_log_path,
392 &input.agent_name,
393 );
394 write_service_file(&service_file_path, &service_contents)?;
395 run_process(
396 "systemctl",
397 &["--user".to_string(), "daemon-reload".to_string()],
398 false,
399 )?;
400 run_process(
401 "systemctl",
402 &[
403 "--user".to_string(),
404 "enable".to_string(),
405 "--now".to_string(),
406 format!("{service_name}.service"),
407 ],
408 false,
409 )?;
410
411 Ok(ConnectorServiceInstallResult {
412 platform: platform.as_str().to_string(),
413 service_name,
414 service_file_path,
415 output_log_path,
416 error_log_path,
417 })
418 }
419 ConnectorServicePlatform::Launchd => {
420 let launch_agents_dir = home_dir.join("Library/LaunchAgents");
421 let label = format!("com.clawdentity.{service_name}");
422 let service_file_path = launch_agents_dir.join(format!("{label}.plist"));
423 let plist_contents = create_launchd_plist_content(
424 &label,
425 &command,
426 &home_dir,
427 &output_log_path,
428 &error_log_path,
429 );
430 write_service_file(&service_file_path, &plist_contents)?;
431
432 run_process(
433 "launchctl",
434 &[
435 "unload".to_string(),
436 "-w".to_string(),
437 service_file_path.display().to_string(),
438 ],
439 true,
440 )?;
441 run_process(
442 "launchctl",
443 &[
444 "load".to_string(),
445 "-w".to_string(),
446 service_file_path.display().to_string(),
447 ],
448 false,
449 )?;
450
451 Ok(ConnectorServiceInstallResult {
452 platform: platform.as_str().to_string(),
453 service_name,
454 service_file_path,
455 output_log_path,
456 error_log_path,
457 })
458 }
459 }
460}
461
462#[allow(clippy::too_many_lines)]
464pub fn uninstall_connector_service(
465 options: &ConfigPathOptions,
466 input: ConnectorServiceUninstallInput,
467) -> Result<ConnectorServiceUninstallResult> {
468 let platform = parse_connector_service_platform(input.platform.as_deref())?;
469 let service_name = service_name_for_agent(&input.agent_name)?;
470 let home_dir = resolve_home_dir(options)?;
471
472 let service_file_path = match platform {
473 ConnectorServicePlatform::Systemd => home_dir
474 .join(".config/systemd/user")
475 .join(format!("{service_name}.service")),
476 ConnectorServicePlatform::Launchd => {
477 let label = format!("com.clawdentity.{service_name}");
478 home_dir
479 .join("Library/LaunchAgents")
480 .join(format!("{label}.plist"))
481 }
482 };
483
484 match platform {
485 ConnectorServicePlatform::Systemd => {
486 let _ = run_process(
487 "systemctl",
488 &[
489 "--user".to_string(),
490 "disable".to_string(),
491 "--now".to_string(),
492 format!("{service_name}.service"),
493 ],
494 true,
495 );
496 let _ = fs::remove_file(&service_file_path);
497 let _ = run_process(
498 "systemctl",
499 &["--user".to_string(), "daemon-reload".to_string()],
500 true,
501 );
502 }
503 ConnectorServicePlatform::Launchd => {
504 let _ = run_process(
505 "launchctl",
506 &[
507 "unload".to_string(),
508 "-w".to_string(),
509 service_file_path.display().to_string(),
510 ],
511 true,
512 );
513 let _ = fs::remove_file(&service_file_path);
514 }
515 }
516
517 Ok(ConnectorServiceUninstallResult {
518 platform: platform.as_str().to_string(),
519 service_name,
520 service_file_path,
521 })
522}
523
524#[cfg(test)]
525mod tests {
526 use std::path::Path;
527
528 use super::{
529 ConnectorServiceInstallInput, create_launchd_plist_content,
530 create_systemd_service_file_content, parse_connector_service_platform,
531 sanitize_service_segment,
532 };
533
534 #[test]
535 fn sanitize_service_segment_replaces_non_alnum_sequences() {
536 let sanitized = sanitize_service_segment("clawdentity connector!!alpha");
537 assert_eq!(sanitized, "clawdentity-connector-alpha");
538 }
539
540 #[test]
541 fn parse_platform_allows_explicit_values() {
542 assert_eq!(
543 parse_connector_service_platform(Some("launchd")).expect("launchd"),
544 super::ConnectorServicePlatform::Launchd
545 );
546 assert_eq!(
547 parse_connector_service_platform(Some("systemd")).expect("systemd"),
548 super::ConnectorServicePlatform::Systemd
549 );
550 }
551
552 #[test]
553 fn generated_service_templates_include_connector_start_args() {
554 let input = ConnectorServiceInstallInput {
555 agent_name: "alpha".to_string(),
556 platform: Some("systemd".to_string()),
557 proxy_ws_url: Some("wss://proxy.example/v1/relay/connect".to_string()),
558 openclaw_base_url: Some("http://127.0.0.1:18789".to_string()),
559 openclaw_hook_path: Some("/hooks/agent".to_string()),
560 openclaw_hook_token: Some("token".to_string()),
561 executable_path: Some("/tmp/clawdentity".into()),
562 };
563 let command = {
564 let mut args = vec!["/tmp/clawdentity".to_string()];
565 args.extend(super::build_connector_start_args(
566 &input,
567 Path::new("/tmp/home"),
568 ));
569 args
570 };
571 let systemd = create_systemd_service_file_content(
572 &command,
573 Path::new("/tmp"),
574 Path::new("/tmp/out.log"),
575 Path::new("/tmp/err.log"),
576 "alpha",
577 );
578 assert!(systemd.contains("connector\" \"start\" \"alpha"));
579 assert!(systemd.contains("--home-dir"));
580 assert!(systemd.contains("--openclaw-hook-token"));
581
582 let launchd = create_launchd_plist_content(
583 "com.clawdentity.clawdentity-connector-alpha",
584 &command,
585 Path::new("/tmp"),
586 Path::new("/tmp/out.log"),
587 Path::new("/tmp/err.log"),
588 );
589 assert!(launchd.contains("<string>connector</string>"));
590 assert!(launchd.contains("<string>--home-dir</string>"));
591 assert!(launchd.contains("<string>--proxy-ws-url</string>"));
592 }
593}