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::profiles::{read_json5, yaml_key, yaml_scalar};
54use crate::{HarnessHomes, HarnessId};
55
56/// Stable row schema shared by Rust, JSON-RPC, the SDKs, and the CLI.
57pub const CHANNELS_SCHEMA: &str = "supercode.channels.v1";
58
59/// Harnesses with a channel concept supercode reads, in product order.
60/// Every other harness id is [`ChannelError::UnsupportedHarness`].
61pub const CHANNEL_HARNESSES: &[&str] = &[
62    HarnessId::HERMES,
63    HarnessId::OPENCLAW,
64    HarnessId::ORCHESTRATOR,
65];
66
67/// Key names whose VALUE is a public account identifier, safe to emit. Read
68/// from a Hermes platform block's `extra` first, then its top level. Nothing
69/// outside this list is ever read for a value.
70///
71/// Transcribed from the identifiers `gateway/config.py::_apply_env_overrides`
72/// stores in `PlatformConfig.extra` (hermes-agent 0.21.0 on the build box):
73/// `client_id` (DingTalk), `app_id` (Feishu, QQ, Yuanbao), `bot_id` (WeCom),
74/// `corp_id` (WeCom callback), `phone_number_id` (WhatsApp Cloud), `account`
75/// (Signal), `account_id` (Weixin).
76pub const HERMES_ACCOUNT_KEYS: &[&str] = &[
77    "account",
78    "account_id",
79    "app_id",
80    "bot_id",
81    "client_id",
82    "corp_id",
83    "phone_number_id",
84    "user_id",
85];
86
87/// Key names whose VALUE is a public account identifier in an OpenClaw
88/// channel entry. The `accounts` MAP's keys are account ids in their own
89/// right and are used first; this list covers a single-account entry that
90/// names its account inline.
91pub const OPENCLAW_ACCOUNT_KEYS: &[&str] = &[
92    "accountId",
93    "account_id",
94    "account",
95    "teamId",
96    "appId",
97    "userId",
98];
99
100/// Env vars whose PRESENCE enables a Hermes platform, per platform.
101///
102/// Transcribed from `gateway/config.py::_ENV_ENABLE_CREDENTIALS`
103/// ("Env var(s) whose presence drives each platform's env-enable branch")
104/// plus the `api_server` branch, whose credential is `API_SERVER_KEY` and
105/// which that map does not carry because its branch is terminal.
106///
107/// The bool mirrors the branch's own conjunction: WhatsApp Cloud, e-mail,
108/// DingTalk, Feishu, WeCom, WeCom callback, BlueBubbles and Yuanbao require
109/// BOTH of their vars (`if a and b:`); Matrix, Weixin and QQ accept EITHER
110/// (`if a or b:`); single-var platforms read the same under both.
111const HERMES_ENV_CREDENTIALS: &[(&str, &[&str], bool)] = &[
112    ("telegram", &["TELEGRAM_BOT_TOKEN"], false),
113    ("discord", &["DISCORD_BOT_TOKEN"], false),
114    ("slack", &["SLACK_BOT_TOKEN"], false),
115    (
116        "whatsapp_cloud",
117        &[
118            "WHATSAPP_CLOUD_PHONE_NUMBER_ID",
119            "WHATSAPP_CLOUD_ACCESS_TOKEN",
120        ],
121        true,
122    ),
123    ("signal", &["SIGNAL_HTTP_URL"], false),
124    ("mattermost", &["MATTERMOST_TOKEN"], false),
125    ("matrix", &["MATRIX_ACCESS_TOKEN", "MATRIX_PASSWORD"], false),
126    ("homeassistant", &["HASS_TOKEN"], false),
127    (
128        "email",
129        &[
130            "EMAIL_ADDRESS",
131            "EMAIL_PASSWORD",
132            "EMAIL_IMAP_HOST",
133            "EMAIL_SMTP_HOST",
134        ],
135        true,
136    ),
137    ("sms", &["TWILIO_ACCOUNT_SID"], false),
138    (
139        "dingtalk",
140        &["DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"],
141        true,
142    ),
143    ("feishu", &["FEISHU_APP_ID", "FEISHU_APP_SECRET"], true),
144    ("wecom", &["WECOM_BOT_ID", "WECOM_SECRET"], true),
145    (
146        "wecom_callback",
147        &["WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET"],
148        true,
149    ),
150    ("weixin", &["WEIXIN_TOKEN", "WEIXIN_ACCOUNT_ID"], false),
151    (
152        "bluebubbles",
153        &["BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD"],
154        true,
155    ),
156    ("qqbot", &["QQ_APP_ID", "QQ_CLIENT_SECRET"], false),
157    ("yuanbao", &["YUANBAO_APP_ID", "YUANBAO_APP_SECRET"], true),
158    ("relay", &["GATEWAY_RELAY_URL"], false),
159    ("api_server", &["API_SERVER_KEY"], false),
160];
161
162/// Every key name that parses as a Hermes `Platform`, so a config key can be
163/// told apart from an ordinary setting.
164///
165/// The built-in members of `gateway/config.py::Platform` (hermes-agent 0.21.0
166/// on the build box) plus the bundled plugin adapters, which `Platform`
167/// admits through `_missing_` after scanning `plugins/platforms/`. `local` is
168/// omitted on purpose: it is Hermes's own CLI/TUI surface, not a transport
169/// into an external identity space, and `load_gateway_config`'s shared-key
170/// loop skips it too.
171pub const HERMES_PLATFORMS: &[&str] = &[
172    "a2a",
173    "api_server",
174    "bluebubbles",
175    "buzz",
176    "dingtalk",
177    "discord",
178    "email",
179    "feishu",
180    "google_chat",
181    "homeassistant",
182    "irc",
183    "line",
184    "matrix",
185    "mattermost",
186    "msgraph_webhook",
187    "ntfy",
188    "photon",
189    "qqbot",
190    "raft",
191    "relay",
192    "signal",
193    "simplex",
194    "slack",
195    "sms",
196    "teams",
197    "telegram",
198    "webhook",
199    "wecom",
200    "wecom_callback",
201    "weixin",
202    "whatsapp",
203    "whatsapp_cloud",
204    "yuanbao",
205];
206
207/// Whether a live probe answered, and what it said.
208///
209/// At the OBSERVED tier every row answers [`ChannelStatus::Unknown`]: both
210/// harnesses report a channel's connection state from a RUNNING gateway
211/// (`hermes gateway status`, `openclaw channels status` over the Gateway
212/// socket), which is the gateway-health concept, not this one. Reporting
213/// `up` from a config file would be a claim about a process nobody asked.
214#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
215#[serde(rename_all = "snake_case")]
216pub enum ChannelStatus {
217    /// A probe reached the channel's transport.
218    Up,
219    /// A probe ran and the channel's transport did not answer.
220    Down,
221    /// No cheap local probe exists for this harness's channels.
222    Unknown,
223}
224
225impl ChannelStatus {
226    /// Stable wire spelling, identical to the serde representation.
227    pub const fn as_str(self) -> &'static str {
228        match self {
229            Self::Up => "up",
230            Self::Down => "down",
231            Self::Unknown => "unknown",
232        }
233    }
234}
235
236/// One transport + account a harness is reachable on, uniform across
237/// harnesses.
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
239pub struct ChannelRow {
240    /// Unique handle within the harness: the transport id for a single
241    /// account (`telegram`), `<transport>/<account>` when the harness's
242    /// config splits one transport across several accounts.
243    pub name: String,
244    /// Owning harness id.
245    pub harness: String,
246    /// Transport id: a Hermes `Platform` value or an OpenClaw channel key
247    /// (`telegram`, `slack`, `discord`, `api_server`, `webhook`, …).
248    pub kind: String,
249    /// Public account id or label; `None` when the config names none. Never
250    /// a token, key or secret.
251    pub account: Option<String>,
252    /// Whether the harness would start this channel. `None` when the config
253    /// does not say and the harness's own default is not stated in a source
254    /// this workspace pins.
255    pub enabled: Option<bool>,
256    /// Whether the entry has what the harness needs to start it, judged
257    /// only by the PRESENCE of a credential key or credential env var.
258    pub configured: bool,
259    /// Connection state; always [`ChannelStatus::Unknown`] at this tier.
260    pub status: ChannelStatus,
261    /// Discovered sessions whose surface platform is this row's `kind`;
262    /// `None` when discovery could not run. Rows that share a `kind` across
263    /// accounts share the count — a session key names the transport, not
264    /// the account.
265    pub sessions: Option<u64>,
266}
267
268/// Read-only channel failures.
269#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
270pub enum ChannelError {
271    /// The harness has no channel concept supercode reads.
272    #[error("harness `{harness}` has no channel concept (channels exist for: {})", CHANNEL_HARNESSES.join(", "))]
273    UnsupportedHarness {
274        /// The harness id that was asked for.
275        harness: String,
276    },
277    /// The harness has channels, but not this one.
278    #[error("`{harness}` has no channel `{name}`")]
279    NotFound {
280        /// Harness that was searched.
281        harness: String,
282        /// Channel name that was not found.
283        name: String,
284    },
285}
286
287/// List every channel supercode can see, optionally restricted to one
288/// harness. Rows are ordered by harness (as in [`CHANNEL_HARNESSES`]) then
289/// by name.
290pub fn list_channels(
291    homes: &HarnessHomes,
292    harness: Option<&str>,
293) -> Result<Vec<ChannelRow>, ChannelError> {
294    if let Some(harness) = harness {
295        if !CHANNEL_HARNESSES.contains(&harness) {
296            return Err(ChannelError::UnsupportedHarness {
297                harness: harness.to_string(),
298            });
299        }
300    }
301    let mut rows = Vec::new();
302    for id in CHANNEL_HARNESSES {
303        if harness.is_some_and(|requested| requested != *id) {
304            continue;
305        }
306        let sessions = session_counts(homes, id);
307        match *id {
308            HarnessId::HERMES => rows.extend(hermes_rows(
309                HarnessId::HERMES,
310                homes.hermes.parent().unwrap_or(Path::new(".")),
311                sessions.as_ref(),
312                true,
313            )),
314            HarnessId::OPENCLAW => rows.extend(openclaw_rows(&homes.openclaw, sessions.as_ref())),
315            // ORC-7: the orchestrator declares its adapters in each profile
316            // folder's `platforms:` block, Hermes-shaped
317            // (`docs/ORCHESTRATOR-IR.md` §6), so the same reader runs per
318            // folder. Its env fallback is OFF: a credential is a
319            // `{dotenv: NAME}` / `{env: NAME}` REF in the folder, so an
320            // environment variable Hermes happens to read never conjures a
321            // channel the orchestrator never declared. A ref is a MAP under a
322            // credential-shaped key, which `is_credential_key` already counts
323            // as configured by name — the value is never read.
324            HarnessId::ORCHESTRATOR => {
325                for (_, dir) in crate::orchestrator_profile_dirs(&homes.orchestrator) {
326                    rows.extend(hermes_rows(
327                        HarnessId::ORCHESTRATOR,
328                        &dir,
329                        sessions.as_ref(),
330                        false,
331                    ));
332                }
333            }
334            _ => {}
335        }
336    }
337    Ok(rows)
338}
339
340/// Read one channel's row by harness and name. `status` is
341/// [`ChannelStatus::Unknown`] at this tier for every harness; the verb
342/// exists so the noun is complete and the driven tier has one door to fill.
343pub fn channel_status(
344    homes: &HarnessHomes,
345    harness: &str,
346    name: &str,
347) -> Result<ChannelRow, ChannelError> {
348    list_channels(homes, Some(harness))?
349        .into_iter()
350        .find(|row| row.name == name)
351        .ok_or_else(|| ChannelError::NotFound {
352            harness: harness.to_string(),
353            name: name.to_string(),
354        })
355}
356
357// ---------------------------------------------------------------------------
358// Session counts
359// ---------------------------------------------------------------------------
360
361/// Sessions per surface platform, from the SAME discovery rows
362/// `supercode sessions list` shows. `None` means discovery failed, which is
363/// unknown — never zero.
364fn session_counts(homes: &HarnessHomes, harness: &str) -> Option<BTreeMap<String, u64>> {
365    let query = crate::DiscoveryQuery {
366        harnesses: vec![HarnessId::new(harness)],
367        homes: homes.clone(),
368        ..Default::default()
369    };
370    let sessions = crate::HarnessCatalog::new().discover(&query).ok()?;
371    let mut counts: BTreeMap<String, u64> = BTreeMap::new();
372    for session in sessions {
373        if let Some(platform) = session
374            .nouns
375            .surface
376            .as_ref()
377            .and_then(|surface| surface.platform.as_ref())
378        {
379            *counts.entry(platform.clone()).or_default() += 1;
380        }
381    }
382    Some(counts)
383}
384
385// ---------------------------------------------------------------------------
386// Credentials — presence only
387// ---------------------------------------------------------------------------
388
389/// Whether a key NAME is a credential. Used to decide `configured` without
390/// ever reading the value, so a config full of tokens produces booleans and
391/// nothing else. Matches the credential spellings both harnesses write:
392/// `token`, `api_key`, `botToken`, `appToken`, `access_token`, `app_secret`,
393/// `client_secret`, `password`, and api_server's bare `key`.
394fn is_credential_key(key: &str) -> bool {
395    let lower = key.to_ascii_lowercase();
396    ["token", "key", "secret", "password", "credential"]
397        .iter()
398        .any(|marker| lower.ends_with(marker))
399}
400
401/// Whether the env credential(s) for a Hermes platform are present. Only
402/// presence is tested; no value is read. `None` means Hermes lists no env
403/// credential for this platform at all.
404fn hermes_env_credentials_present(platform: &str) -> Option<bool> {
405    let (_, vars, all) = HERMES_ENV_CREDENTIALS
406        .iter()
407        .find(|(name, _, _)| *name == platform)?;
408    let present = |name: &&str| std::env::var_os(name).is_some();
409    Some(if *all {
410        vars.iter().all(present)
411    } else {
412        vars.iter().any(present)
413    })
414}
415
416// ---------------------------------------------------------------------------
417// Hermes
418// ---------------------------------------------------------------------------
419
420/// Hermes channels are the platform blocks of `HERMES_HOME/config.yaml`.
421///
422/// `load_gateway_config` merges FOUR places into one platform map, later
423/// winning (`gateway/config.py::_merge_platform_map` and the shared-key loop
424/// after it) — verified against the real `~/.hermes/config.yaml` on the build
425/// box, which writes NONE of the first three and would have read as empty had
426/// only the documented `platforms:` key been honoured:
427///
428/// 1. `gateway.platforms.<platform>`
429/// 2. `platforms.<platform>` (the shape the ORCH-5 probe writes)
430/// 3. `gateway.<platform>` for any key that parses as a `Platform` value
431/// 4. a TOP-LEVEL `<platform>:` block, which is the only one whose `enabled`
432///    is treated as explicit (`enabled_was_explicit = _cfg_toplevel and …`)
433///
434/// [`HERMES_PLATFORMS`] is what makes 3 and 4 decidable: a key is a platform
435/// block only when its name is a `Platform` value. A platform Hermes would
436/// enable from the environment alone (`_apply_env_overrides`) is listed too,
437/// so an env-only install is not reported as empty.
438///
439/// `state_db` is `HarnessHomes::hermes` (`HERMES_HOME/state.db`).
440fn hermes_rows(
441    harness: &str,
442    home: &Path,
443    sessions: Option<&BTreeMap<String, u64>>,
444    env_fallback: bool,
445) -> Vec<ChannelRow> {
446    let config = std::fs::read_to_string(home.join("config.yaml")).unwrap_or_default();
447    let platforms = hermes_platform_blocks(&config);
448
449    let mut names: Vec<String> = platforms.iter().map(|(name, _)| name.clone()).collect();
450    if env_fallback {
451        for (platform, _, _) in HERMES_ENV_CREDENTIALS {
452            if hermes_env_credentials_present(platform) == Some(true)
453                && !names.iter().any(|name| name == platform)
454            {
455                names.push((*platform).to_string());
456            }
457        }
458    }
459    names.sort();
460    names.dedup();
461
462    names
463        .into_iter()
464        .map(|name| {
465            // Every place that declares this platform, lowest precedence
466            // first, so `enabled` reads the last one that says anything and
467            // `configured` / `account` see the union.
468            let blocks: Vec<&String> = platforms
469                .iter()
470                .filter(|(platform, _)| *platform == name)
471                .map(|(_, block)| block)
472                .collect();
473            // The `extra:` maps first, so an account id a platform bridges
474            // there wins over a same-named key at the block's top level.
475            let scopes: Vec<String> = blocks
476                .iter()
477                .map(|block| yaml_root_child(block, "extra"))
478                .chain(blocks.iter().map(|block| (*block).clone()))
479                .collect();
480            let env_present = env_fallback
481                .then(|| hermes_env_credentials_present(&name))
482                .flatten();
483            // `PlatformConfig.enabled` defaults to false, and
484            // `_enable_from_env` flips it on when the platform's credentials
485            // are in the environment and `enabled:` is not explicitly false.
486            let enabled = match blocks
487                .iter()
488                .filter_map(|block| yaml_scalar(block, "enabled"))
489                .next_back()
490            {
491                Some(explicit) => explicit == "true",
492                None => env_present == Some(true),
493            };
494            let configured = env_present == Some(true)
495                || scopes
496                    .iter()
497                    .flat_map(|scope| yaml_block_keys(scope))
498                    .any(|key| is_credential_key(&key))
499                // A platform Hermes lists no credential for authenticates
500                // some other way (session files, a bound port): declaring it
501                // is all it needs.
502                || env_present.is_none();
503            ChannelRow {
504                name: name.clone(),
505                harness: harness.to_string(),
506                kind: name.clone(),
507                account: HERMES_ACCOUNT_KEYS
508                    .iter()
509                    .find_map(|key| scopes.iter().find_map(|scope| yaml_scalar(scope, key)))
510                    .filter(|value| !value.is_empty()),
511                enabled: Some(enabled),
512                configured,
513                status: ChannelStatus::Unknown,
514                sessions: sessions.map(|counts| counts.get(&name).copied().unwrap_or(0)),
515            }
516        })
517        .collect()
518}
519
520/// Every `(platform, block)` pair the config declares, in Hermes's own merge
521/// order — `gateway.platforms.*`, then `platforms.*`, then Platform-named
522/// keys under `gateway:`, then top-level Platform-named blocks. A platform
523/// declared in several places appears once per place, so the caller can take
524/// the last `enabled` and the union of the keys.
525fn hermes_platform_blocks(config: &str) -> Vec<(String, String)> {
526    let is_platform = |name: &String| HERMES_PLATFORMS.contains(&name.as_str());
527    let top = yaml_block_names(config);
528    let gateway = yaml_root_child(config, "gateway");
529    let gateway_children = yaml_block_names(&gateway);
530
531    let mut blocks = yaml_block_names(&yaml_root_child(&gateway, "platforms"));
532    blocks.extend(yaml_block_names(&yaml_root_child(config, "platforms")));
533    blocks.extend(
534        gateway_children
535            .into_iter()
536            .filter(|(name, _)| is_platform(name)),
537    );
538    blocks.extend(top.into_iter().filter(|(name, _)| is_platform(name)));
539    blocks
540}
541
542/// The body of `key` at the block's OWN outermost indent, empty when the
543/// block has no such key.
544///
545/// `profiles::yaml_child` matches its key at ANY indent, which is right for
546/// the one nested path ORCH-10 reads but wrong here: a config with both
547/// `gateway.platforms:` and a top-level `platforms:` would answer the first
548/// occurrence for both, silently dropping one of Hermes's four merge
549/// sources.
550fn yaml_root_child(block: &str, key: &str) -> String {
551    yaml_block_names(block)
552        .into_iter()
553        .find(|(name, _)| name == key)
554        .map(|(_, body)| body)
555        .unwrap_or_default()
556}
557
558/// The immediate children of a YAML block — every key at the block's own
559/// outermost indent with the lines nested under it — in declaration order.
560/// One pass, so a key nested deeper can never be mistaken for a child.
561fn yaml_block_names(block: &str) -> Vec<(String, String)> {
562    let mut children: Vec<(String, String)> = Vec::new();
563    let Some(root) = yaml_root_indent(block) else {
564        return children;
565    };
566    let mut current: Option<String> = None;
567    for line in block.lines() {
568        let trimmed = line.trim_start();
569        if trimmed.is_empty() || trimmed.starts_with('#') {
570            continue;
571        }
572        let indent = line.len() - trimmed.len();
573        if indent > root {
574            if let Some(key) = &current {
575                if let Some((_, body)) = children.iter_mut().find(|(name, _)| name == key) {
576                    body.push_str(line);
577                    body.push('\n');
578                }
579            }
580            continue;
581        }
582        current = yaml_key(trimmed).map(str::to_string);
583        if let Some(key) = &current {
584            if !children.iter().any(|(name, _)| name == key) {
585                children.push((key.clone(), String::new()));
586            }
587        }
588    }
589    children
590}
591
592/// The key names at a YAML block's own outermost indent. Names only — this
593/// is how `configured` is judged without reading a value.
594fn yaml_block_keys(block: &str) -> Vec<String> {
595    yaml_block_names(block)
596        .into_iter()
597        .map(|(name, _)| name)
598        .collect()
599}
600
601/// The outermost indent of a block's live (non-blank, non-comment) lines.
602fn yaml_root_indent(block: &str) -> Option<usize> {
603    block
604        .lines()
605        .filter(|line| {
606            let trimmed = line.trim_start();
607            !trimmed.is_empty() && !trimmed.starts_with('#')
608        })
609        .map(|line| line.len() - line.trim_start().len())
610        .min()
611}
612
613// ---------------------------------------------------------------------------
614// OpenClaw
615// ---------------------------------------------------------------------------
616
617/// OpenClaw channels are `channels.<name>` entries in
618/// `<openclaw home>/openclaw.json` (read as JSON5), the file
619/// `openclaw channels add|remove|login|logout` writes and
620/// `openclaw channels list` reads. The shape is verified against the real
621/// `~/.openclaw/openclaw.json` on the build box: `channels.slack.enabled`
622/// plus the channel's own credential keys. `channels.<name>.accounts` splits
623/// one channel into one row per account id.
624fn openclaw_rows(home: &Path, sessions: Option<&BTreeMap<String, u64>>) -> Vec<ChannelRow> {
625    let config = read_json5(&home.join("openclaw.json"));
626    let Some(channels) = config.pointer("/channels").and_then(Value::as_object) else {
627        return Vec::new();
628    };
629    let mut rows = Vec::new();
630    for (kind, entry) in channels {
631        let count = sessions.map(|counts| counts.get(kind).copied().unwrap_or(0));
632        let accounts = account_entries(entry);
633        if accounts.is_empty() {
634            rows.push(openclaw_row(kind, kind, entry, None, entry, count));
635            continue;
636        }
637        for (id, account) in accounts {
638            rows.push(openclaw_row(
639                &format!("{kind}/{id}"),
640                kind,
641                entry,
642                Some(id),
643                &account,
644                count,
645            ));
646        }
647    }
648    rows.sort_by(|left, right| left.name.cmp(&right.name));
649    rows
650}
651
652/// One OpenClaw row. `entry` is the channel block and `scope` the block whose
653/// keys decide `enabled` / `configured` — the account's own block when the
654/// channel declares accounts, else the channel block itself.
655fn openclaw_row(
656    name: &str,
657    kind: &str,
658    entry: &Value,
659    account: Option<String>,
660    scope: &Value,
661    sessions: Option<u64>,
662) -> ChannelRow {
663    let account = account.or_else(|| {
664        OPENCLAW_ACCOUNT_KEYS
665            .iter()
666            .find_map(|key| entry.get(*key).and_then(Value::as_str))
667            .map(str::to_string)
668    });
669    // Enablement comes from the account's own entry first, then the
670    // channel's. `None` is honest: openclaw's default for an entry that
671    // omits `enabled` is not stated in any source this workspace pins.
672    let enabled = scope
673        .get("enabled")
674        .or_else(|| entry.get("enabled"))
675        .and_then(Value::as_bool);
676    let configured = has_credential_key(scope) || has_credential_key(entry);
677    ChannelRow {
678        name: name.to_string(),
679        harness: HarnessId::OPENCLAW.to_string(),
680        kind: kind.to_string(),
681        account,
682        enabled,
683        configured,
684        status: ChannelStatus::Unknown,
685        sessions,
686    }
687}
688
689/// `channels.<name>.accounts` as `(accountId, entry)` pairs, sorted by id.
690/// Both the object-keyed map and an array of `{ id | accountId }` objects
691/// are read; anything else means "this channel declares no accounts".
692fn account_entries(entry: &Value) -> Vec<(String, Value)> {
693    let mut accounts: Vec<(String, Value)> = match entry.get("accounts") {
694        Some(Value::Object(map)) => map
695            .iter()
696            .map(|(id, account)| (id.clone(), account.clone()))
697            .collect(),
698        Some(Value::Array(list)) => list
699            .iter()
700            .filter_map(|account| {
701                OPENCLAW_ACCOUNT_KEYS
702                    .iter()
703                    .find_map(|key| account.get(*key).and_then(Value::as_str))
704                    .or_else(|| account.get("id").and_then(Value::as_str))
705                    .map(|id| (id.to_string(), account.clone()))
706            })
707            .collect(),
708        _ => Vec::new(),
709    };
710    accounts.sort_by(|left, right| left.0.cmp(&right.0));
711    accounts
712}
713
714/// Whether a JSON object declares a credential, by key NAME only.
715fn has_credential_key(value: &Value) -> bool {
716    value
717        .as_object()
718        .is_some_and(|map| map.keys().any(|key| is_credential_key(key)))
719}
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724
725    #[test]
726    fn unsupported_harness_is_refused_not_silently_empty() {
727        let error = list_channels(&HarnessHomes::default(), Some(HarnessId::CLAUDE_CODE))
728            .expect_err("claude-code channels are MCP-protocol declarations");
729        assert_eq!(
730            error,
731            ChannelError::UnsupportedHarness {
732                harness: HarnessId::CLAUDE_CODE.to_string()
733            }
734        );
735    }
736
737    /// Credential detection is by key NAME, so no value is ever read.
738    #[test]
739    fn credential_keys_are_recognised_by_name_in_both_spellings() {
740        for key in [
741            "token",
742            "api_key",
743            "botToken",
744            "appToken",
745            "access_token",
746            "app_secret",
747            "client_secret",
748            "password",
749            "key",
750        ] {
751            assert!(is_credential_key(key), "`{key}` is a credential key");
752        }
753        for key in [
754            "enabled",
755            "port",
756            "host",
757            "app_id",
758            "reply_to_mode",
759            "extra",
760        ] {
761            assert!(!is_credential_key(key), "`{key}` is not a credential key");
762        }
763    }
764
765    #[test]
766    fn yaml_block_reader_lists_platform_entries_and_their_keys() {
767        let config = "platforms:\n  telegram:\n    enabled: true\n    token: \"SECRET\"\n  api_server:\n    enabled: false\n    extra:\n      key: \"SECRET\"\n      port: 8642\ngateway:\n  port: 1\n";
768        let platforms = yaml_root_child(config, "platforms");
769        let names: Vec<String> = yaml_block_keys(&platforms);
770        assert_eq!(names, ["telegram", "api_server"]);
771        let api = yaml_root_child(&platforms, "api_server");
772        assert_eq!(yaml_block_keys(&api), ["enabled", "extra"]);
773        assert_eq!(
774            yaml_block_keys(&yaml_root_child(&api, "extra")),
775            ["key", "port"]
776        );
777    }
778
779    /// Receipt-driven: the real `~/.hermes/config.yaml` on the build box
780    /// writes NO top-level `platforms:` key, so a reader that honoured only
781    /// the documented shape would report an install with channels as having
782    /// none. All four places `load_gateway_config` merges must read, and a
783    /// key that is not a `Platform` value must not become a channel.
784    #[test]
785    fn every_hermes_platform_block_shape_is_read() {
786        let config = concat!(
787            "gateway:\n",
788            "  platforms:\n",
789            "    discord:\n",
790            "      enabled: true\n",
791            "      token: \"x\"\n",
792            "  api_server:\n",
793            "    enabled: true\n",
794            "    extra:\n",
795            "      key: \"x\"\n",
796            "  profile_routes:\n",
797            "    - platform: slack\n",
798            "platforms:\n",
799            "  webhook:\n",
800            "    enabled: true\n",
801            "telegram:\n",
802            "  enabled: false\n",
803            "memory:\n",
804            "  enabled: true\n",
805        );
806        let blocks = hermes_platform_blocks(config);
807        let names: Vec<&str> = blocks.iter().map(|(name, _)| name.as_str()).collect();
808        assert_eq!(names, ["discord", "webhook", "api_server", "telegram"]);
809        // `memory` and `profile_routes` are settings, not transports.
810        assert!(!names.contains(&"memory"), "{names:?}");
811        assert!(!names.contains(&"profile_routes"), "{names:?}");
812    }
813
814    /// An account map splits one channel into one row per account, and the
815    /// account id is the row's handle — never a token.
816    #[test]
817    fn openclaw_accounts_split_a_channel_into_one_row_each() {
818        let entry: Value = serde_json::from_str(
819            r#"{"enabled": true, "accounts": {"T2": {"botToken": "x"}, "T1": {"enabled": false}}}"#,
820        )
821        .unwrap();
822        let ids: Vec<String> = account_entries(&entry)
823            .into_iter()
824            .map(|(id, _)| id)
825            .collect();
826        assert_eq!(ids, ["T1", "T2"]);
827        let rows = openclaw_rows(Path::new("/nonexistent-openclaw-home"), None);
828        assert!(rows.is_empty(), "a missing config declares no channels");
829    }
830}