Skip to main content

supercode_harness/
triggers.rs

1//! ORCH-16 (observed tier): the inbound-trigger noun — an HTTP route or hook
2//! mapping that opens a turn when something outside the harness fires.
3//!
4//! * **Hermes** — dynamic webhook subscriptions in
5//!   `HERMES_HOME/webhook_subscriptions.json` (a flat map `route → {description,
6//!   events, prompt, skills, deliver, deliver_extra{chat_id}, created_at,
7//!   secret}`, written by `hermes webhook subscribe`) plus static routes under
8//!   `platforms.webhook.extra.routes` in `config.yaml`; each serves
9//!   `POST /webhooks/<route>` (`gateway/platforms/webhook.py`).
10//! * **OpenClaw (pinned 2026.7.1-2)** — the `hooks` block in `openclaw.json`:
11//!   `enabled`, `path`, `token`, and `hooks.mappings[]` (`id, match{path,source,
12//!   event}, action wake|agent, agentId, sessionKey, wakeMode, deliver, channel,
13//!   to, model`); when enabled the gateway also serves the built-in
14//!   `POST <path>/wake` and `POST <path>/agent` endpoints
15//!   (`docs/automation/cron-jobs.md#webhooks` at the pin). NOTE: `openclaw hooks`
16//!   at the pin manages INTERNAL lifecycle hook packs, not these.
17//! * **Claude Code** — channels are declared over the MCP protocol at connect
18//!   time, not in a file; refused rather than guessed (as ORCH-14).
19//!
20//! Read-only, and no secret is ever read for anything but presence: the
21//! per-route HMAC `secret` (Hermes) and the hook `token` (OpenClaw) are never
22//! emitted.
23
24use std::path::Path;
25
26use serde::{Deserialize, Serialize};
27use serde_json::Value;
28
29use crate::catalog::HarnessHomes;
30use crate::HarnessId;
31
32/// Wire schema of `harness.v1.triggers.list`.
33pub const TRIGGERS_SCHEMA: &str = "supercode.triggers.v1";
34
35/// Harnesses with an inbound-trigger concept supercode can read.
36pub const TRIGGER_HARNESSES: &[&str] = &[
37    HarnessId::HERMES,
38    HarnessId::OPENCLAW,
39    HarnessId::ORCHESTRATOR,
40];
41
42/// What kind of trigger a row is.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum TriggerKind {
46    /// A Hermes webhook route (dynamic subscription or static config route).
47    Webhook,
48    /// An OpenClaw `hooks.mappings[]` entry.
49    HookMapping,
50    /// OpenClaw's built-in `/wake` endpoint (system event into the main session).
51    BuiltinWake,
52    /// OpenClaw's built-in `/agent` endpoint (isolated agent turn).
53    BuiltinAgent,
54}
55
56/// Where the fired turn goes.
57#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
58pub struct TriggerTarget {
59    /// `wake` | `agent` (OpenClaw) or `background` (Hermes: an autonomous lane).
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub action: Option<String>,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub profile: Option<String>,
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub session_key: Option<String>,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub wake_mode: Option<String>,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub model: Option<String>,
70}
71
72/// Where the reply is delivered.
73#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
74pub struct TriggerDeliver {
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub target: Option<String>,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub chat_id: Option<String>,
79}
80
81/// One inbound trigger.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct TriggerRow {
84    pub name: String,
85    pub harness: String,
86    pub kind: TriggerKind,
87    /// The HTTP route the trigger listens on (path only).
88    pub route: String,
89    /// Accepted event names (Hermes) or the mapping's match criteria rendered
90    /// as `key=value` (OpenClaw); empty means any.
91    #[serde(default, skip_serializing_if = "Vec::is_empty")]
92    pub events: Vec<String>,
93    pub target: TriggerTarget,
94    pub deliver: TriggerDeliver,
95    pub enabled: bool,
96    /// The route has an auth secret configured (presence only).
97    pub authenticated: bool,
98    /// Config file the trigger was read from.
99    pub source: String,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub description: Option<String>,
102}
103
104/// Why a listing was refused.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum TriggerError {
107    /// The harness has no inbound-trigger store supercode can read.
108    UnsupportedHarness { harness: String },
109}
110
111impl std::fmt::Display for TriggerError {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        match self {
114            TriggerError::UnsupportedHarness { harness } => write!(
115                f,
116                "`{harness}` has no inbound-trigger store supercode reads; `triggers.list` is supported for: {}",
117                TRIGGER_HARNESSES.join(", ")
118            ),
119        }
120    }
121}
122
123impl std::error::Error for TriggerError {}
124
125/// List inbound triggers, optionally for one harness.
126pub fn list_triggers(
127    homes: &HarnessHomes,
128    harness: Option<&str>,
129) -> Result<Vec<TriggerRow>, TriggerError> {
130    let harnesses: Vec<&str> = match harness {
131        Some(id) if TRIGGER_HARNESSES.contains(&id) => vec![id],
132        Some(id) => {
133            return Err(TriggerError::UnsupportedHarness {
134                harness: id.to_string(),
135            })
136        }
137        None => TRIGGER_HARNESSES.to_vec(),
138    };
139    use supercode_interchange::orchestration::codec::{
140        from_hermes, from_openclaw, load_home, Flavor,
141    };
142    let mut rows = Vec::new();
143    for id in harnesses {
144        match id {
145            HarnessId::HERMES => {
146                if let Ok(loaded) = from_hermes(homes.hermes.parent().unwrap_or(Path::new("."))) {
147                    // Hermes answers webhooks from the root home only
148                    rows.extend(hermes_shaped_rows(
149                        HarnessId::HERMES,
150                        &loaded.orchestration.profiles["default"],
151                        None,
152                    ));
153                }
154            }
155            HarnessId::OPENCLAW => {
156                if let Ok(loaded) = from_openclaw(&homes.openclaw) {
157                    rows.extend(openclaw_rows(&loaded));
158                }
159            }
160            HarnessId::ORCHESTRATOR => {
161                if let Ok(loaded) = load_home(&homes.orchestrator, Flavor::Orchestrator) {
162                    let mut names: Vec<&String> = loaded.orchestration.profiles.keys().collect();
163                    names.sort_by_key(|name| (name.as_str() != "default", name.as_str()));
164                    for name in names {
165                        rows.extend(hermes_shaped_rows(
166                            HarnessId::ORCHESTRATOR,
167                            &loaded.orchestration.profiles[name],
168                            (name != "default").then_some(name.as_str()),
169                        ));
170                    }
171                }
172            }
173            _ => {}
174        }
175    }
176    Ok(rows)
177}
178
179/// One webhook row from the fields the orchestration codec decoded: the
180/// subscription's delivery target, its secret (authentication), and the
181/// residue for what the orchestration does not model (`enabled`, a legacy
182/// `deliver_chat_id`).
183fn hermes_webhook_row(
184    harness: &str,
185    name: &str,
186    events: Vec<String>,
187    deliver: Option<&supercode_interchange::orchestration::Target>,
188    residue: &std::collections::BTreeMap<String, Value>,
189    authenticated: bool,
190    description: Option<String>,
191    source: &str,
192    profile: Option<&str>,
193) -> TriggerRow {
194    use supercode_interchange::orchestration::Target;
195    let (target, chat_id) = match deliver {
196        Some(Target::Explicit {
197            platform, chat_id, ..
198        }) => (Some(platform.clone()), chat_id.clone()),
199        Some(other) => (Some(other.render()), None),
200        None => (Some("log".into()), None),
201    };
202    TriggerRow {
203        name: name.to_string(),
204        harness: harness.into(),
205        kind: TriggerKind::Webhook,
206        route: match profile {
207            Some(p) => format!("/p/{p}/webhooks/{name}"),
208            None => format!("/webhooks/{name}"),
209        },
210        events,
211        target: TriggerTarget {
212            action: Some("background".into()),
213            profile: profile.map(str::to_string),
214            ..TriggerTarget::default()
215        },
216        deliver: TriggerDeliver {
217            target,
218            chat_id: chat_id.or_else(|| {
219                residue
220                    .get("deliver_chat_id")
221                    .and_then(scalar_text)
222                    .filter(|s| !s.is_empty())
223            }),
224        },
225        enabled: residue.get("enabled").is_none_or(|v| match v {
226            Value::Bool(b) => *b,
227            Value::String(s) => s != "false",
228            _ => true,
229        }),
230        authenticated,
231        source: source.to_string(),
232        description,
233    }
234}
235
236/// A scalar's text, as the YAML reader used to see it.
237fn scalar_text(value: &Value) -> Option<String> {
238    match value {
239        Value::String(s) => Some(s.clone()),
240        Value::Number(n) => Some(n.to_string()),
241        Value::Bool(b) => Some(b.to_string()),
242        _ => None,
243    }
244}
245
246/// A profile's webhooks as the orchestration codec reads the home: its
247/// `webhook_subscriptions.json` (typed subscriptions), and the routes a
248/// `platforms.webhook.extra.routes` block declares, which the codec keeps
249/// on the webhook channel's extras.
250fn hermes_shaped_rows(
251    harness: &str,
252    profile: &supercode_interchange::orchestration::Profile,
253    profile_name: Option<&str>,
254) -> Vec<TriggerRow> {
255    let mut rows = Vec::new();
256    let subs_source = profile
257        .dir
258        .join("webhook_subscriptions.json")
259        .display()
260        .to_string();
261    for (name, sub) in &profile.subscriptions {
262        rows.push(hermes_webhook_row(
263            harness,
264            name,
265            sub.events.clone().unwrap_or_default(),
266            sub.deliver.as_ref(),
267            &sub.residue.0,
268            sub.secret.is_some(),
269            sub.description.clone(),
270            &subs_source,
271            profile_name,
272        ));
273    }
274    let config_source = profile.dir.join("config.yaml").display().to_string();
275    let routes = profile
276        .channels
277        .get("webhook")
278        .and_then(|webhook| webhook.extra.get("extra.routes"))
279        .and_then(Value::as_object);
280    for (name, route) in routes.into_iter().flatten() {
281        let Some(route) = route.as_object() else {
282            continue;
283        };
284        let events = match route.get("events") {
285            Some(Value::Array(list)) => list.iter().filter_map(scalar_text).collect(),
286            Some(Value::String(text)) => text
287                .trim_matches(|c| c == '[' || c == ']')
288                .split(',')
289                .map(|e| e.trim().trim_matches(|c| c == '"' || c == '\'').to_string())
290                .filter(|e| !e.is_empty())
291                .collect(),
292            _ => Vec::new(),
293        };
294        let deliver = route
295            .get("deliver")
296            .and_then(scalar_text)
297            .filter(|s| !s.is_empty());
298        let chat_id = route
299            .get("deliver_extra")
300            .and_then(|e| e.get("chat_id"))
301            .and_then(scalar_text)
302            .filter(|s| !s.is_empty());
303        let target = deliver.map(
304            |word| supercode_interchange::orchestration::Target::Explicit {
305                platform: word,
306                chat_id,
307                thread_id: None,
308            },
309        );
310        let residue: std::collections::BTreeMap<String, Value> =
311            route.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
312        rows.push(hermes_webhook_row(
313            harness,
314            name,
315            events,
316            target.as_ref(),
317            &residue,
318            route.get("secret").is_some(),
319            route.get("description").and_then(scalar_text),
320            &config_source,
321            profile_name,
322        ));
323    }
324    rows
325}
326
327/// OpenClaw's hooks as the orchestration codec reads `openclaw.json`: the
328/// block's own fields (enabled, path, token) ride on the root profile's
329/// config residue, each mapping is a subscription whose residue keeps the
330/// mapping's fields and position.
331fn openclaw_rows(
332    loaded: &supercode_interchange::orchestration::codec::OpenclawLoaded,
333) -> Vec<TriggerRow> {
334    let Some(hooks) = loaded.orchestration.profiles["default"]
335        .residue
336        .config
337        .get("openclaw")
338        .and_then(|o| o.get("hooks"))
339        .filter(|h| !h.is_null())
340    else {
341        return Vec::new();
342    };
343    let block = hooks.get("block").and_then(Value::as_object);
344    let field = |key: &str| block.and_then(|b| b.get(key));
345    let source = loaded
346        .root
347        .state_dir
348        .join("openclaw.json")
349        .display()
350        .to_string();
351    let enabled = field("enabled").and_then(Value::as_bool).unwrap_or(false);
352    let authenticated = hooks
353        .get("has_token")
354        .and_then(Value::as_bool)
355        .unwrap_or(false)
356        || field("tokenFile").is_some();
357    let base = field("path")
358        .and_then(Value::as_str)
359        .filter(|s| !s.is_empty())
360        .unwrap_or("/hooks")
361        .trim_end_matches('/')
362        .to_string();
363    let mut rows = vec![
364        TriggerRow {
365            name: "wake".into(),
366            harness: HarnessId::OPENCLAW.into(),
367            kind: TriggerKind::BuiltinWake,
368            route: format!("{base}/wake"),
369            events: Vec::new(),
370            target: TriggerTarget {
371                action: Some("wake".into()),
372                session_key: Some("main".into()),
373                ..TriggerTarget::default()
374            },
375            deliver: TriggerDeliver::default(),
376            enabled,
377            authenticated,
378            source: source.clone(),
379            description: Some("built-in: enqueue a system event into the main session".into()),
380        },
381        TriggerRow {
382            name: "agent".into(),
383            harness: HarnessId::OPENCLAW.into(),
384            kind: TriggerKind::BuiltinAgent,
385            route: format!("{base}/agent"),
386            events: Vec::new(),
387            target: TriggerTarget {
388                action: Some("agent".into()),
389                session_key: Some("isolated".into()),
390                ..TriggerTarget::default()
391            },
392            deliver: TriggerDeliver::default(),
393            enabled,
394            authenticated,
395            source: source.clone(),
396            description: Some("built-in: run an isolated agent turn".into()),
397        },
398    ];
399    let mut mappings: Vec<_> = loaded
400        .orchestration
401        .profiles
402        .values()
403        .flat_map(|profile| profile.subscriptions.values())
404        .filter_map(|sub| Some((sub.residue.0.get("__index")?.as_u64()?, sub)))
405        .collect();
406    mappings.sort_by_key(|(index, _)| *index);
407    for (index, sub) in mappings {
408        let mapping = &sub.residue.0;
409        let text = |key: &str| {
410            mapping
411                .get(key)
412                .and_then(Value::as_str)
413                .filter(|s| !s.is_empty())
414                .map(str::to_string)
415        };
416        let matcher = mapping.get("match").cloned().unwrap_or(Value::Null);
417        let match_text = |key: &str| {
418            matcher
419                .get(key)
420                .and_then(Value::as_str)
421                .filter(|s| !s.is_empty())
422                .map(str::to_string)
423        };
424        let name = text("id")
425            .or_else(|| match_text("path").map(|p| p.trim_start_matches('/').to_string()))
426            .unwrap_or_else(|| format!("mapping-{index}"));
427        let path = match_text("path")
428            .map(|p| format!("{base}/{}", p.trim_start_matches('/')))
429            .unwrap_or_else(|| format!("{base}/{name}"));
430        let mut events = Vec::new();
431        for key in ["source", "event"] {
432            if let Some(v) = match_text(key) {
433                events.push(format!("{key}={v}"));
434            }
435        }
436        let (deliver_target, chat_id) = match &sub.deliver {
437            Some(supercode_interchange::orchestration::Target::Explicit {
438                platform,
439                chat_id,
440                ..
441            }) => (Some(platform.clone()), chat_id.clone()),
442            _ => (None, None),
443        };
444        rows.push(TriggerRow {
445            name,
446            harness: HarnessId::OPENCLAW.into(),
447            kind: TriggerKind::HookMapping,
448            route: path,
449            events,
450            target: TriggerTarget {
451                action: text("action"),
452                profile: text("agentId"),
453                session_key: text("sessionKey"),
454                wake_mode: text("wakeMode"),
455                model: text("model"),
456            },
457            deliver: TriggerDeliver {
458                target: deliver_target.or_else(|| text("channel")),
459                chat_id,
460            },
461            enabled: enabled
462                && mapping
463                    .get("enabled")
464                    .and_then(Value::as_bool)
465                    .unwrap_or(true),
466            authenticated,
467            source: source.clone(),
468            description: text("description"),
469        });
470    }
471    rows
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    fn scratch(tag: &str) -> std::path::PathBuf {
479        let dir = std::env::temp_dir().join(format!(
480            "supercode-triggers-{tag}-{}-{}",
481            std::process::id(),
482            std::time::SystemTime::now()
483                .duration_since(std::time::UNIX_EPOCH)
484                .unwrap()
485                .as_nanos()
486        ));
487        std::fs::create_dir_all(&dir).unwrap();
488        dir
489    }
490
491    #[test]
492    fn openclaw_hooks_block_yields_builtins_and_mappings() {
493        let dir = scratch("openclaw");
494        std::fs::write(
495            dir.join("openclaw.json"),
496            r#"{ "hooks": { "enabled": true, "token": "FAKE-HOOK-TOKEN", "path": "/hooks",
497                 "mappings": [ { "id": "gmail", "match": { "path": "gmail", "source": "gmail" }, "action": "agent", "agentId": "main", "sessionKey": "hook:gmail:{{id}}", "deliver": "slack", "to": "C1" } ] } }"#,
498        )
499        .unwrap();
500        let rows = openclaw_rows(dir.path_buf_hack());
501        let names: Vec<&str> = rows.iter().map(|r| r.name.as_str()).collect();
502        assert_eq!(names, vec!["wake", "agent", "gmail"]);
503        assert_eq!(rows[2].route, "/hooks/gmail");
504        assert_eq!(rows[2].events, vec!["source=gmail"]);
505        assert_eq!(rows[2].target.action.as_deref(), Some("agent"));
506        assert_eq!(
507            rows[2].target.session_key.as_deref(),
508            Some("hook:gmail:{{id}}")
509        );
510        assert!(rows.iter().all(|r| r.authenticated && r.enabled));
511        let rendered = serde_json::to_string(&rows).unwrap();
512        assert!(!rendered.contains("FAKE-"), "{rendered}");
513    }
514
515    trait PathBufHack {
516        fn path_buf_hack(&self) -> &Path;
517    }
518    impl PathBufHack for std::path::PathBuf {
519        fn path_buf_hack(&self) -> &Path {
520            self.as_path()
521        }
522    }
523
524    #[test]
525    fn a_core_harness_is_refused() {
526        let err = list_triggers(&HarnessHomes::default(), Some("claude-code")).unwrap_err();
527        assert!(err.to_string().contains("triggers.list"));
528    }
529}