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::profiles::{read_json5, yaml_child, yaml_key};
31use crate::HarnessId;
32
33/// Wire schema of `harness.v1.triggers.list`.
34pub const TRIGGERS_SCHEMA: &str = "supercode.triggers.v1";
35
36/// Harnesses with an inbound-trigger concept supercode can read.
37pub const TRIGGER_HARNESSES: &[&str] = &[
38    HarnessId::HERMES,
39    HarnessId::OPENCLAW,
40    HarnessId::ORCHESTRATOR,
41];
42
43/// What kind of trigger a row is.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum TriggerKind {
47    /// A Hermes webhook route (dynamic subscription or static config route).
48    Webhook,
49    /// An OpenClaw `hooks.mappings[]` entry.
50    HookMapping,
51    /// OpenClaw's built-in `/wake` endpoint (system event into the main session).
52    BuiltinWake,
53    /// OpenClaw's built-in `/agent` endpoint (isolated agent turn).
54    BuiltinAgent,
55}
56
57/// Where the fired turn goes.
58#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
59pub struct TriggerTarget {
60    /// `wake` | `agent` (OpenClaw) or `background` (Hermes: an autonomous lane).
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub action: Option<String>,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub profile: Option<String>,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub session_key: Option<String>,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub wake_mode: Option<String>,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub model: Option<String>,
71}
72
73/// Where the reply is delivered.
74#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
75pub struct TriggerDeliver {
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub target: Option<String>,
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub chat_id: Option<String>,
80}
81
82/// One inbound trigger.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84pub struct TriggerRow {
85    pub name: String,
86    pub harness: String,
87    pub kind: TriggerKind,
88    /// The HTTP route the trigger listens on (path only).
89    pub route: String,
90    /// Accepted event names (Hermes) or the mapping's match criteria rendered
91    /// as `key=value` (OpenClaw); empty means any.
92    #[serde(default, skip_serializing_if = "Vec::is_empty")]
93    pub events: Vec<String>,
94    pub target: TriggerTarget,
95    pub deliver: TriggerDeliver,
96    pub enabled: bool,
97    /// The route has an auth secret configured (presence only).
98    pub authenticated: bool,
99    /// Config file the trigger was read from.
100    pub source: String,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub description: Option<String>,
103}
104
105/// Why a listing was refused.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub enum TriggerError {
108    /// The harness has no inbound-trigger store supercode can read.
109    UnsupportedHarness { harness: String },
110}
111
112impl std::fmt::Display for TriggerError {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        match self {
115            TriggerError::UnsupportedHarness { harness } => write!(
116                f,
117                "`{harness}` has no inbound-trigger store supercode reads; `triggers.list` is supported for: {}",
118                TRIGGER_HARNESSES.join(", ")
119            ),
120        }
121    }
122}
123
124impl std::error::Error for TriggerError {}
125
126/// List inbound triggers, optionally for one harness.
127pub fn list_triggers(
128    homes: &HarnessHomes,
129    harness: Option<&str>,
130) -> Result<Vec<TriggerRow>, TriggerError> {
131    let harnesses: Vec<&str> = match harness {
132        Some(id) if TRIGGER_HARNESSES.contains(&id) => vec![id],
133        Some(id) => {
134            return Err(TriggerError::UnsupportedHarness {
135                harness: id.to_string(),
136            })
137        }
138        None => TRIGGER_HARNESSES.to_vec(),
139    };
140    let mut rows = Vec::new();
141    for id in harnesses {
142        match id {
143            HarnessId::HERMES => rows.extend(hermes_rows(
144                HarnessId::HERMES,
145                homes.hermes.parent().unwrap_or(Path::new(".")),
146                None,
147            )),
148            HarnessId::OPENCLAW => rows.extend(openclaw_rows(&homes.openclaw)),
149            // ORC-7: `webhook_subscriptions.json` lives in each of the
150            // orchestrator's profile folders, in Hermes's own shape
151            // (`docs/ORCHESTRATOR-IR.md` §6), so the Hermes reader runs once
152            // per folder with the folder's name as the route's profile.
153            HarnessId::ORCHESTRATOR => {
154                for (name, dir) in crate::orchestrator_profile_dirs(&homes.orchestrator) {
155                    let profile = (name != "default").then_some(name);
156                    rows.extend(hermes_rows(
157                        HarnessId::ORCHESTRATOR,
158                        &dir,
159                        profile.as_deref(),
160                    ));
161                }
162            }
163            _ => {}
164        }
165    }
166    Ok(rows)
167}
168
169fn text(value: &Value, key: &str) -> Option<String> {
170    value
171        .get(key)
172        .and_then(Value::as_str)
173        .filter(|s| !s.is_empty())
174        .map(str::to_string)
175}
176
177fn string_list(value: &Value, key: &str) -> Vec<String> {
178    value
179        .get(key)
180        .and_then(Value::as_array)
181        .map(|list| {
182            list.iter()
183                .filter_map(Value::as_str)
184                .map(str::to_string)
185                .collect()
186        })
187        .unwrap_or_default()
188}
189
190fn hermes_route_row(
191    harness: &str,
192    name: &str,
193    route: &Value,
194    source: &str,
195    profile: Option<&str>,
196) -> TriggerRow {
197    let deliver_extra = route.get("deliver_extra").cloned().unwrap_or(Value::Null);
198    TriggerRow {
199        name: name.to_string(),
200        harness: harness.into(),
201        kind: TriggerKind::Webhook,
202        route: match profile {
203            Some(p) => format!("/p/{p}/webhooks/{name}"),
204            None => format!("/webhooks/{name}"),
205        },
206        events: string_list(route, "events"),
207        target: TriggerTarget {
208            action: Some("background".into()),
209            profile: profile.map(str::to_string),
210            ..TriggerTarget::default()
211        },
212        deliver: TriggerDeliver {
213            target: text(route, "deliver").or_else(|| Some("log".into())),
214            chat_id: text(&deliver_extra, "chat_id").or_else(|| text(route, "deliver_chat_id")),
215        },
216        enabled: route
217            .get("enabled")
218            .and_then(Value::as_bool)
219            .unwrap_or(true),
220        // Presence only: the value is a per-route HMAC secret and is never read.
221        authenticated: route.get("secret").is_some(),
222        source: source.to_string(),
223        description: text(route, "description"),
224    }
225}
226
227/// Read the webhook subscriptions of one Hermes-shaped home.
228///
229/// `home` is the folder holding `webhook_subscriptions.json` and
230/// `config.yaml` (Hermes: HERMES_HOME; the orchestrator: one profile folder).
231/// `profile` names the folder when it is a named profile, which is what puts
232/// the route under `/p/<profile>/webhooks/<name>`.
233fn hermes_rows(harness: &str, home: &Path, profile: Option<&str>) -> Vec<TriggerRow> {
234    let mut rows = Vec::new();
235    let subs_path = home.join("webhook_subscriptions.json");
236    if let Ok(text) = std::fs::read_to_string(&subs_path) {
237        if let Ok(Value::Object(map)) = serde_json::from_str::<Value>(&text) {
238            let source = subs_path.display().to_string();
239            for (name, route) in map {
240                rows.push(hermes_route_row(harness, &name, &route, &source, profile));
241            }
242        }
243    }
244    let config_path = home.join("config.yaml");
245    if let Ok(config) = std::fs::read_to_string(&config_path) {
246        let webhook = yaml_child(&yaml_child(&config, "platforms"), "webhook");
247        let routes = yaml_child(&yaml_child(&webhook, "extra"), "routes");
248        let source = config_path.display().to_string();
249        for (name, block) in yaml_route_blocks(&routes) {
250            let mut route = serde_json::Map::new();
251            for line in block.lines() {
252                let trimmed = line.trim();
253                let (Some(key), Some(value)) = (yaml_key(trimmed), scalar(trimmed)) else {
254                    continue;
255                };
256                route.insert(key.to_string(), Value::String(value));
257            }
258            if let Some(events) = route.get("events").and_then(Value::as_str) {
259                let list: Vec<Value> = events
260                    .trim_matches(|c| c == '[' || c == ']')
261                    .split(',')
262                    .map(|e| e.trim().trim_matches(|c| c == '"' || c == '\''))
263                    .filter(|e| !e.is_empty())
264                    .map(|e| Value::String(e.to_string()))
265                    .collect();
266                route.insert("events".into(), Value::Array(list));
267            }
268            if let Some(enabled) = route.get("enabled").and_then(Value::as_str) {
269                let flag = enabled != "false";
270                route.insert("enabled".into(), Value::Bool(flag));
271            }
272            rows.push(hermes_route_row(
273                harness,
274                &name,
275                &Value::Object(route),
276                &source,
277                profile,
278            ));
279        }
280    }
281    rows
282}
283
284/// Split a YAML mapping block (`name:\n  key: value …`) into named child blocks.
285fn yaml_route_blocks(block: &str) -> Vec<(String, String)> {
286    let mut out: Vec<(String, String)> = Vec::new();
287    let mut base: Option<usize> = None;
288    for line in block.lines() {
289        let trimmed = line.trim_start();
290        if trimmed.is_empty() || trimmed.starts_with('#') {
291            continue;
292        }
293        let indent = line.len() - trimmed.len();
294        let base_indent = *base.get_or_insert(indent);
295        if indent == base_indent {
296            if let Some(name) = yaml_key(trimmed) {
297                out.push((name.to_string(), String::new()));
298            }
299        } else if let Some((_, body)) = out.last_mut() {
300            body.push_str(line);
301            body.push('\n');
302        }
303    }
304    out
305}
306
307fn scalar(line: &str) -> Option<String> {
308    let (_, tail) = line.split_once(':')?;
309    let tail = tail.trim();
310    let tail = tail.split_once(" #").map(|(head, _)| head).unwrap_or(tail);
311    Some(
312        tail.trim()
313            .trim_matches(|ch| ch == '"' || ch == '\'')
314            .to_string(),
315    )
316}
317
318/// OpenClaw: the `hooks` block of `openclaw.json` at the 2026.7.1-2 pin.
319fn openclaw_rows(home: &Path) -> Vec<TriggerRow> {
320    let config_path = home.join("openclaw.json");
321    let config = read_json5(&config_path);
322    let hooks = config.get("hooks").cloned().unwrap_or(Value::Null);
323    if hooks.is_null() {
324        return Vec::new();
325    }
326    let source = config_path.display().to_string();
327    let enabled = hooks
328        .get("enabled")
329        .and_then(Value::as_bool)
330        .unwrap_or(false);
331    // Presence only: `token` / `tokenFile` values are never read.
332    let authenticated = hooks.get("token").is_some() || hooks.get("tokenFile").is_some();
333    let base = text(&hooks, "path").unwrap_or_else(|| "/hooks".into());
334    let base = base.trim_end_matches('/').to_string();
335    let mut rows = vec![
336        TriggerRow {
337            name: "wake".into(),
338            harness: HarnessId::OPENCLAW.into(),
339            kind: TriggerKind::BuiltinWake,
340            route: format!("{base}/wake"),
341            events: Vec::new(),
342            target: TriggerTarget {
343                action: Some("wake".into()),
344                session_key: Some("main".into()),
345                ..TriggerTarget::default()
346            },
347            deliver: TriggerDeliver::default(),
348            enabled,
349            authenticated,
350            source: source.clone(),
351            description: Some("built-in: enqueue a system event into the main session".into()),
352        },
353        TriggerRow {
354            name: "agent".into(),
355            harness: HarnessId::OPENCLAW.into(),
356            kind: TriggerKind::BuiltinAgent,
357            route: format!("{base}/agent"),
358            events: Vec::new(),
359            target: TriggerTarget {
360                action: Some("agent".into()),
361                session_key: Some("isolated".into()),
362                ..TriggerTarget::default()
363            },
364            deliver: TriggerDeliver::default(),
365            enabled,
366            authenticated,
367            source: source.clone(),
368            description: Some("built-in: run an isolated agent turn".into()),
369        },
370    ];
371    if let Some(mappings) = hooks.get("mappings").and_then(Value::as_array) {
372        for (index, mapping) in mappings.iter().enumerate() {
373            let matcher = mapping.get("match").cloned().unwrap_or(Value::Null);
374            let name = text(mapping, "id")
375                .or_else(|| text(&matcher, "path").map(|p| p.trim_start_matches('/').to_string()))
376                .unwrap_or_else(|| format!("mapping-{index}"));
377            let path = text(&matcher, "path")
378                .map(|p| format!("{base}/{}", p.trim_start_matches('/')))
379                .unwrap_or_else(|| format!("{base}/{name}"));
380            let mut events = Vec::new();
381            for key in ["source", "event"] {
382                if let Some(v) = text(&matcher, key) {
383                    events.push(format!("{key}={v}"));
384                }
385            }
386            rows.push(TriggerRow {
387                name,
388                harness: HarnessId::OPENCLAW.into(),
389                kind: TriggerKind::HookMapping,
390                route: path,
391                events,
392                target: TriggerTarget {
393                    action: text(mapping, "action"),
394                    profile: text(mapping, "agentId"),
395                    session_key: text(mapping, "sessionKey"),
396                    wake_mode: text(mapping, "wakeMode"),
397                    model: text(mapping, "model"),
398                },
399                deliver: TriggerDeliver {
400                    target: text(mapping, "deliver").or_else(|| text(mapping, "channel")),
401                    chat_id: text(mapping, "to"),
402                },
403                enabled: enabled
404                    && mapping
405                        .get("enabled")
406                        .and_then(Value::as_bool)
407                        .unwrap_or(true),
408                authenticated,
409                source: source.clone(),
410                description: text(mapping, "description"),
411            });
412        }
413    }
414    rows
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    fn scratch(tag: &str) -> std::path::PathBuf {
422        let dir = std::env::temp_dir().join(format!(
423            "supercode-triggers-{tag}-{}-{}",
424            std::process::id(),
425            std::time::SystemTime::now()
426                .duration_since(std::time::UNIX_EPOCH)
427                .unwrap()
428                .as_nanos()
429        ));
430        std::fs::create_dir_all(&dir).unwrap();
431        dir
432    }
433
434    #[test]
435    fn hermes_dynamic_and_static_routes_both_list_without_their_secrets() {
436        let dir = scratch("hermes");
437        std::fs::write(
438            dir.join("webhook_subscriptions.json"),
439            r#"{"deploys": {"description": "CI deploys", "events": ["push", "release"], "prompt": "Summarize {repo}", "skills": ["git"], "deliver": "telegram", "deliver_extra": {"chat_id": "123"}, "secret": "FAKE-HMAC-DO-NOT-EMIT", "created_at": "2026-09-03T00:00:00Z"}}"#,
440        )
441        .unwrap();
442        std::fs::write(
443            dir.join("config.yaml"),
444            "platforms:\n  webhook:\n    enabled: true\n    extra:\n      routes:\n        alerts:\n          prompt: \"Triage\"\n          deliver: log\n          enabled: false\n          secret: \"FAKE-STATIC-SECRET\"\n",
445        )
446        .unwrap();
447        let rows = hermes_rows(HarnessId::HERMES, &dir, None);
448        assert_eq!(rows.len(), 2, "{rows:#?}");
449        let deploys = &rows[0];
450        assert_eq!(deploys.route, "/webhooks/deploys");
451        assert_eq!(deploys.events, vec!["push", "release"]);
452        assert_eq!(deploys.deliver.target.as_deref(), Some("telegram"));
453        assert_eq!(deploys.deliver.chat_id.as_deref(), Some("123"));
454        assert!(deploys.authenticated && deploys.enabled);
455        let alerts = &rows[1];
456        assert_eq!(alerts.kind, TriggerKind::Webhook);
457        assert!(!alerts.enabled && alerts.authenticated);
458        let rendered = serde_json::to_string(&rows).unwrap();
459        assert!(!rendered.contains("FAKE-"), "{rendered}");
460    }
461
462    #[test]
463    fn openclaw_hooks_block_yields_builtins_and_mappings() {
464        let dir = scratch("openclaw");
465        std::fs::write(
466            dir.join("openclaw.json"),
467            r#"{ "hooks": { "enabled": true, "token": "FAKE-HOOK-TOKEN", "path": "/hooks",
468                 "mappings": [ { "id": "gmail", "match": { "path": "gmail", "source": "gmail" }, "action": "agent", "agentId": "main", "sessionKey": "hook:gmail:{{id}}", "deliver": "slack", "to": "C1" } ] } }"#,
469        )
470        .unwrap();
471        let rows = openclaw_rows(dir.path_buf_hack());
472        let names: Vec<&str> = rows.iter().map(|r| r.name.as_str()).collect();
473        assert_eq!(names, vec!["wake", "agent", "gmail"]);
474        assert_eq!(rows[2].route, "/hooks/gmail");
475        assert_eq!(rows[2].events, vec!["source=gmail"]);
476        assert_eq!(rows[2].target.action.as_deref(), Some("agent"));
477        assert_eq!(
478            rows[2].target.session_key.as_deref(),
479            Some("hook:gmail:{{id}}")
480        );
481        assert!(rows.iter().all(|r| r.authenticated && r.enabled));
482        let rendered = serde_json::to_string(&rows).unwrap();
483        assert!(!rendered.contains("FAKE-"), "{rendered}");
484    }
485
486    trait PathBufHack {
487        fn path_buf_hack(&self) -> &Path;
488    }
489    impl PathBufHack for std::path::PathBuf {
490        fn path_buf_hack(&self) -> &Path {
491            self.as_path()
492        }
493    }
494
495    #[test]
496    fn a_core_harness_is_refused() {
497        let err = list_triggers(&HarnessHomes::default(), Some("claude-code")).unwrap_err();
498        assert!(err.to_string().contains("triggers.list"));
499    }
500}