diff --git a/BACKLOG.md b/BACKLOG.md index 55a40102..f56456f7 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -76,6 +76,13 @@ - [ ] Rust client SDK for host-agent - [ ] Python bindings - [ ] CLI admin tool +- [ ] Explore a local web channel for desktop/LAN testing that uses the same + identity, routing, message-envelope, artifact, and proxy policy paths as + Telegram, Matrix, and text channels +- [ ] Prototype a native ACP client adapter using the `agent-client-protocol` + Rust crate, with Zed's `codex-acp` as the first smoke target +- [ ] Evaluate ACP orchestrators such as AgentPool, cagent, and fast-agent as + recipe-backed async work backends - [ ] Architecture decision records (ADRs) ### Observability diff --git a/Cargo.lock b/Cargo.lock index 1e778aa5..c12e0466 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1870,7 +1870,7 @@ checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" dependencies = [ "parse-zoneinfo", "phf 0.11.3", - "phf_codegen", + "phf_codegen 0.11.3", ] [[package]] @@ -2171,6 +2171,17 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "cookie-factory" version = "0.3.3" @@ -2180,6 +2191,24 @@ dependencies = [ "futures", ] +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -2250,6 +2279,15 @@ dependencies = [ "winnow 0.6.24", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -3147,6 +3185,25 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" +[[package]] +name = "env_filter" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", +] + +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "env_filter", + "log", +] + [[package]] name = "envmnt" version = "0.10.4" @@ -3428,6 +3485,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide 0.8.9", + "zlib-rs", ] [[package]] @@ -4086,7 +4144,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "886aa8ec755382a1fdf4651f6e6ec01f2f3bf49f2cb0f068b9a74cafd574a715" dependencies = [ "prost 0.13.5", - "prost-types", + "prost-types 0.13.5", "tonic 0.12.3", ] @@ -4118,7 +4176,7 @@ dependencies = [ "google-cloud-gax 0.19.2", "google-cloud-googleapis", "google-cloud-token", - "prost-types", + "prost-types 0.13.5", "serde", "serde_json", "thiserror 1.0.69", @@ -5569,7 +5627,7 @@ dependencies = [ "is-terminal", "itertools 0.10.5", "lalrpop-util", - "petgraph", + "petgraph 0.6.5", "regex", "regex-syntax 0.6.29", "string_cache", @@ -6048,6 +6106,12 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "md5" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" + [[package]] name = "memchr" version = "2.8.0" @@ -6192,6 +6256,26 @@ dependencies = [ "tokio", ] +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "async-lock", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "event-listener 5.4.1", + "futures-util", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -6202,6 +6286,12 @@ dependencies = [ "pxfm", ] +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + [[package]] name = "nanohtml2text" version = "0.2.1" @@ -6921,6 +7011,17 @@ dependencies = [ "indexmap 2.13.0", ] +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset 0.5.7", + "hashbrown 0.15.5", + "indexmap 2.13.0", +] + [[package]] name = "phf" version = "0.11.3" @@ -6940,16 +7041,35 @@ dependencies = [ "phf_shared 0.12.1", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared 0.13.1", +] + [[package]] name = "phf_codegen" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ - "phf_generator", + "phf_generator 0.11.3", "phf_shared 0.11.3", ] +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + [[package]] name = "phf_generator" version = "0.11.3" @@ -6960,13 +7080,23 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + [[package]] name = "phf_macros" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ - "phf_generator", + "phf_generator 0.11.3", "phf_shared 0.11.3", "proc-macro2", "quote", @@ -6991,6 +7121,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.11" @@ -7295,6 +7434,23 @@ dependencies = [ "prost-derive 0.14.3", ] +[[package]] +name = "prost-build" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +dependencies = [ + "heck 0.4.1", + "itertools 0.10.5", + "log", + "multimap", + "petgraph 0.8.3", + "prost 0.14.3", + "prost-types 0.14.3", + "regex", + "tempfile", +] + [[package]] name = "prost-derive" version = "0.13.5" @@ -7330,12 +7486,50 @@ dependencies = [ "prost 0.13.5", ] +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost 0.14.3", +] + +[[package]] +name = "protobuf" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" +dependencies = [ + "once_cell", + "protobuf-support", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-support" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "pxfm" version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" +dependencies = [ + "image", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -8940,6 +9134,15 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-big-array" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f" +dependencies = [ + "serde", +] + [[package]] name = "serde_bytes" version = "0.11.19" @@ -9295,6 +9498,12 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "similar" version = "2.7.0" @@ -9523,7 +9732,7 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" dependencies = [ - "phf_generator", + "phf_generator 0.11.3", "phf_shared 0.11.3", "proc-macro2", "quote", @@ -9726,6 +9935,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "take_mut" version = "0.2.2" @@ -9902,7 +10117,7 @@ dependencies = [ "fnv", "nom 7.1.3", "phf 0.11.3", - "phf_codegen", + "phf_codegen 0.11.3", ] [[package]] @@ -10277,6 +10492,27 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-websockets" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-sink", + "http 1.4.0", + "httparse", + "rand 0.10.1", + "ring", + "rustls-pki-types", + "simdutf8", + "tokio", + "tokio-rustls 0.26.4", + "tokio-util", +] + [[package]] name = "toml" version = "0.5.11" @@ -10677,6 +10913,26 @@ dependencies = [ "rustc-hash 2.1.1", ] +[[package]] +name = "typed-builder" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "typed-path" version = "0.12.3" @@ -10892,6 +11148,37 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "cookie_store", + "log", + "percent-encoding", + "rustls 0.23.37", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf8-zero", + "webpki-roots 1.0.6", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http 1.4.0", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -10941,6 +11228,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -11049,6 +11342,223 @@ dependencies = [ "utf8parse", ] +[[package]] +name = "wa-rs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fecb468bdfe1e7d4c06a1bd12908c66edaca59024862cb64757ad11c3b948b1" +dependencies = [ + "anyhow", + "async-channel 2.5.0", + "async-trait", + "base64 0.22.1", + "bytes", + "chrono", + "dashmap", + "env_logger", + "hex", + "log", + "moka", + "prost 0.14.3", + "rand 0.9.2", + "rand_core 0.10.1", + "scopeguard", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "wa-rs-binary", + "wa-rs-core", + "wa-rs-proto", +] + +[[package]] +name = "wa-rs-appstate" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3845137b3aead2d99de7c6744784bf2f5a908be9dc97a3dbd7585dc40296925c" +dependencies = [ + "anyhow", + "bytemuck", + "hex", + "hkdf 0.12.4", + "log", + "prost 0.14.3", + "serde", + "serde-big-array", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "wa-rs-binary", + "wa-rs-libsignal", + "wa-rs-proto", +] + +[[package]] +name = "wa-rs-binary" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b30a6e11aebb39c07392675256ead5e2570c31382bd4835d6ddc877284b6be" +dependencies = [ + "flate2", + "phf 0.13.1", + "phf_codegen 0.13.1", + "serde", + "serde_json", +] + +[[package]] +name = "wa-rs-core" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed13bb2aff2de43fc4dd821955f03ea48a1d31eda3c80efe6f905898e304d11f" +dependencies = [ + "aes 0.8.4", + "aes-gcm 0.10.3", + "anyhow", + "async-channel 2.5.0", + "async-trait", + "base64 0.22.1", + "bytes", + "chrono", + "ctr 0.9.2", + "flate2", + "hex", + "hkdf 0.12.4", + "hmac 0.12.1", + "log", + "md5", + "once_cell", + "pbkdf2", + "prost 0.14.3", + "protobuf", + "rand 0.9.2", + "rand_core 0.10.1", + "serde", + "serde-big-array", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "typed-builder", + "wa-rs-appstate", + "wa-rs-binary", + "wa-rs-derive", + "wa-rs-libsignal", + "wa-rs-noise", + "wa-rs-proto", +] + +[[package]] +name = "wa-rs-derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75c03f610c9bc960e653d5d6d2a4cced9013bedbe5e6e8948787bbd418e4137c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "wa-rs-libsignal" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3471be8ff079ae4959fcddf2e7341281e5c6756bdc6a66454ea1a8e474d14576" +dependencies = [ + "aes 0.8.4", + "aes-gcm 0.10.3", + "arrayref", + "async-trait", + "cbc 0.1.2", + "chrono", + "ctr 0.9.2", + "curve25519-dalek", + "derive_more 2.1.1", + "displaydoc", + "ghash 0.5.1", + "hex", + "hkdf 0.12.4", + "hmac 0.12.1", + "itertools 0.14.0", + "log", + "prost 0.14.3", + "rand 0.9.2", + "serde", + "sha1", + "sha2 0.10.9", + "subtle", + "thiserror 2.0.18", + "uuid", + "wa-rs-proto", + "x25519-dalek", +] + +[[package]] +name = "wa-rs-noise" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3efb3891c1e22ce54646dc581e34e79377dc402ed8afb11a7671c5ef629b3ae" +dependencies = [ + "aes-gcm 0.10.3", + "anyhow", + "bytes", + "hkdf 0.12.4", + "log", + "prost 0.14.3", + "rand 0.9.2", + "rand_core 0.10.1", + "sha2 0.10.9", + "thiserror 2.0.18", + "wa-rs-binary", + "wa-rs-libsignal", + "wa-rs-proto", +] + +[[package]] +name = "wa-rs-proto" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ada50ee03752f0e66ada8cf415ed5f90d572d34039b058ce23d8b13493e510" +dependencies = [ + "prost 0.14.3", + "prost-build", + "serde", +] + +[[package]] +name = "wa-rs-tokio-transport" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc638c168949dc99cbb756a776869898d4ae654b36b90d5f7ce2d32bf92a404" +dependencies = [ + "anyhow", + "async-channel 2.5.0", + "async-trait", + "bytes", + "futures-util", + "http 1.4.0", + "log", + "rustls 0.23.37", + "tokio", + "tokio-rustls 0.26.4", + "tokio-websockets", + "wa-rs-core", + "webpki-roots 1.0.6", +] + +[[package]] +name = "wa-rs-ureq-http" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d0c7fff8a7bd93d0c17af8d797a3934144fa269fe47a615635f3bf04238806" +dependencies = [ + "anyhow", + "async-trait", + "tokio", + "ureq", + "wa-rs-core", +] + [[package]] name = "wait-timeout" version = "0.2.1" @@ -11234,7 +11744,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" dependencies = [ "phf 0.11.3", - "phf_codegen", + "phf_codegen 0.11.3", "string_cache", "string_cache_codegen", ] @@ -12193,6 +12703,7 @@ dependencies = [ "portable-atomic", "prometheus", "prost 0.14.3", + "qrcode", "rand 0.10.1", "ratatui", "regex", @@ -12206,6 +12717,7 @@ dependencies = [ "rustls-pki-types", "schemars 1.2.1", "serde", + "serde-big-array", "serde_json", "sha2 0.10.9", "shellexpand", @@ -12225,6 +12737,12 @@ dependencies = [ "tracing-subscriber", "urlencoding", "uuid", + "wa-rs", + "wa-rs-binary", + "wa-rs-core", + "wa-rs-proto", + "wa-rs-tokio-transport", + "wa-rs-ureq-http", "webpki-roots 1.0.6", "which", "zeroclaw-macros", @@ -12339,6 +12857,12 @@ dependencies = [ "typed-path", ] +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" diff --git a/README.md b/README.md index e94605e3..c96af039 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ being treated as daily-driver infrastructure. | Per-secret destination allowlists | Working | [Outbound traffic gating](https://calciforge.org/#outbound-traffic-gating) | | Local paste UI for one-shot and bulk `.env` secret input | Working | [Secret management](https://calciforge.org/#secret-management) | | MCP and CLI tools for agent-facing secret-name discovery, with no value readback | Working | [Agent-facing tools](https://calciforge.org/#agent-facing-tools-mcp) | -| Telegram, Matrix, WhatsApp, and Signal routing | Working | [Multi-channel chat](https://calciforge.org/#multi-channel-chat) | +| Telegram, Matrix, WhatsApp, Signal, and text/iMessage routing | Working | [Multi-channel chat](https://calciforge.org/#multi-channel-chat) | | OpenAI-compatible model gateway, provider routing, model aliases, alloys, cascades, dispatchers, exec models, and local model switching | Working | [Model gateway](docs/model-gateway.md) | | Codex CLI and OpenClaw Codex subscription/OAuth integration paths | Working | [Codex integration](docs/codex-openclaw-integration.md) | | `calciforge doctor` config/state/endpoint diagnostics | Working | [Quick Start](#quick-start) | @@ -65,8 +65,8 @@ again after editing config or moving services. It validates config, checks referenced secret files without printing values, flags stale active-agent/model state, detects suspicious self-routing into the local model gateway, warns if the Calciforge -service itself has ambient proxy env, checks whether subprocess agents -define explicit proxy env, warns about externally managed agent daemons +service itself has ambient proxy env, flags subprocess agents that explicitly +set proxy env, warns about externally managed agent daemons whose outbound proxy environment cannot be proven, validates configured scanner policy files and rule syntax, and can probe configured agent endpoints. Use `--no-network` for a purely local check. @@ -75,10 +75,18 @@ Channel-based secret input is intentionally being de-emphasized because chat transports can retain plaintext values. Prefer the local paste UI or direct `fnox` input for new secrets. -Route Claude Code or another HTTP-speaking agent process through the gateway. -The installer and examples bias toward setting this on managed subprocess -agents directly; for external daemons, set it on the agent process or its -service manager, not on the Calciforge daemon: +Do not put proxy variables on the Calciforge daemon itself; that can route +Calciforge's own provider and control-plane traffic through its security proxy. +Do not assume CLI agents can be wrapped by setting `HTTP_PROXY` or +`HTTPS_PROXY`; Codex, Claude, ACPX, npm-backed adapters, and streaming clients +may use CONNECT, WebSockets, or browser-backed auth flows that the current +proxy cannot inspect and may break. Use OpenAI-compatible gateway routes, +explicit fetch/tool integrations, or tested wrappers for traffic that must pass +through `security-proxy`. + +For externally managed agent daemons that Calciforge does not launch, proxying +has to be configured on that daemon or its service manager and validated +against `security-proxy` logs: ```bash export HTTP_PROXY=http://127.0.0.1:8888 @@ -105,7 +113,6 @@ id = "codex" kind = "codex-cli" model = "gpt-5.5" timeout_ms = 600000 -env = { HTTP_PROXY = "http://127.0.0.1:8888", NO_PROXY = "localhost,127.0.0.1,::1" } [[routing]] identity = "owner" @@ -181,7 +188,6 @@ bash scripts/install-git-hooks.sh - [Roadmap](docs/roadmap/) - [Staging test matrix](docs/staging-test-matrix.md) - [Channel secret-input deprecation note](docs/roadmap/channel-secret-input-deprecation.md) -- [Internal research and planning notes](research/) ## License diff --git a/crates/calciforge/Cargo.toml b/crates/calciforge/Cargo.toml index 5d16424e..1410c701 100644 --- a/crates/calciforge/Cargo.toml +++ b/crates/calciforge/Cargo.toml @@ -35,7 +35,7 @@ axum = "0.7" # Embedded Signal channel implementation. The crate brings its own # SignalChannel that talks to signal-cli-rest-api, so we no longer host a # webhook receiver inside calciforge for Signal. -zeroclawlabs = { workspace = true } +zeroclawlabs = { workspace = true, features = ["whatsapp-web"] } # Secret resolution helpers secrets-client = { path = "../secrets-client" } diff --git a/crates/calciforge/Dockerfile b/crates/calciforge/Dockerfile index a5d00ce1..974ddcea 100644 --- a/crates/calciforge/Dockerfile +++ b/crates/calciforge/Dockerfile @@ -17,7 +17,7 @@ COPY --from=builder /app/target/release/security-proxy /usr/local/bin/security-p # Create config directory RUN mkdir -p /root/.calciforge -EXPOSE 8888 18792 18793 18794 18795 18796 18797 +EXPOSE 8888 18792 18793 18794 18795 18796 18797 18798 ENTRYPOINT ["/usr/local/bin/calciforge"] CMD ["--config", "/root/.calciforge/config.toml"] diff --git a/crates/calciforge/examples/config.toml b/crates/calciforge/examples/config.toml index e6f164f3..8b37b2ce 100644 --- a/crates/calciforge/examples/config.toml +++ b/crates/calciforge/examples/config.toml @@ -83,7 +83,6 @@ args = [ "--skip-git-repo-check", "-" ] -env = { HTTP_PROXY = "http://127.0.0.1:8888", HTTPS_PROXY = "http://127.0.0.1:8888", NO_PROXY = "localhost,127.0.0.1,::1" } timeout_ms = 600000 [agents.registry] @@ -99,7 +98,6 @@ id = "dirac" kind = "dirac-cli" command = "dirac" args = ["--yolo", "--json"] -env = { HTTP_PROXY = "http://127.0.0.1:8888", HTTPS_PROXY = "http://127.0.0.1:8888", NO_PROXY = "localhost,127.0.0.1,::1" } timeout_ms = 600000 [agents.registry] diff --git a/crates/calciforge/src/adapters/acpx.rs b/crates/calciforge/src/adapters/acpx.rs index 6d84540a..296c847e 100644 --- a/crates/calciforge/src/adapters/acpx.rs +++ b/crates/calciforge/src/adapters/acpx.rs @@ -14,6 +14,9 @@ use tracing::{debug, info}; use crate::adapters::{AdapterError, AgentAdapter, DispatchContext}; +/// Shared cwd for ACPX sessions created through Calciforge. +pub const ACPX_SESSION_DIR: &str = "/tmp/acpx-sessions"; + /// ACPX adapter — wraps acpx CLI for ACP agent communication pub struct AcpxAdapter { agent_name: String, @@ -36,7 +39,7 @@ impl AcpxAdapter { _args: args.unwrap_or_default(), env: env.unwrap_or_default(), timeout_ms: timeout_ms.unwrap_or(300_000), - session_dir: PathBuf::from("/tmp/acpx-sessions"), + session_dir: PathBuf::from(ACPX_SESSION_DIR), } } @@ -92,7 +95,9 @@ impl AcpxAdapter { let output = Command::new("acpx") .arg(&self.agent_name) .arg("sessions") - .arg("new") + .arg("ensure") + .arg("--name") + .arg(session_name) .current_dir(&self.session_dir) .envs(&self.env) .output() @@ -142,6 +147,35 @@ impl AcpxAdapter { .to_string() } + async fn run_session_prompt( + &self, + message: &str, + session: Option<&str>, + ) -> Result { + let mut cmd = Command::new("acpx"); + cmd.arg("--format").arg("text").arg(&self.agent_name); + if let Some(session) = session { + cmd.arg("--session").arg(session); + } + cmd.arg("prompt") + .arg(message) + .current_dir(&self.session_dir) + .envs(&self.env) + .kill_on_drop(true) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let timeout = std::time::Duration::from_millis(self.timeout_ms); + let child = cmd + .spawn() + .map_err(|e| AdapterError::Unavailable(format!("Failed to spawn acpx: {}", e)))?; + + tokio::time::timeout(timeout, child.wait_with_output()) + .await + .map_err(|_| AdapterError::Unavailable("acpx prompt timed out".to_string()))? + .map_err(|e| AdapterError::Unavailable(format!("Failed to run acpx: {}", e))) + } + /// Execute one-shot prompt (no session persistence) async fn exec_prompt(&self, message: &str) -> Result { self.ensure_session_dir().await?; @@ -156,6 +190,7 @@ impl AcpxAdapter { .arg(message) .current_dir(&self.session_dir) .envs(&self.env) + .kill_on_drop(true) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -190,40 +225,38 @@ impl AcpxAdapter { } /// Send prompt to persistent session - async fn session_prompt(&self, message: &str) -> Result { + async fn session_prompt( + &self, + message: &str, + session: Option<&str>, + ) -> Result { self.ensure_session_dir().await?; // Use cwd session (default session name) - info!(agent = %self.agent_name, "Running acpx prompt with session"); - - let mut cmd = Command::new("acpx"); - cmd.arg("--format") - .arg("text") - .arg(&self.agent_name) - .arg("prompt") - .arg(message) - .current_dir(&self.session_dir) - .envs(&self.env) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - let timeout = std::time::Duration::from_millis(self.timeout_ms); - let child = cmd - .spawn() - .map_err(|e| AdapterError::Unavailable(format!("Failed to spawn acpx: {}", e)))?; - - let result = tokio::time::timeout(timeout, child.wait_with_output()).await; + info!( + agent = %self.agent_name, + session = ?session, + "Running acpx prompt with session" + ); - match result { - Ok(Ok(output)) => { + match self.run_session_prompt(message, session).await { + Ok(output) => { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); // Session might not exist — try creating it if stderr.contains("session") || stderr.contains("not found") { info!("Session not found, creating..."); - self.ensure_session("cwd").await?; - // Retry - return self.exec_prompt(message).await; + self.ensure_session(session.unwrap_or("cwd")).await?; + let retry = self.run_session_prompt(message, session).await?; + if retry.status.success() { + let stdout = String::from_utf8_lossy(&retry.stdout); + return Ok(Self::strip_acpx_noise(&stdout)); + } + let retry_stderr = String::from_utf8_lossy(&retry.stderr); + return Err(AdapterError::Protocol(format!( + "acpx prompt failed: {}", + retry_stderr + ))); } return Err(AdapterError::Protocol(format!( "acpx prompt failed: {}", @@ -233,13 +266,7 @@ impl AcpxAdapter { let stdout = String::from_utf8_lossy(&output.stdout); Ok(Self::strip_acpx_noise(&stdout)) } - Ok(Err(e)) => Err(AdapterError::Unavailable(format!( - "Failed to run acpx: {}", - e - ))), - Err(_) => Err(AdapterError::Unavailable( - "acpx prompt timed out".to_string(), - )), + Err(e) => Err(e), } } } @@ -262,8 +289,9 @@ impl AgentAdapter for AcpxAdapter { }; // Try session mode first, fall back to exec - match self.session_prompt(&message).await { + match self.session_prompt(&message, ctx.session).await { Ok(response) => Ok(response), + Err(e @ AdapterError::Protocol(_)) if ctx.session.is_some() => Err(e), Err(AdapterError::Protocol(_)) => { // Session error — try one-shot exec self.exec_prompt(&message).await diff --git a/crates/calciforge/src/adapters/artifact_cli.rs b/crates/calciforge/src/adapters/artifact_cli.rs index 8209a239..0fdd8c19 100644 --- a/crates/calciforge/src/adapters/artifact_cli.rs +++ b/crates/calciforge/src/adapters/artifact_cli.rs @@ -401,12 +401,33 @@ mod tests { let mut file = std::fs::File::create(&path).expect("create script"); writeln!(file, "#!/bin/sh").expect("write shebang"); writeln!(file, "{body}").expect("write body"); + file.sync_all().expect("sync script"); let mut perms = file.metadata().expect("script metadata").permissions(); + drop(file); perms.set_mode(0o755); std::fs::set_permissions(&path, perms).expect("chmod script"); path } + fn script_adapter( + script: &Path, + args: Option>, + artifact_root: PathBuf, + max_artifact_bytes: u64, + ) -> ArtifactCliAdapter { + let mut shell_args = vec![script.display().to_string()]; + shell_args.extend(args.unwrap_or_default()); + ArtifactCliAdapter::with_artifact_root( + "/bin/sh".to_string(), + Some(shell_args), + HashMap::new(), + None, + Some(5000), + artifact_root, + max_artifact_bytes, + ) + } + #[tokio::test] async fn dispatch_captures_artifact_and_stdout() { let temp = tempfile::tempdir().expect("tempdir"); @@ -414,12 +435,9 @@ mod tests { temp.path(), "cat >/dev/null\nprintf '\\211PNG\\r\\n\\032\\n' > \"$CALCIFORGE_ARTIFACT_DIR/out.png\"\necho generated image", ); - let adapter = ArtifactCliAdapter::with_artifact_root( - script.display().to_string(), - None, - HashMap::new(), + let adapter = script_adapter( + &script, None, - Some(5000), temp.path().join("artifacts"), DEFAULT_MAX_ARTIFACT_BYTES, ); @@ -442,12 +460,9 @@ mod tests { temp.path(), "read task\nprintf '%s' \"$1\" > \"$CALCIFORGE_ARTIFACT_DIR/arg.txt\"\nprintf '%s' \"$task\"", ); - let adapter = ArtifactCliAdapter::with_artifact_root( - script.display().to_string(), + let adapter = script_adapter( + &script, Some(vec![MESSAGE_PLACEHOLDER.to_string()]), - HashMap::new(), - None, - Some(5000), temp.path().join("artifacts"), DEFAULT_MAX_ARTIFACT_BYTES, ); @@ -474,15 +489,7 @@ mod tests { temp.path(), "cat >/dev/null\nprintf 'too large' > \"$CALCIFORGE_ARTIFACT_DIR/out.txt\"\necho done", ); - let adapter = ArtifactCliAdapter::with_artifact_root( - script.display().to_string(), - None, - HashMap::new(), - None, - Some(5000), - temp.path().join("artifacts"), - 4, - ); + let adapter = script_adapter(&script, None, temp.path().join("artifacts"), 4); let err = adapter .dispatch_message_with_context(DispatchContext::message_only("make file")) @@ -501,12 +508,9 @@ mod tests { temp.path(), "cat >/dev/null\ni=0\nwhile [ \"$i\" -le 16 ]; do printf x > \"$CALCIFORGE_ARTIFACT_DIR/$i.txt\"; i=$((i + 1)); done\necho done", ); - let adapter = ArtifactCliAdapter::with_artifact_root( - script.display().to_string(), + let adapter = script_adapter( + &script, None, - HashMap::new(), - None, - Some(5000), temp.path().join("artifacts"), DEFAULT_MAX_ARTIFACT_BYTES, ); diff --git a/crates/calciforge/src/adapters/cli.rs b/crates/calciforge/src/adapters/cli.rs index 7ae7f77d..bdcd8002 100644 --- a/crates/calciforge/src/adapters/cli.rs +++ b/crates/calciforge/src/adapters/cli.rs @@ -25,7 +25,7 @@ //! command = "/usr/local/bin/ironclaw" //! args = ["run", "-m", "{message}"] //! timeout_ms = 30000 -//! env = { LLM_BACKEND = "openai_compatible", LLM_BASE_URL = "...", LLM_MODEL = "kimi-k2.5", HTTP_PROXY = "http://127.0.0.1:8888", HTTPS_PROXY = "http://127.0.0.1:8888", NO_PROXY = "localhost,127.0.0.1,::1" } +//! env = { LLM_BACKEND = "openai_compatible", LLM_BASE_URL = "...", LLM_MODEL = "kimi-k2.5", HTTP_PROXY = "http://127.0.0.1:8888", NO_PROXY = "localhost,127.0.0.1,::1" } //! ``` use std::collections::HashMap; diff --git a/crates/calciforge/src/adapters/mod.rs b/crates/calciforge/src/adapters/mod.rs index 613a28d7..a4d2207b 100644 --- a/crates/calciforge/src/adapters/mod.rs +++ b/crates/calciforge/src/adapters/mod.rs @@ -113,6 +113,8 @@ pub struct DispatchContext<'a> { /// Optional per-request model override (used by alloy routing). #[allow(dead_code)] pub model_override: Option<&'a str>, + /// Optional downstream session selected for session-aware adapters. + pub session: Option<&'a str>, } impl<'a> DispatchContext<'a> { @@ -122,6 +124,7 @@ impl<'a> DispatchContext<'a> { message, sender: None, model_override: None, + session: None, } } } diff --git a/crates/calciforge/src/adapters/openai_compat.rs b/crates/calciforge/src/adapters/openai_compat.rs index 0d85bf7c..f903fe6e 100644 --- a/crates/calciforge/src/adapters/openai_compat.rs +++ b/crates/calciforge/src/adapters/openai_compat.rs @@ -279,6 +279,7 @@ mod tests { message: "hello", sender: Some("brian"), model_override: Some("override"), + session: None, }) .await .unwrap(); diff --git a/crates/calciforge/src/adapters/openclaw_channel.rs b/crates/calciforge/src/adapters/openclaw_channel.rs index c4f661a6..08671f3f 100644 --- a/crates/calciforge/src/adapters/openclaw_channel.rs +++ b/crates/calciforge/src/adapters/openclaw_channel.rs @@ -493,6 +493,7 @@ mod tests { message: "hello from calciforge", sender: Some("brian"), model_override: None, + session: None, }) .await .expect("dispatch should succeed"); @@ -536,6 +537,7 @@ mod tests { message: "route this", sender: Some("renee"), model_override: None, + session: None, }) .await .expect("dispatch should return reply callback"); @@ -566,6 +568,7 @@ mod tests { message: "first", sender: Some("brian"), model_override: None, + session: None, }) .await .expect("first dispatch should start reply server"); @@ -577,6 +580,7 @@ mod tests { message: "second", sender: Some("renee"), model_override: None, + session: None, }) .await .expect("rebuilt adapter should reuse reply server/router"); @@ -603,6 +607,7 @@ mod tests { message: "will not send", sender: Some("brian"), model_override: None, + session: None, }) .await .expect_err("conflicting reply auth token should fail"); diff --git a/crates/calciforge/src/adapters/openclaw_native.rs b/crates/calciforge/src/adapters/openclaw_native.rs index 2e7226bd..c3b1c3d2 100644 --- a/crates/calciforge/src/adapters/openclaw_native.rs +++ b/crates/calciforge/src/adapters/openclaw_native.rs @@ -463,6 +463,7 @@ mod tests { message: "hello", sender: Some("brian"), model_override: None, + session: None, }; let result = a.dispatch_with_context(ctx).await; @@ -597,6 +598,7 @@ mod tests { message: msg, sender: Some("brian"), model_override: None, + session: None, }; let _ = a.dispatch_with_context(ctx).await; } @@ -667,6 +669,7 @@ mod tests { message: "status check", sender: Some("renee"), model_override: None, + session: None, }; let _ = a.dispatch_with_context(ctx).await; diff --git a/crates/calciforge/src/adapters/zeroclaw_native.rs b/crates/calciforge/src/adapters/zeroclaw_native.rs index af1b29fd..05fab899 100644 --- a/crates/calciforge/src/adapters/zeroclaw_native.rs +++ b/crates/calciforge/src/adapters/zeroclaw_native.rs @@ -194,6 +194,7 @@ impl AgentAdapter for ZeroClawNativeAdapter { message: &full_message, sender: ctx.sender, model_override: ctx.model_override, + session: ctx.session, }; match self.inner.dispatch_with_context(inner_ctx).await { @@ -398,6 +399,7 @@ mod tests { message: "what is 2+2?", sender: Some("brian"), model_override: None, + session: None, }) .await; assert!(r1.is_ok(), "first dispatch failed: {:?}", r1); @@ -408,6 +410,7 @@ mod tests { message: "and 3+3?", sender: Some("brian"), model_override: None, + session: None, }) .await; assert!(r2.is_ok(), "second dispatch failed: {:?}", r2); @@ -496,6 +499,7 @@ mod tests { message: "brian first message", sender: Some("brian"), model_override: None, + session: None, }) .await; @@ -505,6 +509,7 @@ mod tests { message: "renee first message", sender: Some("renee"), model_override: None, + session: None, }) .await; @@ -514,6 +519,7 @@ mod tests { message: "brian second message", sender: Some("brian"), model_override: None, + session: None, }) .await; diff --git a/crates/calciforge/src/channels/matrix.rs b/crates/calciforge/src/channels/matrix.rs index 9ed0d1b3..e2e0fc01 100644 --- a/crates/calciforge/src/channels/matrix.rs +++ b/crates/calciforge/src/channels/matrix.rs @@ -310,18 +310,45 @@ async fn send_matrix_message( "msgtype": "m.text", "body": body, }); - let resp = http - .put(&url) - .header("Authorization", auth_header) - .json(&payload) - .send() - .await?; - if !resp.status().is_success() { + const MAX_RETRIES: u32 = 4; + let mut attempt = 0u32; + loop { + let resp = http + .put(&url) + .header("Authorization", auth_header) + .json(&payload) + .send() + .await?; let status = resp.status(); + if status.is_success() { + return Ok(()); + } + if status.as_u16() == 429 && attempt < MAX_RETRIES { + let body_text = resp.text().await.unwrap_or_default(); + let retry_ms = matrix_retry_after_ms(&body_text); + tracing::debug!( + attempt, + retry_ms, + "Matrix send rate-limited (429); retrying" + ); + tokio::time::sleep(tokio::time::Duration::from_millis(retry_ms + 50)).await; + attempt += 1; + continue; + } let err = resp.text().await.unwrap_or_default(); anyhow::bail!("Matrix send failed ({status}): {err}"); } - Ok(()) +} + +fn matrix_retry_after_ms(body_text: &str) -> u64 { + const DEFAULT_RETRY_AFTER_MS: u64 = 1_000; + const MAX_RETRY_AFTER_MS: u64 = 30_000; + + serde_json::from_str::(body_text) + .ok() + .and_then(|value| value["retry_after_ms"].as_u64()) + .unwrap_or(DEFAULT_RETRY_AFTER_MS) + .min(MAX_RETRY_AFTER_MS) } async fn send_matrix_outbound_message( @@ -813,14 +840,16 @@ async fn handle_message( ); let dispatch_start = std::time::Instant::now(); let model_override = cmd_handler.active_model_for_identity(identity_id); + let selected_session = cmd_handler.active_session_for(identity_id, &agent_id); match router - .dispatch_message_with_sender_and_model( + .dispatch_message_with_sender_model_and_session( &augmented, &agent, config, Some(identity_id), model_override.as_deref(), + selected_session.as_deref(), ) .await { @@ -981,6 +1010,16 @@ mod tests { assert!(lookup.contains("event2049")); } + #[test] + fn test_matrix_retry_after_ms_clamps_untrusted_server_delay() { + assert_eq!(matrix_retry_after_ms(r#"{"retry_after_ms":250}"#), 250); + assert_eq!( + matrix_retry_after_ms(r#"{"retry_after_ms":999999999999}"#), + 30_000 + ); + assert_eq!(matrix_retry_after_ms(r#"{}"#), 1_000); + } + #[tokio::test] async fn matrix_loop_dispatches_allowed_message_and_sends_agent_reply() { use crate::config::{ diff --git a/crates/calciforge/src/channels/mod.rs b/crates/calciforge/src/channels/mod.rs index 52025b45..0bcf2bd8 100644 --- a/crates/calciforge/src/channels/mod.rs +++ b/crates/calciforge/src/channels/mod.rs @@ -1,26 +1,16 @@ //! Channel adapters for Calciforge. //! -//! Currently active: Telegram. -//! Scaffolded (needs bot account): Matrix. -//! Scaffolded (needs ZeroClaw WA session): WhatsApp. -//! Scaffolded (needs OpenClaw Signal session): Signal. +//! Active: Telegram, Matrix, WhatsApp, Signal, text/iMessage, and mock. //! -//! Matrix was removed in v0.4.x (Zig) due to a tight-loop bug. The Rust v2 doesn't -//! have that problem — the adapter below is ready to wire up once the bot account exists. -//! See MATRIX-SETUP-NEEDED.md in the repo root for what's required. -//! -//! WhatsApp runs as a webhook receiver sidecar to ZeroClaw's wa-rs session. -//! Calciforge listens for incoming webhook POSTs (forwarded from ZeroClaw) and sends -//! replies back via ZeroClaw's /tools/invoke API. The QR pairing happens in ZeroClaw; -//! Calciforge only handles identity routing and agent dispatch. -//! -//! Signal follows the same webhook receiver pattern as WhatsApp, but uses -//! OpenClaw's native Signal support. Calciforge receives webhooks from OpenClaw -//! and sends replies via the /tools/invoke API. +//! Matrix and Telegram use their native HTTP APIs. WhatsApp and Signal embed +//! zeroclawlabs transports directly. Text/iMessage uses the zeroclawlabs Linq +//! transport for outbound sends plus a Calciforge-hosted Linq webhook receiver +//! for inbound iMessage/RCS/SMS events. pub mod matrix; pub mod mock; pub mod signal; +pub mod sms; pub mod telegram; pub mod telemetry; pub mod whatsapp; diff --git a/crates/calciforge/src/channels/signal.rs b/crates/calciforge/src/channels/signal.rs index 8779522d..bdde02ad 100644 --- a/crates/calciforge/src/channels/signal.rs +++ b/crates/calciforge/src/channels/signal.rs @@ -45,6 +45,7 @@ use crate::{ commands::CommandHandler, config::CalciforgeConfig, context::ContextStore, + messages::OutboundMessage, router::Router, }; @@ -124,6 +125,11 @@ impl SignalChannel { } } + async fn send_outbound(&self, recipient: &str, message: &OutboundMessage) { + self.send_reply(recipient, &message.render_text_fallback()) + .await; + } + /// Handle a single inbound `ChannelMessage` end-to-end. pub async fn handle_message(self: Arc, msg: ChannelMessage) { let received_at = std::time::Instant::now(); @@ -209,6 +215,7 @@ impl SignalChannel { && !CommandHandler::is_default_command(&text) && !CommandHandler::is_sessions_command(&text) && !CommandHandler::is_model_command(&text) + && !CommandHandler::is_secure_command(&text) { let reply = self.command_handler.unknown_command(&text); let channel = self.clone(); @@ -280,6 +287,33 @@ impl SignalChannel { return; } + // !secure + if CommandHandler::is_secure_command(&text) { + debug!(identity = %identity.id, "Signal: handling !secure command"); + if CommandHandler::is_secure_set_command(&text) + && !crate::config::channel_allows_chat_secret_set(&self.config, "signal") + { + let reply = CommandHandler::secure_set_disabled_reply("Signal"); + let channel = self.clone(); + let target = reply_target.clone(); + tokio::spawn(async move { + channel.send_reply(&target, &reply).await; + }); + return; + } + + let reply = self + .command_handler + .handle_secure(&text, &identity.id) + .await; + let channel = self.clone(); + let target = reply_target.clone(); + tokio::spawn(async move { + channel.send_reply(&target, &reply).await; + }); + return; + } + // !context clear if text.trim().eq_ignore_ascii_case("!context clear") { self.context_store.clear(&chat_key); @@ -329,6 +363,9 @@ impl SignalChannel { let identity_id = identity.id.clone(); let model_override = self.command_handler.active_model_for_identity(&identity_id); + let selected_session = self + .command_handler + .active_session_for(&identity_id, &agent_id); let preserve_native_commands = crate::adapters::agent_supports_native_commands(&agent); tokio::spawn(async move { @@ -345,32 +382,33 @@ impl SignalChannel { let dispatch_start = std::time::Instant::now(); match self .router - .dispatch_with_sender_and_model( + .dispatch_message_with_sender_model_and_session( &augmented, &agent, &self.config, Some(&identity_id), model_override.as_deref(), + selected_session.as_deref(), ) .await { Ok(response) => { let latency_ms = dispatch_start.elapsed().as_millis() as u64; + let final_response = response.render_text_fallback(); self.command_handler.record_dispatch(latency_ms); telemetry::agent_dispatch_succeeded( "signal", &identity_id, &agent_id, latency_ms, - response.len(), + response.response_len(), ); - let final_response = response; - debug!( identity = %identity_id, agent_id = %agent_id, response_len = %final_response.len(), + attachments = response.attachments.len(), "Signal: got agent response" ); @@ -383,7 +421,7 @@ impl SignalChannel { preserve_native_commands, ); - self.send_reply(&reply_target, &final_response).await; + self.send_outbound(&reply_target, &response).await; } Err(e) => { warn!(identity = %identity_id, error = %e, "Signal: agent dispatch failed"); @@ -617,26 +655,11 @@ mod tests { fn make_test_config(mutate: F) -> Arc { let mut channel = ChannelConfig { kind: "signal".to_string(), - bot_token_file: None, enabled: true, - homeserver: None, - access_token_file: None, - room_id: None, - allowed_users: vec![], - zeroclaw_endpoint: None, - zeroclaw_auth_token: None, - webhook_listen: None, - webhook_path: None, - webhook_secret: None, allowed_numbers: vec!["+15555550100".to_string()], signal_cli_url: Some("http://127.0.0.1:8080".to_string()), signal_account: Some("+15555550001".to_string()), - signal_group_id: None, - signal_ignore_attachments: false, - signal_ignore_stories: false, - control_port: None, - scan_messages: false, - allow_chat_secret_set: false, + ..Default::default() }; mutate(&mut channel); diff --git a/crates/calciforge/src/channels/sms.rs b/crates/calciforge/src/channels/sms.rs new file mode 100644 index 00000000..fbc2f0d1 --- /dev/null +++ b/crates/calciforge/src/channels/sms.rs @@ -0,0 +1,839 @@ +//! Text/iMessage channel adapter for Calciforge. +//! +//! Calciforge exposes `kind = "sms"` and uses `zeroclawlabs::LinqChannel` +//! underneath. Linq is webhook based for inbound iMessage/RCS/SMS events, so +//! this module hosts a small webhook receiver, lets the zeroclawlabs parser +//! normalize incoming payloads, then sends replies through the same `Channel` +//! interface used by other embedded transports. + +use crate::sync::Arc; +use anyhow::{Context, Result}; +use axum::{ + body::Bytes, + extract::State, + http::{HeaderMap, StatusCode}, + response::IntoResponse, + routing::{get, post}, + Json, Router as AxumRouter, +}; +use serde_json::json; +use tracing::{debug, info, warn}; +use zeroclaw::channels::traits::{Channel, ChannelMessage, SendMessage}; +use zeroclaw::channels::LinqChannel as ZclLinqChannel; + +use crate::{ + auth::{find_agent, resolve_channel_sender}, + commands::CommandHandler, + config::{expand_tilde, CalciforgeConfig}, + context::ContextStore, + messages::OutboundMessage, + router::Router, +}; + +use super::telemetry; + +use adversary_detector::middleware::ChannelScanner; +use adversary_detector::verdict::ScanContext; + +pub struct SmsChannel { + config: Arc, + router: Arc, + command_handler: Arc, + context_store: ContextStore, + channel_scanner: Arc, + transport: Arc, +} + +impl SmsChannel { + pub fn new( + config: Arc, + router: Arc, + command_handler: Arc, + context_store: ContextStore, + channel_scanner: Arc, + transport: Arc, + ) -> Self { + Self { + config, + router, + command_handler, + context_store, + channel_scanner, + transport, + } + } + + fn scan_enabled(&self) -> bool { + self.config + .channels + .iter() + .find(|c| c.kind == "sms") + .map(|c| c.scan_messages) + .unwrap_or(false) + } + + async fn send_reply(&self, recipient: &str, body: &str) { + let start = std::time::Instant::now(); + let response_len = body.len(); + match self + .transport + .send(&SendMessage::new(body, recipient)) + .await + { + Ok(()) => { + telemetry::reply_sent( + "sms", + recipient, + "reply", + response_len, + start.elapsed().as_millis() as u64, + ); + } + Err(e) => { + telemetry::reply_failed( + "sms", + recipient, + "reply", + start.elapsed().as_millis() as u64, + &e, + ); + warn!(recipient = %recipient, error = %e, "Text/iMessage: failed to send reply"); + } + } + } + + async fn send_outbound(&self, recipient: &str, message: &OutboundMessage) { + self.send_reply(recipient, &message.render_text_fallback()) + .await; + } + + pub async fn handle_message(self: Arc, msg: ChannelMessage) { + let received_at = std::time::Instant::now(); + let delivery_lag_ms = telemetry::delivery_lag_ms_from_unix_seconds(msg.timestamp); + + let from = msg.sender.clone(); + let reply_target = if msg.reply_target.is_empty() { + msg.sender.clone() + } else { + msg.reply_target.clone() + }; + let text = msg.content.clone(); + + let identity = match resolve_channel_sender("sms", &from, &self.config) { + Some(id) => id, + None => { + warn!(from = %from, "Text/iMessage: unknown sender - dropping"); + return; + } + }; + + telemetry::authorized_message("sms", &identity.id, &from, text.len(), delivery_lag_ms); + + let chat_key = conversation_chat_key(&identity.id, &reply_target); + + if self.scan_enabled() { + let verdict = self + .channel_scanner + .scan_text(&text, ScanContext::UserMessage) + .await; + match &verdict { + adversary_detector::verdict::ScanVerdict::Unsafe { reason } => { + warn!( + identity = %identity.id, + reason = %reason, + "Text/iMessage: inbound message BLOCKED by adversary scan" + ); + let channel = self.clone(); + let target = reply_target.clone(); + let reason_owned = reason.clone(); + tokio::spawn(async move { + channel + .send_reply( + &target, + &format!("Message blocked by security scanner: {reason_owned}"), + ) + .await; + }); + return; + } + adversary_detector::verdict::ScanVerdict::Review { reason } => { + warn!( + identity = %identity.id, + reason = %reason, + "Text/iMessage: inbound message flagged REVIEW - passing with caution" + ); + } + adversary_detector::verdict::ScanVerdict::Clean => { + debug!(identity = %identity.id, "Text/iMessage: inbound scan clean"); + } + } + } + + if let Some(reply) = self.command_handler.handle(&text) { + debug!(identity = %identity.id, cmd = %text.trim(), "Text/iMessage: handled pre-auth command"); + let channel = self.clone(); + let target = reply_target.clone(); + tokio::spawn(async move { + channel.send_reply(&target, &reply).await; + }); + return; + } + + if CommandHandler::is_command(&text) + && !CommandHandler::is_status_command(&text) + && !CommandHandler::is_switch_command(&text) + && !CommandHandler::is_default_command(&text) + && !CommandHandler::is_sessions_command(&text) + && !CommandHandler::is_model_command(&text) + && !CommandHandler::is_secure_command(&text) + { + let reply = self.command_handler.unknown_command(&text); + let channel = self.clone(); + let target = reply_target.clone(); + tokio::spawn(async move { + channel.send_reply(&target, &reply).await; + }); + return; + } + + if CommandHandler::is_status_command(&text) { + let reply = self + .command_handler + .cmd_status_for_identity(&identity.id) + .await; + let channel = self.clone(); + let target = reply_target.clone(); + tokio::spawn(async move { + channel.send_reply(&target, &reply).await; + }); + return; + } + + if CommandHandler::is_switch_command(&text) { + let reply = self.command_handler.handle_switch(&text, &identity.id); + let channel = self.clone(); + let target = reply_target.clone(); + tokio::spawn(async move { + channel.send_reply(&target, &reply).await; + }); + return; + } + + if CommandHandler::is_model_command(&text) { + let reply = self.command_handler.handle_model(&text, &identity.id); + let channel = self.clone(); + let target = reply_target.clone(); + tokio::spawn(async move { + channel.send_reply(&target, &reply).await; + }); + return; + } + + if CommandHandler::is_sessions_command(&text) { + let reply = self + .command_handler + .handle_sessions(&text, &identity.id) + .await; + let channel = self.clone(); + let target = reply_target.clone(); + tokio::spawn(async move { + channel.send_reply(&target, &reply).await; + }); + return; + } + + if CommandHandler::is_default_command(&text) { + let reply = self.command_handler.handle_default(&identity.id); + let channel = self.clone(); + let target = reply_target.clone(); + tokio::spawn(async move { + channel.send_reply(&target, &reply).await; + }); + return; + } + + if CommandHandler::is_secure_command(&text) { + debug!(identity = %identity.id, "Text/iMessage: handling !secure command"); + if CommandHandler::is_secure_set_command(&text) + && !crate::config::channel_allows_chat_secret_set(&self.config, "sms") + { + let reply = CommandHandler::secure_set_disabled_reply("SMS"); + let channel = self.clone(); + let target = reply_target.clone(); + tokio::spawn(async move { + channel.send_reply(&target, &reply).await; + }); + return; + } + + let reply = self + .command_handler + .handle_secure(&text, &identity.id) + .await; + let channel = self.clone(); + let target = reply_target.clone(); + tokio::spawn(async move { + channel.send_reply(&target, &reply).await; + }); + return; + } + + if text.trim().eq_ignore_ascii_case("!context clear") { + self.context_store.clear(&chat_key); + let channel = self.clone(); + let target = reply_target.clone(); + tokio::spawn(async move { + channel + .send_reply(&target, "Conversation context cleared.") + .await; + }); + return; + } + + let agent_id = match self.command_handler.active_agent_for(&identity.id) { + Some(id) => id, + None => { + warn!(identity = %identity.id, "Text/iMessage: no routing rule for identity - dropping"); + return; + } + }; + + let agent = match find_agent(&agent_id, &self.config) { + Some(a) => a.clone(), + None => { + warn!(agent_id = %agent_id, "Text/iMessage: agent not in config"); + let channel = self.clone(); + let target = reply_target.clone(); + tokio::spawn(async move { + channel.send_reply(&target, "Agent not configured.").await; + }); + return; + } + }; + + let sender_label = self + .config + .identities + .iter() + .find(|i| i.id == identity.id) + .and_then(|i| i.display_name.as_deref()) + .unwrap_or(&identity.id) + .to_string(); + + let identity_id = identity.id.clone(); + let model_override = self.command_handler.active_model_for_identity(&identity_id); + let selected_session = self + .command_handler + .active_session_for(&identity_id, &agent_id); + let preserve_native_commands = crate::adapters::agent_supports_native_commands(&agent); + + tokio::spawn(async move { + let queue_wait_ms = received_at.elapsed().as_millis() as u64; + telemetry::agent_dispatch_started("sms", &identity_id, &agent_id, queue_wait_ms); + + let augmented = self.context_store.augment_message_with_options( + &chat_key, + &agent_id, + &text, + preserve_native_commands, + ); + + let dispatch_start = std::time::Instant::now(); + match self + .router + .dispatch_message_with_sender_model_and_session( + &augmented, + &agent, + &self.config, + Some(&identity_id), + model_override.as_deref(), + selected_session.as_deref(), + ) + .await + { + Ok(response) => { + let latency_ms = dispatch_start.elapsed().as_millis() as u64; + let final_response = response.render_text_fallback(); + self.command_handler.record_dispatch(latency_ms); + telemetry::agent_dispatch_succeeded( + "sms", + &identity_id, + &agent_id, + latency_ms, + response.response_len(), + ); + + debug!( + identity = %identity_id, + agent_id = %agent_id, + response_len = %final_response.len(), + attachments = response.attachments.len(), + "Text/iMessage: got agent response" + ); + + self.context_store.push_with_options( + &chat_key, + &sender_label, + &text, + &agent_id, + &final_response, + preserve_native_commands, + ); + + self.send_outbound(&reply_target, &response).await; + } + Err(e) => { + warn!(identity = %identity_id, error = %e, "Text/iMessage: agent dispatch failed"); + self.send_reply(&reply_target, &format!("Agent error: {e}")) + .await; + } + } + }); + } +} + +fn conversation_chat_key(identity_id: &str, reply_target: &str) -> String { + format!("sms-{identity_id}-{reply_target}") +} + +#[derive(Clone)] +struct WebhookState { + bridge: Arc>, + transport: Arc, + signing_secret: Option, +} + +async fn health_handler() -> impl IntoResponse { + Json(json!({ "status": "ok", "channel": "sms" })) +} + +async fn webhook_handler( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> impl IntoResponse { + if let Some(secret) = state.signing_secret.as_deref() { + let timestamp = match headers + .get("x-webhook-timestamp") + .and_then(|value| value.to_str().ok()) + { + Some(value) => value, + None => return (StatusCode::UNAUTHORIZED, "missing webhook timestamp"), + }; + let signature = match headers + .get("x-webhook-signature") + .and_then(|value| value.to_str().ok()) + { + Some(value) => value, + None => return (StatusCode::UNAUTHORIZED, "missing webhook signature"), + }; + let body_text = match std::str::from_utf8(&body) { + Ok(value) => value, + Err(_) => return (StatusCode::BAD_REQUEST, "body must be utf-8 json"), + }; + if !zeroclaw::channels::linq::verify_linq_signature(secret, body_text, timestamp, signature) + { + return (StatusCode::UNAUTHORIZED, "invalid webhook signature"); + } + } + + let payload: serde_json::Value = match serde_json::from_slice(&body) { + Ok(value) => value, + Err(_) => return (StatusCode::BAD_REQUEST, "invalid json"), + }; + + let messages = state.transport.parse_webhook_payload(&payload); + for msg in messages { + let bridge = state.bridge.clone(); + tokio::spawn(async move { + bridge.handle_message(msg).await; + }); + } + + (StatusCode::OK, "ok") +} + +fn read_secret_file(path: &str, label: &str) -> Result { + Ok(std::fs::read_to_string(expand_tilde(path)) + .with_context(|| format!("Text/iMessage: failed to read {label} '{path}'"))? + .trim() + .to_string()) +} + +fn resolve_optional_secret( + inline: &Option, + file: &Option, + label: &str, +) -> Result> { + if let Some(path) = file { + return Ok(Some(read_secret_file(path, label)?)); + } + Ok(inline.clone().map(|value| value.trim().to_string())) +} + +pub async fn run( + config: Arc, + router: Arc, + command_handler: Arc, + context_store: ContextStore, + channel_scanner: Arc, +) -> Result<()> { + let sms_cfg = config + .channels + .iter() + .find(|c| c.kind == "sms" && c.enabled) + .context("no enabled sms channel found in config")?; + + let api_token = resolve_optional_secret( + &sms_cfg.sms_linq_api_token, + &sms_cfg.sms_linq_api_token_file, + "sms_linq_api_token_file", + )? + .filter(|value| !value.is_empty()) + .context("sms_linq_api_token_file or sms_linq_api_token is required for kind = \"sms\"")?; + let signing_secret = resolve_optional_secret( + &sms_cfg.sms_linq_signing_secret, + &sms_cfg.sms_linq_signing_secret_file, + "sms_linq_signing_secret_file", + )? + .filter(|value| !value.is_empty()); + if signing_secret.is_none() { + warn!( + "Text/iMessage webhook signature verification is disabled; \ + configure sms_linq_signing_secret_file for public webhook endpoints" + ); + } + let from_phone = sms_cfg + .sms_from_phone + .as_deref() + .context("sms_from_phone is required for kind = \"sms\"")? + .to_string(); + let listen_addr = sms_cfg + .sms_webhook_listen + .clone() + .unwrap_or_else(|| "0.0.0.0:18798".to_string()); + let webhook_path = sms_cfg + .sms_webhook_path + .clone() + .unwrap_or_else(|| "/webhooks/sms".to_string()); + let allowed = sms_cfg.allowed_numbers.clone(); + + info!( + listen = %listen_addr, + path = %webhook_path, + from_phone = %from_phone, + signed = signing_secret.is_some(), + "Text/iMessage channel starting (Linq webhook receiver)" + ); + + let transport = Arc::new(ZclLinqChannel::new(api_token, from_phone, allowed)); + let bridge = Arc::new(SmsChannel::::new( + config, + router, + command_handler, + context_store, + channel_scanner, + transport.clone(), + )); + + let state = WebhookState { + bridge, + transport, + signing_secret, + }; + let app = AxumRouter::new() + .route("/health", get(health_handler)) + .route(&webhook_path, post(webhook_handler)) + .with_state(state); + + let listener = tokio::net::TcpListener::bind(&listen_addr) + .await + .with_context(|| format!("binding SMS webhook listener on {listen_addr}"))?; + + axum::serve(listener, app) + .await + .context("Text/iMessage webhook listener exited") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{ + AgentConfig, CalciforgeConfig, CalciforgeHeader, ChannelAlias, ChannelConfig, Identity, + RoutingRule, + }; + use async_trait::async_trait; + use std::sync::Mutex as StdMutex; + use tokio::sync::mpsc; + use tokio::sync::Notify; + + struct MockChannel { + sent: StdMutex>, + sent_notify: Notify, + } + + impl MockChannel { + fn new() -> Self { + Self { + sent: StdMutex::new(Vec::new()), + sent_notify: Notify::new(), + } + } + + fn drain(&self) -> Vec { + std::mem::take(&mut *self.sent.lock().unwrap()) + } + + async fn wait_for_sent_len(&self, expected: usize) { + tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + let notified = self.sent_notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if self.sent.lock().unwrap().len() >= expected { + return; + } + notified.await; + } + }) + .await + .expect("timed out waiting for SMS mock send"); + } + } + + #[async_trait] + impl Channel for MockChannel { + fn name(&self) -> &str { + "mock-sms" + } + + async fn send(&self, message: &SendMessage) -> anyhow::Result<()> { + self.sent.lock().unwrap().push(message.clone()); + self.sent_notify.notify_waiters(); + Ok(()) + } + + async fn listen(&self, _tx: mpsc::Sender) -> anyhow::Result<()> { + Err(anyhow::anyhow!("SMS tests drive handle_message directly")) + } + } + + fn make_test_config() -> Arc { + Arc::new(CalciforgeConfig { + calciforge: CalciforgeHeader { version: 2 }, + identities: vec![Identity { + id: "alice".to_string(), + display_name: Some("Alice".to_string()), + aliases: vec![ChannelAlias { + channel: "sms".to_string(), + id: "+15555550100".to_string(), + }], + role: Some("owner".to_string()), + }], + agents: vec![AgentConfig { + id: "librarian".to_string(), + kind: "openclaw-channel".to_string(), + endpoint: "http://127.0.0.1:18789".to_string(), + ..Default::default() + }], + routing: vec![RoutingRule { + identity: "alice".to_string(), + default_agent: "librarian".to_string(), + allowed_agents: vec![], + }], + channels: vec![ChannelConfig { + kind: "sms".to_string(), + enabled: true, + allowed_numbers: vec!["+15555550100".to_string()], + sms_linq_api_token: Some("test-token".to_string()), + sms_from_phone: Some("+15555550001".to_string()), + ..Default::default() + }], + permissions: None, + memory: None, + context: Default::default(), + model_shortcuts: vec![], + alloys: vec![], + cascades: vec![], + dispatchers: vec![], + exec_models: vec![], + security: None, + proxy: None, + local_models: None, + }) + } + + fn make_scanner() -> Arc { + let security_config = adversary_detector::profiles::SecurityConfig::balanced(); + let scanner = + adversary_detector::scanner::AdversaryScanner::new(security_config.scanner.clone()); + let audit_logger = adversary_detector::audit::AuditLogger::new("test-sms"); + Arc::new(ChannelScanner::new(scanner, audit_logger, security_config)) + } + + struct TestBridge { + bridge: Arc>, + _state_dir: tempfile::TempDir, + } + + fn dummy_bridge_with(config: Arc, transport: Arc) -> TestBridge { + let router = Arc::new(Router::new()); + let tmp = tempfile::tempdir().expect("tempdir for sms test state isolation"); + let command_handler = Arc::new(CommandHandler::with_state_dir( + config.clone(), + tmp.path().to_path_buf(), + )); + TestBridge { + bridge: Arc::new(SmsChannel::::new( + config, + router, + command_handler, + ContextStore::new(20, 5), + make_scanner(), + transport, + )), + _state_dir: tmp, + } + } + + #[tokio::test] + async fn test_handle_message_unknown_sender_drops() { + let transport = Arc::new(MockChannel::new()); + let bridge = dummy_bridge_with(make_test_config(), transport.clone()); + + bridge + .bridge + .handle_message(ChannelMessage { + id: "1".into(), + sender: "+19990001111".into(), + reply_target: "+19990001111".into(), + content: "!ping".into(), + channel: "linq".into(), + timestamp: 0, + thread_ts: None, + interruption_scope_id: None, + attachments: vec![], + }) + .await; + + assert!(transport.drain().is_empty()); + } + + #[tokio::test] + async fn test_handle_message_replies_to_chat_id_target() { + let transport = Arc::new(MockChannel::new()); + let bridge = dummy_bridge_with(make_test_config(), transport.clone()); + + bridge + .bridge + .handle_message(ChannelMessage { + id: "1".into(), + sender: "+15555550100".into(), + reply_target: "chat_123".into(), + content: "!ping".into(), + channel: "linq".into(), + timestamp: 0, + thread_ts: None, + interruption_scope_id: None, + attachments: vec![], + }) + .await; + + transport.wait_for_sent_len(1).await; + let sent = transport.drain(); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].recipient, "chat_123"); + } + + #[tokio::test] + async fn test_conversation_ids_do_not_share_context_between_agents() { + let mut config = (*make_test_config()).clone(); + config.agents = vec![ + AgentConfig { + id: "librarian".to_string(), + kind: "artifact-cli".to_string(), + command: Some("/bin/sh".to_string()), + args: Some(vec!["-c".to_string(), "cat".to_string()]), + ..Default::default() + }, + AgentConfig { + id: "critic".to_string(), + kind: "artifact-cli".to_string(), + command: Some("/bin/sh".to_string()), + args: Some(vec!["-c".to_string(), "cat".to_string()]), + ..Default::default() + }, + ]; + config.routing[0].allowed_agents = vec!["librarian".to_string(), "critic".to_string()]; + let transport = Arc::new(MockChannel::new()); + let bridge = dummy_bridge_with(Arc::new(config), transport.clone()); + + bridge + .bridge + .clone() + .handle_message(ChannelMessage { + id: "1".into(), + sender: "+15555550100".into(), + reply_target: "chat_123".into(), + content: "alpha private context".into(), + channel: "linq".into(), + timestamp: 0, + thread_ts: None, + interruption_scope_id: None, + attachments: vec![], + }) + .await; + transport.wait_for_sent_len(1).await; + let first = transport.drain(); + assert_eq!(first[0].recipient, "chat_123"); + assert!(first[0].content.contains("alpha private context")); + + bridge + .bridge + .clone() + .handle_message(ChannelMessage { + id: "2".into(), + sender: "+15555550100".into(), + reply_target: "chat_456".into(), + content: "!switch critic".into(), + channel: "linq".into(), + timestamp: 0, + thread_ts: None, + interruption_scope_id: None, + attachments: vec![], + }) + .await; + transport.wait_for_sent_len(1).await; + let switch_reply = transport.drain(); + assert_eq!(switch_reply[0].recipient, "chat_456"); + + bridge + .bridge + .handle_message(ChannelMessage { + id: "3".into(), + sender: "+15555550100".into(), + reply_target: "chat_456".into(), + content: "beta fresh prompt".into(), + channel: "linq".into(), + timestamp: 0, + thread_ts: None, + interruption_scope_id: None, + attachments: vec![], + }) + .await; + transport.wait_for_sent_len(1).await; + let second = transport.drain(); + assert_eq!(second[0].recipient, "chat_456"); + assert!(second[0].content.contains("beta fresh prompt")); + assert!( + !second[0].content.contains("alpha private context"), + "chat_456 must not receive chat_123 context: {}", + second[0].content + ); + assert!( + !second[0].content.contains("[Recent context:"), + "new conversation/agent pair should start without another chat's preamble: {}", + second[0].content + ); + } +} diff --git a/crates/calciforge/src/channels/telegram.rs b/crates/calciforge/src/channels/telegram.rs index 3fbf56d2..7c978012 100644 --- a/crates/calciforge/src/channels/telegram.rs +++ b/crates/calciforge/src/channels/telegram.rs @@ -451,6 +451,7 @@ fn handle_message_nonblocking( .unwrap_or(&identity.id) .to_string(); let model_override = command_handler.active_model_for_identity(&identity.id); + let selected_session = command_handler.active_session_for(&identity.id, &agent_id); let preserve_native_commands = crate::adapters::agent_supports_native_commands(&agent); // Spawn the agent dispatch — handler returns immediately. @@ -479,12 +480,13 @@ fn handle_message_nonblocking( let dispatch_start = std::time::Instant::now(); match router - .dispatch_message_with_sender_and_model( + .dispatch_message_with_sender_model_and_session( &augmented_text, &agent, &config, Some(&identity.id), model_override.as_deref(), + selected_session.as_deref(), ) .await { @@ -830,6 +832,7 @@ async fn handle_message( .unwrap_or(&identity.id) .to_string(); let model_override = command_handler.active_model_for_identity(&identity.id); + let selected_session = command_handler.active_session_for(&identity.id, &agent_id); let preserve_native_commands = crate::adapters::agent_supports_native_commands(&agent); // Augment message with conversation context (unseen exchanges for this agent). @@ -854,16 +857,18 @@ async fn handle_message( // Dispatch to agent let dispatch_start = std::time::Instant::now(); match router - .dispatch_with_sender_and_model( + .dispatch_message_with_sender_model_and_session( &augmented_text, &agent, &config, Some(&identity.id), model_override.as_deref(), + selected_session.as_deref(), ) .await { - Ok(response) => { + Ok(response_message) => { + let response = response_message.render_text_fallback(); let latency_ms = dispatch_start.elapsed().as_millis() as u64; command_handler.record_dispatch(latency_ms); debug!( @@ -883,14 +888,7 @@ async fn handle_message( preserve_native_commands, ); - // Send response — try MarkdownV2 first, fall back to plain text. - let send_result = bot - .send_message(chat_id, &response) - .parse_mode(ParseMode::MarkdownV2) - .await; - if send_result.is_err() { - let _ = bot.send_message(chat_id, &response).await; - } + send_outbound_reply(bot, chat_id, response_message, "agent_response").await; } Err(e) => { warn!(identity = %identity.id, error = %e, "agent dispatch failed"); diff --git a/crates/calciforge/src/channels/telemetry.rs b/crates/calciforge/src/channels/telemetry.rs index 11ad245b..9a9e590e 100644 --- a/crates/calciforge/src/channels/telemetry.rs +++ b/crates/calciforge/src/channels/telemetry.rs @@ -1,7 +1,7 @@ //! Shared channel telemetry helpers. //! //! Keep channel adapters on the same event schema so latency regressions can -//! be compared across Telegram, WhatsApp, Signal, and Matrix without scraping +//! be compared across Telegram, WhatsApp, Signal, SMS, and Matrix without scraping //! channel-specific log text. use std::fmt::Display; diff --git a/crates/calciforge/src/channels/whatsapp.rs b/crates/calciforge/src/channels/whatsapp.rs index 42c113b5..313870b6 100644 --- a/crates/calciforge/src/channels/whatsapp.rs +++ b/crates/calciforge/src/channels/whatsapp.rs @@ -1,79 +1,30 @@ //! WhatsApp channel adapter for Calciforge. //! -//! ## Architecture -//! -//! This channel is a **webhook receiver + identity router + reply sender**. -//! -//! The actual WhatsApp protocol handling (QR pairing, WA Web encryption, send/receive -//! at the WA wire level) lives in ZeroClaw's `whatsapp-web` feature. Calciforge -//! acts as a routing sidecar: +//! Calciforge embeds [`zeroclaw::channels::WhatsAppWebChannel`] directly. The +//! embedded channel owns the WhatsApp Web session, surfaces inbound messages via +//! a `tokio::mpsc` `Receiver`, and sends replies through the same +//! `Channel::send` interface. //! //! ```text -//! WA user → ZeroClaw (wa-rs session) → POST /webhooks/whatsapp → Calciforge -//! │ -//! identity resolution │ -//! agent dispatch │ -//! ↓ -//! WA user ← ZeroClaw (wa-rs session) ← POST /tools/invoke ← Calciforge reply +//! WhatsApp user <-> zeroclawlabs::WhatsAppWebChannel <-> Calciforge dispatch //! ``` //! -//! ## Webhook payload format -//! -//! Incoming messages are expected in the WhatsApp Cloud API webhook format -//! (also used by ZeroClaw's outbound forwarding): -//! -//! ```json -//! { -//! "object": "whatsapp_business_account", -//! "entry": [{ -//! "changes": [{ -//! "value": { -//! "messages": [{ -//! "from": "15555550001", -//! "type": "text", -//! "text": { "body": "Hello!" }, -//! "timestamp": "1699999999" -//! }] -//! } -//! }] -//! }] -//! } -//! ``` -//! -//! The `from` field is a phone number with or without the leading `+`. -//! Calciforge normalises it to E.164 format (`+15555550001`) before identity lookup. -//! -//! ## Config -//! -//! ```toml -//! [[channels]] -//! kind = "whatsapp" -//! enabled = true -//! # OpenClaw gateway endpoint — the running OpenClaw instance that owns the WA session -//! # and can forward messages via its /tools/invoke HTTP API. -//! zeroclaw_endpoint = "http://127.0.0.1:18789" -//! # Bearer token for the OpenClaw gateway -//! zeroclaw_auth_token = "REPLACE_WITH_AUTH_TOKEN" -//! # Webhook path Calciforge registers (on the Calciforge gateway HTTP server — see main.rs TODO) -//! webhook_path = "/webhooks/whatsapp" -//! # HMAC secret for webhook signature verification (optional but recommended) -//! webhook_secret = "your-shared-secret" -//! # Webhook HTTP listen address (host:port) -//! webhook_listen = "0.0.0.0:18795" -//! # Allowed E.164 phone numbers. Must match identity aliases with channel = "whatsapp". -//! allowed_numbers = ["+15555550001"] -//! ``` +//! There is no webhook receiver in Calciforge for WhatsApp anymore. Legacy +//! ZeroClaw/OpenClaw webhook fields are rejected at startup for `kind = +//! "whatsapp"`. use crate::sync::Arc; -use anyhow::{Context, Result}; -use serde::{Deserialize, Serialize}; +use anyhow::{anyhow, Context, Result}; use tracing::{debug, info, warn}; +use zeroclaw::channels::traits::{Channel, ChannelMessage, SendMessage}; +use zeroclaw::channels::WhatsAppWebChannel as ZclWhatsAppWebChannel; use crate::{ auth::{find_agent, resolve_channel_sender}, commands::CommandHandler, - config::CalciforgeConfig, + config::{expand_tilde, CalciforgeConfig, ChannelConfig}, context::ContextStore, + messages::OutboundMessage, router::Router, }; @@ -82,116 +33,37 @@ use super::telemetry; use adversary_detector::middleware::ChannelScanner; use adversary_detector::verdict::ScanContext; -// --------------------------------------------------------------------------- -// Incoming webhook payload types -// --------------------------------------------------------------------------- - -/// Top-level WhatsApp Cloud API webhook body. -#[derive(Debug, Deserialize)] -struct WaWebhookPayload { - entry: Option>, -} - -#[derive(Debug, Deserialize)] -struct WaEntry { - changes: Option>, -} - -#[derive(Debug, Deserialize)] -struct WaChange { - value: Option, -} - -#[derive(Debug, Deserialize)] -struct WaChangeValue { - messages: Option>, -} - -#[derive(Debug, Deserialize)] -struct WaMessage { - from: String, - #[serde(rename = "type")] - kind: Option, - text: Option, - timestamp: Option, -} - -#[derive(Debug, Deserialize)] -struct WaTextBody { - body: String, -} - -/// A parsed, normalised inbound WhatsApp message. -#[derive(Debug, Clone)] -pub struct InboundWaMessage { - /// Sender phone number in E.164 format (e.g. `"+15555550001"`). - pub from: String, - /// Message text content. - pub text: String, - /// Unix timestamp (best-effort). - pub _timestamp: u64, -} - -// --------------------------------------------------------------------------- -// Outbound reply request (for the ZeroClaw /tools/invoke send API) -// --------------------------------------------------------------------------- - -#[derive(Debug, Serialize)] -struct ToolInvokeRequest { - tool: &'static str, - args: ToolInvokeArgs, -} - -#[derive(Debug, Serialize)] -struct ToolInvokeArgs { - action: &'static str, - channel: &'static str, - target: String, - message: String, -} - -// --------------------------------------------------------------------------- -// WhatsApp channel -// --------------------------------------------------------------------------- - -/// WhatsApp channel adapter. -/// -/// Runs an HTTP server listening for incoming webhook POSTs from ZeroClaw (or any -/// conforming WA webhook source), resolves sender identity, dispatches to the -/// configured agent, and sends the reply back via the ZeroClaw `/tools/invoke` API. -pub struct WhatsAppChannel { +/// Calciforge-side bridge that owns a `zeroclawlabs::WhatsAppWebChannel`, +/// drains its inbound stream, and dispatches messages through the standard +/// router / command / context pipeline. +pub struct WhatsAppChannel { config: Arc, router: Arc, command_handler: Arc, context_store: ContextStore, channel_scanner: Arc, - http_client: reqwest::Client, + transport: Arc, } -impl WhatsAppChannel { +impl WhatsAppChannel { pub fn new( config: Arc, router: Arc, command_handler: Arc, context_store: ContextStore, channel_scanner: Arc, + transport: Arc, ) -> Self { - let http_client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(300)) - .build() - .expect("failed to build HTTP client"); - Self { config, router, command_handler, context_store, channel_scanner, - http_client, + transport, } } - /// Check if message scanning is enabled for this channel. fn scan_enabled(&self) -> bool { self.config .channels @@ -201,164 +73,82 @@ impl WhatsAppChannel { .unwrap_or(false) } - /// Parse an incoming webhook payload and return all valid inbound messages. - /// - /// Filters out: - /// - Non-text messages (images, audio, etc.) - /// - Messages from numbers not in the `allowed_numbers` list - /// - Messages with empty body - pub fn parse_webhook_payload( - &self, - raw: &serde_json::Value, - allowed_numbers: &[String], - ) -> Vec { - let mut messages = Vec::new(); - - let payload: WaWebhookPayload = match serde_json::from_value(raw.clone()) { - Ok(p) => p, - Err(e) => { - warn!("WhatsApp: failed to deserialise webhook payload: {e}"); - return messages; + async fn send_reply(&self, recipient: &str, body: &str) { + let start = std::time::Instant::now(); + let response_len = body.len(); + match self + .transport + .send(&SendMessage::new(body, recipient)) + .await + { + Ok(()) => { + telemetry::reply_sent( + "whatsapp", + recipient, + "reply", + response_len, + start.elapsed().as_millis() as u64, + ); } - }; - - let Some(entries) = payload.entry else { - return messages; - }; - - for entry in entries { - let Some(changes) = entry.changes else { - continue; - }; - for change in changes { - let Some(value) = change.value else { continue }; - let Some(msgs) = value.messages else { continue }; - - for msg in msgs { - // Text-only for now; skip media, reactions, etc. - let kind = msg.kind.as_deref().unwrap_or("unknown"); - if kind != "text" { - debug!("WhatsApp: skipping non-text message type '{kind}'"); - continue; - } - - let text_body = match msg.text { - Some(t) if !t.body.is_empty() => t.body, - _ => { - debug!("WhatsApp: skipping empty text message"); - continue; - } - }; - - // Normalise phone number to E.164 - let from = normalise_phone(&msg.from); - - // Allowlist check - if !is_number_allowed(&from, allowed_numbers) { - warn!( - from = %from, - "WhatsApp: dropping message from number not in allowed_numbers" - ); - continue; - } - - let timestamp = extract_timestamp(&msg.timestamp); - - messages.push(InboundWaMessage { - from, - text: text_body, - _timestamp: timestamp, - }); - } + Err(e) => { + telemetry::reply_failed( + "whatsapp", + recipient, + "reply", + start.elapsed().as_millis() as u64, + &e, + ); + warn!(recipient = %recipient, error = %e, "WhatsApp: failed to send reply"); } } + } - messages + async fn send_outbound(&self, recipient: &str, message: &OutboundMessage) { + self.send_reply(recipient, &message.render_text_fallback()) + .await; } - /// Send a reply to a WhatsApp user via the ZeroClaw OpenClaw gateway `/tools/invoke` API. - /// - /// ZeroClaw must be running with a live WA Web session for this to succeed. - /// Returns Ok(()) if the HTTP call was accepted (2xx), Err otherwise. - pub async fn send_reply( + fn command_reply_ready( &self, - zeroclaw_endpoint: &str, - zeroclaw_auth_token: Option<&str>, - to: &str, - text: &str, - ) -> Result<()> { - let url = format!("{zeroclaw_endpoint}/tools/invoke"); - - let body = ToolInvokeRequest { - tool: "message", - args: ToolInvokeArgs { - action: "send", - channel: "whatsapp", - target: to.to_string(), - message: text.to_string(), - }, - }; - - let mut req = self.http_client.post(&url).json(&body); - - if let Some(token) = zeroclaw_auth_token { - req = req.bearer_auth(token); - } - - let start = std::time::Instant::now(); - let response_len = text.len(); - let resp = req - .send() - .await - .with_context(|| format!("WhatsApp: HTTP error sending reply via {url}"))?; - - let status = resp.status(); - if !status.is_success() { - let body_text = resp.text().await.unwrap_or_default(); - anyhow::bail!("WhatsApp: ZeroClaw replied {status} for send to {to}: {body_text}"); - } - - telemetry::reply_sent( + identity_id: &str, + command_kind: &'static str, + received_at: std::time::Instant, + handler_latency_ms: u64, + response_len: usize, + ) { + telemetry::command_reply_ready( "whatsapp", - to, - "reply", + identity_id, + command_kind, + received_at.elapsed().as_millis() as u64, + handler_latency_ms, response_len, - start.elapsed().as_millis() as u64, ); - Ok(()) } - /// Handle a single inbound WhatsApp message end-to-end. - /// - /// Performs identity lookup, command dispatch, agent routing, and reply. - pub async fn handle_message( - self: Arc, - msg: InboundWaMessage, - zeroclaw_endpoint: String, - zeroclaw_auth_token: Option, - ) { + pub async fn handle_message(self: Arc, msg: ChannelMessage) { let received_at = std::time::Instant::now(); - let delivery_lag_ms = telemetry::delivery_lag_ms_from_unix_seconds(msg._timestamp); + let delivery_lag_ms = telemetry::delivery_lag_ms_from_unix_seconds(msg.timestamp); - // Clone owned strings up front so they can be moved into spawned tasks. - let from: String = msg.from.clone(); - let text: String = msg.text.clone(); + let from = msg.sender.clone(); + let reply_target = if msg.reply_target.is_empty() { + msg.sender.clone() + } else { + msg.reply_target.clone() + }; + let text = msg.content.clone(); - // Auth boundary: resolve sender to identity let identity = match resolve_channel_sender("whatsapp", &from, &self.config) { Some(id) => id, None => { - warn!(from = %from, "WhatsApp: unknown sender — dropping"); + warn!(from = %from, "WhatsApp: unknown sender - dropping"); return; } }; telemetry::authorized_message("whatsapp", &identity.id, &from, text.len(), delivery_lag_ms); - // Context key: scoped per identity (no chat_id for WA, phone is the key) - let chat_key = format!("whatsapp-{}", identity.id); - - // ── Adversary inbound scan ──────────────────────────────────────────── + let chat_key = conversation_chat_key(&identity.id, &reply_target); if self.scan_enabled() { let verdict = self @@ -373,28 +163,24 @@ impl WhatsAppChannel { "WhatsApp: inbound message BLOCKED by adversary scan" ); let channel = self.clone(); - let from_owned = from.clone(); + let target = reply_target.clone(); let reason_owned = reason.clone(); tokio::spawn(async move { - let reply = - format!("🚫 Message blocked by security scanner: {reason_owned}"); - if let Err(e) = channel + channel .send_reply( - &zeroclaw_endpoint, - zeroclaw_auth_token.as_deref(), - &from_owned, - &reply, + &target, + &format!("Message blocked by security scanner: {reason_owned}"), ) - .await - { - warn!(from = %from_owned, error = %e, "WhatsApp: failed to send block notice"); - } + .await; }); return; } adversary_detector::verdict::ScanVerdict::Review { reason } => { - warn!(identity = %identity.id, reason = %reason, "WhatsApp: inbound message flagged REVIEW — passing with caution"); - // Pass through but logged + warn!( + identity = %identity.id, + reason = %reason, + "WhatsApp: inbound message flagged REVIEW - passing with caution" + ); } adversary_detector::verdict::ScanVerdict::Clean => { debug!(identity = %identity.id, "WhatsApp: inbound scan clean"); @@ -402,38 +188,24 @@ impl WhatsAppChannel { } } - // ── Command fast-path ────────────────────────────────────────────── - - // Pre-auth commands (!ping, !help, !agents, !metrics) + let command_start = std::time::Instant::now(); if let Some(reply) = self.command_handler.handle(&text) { debug!(identity = %identity.id, cmd = %text.trim(), "WhatsApp: handled pre-auth command"); - telemetry::command_reply_ready( - "whatsapp", + self.command_reply_ready( &identity.id, - "command", - received_at.elapsed().as_millis() as u64, - 0, + "pre_auth", + received_at, + command_start.elapsed().as_millis() as u64, reply.len(), ); let channel = self.clone(); - let from_owned = from.clone(); + let target = reply_target.clone(); tokio::spawn(async move { - if let Err(e) = channel - .send_reply( - &zeroclaw_endpoint, - zeroclaw_auth_token.as_deref(), - &from_owned, - &reply, - ) - .await - { - warn!(from = %from_owned, error = %e, "WhatsApp: failed to send command reply"); - } + channel.send_reply(&target, &reply).await; }); return; } - // Unknown !command handling if CommandHandler::is_command(&text) && !CommandHandler::is_status_command(&text) && !CommandHandler::is_switch_command(&text) @@ -442,291 +214,183 @@ impl WhatsAppChannel { && !CommandHandler::is_model_command(&text) && !CommandHandler::is_secure_command(&text) { + let command_start = std::time::Instant::now(); let reply = self.command_handler.unknown_command(&text); - telemetry::command_reply_ready( - "whatsapp", + self.command_reply_ready( &identity.id, - "unknown_command", - received_at.elapsed().as_millis() as u64, - 0, + "unknown", + received_at, + command_start.elapsed().as_millis() as u64, reply.len(), ); let channel = self.clone(); - let from_owned = from.clone(); + let target = reply_target.clone(); tokio::spawn(async move { - if let Err(e) = channel - .send_reply( - &zeroclaw_endpoint, - zeroclaw_auth_token.as_deref(), - &from_owned, - &reply, - ) - .await - { - warn!(from = %from_owned, error = %e, "failed to send unknown-command reply"); - } + channel.send_reply(&target, &reply).await; }); return; } - // !status — post-auth command if CommandHandler::is_status_command(&text) { let command_start = std::time::Instant::now(); let reply = self .command_handler .cmd_status_for_identity(&identity.id) .await; - telemetry::command_reply_ready( - "whatsapp", + self.command_reply_ready( &identity.id, "status", - received_at.elapsed().as_millis() as u64, + received_at, command_start.elapsed().as_millis() as u64, reply.len(), ); let channel = self.clone(); - let from_owned = from.clone(); + let target = reply_target.clone(); tokio::spawn(async move { - if let Err(e) = channel - .send_reply( - &zeroclaw_endpoint, - zeroclaw_auth_token.as_deref(), - &from_owned, - &reply, - ) - .await - { - warn!(from = %from_owned, error = %e, "WhatsApp: failed to send status reply"); - } + channel.send_reply(&target, &reply).await; }); return; } - // !switch — post-auth command if CommandHandler::is_switch_command(&text) { let command_start = std::time::Instant::now(); let reply = self.command_handler.handle_switch(&text, &identity.id); - telemetry::command_reply_ready( - "whatsapp", + self.command_reply_ready( &identity.id, "switch", - received_at.elapsed().as_millis() as u64, + received_at, command_start.elapsed().as_millis() as u64, reply.len(), ); let channel = self.clone(); - let from_owned = from.clone(); + let target = reply_target.clone(); tokio::spawn(async move { - if let Err(e) = channel - .send_reply( - &zeroclaw_endpoint, - zeroclaw_auth_token.as_deref(), - &from_owned, - &reply, - ) - .await - { - warn!(from = %from_owned, error = %e, "WhatsApp: failed to send switch reply"); - } + channel.send_reply(&target, &reply).await; }); return; } - // !model — post-auth command for alloy selection if CommandHandler::is_model_command(&text) { let command_start = std::time::Instant::now(); let reply = self.command_handler.handle_model(&text, &identity.id); - telemetry::command_reply_ready( - "whatsapp", + self.command_reply_ready( &identity.id, "model", - received_at.elapsed().as_millis() as u64, + received_at, command_start.elapsed().as_millis() as u64, reply.len(), ); let channel = self.clone(); - let from_owned = from.clone(); + let target = reply_target.clone(); tokio::spawn(async move { - if let Err(e) = channel - .send_reply( - &zeroclaw_endpoint, - zeroclaw_auth_token.as_deref(), - &from_owned, - &reply, - ) - .await - { - warn!(from = %from_owned, error = %e, "WhatsApp: failed to send model reply"); - } + channel.send_reply(&target, &reply).await; }); return; } - // !sessions — post-auth command if CommandHandler::is_sessions_command(&text) { let command_start = std::time::Instant::now(); let reply = self .command_handler .handle_sessions(&text, &identity.id) .await; - telemetry::command_reply_ready( - "whatsapp", + self.command_reply_ready( &identity.id, "sessions", - received_at.elapsed().as_millis() as u64, + received_at, command_start.elapsed().as_millis() as u64, reply.len(), ); let channel = self.clone(); - let from_owned = from.clone(); + let target = reply_target.clone(); tokio::spawn(async move { - if let Err(e) = channel - .send_reply( - &zeroclaw_endpoint, - zeroclaw_auth_token.as_deref(), - &from_owned, - &reply, - ) - .await - { - warn!(from = %from_owned, error = %e, "WhatsApp: failed to send sessions reply"); - } + channel.send_reply(&target, &reply).await; }); return; } - // !default — post-auth command if CommandHandler::is_default_command(&text) { let command_start = std::time::Instant::now(); let reply = self.command_handler.handle_default(&identity.id); - telemetry::command_reply_ready( - "whatsapp", + self.command_reply_ready( &identity.id, "default", - received_at.elapsed().as_millis() as u64, + received_at, command_start.elapsed().as_millis() as u64, reply.len(), ); let channel = self.clone(); - let from_owned = from.clone(); + let target = reply_target.clone(); tokio::spawn(async move { - if let Err(e) = channel - .send_reply( - &zeroclaw_endpoint, - zeroclaw_auth_token.as_deref(), - &from_owned, - &reply, - ) - .await - { - warn!(from = %from_owned, error = %e, "WhatsApp: failed to send default reply"); - } + channel.send_reply(&target, &reply).await; }); return; } - // !secure — store/list secrets without surfacing the value to - // any agent. Same redaction discipline as Telegram/Matrix: - // never log the body (which contains the value for `set`). if CommandHandler::is_secure_command(&text) { - debug!(from = %from, "WhatsApp: handling !secure command"); + debug!(identity = %identity.id, "WhatsApp: handling !secure command"); if CommandHandler::is_secure_set_command(&text) && !crate::config::channel_allows_chat_secret_set(&self.config, "whatsapp") { let reply = CommandHandler::secure_set_disabled_reply("WhatsApp"); - telemetry::command_reply_ready( - "whatsapp", + self.command_reply_ready( &identity.id, "secure_disabled", - received_at.elapsed().as_millis() as u64, + received_at, 0, reply.len(), ); let channel = self.clone(); - let from_owned = from.clone(); - let zeroclaw_endpoint_owned = zeroclaw_endpoint.clone(); - let zeroclaw_auth_token_owned = zeroclaw_auth_token.clone(); + let target = reply_target.clone(); tokio::spawn(async move { - if let Err(e) = channel - .send_reply( - &zeroclaw_endpoint_owned, - zeroclaw_auth_token_owned.as_deref(), - &from_owned, - &reply, - ) - .await - { - warn!(from = %from_owned, error = %e, "WhatsApp: failed to send !secure disabled reply"); - } + channel.send_reply(&target, &reply).await; }); return; } + let command_start = std::time::Instant::now(); let reply = self .command_handler .handle_secure(&text, &identity.id) .await; - telemetry::command_reply_ready( - "whatsapp", + self.command_reply_ready( &identity.id, "secure", - received_at.elapsed().as_millis() as u64, + received_at, command_start.elapsed().as_millis() as u64, reply.len(), ); let channel = self.clone(); - let from_owned = from.clone(); + let target = reply_target.clone(); tokio::spawn(async move { - if let Err(e) = channel - .send_reply( - &zeroclaw_endpoint, - zeroclaw_auth_token.as_deref(), - &from_owned, - &reply, - ) - .await - { - warn!(from = %from_owned, error = %e, "WhatsApp: failed to send !secure reply"); - } + channel.send_reply(&target, &reply).await; }); return; } - // !context clear if text.trim().eq_ignore_ascii_case("!context clear") { self.context_store.clear(&chat_key); - telemetry::command_reply_ready( - "whatsapp", + self.command_reply_ready( &identity.id, "context_clear", - received_at.elapsed().as_millis() as u64, + received_at, 0, - "🧹 Conversation context cleared.".len(), + "Conversation context cleared.".len(), ); let channel = self.clone(); - let from_owned = from.clone(); + let target = reply_target.clone(); tokio::spawn(async move { - if let Err(e) = channel - .send_reply( - &zeroclaw_endpoint, - zeroclaw_auth_token.as_deref(), - &from_owned, - "🧹 Conversation context cleared.", - ) - .await - { - warn!(from = %from_owned, error = %e, "WhatsApp: failed to send context-clear reply"); - } + channel + .send_reply(&target, "Conversation context cleared.") + .await; }); return; } - // ── Agent dispatch ───────────────────────────────────────────────── - let agent_id = match self.command_handler.active_agent_for(&identity.id) { Some(id) => id, None => { - warn!(identity = %identity.id, "WhatsApp: no routing rule for identity — dropping"); + warn!(identity = %identity.id, "WhatsApp: no routing rule for identity - dropping"); return; } }; @@ -736,22 +400,14 @@ impl WhatsAppChannel { None => { warn!(agent_id = %agent_id, "WhatsApp: agent not in config"); let channel = self.clone(); - let from_owned = from.clone(); + let target = reply_target.clone(); tokio::spawn(async move { - let _ = channel - .send_reply( - &zeroclaw_endpoint, - zeroclaw_auth_token.as_deref(), - &from_owned, - "⚠️ Agent not configured.", - ) - .await; + channel.send_reply(&target, "Agent not configured.").await; }); return; } }; - // Sender label for context preambles let sender_label = self .config .identities @@ -763,9 +419,11 @@ impl WhatsAppChannel { let identity_id = identity.id.clone(); let model_override = self.command_handler.active_model_for_identity(&identity_id); + let selected_session = self + .command_handler + .active_session_for(&identity_id, &agent_id); let preserve_native_commands = crate::adapters::agent_supports_native_commands(&agent); - // Spawn agent dispatch — handler returns immediately tokio::spawn(async move { let queue_wait_ms = received_at.elapsed().as_millis() as u64; telemetry::agent_dispatch_started("whatsapp", &identity_id, &agent_id, queue_wait_ms); @@ -780,37 +438,36 @@ impl WhatsAppChannel { let dispatch_start = std::time::Instant::now(); match self .router - .dispatch_with_sender_and_model( + .dispatch_message_with_sender_model_and_session( &augmented, &agent, &self.config, Some(&identity_id), model_override.as_deref(), + selected_session.as_deref(), ) .await { Ok(response) => { let latency_ms = dispatch_start.elapsed().as_millis() as u64; + let final_response = response.render_text_fallback(); self.command_handler.record_dispatch(latency_ms); telemetry::agent_dispatch_succeeded( "whatsapp", &identity_id, &agent_id, latency_ms, - response.len(), + response.response_len(), ); - // Outbound scanning dropped — see docs/roadmap/outbound-sensitive-data-detection.md - let final_response = response; - debug!( identity = %identity_id, agent_id = %agent_id, response_len = %final_response.len(), + attachments = response.attachments.len(), "WhatsApp: got agent response" ); - // Record exchange in context buffer self.context_store.push_with_options( &chat_key, &sender_label, @@ -820,31 +477,11 @@ impl WhatsAppChannel { preserve_native_commands, ); - if let Err(e) = self - .send_reply( - &zeroclaw_endpoint, - zeroclaw_auth_token.as_deref(), - &from, - &final_response, - ) - .await - { - warn!( - identity = %identity_id, - error = %e, - "WhatsApp: failed to send agent reply" - ); - } + self.send_outbound(&reply_target, &response).await; } Err(e) => { warn!(identity = %identity_id, error = %e, "WhatsApp: agent dispatch failed"); - let _ = self - .send_reply( - &zeroclaw_endpoint, - zeroclaw_auth_token.as_deref(), - &from, - &format!("⚠️ Agent error: {e}"), - ) + self.send_reply(&reply_target, &format!("Agent error: {e}")) .await; } } @@ -852,17 +489,36 @@ impl WhatsAppChannel { } } -// --------------------------------------------------------------------------- -// Webhook HTTP server -// --------------------------------------------------------------------------- - -/// Run the WhatsApp webhook HTTP listener. -/// -/// Starts an HTTP server on `listen_addr` that accepts POST requests on -/// `webhook_path`. For each valid inbound message, spawns a handler task that -/// routes through Calciforge's identity/agent system and sends the reply via ZeroClaw. -/// -/// This function runs until the server errors or is cancelled. +fn conversation_chat_key(identity_id: &str, reply_target: &str) -> String { + format!("whatsapp-{identity_id}-{reply_target}") +} + +const MIGRATION_TOML: &str = r#" +[[channels]] +kind = "whatsapp" +enabled = true +whatsapp_session_path = "~/.calciforge/whatsapp/session.db" +allowed_numbers = ["+15555550001"] +# Optional pairing-code login: +# whatsapp_pair_phone = "15555550001" +"#; + +fn migration_error(field: &str) -> anyhow::Error { + anyhow::anyhow!( + "WhatsApp channel: legacy webhook field `{field}` is no longer supported. \ + Calciforge now embeds zeroclawlabs::WhatsAppWebChannel and owns the \ + WhatsApp Web session directly. Update your config to the new schema:\n{MIGRATION_TOML}" + ) +} + +fn resolved_session_path(config: &ChannelConfig) -> Result { + let configured = config + .whatsapp_session_path + .as_deref() + .context("whatsapp_session_path is required for kind = \"whatsapp\"")?; + Ok(expand_tilde(configured).display().to_string()) +} + pub async fn run( config: Arc, router: Arc, @@ -870,322 +526,208 @@ pub async fn run( context_store: ContextStore, channel_scanner: Arc, ) -> Result<()> { - use std::net::SocketAddr; - use tokio::io::AsyncReadExt; - - // Find WhatsApp channel config - let wa_channel_cfg = config + let whatsapp_cfg = config .channels .iter() .find(|c| c.kind == "whatsapp" && c.enabled) .context("no enabled whatsapp channel found in config")?; - let listen_addr: SocketAddr = wa_channel_cfg - .webhook_listen - .as_deref() - .unwrap_or("0.0.0.0:18795") - .parse() - .context("invalid whatsapp webhook_listen address")?; - - let zeroclaw_endpoint = wa_channel_cfg - .zeroclaw_endpoint - .as_deref() - .unwrap_or("http://127.0.0.1:18789") - .to_string(); + if whatsapp_cfg.zeroclaw_endpoint.is_some() { + return Err(migration_error("zeroclaw_endpoint")); + } + if whatsapp_cfg.zeroclaw_auth_token.is_some() { + return Err(migration_error("zeroclaw_auth_token")); + } + if whatsapp_cfg.webhook_listen.is_some() { + return Err(migration_error("webhook_listen")); + } + if whatsapp_cfg.webhook_path.is_some() { + return Err(migration_error("webhook_path")); + } + if whatsapp_cfg.webhook_secret.is_some() { + return Err(migration_error("webhook_secret")); + } - let zeroclaw_auth_token = wa_channel_cfg.zeroclaw_auth_token.clone(); - let webhook_path = wa_channel_cfg - .webhook_path - .as_deref() - .unwrap_or("/webhooks/whatsapp") - .to_string(); - let webhook_secret = wa_channel_cfg.webhook_secret.clone(); - let allowed_numbers = wa_channel_cfg.allowed_numbers.clone(); + let session_path = resolved_session_path(whatsapp_cfg)?; + let pair_phone = whatsapp_cfg.whatsapp_pair_phone.clone(); + let pair_code = whatsapp_cfg.whatsapp_pair_code.clone(); + let allowed = whatsapp_cfg.allowed_numbers.clone(); + let mention_only = whatsapp_cfg.whatsapp_mention_only; + let mode = whatsapp_cfg.whatsapp_mode.clone(); + let dm_policy = whatsapp_cfg.whatsapp_dm_policy.clone(); + let group_policy = whatsapp_cfg.whatsapp_group_policy.clone(); + let self_chat_mode = whatsapp_cfg.whatsapp_self_chat_mode; + let dm_mention_patterns = whatsapp_cfg.whatsapp_dm_mention_patterns.clone(); + let group_mention_patterns = whatsapp_cfg.whatsapp_group_mention_patterns.clone(); info!( - listen = %listen_addr, - path = %webhook_path, - zeroclaw = %zeroclaw_endpoint, - "WhatsApp webhook channel starting" + session_path = %session_path, + pair_phone = ?pair_phone, + mode = ?mode, + mention_only, + "WhatsApp channel starting (embedded zeroclawlabs::WhatsAppWebChannel)" + ); + + let transport = Arc::new( + ZclWhatsAppWebChannel::new( + session_path, + pair_phone, + pair_code, + allowed, + mention_only, + mode, + dm_policy, + group_policy, + self_chat_mode, + ) + .with_dm_mention_patterns(dm_mention_patterns) + .with_group_mention_patterns(group_mention_patterns), ); - let channel = Arc::new(WhatsAppChannel::new( + let bridge = Arc::new(WhatsAppChannel::::new( config, router, command_handler, context_store, channel_scanner, + transport.clone(), )); - let listener = tokio::net::TcpListener::bind(listen_addr) - .await - .with_context(|| format!("binding WhatsApp webhook listener on {listen_addr}"))?; - - info!(addr = %listen_addr, "WhatsApp webhook listener ready"); + run_transport_loop(bridge, transport).await +} - loop { - let (mut stream, peer_addr) = match listener.accept().await { - Ok(conn) => conn, - Err(e) => { - warn!(error = %e, "WhatsApp: accept error"); - continue; - } - }; +async fn run_transport_loop(bridge: Arc>, transport: Arc) -> Result<()> +where + C: Channel + ?Sized + 'static, +{ + let (tx, mut rx) = tokio::sync::mpsc::channel::(64); - let channel = channel.clone(); - let zeroclaw_endpoint = zeroclaw_endpoint.clone(); - let zeroclaw_auth_token = zeroclaw_auth_token.clone(); - let webhook_path = webhook_path.clone(); - let webhook_secret = webhook_secret.clone(); - let allowed_numbers = allowed_numbers.clone(); + let listener_transport = Arc::clone(&transport); + let listener_handle = tokio::spawn(async move { listener_transport.listen(tx).await }); + while let Some(msg) = rx.recv().await { + let bridge = bridge.clone(); tokio::spawn(async move { - // Read the raw HTTP request (max 256 KB) - let mut buf = vec![0u8; 262_144]; - let n = match stream.read(&mut buf).await { - Ok(n) => n, - Err(e) => { - warn!(peer = %peer_addr, error = %e, "WhatsApp: read error"); - return; - } - }; + bridge.handle_message(msg).await; + }); + } - let raw = match std::str::from_utf8(&buf[..n]) { - Ok(s) => s, - Err(_) => { - let _ = - send_http_response(&mut stream, 400, "Bad Request", "Invalid UTF-8").await; - return; - } - }; + match listener_handle.await { + Ok(Ok(())) => Err(anyhow!("WhatsApp listener exited unexpectedly")), + Ok(Err(e)) => Err(e).context("WhatsApp listener exited with error"), + Err(e) => Err(anyhow!("WhatsApp listener task failed: {e}")), + } +} - // Parse method and path from first line - let first_line = raw.lines().next().unwrap_or(""); - let mut parts = first_line.splitn(3, ' '); - let method = parts.next().unwrap_or("").to_uppercase(); - let path = parts.next().unwrap_or(""); +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{ + AgentConfig, CalciforgeConfig, CalciforgeHeader, ChannelAlias, ChannelConfig, Identity, + RoutingRule, + }; + use async_trait::async_trait; + use std::sync::Mutex as StdMutex; + use tokio::sync::mpsc; + use tokio::sync::Notify; + + struct MockChannel { + sent: StdMutex>, + sent_notify: Notify, + listen_error: StdMutex>, + } - // Health check - if method == "GET" && (path == "/health" || path == "/healthz") { - let _ = send_http_response(&mut stream, 200, "OK", r#"{"status":"ok"}"#).await; - return; + impl MockChannel { + fn new() -> Self { + Self { + sent: StdMutex::new(Vec::new()), + sent_notify: Notify::new(), + listen_error: StdMutex::new(None), } + } - // Only POST to the configured webhook path - if method != "POST" || path != webhook_path { - let _ = - send_http_response(&mut stream, 404, "Not Found", r#"{"error":"not found"}"#) - .await; - return; + fn with_listen_error(error: &str) -> Self { + Self { + sent: StdMutex::new(Vec::new()), + sent_notify: Notify::new(), + listen_error: StdMutex::new(Some(error.to_string())), } + } - // Extract body (everything after the blank line that separates headers from body) - let body = if let Some(idx) = raw.find("\r\n\r\n") { - &raw[idx + 4..] - } else if let Some(idx) = raw.find("\n\n") { - &raw[idx + 2..] - } else { - "" - }; - - // HMAC verification (optional — only when webhook_secret is set) - if let Some(ref secret) = webhook_secret { - // Extract X-Hub-Signature-256 header - let sig_header = raw - .lines() - .find(|l| l.to_lowercase().starts_with("x-hub-signature-256:")) - .and_then(|l| l.split_once(':').map(|x| x.1)) - .map(|s| s.trim()) - .unwrap_or(""); - - if !verify_hmac_sha256(secret, body.as_bytes(), sig_header) { - warn!(peer = %peer_addr, "WhatsApp: webhook HMAC verification failed"); - let _ = send_http_response( - &mut stream, - 401, - "Unauthorized", - r#"{"error":"invalid signature"}"#, - ) - .await; - return; - } - } + fn drain(&self) -> Vec { + std::mem::take(&mut *self.sent.lock().unwrap()) + } - // Parse JSON - let payload: serde_json::Value = match serde_json::from_str(body) { - Ok(v) => v, - Err(e) => { - warn!(peer = %peer_addr, error = %e, "WhatsApp: invalid JSON body"); - let _ = send_http_response( - &mut stream, - 400, - "Bad Request", - r#"{"error":"invalid json"}"#, - ) - .await; - return; + async fn wait_for_sent_len(&self, expected: usize) { + tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + let notified = self.sent_notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if self.sent.lock().unwrap().len() >= expected { + return; + } + notified.await; } - }; - - // Acknowledge immediately (Meta/ZeroClaw expects fast 200) - let _ = send_http_response(&mut stream, 200, "OK", r#"{"status":"ok"}"#).await; - - // Parse and dispatch messages - let messages = channel.parse_webhook_payload(&payload, &allowed_numbers); - if messages.is_empty() { - debug!(peer = %peer_addr, "WhatsApp: webhook acknowledged (no actionable messages)"); - return; - } - - for msg in messages { - let ch = channel.clone(); - let ep = zeroclaw_endpoint.clone(); - let tok = zeroclaw_auth_token.clone(); - tokio::spawn(async move { - ch.handle_message(msg, ep, tok).await; - }); - } - }); + }) + .await + .expect("timed out waiting for WhatsApp mock send"); + } } -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- -/// Normalise a phone number to E.164 format (`+15555550001`). -/// Strips spaces/dashes; adds `+` prefix if missing. -fn normalise_phone(raw: &str) -> String { - let digits_only: String = raw.chars().filter(|c| c.is_ascii_digit()).collect(); - format!("+{digits_only}") -} + #[async_trait] + impl Channel for MockChannel { + fn name(&self) -> &str { + "mock-whatsapp" + } -/// Check whether a normalised E.164 phone number is in the allowlist. -/// A `"*"` entry allows all numbers. -fn is_number_allowed(phone: &str, allowed: &[String]) -> bool { - allowed.iter().any(|n| n == "*" || n == phone) -} + async fn send(&self, message: &SendMessage) -> anyhow::Result<()> { + self.sent.lock().unwrap().push(message.clone()); + self.sent_notify.notify_waiters(); + Ok(()) + } -/// Extract a Unix timestamp from the webhook message's `timestamp` field. -/// Handles both string and integer JSON values. -fn extract_timestamp(ts: &Option) -> u64 { - match ts { - Some(serde_json::Value::Number(n)) => n.as_u64().unwrap_or_default(), - Some(serde_json::Value::String(s)) => s.parse().unwrap_or_default(), - _ => std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), + async fn listen(&self, _tx: mpsc::Sender) -> anyhow::Result<()> { + if let Some(error) = self.listen_error.lock().unwrap().take() { + return Err(anyhow::anyhow!(error)); + } + Ok(()) + } } -} - -/// Verify a WhatsApp `X-Hub-Signature-256` header using HMAC-SHA256. -/// -/// The header format is `sha256=`. Returns `true` if the signature matches. -fn verify_hmac_sha256(secret: &str, body: &[u8], sig_header: &str) -> bool { - let expected_hex = match sig_header.strip_prefix("sha256=") { - Some(h) => h, - None => return false, - }; - let sig_bytes = match hex::decode(expected_hex) { - Ok(bytes) => bytes, - Err(_) => return false, - }; - - use hmac::{Hmac, Mac}; - use sha2::Sha256; - - type HmacSha256 = Hmac; - let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) { - Ok(mac) => mac, - Err(_) => return false, - }; - mac.update(body); - mac.verify_slice(&sig_bytes).is_ok() -} - -/// Write a minimal HTTP/1.1 response to the stream. -async fn send_http_response( - stream: &mut tokio::net::TcpStream, - status: u16, - reason: &str, - body: &str, -) -> std::io::Result<()> { - use tokio::io::AsyncWriteExt; - let response = format!( - "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ); - stream.write_all(response.as_bytes()).await?; - stream.flush().await -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::{ - AgentConfig, CalciforgeConfig, CalciforgeHeader, ChannelAlias, ChannelConfig, Identity, - RoutingRule, - }; + fn make_test_config(mutate: F) -> Arc { + let mut channel = ChannelConfig { + kind: "whatsapp".to_string(), + enabled: true, + allowed_numbers: vec!["+15555550100".to_string()], + whatsapp_session_path: Some("test-session.db".to_string()), + ..Default::default() + }; + mutate(&mut channel); - fn make_test_config() -> Arc { Arc::new(CalciforgeConfig { calciforge: CalciforgeHeader { version: 2 }, identities: vec![Identity { - id: "brian".to_string(), - display_name: Some("Brian".to_string()), - aliases: vec![ - ChannelAlias { - channel: "telegram".to_string(), - id: "7000000001".to_string(), - }, - ChannelAlias { - channel: "whatsapp".to_string(), - id: "+15555550001".to_string(), - }, - ], + id: "alice".to_string(), + display_name: Some("Alice".to_string()), + aliases: vec![ChannelAlias { + channel: "whatsapp".to_string(), + id: "+15555550100".to_string(), + }], role: Some("owner".to_string()), }], agents: vec![AgentConfig { id: "librarian".to_string(), kind: "openclaw-channel".to_string(), - endpoint: "http://10.0.0.20:18789".to_string(), - timeout_ms: Some(120000), - model: None, - auth_token: Some("REPLACE_WITH_AUTH_TOKEN".to_string()), - api_key: None, - api_key_file: None, - openclaw_agent_id: None, - allow_model_override: None, - reply_port: None, - reply_auth_token: None, - command: None, - args: None, - env: None, - registry: None, - aliases: vec![], + endpoint: "http://127.0.0.1:18789".to_string(), + ..Default::default() }], routing: vec![RoutingRule { - identity: "brian".to_string(), + identity: "alice".to_string(), default_agent: "librarian".to_string(), allowed_agents: vec![], }], - channels: vec![ChannelConfig { - kind: "whatsapp".to_string(), - enabled: true, - zeroclaw_endpoint: Some("http://127.0.0.1:18789".to_string()), - zeroclaw_auth_token: Some("REPLACE_WITH_AUTH_TOKEN".to_string()), - webhook_path: Some("/webhooks/whatsapp".to_string()), - webhook_listen: Some("0.0.0.0:18795".to_string()), - webhook_secret: None, - allowed_numbers: vec!["+15555550001".to_string()], - ..Default::default() - }], + channels: vec![channel], permissions: None, memory: None, context: Default::default(), @@ -1200,259 +742,309 @@ mod tests { }) } - fn make_channel(config: Arc) -> Arc { + fn make_scanner() -> Arc { + let security_config = adversary_detector::profiles::SecurityConfig::balanced(); + let scanner = + adversary_detector::scanner::AdversaryScanner::new(security_config.scanner.clone()); + let audit_logger = adversary_detector::audit::AuditLogger::new("test-whatsapp"); + Arc::new(ChannelScanner::new(scanner, audit_logger, security_config)) + } + + struct TestBridge { + bridge: Arc>, + _state_dir: tempfile::TempDir, + } + + fn dummy_bridge_with(config: Arc, transport: Arc) -> TestBridge { let router = Arc::new(Router::new()); - // Use a per-test temp state dir to avoid cross-test state pollution. let tmp = tempfile::tempdir().expect("tempdir for whatsapp test state isolation"); let command_handler = Arc::new(CommandHandler::with_state_dir( config.clone(), tmp.path().to_path_buf(), )); let context_store = ContextStore::new(20, 5); - let security_config = adversary_detector::profiles::SecurityConfig::balanced(); - let scanner = - adversary_detector::scanner::AdversaryScanner::new(security_config.scanner.clone()); - let audit_logger = adversary_detector::audit::AuditLogger::new("test-wa"); - let channel_scanner = Arc::new(adversary_detector::middleware::ChannelScanner::new( - scanner, - audit_logger, - security_config, + TestBridge { + bridge: Arc::new(WhatsAppChannel::::new( + config, + router, + command_handler, + context_store, + make_scanner(), + transport, + )), + _state_dir: tmp, + } + } + + #[tokio::test] + async fn test_run_errors_on_old_config_fields() { + let config = make_test_config(|c| { + c.zeroclaw_endpoint = Some("http://127.0.0.1:18789".to_string()); + }); + + let router = Arc::new(Router::new()); + let tmp = tempfile::tempdir().expect("tempdir for whatsapp test state isolation"); + let command_handler = Arc::new(CommandHandler::with_state_dir( + config.clone(), + tmp.path().to_path_buf(), )); - Arc::new(WhatsAppChannel::new( + let context_store = ContextStore::new(20, 5); + let channel_scanner = make_scanner(); + + let err = run( config, router, command_handler, context_store, channel_scanner, - )) - } - - // --- Payload parsing tests --- - - #[test] - fn test_parse_valid_text_message() { - let config = make_test_config(); - let channel = make_channel(config); - let allowed = vec!["+15555550001".to_string()]; - - let payload = serde_json::json!({ - "object": "whatsapp_business_account", - "entry": [{ - "changes": [{ - "value": { - "messages": [{ - "from": "15555550001", - "type": "text", - "text": { "body": "Hello Calciforge!" }, - "timestamp": "1699999999" - }] - } - }] - }] - }); - - let msgs = channel.parse_webhook_payload(&payload, &allowed); - assert_eq!(msgs.len(), 1); - assert_eq!(msgs[0].from, "+15555550001"); - assert_eq!(msgs[0].text, "Hello Calciforge!"); - assert_eq!(msgs[0]._timestamp, 1_699_999_999); - } - - #[test] - fn test_parse_empty_payload() { - let config = make_test_config(); - let channel = make_channel(config); - let payload = serde_json::json!({}); - let msgs = channel.parse_webhook_payload(&payload, &[]); - assert!(msgs.is_empty()); - } - - #[test] - fn test_parse_skips_non_text_message() { - let config = make_test_config(); - let channel = make_channel(config); - let allowed = vec!["+15555550001".to_string()]; - - let payload = serde_json::json!({ - "entry": [{ - "changes": [{ - "value": { - "messages": [{ - "from": "15555550001", - "type": "image", - "timestamp": "1699999999" - }] - } - }] - }] - }); + ) + .await + .expect_err("legacy zeroclaw_endpoint must be rejected"); - let msgs = channel.parse_webhook_payload(&payload, &allowed); - assert!(msgs.is_empty(), "non-text messages must be skipped"); + let rendered = format!("{err}"); + assert!(rendered.contains("zeroclaw_endpoint")); + assert!(rendered.contains("whatsapp_session_path")); } #[test] - fn test_parse_drops_unauthorized_number() { - let config = make_test_config(); - let channel = make_channel(config); - let allowed = vec!["+15555550001".to_string()]; - - let payload = serde_json::json!({ - "entry": [{ - "changes": [{ - "value": { - "messages": [{ - "from": "9999999999", - "type": "text", - "text": { "body": "Spam" }, - "timestamp": "1699999999" - }] - } - }] - }] - }); + fn test_session_path_expands_tilde() { + let mut config = ChannelConfig { + whatsapp_session_path: Some("~/.calciforge/whatsapp/session.db".to_string()), + ..Default::default() + }; - let msgs = channel.parse_webhook_payload(&payload, &allowed); - assert!(msgs.is_empty(), "unauthorised numbers must be dropped"); - } + let session_path = resolved_session_path(&config) + .expect("configured WhatsApp session path should resolve"); - #[test] - fn test_parse_wildcard_allowlist() { - let config = make_test_config(); - let channel = make_channel(config); - let allowed = vec!["*".to_string()]; - - let payload = serde_json::json!({ - "entry": [{ - "changes": [{ - "value": { - "messages": [{ - "from": "9999999999", - "type": "text", - "text": { "body": "Anyone can message with wildcard" }, - "timestamp": "1699999999" - }] - } - }] - }] - }); + assert!( + !session_path.starts_with("~/"), + "WhatsApp session path should not keep a literal tilde: {session_path}" + ); + assert!( + session_path.ends_with(".calciforge/whatsapp/session.db"), + "WhatsApp session path should preserve the configured suffix: {session_path}" + ); - let msgs = channel.parse_webhook_payload(&payload, &allowed); - assert_eq!(msgs.len(), 1); + config.whatsapp_session_path = Some("/var/lib/calciforge/wa.db".to_string()); + assert_eq!( + resolved_session_path(&config).expect("absolute session path should resolve"), + "/var/lib/calciforge/wa.db" + ); } - // --- Phone normalisation tests --- + #[tokio::test] + async fn test_transport_loop_propagates_listener_error() { + let config = make_test_config(|_| {}); + let transport = Arc::new(MockChannel::with_listen_error("listen failed")); + let bridge = dummy_bridge_with(config, Arc::clone(&transport)); - #[test] - fn test_normalise_phone_with_plus() { - assert_eq!(normalise_phone("+15555550001"), "+15555550001"); - } + let err = run_transport_loop(bridge.bridge, transport) + .await + .expect_err("listener errors must surface from WhatsApp run loop"); - #[test] - fn test_normalise_phone_without_plus() { - assert_eq!(normalise_phone("15555550001"), "+15555550001"); + let rendered = format!("{err:#}"); + assert!(rendered.contains("listen failed")); } - #[test] - fn test_normalise_phone_strips_spaces() { - assert_eq!(normalise_phone("1 555 555 0100"), "+15555550100"); - } + #[tokio::test] + async fn test_transport_loop_errors_on_clean_listener_exit() { + let config = make_test_config(|_| {}); + let transport = Arc::new(MockChannel::new()); + let bridge = dummy_bridge_with(config, Arc::clone(&transport)); - // --- Identity resolution tests --- - - #[test] - fn test_whatsapp_identity_resolves() { - let config = make_test_config(); - let result = resolve_channel_sender("whatsapp", "+15555550001", &config); - assert!(result.is_some()); - assert_eq!(result.unwrap().id, "brian"); - } + let err = run_transport_loop(bridge.bridge, transport) + .await + .expect_err("clean listener exits are unexpected in production"); - #[test] - fn test_whatsapp_unknown_sender_drops() { - let config = make_test_config(); - let result = resolve_channel_sender("whatsapp", "+19998887777", &config); - assert!(result.is_none(), "unknown WA sender must return None"); + let rendered = format!("{err:#}"); + assert!(rendered.contains("exited unexpectedly")); } - // --- Allowlist helper tests --- + #[tokio::test] + async fn test_handle_message_unknown_sender_drops() { + let config = make_test_config(|_| {}); + let transport = Arc::new(MockChannel::new()); + let bridge = dummy_bridge_with(config, transport.clone()); + + let msg = ChannelMessage { + id: "1".into(), + sender: "+19990001111".into(), + reply_target: "+19990001111".into(), + content: "!ping".into(), + channel: "whatsapp".into(), + timestamp: 0, + thread_ts: None, + interruption_scope_id: None, + attachments: vec![], + }; - #[test] - fn test_is_number_allowed_exact() { - assert!(is_number_allowed( - "+15555550001", - &["+15555550001".to_string()] - )); - assert!(!is_number_allowed( - "+19998887777", - &["+15555550001".to_string()] - )); - } + bridge.bridge.handle_message(msg).await; - #[test] - fn test_is_number_allowed_wildcard() { - assert!(is_number_allowed("+19998887777", &["*".to_string()])); + assert!(transport.drain().is_empty()); } - #[test] - fn test_is_number_allowed_empty_list() { - assert!(!is_number_allowed("+15555550001", &[])); - } + #[tokio::test] + async fn test_handle_message_replies_to_group_target() { + let config = make_test_config(|_| {}); + let transport = Arc::new(MockChannel::new()); + let bridge = dummy_bridge_with(config, transport.clone()); + + let msg = ChannelMessage { + id: "1".into(), + sender: "+15555550100".into(), + reply_target: "12345@g.us".into(), + content: "!ping".into(), + channel: "whatsapp".into(), + timestamp: 0, + thread_ts: None, + interruption_scope_id: None, + attachments: vec![], + }; - // --- Timestamp extraction tests --- + bridge.bridge.handle_message(msg).await; - #[test] - fn test_extract_timestamp_from_string() { - let ts = Some(serde_json::Value::String("1699999999".to_string())); - assert_eq!(extract_timestamp(&ts), 1_699_999_999); - } + transport.wait_for_sent_len(1).await; - #[test] - fn test_extract_timestamp_from_number() { - let ts = Some(serde_json::json!(1699999999u64)); - assert_eq!(extract_timestamp(&ts), 1_699_999_999); + let sent = transport.drain(); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].recipient, "12345@g.us"); } - #[test] - fn test_extract_timestamp_fallback_on_none() { - let now_before = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - let ts = extract_timestamp(&None); - assert!(ts >= now_before, "fallback timestamp should be recent"); - } - - #[test] - fn test_verify_hmac_sha256_valid() { - use hmac::{Hmac, Mac}; - use sha2::Sha256; - - type HmacSha256 = Hmac; - let secret = "webhook-secret"; - let body = br#"{"entry":[{"changes":[]}]}"#; - let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap(); - mac.update(body); - let signature = format!("sha256={}", hex::encode(mac.finalize().into_bytes())); - - assert!(verify_hmac_sha256(secret, body, &signature)); - } - - #[test] - fn test_verify_hmac_sha256_rejects_missing_prefix() { - assert!(!verify_hmac_sha256("secret", b"body", "abcd")); + #[tokio::test] + async fn test_group_targets_do_not_share_context_between_agents() { + let mut config = (*make_test_config(|_| {})).clone(); + config.agents = vec![ + AgentConfig { + id: "librarian".to_string(), + kind: "artifact-cli".to_string(), + command: Some("/bin/sh".to_string()), + args: Some(vec!["-c".to_string(), "cat".to_string()]), + ..Default::default() + }, + AgentConfig { + id: "critic".to_string(), + kind: "artifact-cli".to_string(), + command: Some("/bin/sh".to_string()), + args: Some(vec!["-c".to_string(), "cat".to_string()]), + ..Default::default() + }, + ]; + config.routing[0].allowed_agents = vec!["librarian".to_string(), "critic".to_string()]; + let config = Arc::new(config); + let transport = Arc::new(MockChannel::new()); + let bridge = dummy_bridge_with(config, transport.clone()); + + bridge + .bridge + .clone() + .handle_message(ChannelMessage { + id: "1".into(), + sender: "+15555550100".into(), + reply_target: "group-a@g.us".into(), + content: "alpha private context".into(), + channel: "whatsapp".into(), + timestamp: 0, + thread_ts: None, + interruption_scope_id: None, + attachments: vec![], + }) + .await; + transport.wait_for_sent_len(1).await; + let first = transport.drain(); + assert_eq!(first[0].recipient, "group-a@g.us"); + assert!(first[0].content.contains("alpha private context")); + + bridge + .bridge + .clone() + .handle_message(ChannelMessage { + id: "2".into(), + sender: "+15555550100".into(), + reply_target: "group-b@g.us".into(), + content: "!switch critic".into(), + channel: "whatsapp".into(), + timestamp: 0, + thread_ts: None, + interruption_scope_id: None, + attachments: vec![], + }) + .await; + transport.wait_for_sent_len(1).await; + let switch_reply = transport.drain(); + assert_eq!(switch_reply[0].recipient, "group-b@g.us"); + + bridge + .bridge + .handle_message(ChannelMessage { + id: "3".into(), + sender: "+15555550100".into(), + reply_target: "group-b@g.us".into(), + content: "beta fresh prompt".into(), + channel: "whatsapp".into(), + timestamp: 0, + thread_ts: None, + interruption_scope_id: None, + attachments: vec![], + }) + .await; + transport.wait_for_sent_len(1).await; + let second = transport.drain(); + assert_eq!(second[0].recipient, "group-b@g.us"); + assert!(second[0].content.contains("beta fresh prompt")); + assert!( + !second[0].content.contains("alpha private context"), + "group B must not receive group A context: {}", + second[0].content + ); + assert!( + !second[0].content.contains("[Recent context:"), + "new group/agent pair should start without another group's preamble: {}", + second[0].content + ); } - #[test] - fn test_verify_hmac_sha256_rejects_tampered_body() { - use hmac::{Hmac, Mac}; - use sha2::Sha256; - - type HmacSha256 = Hmac; - let mut mac = HmacSha256::new_from_slice(b"secret").unwrap(); - mac.update(b"original"); - let signature = format!("sha256={}", hex::encode(mac.finalize().into_bytes())); + #[tokio::test] + async fn test_handle_message_renders_artifact_fallback() { + let mut config = (*make_test_config(|_| {})).clone(); + config.agents = vec![AgentConfig { + id: "librarian".to_string(), + kind: "artifact-cli".to_string(), + command: Some("/bin/sh".to_string()), + args: Some(vec![ + "-c".to_string(), + "cat >/dev/null; printf 'image-bytes' > \"$CALCIFORGE_ARTIFACT_DIR/result.png\"; printf 'done\\n'" + .to_string(), + ]), + ..Default::default() + }]; + let config = Arc::new(config); + let transport = Arc::new(MockChannel::new()); + let bridge = dummy_bridge_with(config, transport.clone()); + + let msg = ChannelMessage { + id: "1".into(), + sender: "+15555550100".into(), + reply_target: "+15555550100".into(), + content: "make an image".into(), + channel: "whatsapp".into(), + timestamp: 0, + thread_ts: None, + interruption_scope_id: None, + attachments: vec![], + }; - assert!(!verify_hmac_sha256("secret", b"tampered", &signature)); + bridge.bridge.handle_message(msg).await; + transport.wait_for_sent_len(1).await; + + let sent = transport.drain(); + assert_eq!(sent.len(), 1); + assert!(sent[0].content.contains("done")); + assert!(sent[0].content.contains("Attachments:")); + assert!(sent[0].content.contains("result.png")); + assert!( + !sent[0].content.contains("/tmp/calciforge-artifacts"), + "fallback must not leak local artifact paths: {}", + sent[0].content + ); } } diff --git a/crates/calciforge/src/commands.rs b/crates/calciforge/src/commands.rs index 1a56ddf3..36350369 100644 --- a/crates/calciforge/src/commands.rs +++ b/crates/calciforge/src/commands.rs @@ -40,6 +40,11 @@ fn active_model_state_file_path_for(state_dir: &Path) -> PathBuf { state_dir.join("active-models.json") } +/// Path to the active downstream session selections within `state_dir`. +fn active_session_state_file_path_for(state_dir: &Path) -> PathBuf { + state_dir.join("active-agent-sessions.json") +} + /// Load persisted active-agent selections from a given state directory. /// Returns an empty map if the file doesn't exist or can't be parsed. fn load_active_agents_from(state_dir: &Path) -> HashMap { @@ -60,6 +65,16 @@ fn load_active_models_from(state_dir: &Path) -> HashMap { } } +/// Load persisted active downstream session selections. +/// Returns an empty map if the file doesn't exist or can't be parsed. +fn load_active_sessions_from(state_dir: &Path) -> HashMap> { + let path = active_session_state_file_path_for(state_dir); + match std::fs::read_to_string(&path) { + Ok(s) => serde_json::from_str(&s).unwrap_or_default(), + Err(_) => HashMap::new(), + } +} + /// Persist the active-agent map to a given state directory. fn save_active_agents_to(state_dir: &Path, map: &HashMap) { let path = state_file_path_for(state_dir); @@ -82,6 +97,25 @@ fn save_active_models_to(state_dir: &Path, map: &HashMap) { } } +/// Persist the active downstream session selections to a given state directory. +fn save_active_sessions_to(state_dir: &Path, map: &HashMap>) { + let path = active_session_state_file_path_for(state_dir); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_string_pretty(map) { + let _ = std::fs::write(&path, json); + } +} + +fn valid_downstream_session_name(session: &str) -> bool { + !session.is_empty() + && session.len() <= 128 + && session + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) +} + /// In-memory command handler with simple counters and per-identity active-agent state. pub struct CommandHandler { start_time: Instant, @@ -95,6 +129,9 @@ pub struct CommandHandler { /// Persisted to `state_dir/active-models.json` and restored into the /// [`AlloyManager`] once it is attached. active_models: Mutex>, + /// Per-identity, per-agent active downstream session selection. + /// Persisted to `state_dir/active-agent-sessions.json`. + active_sessions: Mutex>>, /// Directory for persisted state files. /// Defaults to `~/.calciforge/state/`; overridable for tests via /// [`CommandHandler::with_state_dir`]. @@ -140,6 +177,13 @@ impl CommandHandler { "loaded persisted active-model selections" ); } + let active_sessions = load_active_sessions_from(&state_dir); + if !active_sessions.is_empty() { + tracing::info!( + sessions = ?active_sessions, + "loaded persisted active session selections" + ); + } let http_client = reqwest::Client::builder() .connect_timeout(std::time::Duration::from_secs(30)) .build() @@ -151,6 +195,7 @@ impl CommandHandler { total_latency_ms: AtomicU64::new(0), active_agents: Mutex::new(active_agents), active_models: Mutex::new(active_models), + active_sessions: Mutex::new(active_sessions), state_dir, pending_approvals: Arc::new(tokio::sync::Mutex::new(HashMap::new())), http_client, @@ -236,6 +281,14 @@ impl CommandHandler { crate::auth::default_agent_for(identity_id, &self.config) } + /// Return the currently selected downstream session for an identity/agent. + pub fn active_session_for(&self, identity_id: &str, agent_id: &str) -> Option { + let map = self.active_sessions.lock().unwrap(); + map.get(identity_id) + .and_then(|sessions| sessions.get(agent_id)) + .cloned() + } + /// Handle a pre-auth command (commands that do not require identity context). /// /// Returns `Some(response)` if `text` starts with `!` and matches a known @@ -771,8 +824,15 @@ impl CommandHandler { // Check if this is an acpx agent and session was specified let is_acpx = agent_cfg.map(|a| a.kind == "acpx").unwrap_or(false); + if is_acpx { + if let Some(session) = session_arg.as_deref() { + if !valid_downstream_session_name(session) { + return "⚠️ Invalid session name. Use only letters, numbers, dot, underscore, and dash.".to_string(); + } + } + } let session_info = if is_acpx { - if let Some(session) = session_arg { + if let Some(session) = session_arg.as_ref() { format!(" (session: {})", session) } else { " (default session)".to_string() @@ -789,6 +849,25 @@ impl CommandHandler { map.insert(identity_id.to_string(), agent_id.to_string()); save_active_agents_to(&self.state_dir, &map); } + { + let mut sessions = self.active_sessions.lock().unwrap(); + if let Some(session) = session_arg.as_ref().filter(|_| is_acpx) { + sessions + .entry(identity_id.to_string()) + .or_default() + .insert(agent_id.to_string(), session.to_string()); + } else if is_acpx { + let mut remove_identity = false; + if let Some(identity_sessions) = sessions.get_mut(identity_id) { + identity_sessions.remove(agent_id); + remove_identity = identity_sessions.is_empty(); + } + if remove_identity { + sessions.remove(identity_id); + } + } + save_active_sessions_to(&self.state_dir, &sessions); + } format!( "✅ Switched to {}{}. Your messages will now route to {}.", @@ -908,11 +987,15 @@ impl CommandHandler { /// List ACPX sessions for an agent using the acpx CLI. async fn list_acpx_sessions(&self, agent_name: &str) -> Result, String> { + tokio::fs::create_dir_all(crate::adapters::acpx::ACPX_SESSION_DIR) + .await + .map_err(|e| format!("Failed to create acpx session dir: {}", e))?; + let output = tokio::process::Command::new("acpx") .arg(agent_name) .arg("sessions") .arg("list") - .current_dir("/tmp") + .current_dir(crate::adapters::acpx::ACPX_SESSION_DIR) .output() .await .map_err(|e| format!("Failed to run acpx: {}", e))?; @@ -950,6 +1033,11 @@ impl CommandHandler { map.insert(identity_id.to_string(), default_agent_id.clone()); save_active_agents_to(&self.state_dir, &map); } + { + let mut sessions = self.active_sessions.lock().unwrap(); + sessions.remove(identity_id); + save_active_sessions_to(&self.state_dir, &sessions); + } format!("✅ Switched to default agent: {}", default_agent_id) } @@ -1699,6 +1787,25 @@ mod tests { registry: None, aliases: vec!["keeper".to_string(), "cust".to_string()], }, + AgentConfig { + id: "claude-acpx".to_string(), + kind: "acpx".to_string(), + endpoint: String::new(), + timeout_ms: Some(120000), + model: None, + auth_token: None, + api_key: None, + api_key_file: None, + openclaw_agent_id: None, + allow_model_override: None, + reply_port: None, + reply_auth_token: None, + command: Some("claude".to_string()), + args: None, + env: None, + registry: None, + aliases: vec!["claude".to_string()], + }, ], routing: vec![ RoutingRule { @@ -2239,6 +2346,66 @@ mod tests { assert_eq!(h.active_agent_for("brian"), Some("custodian".to_string())); } + #[test] + fn test_switch_records_acpx_session_selection() { + let h = make_handler(); + let reply = h.handle_switch("!switch claude-acpx backend", "brian"); + assert!( + reply.contains("session: backend"), + "reply should identify selected session: {}", + reply + ); + assert_eq!(h.active_agent_for("brian"), Some("claude-acpx".to_string())); + assert_eq!( + h.active_session_for("brian", "claude-acpx"), + Some("backend".to_string()) + ); + } + + #[test] + fn test_switch_acpx_without_session_clears_prior_session() { + let h = make_handler(); + h.handle_switch("!switch claude-acpx backend", "brian"); + assert_eq!( + h.active_session_for("brian", "claude-acpx"), + Some("backend".to_string()) + ); + + let reply = h.handle_switch("!switch claude-acpx", "brian"); + assert!( + reply.contains("default session"), + "reply should show default session after clearing: {}", + reply + ); + assert_eq!(h.active_session_for("brian", "claude-acpx"), None); + } + + #[test] + fn test_switch_acpx_rejects_path_like_session_name() { + let h = make_handler(); + let reply = h.handle_switch("!switch claude-acpx ../backend", "brian"); + assert!( + reply.contains("Invalid session name"), + "path-like session should be rejected: {}", + reply + ); + assert_eq!(h.active_session_for("brian", "claude-acpx"), None); + } + + #[test] + fn test_default_clears_acpx_session_selection() { + let h = make_handler(); + h.handle_switch("!switch claude-acpx backend", "brian"); + assert_eq!( + h.active_session_for("brian", "claude-acpx"), + Some("backend".to_string()) + ); + + h.handle_default("brian"); + assert_eq!(h.active_agent_for("brian"), Some("librarian".to_string())); + assert_eq!(h.active_session_for("brian", "claude-acpx"), None); + } + #[test] fn test_switch_alias_not_in_allowed_is_rejected() { let h = make_handler(); diff --git a/crates/calciforge/src/config.rs b/crates/calciforge/src/config.rs index 64759f3f..dfebc208 100644 --- a/crates/calciforge/src/config.rs +++ b/crates/calciforge/src/config.rs @@ -307,11 +307,15 @@ pub struct RoutingRule { /// A channel entry (`[[channels]]`). /// -/// Supports `kind = "telegram"`, `kind = "matrix"`, `kind = "whatsapp"`, and `kind = "signal"`. +/// Supports `kind = "telegram"`, `kind = "matrix"`, `kind = "whatsapp"`, `kind = "signal"`, +/// and `kind = "sms"` for Linq-backed iMessage/RCS/SMS. /// For Telegram: set `bot_token_file`. /// For Matrix: set `homeserver`, `access_token_file`, `room_id`, and optionally `allowed_users`. -/// For WhatsApp: set `zeroclaw_endpoint`, `zeroclaw_auth_token`, `webhook_listen`, and `allowed_numbers`. -/// For Signal: set `zeroclaw_endpoint`, `zeroclaw_auth_token`, `webhook_listen`, and `allowed_numbers` (same fields as WhatsApp). +/// For WhatsApp: set `whatsapp_session_path` and `allowed_numbers`. +/// For Signal: set `signal_cli_url`, `signal_account`, and `allowed_numbers`. +/// For text/iMessage: set `sms_linq_api_token_file`, `sms_from_phone`, and `allowed_numbers`; +/// configure inbound webhooks with `sms_webhook_listen` and `sms_webhook_path`; and prefer +/// `sms_linq_signing_secret_file` or `sms_linq_signing_secret` for webhook signature checks. #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct ChannelConfig { pub kind: String, @@ -336,34 +340,92 @@ pub struct ChannelConfig { #[serde(default)] pub allowed_users: Vec, - // --- WhatsApp/Signal-specific fields (shared) --- - /// ZeroClaw / OpenClaw gateway endpoint that owns the WA Web or Signal session. - /// Calciforge will POST reply messages to `{zeroclaw_endpoint}/tools/invoke`. - /// Example: `"http://127.0.0.1:18789"` (local OpenClaw) or - /// `"http://10.0.0.10:18789"` (remote Lucien/ZeroClaw instance). + // --- Legacy webhook fields (rejected by embedded Signal/WhatsApp channels) --- + /// Legacy ZeroClaw / OpenClaw gateway endpoint. Embedded Signal and + /// WhatsApp reject this field with a migration error. pub zeroclaw_endpoint: Option, - /// Bearer token for the ZeroClaw / OpenClaw gateway. + /// Legacy bearer token for the ZeroClaw / OpenClaw gateway. pub zeroclaw_auth_token: Option, - /// HTTP address to listen on for incoming webhook POSTs from ZeroClaw. - /// Defaults to `"0.0.0.0:18795"`. + /// Legacy webhook listen address. pub webhook_listen: Option, - /// URL path Calciforge registers for incoming WhatsApp webhooks. - /// Defaults to `"/webhooks/whatsapp"`. + /// Legacy webhook path. pub webhook_path: Option, - /// Optional HMAC-SHA256 secret for `X-Hub-Signature-256` webhook verification. - /// Leave unset to skip signature checking (not recommended for production). + /// Legacy webhook HMAC secret. pub webhook_secret: Option, /// Allowed sender phone numbers in E.164 format, e.g. `["+15555550001"]`. /// Use `"*"` to allow any number (not recommended). - /// Must correspond to identity aliases with `channel = "whatsapp"` or `channel = "signal"`. + /// Must correspond to identity aliases with `channel = "whatsapp"`, `channel = "signal"`, + /// or `channel = "sms"`. #[serde(default)] pub allowed_numbers: Vec, + // --- WhatsApp-specific fields (embedded `zeroclawlabs::WhatsAppWebChannel`) --- + /// Path to the WhatsApp Web SQLite session database. + pub whatsapp_session_path: Option, + + /// Optional phone number for pairing-code linking, without punctuation. + pub whatsapp_pair_phone: Option, + + /// Optional custom pair code; leave unset for auto-generated pairing. + pub whatsapp_pair_code: Option, + + /// When true, only respond to WhatsApp group messages that mention the bot. + #[serde(default)] + pub whatsapp_mention_only: bool, + + /// WhatsApp Web operating mode. + #[serde(default)] + pub whatsapp_mode: zeroclaw::config::WhatsAppWebMode, + + /// Direct-message policy when `whatsapp_mode = "personal"`. + #[serde(default)] + pub whatsapp_dm_policy: zeroclaw::config::WhatsAppChatPolicy, + + /// Group-chat policy when `whatsapp_mode = "personal"`. + #[serde(default)] + pub whatsapp_group_policy: zeroclaw::config::WhatsAppChatPolicy, + + /// When true, always respond in self-chat mode when `whatsapp_mode = "personal"`. + #[serde(default)] + pub whatsapp_self_chat_mode: bool, + + /// Optional case-insensitive regexes that count as bot mentions in DMs. + #[serde(default)] + pub whatsapp_dm_mention_patterns: Vec, + + /// Optional case-insensitive regexes that count as bot mentions in groups. + #[serde(default)] + pub whatsapp_group_mention_patterns: Vec, + + // --- Text/iMessage-specific fields (embedded `zeroclawlabs::LinqChannel`) --- + /// Linq Partner API token. Prefer `sms_linq_api_token_file`. + pub sms_linq_api_token: Option, + + /// Path to a file containing the Linq Partner API token. + pub sms_linq_api_token_file: Option, + + /// E.164 phone number Linq should send from. + pub sms_from_phone: Option, + + /// HTTP address to listen on for Linq inbound webhooks. + /// Defaults to `"0.0.0.0:18798"`. + pub sms_webhook_listen: Option, + + /// URL path for Linq inbound webhooks. + /// Defaults to `"/webhooks/sms"`. + pub sms_webhook_path: Option, + + /// Linq webhook signing secret. Prefer `sms_linq_signing_secret_file`. + pub sms_linq_signing_secret: Option, + + /// Path to a file containing the Linq webhook signing secret. + pub sms_linq_signing_secret_file: Option, + // --- Signal-specific fields (embedded `zeroclawlabs::SignalChannel`) --- /// HTTP URL of `signal-cli-rest-api` (or compatible signal-cli daemon /// HTTP front-end). Example: `"http://127.0.0.1:8080"`. @@ -1611,6 +1673,13 @@ weight = 20 .collect() } + fn full_config_blocks_from_doc(markdown: &str) -> Vec { + extract_toml_blocks(markdown) + .into_iter() + .filter(|b| b.contains("[calciforge]")) + .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| { @@ -1702,17 +1771,46 @@ 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" +whatsapp_session_path = "~/.calciforge/whatsapp/session.db" +whatsapp_pair_phone = "15555550001" 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") + cfg.channels[0].whatsapp_session_path.as_deref(), + Some("~/.calciforge/whatsapp/session.db") + ); + assert_eq!( + cfg.channels[0].whatsapp_pair_phone.as_deref(), + Some("15555550001") + ); + } + + #[test] + fn test_channel_config_sms_inline() { + let raw = r#" +[calciforge] +version = 2 + +[[channels]] +kind = "sms" +enabled = true +sms_linq_api_token_file = "~/.calciforge/secrets/linq-token" +sms_from_phone = "+15555550001" +sms_webhook_listen = "0.0.0.0:18798" +sms_webhook_path = "/webhooks/sms" +allowed_numbers = ["+15555550100"] +"#; + let cfg: CalciforgeConfig = toml::from_str(raw).expect("sms channel config"); + assert_eq!(cfg.channels[0].kind, "sms"); + assert_eq!( + cfg.channels[0].sms_linq_api_token_file.as_deref(), + Some("~/.calciforge/secrets/linq-token") + ); + assert_eq!( + cfg.channels[0].sms_from_phone.as_deref(), + Some("+15555550001") ); } @@ -1783,4 +1881,36 @@ allowed_numbers = ["+15555550001"] ); } } + + #[test] + fn test_channel_docs_sms_toml_blocks_valid() { + let doc = include_str!("../../../docs/channels/sms.md"); + let blocks = channel_blocks_from_doc(doc); + assert!( + !blocks.is_empty(), + "no [[channels]] TOML blocks found in sms.md" + ); + for block in blocks { + let cfg = parse_channel_block(&block); + assert_eq!( + cfg.channels[0].kind, "sms", + "unexpected kind in block:\n{block}" + ); + } + } + + #[test] + fn test_agents_doc_full_config_toml_blocks_valid() { + let doc = include_str!("../../../docs/agents.md"); + let blocks = full_config_blocks_from_doc(doc); + assert!( + !blocks.is_empty(), + "no full config TOML blocks found in agents.md" + ); + for block in blocks { + toml::from_str::(&block).unwrap_or_else(|e| { + panic!("agents.md full config block failed to parse:\n{block}\nerror: {e}") + }); + } + } } diff --git a/crates/calciforge/src/doctor.rs b/crates/calciforge/src/doctor.rs index f3481d15..830bc5dc 100644 --- a/crates/calciforge/src/doctor.rs +++ b/crates/calciforge/src/doctor.rs @@ -383,23 +383,23 @@ fn check_proxy_environment_in(env: ProxyEnvironment, report: &mut DoctorReport) (Some(http), Some(https)) => { if http == https { report.warn(format!( - "Current calciforge doctor process has ambient HTTP(S)_PROXY configured ({}); if the service runs with the same env, model-provider and control-plane traffic can route through security-proxy. Prefer explicit proxy env on agent subprocesses instead.", + "Current calciforge doctor process has ambient HTTP(S)_PROXY configured ({}); if the service runs with the same env, model-provider, channel, and control-plane traffic can route through security-proxy. Prefer no ambient proxy on the Calciforge service.", display_proxy_value(http) )); } else { report.warn(format!( - "Current calciforge doctor process has ambient HTTP_PROXY and HTTPS_PROXY configured but they differ; HTTP_PROXY={}, HTTPS_PROXY={}. Prefer no ambient proxy on the Calciforge service and explicit proxy env on agent subprocesses.", + "Current calciforge doctor process has ambient HTTP_PROXY and HTTPS_PROXY configured but they differ; HTTP_PROXY={}, HTTPS_PROXY={}. Prefer no ambient proxy on the Calciforge service.", display_proxy_value(http), display_proxy_value(https) )); } } (Some(http), None) => report.warn(format!( - "Current calciforge doctor process has ambient HTTP_PROXY set ({}). Prefer no ambient proxy on the Calciforge service and explicit proxy env on agent subprocesses.", + "Current calciforge doctor process has ambient HTTP_PROXY set ({}). Prefer no ambient proxy on the Calciforge service.", display_proxy_value(http) )), (None, Some(https)) => report.warn(format!( - "Current calciforge doctor process has ambient HTTPS_PROXY set ({}) but HTTP_PROXY is not set. Prefer no ambient proxy on the Calciforge service and explicit proxy env on agent subprocesses.", + "Current calciforge doctor process has ambient HTTPS_PROXY set ({}) but HTTP_PROXY is not set. Prefer no ambient proxy on the Calciforge service.", display_proxy_value(https) )), (None, None) => report.ok( @@ -455,7 +455,7 @@ fn check_agent_proxy_coverage( if has_http_proxy(env) { report.warn( - "Current calciforge doctor process has ambient HTTP_PROXY; subprocess inheritance works if the service has the same env, but it also risks proxying Calciforge's own provider/control-plane calls. Move proxy env to agent-level config or wrappers.", + "Current calciforge doctor process has ambient HTTP_PROXY; subprocess inheritance works only if the service has the same env, and it can break CLI agents that use CONNECT, WebSockets, npm, or browser-backed auth. Prefer no ambient proxy and only wrap agents through tested recipes.", ); } @@ -467,28 +467,28 @@ fn check_agent_proxy_coverage( if incomplete_count > 0 { report.warn(format!( - "{incomplete_count} subprocess agent(s) define incomplete proxy env; set HTTP_PROXY to the intended security-proxy endpoint or clear proxy env keys" + "{incomplete_count} subprocess agent(s) define partial proxy env; either remove proxy env or use a tested wrapper that the agent supports" )); } - if complete_count == subprocess_count && clearing_count == 0 && incomplete_count == 0 { + if complete_count > 0 { + report.warn(format!( + "{complete_count} subprocess agent(s) define explicit HTTP_PROXY env; verify the specific CLI supports that proxy path. HTTPS_PROXY/CONNECT traffic is not inspected by security-proxy and may break streaming agents." + )); + } + + let missing_count = subprocess_agents + .iter() + .filter(|agent| { + !has_complete_agent_proxy_env(agent) + && !has_incomplete_agent_proxy_env(agent) + && !clears_agent_proxy_env(agent) + }) + .count(); + if missing_count > 0 { report.ok(format!( - "{subprocess_count} subprocess agent(s) define explicit HTTP_PROXY env; verify these point at security-proxy and include NO_PROXY for loopback" + "{missing_count} subprocess agent(s) have no explicit proxy env; use explicit tool/fetch integration or a tested wrapper for traffic that must pass through security-proxy" )); - } else { - let missing_count = subprocess_agents - .iter() - .filter(|agent| { - !has_complete_agent_proxy_env(agent) - && !has_incomplete_agent_proxy_env(agent) - && !clears_agent_proxy_env(agent) - }) - .count(); - if missing_count > 0 { - report.warn(format!( - "{missing_count} subprocess agent(s) lack explicit HTTP_PROXY env; Calciforge no longer supplies ambient proxy env" - )); - } } } @@ -1428,7 +1428,7 @@ mod tests { } #[test] - fn subprocess_agent_proxy_coverage_warns_without_complete_proxy_env() { + fn subprocess_agent_proxy_coverage_accepts_missing_proxy_env() { let mut config = base_config(); config.agents = vec![AgentConfig { id: "codex".to_string(), @@ -1448,13 +1448,13 @@ mod tests { ); assert!(report.findings.iter().any(|finding| { - finding.severity == Severity::Warn - && finding.message.contains("lack explicit HTTP_PROXY env") + finding.severity == Severity::Ok + && finding.message.contains("have no explicit proxy env") })); } #[test] - fn subprocess_agent_proxy_coverage_accepts_complete_agent_proxy_env() { + fn subprocess_agent_proxy_coverage_warns_on_complete_agent_proxy_env() { let mut config = base_config(); config.agents = vec![AgentConfig { id: "dirac".to_string(), @@ -1484,7 +1484,7 @@ mod tests { ); assert!(report.findings.iter().any(|finding| { - finding.severity == Severity::Ok + finding.severity == Severity::Warn && finding.message.contains("define explicit HTTP_PROXY env") })); } @@ -1515,7 +1515,7 @@ mod tests { assert!(report.findings.iter().any(|finding| { finding.severity == Severity::Warn - && finding.message.contains("define incomplete proxy env") + && finding.message.contains("define partial proxy env") })); } diff --git a/crates/calciforge/src/main.rs b/crates/calciforge/src/main.rs index 62f6870a..4cccf59d 100644 --- a/crates/calciforge/src/main.rs +++ b/crates/calciforge/src/main.rs @@ -371,12 +371,20 @@ async fn main() -> Result<()> { .iter() .any(|c| c.kind == "signal" && c.enabled); + let has_sms = config.channels.iter().any(|c| c.kind == "sms" && c.enabled); + let has_mock = config .channels .iter() .any(|c| c.kind == "mock" && c.enabled); - if !args.proxy_only && !has_telegram && !has_matrix && !has_whatsapp && !has_signal && !has_mock + if !args.proxy_only + && !has_telegram + && !has_matrix + && !has_whatsapp + && !has_signal + && !has_sms + && !has_mock { error!("no enabled channels found in config — nothing to do"); std::process::exit(1); @@ -418,7 +426,7 @@ async fn main() -> Result<()> { let whatsapp_fut = async { if !args.proxy_only && has_whatsapp { - info!("starting WhatsApp channel (webhook receiver)"); + info!("starting WhatsApp channel (embedded WhatsApp Web client)"); channels::whatsapp::run( config.clone(), router.clone(), @@ -435,7 +443,7 @@ async fn main() -> Result<()> { let signal_fut = async { if !args.proxy_only && has_signal { - info!("starting Signal channel (webhook receiver)"); + info!("starting Signal channel (embedded signal-cli-rest-api bridge)"); channels::signal::run( config.clone(), router.clone(), @@ -450,6 +458,23 @@ async fn main() -> Result<()> { } }; + let sms_fut = async { + if !args.proxy_only && has_sms { + info!("starting Text/iMessage channel (Linq webhook receiver)"); + channels::sms::run( + config.clone(), + router.clone(), + command_handler.clone(), + context_store.clone(), + channel_scanner.clone(), + ) + .await + .context("Text/iMessage channel error") + } else { + Ok(()) + } + }; + let mock_fut = async { if !args.proxy_only && has_mock { info!("starting Mock channel"); @@ -510,11 +535,12 @@ async fn main() -> Result<()> { } }; - let (tg_result, mx_result, wa_result, sig_result, mock_result, proxy_result) = tokio::join!( + let (tg_result, mx_result, wa_result, sig_result, sms_result, mock_result, proxy_result) = tokio::join!( telegram_fut, matrix_fut, whatsapp_fut, signal_fut, + sms_fut, mock_fut, proxy_fut ); @@ -523,6 +549,7 @@ async fn main() -> Result<()> { mx_result?; wa_result?; sig_result?; + sms_result?; mock_result?; Ok(()) diff --git a/crates/calciforge/src/router.rs b/crates/calciforge/src/router.rs index 3f53efca..9914325f 100644 --- a/crates/calciforge/src/router.rs +++ b/crates/calciforge/src/router.rs @@ -74,13 +74,24 @@ impl Router { model_override: Option<&str>, ) -> Result { let response = self - .dispatch_message_with_sender_and_model(text, agent, _config, sender, model_override) + .dispatch_message_with_sender_model_and_session( + text, + agent, + _config, + sender, + model_override, + None, + ) .await?; Ok(response.render_text_fallback()) } /// Dispatch a message and preserve attachments/artifacts for channels that /// know how to render richer outbound messages. + #[expect( + dead_code, + reason = "kept for callers that need sender/model context without downstream session selection" + )] pub async fn dispatch_message_with_sender_and_model( &self, text: &str, @@ -88,6 +99,28 @@ impl Router { _config: &CalciforgeConfig, sender: Option<&str>, model_override: Option<&str>, + ) -> Result { + self.dispatch_message_with_sender_model_and_session( + text, + agent, + _config, + sender, + model_override, + None, + ) + .await + } + + /// Dispatch a message with sender/model context and an optional downstream + /// session selected by the authenticated user. + pub async fn dispatch_message_with_sender_model_and_session( + &self, + text: &str, + agent: &AgentConfig, + _config: &CalciforgeConfig, + sender: Option<&str>, + model_override: Option<&str>, + session: Option<&str>, ) -> Result { let adapter = build_adapter(agent).map_err(|e| { anyhow::anyhow!("failed to build adapter for agent '{}': {}", agent.id, e) @@ -116,6 +149,7 @@ impl Router { endpoint = %agent.endpoint, configured_model = ?agent.model, model_override = ?effective_model_override, + session = ?session, sender = ?sender, "routing message via {} adapter", adapter.kind() @@ -125,6 +159,7 @@ impl Router { message: text, sender, model_override: effective_model_override, + session, }; adapter .dispatch_message_with_context(ctx) diff --git a/docs/MANUAL_INSTALL.md b/docs/MANUAL_INSTALL.md index 47960466..0f4f0554 100644 --- a/docs/MANUAL_INSTALL.md +++ b/docs/MANUAL_INSTALL.md @@ -104,9 +104,16 @@ systemctl enable adversary-detector security-gateway clashd systemctl start adversary-detector security-gateway clashd ``` -## Step 5: Set up proxy env vars +## Step 5: Configure optional external-agent proxy env + +Do not set `HTTP_PROXY` or `HTTPS_PROXY` globally for Calciforge itself. The +Calciforge service should call providers, channels, and control-plane endpoints +directly unless a stronger host/container boundary is configured. + +For an external agent daemon that you have tested with `security-proxy`, set +proxy environment in that daemon's service manager instead of in +`/etc/profile.d`. For example: -Create `/etc/profile.d/calciforge-proxy.sh`: ```bash export HTTP_PROXY=http://127.0.0.1:8080 export NO_PROXY=localhost,127.0.0.1,10.*.*.*,172.16.*.*,192.168.*.* @@ -115,11 +122,8 @@ export NO_PROXY=localhost,127.0.0.1,10.*.*.*,172.16.*.*,192.168.*.* `HTTPS_PROXY` is intentionally omitted from the basic setup because standard HTTPS proxying uses CONNECT tunnels that Calciforge cannot inspect without a separate MITM design. Use explicit Calciforge fetch/tool integration for HTTPS -content that must be scanned or rewritten. - -```bash -chmod +x /etc/profile.d/calciforge-proxy.sh -``` +content that must be scanned or rewritten, or run the agent inside a controlled +container/VM profile that forces egress through Calciforge services. ## Step 6: Set API credentials diff --git a/docs/README.md b/docs/README.md index e9dc1a67..c16319f7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,6 +5,7 @@ that should be reasonably stable: - `index.md` — GitHub Pages feature tour - `agent-adapters.md` — agent adapter selection and evaluation notes +- `agents.md` — agent backends, identities, and routing rules - `agent-adapters.md` also covers secured recipes, artifact-producing CLI integrations, and the early orchestrator support model for async work systems. @@ -22,5 +23,5 @@ Manual candidate-adapter smoke checks live in Gas Town in disposable Docker containers to verify current CLI surfaces before turning a recipe into first-class support. -Internal reviews, audit scratchpads, vendor comparisons, and session -planning notes live under [`../research/`](../research/) instead. +Internal reviews, audit scratchpads, vendor comparisons, and session planning +notes should stay outside the public documentation tree. diff --git a/docs/_config.yml b/docs/_config.yml index 64749a55..4118784d 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -1,5 +1,2 @@ title: Calciforge description: Keep your castle secure and moving. -exclude: - - rfcs/ - - roadmap/ diff --git a/docs/acpx-claude-setup.md b/docs/acpx-claude-setup.md index 4a478aea..7cce82da 100644 --- a/docs/acpx-claude-setup.md +++ b/docs/acpx-claude-setup.md @@ -110,6 +110,18 @@ Once configured, messages routed to `claude-acpx` go directly to Claude Code. Us !agents # list available agents ``` +ACPX sessions can be listed and selected from any authenticated channel: + +``` +!sessions claude-acpx +!switch claude-acpx backend-review +``` + +When a session name is supplied, Calciforge persists that identity's selected +session for the ACPX agent and passes it to `acpx --session ` on +subsequent messages. Running `!switch claude-acpx` without a session returns +the identity to the default ACPX session. + ## Troubleshooting **`acpx exec failed: Authentication required`** diff --git a/docs/agent-adapters.md b/docs/agent-adapters.md index 6f5530fc..320dc9ad 100644 --- a/docs/agent-adapters.md +++ b/docs/agent-adapters.md @@ -24,7 +24,7 @@ and failure modes. | Agent | Calciforge path | Notes | |---|---|---| -| Codex CLI | `kind = "codex-cli"` or `[[exec_models]]` | Good fit when the Unix account running Calciforge owns Codex credentials. Keep chat-facing agents conservative unless the channel is trusted. | +| Codex CLI | `kind = "codex-cli"`, `[[exec_models]]`, or future ACP via `codex-acp` | Good fit when the Unix account running Calciforge owns Codex credentials. Zed's Apache-2.0 `codex-acp` adapter is the better reference path for richer Codex sessions because it exposes ACP features such as images, tool-call permission requests, edit review, TODO lists, slash commands, MCP server forwarding, and Codex auth methods. Keep chat-facing agents conservative unless the channel is trusted. | | Claude Code | `kind = "cli"` or `acpx` | Use `claude -p` for simple subscription-backed prompt execution. Use `acpx` when ACP sessions are needed. | | OpenClaw | `openclaw-channel` | Preferred path for richer agent runtime, skills, plugins, provider routing, and slash commands. Calciforge no longer supports OpenClaw agent chat through `/v1/chat/completions`. | | OpenAI-compatible endpoint | `openai-compat` | Plain `/v1/chat/completions` target for Calciforge's model gateway, local test gateways, or compatible model APIs. Set `allow_model_override = true` only when this endpoint should accept Calciforge `!model` selections. | @@ -52,8 +52,9 @@ parsing or safety behavior. Use a recipe when an upstream tool is useful but its protocol is still just a documented command invocation. Recipes can still be security-aware: Calciforge -owns identity checks, routing, proxy environment, per-run artifact directories, -timeouts, output validation, and audit logs. +owns identity checks, routing, per-run artifact directories, timeouts, output +validation, audit logs, and tested network wrappers where the upstream runtime +supports them. Use a named adapter when Calciforge needs upstream-specific parsing or safety defaults that cannot be expressed cleanly as a recipe. Dirac is the current @@ -95,7 +96,6 @@ args = [ "{message}", ] timeout_ms = 120000 -env = { HTTP_PROXY = "http://127.0.0.1:8888", HTTPS_PROXY = "http://127.0.0.1:8888", NO_PROXY = "localhost,127.0.0.1,::1" } ``` The command above must read the actual task from stdin. `{message}` is a safe @@ -113,7 +113,6 @@ args = [ "{artifact_dir}/image.png", ] timeout_ms = 180000 -env = { HTTP_PROXY = "http://127.0.0.1:8888", HTTPS_PROXY = "http://127.0.0.1:8888", NO_PROXY = "localhost,127.0.0.1,::1" } ``` Treat this npcsh command as a recipe to verify against the installed npcsh @@ -127,6 +126,8 @@ Before promoting a recipe, run: ```bash scripts/agent-recipe-smoke.sh +# or include it in the local Docker deploy smoke: +CALCIFORGE_AGENT_RECIPE_SMOKE=1 scripts/manual-docker-test.sh ``` The smoke script installs npcsh, OmO/oh-my-opencode, and Gas Town in disposable diff --git a/docs/agents.md b/docs/agents.md new file mode 100644 index 00000000..b1c0410a --- /dev/null +++ b/docs/agents.md @@ -0,0 +1,325 @@ +--- +layout: default +title: Agents, Identities, and Routing +--- + +# Agents, Identities, and Routing + +This page covers the three configuration sections that together control who +can talk to Calciforge and which AI backend handles their messages: + +- `[[agents]]` — AI backends Calciforge dispatches to +- `[[identities]]` — users and their per-channel aliases +- `[[routing]]` — maps identities to agents + +## Architecture + +``` +Channel message arrives + │ + ▼ + Identity lookup [[identities]] — alias (channel + id) → identity + │ + ▼ + Routing rule [[routing]] — identity → default_agent + allowed_agents + │ + ▼ + Agent dispatch [[agents]] — build adapter, send message, return reply + │ + ▼ + Reply sent back to user +``` + +--- + +## Agents (`[[agents]]`) + +Each `[[agents]]` entry defines one AI backend. The `kind` field selects the +adapter. All other fields are adapter-specific. + +### Common fields + +| Field | Required | Default | Description | +|---|---|---|---| +| `id` | yes | — | Unique name used in routing and `!switch` commands | +| `kind` | yes | — | Adapter type (see below) | +| `timeout_ms` | no | adapter default | Per-request timeout in milliseconds | +| `model` | no | — | Model name forwarded to the backend | +| `api_key` | no | — | Bearer token for the backend; overrides `CALCIFORGE_AGENT_TOKEN` | +| `api_key_file` | no | — | Path to file containing the API key (preferred over inline `api_key`) | +| `auth_token` | no | — | Legacy alias for `api_key` (openclaw-channel) | +| `aliases` | no | `[]` | Additional names matched by `!switch` | +| `allow_model_override` | no | adapter default | Whether `!model` overrides from identities are forwarded | +| `registry` | no | — | Optional metadata shown in `!agents` output (see below) | + +### `kind = "openclaw-channel"` + +HTTP adapter that talks to an OpenClaw or Calciforge gateway running the +channel plugin. The gateway maintains the agent session; Calciforge acts as +the routing and security layer in front of it. + +Required: `endpoint`. Recommended: `api_key` or `api_key_file`. + +```toml +[[agents]] +id = "librarian" +kind = "openclaw-channel" +endpoint = "http://127.0.0.1:18789" +api_key_file = "~/.calciforge/secrets/librarian-token" +timeout_ms = 120000 +aliases = ["lib", "main"] +registry = { display_name = "Librarian", specialties = ["general", "homelab-ops"] } +``` + +`openclaw_agent_id` (optional) sets the lane id sent to the gateway; defaults +to this agent's `id`. + +`reply_port` (optional, default 18797) is the local port Calciforge listens on +for async `/hooks/reply` callbacks when the gateway pushes replies +asynchronously instead of returning them synchronously. + +`reply_auth_token` (optional) — bearer token required on incoming +`/hooks/reply` callbacks. + +### `kind = "openai-compat"` + +Generic OpenAI-compatible HTTP endpoint (Ollama, LM Studio, Anthropic, +Together, any endpoint that accepts `/v1/chat/completions`). + +Required: `endpoint`. Recommended: `model`. + +```toml +[[agents]] +id = "local-llm" +kind = "openai-compat" +endpoint = "http://127.0.0.1:11434" +model = "llama3.2" +timeout_ms = 180000 +allow_model_override = true +``` + +Without `model`, Calciforge will not forward a model name to the backend +unless `allow_model_override = true` and the identity sets `!model`. + +### `kind = "zeroclaw"` + +Direct ZeroClaw agent endpoint (legacy; use `openclaw-channel` for new +deployments). + +Required: `endpoint`, `api_key`. + +```toml +[[agents]] +id = "zeroclaw" +kind = "zeroclaw" +endpoint = "http://127.0.0.1:18792" +api_key_file = "~/.calciforge/secrets/zeroclaw-token" +timeout_ms = 90000 +``` + +### `kind = "cli"` + +Spawns a subprocess for each message. The command receives the message via +the argument template: `{message}` in `args` is replaced at dispatch time. + +Required: `command`. + +```toml +[[agents]] +id = "ironclaw" +kind = "cli" +command = "/usr/local/bin/ironclaw" +args = ["run", "-m", "{message}"] +timeout_ms = 60000 +env = { "LLM_BACKEND" = "openai_compatible", "LLM_MODEL" = "kimi-k2.5" } +``` + +`env` (optional) — extra environment variables passed to the subprocess. + +**Security note:** `{message}` in `args` places user content in the process +argv, which is visible in `ps` output and `/proc//cmdline` on multi-user +systems. If the message may contain secret values, use a CLI that reads from +stdin instead and pass the message via stdin rather than argv. + +### `kind = "acp"` + +Persistent-session adapter for ACP-compliant agents (e.g. `claude --acp`, +`opencode acp`). Unlike `cli`, the process stays alive between messages so +session context is preserved. + +Required: `command` (the binary to invoke). + +```toml +[[agents]] +id = "claude-code" +kind = "acp" +command = "claude" +args = ["--acp"] +model = "claude-sonnet-4-5" +timeout_ms = 300000 +aliases = ["cc", "claude"] +registry = { display_name = "Claude Code", specialties = ["coding", "refactoring"] } +``` + +### `kind = "acpx"` + +Like `acp`, but delegates ACP protocol handling to the `acpx` binary, which +supports additional protocol versions. The `command` field holds the agent +name (not a path); `acpx` resolves it. + +Required: `command` (agent name passed to acpx). + +```toml +[[agents]] +id = "opencode" +kind = "acpx" +command = "opencode" +timeout_ms = 300000 +``` + +### `kind = "codex-cli"` and `kind = "dirac-cli"` + +Subprocess adapters for OpenAI Codex CLI and Dirac CLI respectively. +`command` is optional and defaults to the standard binary name. Both support +`model`, `args`, `env`, and `timeout_ms`. + +```toml +[[agents]] +id = "codex" +kind = "codex-cli" +model = "codex-mini-latest" +timeout_ms = 120000 +``` + +### Registry metadata + +The optional `registry` table is not used at dispatch time — it populates the +`!agents` command output so users can discover available agents. + +```toml +[[agents]] +id = "librarian" +kind = "openclaw-channel" +endpoint = "http://127.0.0.1:18789" +api_key_file = "~/.calciforge/secrets/librarian-token" +timeout_ms = 120000 + +[agents.registry] +display_name = "Librarian" +description = "General-purpose assistant for homelab and daily tasks" +specialties = ["general", "homelab-ops", "research"] +access = ["admin", "user"] +primary_channels = ["telegram", "matrix"] +``` + +--- + +## Identities (`[[identities]]`) + +An identity is a named user. The `aliases` list maps channel-specific IDs +(phone numbers, Telegram user IDs, Matrix handles) to the identity name. +Routing rules reference the identity `id`. + +| Field | Required | Default | Description | +|---|---|---|---| +| `id` | yes | — | Unique identity name used in routing rules | +| `display_name` | no | — | Human-readable name for logs and `!who` output | +| `role` | no | — | Arbitrary role string (e.g. `"admin"`, `"user"`) | +| `aliases` | no | `[]` | Per-channel IDs: `{ channel = "...", id = "..." }` | + +Alias `id` format by channel: + +| Channel | Alias `id` format | Example | +|---|---|---| +| `telegram` | numeric user ID | `"7000000001"` | +| `matrix` | Matrix user ID | `"@alice:matrix.org"` | +| `whatsapp` | E.164 phone number | `"+15555550001"` | +| `signal` | E.164 phone number | `"+15555550001"` | +| `sms` | E.164 phone number | `"+15555550001"` | + +```toml +[[identities]] +id = "operator" +display_name = "Alice" +role = "admin" +aliases = [ + { channel = "telegram", id = "7000000001" }, + { channel = "matrix", id = "@alice:matrix.org" }, + { channel = "whatsapp", id = "+15555550001" }, + { channel = "signal", id = "+15555550001" }, +] +``` + +--- + +## Routing (`[[routing]]`) + +Each routing rule maps one identity to a default agent and an optional +allowlist of agents they may switch to. + +| Field | Required | Default | Description | +|---|---|---|---| +| `identity` | yes | — | Must match an `id` in `[[identities]]` | +| `default_agent` | yes | — | Agent dispatched when no `!switch` is active | +| `allowed_agents` | no | `[]` | Agents the identity may `!switch` to; empty = no restriction (any configured agent, regardless of role) | + +```toml +[[routing]] +identity = "operator" +default_agent = "librarian" +allowed_agents = ["librarian", "claude-code", "local-llm"] + +[[routing]] +identity = "readonly-user" +default_agent = "librarian" +allowed_agents = ["librarian"] +``` + +When `allowed_agents` is empty, the identity can switch to any configured +agent — there is no role-based check. Set it explicitly for every identity +that should not have unrestricted agent access. + +--- + +## Full example + +Minimal working config combining agents, identities, and routing: + +```toml +[calciforge] +version = 2 + +[[identities]] +id = "operator" +display_name = "Alice" +role = "admin" +aliases = [{ channel = "telegram", id = "7000000001" }] + +[[agents]] +id = "librarian" +kind = "openclaw-channel" +endpoint = "http://127.0.0.1:18789" +api_key_file = "~/.calciforge/secrets/librarian-token" +timeout_ms = 120000 + +[[routing]] +identity = "operator" +default_agent = "librarian" +allowed_agents = ["librarian"] + +[[channels]] +kind = "telegram" +enabled = true +bot_token_file = "~/.calciforge/secrets/telegram-token" +``` + +## Verify + +```bash +calciforge doctor # checks agent reachability and identity/routing consistency +calciforge # start; send a message from a configured alias +``` + +`calciforge doctor` warns on common misconfigurations: missing `api_key` on +`openclaw-channel` agents, `openai-compat` without `model`, identities with +no routing rule, and routing rules that reference undefined agents. diff --git a/docs/channels/sms.md b/docs/channels/sms.md new file mode 100644 index 00000000..6ac9c876 --- /dev/null +++ b/docs/channels/sms.md @@ -0,0 +1,71 @@ +--- +layout: default +title: Text/iMessage Channel Setup +--- + +# Text/iMessage Channel + +Calciforge exposes text/iMessage routing as `kind = "sms"`. Under the hood it uses +the `zeroclawlabs::LinqChannel` transport, which can send and receive +iMessage, RCS, and SMS through the Linq Partner API. + +Inbound messages arrive as Linq webhooks. Outbound replies go through the Linq +API, but still pass through Calciforge identity resolution, routing, security +scan settings, and artifact fallback rendering. + +```text +phone user -> Linq webhook -> Calciforge -> agent +phone user <- Linq API <- Calciforge <- agent +``` + +## Configure + +```toml +[[channels]] +kind = "sms" +enabled = true +sms_linq_api_token_file = "~/.calciforge/secrets/linq-token" +sms_from_phone = "+15555550001" +sms_webhook_listen = "0.0.0.0:18798" +sms_webhook_path = "/webhooks/sms" +allowed_numbers = ["+15555550100"] + +# Recommended for public webhooks. +# sms_linq_signing_secret_file = "~/.calciforge/secrets/linq-webhook-secret" + +# Optional security scan for inbound messages. +# scan_messages = true +``` + +```toml +[[identities]] +id = "operator" +display_name = "Operator" +role = "owner" +aliases = [ + { channel = "sms", id = "+15555550100" }, +] +``` + +## Linq Webhook + +Point the Linq Partner webhook at: + +```text +https://YOUR-HOST.example.com/webhooks/sms +``` + +If `sms_linq_signing_secret_file` or `sms_linq_signing_secret` is configured, +Calciforge verifies `X-Webhook-Timestamp` and `X-Webhook-Signature` before +parsing the payload. + +## Verify + +```bash +calciforge doctor +calciforge +``` + +Send `!ping` from an allowed phone number. Calciforge replies to the Linq +conversation id when the webhook includes one, otherwise it replies directly to +the sender phone number. diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index 906dd46d..d067ef8e 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -5,191 +5,83 @@ title: WhatsApp Channel Setup # WhatsApp Channel -Calciforge's WhatsApp channel is a **webhook receiver**. It accepts incoming -messages from any compatible WhatsApp gateway and sends replies back through the -gateway's outbound API. Calciforge does not own the WhatsApp Web session itself. - -The contract is the wire format, not a specific product: - -- **Inbound:** the gateway POSTs to `/webhooks/whatsapp` in the WhatsApp Cloud API - webhook payload format (see [Webhook payload format](#webhook-payload-format) below). -- **Outbound:** Calciforge POSTs replies to `{gateway}/tools/invoke` with the body - shape documented under [Reply API](#reply-api). - -Any gateway that implements those two endpoints will work. The known-working -implementation is [ZeroClaw](https://github.com/zeroclaw-labs/zeroclaw)'s -`whatsapp-web` feature, which is what this guide configures. If you're running -a different gateway, point Calciforge at it and adjust the auth token accordingly. - -> **Future work:** embedding the WhatsApp protocol directly into Calciforge (so -> no external gateway is required) is tracked in the project backlog. It would -> use `zeroclawlabs::WhatsAppWebChannel` (which wraps `whatsmeow-rs`) as a Rust -> library — distinct from running ZeroClaw as a separate daemon. - -## Architecture +Calciforge embeds the `zeroclawlabs::WhatsAppWebChannel` transport directly. +The WhatsApp Web session now lives inside the Calciforge process, so there is +no ZeroClaw/OpenClaw webhook sidecar for this channel. +```text +WhatsApp user <-> WhatsApp Web session <-> Calciforge <-> agent ``` -WA user ──→ WhatsApp gateway (e.g. ZeroClaw) ──→ POST /webhooks/whatsapp ──→ Calciforge - │ - identity resolution │ - agent dispatch │ - ↓ -WA user ←── WhatsApp gateway (e.g. ZeroClaw) ←── POST /tools/invoke ←── Calciforge reply -``` - -## Prerequisites -- A WhatsApp gateway that implements the wire protocol described above and has - an active WhatsApp Web session. ZeroClaw with `whatsapp-web` enabled is the - reference implementation; any compatible alternative is fine. +## Requirements -## Step 1: Channel config +- A WhatsApp account or linked device that can pair with WhatsApp Web. +- Persistent storage for the session database. +- Identity aliases that match incoming phone numbers in E.164 format. -Add to `~/.calciforge/config.toml`: +## Configure ```toml [[channels]] kind = "whatsapp" enabled = true - -# ZeroClaw 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. +whatsapp_session_path = "~/.calciforge/whatsapp/session.db" allowed_numbers = ["+15555550001"] -``` -| Field | Required | Default | Description | -|---|---|---|---| -| `zeroclaw_endpoint` | yes | — | URL of the ZeroClaw 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 | +# Optional pairing-code login. Use digits only. +# whatsapp_pair_phone = "15555550001" -## Step 2: ZeroClaw forwarding config +# Optional personal-mode controls. +# whatsapp_mode = "personal" +# whatsapp_dm_policy = "allowlist" +# whatsapp_group_policy = "allowlist" +# whatsapp_mention_only = false +# whatsapp_self_chat_mode = false +# whatsapp_group_mention_patterns = ["@Calciforge", "calciforge"] -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"] +# Optional security scan for inbound messages. +# scan_messages = true ``` -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" +display_name = "Operator" +role = "owner" aliases = [ - { channel = "whatsapp", id = "+15555550001" }, + { 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: +## Pair -```bash -ufw allow from to any port 18795 -``` +Start Calciforge with the channel enabled. On first run, the embedded transport +will create or open the configured session database and initiate WhatsApp Web +pairing. Keep `whatsapp_session_path` on durable storage so restarts reuse the +same linked session. -## Step 5: Verify +## Verify ```bash -calciforge doctor # validates config -calciforge # start; send a WhatsApp message from an allowed number +calciforge doctor +calciforge ``` -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" - }] - } - }] - }] - }' -``` +Send `!ping` from an allowed WhatsApp number. Replies go to the transport +`reply_target`, so direct chats and group chats reply to the original +conversation. -A `200 ok` response means the webhook is reachable. The message will be dropped (unknown -identity) unless `15555550001` is in an identity alias. +## Migration -## Webhook payload format +The legacy webhook fields are rejected for `kind = "whatsapp"`: -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" - } -} +```toml +zeroclaw_endpoint = "http://127.0.0.1:18789" +zeroclaw_auth_token = "..." +webhook_listen = "0.0.0.0:18795" +webhook_path = "/webhooks/whatsapp" +webhook_secret = "..." ``` -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. +Move the session into Calciforge with `whatsapp_session_path` and remove the +sidecar webhook settings. diff --git a/docs/codex-openclaw-integration.md b/docs/codex-openclaw-integration.md index 3e92f332..9cf38469 100644 --- a/docs/codex-openclaw-integration.md +++ b/docs/codex-openclaw-integration.md @@ -30,7 +30,6 @@ id = "codex" kind = "codex-cli" model = "gpt-5.5" timeout_ms = 600000 -env = { HTTP_PROXY = "http://127.0.0.1:8888", NO_PROXY = "localhost,127.0.0.1,::1" } aliases = ["gpt", "openai"] [[routing]] @@ -66,13 +65,14 @@ args = [ "--skip-git-repo-check", "-", ] -env = { HTTP_PROXY = "http://127.0.0.1:8888", NO_PROXY = "localhost,127.0.0.1,::1" } ``` -Do not assume ambient `HTTPS_PROXY` gives Calciforge visibility into encrypted -model traffic. HTTPS clients normally use CONNECT tunnels; use explicit -fetch/tool integration when encrypted content needs scanning or secret -substitution. +Do not wrap Codex CLI with generic `HTTP_PROXY`/`HTTPS_PROXY` unless you have +validated that specific route. Codex uses streaming and browser/OAuth-backed +control-plane calls; the current `security-proxy` does not inspect CONNECT +tunnels and can break those flows. Use Calciforge's OpenAI-compatible gateway, +exec models, or explicit fetch/tool integration for traffic that needs +scanning or secret substitution. Keep chat-facing Codex agents conservative. `read-only` is the safer default for general messaging channels. Use `workspace-write` only for diff --git a/docs/index.md b/docs/index.md index 057f59b2..3a3fcd42 100644 --- a/docs/index.md +++ b/docs/index.md @@ -178,8 +178,8 @@ raw API keys or trusting the agent's own restraint.

