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) = ¤t {
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) = ¤t {
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}