Skip to main content

agent_config/agents/
openclaw.rs

1//! OpenClaw integration.
2//!
3//! Implemented surfaces:
4//!
5//! 1. **Prompt rules**: project-local fenced blocks in `AGENTS.md`.
6//! 2. **Skills**: AgentSkills-compatible folders at `~/.openclaw/skills`
7//!    (Global) or `<root>/.agents/skills` (Local).
8//! 3. **MCP servers**: global OpenClaw JSON5 config at
9//!    `~/.openclaw/openclaw.json`, under `mcp.servers.<name>`.
10//!
11//! Native OpenClaw plugin and hook-pack installation remains intentionally
12//! deferred because upstream documents that as a CLI/plugin lifecycle rather
13//! than a stable file-backed hook contract.
14
15use std::collections::BTreeMap;
16use std::path::{Path, PathBuf};
17
18use serde_json::{Map, Value};
19
20use crate::agents::planning as agent_planning;
21use crate::error::AgentConfigError;
22use crate::integration::{
23    InstallReport, InstructionSurface, Integration, McpSurface, SkillSurface, UninstallReport,
24};
25use crate::paths;
26use crate::plan::{InstallPlan, UninstallPlan};
27use crate::scope::{Scope, ScopeKind};
28use crate::spec::{HookSpec, InstructionSpec, McpSpec, McpTransport, SkillSpec};
29use crate::status::StatusReport;
30use crate::util::{
31    file_lock, fs_atomic, instructions_dir, mcp_json_map, md_block, ownership, safe_fs, skills_dir,
32};
33
34/// OpenClaw file-backed installer.
35#[derive(Debug, Clone, Copy, Default)]
36pub struct OpenClawAgent {
37    _private: (),
38}
39
40impl OpenClawAgent {
41    /// Construct an instance. Stateless.
42    pub const fn new() -> Self {
43        Self { _private: () }
44    }
45
46    fn require_local(scope: &Scope) -> Result<&Path, AgentConfigError> {
47        match scope {
48            Scope::Local(p) => Ok(p),
49            Scope::Global => Err(AgentConfigError::UnsupportedScope {
50                id: "openclaw",
51                scope: ScopeKind::Global,
52            }),
53        }
54    }
55
56    fn prompt_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
57        Ok(Self::require_local(scope)?.join("AGENTS.md"))
58    }
59
60    fn openclaw_home_from_home(home: &Path) -> PathBuf {
61        home.join(".openclaw")
62    }
63
64    fn mcp_config_path_from_home(home: &Path) -> PathBuf {
65        Self::openclaw_home_from_home(home).join("openclaw.json")
66    }
67
68    fn mcp_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
69        match scope {
70            Scope::Global => Ok(Self::mcp_config_path_from_home(&paths::home_dir()?)),
71            Scope::Local(_) => Err(AgentConfigError::UnsupportedScope {
72                id: "openclaw",
73                scope: ScopeKind::Local,
74            }),
75        }
76    }
77
78    fn skills_root_from_home(home: &Path) -> PathBuf {
79        Self::openclaw_home_from_home(home).join("skills")
80    }
81
82    fn skills_root(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
83        Ok(match scope {
84            Scope::Global => Self::skills_root_from_home(&paths::home_dir()?),
85            Scope::Local(p) => p.join(".agents").join("skills"),
86        })
87    }
88
89    /// Directory holding the instruction ownership ledger. Local-only;
90    /// reuses the existing `<root>/.agents/` skills directory so the
91    /// sidecar lives next to other agent-config artifacts.
92    fn instruction_config_dir(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
93        Ok(Self::require_local(scope)?.join(".agents"))
94    }
95
96    fn install_mcp_config(
97        config_path: &Path,
98        spec: &McpSpec,
99    ) -> Result<InstallReport, AgentConfigError> {
100        let ledger = ownership::mcp_ledger_for(config_path);
101        mcp_json_map::install(
102            config_path,
103            &ledger,
104            spec,
105            &["mcp", "servers"],
106            openclaw_mcp_value,
107            mcp_json_map::ConfigFormat::Json5,
108        )
109    }
110
111    fn uninstall_mcp_config(
112        config_path: &Path,
113        name: &str,
114        owner_tag: &str,
115    ) -> Result<UninstallReport, AgentConfigError> {
116        let ledger = ownership::mcp_ledger_for(config_path);
117        mcp_json_map::uninstall(
118            config_path,
119            &ledger,
120            name,
121            owner_tag,
122            "mcp server",
123            &["mcp", "servers"],
124            mcp_json_map::ConfigFormat::Json5,
125        )
126    }
127}
128
129impl Integration for OpenClawAgent {
130    fn id(&self) -> &'static str {
131        "openclaw"
132    }
133
134    fn display_name(&self) -> &'static str {
135        "OpenClaw"
136    }
137
138    fn supported_scopes(&self) -> &'static [ScopeKind] {
139        &[ScopeKind::Local]
140    }
141
142    fn status(&self, scope: &Scope, tag: &str) -> Result<StatusReport, AgentConfigError> {
143        HookSpec::validate_tag(tag)?;
144        let path = Self::prompt_path(scope)?;
145        StatusReport::for_markdown_block_hook(tag, path)
146    }
147
148    fn plan_install(
149        &self,
150        scope: &Scope,
151        spec: &HookSpec,
152    ) -> Result<InstallPlan, AgentConfigError> {
153        agent_planning::markdown_install(
154            Integration::id(self),
155            scope,
156            spec,
157            Self::prompt_path(scope),
158            true,
159        )
160    }
161
162    fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError> {
163        agent_planning::markdown_uninstall(
164            Integration::id(self),
165            scope,
166            tag,
167            Self::prompt_path(scope),
168        )
169    }
170
171    fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError> {
172        HookSpec::validate_tag(&spec.tag)?;
173        let rules = spec
174            .rules
175            .as_ref()
176            .ok_or(AgentConfigError::MissingSpecField {
177                id: "openclaw",
178                field: "rules",
179            })?;
180        let path = Self::prompt_path(scope)?;
181        let mut report = InstallReport::default();
182        scope.ensure_contained(&path)?;
183        file_lock::with_lock(&path, || {
184            let host = fs_atomic::read_to_string_or_empty(&path)?;
185            let new_host = md_block::upsert(&host, &spec.tag, &rules.content);
186            let outcome = safe_fs::write(scope, &path, new_host.as_bytes(), true)?;
187            if outcome.no_change {
188                report.already_installed = true;
189            } else if outcome.existed {
190                report.patched.push(outcome.path.clone());
191            } else {
192                report.created.push(outcome.path.clone());
193            }
194            if let Some(b) = outcome.backup {
195                report.backed_up.push(b);
196            }
197            Ok::<(), AgentConfigError>(())
198        })?;
199        Ok(report)
200    }
201
202    fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
203        HookSpec::validate_tag(tag)?;
204        let path = Self::prompt_path(scope)?;
205        let mut report = UninstallReport::default();
206        scope.ensure_contained(&path)?;
207        file_lock::with_lock(&path, || {
208            let host = fs_atomic::read_to_string_or_empty(&path)?;
209            let (stripped, removed) = md_block::remove(&host, tag);
210
211            if !removed {
212                report.not_installed = true;
213                return Ok(());
214            }
215
216            if stripped.trim().is_empty() {
217                if safe_fs::restore_backup_if_matches(scope, &path, stripped.as_bytes())? {
218                    report.restored.push(path.clone());
219                } else {
220                    safe_fs::remove_file(scope, &path)?;
221                    report.removed.push(path.clone());
222                }
223            } else {
224                safe_fs::write(scope, &path, stripped.as_bytes(), false)?;
225                report.patched.push(path.clone());
226            }
227            Ok::<(), AgentConfigError>(())
228        })?;
229        Ok(report)
230    }
231}
232
233impl McpSurface for OpenClawAgent {
234    fn id(&self) -> &'static str {
235        "openclaw"
236    }
237
238    fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
239        &[ScopeKind::Global]
240    }
241
242    fn mcp_status(
243        &self,
244        scope: &Scope,
245        name: &str,
246        expected_owner: &str,
247    ) -> Result<StatusReport, AgentConfigError> {
248        McpSpec::validate_name(name)?;
249        let cfg = Self::mcp_path(scope)?;
250        let ledger = ownership::mcp_ledger_for(&cfg);
251        let presence = mcp_json_map::config_presence(
252            &cfg,
253            &["mcp", "servers"],
254            name,
255            mcp_json_map::ConfigFormat::Json5,
256        )?;
257        let recorded = ownership::owner_of(&ledger, name)?;
258        Ok(StatusReport::for_mcp(
259            name,
260            cfg,
261            ledger,
262            presence,
263            expected_owner,
264            recorded,
265        ))
266    }
267
268    fn plan_install_mcp(
269        &self,
270        scope: &Scope,
271        spec: &McpSpec,
272    ) -> Result<InstallPlan, AgentConfigError> {
273        agent_planning::mcp_json_map_install(
274            McpSurface::id(self),
275            scope,
276            spec,
277            Self::mcp_path(scope),
278            &["mcp", "servers"],
279            openclaw_mcp_value,
280            mcp_json_map::ConfigFormat::Json5,
281        )
282    }
283
284    fn plan_uninstall_mcp(
285        &self,
286        scope: &Scope,
287        name: &str,
288        owner_tag: &str,
289    ) -> Result<UninstallPlan, AgentConfigError> {
290        agent_planning::mcp_json_map_uninstall(
291            McpSurface::id(self),
292            scope,
293            name,
294            owner_tag,
295            Self::mcp_path(scope),
296            &["mcp", "servers"],
297            mcp_json_map::ConfigFormat::Json5,
298        )
299    }
300
301    fn is_mcp_installed(&self, scope: &Scope, name: &str) -> Result<bool, AgentConfigError> {
302        McpSpec::validate_name(name)?;
303        let cfg = Self::mcp_path(scope)?;
304        let ledger = ownership::mcp_ledger_for(&cfg);
305        mcp_json_map::is_installed(&ledger, name)
306    }
307
308    fn install_mcp(
309        &self,
310        scope: &Scope,
311        spec: &McpSpec,
312    ) -> Result<InstallReport, AgentConfigError> {
313        spec.validate()?;
314        let cfg = Self::mcp_path(scope)?;
315        spec.validate_local_secret_policy(scope)?;
316        scope.ensure_contained(&cfg)?;
317        Self::install_mcp_config(&cfg, spec)
318    }
319
320    fn uninstall_mcp(
321        &self,
322        scope: &Scope,
323        name: &str,
324        owner_tag: &str,
325    ) -> Result<UninstallReport, AgentConfigError> {
326        McpSpec::validate_name(name)?;
327        HookSpec::validate_tag(owner_tag)?;
328        let cfg = Self::mcp_path(scope)?;
329        scope.ensure_contained(&cfg)?;
330        Self::uninstall_mcp_config(&cfg, name, owner_tag)
331    }
332}
333
334impl SkillSurface for OpenClawAgent {
335    fn id(&self) -> &'static str {
336        "openclaw"
337    }
338
339    fn supported_skill_scopes(&self) -> &'static [ScopeKind] {
340        &[ScopeKind::Global, ScopeKind::Local]
341    }
342
343    fn skill_status(
344        &self,
345        scope: &Scope,
346        name: &str,
347        expected_owner: &str,
348    ) -> Result<StatusReport, AgentConfigError> {
349        SkillSpec::validate_name(name)?;
350        let root = Self::skills_root(scope)?;
351        let (dir, manifest, ledger) = skills_dir::paths_for_status(&root, name);
352        let recorded = ownership::owner_of(&ledger, name)?;
353        Ok(StatusReport::for_skill(
354            name,
355            dir,
356            manifest,
357            ledger,
358            expected_owner,
359            recorded,
360        ))
361    }
362
363    fn plan_install_skill(
364        &self,
365        scope: &Scope,
366        spec: &SkillSpec,
367    ) -> Result<InstallPlan, AgentConfigError> {
368        agent_planning::skill_install(
369            SkillSurface::id(self),
370            scope,
371            spec,
372            Self::skills_root(scope),
373        )
374    }
375
376    fn plan_uninstall_skill(
377        &self,
378        scope: &Scope,
379        name: &str,
380        owner_tag: &str,
381    ) -> Result<UninstallPlan, AgentConfigError> {
382        agent_planning::skill_uninstall(
383            SkillSurface::id(self),
384            scope,
385            name,
386            owner_tag,
387            Self::skills_root(scope),
388        )
389    }
390
391    fn is_skill_installed(&self, scope: &Scope, name: &str) -> Result<bool, AgentConfigError> {
392        let root = Self::skills_root(scope)?;
393        skills_dir::is_installed(&root, name)
394    }
395
396    fn install_skill(
397        &self,
398        scope: &Scope,
399        spec: &SkillSpec,
400    ) -> Result<InstallReport, AgentConfigError> {
401        let root = Self::skills_root(scope)?;
402        scope.ensure_contained(&root)?;
403        skills_dir::install(&root, spec)
404    }
405
406    fn uninstall_skill(
407        &self,
408        scope: &Scope,
409        name: &str,
410        owner_tag: &str,
411    ) -> Result<UninstallReport, AgentConfigError> {
412        let root = Self::skills_root(scope)?;
413        scope.ensure_contained(&root)?;
414        skills_dir::uninstall(&root, name, owner_tag)
415    }
416}
417
418impl OpenClawAgent {
419    fn inline_layout(
420        &self,
421        scope: &Scope,
422    ) -> Result<instructions_dir::InlineLayout, AgentConfigError> {
423        Ok(instructions_dir::InlineLayout {
424            config_dir: Self::instruction_config_dir(scope)?,
425            host_file: Self::prompt_path(scope)?,
426        })
427    }
428}
429
430impl InstructionSurface for OpenClawAgent {
431    fn id(&self) -> &'static str {
432        "openclaw"
433    }
434
435    fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
436        &[ScopeKind::Local]
437    }
438
439    fn instruction_status(
440        &self,
441        scope: &Scope,
442        name: &str,
443        expected_owner: &str,
444    ) -> Result<StatusReport, AgentConfigError> {
445        instructions_dir::inline_status(self.inline_layout(scope)?, name, expected_owner)
446    }
447
448    fn plan_install_instruction(
449        &self,
450        scope: &Scope,
451        spec: &InstructionSpec,
452    ) -> Result<InstallPlan, AgentConfigError> {
453        instructions_dir::inline_plan_install(
454            InstructionSurface::id(self),
455            scope,
456            self.inline_layout(scope),
457            spec,
458        )
459    }
460
461    fn plan_uninstall_instruction(
462        &self,
463        scope: &Scope,
464        name: &str,
465        owner_tag: &str,
466    ) -> Result<UninstallPlan, AgentConfigError> {
467        instructions_dir::inline_plan_uninstall(
468            InstructionSurface::id(self),
469            scope,
470            self.inline_layout(scope),
471            name,
472            owner_tag,
473        )
474    }
475
476    fn install_instruction(
477        &self,
478        scope: &Scope,
479        spec: &InstructionSpec,
480    ) -> Result<InstallReport, AgentConfigError> {
481        instructions_dir::inline_install(scope, self.inline_layout(scope)?, spec)
482    }
483
484    fn uninstall_instruction(
485        &self,
486        scope: &Scope,
487        name: &str,
488        owner_tag: &str,
489    ) -> Result<UninstallReport, AgentConfigError> {
490        instructions_dir::inline_uninstall(scope, self.inline_layout(scope)?, name, owner_tag)
491    }
492}
493
494fn openclaw_mcp_value(spec: &McpSpec) -> Value {
495    let mut obj = Map::new();
496    match &spec.transport {
497        McpTransport::Stdio { command, args, env } => {
498            obj.insert("command".into(), Value::String(command.clone()));
499            obj.insert(
500                "args".into(),
501                Value::Array(args.iter().cloned().map(Value::String).collect()),
502            );
503            if !env.is_empty() {
504                obj.insert("env".into(), string_map_value(env));
505            }
506        }
507        McpTransport::Http { url, headers } => {
508            obj.insert("url".into(), Value::String(url.clone()));
509            obj.insert("transport".into(), Value::String("streamable-http".into()));
510            if !headers.is_empty() {
511                obj.insert("headers".into(), string_map_value(headers));
512            }
513        }
514        McpTransport::Sse { url, headers } => {
515            obj.insert("url".into(), Value::String(url.clone()));
516            obj.insert("transport".into(), Value::String("sse".into()));
517            if !headers.is_empty() {
518                obj.insert("headers".into(), string_map_value(headers));
519            }
520        }
521    }
522    Value::Object(obj)
523}
524
525fn string_map_value(map: &BTreeMap<String, String>) -> Value {
526    let mut obj = Map::new();
527    for (k, v) in map {
528        obj.insert(k.clone(), Value::String(v.clone()));
529    }
530    Value::Object(obj)
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536    use serde_json::json;
537    use tempfile::tempdir;
538
539    fn rules_spec(tag: &str, rules: &str) -> HookSpec {
540        HookSpec::builder(tag)
541            .command_program("noop", [] as [&str; 0])
542            .rules(rules)
543            .build()
544    }
545
546    fn skill(name: &str, owner: &str) -> SkillSpec {
547        SkillSpec::builder(name)
548            .owner(owner)
549            .description("A test OpenClaw skill.")
550            .body("## Goal\nDo the thing.\n")
551            .build()
552    }
553
554    fn mcp_spec(name: &str, owner: &str) -> McpSpec {
555        McpSpec::builder(name)
556            .owner(owner)
557            .stdio("npx", ["-y", "@example/server"])
558            .env("FOO", "bar")
559            .build()
560    }
561
562    #[test]
563    fn install_rules_writes_agents_md_block() {
564        let dir = tempdir().unwrap();
565        let agent = OpenClawAgent::new();
566        let scope = Scope::Local(dir.path().to_path_buf());
567
568        agent
569            .install(&scope, &rules_spec("alpha", "Use the test rules."))
570            .unwrap();
571
572        let body = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
573        assert!(body.contains("BEGIN AGENT-CONFIG:alpha"));
574        assert!(body.contains("Use the test rules."));
575        assert!(agent.is_installed(&scope, "alpha").unwrap());
576    }
577
578    #[test]
579    fn rules_install_is_idempotent() {
580        let dir = tempdir().unwrap();
581        let agent = OpenClawAgent::new();
582        let scope = Scope::Local(dir.path().to_path_buf());
583        let spec = rules_spec("alpha", "rules");
584
585        agent.install(&scope, &spec).unwrap();
586        let second = agent.install(&scope, &spec).unwrap();
587        assert!(second.already_installed);
588    }
589
590    #[test]
591    fn uninstall_rules_round_trip() {
592        let dir = tempdir().unwrap();
593        let agent = OpenClawAgent::new();
594        let scope = Scope::Local(dir.path().to_path_buf());
595
596        agent
597            .install(&scope, &rules_spec("alpha", "rules"))
598            .unwrap();
599        let report = agent.uninstall(&scope, "alpha").unwrap();
600        assert!(!report.removed.is_empty());
601        assert!(!dir.path().join("AGENTS.md").exists());
602    }
603
604    #[test]
605    fn install_skill_writes_local_agents_skills() {
606        let dir = tempdir().unwrap();
607        let agent = OpenClawAgent::new();
608        let scope = Scope::Local(dir.path().to_path_buf());
609
610        agent
611            .install_skill(&scope, &skill("alpha-skill", "myapp"))
612            .unwrap();
613
614        assert!(dir
615            .path()
616            .join(".agents/skills/alpha-skill/SKILL.md")
617            .exists());
618    }
619
620    #[test]
621    fn global_skill_root_uses_openclaw_skills() {
622        let home = PathBuf::from("/tmp/home");
623        assert_eq!(
624            OpenClawAgent::skills_root_from_home(&home),
625            PathBuf::from("/tmp/home/.openclaw/skills")
626        );
627    }
628
629    #[test]
630    fn skill_owner_mismatch_is_refused() {
631        let dir = tempdir().unwrap();
632        let agent = OpenClawAgent::new();
633        let scope = Scope::Local(dir.path().to_path_buf());
634
635        agent
636            .install_skill(&scope, &skill("alpha-skill", "app-a"))
637            .unwrap();
638        let err = agent
639            .install_skill(&scope, &skill("alpha-skill", "app-b"))
640            .unwrap_err();
641        assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
642    }
643
644    #[test]
645    fn install_mcp_reads_json5_and_preserves_user_entries() {
646        let dir = tempdir().unwrap();
647        let cfg = dir.path().join("openclaw.json");
648        std::fs::write(
649            &cfg,
650            r#"{
651  // existing OpenClaw config
652  mcp: {
653    servers: {
654      user: { url: 'https://example.com/mcp' },
655    },
656  },
657  plugins: { enabled: true },
658}
659"#,
660        )
661        .unwrap();
662
663        OpenClawAgent::install_mcp_config(&cfg, &mcp_spec("github", "myapp")).unwrap();
664
665        let parsed: Value = serde_json::from_slice(&std::fs::read(&cfg).unwrap()).unwrap();
666        assert_eq!(parsed["plugins"]["enabled"], json!(true));
667        assert_eq!(
668            parsed["mcp"]["servers"]["user"]["url"],
669            json!("https://example.com/mcp")
670        );
671        assert_eq!(parsed["mcp"]["servers"]["github"]["command"], json!("npx"));
672        assert_eq!(
673            parsed["mcp"]["servers"]["github"]["env"]["FOO"],
674            json!("bar")
675        );
676    }
677
678    #[test]
679    fn install_mcp_is_idempotent() {
680        let dir = tempdir().unwrap();
681        let cfg = dir.path().join("openclaw.json");
682        let spec = mcp_spec("github", "myapp");
683
684        OpenClawAgent::install_mcp_config(&cfg, &spec).unwrap();
685        let second = OpenClawAgent::install_mcp_config(&cfg, &spec).unwrap();
686        assert!(second.already_installed);
687    }
688
689    #[test]
690    fn uninstall_mcp_round_trip() {
691        let dir = tempdir().unwrap();
692        let cfg = dir.path().join("openclaw.json");
693
694        OpenClawAgent::install_mcp_config(&cfg, &mcp_spec("github", "myapp")).unwrap();
695        let report = OpenClawAgent::uninstall_mcp_config(&cfg, "github", "myapp").unwrap();
696        assert!(!report.removed.is_empty());
697        assert!(!cfg.exists());
698    }
699
700    #[test]
701    fn uninstall_mcp_owner_mismatch_is_refused() {
702        let dir = tempdir().unwrap();
703        let cfg = dir.path().join("openclaw.json");
704
705        OpenClawAgent::install_mcp_config(&cfg, &mcp_spec("github", "app-a")).unwrap();
706        let err = OpenClawAgent::uninstall_mcp_config(&cfg, "github", "app-b").unwrap_err();
707        assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
708    }
709
710    #[test]
711    fn local_mcp_scope_is_rejected() {
712        let dir = tempdir().unwrap();
713        let agent = OpenClawAgent::new();
714        let scope = Scope::Local(dir.path().to_path_buf());
715        let err = agent
716            .install_mcp(&scope, &mcp_spec("github", "myapp"))
717            .unwrap_err();
718        assert!(matches!(
719            err,
720            AgentConfigError::UnsupportedScope {
721                scope: ScopeKind::Local,
722                ..
723            }
724        ));
725    }
726
727    #[test]
728    fn remote_mcp_mapping_uses_openclaw_transport_names() {
729        let http = McpSpec::builder("docs")
730            .owner("myapp")
731            .http("https://example.com/mcp")
732            .build();
733        let sse = McpSpec::builder("events")
734            .owner("myapp")
735            .sse("https://example.com/sse")
736            .build();
737
738        assert_eq!(
739            openclaw_mcp_value(&http)["transport"],
740            json!("streamable-http")
741        );
742        assert_eq!(openclaw_mcp_value(&sse)["transport"], json!("sse"));
743    }
744}