Skip to main content

clawdentity_core/connector/
service.rs

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    /// TODO(clawdentity): document `as_str`.
22    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
65/// TODO(clawdentity): document `parse_connector_service_platform`.
66pub 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
81/// TODO(clawdentity): document `sanitize_service_segment`.
82pub 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('&', "&amp;")
244        .replace('<', "&lt;")
245        .replace('>', "&gt;")
246        .replace('"', "&quot;")
247        .replace('\'', "&apos;")
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/// TODO(clawdentity): document `install_connector_service`.
361#[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/// TODO(clawdentity): document `uninstall_connector_service`.
462#[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}