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}