Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<channel>.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_<channel>_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/<channel>.md` with accurate config examples
2. Add or update the corresponding `test_channel_config_<channel>_inline` and
`test_channel_docs_<channel>_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/<channel>.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).
Expand Down
215 changes: 215 additions & 0 deletions crates/calciforge/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1542,4 +1542,219 @@ 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 that contains [[channels]], wrapping each in a
// minimal CalciforgeConfig envelope and parsing it. If any
// [[channels]] 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<String> {
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 {
Comment on lines +1568 to +1571
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extract_toml_blocks only enters TOML mode when a line trims to exactly ```toml. This misses valid Markdown fences like ```toml with trailing spaces or extra info-string attributes (which would silently skip blocks and reduce the effectiveness of the doc/schema sync). Consider loosening the check to starts_with("```toml") (after trimming) so the extraction matches common Markdown variations.

Suggested change
if line.trim() == "```toml" {
in_block = true;
current.clear();
} else if line.trim() == "```" && in_block {
let trimmed = line.trim();
if trimmed.starts_with("```toml") {
in_block = true;
current.clear();
} else if trimmed == "```" && in_block {

Copilot uses AI. Check for mistakes.
in_block = false;
blocks.push(current.clone());
} else if in_block {
current.push_str(line);
current.push('\n');
}
}
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extract_toml_blocks() silently drops an unterminated toml fence: if the markdown ends while `in_block` is still true, the current block is never pushed (and the doc tests can still pass). Consider detecting this and failing the test (e.g., `assert!(!in_block, "unterminated toml block")`) or pushing the final block so it gets parsed and fails appropriately.

Suggested change
}
}
assert!(!in_block, "unterminated ```toml block");

Copilot uses AI. Check for mistakes.
assert!(!in_block, "unclosed ```toml block in markdown");
blocks
}

fn channel_blocks_from_doc(markdown: &str) -> Vec<String> {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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,
vec!["@operator:example.com".to_string()]
);
}

#[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,
vec!["+15555550001".to_string()]
);
}

#[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");
let blocks = channel_blocks_from_doc(doc);
assert!(
!blocks.is_empty(),
"no [[channels]] TOML blocks found in telegram.md"
);
for block in blocks {
let cfg = parse_channel_block(&block);
assert_eq!(
cfg.channels[0].kind, "telegram",
"unexpected kind in block:\n{block}"
);
}
Comment on lines +1694 to +1707
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These doc tests will pass if channel_blocks_from_doc(doc) returns an empty Vec (e.g., if the [[channels]] example block is accidentally removed or extract_toml_blocks fails to detect it). Recommend capturing let blocks = channel_blocks_from_doc(doc); then assert!(!blocks.is_empty(), "no [[channels]] TOML blocks found") before iterating (same applies to the other test_channel_docs_* tests below).

Copilot uses AI. Check for mistakes.
Comment on lines +1701 to +1707
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These doc-validation tests index cfg.channels[0], which produces a less-informative panic if a fenced TOML block contains the string [[channels]] (e.g. in a comment) but doesn’t actually deserialize into any channel entries. Also, if a single fenced block contains multiple [[channels]] entries, only the first one is checked. Consider asserting cfg.channels.len() == 1 (or at least !cfg.channels.is_empty()) and validating the kind for all parsed channel entries; same applies to the other test_channel_docs_*_toml_blocks_valid loops.

Copilot uses AI. Check for mistakes.
}

#[test]
fn test_channel_docs_matrix_toml_blocks_valid() {
let doc = include_str!("../../../docs/channels/matrix.md");
let blocks = channel_blocks_from_doc(doc);
assert!(
!blocks.is_empty(),
"no [[channels]] TOML blocks found in matrix.md"
);
for block in blocks {
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");
let blocks = channel_blocks_from_doc(doc);
assert!(
!blocks.is_empty(),
"no [[channels]] TOML blocks found in signal.md"
);
for block in blocks {
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");
let blocks = channel_blocks_from_doc(doc);
assert!(
!blocks.is_empty(),
"no [[channels]] TOML blocks found in whatsapp.md"
);
for block in blocks {
let cfg = parse_channel_block(&block);
assert_eq!(
cfg.channels[0].kind, "whatsapp",
"unexpected kind in block:\n{block}"
);
}
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
116 changes: 116 additions & 0 deletions docs/channels/matrix.md
Original file line number Diff line number Diff line change
@@ -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; use `["*"]` to allow all room members; empty list is rejected at startup |
| `scan_messages` | no (`false`) | Enable inbound adversarial content scanning |
| `allow_chat_secret_set` | no (`false`) | Allow `!secure set` via Matrix (not recommended) |

Comment on lines +72 to +80
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The table header starts with ||, which breaks standard Markdown table rendering in many parsers. Use a single leading | for the header/separator rows (and ensure the column count matches the header).

Copilot uses AI. Check for mistakes.
## 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.
Loading
Loading