From 7e3b8e4bc6a8701cc6710ab88e2c7fa6db8e45b4 Mon Sep 17 00:00:00 2001 From: Librarian Date: Wed, 29 Apr 2026 19:09:05 +0000 Subject: [PATCH 1/4] docs: add channel setup guides with compile-tested TOML examples Adds per-channel setup docs (Telegram, Matrix, Signal, WhatsApp) to docs/channels/ as part of the public docs site (calciforge.org/channels/). Each guide covers: architecture diagram, prerequisites, config TOML, identity/routing wiring, and a verify step. The TOML config blocks in each doc are compile-tested: config.rs grows extract_toml_blocks() + test_channel_docs__toml_blocks_valid tests that load each markdown via include_str!, extract every fenced [[channels]] block, and parse it against the live CalciforgeConfig schema. If a field is renamed and the doc isn't updated, cargo test fails. Also adds test_channel_config__inline unit tests as schema-correct reference examples for each channel kind. AGENTS.md documents the standard and process: every channel needs a docs/channels/ entry, tests in config.rs, and both must be updated together when the schema changes. Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 31 ++++++ crates/calciforge/src/config.rs | 175 +++++++++++++++++++++++++++++++ docs/channels/matrix.md | 116 +++++++++++++++++++++ docs/channels/signal.md | 122 ++++++++++++++++++++++ docs/channels/telegram.md | 91 +++++++++++++++++ docs/channels/whatsapp.md | 176 ++++++++++++++++++++++++++++++++ docs/index.md | 8 ++ 7 files changed, 719 insertions(+) create mode 100644 docs/channels/matrix.md create mode 100644 docs/channels/signal.md create mode 100644 docs/channels/telegram.md create mode 100644 docs/channels/whatsapp.md diff --git a/AGENTS.md b/AGENTS.md index 717a360a..67e6f745 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,37 @@ bash scripts/install-git-hooks.sh # one-time Mixed: older crates on `2021`, newer on `2024`. Known and tracked. Don't bump in a PR that isn't explicitly about edition migration. +## Documentation standard for channels (and future subsystems) + +Channel setup guides live in `docs/channels/.md` and are part of the +public docs site ([calciforge.org/channels/…](https://calciforge.org/channels/telegram)). + +**Every channel doc must have:** +- An architecture diagram (ASCII text art showing the message flow) +- Prerequisites section (external accounts, tokens, running services) +- A `[[channels]]` TOML config block — this is the source of truth for config examples +- An identity/routing TOML block showing how to wire users to the channel +- A verify/health-check step + +**The TOML blocks are compile-tested.** `crates/calciforge/src/config.rs` contains +`test_channel_docs__toml_blocks_valid` tests that load each markdown file +via `include_str!`, extract every fenced `toml` block containing `[[channels]]`, +and parse it against the live `CalciforgeConfig` schema. If a field is renamed or +removed and the doc isn't updated, `cargo test -p calciforge` fails. + +**When adding or modifying a channel:** +1. Update or create `docs/channels/.md` with accurate config examples +2. Add or update the corresponding `test_channel_config__inline` and + `test_channel_docs__toml_blocks_valid` tests in `config.rs` +3. Run `cargo test -p calciforge` to confirm all doc tests pass +4. Update `docs/index.md` if adding a new channel + +**When renaming a `ChannelConfig` field:** +1. Run `cargo test -p calciforge` — the doc-block tests will fail, naming the broken doc +2. Fix the markdown file, re-run tests, then commit both together + +Do not add a new channel without a corresponding `docs/channels/.md`. + ## When working on a specific area, also read - `crates/host-agent/AGENTS.md` — host-agent security model (Unix-permissions enforcement, fail-closed, mTLS CN→Unix user mapping). diff --git a/crates/calciforge/src/config.rs b/crates/calciforge/src/config.rs index c84524ae..c6a37c11 100644 --- a/crates/calciforge/src/config.rs +++ b/crates/calciforge/src/config.rs @@ -1542,4 +1542,179 @@ weight = 20 "error should name the missing field, got: {msg}" ); } + + // ── Channel config example tests ───────────────────────────────────────── + // + // Each test below does two things: + // + // 1. Parses the inline TOML example that appears in the corresponding + // docs/channels/*.md setup guide, verifying the example is + // syntactically valid and matches the live ChannelConfig schema. + // + // 2. Loads the markdown file via include_str! and scans every fenced + // ```toml block, wrapping each in a minimal CalciforgeConfig envelope + // and parsing it. If any code block in the doc drifts out of sync + // with the schema, cargo test catches it here. + // + // When you add or rename a ChannelConfig field, run `cargo test -p calciforge` + // and fix any failures in these tests before updating the docs. + + fn extract_toml_blocks(markdown: &str) -> Vec { + let mut blocks = Vec::new(); + let mut in_block = false; + let mut current = String::new(); + for line in markdown.lines() { + if line.trim() == "```toml" { + in_block = true; + current.clear(); + } else if line.trim() == "```" && in_block { + in_block = false; + blocks.push(current.clone()); + } else if in_block { + current.push_str(line); + current.push('\n'); + } + } + blocks + } + + fn channel_blocks_from_doc(markdown: &str) -> Vec { + extract_toml_blocks(markdown) + .into_iter() + .filter(|b| b.contains("[[channels]]")) + .collect() + } + + fn parse_channel_block(block: &str) -> CalciforgeConfig { + let wrapped = format!("[calciforge]\nversion = 2\n\n{block}"); + toml::from_str(&wrapped).unwrap_or_else(|e| { + panic!("channel config block failed to parse:\n{block}\nerror: {e}") + }) + } + + #[test] + fn test_channel_config_telegram_inline() { + let raw = r#" +[calciforge] +version = 2 + +[[channels]] +kind = "telegram" +enabled = true +bot_token_file = "~/.calciforge/secrets/telegram-token" +"#; + let cfg: CalciforgeConfig = toml::from_str(raw).expect("telegram channel config"); + assert_eq!(cfg.channels[0].kind, "telegram"); + assert!(cfg.channels[0].enabled); + assert_eq!( + cfg.channels[0].bot_token_file.as_deref(), + Some("~/.calciforge/secrets/telegram-token") + ); + } + + #[test] + fn test_channel_config_matrix_inline() { + let raw = r#" +[calciforge] +version = 2 + +[[channels]] +kind = "matrix" +enabled = true +homeserver = "https://matrix.example.com" +access_token_file = "~/.calciforge/secrets/matrix-token" +room_id = "!abc123def456:example.com" +allowed_users = ["@operator:example.com"] +"#; + let cfg: CalciforgeConfig = toml::from_str(raw).expect("matrix channel config"); + assert_eq!(cfg.channels[0].kind, "matrix"); + assert_eq!( + cfg.channels[0].homeserver.as_deref(), + Some("https://matrix.example.com") + ); + assert_eq!(cfg.channels[0].allowed_users, ["@operator:example.com"]); + } + + #[test] + fn test_channel_config_signal_inline() { + let raw = r#" +[calciforge] +version = 2 + +[[channels]] +kind = "signal" +enabled = true +zeroclaw_endpoint = "http://127.0.0.1:18789" +zeroclaw_auth_token = "REPLACE_WITH_AUTH_TOKEN" +webhook_listen = "0.0.0.0:18796" +webhook_path = "/webhooks/signal" +allowed_numbers = ["+15555550001"] +"#; + let cfg: CalciforgeConfig = toml::from_str(raw).expect("signal channel config"); + assert_eq!(cfg.channels[0].kind, "signal"); + assert_eq!( + cfg.channels[0].webhook_listen.as_deref(), + Some("0.0.0.0:18796") + ); + assert_eq!(cfg.channels[0].allowed_numbers, ["+15555550001"]); + } + + #[test] + fn test_channel_config_whatsapp_inline() { + let raw = r#" +[calciforge] +version = 2 + +[[channels]] +kind = "whatsapp" +enabled = true +zeroclaw_endpoint = "http://127.0.0.1:18789" +zeroclaw_auth_token = "REPLACE_WITH_AUTH_TOKEN" +webhook_listen = "0.0.0.0:18795" +webhook_path = "/webhooks/whatsapp" +allowed_numbers = ["+15555550001"] +"#; + let cfg: CalciforgeConfig = toml::from_str(raw).expect("whatsapp channel config"); + assert_eq!(cfg.channels[0].kind, "whatsapp"); + assert_eq!( + cfg.channels[0].webhook_listen.as_deref(), + Some("0.0.0.0:18795") + ); + } + + #[test] + fn test_channel_docs_telegram_toml_blocks_valid() { + let doc = include_str!("../../../docs/channels/telegram.md"); + for block in channel_blocks_from_doc(doc) { + let cfg = parse_channel_block(&block); + assert_eq!(cfg.channels[0].kind, "telegram", "unexpected kind in block:\n{block}"); + } + } + + #[test] + fn test_channel_docs_matrix_toml_blocks_valid() { + let doc = include_str!("../../../docs/channels/matrix.md"); + for block in channel_blocks_from_doc(doc) { + let cfg = parse_channel_block(&block); + assert_eq!(cfg.channels[0].kind, "matrix", "unexpected kind in block:\n{block}"); + } + } + + #[test] + fn test_channel_docs_signal_toml_blocks_valid() { + let doc = include_str!("../../../docs/channels/signal.md"); + for block in channel_blocks_from_doc(doc) { + let cfg = parse_channel_block(&block); + assert_eq!(cfg.channels[0].kind, "signal", "unexpected kind in block:\n{block}"); + } + } + + #[test] + fn test_channel_docs_whatsapp_toml_blocks_valid() { + let doc = include_str!("../../../docs/channels/whatsapp.md"); + for block in channel_blocks_from_doc(doc) { + let cfg = parse_channel_block(&block); + assert_eq!(cfg.channels[0].kind, "whatsapp", "unexpected kind in block:\n{block}"); + } + } } diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md new file mode 100644 index 00000000..1d28a3b0 --- /dev/null +++ b/docs/channels/matrix.md @@ -0,0 +1,116 @@ +--- +layout: default +title: Matrix Channel Setup +--- + +# Matrix Channel + +Calciforge connects to Matrix via the [Client-Server API v3](https://spec.matrix.org/v1.9/client-server-api/) +using **HTTP long-polling** (`/sync`). No webhook endpoint or open firewall port required. + +> **No end-to-end encryption.** The Matrix channel sends and receives plaintext `m.text` +> events only. E2EE is not supported due to compile-time dependency conflicts in the +> current workspace. Do not use this channel in rooms where E2EE is required. + +## Architecture + +``` +Matrix user ──→ homeserver ──→ Calciforge (/sync long-poll) + │ + identity resolution + (allowed_users check) + agent dispatch + │ +Matrix user ←── homeserver ←── Calciforge (PUT /send/m.room.message) +``` + +## Prerequisites + +1. **Register a Matrix account** for the bot on your homeserver (or matrix.org for testing). + The account does not need to be a human account — a plain `@calciforge-bot:example.com` + works fine. +2. **Generate an access token** for that account: + +```bash +curl -s -X POST 'https://matrix.example.com/_matrix/client/v3/login' \ + -H 'Content-Type: application/json' \ + -d '{ + "type": "m.login.password", + "user": "@calciforge-bot:example.com", + "password": "botpassword" + }' | grep access_token +``` + + Copy the `access_token` value from the response. + +3. **Find the room ID** for the room you want the bot to listen in: + - In most clients: room settings → Advanced → Internal room ID + - Format: `!abc123def456:example.com` + - The bot will auto-accept room invites from users listed in `allowed_users` + +## Step 1: Save the access token + +```bash +install -m 600 /dev/null ~/.calciforge/secrets/matrix-token +printf '%s' 'syt_YOUR_ACCESS_TOKEN_HERE' > ~/.calciforge/secrets/matrix-token +``` + +## Step 2: Channel config + +Add to `~/.calciforge/config.toml`: + +```toml +[[channels]] +kind = "matrix" +enabled = true +homeserver = "https://matrix.example.com" +access_token_file = "~/.calciforge/secrets/matrix-token" +room_id = "!abc123def456:example.com" +allowed_users = ["@operator:example.com"] +``` + +| Field | Required | Description | +|---|---|---| +| `homeserver` | yes | Full URL of the Matrix homeserver | +| `access_token_file` | yes | Path to file containing the bot's access token | +| `room_id` | yes | Internal room ID (starts with `!`) | +| `allowed_users` | yes | Matrix user IDs permitted to send commands; empty list allows all room members (not recommended) | +| `scan_messages` | no (`false`) | Enable inbound adversarial content scanning | +| `allow_chat_secret_set` | no (`false`) | Allow `!secure set` via Matrix (not recommended) | + +## Step 3: Identity config + +The alias `id` is the full Matrix user ID including homeserver: + +```toml +[[identities]] +id = "operator" +display_name = "Alice" +role = "admin" +aliases = [ + { channel = "matrix", id = "@alice:example.com" }, +] + +[[routing]] +identity = "operator" +default_agent = "librarian" +allowed_agents = ["librarian"] +``` + +Messages from Matrix users not in `allowed_users` are ignored before identity resolution. +Messages from `allowed_users` members with no matching identity alias are also dropped. + +## Step 4: Invite the bot + +Invite `@calciforge-bot:example.com` to the room. Calciforge will auto-accept the invite +if the inviting user's Matrix ID is in `allowed_users`. + +## Step 5: Verify + +```bash +calciforge doctor # validates config +calciforge # start; send a message in the room +``` + +The bot responds to commands (`!help`, `!ping`, `!agents`, etc.) and routes other messages +to the default agent for the sender's identity. diff --git a/docs/channels/signal.md b/docs/channels/signal.md new file mode 100644 index 00000000..107bf3ec --- /dev/null +++ b/docs/channels/signal.md @@ -0,0 +1,122 @@ +--- +layout: default +title: Signal Channel Setup +--- + +# Signal Channel + +Calciforge receives Signal messages via a **webhook** posted by a running +[ZeroClaw](https://github.com/zeroclaw-labs/zeroclaw) or OpenClaw instance that owns +the Signal session. Replies are sent back through the same gateway. + +## Architecture + +``` +Signal user ──→ ZeroClaw (Signal session host) ──→ POST /webhooks/signal ──→ Calciforge + │ + identity resolution │ + agent dispatch │ + ↓ +Signal user ←── ZeroClaw (Signal session host) ←── POST /tools/invoke ←── Calciforge reply +``` + +## Prerequisites + +- A running ZeroClaw or OpenClaw instance with an active Signal session and its auth token + +## Step 1: Channel config + +Add to `~/.calciforge/config.toml`: + +```toml +[[channels]] +kind = "signal" +enabled = true + +# ZeroClaw / OpenClaw gateway that owns the Signal session. +# Calciforge sends replies by POSTing to {zeroclaw_endpoint}/tools/invoke. +# Use 127.0.0.1 if co-located; use the host IP if running on a separate machine. +zeroclaw_endpoint = "http://127.0.0.1:18789" +zeroclaw_auth_token = "REPLACE_WITH_AUTH_TOKEN" + +# Calciforge's webhook listener — ZeroClaw will POST incoming Signal messages here. +# Must be reachable from wherever ZeroClaw is running. +webhook_listen = "0.0.0.0:18796" +webhook_path = "/webhooks/signal" + +# Optional HMAC-SHA256 secret for X-Hub-Signature-256 header verification. +# Set the same value in ZeroClaw as its webhook_forward_secret. +# webhook_secret = "change-me-to-a-random-secret" + +# Allowed sender phone numbers in E.164 format. +# Must match identity aliases below. +allowed_numbers = ["+15555550001"] +``` + +| Field | Required | Default | Description | +|---|---|---|---| +| `zeroclaw_endpoint` | yes | — | URL of the ZeroClaw/OpenClaw gateway | +| `zeroclaw_auth_token` | yes | — | Bearer token for the gateway | +| `webhook_listen` | no | `0.0.0.0:18796` | Address Calciforge listens on for incoming Signal webhooks | +| `webhook_path` | no | `/webhooks/signal` | URL path for incoming webhooks | +| `webhook_secret` | no | — | HMAC-SHA256 secret; when set, Calciforge rejects unsigned requests | +| `allowed_numbers` | yes | `[]` | E.164 phone numbers allowed to interact | +| `scan_messages` | no | `false` | Enable inbound adversarial content scanning | + +## Step 2: ZeroClaw forwarding config + +In ZeroClaw's config, point Signal message delivery at Calciforge's webhook: + +```toml +[channels_config.signal] +webhook_forward_url = "http://127.0.0.1:18796/webhooks/signal" +# webhook_forward_secret = "change-me-to-a-random-secret" # must match Calciforge's webhook_secret +allowed_numbers = ["+15555550001"] +``` + +## Step 3: Identity config + +The alias `id` is the E.164 phone number. The leading `+` is required: + +```toml +[[identities]] +id = "operator" +display_name = "Alice" +role = "admin" +aliases = [ + { channel = "signal", id = "+15555550001" }, +] + +[[routing]] +identity = "operator" +default_agent = "librarian" +allowed_agents = ["librarian"] +``` + +Phone numbers from `allowed_numbers` that don't match any identity alias are silently +dropped. Calciforge normalises the `from` field to E.164 before lookup (adds `+` prefix +if absent, strips spaces and dashes). + +## Step 4: Firewall + +If ZeroClaw and Calciforge are on the same host, no changes needed — both use localhost. + +If they're on separate hosts, open port 18796 on the Calciforge host from the ZeroClaw host: + +```bash +ufw allow from to any port 18796 +``` + +## Step 5: Verify + +```bash +calciforge doctor # validates config +calciforge # start; send a Signal message from an allowed number +``` + +Check logs for `identity resolved` on a known number, or `no identity for signal/` +on an unknown one. Run a health check against the webhook listener: + +```bash +curl http://localhost:18796/health +``` diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md new file mode 100644 index 00000000..80b025c1 --- /dev/null +++ b/docs/channels/telegram.md @@ -0,0 +1,91 @@ +--- +layout: default +title: Telegram Channel Setup +--- + +# Telegram Channel + +Calciforge connects to Telegram via the [Telegram Bot API](https://core.telegram.org/bots/api) +using **long-polling** — no public webhook endpoint or open firewall port required. + +## Architecture + +``` +Telegram user ──→ Telegram Bot API ──→ Calciforge (long-poll) + │ + identity resolution + agent dispatch + │ +Telegram user ←── Telegram Bot API ←── Calciforge reply +``` + +## Prerequisites + +1. **Create a bot** via [@BotFather](https://t.me/BotFather): send `/newbot`, follow the + prompts, copy the token it returns (format: `1234567890:ABCDEFghijklmnopqrstuvwxyz01234567`) +2. **Find your Telegram user ID** (numeric, not your username): + - Send any message to your new bot, then run `calciforge` — the user ID appears in logs + on the first unrecognised message + - Or send a message to [@userinfobot](https://t.me/userinfobot) — it replies with your ID + +## Step 1: Save the bot token + +Write the raw token string (no extra whitespace) to a file readable only by the Calciforge +process: + +```bash +install -m 600 /dev/null ~/.calciforge/secrets/telegram-token +printf '%s' '1234567890:ABCDEFghijklmnopqrstuvwxyz01234567' \ + > ~/.calciforge/secrets/telegram-token +``` + +## Step 2: Channel config + +Add to `~/.calciforge/config.toml`: + +```toml +[[channels]] +kind = "telegram" +enabled = true +bot_token_file = "~/.calciforge/secrets/telegram-token" +``` + +Optional fields: + +| Field | Default | Description | +|---|---|---| +| `scan_messages` | `false` | Enable inbound adversarial content scanning via the security proxy | +| `allow_chat_secret_set` | `false` | Allow `!secure set NAME=value` via Telegram chat (not recommended — the value appears in chat history and provider logs) | + +## Step 3: Identity config + +Each user you want to allow needs an `[[identities]]` entry. The alias `id` is the +**numeric Telegram user ID** — not a username, not a phone number: + +```toml +[[identities]] +id = "operator" +display_name = "Alice" +role = "admin" +aliases = [ + { channel = "telegram", id = "7000000001" }, +] + +[[routing]] +identity = "operator" +default_agent = "librarian" +allowed_agents = ["librarian"] +``` + +Messages from Telegram user IDs not listed in any identity's aliases are silently dropped. + +## Step 4: Verify + +```bash +calciforge doctor # validates config before starting +calciforge # start; send /start to your bot in Telegram +``` + +On first message from a known identity, you'll see `identity resolved` in the logs and the +bot will route to the default agent. On an unknown user ID, you'll see +`no identity for telegram/` — use that output to find the ID if needed. diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md new file mode 100644 index 00000000..6336d7de --- /dev/null +++ b/docs/channels/whatsapp.md @@ -0,0 +1,176 @@ +--- +layout: default +title: WhatsApp Channel Setup +--- + +# WhatsApp Channel + +Calciforge receives WhatsApp messages via a **webhook** posted by a running +[ZeroClaw](https://github.com/zeroclaw-labs/zeroclaw) or OpenClaw instance that owns +the WhatsApp Web session. Replies are sent back through the same gateway. + +## Architecture + +``` +WA user ──→ ZeroClaw (WhatsApp Web session host) ──→ POST /webhooks/whatsapp ──→ Calciforge + │ + identity resolution │ + agent dispatch │ + ↓ +WA user ←── ZeroClaw (WhatsApp Web session host) ←── POST /tools/invoke ←── Calciforge reply +``` + +## Prerequisites + +- A running ZeroClaw or OpenClaw instance with an active WhatsApp Web session and its auth token + +## Step 1: Channel config + +Add to `~/.calciforge/config.toml`: + +```toml +[[channels]] +kind = "whatsapp" +enabled = true + +# ZeroClaw / OpenClaw gateway that owns the WhatsApp Web session. +# Calciforge sends replies by POSTing to {zeroclaw_endpoint}/tools/invoke. +# Use 127.0.0.1 if co-located; use the host IP if running on a separate machine. +zeroclaw_endpoint = "http://127.0.0.1:18789" +zeroclaw_auth_token = "REPLACE_WITH_AUTH_TOKEN" + +# Calciforge's webhook listener — ZeroClaw will POST incoming WA messages here. +# Must be reachable from wherever ZeroClaw is running. +webhook_listen = "0.0.0.0:18795" +webhook_path = "/webhooks/whatsapp" + +# Optional HMAC-SHA256 secret for X-Hub-Signature-256 header verification. +# Set the same value in ZeroClaw as its webhook_forward_secret. +# webhook_secret = "change-me-to-a-random-secret" + +# Allowed sender phone numbers in E.164 format. +# Must match identity aliases below. +allowed_numbers = ["+15555550001"] +``` + +| Field | Required | Default | Description | +|---|---|---|---| +| `zeroclaw_endpoint` | yes | — | URL of the ZeroClaw/OpenClaw gateway | +| `zeroclaw_auth_token` | yes | — | Bearer token for the gateway | +| `webhook_listen` | no | `0.0.0.0:18795` | Address Calciforge listens on for incoming WhatsApp webhooks | +| `webhook_path` | no | `/webhooks/whatsapp` | URL path for incoming webhooks | +| `webhook_secret` | no | — | HMAC-SHA256 secret; when set, Calciforge rejects requests with invalid or missing `X-Hub-Signature-256` headers | +| `allowed_numbers` | yes | `[]` | E.164 phone numbers allowed to interact | +| `scan_messages` | no | `false` | Enable inbound adversarial content scanning | + +## Step 2: ZeroClaw forwarding config + +In ZeroClaw's config, point WhatsApp message delivery at Calciforge's webhook. Also +configure the QR-linked session path: + +```toml +[channels_config.whatsapp] +session_path = "~/.zeroclaw/whatsapp-session.db" +webhook_forward_url = "http://127.0.0.1:18795/webhooks/whatsapp" +# webhook_forward_secret = "change-me-to-a-random-secret" # must match Calciforge's webhook_secret +allowed_numbers = ["+15555550001"] +``` + +Start ZeroClaw — it prints a QR code. Scan from WhatsApp on your phone to pair the session. +After pairing, the session persists to the SQLite DB and survives restarts. + +## Step 3: Identity config + +The alias `id` is the E.164 phone number. The leading `+` is required: + +```toml +[[identities]] +id = "operator" +display_name = "Alice" +role = "admin" +aliases = [ + { channel = "whatsapp", id = "+15555550001" }, +] + +[[routing]] +identity = "operator" +default_agent = "librarian" +allowed_agents = ["librarian"] +``` + +Phone numbers from `allowed_numbers` that don't match any identity alias are silently +dropped. Calciforge normalises the `from` field to E.164 before lookup. + +## Step 4: Firewall + +If ZeroClaw and Calciforge are on the same host, no changes needed — both use localhost. + +If they're on separate hosts, open port 18795 on the Calciforge host from the ZeroClaw host: + +```bash +ufw allow from to any port 18795 +``` + +## Step 5: Verify + +```bash +calciforge doctor # validates config +calciforge # start; send a WhatsApp message from an allowed number +``` + +Health check the webhook listener and test with a synthetic payload: + +```bash +curl http://localhost:18795/health + +curl -X POST http://localhost:18795/webhooks/whatsapp \ + -H "Content-Type: application/json" \ + -d '{ + "object": "whatsapp_business_account", + "entry": [{ + "changes": [{ + "value": { + "messages": [{ + "from": "15555550001", + "type": "text", + "text": { "body": "test" }, + "timestamp": "1699999999" + }] + } + }] + }] + }' +``` + +A `200 ok` response means the webhook is reachable. The message will be dropped (unknown +identity) unless `15555550001` is in an identity alias. + +## Webhook payload format + +Calciforge accepts the standard WhatsApp Cloud API format. The `from` field may omit the +leading `+` — Calciforge normalises to E.164 before identity lookup. + +## Reply API + +Calciforge sends replies by POSTing to `{zeroclaw_endpoint}/tools/invoke`: + +```json +{ + "tool": "message", + "args": { + "action": "send", + "channel": "whatsapp", + "target": "+15555550001", + "message": "Agent reply text here" + } +} +``` + +ZeroClaw must have a live WhatsApp Web session for the reply to reach the user. + +## HMAC verification + +When `webhook_secret` is set, Calciforge verifies the `X-Hub-Signature-256` header on +every incoming request using HMAC-SHA256. Requests with a missing or invalid signature +are rejected with HTTP 401. Set the same secret in ZeroClaw as `webhook_forward_secret` +to keep the two sides in sync. diff --git a/docs/index.md b/docs/index.md index 0fb2dfdc..6ca055aa 100644 --- a/docs/index.md +++ b/docs/index.md @@ -536,6 +536,14 @@ calciforge-secrets ref BRAVE_API_KEY Today: Telegram, Matrix, WhatsApp, Signal. Optional voice forwarding on channels that support it. +Per-channel setup guides (config reference + TOML examples tested against +the live schema in CI): + +- [Telegram](channels/telegram) — long-poll, no open port required +- [Matrix](channels/matrix) — HTTP long-poll; note: no E2EE +- [Signal](channels/signal) — webhook via ZeroClaw/OpenClaw gateway +- [WhatsApp](channels/whatsapp) — webhook via ZeroClaw/OpenClaw gateway + ```toml # /etc/calciforge/config.toml — channel configuration [[channels]] From 86553af3a860a3b1eb05e8ee9baa0a5b970c8302 Mon Sep 17 00:00:00 2001 From: Librarian Date: Wed, 29 Apr 2026 19:25:48 +0000 Subject: [PATCH 2/4] style: cargo fmt on channel config tests --- crates/calciforge/src/config.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/calciforge/src/config.rs b/crates/calciforge/src/config.rs index c6a37c11..9764b032 100644 --- a/crates/calciforge/src/config.rs +++ b/crates/calciforge/src/config.rs @@ -1687,7 +1687,10 @@ allowed_numbers = ["+15555550001"] let doc = include_str!("../../../docs/channels/telegram.md"); for block in channel_blocks_from_doc(doc) { let cfg = parse_channel_block(&block); - assert_eq!(cfg.channels[0].kind, "telegram", "unexpected kind in block:\n{block}"); + assert_eq!( + cfg.channels[0].kind, "telegram", + "unexpected kind in block:\n{block}" + ); } } @@ -1696,7 +1699,10 @@ allowed_numbers = ["+15555550001"] let doc = include_str!("../../../docs/channels/matrix.md"); for block in channel_blocks_from_doc(doc) { let cfg = parse_channel_block(&block); - assert_eq!(cfg.channels[0].kind, "matrix", "unexpected kind in block:\n{block}"); + assert_eq!( + cfg.channels[0].kind, "matrix", + "unexpected kind in block:\n{block}" + ); } } @@ -1705,7 +1711,10 @@ allowed_numbers = ["+15555550001"] let doc = include_str!("../../../docs/channels/signal.md"); for block in channel_blocks_from_doc(doc) { let cfg = parse_channel_block(&block); - assert_eq!(cfg.channels[0].kind, "signal", "unexpected kind in block:\n{block}"); + assert_eq!( + cfg.channels[0].kind, "signal", + "unexpected kind in block:\n{block}" + ); } } @@ -1714,7 +1723,10 @@ allowed_numbers = ["+15555550001"] let doc = include_str!("../../../docs/channels/whatsapp.md"); for block in channel_blocks_from_doc(doc) { let cfg = parse_channel_block(&block); - assert_eq!(cfg.channels[0].kind, "whatsapp", "unexpected kind in block:\n{block}"); + assert_eq!( + cfg.channels[0].kind, "whatsapp", + "unexpected kind in block:\n{block}" + ); } } } From b36f0f305b83753e2cebf18b5ed2e76f23cdae5d Mon Sep 17 00:00:00 2001 From: Librarian Date: Wed, 29 Apr 2026 19:36:56 +0000 Subject: [PATCH 3/4] docs: generalize doc standard to all crates and config sections Broadens the documentation standard from channels-only to a project-wide gold standard covering all user-facing config, public library APIs, and user-facing features across all crates. Key points: - Library crates (secrets-client, adversary-detector, etc.) use native Rust doctests (cargo test --doc -p ) - Binary crates (calciforge, host-agent) use the include_str! + unit test pattern established for channels - Every pub struct field needs a doc comment; every TOML example in docs/ must be compile-tested - Defines the five-section structure all setup guides must follow - Table of which doc file maps to which test for each config area - Explicit rule: no new config section, channel, or public API without tested docs; PRs without docs should not be merged Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 131 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 101 insertions(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 67e6f745..5c626086 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,36 +73,107 @@ bash scripts/install-git-hooks.sh # one-time Mixed: older crates on `2021`, newer on `2024`. Known and tracked. Don't bump in a PR that isn't explicitly about edition migration. -## Documentation standard for channels (and future subsystems) - -Channel setup guides live in `docs/channels/.md` and are part of the -public docs site ([calciforge.org/channels/…](https://calciforge.org/channels/telegram)). - -**Every channel doc must have:** -- An architecture diagram (ASCII text art showing the message flow) -- Prerequisites section (external accounts, tokens, running services) -- A `[[channels]]` TOML config block — this is the source of truth for config examples -- An identity/routing TOML block showing how to wire users to the channel -- A verify/health-check step - -**The TOML blocks are compile-tested.** `crates/calciforge/src/config.rs` contains -`test_channel_docs__toml_blocks_valid` tests that load each markdown file -via `include_str!`, extract every fenced `toml` block containing `[[channels]]`, -and parse it against the live `CalciforgeConfig` schema. If a field is renamed or -removed and the doc isn't updated, `cargo test -p calciforge` fails. - -**When adding or modifying a channel:** -1. Update or create `docs/channels/.md` with accurate config examples -2. Add or update the corresponding `test_channel_config__inline` and - `test_channel_docs__toml_blocks_valid` tests in `config.rs` -3. Run `cargo test -p calciforge` to confirm all doc tests pass -4. Update `docs/index.md` if adding a new channel - -**When renaming a `ChannelConfig` field:** -1. Run `cargo test -p calciforge` — the doc-block tests will fail, naming the broken doc -2. Fix the markdown file, re-run tests, then commit both together - -Do not add a new channel without a corresponding `docs/channels/.md`. +## Documentation standard (gold standard — applies to all crates) + +**The rule:** every user-facing config section, public API, and user-facing feature must +have documentation with examples that are verified by the test suite. Docs that aren't +tested go stale silently; tested docs can't. + +Channels (`docs/channels/`) were the first area to reach this standard and serve as the +reference implementation. All other config sections and crates follow the same pattern. + +### What needs docs + +- **Every `pub struct` in `config.rs`** — all fields documented; a TOML example that + parses against the live schema and is tested +- **Every user-facing feature** — setup guide in `docs/` covering prerequisites, + config, identity wiring (where applicable), and a verify step +- **Every public API in library crates** — at least one doctest per public function / + type showing the happy path; complex types get a full usage example + +### Two testing mechanisms — use the right one for the crate type + +**Library crates** (`secrets-client`, `adversary-detector`, `clashd`, `mcp-server`, +`paste-server`, `security-proxy`): use native Rust **doctests** in `///` comments. +`cargo test --doc -p ` compiles and runs them. If an API changes and the +example is wrong, the build fails. + +```rust +/// Resolves a secret name to its value. +/// +/// ```no_run +/// # use secrets_client::SecretsClient; +/// let client = SecretsClient::new(); +/// let value = client.resolve("MY_API_KEY")?; +/// # Ok::<(), anyhow::Error>(()) +/// ``` +pub fn resolve(&self, name: &str) -> anyhow::Result { ... } +``` + +**Binary crates** (`calciforge`, `host-agent`): use **`include_str!` + unit tests** +in the relevant source file's `#[cfg(test)]` block. The test loads the markdown doc, +extracts fenced TOML blocks, and parses them against the live config schema. + +```rust +#[test] +fn test_agent_docs_toml_blocks_valid() { + let doc = include_str!("../../../docs/agents.md"); + for block in doc_blocks_with(doc, "[[agents]]") { + parse_as_config(&block); // panics with file + block on failure + } +} +``` + +The channel tests in `config.rs` (`test_channel_docs_*_toml_blocks_valid`) are the +canonical example — copy that pattern for each new config section. + +### Doc file locations + +| What | Location | Tested by | +|---|---|---| +| Channel setup guides | `docs/channels/.md` | `config.rs` unit tests | +| Agent config | `docs/agents.md` | `config.rs` unit tests | +| Routing / identity | `docs/routing.md` | `config.rs` unit tests | +| Model gateway (alloys, cascades, dispatchers, exec models) | `docs/model-gateway.md` | `config.rs` unit tests | +| Security / proxy config | `docs/security-gateway.md` | `config.rs` unit tests | +| Library crate APIs | `///` doc comments in source | `cargo test --doc -p ` | +| host-agent setup | `docs/host-agent.md` | `host-agent` unit tests | + +### Structure for setup guides in `docs/` + +Every setup guide covering a user-facing feature must have these sections, in order: + +1. **Architecture** — ASCII diagram showing data flow end to end +2. **Prerequisites** — external accounts, tokens, running services, dependencies +3. **Config** — TOML block(s) with every required field shown; optional fields in a table +4. **Identity / routing** (where applicable) — how to wire a user to the feature +5. **Verify** — commands to confirm it's working (health check, log output, smoke test) + +### Rules for every agent and contributor + +**When adding a config field to any `pub struct`:** +1. Add a `///` doc comment to the field explaining what it does and its default +2. Update the TOML example in the relevant `docs/` file +3. Run `cargo test -p calciforge` — the doc-block tests will fail if the example is now + invalid, telling you exactly which file to fix +4. Commit the code change and the doc update together — never in separate PRs + +**When adding a new config section:** +1. Create `docs/
.md` with all five structure sections above +2. Add `test_
_docs_toml_blocks_valid` in the relevant `#[cfg(test)]` block +3. Link from `docs/index.md` + +**When adding a public function to a library crate:** +1. Write at least one `///` doctest showing the happy path +2. Run `cargo test --doc -p ` to confirm it compiles and passes +3. For functions with meaningful error paths, add a second doctest for the failure case + +**When renaming any field or function:** +- `cargo test` will name every broken doc example +- Fix markdown files and doctests in the same commit as the rename + +Do not add a new channel, config section, or public library API without corresponding +tested documentation. A PR that adds functionality without docs should not be merged. ## When working on a specific area, also read From 0f162da85782ebca5c6c4353dd180a5f61ab21eb Mon Sep 17 00:00:00 2001 From: Librarian Date: Wed, 29 Apr 2026 20:28:21 +0000 Subject: [PATCH 4/4] fix(docs): fix SecretsClient example in AGENTS.md The doctest example used secrets_client::SecretsClient which doesn't exist. The real public API is FnoxClient with an async get() method. Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5c626086..de19a938 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,15 +99,17 @@ reference implementation. All other config sections and crates follow the same p example is wrong, the build fails. ```rust -/// Resolves a secret name to its value. +/// Fetches a secret value by name. /// /// ```no_run -/// # use secrets_client::SecretsClient; -/// let client = SecretsClient::new(); -/// let value = client.resolve("MY_API_KEY")?; -/// # Ok::<(), anyhow::Error>(()) +/// # use secrets_client::FnoxClient; +/// # #[tokio::main] async fn main() -> anyhow::Result<()> { +/// let client = FnoxClient::new(); +/// let value = client.get("MY_API_KEY").await?; +/// # Ok(()) +/// # } /// ``` -pub fn resolve(&self, name: &str) -> anyhow::Result { ... } +pub async fn get(&self, name: &str) -> Result { ... } ``` **Binary crates** (`calciforge`, `host-agent`): use **`include_str!` + unit tests**