Skip to main content

supercode_harness/
channels.rs

1//! ORCH-14 — the `channel` noun at the OBSERVED tier: one uniform row per
2//! transport + account a gateway harness is reachable on, read from the
3//! harness's own config file and never written.
4//!
5//! Two sources, one row shape:
6//!
7//! * **Hermes** — the platform blocks of `HERMES_HOME/config.yaml`, keyed by
8//!   `gateway.config.Platform` values (`telegram`, `slack`, `discord`,
9//!   `api_server`, `webhook`, …) and carrying `enabled`, `extra.*` and the
10//!   platform's own credential keys. `load_gateway_config` merges FOUR
11//!   places into one map, so all four are read — see [`hermes_rows`]. Hermes
12//!   also enables a platform from the ENVIRONMENT alone
13//!   (`_apply_env_overrides`), so a platform with no config block but with
14//!   its credential env var set is reported too — by the var's PRESENCE,
15//!   never its value.
16//! * **OpenClaw** — `channels.<name>` in `<openclaw home>/openclaw.json`
17//!   (JSON5), with `channels.<name>.accounts` splitting a channel into one
18//!   row per account id.
19//!
20//! **Claude Code is deliberately absent.** Its channels are MCP servers that
21//! declare the channel capability over the MCP protocol at connect time
22//! (`docs/composable-harness/inventory/claude-code.md` §7 "Channels": the
23//! channel contract is "capability declaration, notification events, reply
24//! tools, sender gating, permission relay"). Nothing in `settings.json` or
25//! `.mcp.json` marks a server as a channel — `channelsEnabled` and
26//! `allowedChannelPlugins` are enterprise GATES, not declarations — so
27//! supercode cannot tell a channel server from any other MCP server without
28//! connecting to it. Guessing a key name would fabricate rows, so
29//! `claude-code` is refused with [`ChannelError::UnsupportedHarness`].
30//!
31//! # Secrecy
32//!
33//! This module never emits a token, key, secret or password, and never reads
34//! one to decide anything but PRESENCE. Two mechanisms enforce that:
35//!
36//! * values are read only for key names on [`HERMES_ACCOUNT_KEYS`] /
37//!   [`OPENCLAW_ACCOUNT_KEYS`] — public identifiers (`app_id`, `client_id`,
38//!   `phone_number_id`, …), never a credential;
39//! * `configured` is decided by [`is_credential_key`], which looks at the
40//!   key NAME only, and by `std::env::var_os(..).is_some()` for the env
41//!   fallbacks — the value never leaves the check.
42//!
43//! Everything here is read-only: no harness home is created or written. A
44//! harness with no channel concept is refused, never answered with an empty
45//! list.
46
47use std::collections::BTreeMap;
48use std::path::Path;
49
50use serde::{Deserialize, Serialize};
51use serde_json::Value;
52
53use crate::{HarnessHomes, HarnessId};
54
55/// Stable row schema shared by Rust, JSON-RPC, the SDKs, and the CLI.
56pub const CHANNELS_SCHEMA: &str = "supercode.channels.v1";
57
58/// Harnesses with a channel concept supercode reads, in product order.
59/// Every other harness id is [`ChannelError::UnsupportedHarness`].
60pub const CHANNEL_HARNESSES: &[&str] = &[
61    HarnessId::HERMES,
62    HarnessId::OPENCLAW,
63    HarnessId::ORCHESTRATOR,
64];
65
66/// Key names whose VALUE is a public account identifier, safe to emit. Read
67/// from a Hermes platform block's `extra` first, then its top level. Nothing
68/// outside this list is ever read for a value.
69///
70/// Transcribed from the identifiers `gateway/config.py::_apply_env_overrides`
71/// stores in `PlatformConfig.extra` (hermes-agent 0.21.0 on the build box):
72/// `client_id` (DingTalk), `app_id` (Feishu, QQ, Yuanbao), `bot_id` (WeCom),
73/// `corp_id` (WeCom callback), `phone_number_id` (WhatsApp Cloud), `account`
74/// (Signal), `account_id` (Weixin).
75pub const HERMES_ACCOUNT_KEYS: &[&str] = &[
76    "account",
77    "account_id",
78    "app_id",
79    "bot_id",
80    "client_id",
81    "corp_id",
82    "phone_number_id",
83    "user_id",
84];
85
86/// Key names whose VALUE is a public account identifier in an OpenClaw
87/// channel entry. The `accounts` MAP's keys are account ids in their own
88/// right and are used first; this list covers a single-account entry that
89/// names its account inline.
90pub const OPENCLAW_ACCOUNT_KEYS: &[&str] = &[
91    "accountId",
92    "account_id",
93    "account",
94    "teamId",
95    "appId",
96    "userId",
97];
98
99/// Env vars whose PRESENCE enables a Hermes platform, per platform.
100///
101/// Transcribed from `gateway/config.py::_ENV_ENABLE_CREDENTIALS`
102/// ("Env var(s) whose presence drives each platform's env-enable branch")
103/// plus the `api_server` branch, whose credential is `API_SERVER_KEY` and
104/// which that map does not carry because its branch is terminal.
105///
106/// The bool mirrors the branch's own conjunction: WhatsApp Cloud, e-mail,
107/// DingTalk, Feishu, WeCom, WeCom callback, BlueBubbles and Yuanbao require
108/// BOTH of their vars (`if a and b:`); Matrix, Weixin and QQ accept EITHER
109/// (`if a or b:`); single-var platforms read the same under both.
110const HERMES_ENV_CREDENTIALS: &[(&str, &[&str], bool)] = &[
111    ("telegram", &["TELEGRAM_BOT_TOKEN"], false),
112    ("discord", &["DISCORD_BOT_TOKEN"], false),
113    ("slack", &["SLACK_BOT_TOKEN"], false),
114    (
115        "whatsapp_cloud",
116        &[
117            "WHATSAPP_CLOUD_PHONE_NUMBER_ID",
118            "WHATSAPP_CLOUD_ACCESS_TOKEN",
119        ],
120        true,
121    ),
122    ("signal", &["SIGNAL_HTTP_URL"], false),
123    ("mattermost", &["MATTERMOST_TOKEN"], false),
124    ("matrix", &["MATRIX_ACCESS_TOKEN", "MATRIX_PASSWORD"], false),
125    ("homeassistant", &["HASS_TOKEN"], false),
126    (
127        "email",
128        &[
129            "EMAIL_ADDRESS",
130            "EMAIL_PASSWORD",
131            "EMAIL_IMAP_HOST",
132            "EMAIL_SMTP_HOST",
133        ],
134        true,
135    ),
136    ("sms", &["TWILIO_ACCOUNT_SID"], false),
137    (
138        "dingtalk",
139        &["DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"],
140        true,
141    ),
142    ("feishu", &["FEISHU_APP_ID", "FEISHU_APP_SECRET"], true),
143    ("wecom", &["WECOM_BOT_ID", "WECOM_SECRET"], true),
144    (
145        "wecom_callback",
146        &["WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET"],
147        true,
148    ),
149    ("weixin", &["WEIXIN_TOKEN", "WEIXIN_ACCOUNT_ID"], false),
150    (
151        "bluebubbles",
152        &["BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD"],
153        true,
154    ),
155    ("qqbot", &["QQ_APP_ID", "QQ_CLIENT_SECRET"], false),
156    ("yuanbao", &["YUANBAO_APP_ID", "YUANBAO_APP_SECRET"], true),
157    ("relay", &["GATEWAY_RELAY_URL"], false),
158    ("api_server", &["API_SERVER_KEY"], false),
159];
160
161/// Every key name that parses as a Hermes `Platform`, so a config key can be
162/// told apart from an ordinary setting.
163///
164/// The built-in members of `gateway/config.py::Platform` (hermes-agent 0.21.0
165/// on the build box) plus the bundled plugin adapters, which `Platform`
166/// admits through `_missing_` after scanning `plugins/platforms/`. `local` is
167/// omitted on purpose: it is Hermes's own CLI/TUI surface, not a transport
168/// into an external identity space, and `load_gateway_config`'s shared-key
169/// loop skips it too.
170pub const HERMES_PLATFORMS: &[&str] = &[
171    "a2a",
172    "api_server",
173    "bluebubbles",
174    "buzz",
175    "dingtalk",
176    "discord",
177    "email",
178    "feishu",
179    "google_chat",
180    "homeassistant",
181    "irc",
182    "line",
183    "matrix",
184    "mattermost",
185    "msgraph_webhook",
186    "ntfy",
187    "photon",
188    "qqbot",
189    "raft",
190    "relay",
191    "signal",
192    "simplex",
193    "slack",
194    "sms",
195    "teams",
196    "telegram",
197    "webhook",
198    "wecom",
199    "wecom_callback",
200    "weixin",
201    "whatsapp",
202    "whatsapp_cloud",
203    "yuanbao",
204];
205
206/// Whether a live probe answered, and what it said.
207///
208/// At the OBSERVED tier every row answers [`ChannelStatus::Unknown`]: both
209/// harnesses report a channel's connection state from a RUNNING gateway
210/// (`hermes gateway status`, `openclaw channels status` over the Gateway
211/// socket), which is the gateway-health concept, not this one. Reporting
212/// `up` from a config file would be a claim about a process nobody asked.
213#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
214#[serde(rename_all = "snake_case")]
215pub enum ChannelStatus {
216    /// A probe reached the channel's transport.
217    Up,
218    /// A probe ran and the channel's transport did not answer.
219    Down,
220    /// No cheap local probe exists for this harness's channels.
221    Unknown,
222}
223
224impl ChannelStatus {
225    /// Stable wire spelling, identical to the serde representation.
226    pub const fn as_str(self) -> &'static str {
227        match self {
228            Self::Up => "up",
229            Self::Down => "down",
230            Self::Unknown => "unknown",
231        }
232    }
233}
234
235/// One transport + account a harness is reachable on, uniform across
236/// harnesses.
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238pub struct ChannelRow {
239    /// Unique handle within the harness: the transport id for a single
240    /// account (`telegram`), `<transport>/<account>` when the harness's
241    /// config splits one transport across several accounts.
242    pub name: String,
243    /// Owning harness id.
244    pub harness: String,
245    /// Transport id: a Hermes `Platform` value or an OpenClaw channel key
246    /// (`telegram`, `slack`, `discord`, `api_server`, `webhook`, …).
247    pub kind: String,
248    /// Public account id or label; `None` when the config names none. Never
249    /// a token, key or secret.
250    pub account: Option<String>,
251    /// Whether the harness would start this channel. `None` when the config
252    /// does not say and the harness's own default is not stated in a source
253    /// this workspace pins.
254    pub enabled: Option<bool>,
255    /// Whether the entry has what the harness needs to start it, judged
256    /// only by the PRESENCE of a credential key or credential env var.
257    pub configured: bool,
258    /// Connection state; always [`ChannelStatus::Unknown`] at this tier.
259    pub status: ChannelStatus,
260    /// Discovered sessions whose surface platform is this row's `kind`;
261    /// `None` when discovery could not run. Rows that share a `kind` across
262    /// accounts share the count — a session key names the transport, not
263    /// the account.
264    pub sessions: Option<u64>,
265}
266
267/// Read-only channel failures.
268#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
269pub enum ChannelError {
270    /// The harness has no channel concept supercode reads.
271    #[error("harness `{harness}` has no channel concept (channels exist for: {})", CHANNEL_HARNESSES.join(", "))]
272    UnsupportedHarness {
273        /// The harness id that was asked for.
274        harness: String,
275    },
276    /// The harness has channels, but not this one.
277    #[error("`{harness}` has no channel `{name}`")]
278    NotFound {
279        /// Harness that was searched.
280        harness: String,
281        /// Channel name that was not found.
282        name: String,
283    },
284}
285
286/// List every channel supercode can see, optionally restricted to one
287/// harness. Rows are ordered by harness (as in [`CHANNEL_HARNESSES`]) then
288/// by name.
289pub fn list_channels(
290    homes: &HarnessHomes,
291    harness: Option<&str>,
292) -> Result<Vec<ChannelRow>, ChannelError> {
293    if let Some(harness) = harness {
294        if !CHANNEL_HARNESSES.contains(&harness) {
295            return Err(ChannelError::UnsupportedHarness {
296                harness: harness.to_string(),
297            });
298        }
299    }
300    use supercode_interchange::orchestration::codec::{
301        from_hermes, from_openclaw, load_home, Flavor,
302    };
303    let mut rows = Vec::new();
304    for id in CHANNEL_HARNESSES {
305        if harness.is_some_and(|requested| requested != *id) {
306            continue;
307        }
308        let sessions = session_counts(homes, id);
309        match *id {
310            HarnessId::HERMES => {
311                if let Ok(loaded) = from_hermes(homes.hermes.parent().unwrap_or(Path::new("."))) {
312                    // Hermes's platforms are the root home's
313                    rows.extend(hermes_shaped_rows(
314                        HarnessId::HERMES,
315                        &loaded.orchestration.profiles["default"],
316                        sessions.as_ref(),
317                        true,
318                    ));
319                }
320            }
321            HarnessId::OPENCLAW => {
322                if let Ok(loaded) = from_openclaw(&homes.openclaw) {
323                    rows.extend(openclaw_rows(&loaded, sessions.as_ref()));
324                }
325            }
326            HarnessId::ORCHESTRATOR => {
327                if let Ok(loaded) = load_home(&homes.orchestrator, Flavor::Orchestrator) {
328                    let mut names: Vec<&String> = loaded.orchestration.profiles.keys().collect();
329                    names.sort_by_key(|name| (name.as_str() != "default", name.as_str()));
330                    for name in names {
331                        rows.extend(hermes_shaped_rows(
332                            HarnessId::ORCHESTRATOR,
333                            &loaded.orchestration.profiles[name],
334                            sessions.as_ref(),
335                            false,
336                        ));
337                    }
338                }
339            }
340            _ => {}
341        }
342    }
343    Ok(rows)
344}
345
346/// Read one channel's row by harness and name. `status` is
347/// [`ChannelStatus::Unknown`] at this tier for every harness; the verb
348/// exists so the noun is complete and the driven tier has one door to fill.
349pub fn channel_status(
350    homes: &HarnessHomes,
351    harness: &str,
352    name: &str,
353) -> Result<ChannelRow, ChannelError> {
354    list_channels(homes, Some(harness))?
355        .into_iter()
356        .find(|row| row.name == name)
357        .ok_or_else(|| ChannelError::NotFound {
358            harness: harness.to_string(),
359            name: name.to_string(),
360        })
361}
362
363// ---------------------------------------------------------------------------
364// Session counts
365// ---------------------------------------------------------------------------
366
367/// Sessions per surface platform, from the SAME discovery rows
368/// `supercode sessions list` shows. `None` means discovery failed, which is
369/// unknown — never zero.
370fn session_counts(homes: &HarnessHomes, harness: &str) -> Option<BTreeMap<String, u64>> {
371    let query = crate::DiscoveryQuery {
372        harnesses: vec![HarnessId::new(harness)],
373        homes: homes.clone(),
374        ..Default::default()
375    };
376    let sessions = crate::HarnessCatalog::new().discover(&query).ok()?;
377    let mut counts: BTreeMap<String, u64> = BTreeMap::new();
378    for session in sessions {
379        if let Some(platform) = session
380            .nouns
381            .surface
382            .as_ref()
383            .and_then(|surface| surface.platform.as_ref())
384        {
385            *counts.entry(platform.clone()).or_default() += 1;
386        }
387    }
388    Some(counts)
389}
390
391// ---------------------------------------------------------------------------
392// Credentials — presence only
393// ---------------------------------------------------------------------------
394
395/// Whether the env credential(s) for a Hermes platform are present. Only
396/// presence is tested; no value is read. `None` means Hermes lists no env
397/// credential for this platform at all.
398fn hermes_env_credentials_present(platform: &str) -> Option<bool> {
399    let (_, vars, all) = HERMES_ENV_CREDENTIALS
400        .iter()
401        .find(|(name, _, _)| *name == platform)?;
402    let present = |name: &&str| std::env::var_os(name).is_some();
403    Some(if *all {
404        vars.iter().all(present)
405    } else {
406        vars.iter().any(present)
407    })
408}
409
410// ---------------------------------------------------------------------------
411// Hermes
412// ---------------------------------------------------------------------------
413
414/// Hermes channels are the platform blocks of `HERMES_HOME/config.yaml`.
415///
416/// `load_gateway_config` merges FOUR places into one platform map, later
417/// winning (`gateway/config.py::_merge_platform_map` and the shared-key loop
418/// after it) — verified against the real `~/.hermes/config.yaml` on the build
419/// box, which writes NONE of the first three and would have read as empty had
420/// only the documented `platforms:` key been honoured:
421///
422/// 1. `gateway.platforms.<platform>`
423/// 2. `platforms.<platform>` (the shape the ORCH-5 probe writes)
424/// 3. `gateway.<platform>` for any key that parses as a `Platform` value
425/// 4. a TOP-LEVEL `<platform>:` block, which is the only one whose `enabled`
426///    is treated as explicit (`enabled_was_explicit = _cfg_toplevel and …`)
427///
428/// [`HERMES_PLATFORMS`] is what makes 3 and 4 decidable: a key is a platform
429/// block only when its name is a `Platform` value. A platform Hermes would
430/// enable from the environment alone (`_apply_env_overrides`) is listed too,
431/// so an env-only install is not reported as empty.
432///
433/// `state_db` is `HarnessHomes::hermes` (`HERMES_HOME/state.db`).
434/// A profile's platforms as the orchestration codec reads `config.yaml`:
435/// one row per configured channel (credentials are the codec's secret
436/// refs; the account id sits in the channel's extras), plus — for Hermes's
437/// root home — the platforms the process environment alone enables.
438fn hermes_shaped_rows(
439    harness: &str,
440    profile: &supercode_interchange::orchestration::Profile,
441    sessions: Option<&BTreeMap<String, u64>>,
442    env_fallback: bool,
443) -> Vec<ChannelRow> {
444    let mut names: Vec<String> = profile.channels.keys().cloned().collect();
445    if env_fallback {
446        for (platform, _, _) in HERMES_ENV_CREDENTIALS {
447            if hermes_env_credentials_present(platform) == Some(true)
448                && !names.iter().any(|name| name == platform)
449            {
450                names.push((*platform).to_string());
451            }
452        }
453    }
454    names.sort();
455    names.dedup();
456    names
457        .into_iter()
458        .map(|name| {
459            let channel = profile.channels.get(&name);
460            let env_present = env_fallback
461                .then(|| hermes_env_credentials_present(&name))
462                .flatten();
463            let enabled = match channel {
464                Some(channel) => channel.enabled,
465                None => env_present == Some(true),
466            };
467            let configured = env_present == Some(true)
468                || channel.is_some_and(|channel| !channel.credentials.is_empty())
469                || env_present.is_none();
470            let account = channel.and_then(|channel| {
471                HERMES_ACCOUNT_KEYS.iter().find_map(|key| {
472                    channel
473                        .extra
474                        .get(*key)
475                        .or_else(|| channel.extra.get(&format!("extra.{key}")))
476                        .and_then(|v| match v {
477                            Value::String(s) => Some(s.clone()),
478                            Value::Number(n) => Some(n.to_string()),
479                            _ => None,
480                        })
481                        .filter(|value| !value.is_empty())
482                })
483            });
484            ChannelRow {
485                name: name.clone(),
486                harness: harness.to_string(),
487                kind: name.clone(),
488                account,
489                enabled: Some(enabled),
490                configured,
491                status: ChannelStatus::Unknown,
492                sessions: sessions.map(|counts| counts.get(&name).copied().unwrap_or(0)),
493            }
494        })
495        .collect()
496}
497
498/// OpenClaw's channels as the orchestration codec reads `openclaw.json`:
499/// one row per channel or per account (`<kind>/<account>`), the kind,
500/// account and where `enabled` was set riding on the channel's extras.
501fn openclaw_rows(
502    loaded: &supercode_interchange::orchestration::codec::OpenclawLoaded,
503    sessions: Option<&BTreeMap<String, u64>>,
504) -> Vec<ChannelRow> {
505    loaded.orchestration.profiles["default"]
506        .channels
507        .iter()
508        .map(|(name, channel)| {
509            let text = |key: &str| {
510                channel
511                    .extra
512                    .get(key)
513                    .and_then(Value::as_str)
514                    .map(str::to_string)
515            };
516            let kind = text("kind").unwrap_or_else(|| name.clone());
517            ChannelRow {
518                name: name.clone(),
519                harness: HarnessId::OPENCLAW.to_string(),
520                kind: kind.clone(),
521                account: text("accountId"),
522                enabled: text("enabled_on").map(|_| channel.enabled),
523                configured: !channel.credentials.is_empty(),
524                status: ChannelStatus::Unknown,
525                sessions: sessions.map(|counts| counts.get(&kind).copied().unwrap_or(0)),
526            }
527        })
528        .collect()
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534
535    #[test]
536    fn unsupported_harness_is_refused_not_silently_empty() {
537        let error = list_channels(&HarnessHomes::default(), Some(HarnessId::CLAUDE_CODE))
538            .expect_err("claude-code channels are MCP-protocol declarations");
539        assert_eq!(
540            error,
541            ChannelError::UnsupportedHarness {
542                harness: HarnessId::CLAUDE_CODE.to_string()
543            }
544        );
545    }
546}