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(
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('&', "&amp;")
249        .replace('<', "&lt;")
250        .replace('>', "&gt;")
251        .replace('"', "&quot;")
252        .replace('\'', "&apos;")
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/// TODO(clawdentity): document `install_connector_service`.
366#[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/// TODO(clawdentity): document `uninstall_connector_service`.
467#[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}