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()
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('&', "&amp;")
245        .replace('<', "&lt;")
246        .replace('>', "&gt;")
247        .replace('"', "&quot;")
248        .replace('\'', "&apos;")
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/// TODO(clawdentity): document `install_connector_service`.
362#[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/// TODO(clawdentity): document `uninstall_connector_service`.
463#[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}