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