@@ -205,6 +205,10 @@ tool permissions can be checked before traffic leaves the machine. Ambient `HTTPS_PROXY` is deliberately not presented as full protection: standard HTTPS proxying uses CONNECT tunnels, so encrypted request bodies cannot be inspected or rewritten without a separate MITM design. +For agents that do not work with cooperative proxy env, Calciforge's +security boundary shifts to model-gateway routing, explicit MCP/fetch tools, +audited recipe wrappers, or future container/VM isolation profiles that deny +egress except through Calciforge services. The gateway protects in three places: @@ -247,7 +251,7 @@ included example wraps an OpenAI-compatible classifier with an editable prompt for foreign-language, poetry/style-shift, fictional-framing, and multi-step manipulation cases that are too semantic for local regexes. -See the [security gateway docs](security-gateway.md) for configuration +See the [security gateway docs](security-gateway.html) for configuration details and the [red-team fixtures](https://github.com/bglusman/calciforge/tree/main/examples/red-team) for the contributor-friendly suite used to harden detection over time. @@ -315,7 +319,7 @@ Outbound bodies are also scanned for *exfiltration-attempt* patterns `what is your api key`). Generic high-entropy secret-shape detection (JWT-shaped strings, `sk-*` keys, etc.) was deliberately removed during the channel-integration cut and is on the -[roadmap](https://github.com/bglusman/calciforge/blob/main/docs/roadmap/outbound-sensitive-data-detection.md). +[roadmap](roadmap/outbound-sensitive-data-detection.html). The scanner pipeline is configurable. The default policy now runs through `builtin:calciforge/default-scanner.star`, so the rule set can be copied, @@ -473,10 +477,10 @@ context_window = 262144 ``` The full gateway reference is -[`docs/model-gateway.md`](https://github.com/bglusman/calciforge/blob/main/docs/model-gateway.md). +[`docs/model-gateway.md`](model-gateway.html). Named cascades, dispatchers, and token-window fit checks are captured in -[`docs/rfcs/model-gateway-primitives.md`](https://github.com/bglusman/calciforge/blob/main/docs/rfcs/model-gateway-primitives.md). +[`docs/rfcs/model-gateway-primitives.md`](rfcs/model-gateway-primitives.html). ### Subscription-backed agents and models @@ -495,8 +499,8 @@ terms change; operators should validate the installed CLI version and subscription terms before making an exec model part of their default route. -Read the [agent adapter notes](agent-adapters.md) and -[Codex/OpenClaw integration guide](codex-openclaw-integration.md) for +Read the [agent adapter notes](agent-adapters.html) and +[Codex/OpenClaw integration guide](codex-openclaw-integration.html) for direct `codex-cli`, `openclaw-channel`, `cli`, `acpx`, and exec-model examples. @@ -507,9 +511,9 @@ correctly, for first-class adapter support. The working vocabulary is: - **Recipes** — documented, security-aware command configurations for local tools such as npcsh, opencode profiles, or one-off media agents. - Recipes can still use Calciforge identity checks, per-agent proxy - environment, timeouts, stdin prompt delivery, stderr redaction, audit - logs, and controlled artifact directories. + Recipes can still use Calciforge identity checks, timeouts, stdin prompt + delivery, stderr redaction, audit logs, controlled artifact directories, + and tested proxy wrappers where the upstream runtime supports them. - **Adapters** — first-class protocol integrations used when Calciforge must understand upstream-specific behavior, such as event streams, final-answer parsing, approval pauses, callbacks, or native session @@ -542,7 +546,6 @@ kind = "artifact-cli" command = "/usr/local/bin/npcsh-vixynt-stdin" args = ["{artifact_dir}/image.png"] timeout_ms = 180000 -env = { HTTP_PROXY = "http://127.0.0.1:8888", HTTPS_PROXY = "http://127.0.0.1:8888", NO_PROXY = "localhost,127.0.0.1,::1" } ``` The command above is a recipe shape, not a promise that every npcsh @@ -553,7 +556,7 @@ weaker process-listing tradeoff. The broader plan for async orchestrators, native media delivery, and richer agent outputs is tracked in the -[agent recipes and orchestrators roadmap](roadmap/agent-recipes-orchestrators.md). +[agent recipes and orchestrators roadmap](roadmap/agent-recipes-orchestrators.html). ### Agent-facing tools (MCP and CLI) @@ -567,7 +570,7 @@ Today, discovery is process-scoped: it sees the fnox names available to the MCP server or CLI process. Calciforge enforces per-secret destination allowlists at substitution time, but does not yet enforce per-agent secret discovery/use ACLs. That policy layer is on the -[roadmap](https://github.com/bglusman/calciforge/blob/main/docs/roadmap/agent-secret-access-policy.md). +[roadmap](roadmap/agent-secret-access-policy.html). ```json // ~/.claude/mcp-config.json @@ -588,16 +591,21 @@ calciforge-secrets ref BRAVE_API_KEY ### Multi-channel chat -Today: Telegram, Matrix, WhatsApp, Signal. Optional voice forwarding -on channels that support it. +Today: Telegram, Matrix, WhatsApp, Signal, and text/iMessage. Voice is a separate +proxy passthrough surface today, not a settled per-chat-channel capability; richer +voice input, push-to-talk channels, and audio artifacts remain roadmap work. 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) — embedded `zeroclawlabs::SignalChannel` via `signal-cli-rest-api` -- [WhatsApp](channels/whatsapp) — webhook via ZeroClaw/OpenClaw gateway +- [Telegram](channels/telegram.html) — long-poll, no open port required +- [Matrix](channels/matrix.html) — HTTP long-poll; note: no E2EE +- [Signal](channels/signal.html) — embedded `zeroclawlabs::SignalChannel` via `signal-cli-rest-api` +- [WhatsApp](channels/whatsapp.html) — embedded WhatsApp Web session +- [Text/iMessage](channels/sms.html) — Linq webhook receiver for iMessage/RCS/SMS + +Agent backends, identities, and routing rules are documented in the +[Agents, Identities, and Routing](agents.html) guide. ```toml # /etc/calciforge/config.toml — channel configuration @@ -618,8 +626,16 @@ allowed_users = ["@alice:example.com"] [[channels]] kind = "whatsapp" enabled = true -zeroclaw_endpoint = "http://127.0.0.1:18789" -zeroclaw_auth_token = "{% raw %}{{secret:OPENCLAW_HOOK_TOKEN}}{% endraw %}" +whatsapp_session_path = "/var/lib/calciforge/whatsapp/session.db" +allowed_numbers = ["+15555550100"] + +[[channels]] +kind = "sms" +enabled = true +sms_linq_api_token_file = "/etc/calciforge/secrets/linq-token" +sms_from_phone = "+15555550001" +sms_webhook_listen = "0.0.0.0:18798" +sms_webhook_path = "/webhooks/sms" allowed_numbers = ["+15555550100"] ``` @@ -669,13 +685,19 @@ unverified, validates configured scanner policy files and rule syntax, and can probe configured endpoints. Use `calciforge doctor --no-network` when you want a local-only check. -Route Claude Code through the gateway. The installer and examples bias -toward setting this on managed subprocess agents directly; for external -daemons, set it on the agent process or its service manager, not on the -Calciforge daemon: +Do not put proxy variables in `~/.zshrc` for the Calciforge daemon itself; +that can route Calciforge's own provider and control-plane traffic through +its security proxy. Do not assume CLI agents can be protected by generic +`HTTP_PROXY` or `HTTPS_PROXY`; Codex, Claude, ACPX, npm-backed adapters, and +streaming clients may use CONNECT, WebSockets, or browser-backed auth flows +that the current proxy cannot inspect and may break. + +For externally managed agent daemons that Calciforge does not launch, configure +a tested proxy path on the agent process or its service manager and validate it +against `security-proxy` logs: ```bash -# ~/.zshrc +# External agent process environment export HTTP_PROXY=http://127.0.0.1:8888 export NO_PROXY=localhost,127.0.0.1,::1 ``` @@ -690,10 +712,9 @@ macOS and a headless Linux service host). Treat new deployments as operator-reviewed until their channel credentials, fnox store, model gateway providers, and synthetic model routes pass smoke tests. -The list of what works today and what's still in flight lives in the -[README's status table](https://github.com/bglusman/calciforge/blob/main/README.md#what-works-today). -Public roadmap ideas live in -[`docs/roadmap/`](https://github.com/bglusman/calciforge/tree/main/docs/roadmap). +The status summary above is the site-facing snapshot of what works today and +what is still in flight. Public roadmap ideas live in +the [roadmap notes](roadmap/v3-ideas.html).