Compare commits

...
22 Commits
Author SHA1 Message Date
jaberjaber23 acf2587e46 bump v0.6.9 2026-05-12 21:42:08 +03:00
jaberjaber23 4583157b49 audit fixes 2026-05-12 21:13:41 +03:00
jaberjaber23 7185ea8808 cargo fmt 2026-05-12 20:57:57 +03:00
jaberjaber23 4aa1508f54 clippy lint 2026-05-12 20:43:56 +03:00
jaberjaber23 5447bf7f1d toctou staging 2026-05-12 17:11:36 +03:00
jaberjaber23 b77ebfb897 thread routing 2026-05-12 17:09:09 +03:00
jaberjaber23 2323cd5e67 audit hands 2026-05-12 17:06:43 +03:00
jaberjaber23 df29e5e8b9 audit endpoint 2026-05-12 17:05:42 +03:00
jaberjaber23 8b411c21c4 codex hardening 2026-05-12 16:38:50 +03:00
jaberjaber23 b00af5eddd add requesty 2026-05-12 16:35:22 +03:00
jaberjaber23 36177425c4 skill tools 2026-05-12 16:34:49 +03:00
jaberjaber23 6bed6c04ff workspace split 2026-05-12 16:33:49 +03:00
jaberjaber23 4c496be02f integration fixes 2026-05-12 16:13:08 +03:00
jaberjaber23 7f7b071528 provider refs 2026-05-12 15:53:15 +03:00
jaberjaber23 2d1fb8171c matrix refresh 2026-05-12 15:50:44 +03:00
Jaber Jaber e683acc565 Merge pull request #1045 from dongtran16092006/fix-mcp-system-prompt
fix: system prompt and identity handling, and config form hydration
2026-05-12 15:49:20 +03:00
jaberjaber23 5396889ff1 bedrock redacted 2026-05-12 15:48:54 +03:00
jaberjaber23 c9e8d31571 ws auth align 2026-05-12 15:47:38 +03:00
Jaber Jaber d8ad91572e Merge pull request #1054 from Hypn0sis/feat/discord-smart-thread
feat(discord): smart auto-thread mode (true/false/smart)
2026-05-12 15:47:05 +03:00
dongtran16092006 93b57bdd52 fix: system prompt and identity handling, and config form hydration #1045 2026-04-20 18:10:16 +07:00
Matteo De Agazio 0227ff1790 fix(discord): cap dedup set on thread delete, add #[cfg(test)] to test mod
- Replace retain(|_| true) no-op with a size-capped clear: when
  threaded_message_ids exceeds MAX_DEDUP_MSG_IDS (2000) it is cleared.
  MESSAGE_UPDATE embed events arrive within seconds so old entries are
  always safe to discard; prevents unbounded growth on busy servers.
- Add #[cfg(test)] to mod tests so empty_threads() helper is only
  compiled in test mode — removes the need for #[allow(dead_code)].
2026-04-20 09:31:09 +02:00
Matteo De Agazio 525d7d844a feat(discord): smart auto-thread mode (true/false/smart) 2026-04-20 09:31:09 +02:00
40 changed files with 3685 additions and 543 deletions
Generated
+93 -90
View File
@@ -139,7 +139,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -150,7 +150,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -893,7 +893,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -1029,27 +1029,27 @@ dependencies = [
[[package]]
name = "cranelift-assembler-x64"
version = "0.130.1"
version = "0.130.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046d4b584c3bb9b5eb500c8f29549bec36be11000f1ba2a927cef3d1a9875691"
checksum = "adc822414b18d1f5b1b33ce1441534e311e62fef86ebb5b9d382af857d0272c9"
dependencies = [
"cranelift-assembler-x64-meta",
]
[[package]]
name = "cranelift-assembler-x64-meta"
version = "0.130.1"
version = "0.130.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9b194a7870becb1490366fc0ae392ccd188065ff35f8391e77ac659db6fb977"
checksum = "8c646808b06f4532478d8d6057d74f15c3322f10d995d9486e7dcea405bf521a"
dependencies = [
"cranelift-srcgen",
]
[[package]]
name = "cranelift-bforest"
version = "0.130.1"
version = "0.130.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb6a4ab44c6b371e661846b97dab687387a60ac4e2f864e2d4257284aad9e889"
checksum = "7b5996f01a686b2349cdb379083ec5ad3e8cb8767fb2d495d3a4f2ee4163a18d"
dependencies = [
"cranelift-entity",
"wasmtime-internal-core",
@@ -1057,9 +1057,9 @@ dependencies = [
[[package]]
name = "cranelift-bitset"
version = "0.130.1"
version = "0.130.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8b7a44150c2f471a94023482bda1902710746e4bed9f9973d60c5a94319b06d"
checksum = "523fea83273f6a985520f57788809a4de2165794d9ab00fb1254fceb4f5aa00c"
dependencies = [
"serde",
"serde_derive",
@@ -1068,9 +1068,9 @@ dependencies = [
[[package]]
name = "cranelift-codegen"
version = "0.130.1"
version = "0.130.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01b06598133b1dd76758b8b95f8d6747c124124aade50cea96a3d88b962da9fa"
checksum = "d73d1e372730b5f64ed1a2bd9f01fe4686c8ec14a28034e3084e530c8d951878"
dependencies = [
"bumpalo",
"cranelift-assembler-x64",
@@ -1096,9 +1096,9 @@ dependencies = [
[[package]]
name = "cranelift-codegen-meta"
version = "0.130.1"
version = "0.130.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6190e2e7bcf0a678da2f715363d34ed530fedf7a2f0ab75edaefef72a70465ff"
checksum = "b0319c18165e93dc1ebf78946a8da0b1c341c95b4a39729a69574671639bdb5f"
dependencies = [
"cranelift-assembler-x64-meta",
"cranelift-codegen-shared",
@@ -1109,24 +1109,24 @@ dependencies = [
[[package]]
name = "cranelift-codegen-shared"
version = "0.130.1"
version = "0.130.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f583cf203d1aa8b79560e3b01f929bdacf9070b015eec4ea9c46e22a3f83e4a0"
checksum = "9195cd8aeecb55e401aa96b2eaa55921636e8246c127ed7908f7ef7e0d40f270"
[[package]]
name = "cranelift-control"
version = "0.130.1"
version = "0.130.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "803159df35cc398ae54473c150b16d6c77e92ab2948be638488de126a3328fbc"
checksum = "8976c2154b74136322befc74222ab5c7249edd7e2604f8cbef2b94975541ffb9"
dependencies = [
"arbitrary",
]
[[package]]
name = "cranelift-entity"
version = "0.130.1"
version = "0.130.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3109e417257082d88087f5bcce677525bdaa8322b88dd7f175ed1a1fd41d546c"
checksum = "6038b3147c7982f4951150d5f96c7c06c1e7214b99d4b4a98607aadf8ded89d1"
dependencies = [
"cranelift-bitset",
"serde",
@@ -1136,9 +1136,9 @@ dependencies = [
[[package]]
name = "cranelift-frontend"
version = "0.130.1"
version = "0.130.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14db6b0e0e4994c581092df78d837be2072578f7cb2528f96a6cf895e56dee63"
checksum = "4cbd294abe236e23cc3d907b0936226b6a8342db7636daa9c7c72be1e323420e"
dependencies = [
"cranelift-codegen",
"log",
@@ -1148,15 +1148,15 @@ dependencies = [
[[package]]
name = "cranelift-isle"
version = "0.130.1"
version = "0.130.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec66ea5025c7317383699778282ac98741d68444f956e3b1d7b62f12b7216e67"
checksum = "b5a90b6ed3aba84189352a87badeb93b2126d3724225a42dc67fdce53d1b139c"
[[package]]
name = "cranelift-native"
version = "0.130.1"
version = "0.130.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "373ade56438e6232619d85678477d0a88a31b3581936e0503e61e96b546b0800"
checksum = "c3ec0cc1a54e22925eacf4fc3dc815f907734d3b377899d19d52bec04863e853"
dependencies = [
"cranelift-codegen",
"libc",
@@ -1165,9 +1165,9 @@ dependencies = [
[[package]]
name = "cranelift-srcgen"
version = "0.130.1"
version = "0.130.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef53619d3cd5c78fd998c6d9420547af26b72e6456f94c2a8a2334cb76b42baa"
checksum = "948865622f87f30907bb46fbb081b235ae63c1896a99a83c26a003305c1fa82d"
[[package]]
name = "crc32fast"
@@ -1555,7 +1555,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -1805,7 +1805,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -3739,7 +3739,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -3959,7 +3959,7 @@ dependencies = [
[[package]]
name = "openfang-api"
version = "0.6.7"
version = "0.6.9"
dependencies = [
"argon2",
"async-trait",
@@ -4001,7 +4001,7 @@ dependencies = [
[[package]]
name = "openfang-channels"
version = "0.6.7"
version = "0.6.9"
dependencies = [
"aes",
"async-trait",
@@ -4040,7 +4040,7 @@ dependencies = [
[[package]]
name = "openfang-cli"
version = "0.6.7"
version = "0.6.9"
dependencies = [
"clap",
"clap_complete",
@@ -4068,7 +4068,7 @@ dependencies = [
[[package]]
name = "openfang-desktop"
version = "0.6.7"
version = "0.6.9"
dependencies = [
"axum",
"open",
@@ -4094,7 +4094,7 @@ dependencies = [
[[package]]
name = "openfang-extensions"
version = "0.6.7"
version = "0.6.9"
dependencies = [
"aes-gcm",
"argon2",
@@ -4122,14 +4122,16 @@ dependencies = [
[[package]]
name = "openfang-hands"
version = "0.6.7"
version = "0.6.9"
dependencies = [
"chrono",
"dashmap",
"dirs 6.0.0",
"hex",
"openfang-types",
"serde",
"serde_json",
"sha2",
"tempfile",
"thiserror 2.0.18",
"tokio-test",
@@ -4140,7 +4142,7 @@ dependencies = [
[[package]]
name = "openfang-kernel"
version = "0.6.7"
version = "0.6.9"
dependencies = [
"async-trait",
"chrono",
@@ -4165,6 +4167,7 @@ dependencies = [
"rustls",
"serde",
"serde_json",
"sha2",
"subtle",
"tempfile",
"thiserror 2.0.18",
@@ -4179,7 +4182,7 @@ dependencies = [
[[package]]
name = "openfang-memory"
version = "0.6.7"
version = "0.6.9"
dependencies = [
"async-trait",
"chrono",
@@ -4199,7 +4202,7 @@ dependencies = [
[[package]]
name = "openfang-migrate"
version = "0.6.7"
version = "0.6.9"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -4218,7 +4221,7 @@ dependencies = [
[[package]]
name = "openfang-runtime"
version = "0.6.7"
version = "0.6.9"
dependencies = [
"anyhow",
"async-trait",
@@ -4254,7 +4257,7 @@ dependencies = [
[[package]]
name = "openfang-skills"
version = "0.6.7"
version = "0.6.9"
dependencies = [
"chrono",
"ed25519-dalek",
@@ -4279,7 +4282,7 @@ dependencies = [
[[package]]
name = "openfang-types"
version = "0.6.7"
version = "0.6.9"
dependencies = [
"async-trait",
"bitflags 2.11.0",
@@ -4299,7 +4302,7 @@ dependencies = [
[[package]]
name = "openfang-wire"
version = "0.6.7"
version = "0.6.9"
dependencies = [
"async-trait",
"chrono",
@@ -4396,7 +4399,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.45.0",
]
[[package]]
@@ -5021,7 +5024,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b"
dependencies = [
"anyhow",
"itertools 0.14.0",
"itertools 0.13.0",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -5029,9 +5032,9 @@ dependencies = [
[[package]]
name = "pulley-interpreter"
version = "43.0.1"
version = "43.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "010dec3755eb61b2f1051ecb3611b718460b7a74c131e474de2af20a845938af"
checksum = "7ec12fe19a9588315a49fe5704502a9c02d6a198303314b0c7c86123b06d29e5"
dependencies = [
"cranelift-bitset",
"log",
@@ -5041,9 +5044,9 @@ dependencies = [
[[package]]
name = "pulley-macros"
version = "43.0.1"
version = "43.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad360c32e85ca4b083ac0e2b6856e8f11c3d5060dafa7d5dc57b370857fa3018"
checksum = "36f7d5ef31ebf1b46cd7e722ffef934e670d7e462f49aa01cde07b9b76dca580"
dependencies = [
"proc-macro2",
"quote",
@@ -5706,7 +5709,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -5765,7 +5768,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -5776,9 +5779,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.10"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"aws-lc-rs",
"ring",
@@ -6311,7 +6314,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -7047,7 +7050,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -7619,7 +7622,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -8063,9 +8066,9 @@ dependencies = [
[[package]]
name = "wasmtime"
version = "43.0.1"
version = "43.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce205cd643d661b5ba5ba4717e13730262e8cdbc8f2eacbc7b906d45c1a74026"
checksum = "efb1ed5899dde98357cfdcf647a4614498798719793898245b4b34e663addabf"
dependencies = [
"addr2line",
"async-trait",
@@ -8116,9 +8119,9 @@ dependencies = [
[[package]]
name = "wasmtime-environ"
version = "43.0.1"
version = "43.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b8b78abf3677d4a0a5db82e5015b4d085ff3a1b8b472cbb8c70d4b769f019ce"
checksum = "4172382dcc785c31d0e862c6780a18f5dd437914d22c4691351f965ef751c821"
dependencies = [
"anyhow",
"cpp_demangle",
@@ -8147,9 +8150,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-cache"
version = "43.0.1"
version = "43.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e4fd4103ba413c0da2e636f73490c6c8e446d708cbde7573703941bc3d6a448"
checksum = "4ed398988226d7aa0505ac6bb576e09532ad722d702ec4e66365d78ed695c95f"
dependencies = [
"base64 0.22.1",
"directories-next",
@@ -8167,9 +8170,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-component-macro"
version = "43.0.1"
version = "43.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d3d6914f34be2f9d78d8ee9f422e834dfc204e71ccce697205fae95fed87892"
checksum = "ae5ec9fff073ff13b81732d56a9515d761c245750bcda09093827f84130ebc25"
dependencies = [
"anyhow",
"proc-macro2",
@@ -8182,15 +8185,15 @@ dependencies = [
[[package]]
name = "wasmtime-internal-component-util"
version = "43.0.1"
version = "43.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3751b0616b914fdd87fe1bf804694a078f321b000338e6476bc48a4d6e454f21"
checksum = "935d9ab293ba27d1ec9aa7bc1b3a43993dbe961af2a8f23f90a11e1331b4c13f"
[[package]]
name = "wasmtime-internal-core"
version = "43.0.1"
version = "43.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22632b187e1b0716f1b9ac57ad29013bed33175fcb19e10bb6896126f82fac67"
checksum = "9a3820b174f477d2a7083209d1ad5353fcdb11eaea434b2137b8681029460dd3"
dependencies = [
"anyhow",
"hashbrown 0.16.1",
@@ -8200,9 +8203,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-cranelift"
version = "43.0.1"
version = "43.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b3ca07b3e0bb3429674b173b5800577719d600774dd81bff58f775c0aaa64ee"
checksum = "d1679d205caf9766c6aa309d45bb3e7c634d7725e3164404df33824b9f7c4fb7"
dependencies = [
"cfg-if",
"cranelift-codegen",
@@ -8227,9 +8230,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-fiber"
version = "43.0.1"
version = "43.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20c8b2c9704eb1f33ead025ec16038277ccb63d0a14c31e99d5b765d7c36da55"
checksum = "f1e505254058be5b0df458d670ee42d9eafe2349d04c1296e9dc01071dc20a85"
dependencies = [
"cc",
"cfg-if",
@@ -8242,9 +8245,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-jit-debug"
version = "43.0.1"
version = "43.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d950310d07391d34369f62c48336ebb14eacbd4d6f772bb5f349c24e838e0664"
checksum = "1c2e05b345f1773e59c20e6ad7298fd6857cdea245023d88bb659c96d8f0ea72"
dependencies = [
"cc",
"object",
@@ -8254,9 +8257,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-jit-icache-coherence"
version = "43.0.1"
version = "43.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3606662c156962d096be3127b8b8ae8ee2f8be3f896dad29259ff01ddb64abfd"
checksum = "b86701b234a4643e3f111869aa792b3a05a06e02d486ee9cb6c04dae16b52dab"
dependencies = [
"cfg-if",
"libc",
@@ -8266,9 +8269,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-unwinder"
version = "43.0.1"
version = "43.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75eef0747e52dc545b075f64fd0e0cc237ae738e641266b1970e07e2d744bc32"
checksum = "f63558d801beb83dde9b336eb4ae049019aee26627926edb32cd119d7e4c83cd"
dependencies = [
"cfg-if",
"cranelift-codegen",
@@ -8279,9 +8282,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-versioned-export-macros"
version = "43.0.1"
version = "43.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b0a5dab02a8fb527f547855ecc0e05f9fdc3d5bd57b8b080349408f9a6cece"
checksum = "737c4d956fc3a848541a064afb683dd2771132a6b125be5baaf95c4379aa47df"
dependencies = [
"proc-macro2",
"quote",
@@ -8290,9 +8293,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-winch"
version = "43.0.1"
version = "43.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8007342bd12ff400293a817973f7ecd6f1d9a8549a53369a9c1af357166f1f1e"
checksum = "f599b79545e3bba0b7913406055ebede5bb0dabee9ba2015ef25a9f4c9f47807"
dependencies = [
"cranelift-codegen",
"gimli",
@@ -8307,9 +8310,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-wit-bindgen"
version = "43.0.1"
version = "43.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7900c3e3c1d6e475bc225d73b02d6d5484815f260022e6964dca9558e50dd01a"
checksum = "2192a77a00b9a67800c2b4e1c70fb6abca79d6b529e53a2ef9dcdcc36090330d"
dependencies = [
"anyhow",
"bitflags 2.11.0",
@@ -8492,7 +8495,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -8503,9 +8506,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "winch-codegen"
version = "43.0.1"
version = "43.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb9f45f7172a2628c8317766e427babc0a400f9d10b1c0f0b0617c5ed5b79de6"
checksum = "52dbb0cf07b0dfe7b7a1ca8efb8f94ba98bd0fb144c411ea1665c78f0449e958"
dependencies = [
"cranelift-assembler-x64",
"cranelift-codegen",
@@ -9233,7 +9236,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "xtask"
version = "0.6.7"
version = "0.6.9"
[[package]]
name = "yoke"
+1 -1
View File
@@ -18,7 +18,7 @@ members = [
]
[workspace.package]
version = "0.6.7"
version = "0.6.9"
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://github.com/RightNow-AI/openfang"
+2 -2
View File
@@ -19,8 +19,8 @@
<p align="center">
<img src="https://img.shields.io/badge/language-Rust-orange?style=flat-square" alt="Rust" />
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT" />
<img src="https://img.shields.io/badge/version-0.6.7-green?style=flat-square" alt="v0.6.7" />
<img src="https://img.shields.io/badge/tests-2,657%2B%20passing-brightgreen?style=flat-square" alt="Tests" />
<img src="https://img.shields.io/badge/version-0.6.9-green?style=flat-square" alt="v0.6.9" />
<img src="https://img.shields.io/badge/tests-2,696%2B%20passing-brightgreen?style=flat-square" alt="Tests" />
<img src="https://img.shields.io/badge/clippy-0%20warnings-brightgreen?style=flat-square" alt="Clippy" />
<a href="https://www.buymeacoffee.com/openfang" target="_blank"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-FFDD00?style=flat-square&logo=buy-me-a-coffee&logoColor=black" alt="Buy Me A Coffee" /></a>
</p>
+11 -2
View File
@@ -1166,11 +1166,12 @@ pub async fn start_channel_bridge_with_config(
if let Some(ref tg_config) = config.telegram {
if let Some(token) = read_token(&tg_config.bot_token_env, "Telegram") {
let poll_interval = Duration::from_secs(tg_config.poll_interval_secs);
let adapter = Arc::new(TelegramAdapter::new(
let adapter = Arc::new(TelegramAdapter::with_thread_routes(
token,
tg_config.allowed_users.clone(),
poll_interval,
tg_config.api_url.clone(),
tg_config.thread_routes.clone(),
));
adapters.push((adapter, tg_config.default_agent.clone()));
}
@@ -1185,6 +1186,7 @@ pub async fn start_channel_bridge_with_config(
dc_config.allowed_users.clone(),
dc_config.ignore_bots,
dc_config.intents,
dc_config.auto_thread.clone(),
));
adapters.push((adapter, dc_config.default_agent.clone()));
}
@@ -1249,10 +1251,17 @@ pub async fn start_channel_bridge_with_config(
// Matrix
if let Some(ref mx_config) = config.matrix {
if let Some(token) = read_token(&mx_config.access_token_env, "Matrix") {
let adapter = Arc::new(MatrixAdapter::new(
// MSC2918 refresh-token support: optional env var, when present the
// adapter auto-recovers from M_UNKNOWN_TOKEN 401s.
let refresh = mx_config
.refresh_token_env
.as_deref()
.and_then(|env| read_token(env, "Matrix refresh"));
let adapter = Arc::new(MatrixAdapter::with_refresh_token(
mx_config.homeserver_url.clone(),
mx_config.user_id.clone(),
token,
refresh,
mx_config.allowed_rooms.clone(),
mx_config.auto_accept_invites,
));
+184 -24
View File
@@ -1509,11 +1509,15 @@ pub async fn get_agent(
"network": entry.manifest.capabilities.network,
},
"description": entry.manifest.description,
"system_prompt": entry.manifest.model.system_prompt,
"tags": entry.manifest.tags,
"identity": {
"emoji": entry.identity.emoji,
"avatar_url": entry.identity.avatar_url,
"color": entry.identity.color,
"archetype": entry.identity.archetype,
"vibe": entry.identity.vibe,
"greeting_style": entry.identity.greeting_style,
},
"skills": entry.manifest.skills,
"skills_mode": if entry.manifest.skills.is_empty() { "all" } else { "allowlist" },
@@ -3699,7 +3703,14 @@ pub async fn install_skill(
let config = openfang_skills::marketplace::MarketplaceConfig::default();
let client = openfang_skills::marketplace::MarketplaceClient::new(config);
match client.install(&req.name, &skills_dir).await {
let opts = openfang_skills::installer::InstallOptions {
require_signed: req.require_signed,
allowed_signer_keys: req.allowed_signer_keys.clone(),
};
match client
.install_with_options(&req.name, &skills_dir, &opts)
.await
{
Ok(version) => {
// Hot-reload so agents see the new skill immediately
state.kernel.reload_skills();
@@ -3756,6 +3767,112 @@ pub async fn reload_skills(State(state): State<Arc<AppState>>) -> impl IntoRespo
Json(serde_json::json!({"status": "reloaded"}))
}
/// POST /api/audit/append — Append an entry to the Merkle hash chain audit
/// trail on behalf of an external (instance-side) wrapper (issue #1174).
///
/// RBAC: gated by the same bearer-token middleware as POST /api/skills/install
/// (see `middleware::auth_middleware`). When `api_key` is configured every
/// caller must present `Authorization: Bearer <key>` — wrappers running in the
/// same trust boundary as the daemon are expected to share that key.
pub async fn audit_append(
State(state): State<Arc<AppState>>,
Json(req): Json<AuditAppendRequest>,
) -> impl IntoResponse {
use openfang_runtime::audit::AuditAction;
// SECURITY: bound input sizes so a wrapper cannot wedge the chain with
// unbounded strings. The audit table stores TEXT columns and the chain
// hash is computed over the same bytes — keep it sane.
const MAX_FIELD: usize = 16 * 1024;
if req.event_type.len() > MAX_FIELD
|| req.agent_id.len() > MAX_FIELD
|| req.detail.len() > MAX_FIELD
|| req
.outcome
.as_ref()
.map(|s| s.len() > MAX_FIELD)
.unwrap_or(false)
|| req
.signing_context
.as_ref()
.map(|s| s.len() > MAX_FIELD)
.unwrap_or(false)
{
return (
StatusCode::PAYLOAD_TOO_LARGE,
Json(serde_json::json!({"error": "field exceeds 16KB limit"})),
);
}
// Map operator-supplied event_type → AuditAction (case-insensitive).
let action = match req.event_type.trim().to_ascii_lowercase().as_str() {
"toolinvoke" | "tool_invoke" | "tool" => AuditAction::ToolInvoke,
"capabilitycheck" | "capability_check" | "capability" => AuditAction::CapabilityCheck,
"agentspawn" | "agent_spawn" | "spawn" => AuditAction::AgentSpawn,
"agentkill" | "agent_kill" | "kill" => AuditAction::AgentKill,
"agentmessage" | "agent_message" | "message" => AuditAction::AgentMessage,
"memoryaccess" | "memory_access" | "memory" => AuditAction::MemoryAccess,
"fileaccess" | "file_access" | "file" => AuditAction::FileAccess,
"networkaccess" | "network_access" | "network" => AuditAction::NetworkAccess,
"shellexec" | "shell_exec" | "shell" => AuditAction::ShellExec,
"authattempt" | "auth_attempt" | "auth" => AuditAction::AuthAttempt,
"wireconnect" | "wire_connect" | "wire" => AuditAction::WireConnect,
"configchange" | "config_change" | "config" => AuditAction::ConfigChange,
other => {
tracing::warn!(
"audit_append: unknown event_type {other:?}, falling back to ToolInvoke"
);
AuditAction::ToolInvoke
}
};
// Compose a detail string that preserves the operator's free-form detail
// plus optional signing context and structured payload, so wrappers can
// attach context without changing the on-chain schema.
let mut detail = req.detail.clone();
if let Some(ctx) = req.signing_context.as_ref().filter(|s| !s.is_empty()) {
if !detail.is_empty() {
detail.push_str(" | ");
}
detail.push_str("signer=");
detail.push_str(ctx);
}
if let Some(payload) = req.payload.as_ref() {
let serialised = serde_json::to_string(payload)
.unwrap_or_else(|_| String::from("<unserialisable payload>"));
// Cap payload contribution so a huge JSON blob cannot blow the entry.
let truncated: String = serialised.chars().take(8 * 1024).collect();
if !detail.is_empty() {
detail.push_str(" | ");
}
detail.push_str("payload=");
detail.push_str(&truncated);
}
let agent_id = if req.agent_id.trim().is_empty() {
"external-wrapper".to_string()
} else {
req.agent_id.clone()
};
let outcome = req.outcome.clone().unwrap_or_else(|| "ok".to_string());
let hash = state
.kernel
.audit_log
.record(agent_id, action, detail, outcome);
let seq = state.kernel.audit_log.len().saturating_sub(1) as u64;
(
StatusCode::OK,
Json(serde_json::json!({
"status": "appended",
"seq": seq,
"hash": hash,
"tip": state.kernel.audit_log.tip_hash(),
})),
)
}
/// GET /api/marketplace/search — Search the FangHub marketplace.
pub async fn marketplace_search(
Query(params): Query<HashMap<String, String>>,
@@ -9949,7 +10066,9 @@ pub async fn clone_agent(
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Failed to serialize manifest: {e}")})),
Json(
serde_json::json!({"error": format!("Failed to serialize manifest: {e}")}),
),
);
}
};
@@ -9969,8 +10088,10 @@ pub async fn clone_agent(
// new_name always wins over any name field in overrides.
cloned_manifest.name = new_name.clone();
// Let the kernel assign a fresh workspace path — never share with the template.
// Let the kernel assign fresh workspace and state directory paths.
// Never share private state with the template (see issue #868, #1097).
cloned_manifest.workspace = None;
cloned_manifest.state_dir = None;
// Spawn the cloned agent.
let new_id = match state.kernel.spawn_agent(cloned_manifest) {
@@ -9983,25 +10104,42 @@ pub async fn clone_agent(
}
};
// Copy non-memory workspace files from source to destination.
// MEMORY.md and HEARTBEAT.md are intentionally skipped — the cloned agent
// must start with independent memory (issue #868).
// Copy non-memory identity files from source state_dir to destination
// state_dir. MEMORY.md and HEARTBEAT.md are intentionally skipped — the
// cloned agent must start with independent memory (issue #868). Identity
// files live in state_dir per #1097; fall back to legacy workspace for
// older agents.
let new_entry = state.kernel.registry.get(new_id);
let src_state = source
.manifest
.state_dir
.as_ref()
.or(source.manifest.workspace.as_ref());
let dst_state = new_entry.as_ref().and_then(|e| {
e.manifest
.state_dir
.as_ref()
.or(e.manifest.workspace.as_ref())
});
if let (Some(src_ws), Some(dst_ws)) = (src_state, dst_state) {
if let (Ok(src_can), Ok(dst_can)) = (src_ws.canonicalize(), dst_ws.canonicalize()) {
for &fname in KNOWN_IDENTITY_FILES {
if MEMORY_FILES.contains(&fname) {
continue;
}
let src_file = src_can.join(fname);
let dst_file = dst_can.join(fname);
if src_file.exists() {
let _ = std::fs::copy(&src_file, &dst_file);
}
}
}
}
// Copy the user-facing `skills/` subdirectory so curated skills travel
// with the template. These live in the workspace, not the state dir.
if let (Some(ref src_ws), Some(ref new_entry)) = (&source.manifest.workspace, &new_entry) {
if let Some(ref dst_ws) = new_entry.manifest.workspace {
if let (Ok(src_can), Ok(dst_can)) = (src_ws.canonicalize(), dst_ws.canonicalize()) {
for &fname in KNOWN_IDENTITY_FILES {
if MEMORY_FILES.contains(&fname) {
continue;
}
let src_file = src_can.join(fname);
let dst_file = dst_can.join(fname);
if src_file.exists() {
let _ = std::fs::copy(&src_file, &dst_file);
}
}
// Copy the `skills/` subdirectory if present so curated skills
// travel with the template.
let src_skills = src_can.join("skills");
let dst_skills = dst_can.join("skills");
if src_skills.is_dir() {
@@ -10104,8 +10242,16 @@ pub async fn list_agent_files(
}
};
let workspace = match entry.manifest.workspace {
Some(ref ws) => ws.clone(),
// Identity files live in the agent's private state directory (see #1097).
// Fall back to the legacy workspace location for agents created before the
// split so existing on-disk files remain reachable.
let workspace = match entry
.manifest
.state_dir
.as_ref()
.or(entry.manifest.workspace.as_ref())
{
Some(ws) => ws.clone(),
None => {
return (
StatusCode::NOT_FOUND,
@@ -10166,8 +10312,15 @@ pub async fn get_agent_file(
}
};
let workspace = match entry.manifest.workspace {
Some(ref ws) => ws.clone(),
// Identity files live in the agent's private state directory (see #1097).
// Fall back to legacy workspace for agents created before the split.
let workspace = match entry
.manifest
.state_dir
.as_ref()
.or(entry.manifest.workspace.as_ref())
{
Some(ws) => ws.clone(),
None => {
return (
StatusCode::NOT_FOUND,
@@ -10273,8 +10426,15 @@ pub async fn set_agent_file(
}
};
let workspace = match entry.manifest.workspace {
Some(ref ws) => ws.clone(),
// Identity files live in the agent's private state directory (see #1097).
// Fall back to legacy workspace for agents created before the split.
let workspace = match entry
.manifest
.state_dir
.as_ref()
.or(entry.manifest.workspace.as_ref())
{
Some(ws) => ws.clone(),
None => {
return (
StatusCode::NOT_FOUND,
+5
View File
@@ -394,6 +394,11 @@ pub async fn build_router(
"/api/skills/reload",
axum::routing::post(routes::reload_skills),
)
// Audit trail (issue #1174 — instance-side wrapper integration)
.route(
"/api/audit/append",
axum::routing::post(routes::audit_append),
)
.route(
"/api/skills/{id}/config",
axum::routing::get(routes::get_skill_config).put(routes::put_skill_config),
+102
View File
@@ -65,6 +65,15 @@ pub struct MessageResponse {
#[derive(Debug, Deserialize)]
pub struct SkillInstallRequest {
pub name: String,
/// When true, reject the install unless the bundle ships a valid
/// Ed25519 SignedManifest envelope bound to the on-disk manifest.
/// Maps to `InstallOptions::require_signed` (issue #1170).
#[serde(default)]
pub require_signed: bool,
/// Optional hex-encoded allow-list of acceptable signer public keys.
/// Empty = TOFU (any valid signature accepted).
#[serde(default)]
pub allowed_signer_keys: Vec<String>,
}
/// Request to uninstall a skill.
@@ -115,3 +124,96 @@ pub struct CommandsQuery {
#[serde(default)]
pub surface: Option<String>,
}
/// Request body for `POST /api/audit/append` (issue #1174).
///
/// Lets external (instance-side) wrappers append entries to the Merkle hash
/// chain audit log. The handler maps `event_type` to an `AuditAction` and
/// records the entry through `kernel.audit_log`.
#[derive(Debug, Deserialize)]
pub struct AuditAppendRequest {
/// Operator-supplied event category. Case-insensitive, matched against the
/// `AuditAction` enum variants (e.g. `tool_invoke`, `ConfigChange`,
/// `agent_message`). Unknown values fall back to `ToolInvoke`.
pub event_type: String,
/// Agent or wrapper identifier responsible for the event. When empty,
/// recorded as `"external-wrapper"`.
#[serde(default)]
pub agent_id: String,
/// Free-form detail string (e.g. tool name, URL, file path).
#[serde(default)]
pub detail: String,
/// Optional arbitrary payload. When present it is serialised to JSON and
/// appended onto the entry's detail so the wrapper retains structured
/// context without changing the on-chain schema.
#[serde(default)]
pub payload: Option<serde_json::Value>,
/// Optional outcome string (`"ok"`, `"denied"`, or an error). Defaults to
/// `"ok"` when omitted.
#[serde(default)]
pub outcome: Option<String>,
/// Optional operator-supplied signing context (e.g. wrapper identity, key
/// fingerprint). Mixed into the detail when present so the chain captures
/// who attested to the event.
#[serde(default)]
pub signing_context: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn skill_install_request_defaults_back_compat() {
// Existing callers send `{"name": "..."}` only. New optional fields
// must default cleanly (issue #1170).
let req: SkillInstallRequest = serde_json::from_str(r#"{"name":"github-helper"}"#).unwrap();
assert_eq!(req.name, "github-helper");
assert!(!req.require_signed);
assert!(req.allowed_signer_keys.is_empty());
}
#[test]
fn skill_install_request_parses_require_signed() {
let req: SkillInstallRequest = serde_json::from_str(
r#"{"name":"x","require_signed":true,"allowed_signer_keys":["abc123"]}"#,
)
.unwrap();
assert!(req.require_signed);
assert_eq!(req.allowed_signer_keys, vec!["abc123".to_string()]);
}
#[test]
fn audit_append_request_required_only() {
// Only `event_type` is required; everything else must default.
let req: AuditAppendRequest =
serde_json::from_str(r#"{"event_type":"ToolInvoke"}"#).unwrap();
assert_eq!(req.event_type, "ToolInvoke");
assert!(req.agent_id.is_empty());
assert!(req.detail.is_empty());
assert!(req.payload.is_none());
assert!(req.outcome.is_none());
assert!(req.signing_context.is_none());
}
#[test]
fn audit_append_request_full_payload() {
let body = r#"{
"event_type": "config_change",
"agent_id": "wrapper-1",
"detail": "rotated key",
"payload": {"key_id": "k-42", "ts": 1700000000},
"outcome": "ok",
"signing_context": "ed25519:deadbeef"
}"#;
let req: AuditAppendRequest = serde_json::from_str(body).unwrap();
assert_eq!(req.event_type, "config_change");
assert_eq!(req.agent_id, "wrapper-1");
assert_eq!(req.detail, "rotated key");
assert_eq!(req.outcome.as_deref(), Some("ok"));
assert_eq!(req.signing_context.as_deref(), Some("ed25519:deadbeef"));
let payload = req.payload.expect("payload present");
assert_eq!(payload["key_id"], "k-42");
assert_eq!(payload["ts"], 1_700_000_000);
}
}
+106 -14
View File
@@ -223,18 +223,31 @@ pub(crate) struct WsAuthCtx<'a> {
pub(crate) fn check_ws_auth(ctx: &WsAuthCtx<'_>) -> Result<(), axum::http::StatusCode> {
use axum::http::StatusCode;
// No api_key configured: only allow loopback or explicit opt-in.
// No api_key configured: behavior depends on whether dashboard auth is on.
//
// Issue #1189: previously this path allowed any loopback request through
// when api_key was empty, EVEN IF dashboard auth was enabled. That diverged
// from the HTTP middleware (which only opens the loopback no-auth path
// when api_key is empty AND auth.enabled is false). A local attacker with
// loopback access could chat with agents over WS even when the operator
// had configured dashboard credentials. Now mirror HTTP exactly.
if ctx.api_key.is_empty() {
// A session cookie can still rescue non-loopback requests when
// dashboard auth is enabled.
if ctx.auth_enabled && !ctx.session_secret.is_empty() {
if let Some(token) = crate::session_auth::extract_session_cookie(ctx.headers) {
if crate::session_auth::verify_session_token(&token, ctx.session_secret).is_some()
{
return Ok(());
// When dashboard auth is configured, require a valid session cookie
// regardless of bind address. Loopback no longer bypasses login.
if ctx.auth_enabled {
if !ctx.session_secret.is_empty() {
if let Some(token) = crate::session_auth::extract_session_cookie(ctx.headers) {
if crate::session_auth::verify_session_token(&token, ctx.session_secret)
.is_some()
{
return Ok(());
}
}
}
return Err(StatusCode::UNAUTHORIZED);
}
// No api_key AND dashboard auth disabled: keep the dev convenience
// path (loopback or explicit OPENFANG_ALLOW_NO_AUTH=1).
if ctx.is_loopback || ctx.allow_no_auth {
return Ok(());
}
@@ -1035,7 +1048,9 @@ async fn handle_command(
"command": cmd,
"message": format!("Hand '{}' deactivated.", instance.hand_id),
}),
Err(e) => serde_json::json!({"type": "error", "content": format!("Stop failed: {e}")}),
Err(e) => {
serde_json::json!({"type": "error", "content": format!("Stop failed: {e}")})
}
}
} else {
match state.kernel.stop_agent_run(agent_id) {
@@ -1045,7 +1060,9 @@ async fn handle_command(
Ok(false) => {
serde_json::json!({"type": "command_result", "command": cmd, "message": "No active run to cancel."})
}
Err(e) => serde_json::json!({"type": "error", "content": format!("Stop failed: {e}")}),
Err(e) => {
serde_json::json!({"type": "error", "content": format!("Stop failed: {e}")})
}
}
}
}
@@ -1799,10 +1816,7 @@ mod tests {
// Cookie signed with the wrong secret must fail.
let bad = crate::session_auth::create_session_token("alice", "other-secret", 1);
let mut headers = axum::http::HeaderMap::new();
headers.insert(
"cookie",
format!("openfang_session={bad}").parse().unwrap(),
);
headers.insert("cookie", format!("openfang_session={bad}").parse().unwrap());
let uri = empty_uri();
let ctx = WsAuthCtx {
api_key: "secret",
@@ -1933,4 +1947,82 @@ mod tests {
};
assert!(check_ws_auth(&ctx).is_ok());
}
// -----------------------------------------------------------------------
// Issue #1189: WS auth must mirror HTTP middleware. When dashboard auth
// is enabled, loopback + empty api_key + no cookie must NOT bypass.
// -----------------------------------------------------------------------
#[test]
fn ws_auth_dashboard_on_loopback_empty_key_no_cookie_rejected() {
// Issue #1189 regression guard: previously this returned Ok(()) because
// the empty-api_key branch allowed any loopback request through, even
// when dashboard credentials were configured. HTTP middleware rejects
// this path; WS must too.
let secret = "password-hash-style-secret";
let headers = axum::http::HeaderMap::new();
let uri = empty_uri();
let ctx = WsAuthCtx {
api_key: "",
auth_enabled: true,
session_secret: secret,
is_loopback: true,
allow_no_auth: false,
headers: &headers,
uri: &uri,
};
assert_eq!(
check_ws_auth(&ctx).unwrap_err(),
axum::http::StatusCode::UNAUTHORIZED,
"loopback must not bypass dashboard auth when api_key is empty"
);
}
#[test]
fn ws_auth_dashboard_on_loopback_valid_cookie_accepted() {
// With dashboard auth on, a valid session cookie is the supported
// credential and must upgrade successfully from loopback too.
let secret = "password-hash-style-secret";
let token = crate::session_auth::create_session_token("admin", secret, 1);
let mut headers = axum::http::HeaderMap::new();
headers.insert(
"cookie",
format!("openfang_session={token}").parse().unwrap(),
);
let uri = empty_uri();
let ctx = WsAuthCtx {
api_key: "",
auth_enabled: true,
session_secret: secret,
is_loopback: true,
allow_no_auth: false,
headers: &headers,
uri: &uri,
};
assert!(
check_ws_auth(&ctx).is_ok(),
"valid session cookie should authorize loopback WS upgrade"
);
}
#[test]
fn ws_auth_dashboard_off_loopback_empty_key_accepted() {
// Preserve the development convenience path: when dashboard auth is
// NOT configured AND api_key is empty, loopback still upgrades.
let headers = axum::http::HeaderMap::new();
let uri = empty_uri();
let ctx = WsAuthCtx {
api_key: "",
auth_enabled: false,
session_secret: "",
is_loopback: true,
allow_no_auth: false,
headers: &headers,
uri: &uri,
};
assert!(
check_ws_auth(&ctx).is_ok(),
"loopback dev path must work when dashboard auth is disabled"
);
}
}
+23 -14
View File
@@ -337,29 +337,38 @@ function agentsPage() {
OpenFangAPI.wsDisconnect();
},
buildConfigForm(agent) {
var identity = (agent && agent.identity) || {};
return {
name: (agent && agent.name) || '',
system_prompt: (agent && agent.system_prompt) || '',
emoji: identity.emoji || '',
color: identity.color || '#FF5C00',
archetype: identity.archetype || '',
vibe: identity.vibe || ''
};
},
async showDetail(agent) {
this.detailAgent = agent;
this.detailAgent._fallbacks = [];
this.detailTab = 'info';
this.agentFiles = [];
this.editingFile = null;
this.fileContent = '';
this.editingFallback = false;
this.newFallbackValue = '';
this.configForm = {
name: agent.name || '',
system_prompt: agent.system_prompt || '',
emoji: (agent.identity && agent.identity.emoji) || '',
color: (agent.identity && agent.identity.color) || '#FF5C00',
archetype: (agent.identity && agent.identity.archetype) || '',
vibe: (agent.identity && agent.identity.vibe) || ''
};
this.showDetailModal = true;
// Fetch full agent detail to get fallback_models
// Load the full detail payload before opening the modal so editable
// fields such as system_prompt and identity metadata are hydrated.
var detail = agent;
try {
var full = await OpenFangAPI.get('/api/agents/' + agent.id);
this.detailAgent._fallbacks = full.fallback_models || [];
} catch(e) { /* ignore */ }
detail = Object.assign({}, agent, full, {
identity: Object.assign({}, (agent && agent.identity) || {}, (full && full.identity) || {})
});
} catch(e) { /* fall back to list payload */ }
this.detailAgent = detail;
this.detailAgent._fallbacks = detail.fallback_models || [];
this.configForm = this.buildConfigForm(detail);
this.showDetailModal = true;
},
killAgent(agent) {
@@ -1660,10 +1660,7 @@ async fn test_clone_agent_happy_path() {
assert_eq!(manifest["description"], "Cloned for user 1");
assert_eq!(
manifest["tags"].as_array().unwrap(),
&vec![
serde_json::json!("clone"),
serde_json::json!("user-1"),
]
&vec![serde_json::json!("clone"), serde_json::json!("user-1"),]
);
// Inherited from template — the system_prompt should match.
assert_eq!(
@@ -1678,10 +1675,7 @@ async fn test_clone_agent_happy_path() {
.await
.unwrap();
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
let names: Vec<&str> = agents
.iter()
.map(|a| a["name"].as_str().unwrap())
.collect();
let names: Vec<&str> = agents.iter().map(|a| a["name"].as_str().unwrap()).collect();
assert!(names.contains(&"test-agent"));
assert!(names.contains(&"cloned-user-1"));
}
@@ -1731,10 +1725,7 @@ async fn test_clone_agent_name_collision() {
"duplicate name must return 409 Conflict"
);
let body: serde_json::Value = resp.json().await.unwrap();
assert!(body["error"]
.as_str()
.unwrap()
.contains("already exists"));
assert!(body["error"].as_str().unwrap().contains("already exists"));
// Cloning into the template's own name must also be rejected.
let resp = client
+67 -9
View File
@@ -773,12 +773,20 @@ async fn dispatch_message(
.as_ref()
.map(|o| o.lifecycle_reactions)
.unwrap_or(true);
let thread_id = if threading_enabled {
message.thread_id.as_deref()
// --- Auto-thread: decide intent now, but create AFTER all policy guards ---
let auto_thread_name = if !threading_enabled && message.thread_id.is_none() {
adapter.should_auto_thread(message).await
} else {
None
};
// thread_id is resolved later, after all guards pass.
// Always propagate an existing thread_id (message arrived inside a thread),
// regardless of threading_enabled — that flag controls explicit threading config,
// not auto-detected thread context.
let mut effective_thread_id: Option<String> = message.thread_id.clone();
// --- DM/Group policy check ---
if let Some(ref ov) = overrides {
if message.is_group {
@@ -839,12 +847,42 @@ async fn dispatch_message(
if let Err(msg) =
rate_limiter.check(ct_str, sender_user_id(message), ov.rate_limit_per_user)
{
send_response(adapter, &message.sender, msg, thread_id, output_format).await;
// Rate-limit rejection: don't create a thread, use existing thread if any
send_response(
adapter,
&message.sender,
msg,
message.thread_id.as_deref(),
output_format,
)
.await;
return;
}
}
}
// --- Create auto-thread NOW (after all policy guards have passed) ---
if let Some(ref thread_name) = auto_thread_name {
match adapter
.create_thread(&message.sender, &message.platform_message_id, thread_name)
.await
{
Ok(new_thread_id) => {
info!(
"Created auto-thread {} for message {}",
thread_name, message.platform_message_id
);
effective_thread_id = Some(new_thread_id);
}
Err(e) => {
warn!("Failed to create auto-thread: {}", e);
}
}
}
// Resolve final thread_id reference used by all downstream send_response calls
let thread_id = effective_thread_id.as_deref();
// Handle commands first (early return)
if let ChannelContent::Command { ref name, ref args } = message.content {
let result = handle_command(
@@ -1153,13 +1191,33 @@ async fn dispatch_message(
// Use resolve_with_context so channel_id-scoped (and guild_id-scoped)
// bindings can route per channel — see binding_context_for() for the
// single-source-of-truth allowlist.
//
// Issue #780: when the adapter stamped a per-thread target agent in
// metadata (e.g. Telegram forum-topic routing via `thread_routes`), prefer
// it over the standard router so operators can scope topics to specific
// agents from config.toml.
let target_agent_name = message
.metadata
.get("target_agent_name")
.and_then(|v| v.as_str());
let routed_by_name = if let Some(name) = target_agent_name {
match handle.find_agent_by_name(name).await {
Ok(Some(id)) => Some(id),
_ => None,
}
} else {
None
};
let binding_ctx = binding_context_for(message);
let agent_id = router.resolve_with_context(
&message.channel,
sender_user_id(message),
message.sender.openfang_user.as_deref(),
&binding_ctx,
);
let agent_id = routed_by_name.or_else(|| {
router.resolve_with_context(
&message.channel,
sender_user_id(message),
message.sender.openfang_user.as_deref(),
&binding_ctx,
)
});
let agent_id = match agent_id {
Some(id) => id,
+303 -29
View File
@@ -8,7 +8,7 @@ use crate::types::{
};
use async_trait::async_trait;
use futures::{SinkExt, Stream, StreamExt};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
@@ -22,6 +22,10 @@ const DISCORD_API_BASE: &str = "https://discord.com/api/v10";
const MAX_BACKOFF: Duration = Duration::from_secs(60);
const INITIAL_BACKOFF: Duration = Duration::from_secs(1);
const DISCORD_MSG_LIMIT: usize = 2000;
/// Maximum number of seen message IDs kept in the dedup set.
/// MESSAGE_UPDATE (embed resolution) events arrive within seconds of the
/// original CREATE; entries older than this cap are safe to discard.
const MAX_DEDUP_MSG_IDS: usize = 2_000;
/// Discord Gateway opcodes.
mod opcode {
@@ -56,6 +60,8 @@ pub struct DiscordAdapter {
allowed_users: Vec<String>,
ignore_bots: bool,
intents: u64,
/// Auto-thread behavior: "true", "false", or "smart"
auto_thread: String,
shutdown_tx: Arc<watch::Sender<bool>>,
shutdown_rx: watch::Receiver<bool>,
/// Bot's own user ID (populated after READY event).
@@ -64,6 +70,13 @@ pub struct DiscordAdapter {
session_id: Arc<RwLock<Option<String>>>,
/// Resume gateway URL.
resume_gateway_url: Arc<RwLock<Option<String>>>,
/// Thread channel IDs created by this bot (thread_id → parent_channel_id).
/// Used to detect when incoming messages are inside a bot-created thread.
created_thread_ids: Arc<RwLock<HashMap<String, String>>>,
/// Message IDs seen via MESSAGE_CREATE (used to drop duplicate MESSAGE_UPDATE events).
/// Populated immediately when MESSAGE_CREATE is forwarded — before bridge processing —
/// to eliminate the race window where MESSAGE_UPDATE arrives before thread creation completes.
threaded_message_ids: Arc<RwLock<HashSet<String>>>,
}
impl DiscordAdapter {
@@ -73,6 +86,7 @@ impl DiscordAdapter {
allowed_users: Vec<String>,
ignore_bots: bool,
intents: u64,
auto_thread: String,
) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
Self {
@@ -82,11 +96,14 @@ impl DiscordAdapter {
allowed_users,
ignore_bots,
intents,
auto_thread,
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
bot_user_id: Arc::new(RwLock::new(None)),
session_id: Arc::new(RwLock::new(None)),
resume_gateway_url: Arc::new(RwLock::new(None)),
created_thread_ids: Arc::new(RwLock::new(HashMap::new())),
threaded_message_ids: Arc::new(RwLock::new(HashSet::new())),
}
}
@@ -147,6 +164,79 @@ impl DiscordAdapter {
.await?;
Ok(())
}
/// Create a thread from a message in a Discord channel.
async fn api_create_thread(
&self,
channel_id: &str,
message_id: &str,
name: &str,
) -> Result<String, Box<dyn std::error::Error>> {
let url = format!(
"{DISCORD_API_BASE}/channels/{channel_id}/messages/{message_id}/threads",
channel_id = channel_id,
message_id = message_id
);
let body = serde_json::json!({
"name": name,
"auto_archive_duration": 1440 // 24 hours
});
let resp = self
.client
.post(&url)
.header("Authorization", format!("Bot {}", self.token.as_str()))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let body_text = resp.text().await.unwrap_or_default();
return Err(format!("Discord createThread failed: {}", body_text).into());
}
let response: serde_json::Value = resp.json().await?;
let thread_id = response["id"].as_str().unwrap_or("").to_string();
// Track thread_id → parent channel_id so we can recognise messages
// that arrive inside this thread.
if !thread_id.is_empty() {
self.created_thread_ids
.write()
.await
.insert(thread_id.clone(), channel_id.to_string());
}
Ok(thread_id)
}
/// Send a message to an existing thread.
/// Discord threads are channels — post directly to channels/{thread_id}/messages.
async fn api_send_thread_message(
&self,
_channel_id: &str,
thread_id: &str,
text: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let url = format!("{DISCORD_API_BASE}/channels/{thread_id}/messages");
let chunks = split_message(text, DISCORD_MSG_LIMIT);
for chunk in chunks {
let body = serde_json::json!({ "content": chunk });
let resp = self
.client
.post(&url)
.header("Authorization", format!("Bot {}", self.token.as_str()))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let body_text = resp.text().await.unwrap_or_default();
warn!("Discord sendThreadMessage failed: {body_text}");
}
}
Ok(())
}
}
#[async_trait]
@@ -159,6 +249,33 @@ impl ChannelAdapter for DiscordAdapter {
ChannelType::Discord
}
async fn should_auto_thread(&self, message: &ChannelMessage) -> Option<String> {
// Only auto-thread in group channels (servers), not DMs
if !message.is_group {
return None;
}
// Check auto_thread mode
match self.auto_thread.as_str() {
"true" => Some(thread_name_from_message(message)),
"false" => None,
"smart" => {
// Only create thread if bot was @mentioned
let was_mentioned = message
.metadata
.get("was_mentioned")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if was_mentioned {
Some(thread_name_from_message(message))
} else {
None
}
}
_ => None,
}
}
async fn start(
&self,
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
@@ -176,6 +293,8 @@ impl ChannelAdapter for DiscordAdapter {
let bot_user_id = self.bot_user_id.clone();
let session_id_store = self.session_id.clone();
let resume_url_store = self.resume_gateway_url.clone();
let created_thread_ids = self.created_thread_ids.clone();
let threaded_message_ids = self.threaded_message_ids.clone();
let mut shutdown = self.shutdown_rx.clone();
tokio::spawn(async move {
@@ -414,19 +533,66 @@ impl ChannelAdapter for DiscordAdapter {
&allowed_guilds,
&allowed_users,
ignore_bots,
&created_thread_ids,
)
.await
{
// MESSAGE_UPDATE must be suppressed if we already
// forwarded a MESSAGE_CREATE for this message ID.
// The check uses `seen_message_ids` (tracked below)
// which is populated the moment MESSAGE_CREATE is
// forwarded — before the bridge even processes it.
// This closes the race window where MESSAGE_UPDATE
// arrives before adapter.create_thread() completes.
if event_name == "MESSAGE_UPDATE"
&& threaded_message_ids
.read()
.await
.contains(&msg.platform_message_id)
{
debug!(
"Discord MESSAGE_UPDATE skipped (already seen {})",
msg.platform_message_id
);
continue;
}
debug!(
"Discord {event_name} from {}: {:?}",
msg.sender.display_name, msg.content
);
// Mark this message as seen immediately so any
// concurrent or subsequent MESSAGE_UPDATE is dropped.
if event_name == "MESSAGE_CREATE" {
threaded_message_ids
.write()
.await
.insert(msg.platform_message_id.clone());
}
if tx.send(msg).await.is_err() {
return;
}
}
}
"THREAD_DELETE" | "CHANNEL_DELETE" => {
// Clean up tracking when a thread is deleted so the
// next message in the parent channel is treated fresh.
if let Some(tid) = d["id"].as_str() {
created_thread_ids.write().await.remove(tid);
// Prune the dedup set to prevent unbounded growth.
// Entries older than MAX_DEDUP_MSG_IDS are safe to
// discard — embed UPDATE events arrive within seconds.
let mut ids = threaded_message_ids.write().await;
if ids.len() > MAX_DEDUP_MSG_IDS {
ids.clear();
}
debug!("Discord thread/channel deleted: {tid}");
}
}
"RESUMED" => {
info!("Discord session resumed successfully");
}
@@ -532,6 +698,46 @@ impl ChannelAdapter for DiscordAdapter {
self.api_send_typing(&user.platform_id).await
}
async fn send_in_thread(
&self,
user: &ChannelUser,
content: ChannelContent,
thread_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let channel_id = &user.platform_id;
match content {
ChannelContent::Text(text) => {
self.api_send_thread_message(channel_id, thread_id, &text)
.await?;
}
_ => {
self.api_send_thread_message(channel_id, thread_id, "(Unsupported content type)")
.await?;
}
}
Ok(())
}
async fn create_thread(
&self,
user: &ChannelUser,
message_id: &str,
thread_name: &str,
) -> Result<String, Box<dyn std::error::Error>> {
let channel_id = &user.platform_id;
let thread_id = self
.api_create_thread(channel_id, message_id, thread_name)
.await?;
// Also ensure the message_id is marked as seen (belt-and-suspenders:
// the gateway loop already inserts on MESSAGE_CREATE, but keep this
// in case create_thread is ever called from another path).
self.threaded_message_ids
.write()
.await
.insert(message_id.to_string());
Ok(thread_id)
}
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
let _ = self.shutdown_tx.send(true);
Ok(())
@@ -616,6 +822,7 @@ async fn parse_discord_message(
allowed_guilds: &[String],
allowed_users: &[String],
ignore_bots: bool,
created_thread_ids: &Arc<RwLock<HashMap<String, String>>>,
) -> Option<ChannelMessage> {
// Diagnostic: dump the raw Discord payload so we can ground attachment
// parsing in real JSON. Gated by RUST_LOG; silent at default `info` level.
@@ -655,6 +862,20 @@ async fn parse_discord_message(
let content_text = d["content"].as_str().unwrap_or("");
let channel_id = d["channel_id"].as_str()?;
let message_id = d["id"].as_str().unwrap_or("0");
// Detect if this message is inside a bot-created thread.
// In Discord, a thread is its own channel — channel_id will be the thread's ID.
// If so, use the parent channel as platform_id and set thread_id so that:
// (a) auto-thread logic is skipped (message.thread_id.is_some())
// (b) responses are sent back into the same thread
let (effective_channel_id, parsed_thread_id) = {
let threads = created_thread_ids.read().await;
if let Some(parent_channel_id) = threads.get(channel_id) {
(parent_channel_id.clone(), Some(channel_id.to_string()))
} else {
(channel_id.to_string(), None)
}
};
let username = author["username"].as_str().unwrap_or("Unknown");
let discriminator = author["discriminator"].as_str().unwrap_or("0000");
let display_name = if discriminator == "0" {
@@ -760,7 +981,7 @@ async fn parse_discord_message(
channel: ChannelType::Discord,
platform_message_id: message_id.to_string(),
sender: ChannelUser {
platform_id: channel_id.to_string(),
platform_id: effective_channel_id,
display_name,
openfang_user: None,
},
@@ -768,15 +989,50 @@ async fn parse_discord_message(
target_agent: None,
timestamp,
is_group,
thread_id: None,
thread_id: parsed_thread_id,
metadata,
})
}
/// Build a Discord thread name from the message content.
/// Strips @mention prefixes (`<@...>`), trims whitespace, and truncates to
/// Discord's 100-character thread name limit. Falls back to the sender's
/// display name if the message has no usable text (e.g. image-only).
fn thread_name_from_message(message: &ChannelMessage) -> String {
let raw = match &message.content {
ChannelContent::Text(t) => t.clone(),
ChannelContent::Image { caption, .. } => caption.clone().unwrap_or_default(),
_ => String::new(),
};
// Strip leading Discord mention tokens (<@id> / <@!id>)
let stripped = regex_lite::Regex::new(r"^(<@!?\d+>\s*)+")
.map(|re| re.replace(&raw, "").into_owned())
.unwrap_or(raw);
let trimmed = stripped.trim().to_string();
if trimmed.is_empty() {
return message.sender.display_name.clone();
}
// Truncate to Discord's 100-char limit
if trimmed.chars().count() <= 100 {
trimmed
} else {
trimmed.chars().take(97).collect::<String>() + ""
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Convenience helper: empty thread-tracking map for tests that don't exercise threading.
fn empty_threads() -> Arc<RwLock<HashMap<String, String>>> {
Arc::new(RwLock::new(HashMap::new()))
}
#[tokio::test]
async fn test_parse_discord_message_basic() {
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
@@ -793,7 +1049,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
assert_eq!(msg.channel, ChannelType::Discord);
@@ -817,7 +1073,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads()).await;
assert!(msg.is_none());
}
@@ -837,7 +1093,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads()).await;
assert!(msg.is_none());
}
@@ -858,7 +1114,7 @@ mod tests {
});
// With ignore_bots=false, other bots' messages should be allowed
let msg = parse_discord_message(&d, &bot_id, &[], &[], false).await;
let msg = parse_discord_message(&d, &bot_id, &[], &[], false, &empty_threads()).await;
assert!(msg.is_some());
let msg = msg.unwrap();
assert_eq!(msg.sender.display_name, "somebot");
@@ -882,7 +1138,7 @@ mod tests {
});
// Even with ignore_bots=false, the bot's own messages must still be filtered
let msg = parse_discord_message(&d, &bot_id, &[], &[], false).await;
let msg = parse_discord_message(&d, &bot_id, &[], &[], false, &empty_threads()).await;
assert!(msg.is_none());
}
@@ -903,12 +1159,20 @@ mod tests {
});
// Not in allowed guilds
let msg =
parse_discord_message(&d, &bot_id, &["111".into(), "222".into()], &[], true).await;
let msg = parse_discord_message(
&d,
&bot_id,
&["111".into(), "222".into()],
&[],
true,
&empty_threads(),
)
.await;
assert!(msg.is_none());
// In allowed guilds
let msg = parse_discord_message(&d, &bot_id, &["999".into()], &[], true).await;
let msg =
parse_discord_message(&d, &bot_id, &["999".into()], &[], true, &empty_threads()).await;
assert!(msg.is_some());
}
@@ -927,7 +1191,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
match &msg.content {
@@ -954,7 +1218,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads()).await;
assert!(msg.is_none());
}
@@ -973,7 +1237,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
assert_eq!(msg.sender.display_name, "alice#1234");
@@ -997,7 +1261,7 @@ mod tests {
});
// MESSAGE_UPDATE uses the same parse function as MESSAGE_CREATE
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
assert_eq!(msg.channel, ChannelType::Discord);
@@ -1028,16 +1292,25 @@ mod tests {
&[],
&["user111".into(), "user222".into()],
true,
&empty_threads(),
)
.await;
assert!(msg.is_none());
// In allowed users
let msg = parse_discord_message(&d, &bot_id, &[], &["user999".into()], true).await;
let msg = parse_discord_message(
&d,
&bot_id,
&[],
&["user999".into()],
true,
&empty_threads(),
)
.await;
assert!(msg.is_some());
// Empty allowed_users = allow all
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads()).await;
assert!(msg.is_some());
}
@@ -1060,7 +1333,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
assert!(msg.is_group);
@@ -1083,7 +1356,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[], true)
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
assert!(msg2.is_group);
@@ -1105,7 +1378,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
assert!(!msg.is_group);
@@ -1144,6 +1417,7 @@ mod tests {
vec![],
true,
37376,
"true".to_string(),
);
assert_eq!(adapter.name(), "discord");
assert_eq!(adapter.channel_type(), ChannelType::Discord);
@@ -1183,7 +1457,7 @@ mod tests {
async fn test_parse_image_only_no_caption() {
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
let d = payload_with("", vec![att("photo.png", Some("image/png"), 100_000)]);
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
match msg.content {
@@ -1206,7 +1480,7 @@ mod tests {
"look at this",
vec![att("photo.jpg", Some("image/jpeg"), 50_000)],
);
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
match msg.content {
@@ -1238,7 +1512,7 @@ mod tests {
att("b.png", Some("image/png"), 20_000),
],
);
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
match msg.content {
@@ -1262,7 +1536,7 @@ mod tests {
att("b.png", Some("image/png"), 20_000),
],
);
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
match msg.content {
@@ -1281,7 +1555,7 @@ mod tests {
async fn test_parse_heic_falls_to_file() {
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
let d = payload_with("", vec![att("photo.heic", Some("image/heic"), 100_000)]);
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
match msg.content {
@@ -1301,7 +1575,7 @@ mod tests {
"",
vec![att("huge.png", Some("image/png"), 6 * 1024 * 1024)],
);
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
match msg.content {
@@ -1326,7 +1600,7 @@ mod tests {
"see attached",
vec![att("doc.pdf", Some("application/pdf"), 200_000)],
);
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
match msg.content {
@@ -1345,7 +1619,7 @@ mod tests {
// we should fall back to the filename extension.
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
let d = payload_with("", vec![att("pic.png", None, 50_000)]);
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads())
.await
.unwrap();
assert!(matches!(msg.content, ChannelContent::Image { .. }));
@@ -1355,7 +1629,7 @@ mod tests {
async fn test_parse_empty_message_with_no_attachments_returns_none() {
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
let d = payload_with("", vec![]);
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads()).await;
assert!(msg.is_none());
}
}
+304 -36
View File
@@ -18,14 +18,20 @@ use zeroize::Zeroizing;
const SYNC_TIMEOUT_MS: u64 = 30000;
const MAX_MESSAGE_LEN: usize = 4096;
/// Shared access + refresh token pair. Tokens are zeroized on drop and rotated
/// in place when MSC2918 refresh succeeds.
type TokenPair = Arc<RwLock<(Zeroizing<String>, Option<Zeroizing<String>>)>>;
/// Matrix channel adapter using the Client-Server API.
pub struct MatrixAdapter {
/// Matrix homeserver URL (e.g., `"https://matrix.org"`).
homeserver_url: String,
/// Bot's user ID (e.g., "@openfang:matrix.org").
user_id: String,
/// SECURITY: Access token is zeroized on drop.
access_token: Zeroizing<String>,
/// SECURITY: Access + refresh tokens are zeroized on drop. Stored behind
/// an RwLock so the sync loop and send paths see rotated tokens after a
/// MSC2918 /refresh call (matrix.org/MAS rotates both tokens every refresh).
tokens: TokenPair,
/// HTTP client.
client: reqwest::Client,
/// Allowed room IDs (empty = all joined rooms).
@@ -40,19 +46,47 @@ pub struct MatrixAdapter {
}
impl MatrixAdapter {
/// Create a new Matrix adapter.
/// Create a new Matrix adapter without a refresh token.
pub fn new(
homeserver_url: String,
user_id: String,
access_token: String,
allowed_rooms: Vec<String>,
auto_accept_invites: bool,
) -> Self {
Self::with_refresh_token(
homeserver_url,
user_id,
access_token,
None,
allowed_rooms,
auto_accept_invites,
)
}
/// Create a new Matrix adapter with an optional refresh token (MSC2918).
///
/// When `refresh_token` is `Some`, the adapter will automatically call
/// `POST /_matrix/client/v3/refresh` on `401 M_UNKNOWN_TOKEN` responses
/// and retry the failed request once. Both tokens rotate on each refresh
/// under Matrix Authentication Service (MAS).
pub fn with_refresh_token(
homeserver_url: String,
user_id: String,
access_token: String,
refresh_token: Option<String>,
allowed_rooms: Vec<String>,
auto_accept_invites: bool,
) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let tokens: TokenPair = Arc::new(RwLock::new((
Zeroizing::new(access_token),
refresh_token.map(Zeroizing::new),
)));
Self {
homeserver_url,
user_id,
access_token: Zeroizing::new(access_token),
tokens,
client: reqwest::Client::new(),
allowed_rooms,
shutdown_tx: Arc::new(shutdown_tx),
@@ -62,6 +96,11 @@ impl MatrixAdapter {
}
}
/// Read the current access token (cloned).
async fn current_access_token(&self) -> String {
self.tokens.read().await.0.as_str().to_string()
}
/// Send a text message to a Matrix room.
async fn api_send_message(
&self,
@@ -81,18 +120,46 @@ impl MatrixAdapter {
"body": chunk,
});
let resp = self
.client
.put(&url)
.bearer_auth(&*self.access_token)
.json(&body)
.send()
.await?;
let mut attempt = 0;
loop {
attempt += 1;
let token = self.current_access_token().await;
let resp = self
.client
.put(&url)
.bearer_auth(&token)
.json(&body)
.send()
.await?;
if resp.status().is_success() {
break;
}
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(format!("Matrix API error {status}: {body}").into());
let body_text = resp.text().await.unwrap_or_default();
// Try a single refresh+retry on M_UNKNOWN_TOKEN (MSC2918).
if attempt == 1
&& status == reqwest::StatusCode::UNAUTHORIZED
&& is_unknown_token_body(&body_text)
{
match try_refresh_tokens(&self.client, &self.homeserver_url, &self.tokens).await
{
Ok(()) => {
info!("Matrix: access token refreshed via MSC2918, retrying send");
continue;
}
Err(e) => {
return Err(format!(
"Matrix API error {status}: {body_text} (refresh failed: {e})"
)
.into());
}
}
}
return Err(format!("Matrix API error {status}: {body_text}").into());
}
}
@@ -103,21 +170,32 @@ impl MatrixAdapter {
async fn validate(&self) -> Result<String, Box<dyn std::error::Error>> {
let url = format!("{}/_matrix/client/v3/account/whoami", self.homeserver_url);
let resp = self
.client
.get(&url)
.bearer_auth(&*self.access_token)
.send()
.await?;
let mut attempt = 0;
loop {
attempt += 1;
let token = self.current_access_token().await;
let resp = self.client.get(&url).bearer_auth(&token).send().await?;
if !resp.status().is_success() {
if resp.status().is_success() {
let body: serde_json::Value = resp.json().await?;
let user_id = body["user_id"].as_str().unwrap_or("unknown").to_string();
return Ok(user_id);
}
let status = resp.status();
let body_text = resp.text().await.unwrap_or_default();
if attempt == 1
&& status == reqwest::StatusCode::UNAUTHORIZED
&& is_unknown_token_body(&body_text)
&& try_refresh_tokens(&self.client, &self.homeserver_url, &self.tokens)
.await
.is_ok()
{
info!("Matrix: access token refreshed via MSC2918, retrying /whoami");
continue;
}
return Err("Matrix authentication failed".into());
}
let body: serde_json::Value = resp.json().await?;
let user_id = body["user_id"].as_str().unwrap_or("unknown").to_string();
Ok(user_id)
}
#[cfg(test)]
@@ -126,6 +204,87 @@ impl MatrixAdapter {
}
}
/// Detect `M_UNKNOWN_TOKEN` errors in a Matrix response body.
///
/// Matrix returns 401 for multiple reasons; we only want to refresh on
/// `M_UNKNOWN_TOKEN` (the access token expired or was revoked). See
/// <https://spec.matrix.org/latest/client-server-api/#soft-logout>.
fn is_unknown_token_body(body: &str) -> bool {
serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| v.get("errcode").and_then(|c| c.as_str()).map(String::from))
.map(|c| c == "M_UNKNOWN_TOKEN")
.unwrap_or(false)
}
/// Whether a Matrix 401 body indicates a hard logout (operator must re-login).
///
/// `soft_logout: true` (or absent — default per spec) means the device is still
/// known to the server and a refresh-token grant is valid. `soft_logout: false`
/// means the device was invalidated and the operator must perform a new
/// `m.login.password` flow.
fn is_hard_logout(body: &str) -> bool {
serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| v.get("soft_logout").and_then(|s| s.as_bool()))
.map(|soft| !soft)
.unwrap_or(false)
}
/// Call `POST /_matrix/client/v3/refresh` (MSC2918) and rotate the stored tokens.
///
/// On success, replaces the access token and (if the server returned one) the
/// refresh token. MAS (matrix.org since 2025-04-07) rotates the refresh token
/// on every call, so callers must use the new value next time.
async fn try_refresh_tokens(
client: &reqwest::Client,
homeserver: &str,
tokens: &TokenPair,
) -> Result<(), String> {
let refresh_token = {
let guard = tokens.read().await;
match guard.1.as_ref() {
Some(rt) => rt.as_str().to_string(),
None => return Err("no refresh token configured".to_string()),
}
};
let url = format!("{homeserver}/_matrix/client/v3/refresh");
let resp = client
.post(&url)
.json(&serde_json::json!({ "refresh_token": refresh_token }))
.send()
.await
.map_err(|e| format!("refresh request failed: {e}"))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("refresh returned {status}: {body}"));
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("refresh response parse error: {e}"))?;
let new_access = body
.get("access_token")
.and_then(|v| v.as_str())
.ok_or_else(|| "refresh response missing access_token".to_string())?;
let new_refresh = body
.get("refresh_token")
.and_then(|v| v.as_str())
.map(String::from);
let mut guard = tokens.write().await;
guard.0 = Zeroizing::new(new_access.to_string());
if let Some(rt) = new_refresh {
guard.1 = Some(Zeroizing::new(rt));
}
Ok(())
}
/// Accept a room invite by calling POST /_matrix/client/v3/rooms/{room_id}/join.
async fn accept_invite(
client: &reqwest::Client,
@@ -218,7 +377,7 @@ impl ChannelAdapter for MatrixAdapter {
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
let homeserver = self.homeserver_url.clone();
let access_token = self.access_token.clone();
let tokens = Arc::clone(&self.tokens);
// Use the validated user ID from /whoami instead of the config value.
// Matrix server delegation or casing differences can cause self.user_id
// to not match the sender field in timeline events, making the bot
@@ -232,9 +391,10 @@ impl ChannelAdapter for MatrixAdapter {
// FIX #4: Do an initial sync to get the since token, skipping old messages.
if since_token.read().await.is_none() {
if let Some(token) = initial_sync(&client, &homeserver, access_token.as_str()).await {
let token = self.current_access_token().await;
if let Some(next) = initial_sync(&client, &homeserver, &token).await {
info!("Matrix: initial sync complete, skipping old messages");
*since_token.write().await = Some(token);
*since_token.write().await = Some(next);
}
}
@@ -257,12 +417,13 @@ impl ChannelAdapter for MatrixAdapter {
url.push_str(&format!("&since={token}"));
}
let current_token = tokens.read().await.0.as_str().to_string();
let resp = tokio::select! {
_ = shutdown_rx.changed() => {
info!("Matrix adapter shutting down");
break;
}
result = client.get(&url).bearer_auth(access_token.as_str()).send() => {
result = client.get(&url).bearer_auth(&current_token).send() => {
match result {
Ok(r) => r,
Err(e) => {
@@ -276,7 +437,38 @@ impl ChannelAdapter for MatrixAdapter {
};
if !resp.status().is_success() {
warn!("Matrix sync returned {}", resp.status());
let status = resp.status();
// MSC2918: on 401 M_UNKNOWN_TOKEN with a refresh token configured,
// try refreshing once and loop again immediately. Hard logout
// (soft_logout:false) is unrecoverable here — the operator must
// perform a fresh m.login.password.
if status == reqwest::StatusCode::UNAUTHORIZED {
let body_text = resp.text().await.unwrap_or_default();
if is_unknown_token_body(&body_text) {
if is_hard_logout(&body_text) {
warn!(
"Matrix: hard logout (soft_logout=false), operator must re-login"
);
} else {
match try_refresh_tokens(&client, &homeserver, &tokens).await {
Ok(()) => {
info!(
"Matrix: access token refreshed via MSC2918, resuming /sync"
);
backoff = Duration::from_secs(1);
continue;
}
Err(e) => {
warn!("Matrix: token refresh failed: {e}");
}
}
}
} else {
warn!("Matrix sync returned {status}: {body_text}");
}
} else {
warn!("Matrix sync returned {status}");
}
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(Duration::from_secs(60));
continue;
@@ -309,8 +501,8 @@ impl ChannelAdapter for MatrixAdapter {
);
continue;
}
accept_invite(&client, &homeserver, access_token.as_str(), room_id)
.await;
let tok = tokens.read().await.0.as_str().to_string();
accept_invite(&client, &homeserver, &tok, room_id).await;
}
}
}
@@ -380,10 +572,11 @@ impl ChannelAdapter for MatrixAdapter {
}
// FIX #3: Determine if room is a DM (2 members) or group.
let tok_for_count = tokens.read().await.0.as_str().to_string();
let is_group = get_room_member_count(
&client,
&homeserver,
access_token.as_str(),
&tok_for_count,
room_id,
)
.await
@@ -409,10 +602,11 @@ impl ChannelAdapter for MatrixAdapter {
}
// FIX #3: Determine if room is a DM (2 members) or group.
let tok_for_count = tokens.read().await.0.as_str().to_string();
let is_group = get_room_member_count(
&client,
&homeserver,
access_token.as_str(),
&tok_for_count,
room_id,
)
.await
@@ -485,10 +679,11 @@ impl ChannelAdapter for MatrixAdapter {
"timeout": 5000,
});
let token = self.current_access_token().await;
let _ = self
.client
.put(&url)
.bearer_auth(&*self.access_token)
.bearer_auth(&token)
.json(&body)
.send()
.await;
@@ -518,6 +713,79 @@ mod tests {
assert_eq!(adapter.name(), "matrix");
}
#[test]
fn test_is_unknown_token_body() {
// Real matrix.org body for M_UNKNOWN_TOKEN under MAS.
let body =
r#"{"errcode":"M_UNKNOWN_TOKEN","error":"Token is not active","soft_logout":true}"#;
assert!(is_unknown_token_body(body));
assert!(!is_hard_logout(body));
let hard = r#"{"errcode":"M_UNKNOWN_TOKEN","error":"Invalidated","soft_logout":false}"#;
assert!(is_unknown_token_body(hard));
assert!(is_hard_logout(hard));
let other = r#"{"errcode":"M_FORBIDDEN","error":"You are not allowed"}"#;
assert!(!is_unknown_token_body(other));
assert!(!is_hard_logout(other));
// Empty / non-JSON must not trigger refresh.
assert!(!is_unknown_token_body(""));
assert!(!is_unknown_token_body("not json"));
}
#[tokio::test]
async fn test_refresh_tokens_rotates_pair() {
// Spin up a tiny axum server that mimics MSC2918 /refresh: rotates both
// access and refresh tokens and returns the new pair.
use axum::{routing::post, Json, Router};
async fn refresh_handler(Json(body): Json<serde_json::Value>) -> Json<serde_json::Value> {
let incoming = body
.get("refresh_token")
.and_then(|v| v.as_str())
.unwrap_or("");
assert_eq!(incoming, "old_refresh");
Json(serde_json::json!({
"access_token": "new_access",
"refresh_token": "new_refresh",
"expires_in_ms": 3_600_000u64,
}))
}
let app = Router::new().route("/_matrix/client/v3/refresh", post(refresh_handler));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let homeserver = format!("http://{addr}");
let tokens: TokenPair = Arc::new(RwLock::new((
Zeroizing::new("old_access".to_string()),
Some(Zeroizing::new("old_refresh".to_string())),
)));
let client = reqwest::Client::new();
try_refresh_tokens(&client, &homeserver, &tokens)
.await
.expect("refresh succeeds");
let guard = tokens.read().await;
assert_eq!(guard.0.as_str(), "new_access");
assert_eq!(guard.1.as_ref().map(|s| s.as_str()), Some("new_refresh"));
drop(guard);
// Refresh with no refresh token configured must fail cleanly.
let no_refresh: TokenPair = Arc::new(RwLock::new((Zeroizing::new("a".to_string()), None)));
let err = try_refresh_tokens(&client, &homeserver, &no_refresh)
.await
.unwrap_err();
assert!(err.contains("no refresh token"));
server.abort();
}
#[test]
fn test_matrix_allowed_rooms() {
let adapter = MatrixAdapter::new(
+2 -1
View File
@@ -722,7 +722,8 @@ mod tests {
assert_eq!(resolved, None);
// Missing channel_id on the wire — no match (the binding is restrictive).
let resolved = router.resolve_with_channel_id(&ChannelType::Discord, "any-user", None, None);
let resolved =
router.resolve_with_channel_id(&ChannelType::Discord, "any-user", None, None);
assert_eq!(resolved, None);
}
+1 -3
View File
@@ -329,9 +329,7 @@ impl ChannelAdapter for SlackAdapter {
// connection during the rotation overlap. Ack on
// both, but only forward to the agent once.
if is_duplicate_envelope(&seen_envelopes, envelope_id) {
debug!(
"Slack: skipping duplicate envelope_id {envelope_id}"
);
debug!("Slack: skipping duplicate envelope_id {envelope_id}");
continue;
}
+451 -64
View File
@@ -46,6 +46,11 @@ pub struct TelegramAdapter {
poll_interval: Duration,
/// Base URL for Telegram Bot API (supports proxies/mirrors).
api_base_url: String,
/// Maps Telegram forum topic `message_thread_id` to an agent name. When a
/// message arrives inside a configured topic, the inbound parser stamps
/// `target_agent` so the bridge dispatches to that agent instead of the
/// default. Empty map = default routing for every thread. Issue #780.
thread_routes: Arc<HashMap<i64, String>>,
/// Bot username (without @), populated from `getMe` during `start()`.
/// Used for @mention detection in group messages.
bot_username: Arc<tokio::sync::RwLock<Option<String>>>,
@@ -79,6 +84,19 @@ impl TelegramAdapter {
allowed_users: Vec<String>,
poll_interval: Duration,
api_url: Option<String>,
) -> Self {
Self::with_thread_routes(token, allowed_users, poll_interval, api_url, HashMap::new())
}
/// Same as [`new`] but accepts a `thread_routes` map for forum-topic
/// routing (issue #780). Keys are Telegram `message_thread_id` values,
/// values are agent names to dispatch matching messages to.
pub fn with_thread_routes(
token: String,
allowed_users: Vec<String>,
poll_interval: Duration,
api_url: Option<String>,
thread_routes: HashMap<i64, String>,
) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let api_base_url = api_url
@@ -91,6 +109,7 @@ impl TelegramAdapter {
allowed_users,
poll_interval,
api_base_url,
thread_routes: Arc::new(thread_routes),
bot_username: Arc::new(tokio::sync::RwLock::new(None)),
rejected_reactions: Arc::new(Mutex::new(HashSet::new())),
shutdown_tx: Arc::new(shutdown_tx),
@@ -597,6 +616,7 @@ impl ChannelAdapter for TelegramAdapter {
let poll_interval = self.poll_interval;
let api_base_url = self.api_base_url.clone();
let bot_username = self.bot_username.clone();
let thread_routes = self.thread_routes.clone();
let mut shutdown = self.shutdown_rx.clone();
tokio::spawn(async move {
@@ -717,6 +737,7 @@ impl ChannelAdapter for TelegramAdapter {
&client,
&api_base_url,
bot_uname.as_deref(),
&thread_routes,
)
.await
{
@@ -826,6 +847,7 @@ async fn parse_telegram_update(
client: &reqwest::Client,
api_base_url: &str,
bot_username: Option<&str>,
thread_routes: &HashMap<i64, String>,
) -> Option<ChannelMessage> {
let update_id = update["update_id"].as_i64().unwrap_or(0);
let message = match update
@@ -1008,9 +1030,15 @@ async fn parse_telegram_update(
// Extract forum topic thread_id (Telegram sends this as `message_thread_id`
// for messages inside forum topics / reply threads).
let thread_id = message["message_thread_id"]
.as_i64()
.map(|tid| tid.to_string());
let thread_id_raw = message["message_thread_id"].as_i64();
let thread_id = thread_id_raw.map(|tid| tid.to_string());
// Forum topic routing (issue #780). If the message lives inside a
// configured topic, stash the target agent name in metadata under
// `target_agent_name` so the bridge dispatcher prefers it over the
// channel default. Topics with no entry leave metadata untouched and
// fall through to the adapter's default_agent.
let target_agent_name = thread_id_raw.and_then(|tid| thread_routes.get(&tid).cloned());
// Detect @mention of the bot in entities / caption_entities for MentionOnly group policy.
let mut metadata = HashMap::new();
@@ -1043,6 +1071,16 @@ async fn parse_telegram_update(
}
}
// Stash the forum-topic route target so the bridge can dispatch to a
// specific agent for this topic. The bridge resolves the name via
// `find_agent_by_name` before the standard router fallback.
if let Some(name) = target_agent_name {
metadata.insert(
"target_agent_name".to_string(),
serde_json::Value::String(name),
);
}
Some(ChannelMessage {
channel: ChannelType::Telegram,
platform_message_id: message_id.to_string(),
@@ -1196,9 +1234,17 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
.await
.unwrap();
let msg = parse_telegram_update(
&update,
&[],
"fake:token",
&client,
DEFAULT_API_URL,
None,
&HashMap::new(),
)
.await
.unwrap();
assert_eq!(msg.channel, ChannelType::Telegram);
assert_eq!(msg.sender.display_name, "Alice Smith");
assert_eq!(msg.sender.platform_id, "111222333");
@@ -1229,9 +1275,17 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
.await
.unwrap();
let msg = parse_telegram_update(
&update,
&[],
"fake:token",
&client,
DEFAULT_API_URL,
None,
&HashMap::new(),
)
.await
.unwrap();
// The chat_id (used for replies) stays on sender.platform_id.
assert_eq!(msg.sender.platform_id, "-1009876543210");
@@ -1270,9 +1324,17 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
.await
.unwrap();
let msg = parse_telegram_update(
&update,
&[],
"fake:token",
&client,
DEFAULT_API_URL,
None,
&HashMap::new(),
)
.await
.unwrap();
let tg_id = msg
.metadata
@@ -1307,9 +1369,17 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
.await
.unwrap();
let msg = parse_telegram_update(
&update,
&[],
"fake:token",
&client,
DEFAULT_API_URL,
None,
&HashMap::new(),
)
.await
.unwrap();
match &msg.content {
ChannelContent::Command { name, args } => {
assert_eq!(name, "agent");
@@ -1341,8 +1411,16 @@ mod tests {
let client = test_client();
// Empty allowed_users = allow all
let msg =
parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await;
let msg = parse_telegram_update(
&update,
&[],
"fake:token",
&client,
DEFAULT_API_URL,
None,
&HashMap::new(),
)
.await;
assert!(msg.is_some());
// Non-matching allowed_users = filter out
@@ -1354,6 +1432,7 @@ mod tests {
&client,
DEFAULT_API_URL,
None,
&HashMap::new(),
)
.await;
assert!(msg.is_none());
@@ -1367,6 +1446,7 @@ mod tests {
&client,
DEFAULT_API_URL,
None,
&HashMap::new(),
)
.await;
assert!(msg.is_some());
@@ -1394,9 +1474,17 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
.await
.unwrap();
let msg = parse_telegram_update(
&update,
&[],
"fake:token",
&client,
DEFAULT_API_URL,
None,
&HashMap::new(),
)
.await
.unwrap();
assert_eq!(msg.channel, ChannelType::Telegram);
assert_eq!(msg.sender.display_name, "Alice Smith");
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message!"));
@@ -1432,9 +1520,17 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
.await
.unwrap();
let msg = parse_telegram_update(
&update,
&[],
"fake:token",
&client,
DEFAULT_API_URL,
None,
&HashMap::new(),
)
.await
.unwrap();
match &msg.content {
ChannelContent::Command { name, args } => {
assert_eq!(name, "agents");
@@ -1458,9 +1554,17 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
.await
.unwrap();
let msg = parse_telegram_update(
&update,
&[],
"fake:token",
&client,
DEFAULT_API_URL,
None,
&HashMap::new(),
)
.await
.unwrap();
assert!(matches!(msg.content, ChannelContent::Location { .. }));
}
@@ -1484,9 +1588,17 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
.await
.unwrap();
let msg = parse_telegram_update(
&update,
&[],
"fake:token",
&client,
DEFAULT_API_URL,
None,
&HashMap::new(),
)
.await
.unwrap();
// With a fake token, getFile will fail, so we get a text fallback
match &msg.content {
ChannelContent::Text(t) => {
@@ -1521,9 +1633,17 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
.await
.unwrap();
let msg = parse_telegram_update(
&update,
&[],
"fake:token",
&client,
DEFAULT_API_URL,
None,
&HashMap::new(),
)
.await
.unwrap();
match &msg.content {
ChannelContent::Text(t) => {
assert!(t.contains("Document received"));
@@ -1554,9 +1674,17 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
.await
.unwrap();
let msg = parse_telegram_update(
&update,
&[],
"fake:token",
&client,
DEFAULT_API_URL,
None,
&HashMap::new(),
)
.await
.unwrap();
match &msg.content {
ChannelContent::Text(t) => {
assert!(t.contains("Voice message"));
@@ -1587,9 +1715,17 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
.await
.unwrap();
let msg = parse_telegram_update(
&update,
&[],
"fake:token",
&client,
DEFAULT_API_URL,
None,
&HashMap::new(),
)
.await
.unwrap();
assert_eq!(msg.thread_id, Some("42".to_string()));
assert!(msg.is_group);
}
@@ -1609,9 +1745,17 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
.await
.unwrap();
let msg = parse_telegram_update(
&update,
&[],
"fake:token",
&client,
DEFAULT_API_URL,
None,
&HashMap::new(),
)
.await
.unwrap();
assert_eq!(msg.thread_id, None);
assert!(!msg.is_group);
}
@@ -1633,12 +1777,193 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
.await
.unwrap();
let msg = parse_telegram_update(
&update,
&[],
"fake:token",
&client,
DEFAULT_API_URL,
None,
&HashMap::new(),
)
.await
.unwrap();
assert_eq!(msg.thread_id, Some("99".to_string()));
}
// ---- Issue #780: forum topic -> agent routing ----
fn forum_topic_update(thread_id: i64, update_id: i64) -> serde_json::Value {
serde_json::json!({
"update_id": update_id,
"message": {
"message_id": 1000 + update_id,
"message_thread_id": thread_id,
"from": { "id": 555, "first_name": "Operator" },
"chat": { "id": -1009998887776_i64, "type": "supergroup" },
"date": 1700000000,
"text": "scoped message"
}
})
}
#[tokio::test]
async fn test_thread_routes_dispatch_matching_topic_to_named_agent() {
// A message inside a configured forum topic must route to that agent
// via `target_agent`, overriding the channel default.
let update = forum_topic_update(42, 1);
let mut routes = HashMap::new();
routes.insert(42_i64, "support-agent".to_string());
routes.insert(99_i64, "ops-agent".to_string());
let client = test_client();
let msg = parse_telegram_update(
&update,
&[],
"fake:token",
&client,
DEFAULT_API_URL,
None,
&routes,
)
.await
.unwrap();
assert_eq!(msg.thread_id, Some("42".to_string()));
assert_eq!(
msg.metadata
.get("target_agent_name")
.and_then(|v| v.as_str()),
Some("support-agent")
);
}
#[tokio::test]
async fn test_thread_routes_unconfigured_topic_falls_through() {
// A thread id NOT in the route map must leave target_agent = None so
// the bridge applies the channel default_agent.
let update = forum_topic_update(7, 2);
let mut routes = HashMap::new();
routes.insert(42_i64, "support-agent".to_string());
let client = test_client();
let msg = parse_telegram_update(
&update,
&[],
"fake:token",
&client,
DEFAULT_API_URL,
None,
&routes,
)
.await
.unwrap();
assert_eq!(msg.thread_id, Some("7".to_string()));
assert!(!msg.metadata.contains_key("target_agent_name"));
}
#[tokio::test]
async fn test_thread_routes_ignored_for_non_topic_message() {
// Messages with no message_thread_id (regular group / DM) must never
// be matched against thread_routes, even if the map is non-empty.
let update = serde_json::json!({
"update_id": 3,
"message": {
"message_id": 1003,
"from": { "id": 555, "first_name": "Operator" },
"chat": { "id": -1009998887776_i64, "type": "supergroup" },
"date": 1700000000,
"text": "general group message"
}
});
let mut routes = HashMap::new();
routes.insert(42_i64, "support-agent".to_string());
let client = test_client();
let msg = parse_telegram_update(
&update,
&[],
"fake:token",
&client,
DEFAULT_API_URL,
None,
&routes,
)
.await
.unwrap();
assert_eq!(msg.thread_id, None);
assert!(!msg.metadata.contains_key("target_agent_name"));
}
#[tokio::test]
async fn test_thread_routes_multiple_topics_route_independently() {
// Two different topics in the same chat must dispatch to two different
// agents.
let mut routes = HashMap::new();
routes.insert(10_i64, "alpha".to_string());
routes.insert(20_i64, "beta".to_string());
let client = test_client();
let update_alpha = forum_topic_update(10, 4);
let msg_alpha = parse_telegram_update(
&update_alpha,
&[],
"fake:token",
&client,
DEFAULT_API_URL,
None,
&routes,
)
.await
.unwrap();
assert_eq!(
msg_alpha
.metadata
.get("target_agent_name")
.and_then(|v| v.as_str()),
Some("alpha")
);
let update_beta = forum_topic_update(20, 5);
let msg_beta = parse_telegram_update(
&update_beta,
&[],
"fake:token",
&client,
DEFAULT_API_URL,
None,
&routes,
)
.await
.unwrap();
assert_eq!(
msg_beta
.metadata
.get("target_agent_name")
.and_then(|v| v.as_str()),
Some("beta")
);
}
#[tokio::test]
async fn test_adapter_with_thread_routes_constructor_stores_map() {
// Sanity check that the new constructor wires the route map onto the
// adapter so the spawned polling task sees it.
let mut routes = HashMap::new();
routes.insert(1_i64, "first".to_string());
let adapter = TelegramAdapter::with_thread_routes(
"test:token".to_string(),
vec![],
Duration::from_millis(10),
Some("https://example.test".to_string()),
routes.clone(),
);
assert_eq!(adapter.thread_routes.as_ref(), &routes);
}
#[tokio::test]
async fn test_parse_sender_chat_fallback() {
// Messages sent on behalf of a channel have `sender_chat` instead of `from`.
@@ -1658,9 +1983,17 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
.await
.unwrap();
let msg = parse_telegram_update(
&update,
&[],
"fake:token",
&client,
DEFAULT_API_URL,
None,
&HashMap::new(),
)
.await
.unwrap();
assert_eq!(msg.sender.display_name, "My Channel");
assert_eq!(msg.sender.platform_id, "-1001234567890");
assert!(
@@ -1682,8 +2015,16 @@ mod tests {
});
let client = test_client();
let msg =
parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await;
let msg = parse_telegram_update(
&update,
&[],
"fake:token",
&client,
DEFAULT_API_URL,
None,
&HashMap::new(),
)
.await;
assert!(msg.is_none());
}
@@ -1714,6 +2055,7 @@ mod tests {
&client,
DEFAULT_API_URL,
Some("testbot"),
&HashMap::new(),
)
.await
.unwrap();
@@ -1746,6 +2088,7 @@ mod tests {
&client,
DEFAULT_API_URL,
Some("testbot"),
&HashMap::new(),
)
.await
.unwrap();
@@ -1780,6 +2123,7 @@ mod tests {
&client,
DEFAULT_API_URL,
Some("testbot"),
&HashMap::new(),
)
.await
.unwrap();
@@ -1817,6 +2161,7 @@ mod tests {
&client,
DEFAULT_API_URL,
Some("testbot"),
&HashMap::new(),
)
.await
.unwrap();
@@ -1854,6 +2199,7 @@ mod tests {
&client,
DEFAULT_API_URL,
Some("testbot"),
&HashMap::new(),
)
.await
.unwrap();
@@ -1890,6 +2236,7 @@ mod tests {
&client,
DEFAULT_API_URL,
Some("testbot"),
&HashMap::new(),
)
.await
.unwrap();
@@ -1943,9 +2290,17 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
.await
.unwrap();
let msg = parse_telegram_update(
&update,
&[],
"fake:token",
&client,
DEFAULT_API_URL,
None,
&HashMap::new(),
)
.await
.unwrap();
match &msg.content {
ChannelContent::Text(t) => {
assert!(t.starts_with("[Replying to Bob: We should use Rust]\n\n"));
@@ -1985,9 +2340,17 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
.await
.unwrap();
let msg = parse_telegram_update(
&update,
&[],
"fake:token",
&client,
DEFAULT_API_URL,
None,
&HashMap::new(),
)
.await
.unwrap();
match &msg.content {
ChannelContent::Text(t) => {
assert!(t.starts_with("[Replying to Carol: Sunset view]\n\n"));
@@ -2026,9 +2389,17 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
.await
.unwrap();
let msg = parse_telegram_update(
&update,
&[],
"fake:token",
&client,
DEFAULT_API_URL,
None,
&HashMap::new(),
)
.await
.unwrap();
match &msg.content {
ChannelContent::Text(t) => {
assert_eq!(t, "What was that?");
@@ -2064,9 +2435,17 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
.await
.unwrap();
let msg = parse_telegram_update(
&update,
&[],
"fake:token",
&client,
DEFAULT_API_URL,
None,
&HashMap::new(),
)
.await
.unwrap();
match &msg.content {
ChannelContent::Text(t) => {
assert!(t.starts_with("[Replying to Unknown: Anonymous message]\n\n"));
@@ -2091,9 +2470,17 @@ mod tests {
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
.await
.unwrap();
let msg = parse_telegram_update(
&update,
&[],
"fake:token",
&client,
DEFAULT_API_URL,
None,
&HashMap::new(),
)
.await
.unwrap();
match &msg.content {
ChannelContent::Text(t) => {
assert_eq!(t, "Just a normal message");
+18
View File
@@ -341,6 +341,24 @@ pub trait ChannelAdapter: Send + Sync {
self.send(user, content).await
}
/// Determine whether to auto-create a thread for an incoming message.
/// Returns Some(thread_name) to create a thread, or None to reply directly.
/// Default implementation returns None (no auto-threading).
async fn should_auto_thread(&self, _message: &ChannelMessage) -> Option<String> {
None
}
/// Create a new thread (typically triggered after should_auto_thread returns Some).
/// Returns the new thread ID on success.
async fn create_thread(
&self,
_user: &ChannelUser,
_message_id: &str,
_thread_name: &str,
) -> Result<String, Box<dyn std::error::Error>> {
Err("Thread creation not supported for this adapter".into())
}
/// Whether this adapter should suppress sending internal agent errors back to the user.
///
/// Returns `true` for public broadcast channels (e.g. Mastodon) where posting
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenFang",
"version": "0.6.7",
"version": "0.6.9",
"identifier": "ai.openfang.desktop",
"build": {},
"app": {
+2
View File
@@ -16,6 +16,8 @@ uuid = { workspace = true }
chrono = { workspace = true }
dashmap = { workspace = true }
dirs = { workspace = true }
sha2 = { workspace = true }
hex = { workspace = true }
[dev-dependencies]
tokio-test = { workspace = true }
+167 -5
View File
@@ -8,10 +8,27 @@ use crate::{
use dashmap::DashMap;
use openfang_types::agent::AgentId;
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use tracing::{info, warn};
use uuid::Uuid;
/// Callback signature invoked on every successful hand load / reload.
///
/// Arguments: `(hand_id, sha256_hex_of_hand_toml)`. The kernel wires this
/// into the Merkle audit chain so reload events leave a tamper-evident
/// record (issue #1172). The callback must be cheap and non-blocking; it
/// runs inline on the loader thread.
pub type HandAuditCallback = Arc<dyn Fn(&str, &str) + Send + Sync>;
/// Compute the SHA-256 hex digest of raw HAND.toml content.
fn hand_toml_sha256(toml_content: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(toml_content.as_bytes());
hex::encode(hasher.finalize())
}
// ─── Settings availability types ────────────────────────────────────────────
/// Availability status of a single setting option.
@@ -41,6 +58,10 @@ pub struct HandRegistry {
definitions: DashMap<String, HandDefinition>,
/// Active hand instances, keyed by instance UUID.
instances: DashMap<Uuid, HandInstance>,
/// Optional callback invoked on every successful HAND.toml load with
/// the computed SHA-256 of the file content. Wired by the kernel into
/// the Merkle audit chain (issue #1172).
audit_callback: RwLock<Option<HandAuditCallback>>,
}
impl HandRegistry {
@@ -49,9 +70,37 @@ impl HandRegistry {
Self {
definitions: DashMap::new(),
instances: DashMap::new(),
audit_callback: RwLock::new(None),
}
}
/// Install a callback invoked on every successful HAND.toml load /
/// reload with the file's SHA-256. The kernel wires this to
/// `AuditLog::record(AuditAction::ConfigChange, ...)` so reload events
/// leave a tamper-evident audit record (issue #1172).
pub fn set_audit_callback(&self, callback: HandAuditCallback) {
let mut guard = self
.audit_callback
.write()
.unwrap_or_else(|e| e.into_inner());
*guard = Some(callback);
}
/// Compute SHA-256 of the given HAND.toml content and invoke the audit
/// callback if one is registered. Returns the hex digest for callers
/// that want to log or compare it.
fn emit_hand_loaded_audit(&self, hand_id: &str, toml_content: &str) -> String {
let hash = hand_toml_sha256(toml_content);
let guard = self
.audit_callback
.read()
.unwrap_or_else(|e| e.into_inner());
if let Some(cb) = guard.as_ref() {
cb(hand_id, &hash);
}
hash
}
/// Persist active hand state to disk so it survives restarts.
pub fn persist_state(&self, path: &std::path::Path) -> HandResult<()> {
let entries: Vec<serde_json::Value> = self
@@ -112,7 +161,8 @@ impl HandRegistry {
for (id, toml_content, skill_content) in bundled {
match bundled::parse_bundled(id, toml_content, skill_content) {
Ok(def) => {
info!(hand = %def.id, name = %def.name, "Loaded bundled hand");
let hash = self.emit_hand_loaded_audit(&def.id, toml_content);
info!(hand = %def.id, name = %def.name, sha256 = %hash, "Loaded bundled hand");
self.definitions.insert(def.id.clone(), def);
count += 1;
}
@@ -173,7 +223,8 @@ impl HandRegistry {
match bundled::parse_bundled("custom", &contents, &skill_content) {
Ok(def) => {
let hand_id = def.id.clone();
info!(hand = %hand_id, path = %path.display(), "Loaded workspace hand");
let hash = self.emit_hand_loaded_audit(&hand_id, &contents);
info!(hand = %hand_id, path = %path.display(), sha256 = %hash, "Loaded workspace hand");
self.definitions.insert(hand_id, def);
count += 1;
}
@@ -204,7 +255,8 @@ impl HandRegistry {
)));
}
info!(hand = %def.id, name = %def.name, path = %path.display(), "Installed hand from path");
let hash = self.emit_hand_loaded_audit(&def.id, &toml_content);
info!(hand = %def.id, name = %def.name, path = %path.display(), sha256 = %hash, "Installed hand from path");
self.definitions.insert(def.id.clone(), def.clone());
// Persist the hand to the user's data dir so it survives daemon
@@ -251,7 +303,8 @@ impl HandRegistry {
)));
}
info!(hand = %def.id, name = %def.name, "Installed hand from content");
let hash = self.emit_hand_loaded_audit(&def.id, toml_content);
info!(hand = %def.id, name = %def.name, sha256 = %hash, "Installed hand from content");
self.definitions.insert(def.id.clone(), def.clone());
Ok(def)
}
@@ -269,7 +322,8 @@ impl HandRegistry {
let def = bundled::parse_bundled("custom", toml_content, skill_content)?;
let existed = self.definitions.contains_key(&def.id);
let verb = if existed { "Updated" } else { "Installed" };
info!(hand = %def.id, name = %def.name, "{verb} hand from content");
let hash = self.emit_hand_loaded_audit(&def.id, toml_content);
info!(hand = %def.id, name = %def.name, sha256 = %hash, "{verb} hand from content");
self.definitions.insert(def.id.clone(), def.clone());
Ok(def)
}
@@ -1145,6 +1199,114 @@ metrics = []
assert!(matches!(err, HandError::AlreadyActive(_)));
}
/// Issue #1172: HAND.toml SHA-256 must be emitted to the audit
/// callback on every successful load / reload.
#[test]
fn audit_callback_records_hand_toml_hash_on_load() {
use std::sync::Mutex;
let captured: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&captured);
let reg = HandRegistry::new();
reg.set_audit_callback(Arc::new(move |hand_id: &str, hash: &str| {
sink.lock()
.unwrap()
.push((hand_id.to_string(), hash.to_string()));
}));
let toml_str = r#"
id = "audit-hand"
name = "Audit Hand"
description = "Used to verify audit-trail wiring"
category = "other"
tools = []
[agent]
name = "audit-agent"
description = "audit"
system_prompt = "audit."
"#;
// Precompute the expected hash so the test fails loudly if the
// registry ever changes how it digests the TOML content.
let expected_hash = {
let mut h = Sha256::new();
h.update(toml_str.as_bytes());
hex::encode(h.finalize())
};
let def = reg.install_from_content(toml_str, "").unwrap();
assert_eq!(def.id, "audit-hand");
let events = captured.lock().unwrap().clone();
assert_eq!(
events.len(),
1,
"exactly one audit event should be emitted per load"
);
assert_eq!(events[0].0, "audit-hand", "hand id propagated to callback");
assert_eq!(
events[0].1, expected_hash,
"callback received SHA-256 of the HAND.toml content"
);
assert_eq!(events[0].1.len(), 64, "SHA-256 hex is 64 chars");
}
/// Issue #1172: reloading the same HAND.toml via upsert must emit a
/// fresh audit event so the chain records when the swap took effect.
/// A content change must surface a different hash.
#[test]
fn audit_callback_fires_on_reload_with_new_hash() {
use std::sync::Mutex;
let captured: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&captured);
let reg = HandRegistry::new();
reg.set_audit_callback(Arc::new(move |hand_id: &str, hash: &str| {
sink.lock()
.unwrap()
.push((hand_id.to_string(), hash.to_string()));
}));
let v1 = r#"
id = "reload-hand"
name = "Reload Hand v1"
description = "v1"
category = "other"
tools = []
[agent]
name = "reload-agent"
description = "reload"
system_prompt = "v1."
"#;
let v2 = r#"
id = "reload-hand"
name = "Reload Hand v2"
description = "v2"
category = "other"
tools = []
[agent]
name = "reload-agent"
description = "reload"
system_prompt = "v2 — schedule changed."
"#;
reg.upsert_from_content(v1, "").unwrap();
reg.upsert_from_content(v2, "").unwrap();
let events = captured.lock().unwrap().clone();
assert_eq!(events.len(), 2, "one event per upsert (load + reload)");
assert_eq!(events[0].0, "reload-hand");
assert_eq!(events[1].0, "reload-hand");
assert_ne!(
events[0].1, events[1].1,
"different HAND.toml content must yield different SHA-256"
);
}
/// Integration test for issue #809: `hand config` round-trip.
///
/// Simulates what `openfang hand config <id> --set KEY=VAL` does against
+1
View File
@@ -32,6 +32,7 @@ futures = { workspace = true }
subtle = { workspace = true }
rand = { workspace = true }
hex = { workspace = true }
sha2 = { workspace = true }
reqwest = { workspace = true }
rustls = { workspace = true }
cron = "0.16"
+1
View File
@@ -339,6 +339,7 @@ mod tests {
autonomous: None,
pinned_model: None,
workspace: None,
state_dir: None,
generate_identity_files: true,
exec_policy: None,
tool_allowlist: vec![],
+624 -90
View File
@@ -288,9 +288,38 @@ impl DeliveryTracker {
}
}
/// Create workspace directory structure for an agent.
/// Create the agent's private state directory layout. Holds identity files,
/// AGENT.json, sessions/, memory/, and logs/. Lives under
/// `~/.openfang/workspaces/{name}/` regardless of where the user pointed the
/// user-facing workspace. See issue #1097.
fn ensure_state_dir(state_dir: &Path, workspace: &Path) -> KernelResult<()> {
for subdir in &["sessions", "logs", "memory"] {
std::fs::create_dir_all(state_dir.join(subdir)).map_err(|e| {
KernelError::OpenFang(OpenFangError::Internal(format!(
"Failed to create state dir {}/{subdir}: {e}",
state_dir.display()
)))
})?;
}
// Write agent metadata file (best-effort).
let meta = serde_json::json!({
"created_at": chrono::Utc::now().to_rfc3339(),
"state_dir": state_dir.display().to_string(),
"workspace": workspace.display().to_string(),
});
let _ = std::fs::write(
state_dir.join("AGENT.json"),
serde_json::to_string_pretty(&meta).unwrap_or_default(),
);
Ok(())
}
/// Create the user-facing workspace layout. Only `data/`, `output/`, and
/// `skills/` are scaffolded here. When the user points the workspace at a
/// pre-existing path like `~/Documents`, these subdirs are created lazily
/// inside it without dumping identity files or sessions. See issue #1097.
fn ensure_workspace(workspace: &Path) -> KernelResult<()> {
for subdir in &["data", "output", "sessions", "skills", "logs", "memory"] {
for subdir in &["data", "output", "skills"] {
std::fs::create_dir_all(workspace.join(subdir)).map_err(|e| {
KernelError::OpenFang(OpenFangError::Internal(format!(
"Failed to create workspace dir {}/{subdir}: {e}",
@@ -298,15 +327,6 @@ fn ensure_workspace(workspace: &Path) -> KernelResult<()> {
)))
})?;
}
// Write agent metadata file (best-effort)
let meta = serde_json::json!({
"created_at": chrono::Utc::now().to_rfc3339(),
"workspace": workspace.display().to_string(),
});
let _ = std::fs::write(
workspace.join("AGENT.json"),
serde_json::to_string_pretty(&meta).unwrap_or_default(),
);
Ok(())
}
@@ -447,15 +467,18 @@ fn generate_identity_files(workspace: &Path, manifest: &AgentManifest) {
}
/// Append an assistant response summary to the daily memory log (best-effort, append-only).
/// Caps daily log at 1MB to prevent unbounded growth.
fn append_daily_memory_log(workspace: &Path, response: &str) {
/// Caps daily log at 1MB to prevent unbounded growth. Writes to the agent's
/// private state directory (`state_dir/memory/`), never to the user-facing
/// workspace, so pointing `workspace = "/home/me/Documents"` does not litter
/// the user's folder with per-day markdown files. See issue #1097.
fn append_daily_memory_log(state_dir: &Path, response: &str) {
use std::io::Write;
let trimmed = response.trim();
if trimmed.is_empty() {
return;
}
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
let log_path = workspace.join("memory").join(format!("{today}.md"));
let log_path = state_dir.join("memory").join(format!("{today}.md"));
// Security: cap total daily log to 1MB
if let Ok(metadata) = std::fs::metadata(&log_path) {
if metadata.len() > 1_048_576 {
@@ -474,16 +497,18 @@ fn append_daily_memory_log(workspace: &Path, response: &str) {
}
}
/// Read a workspace identity file with a size cap to prevent prompt stuffing.
/// Returns None if the file doesn't exist or is empty.
fn read_identity_file(workspace: &Path, filename: &str) -> Option<String> {
/// Read an identity file from the agent's private state directory with a size
/// cap to prevent prompt stuffing. Returns None if the file doesn't exist or
/// is empty. Identity files live in `state_dir`, not in the user-facing
/// workspace (see issue #1097), so this is called with the state directory.
fn read_identity_file(state_dir: &Path, filename: &str) -> Option<String> {
const MAX_IDENTITY_FILE_BYTES: usize = 32_768; // 32KB cap
let path = workspace.join(filename);
// Security: ensure path stays inside workspace
let path = state_dir.join(filename);
// Security: ensure path stays inside the state directory
match path.canonicalize() {
Ok(canonical) => {
if let Ok(ws_canonical) = workspace.canonicalize() {
if !canonical.starts_with(&ws_canonical) {
if let Ok(sd_canonical) = state_dir.canonicalize() {
if !canonical.starts_with(&sd_canonical) {
return None; // path traversal attempt
}
}
@@ -800,6 +825,9 @@ impl OpenFangKernel {
// Initialize model catalog, detect provider auth, and apply URL overrides
let mut model_catalog = openfang_runtime::model_catalog::ModelCatalog::new();
model_catalog.detect_auth();
// Env-var overrides for local providers (OLLAMA_HOST, LMSTUDIO_BASE_URL, etc.).
// Applied before `provider_urls` so explicit config.toml entries win. See #1154.
model_catalog.apply_local_env_overrides();
if !config.provider_urls.is_empty() {
model_catalog.apply_url_overrides(&config.provider_urls);
info!(
@@ -1058,11 +1086,10 @@ impl OpenFangKernel {
openfang_runtime::media_understanding::MediaEngine::new(config.media.clone());
// Closes #1051: thread MediaConfig URL overrides into the TTS engine
// so local OpenAI/ElevenLabs-compatible services can be targeted.
let tts_engine = openfang_runtime::tts::TtsEngine::new(config.tts.clone())
.with_base_urls(
config.media.tts_openai_base_url.clone(),
config.media.tts_elevenlabs_base_url.clone(),
);
let tts_engine = openfang_runtime::tts::TtsEngine::new(config.tts.clone()).with_base_urls(
config.media.tts_openai_base_url.clone(),
config.media.tts_elevenlabs_base_url.clone(),
);
let mut pairing = crate::pairing::PairingManager::new(config.pairing.clone());
// Load paired devices from database and set up persistence callback
@@ -1195,6 +1222,41 @@ impl OpenFangKernel {
self_handle: OnceLock::new(),
};
// Wire HAND.toml load events into the Merkle audit chain so reload
// events (and future installs) leave a tamper-evident record of
// which manifest hash was active at any point in time. Issue #1172.
//
// The bundled + workspace hands were loaded before the kernel struct
// existed, so we backfill those hashes now and install a callback
// for every subsequent install/upsert/reload.
{
let audit_log_initial = Arc::clone(&kernel.audit_log);
for (hand_id, toml_content, _skill) in openfang_hands::bundled::bundled_hands() {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(toml_content.as_bytes());
let hash = hex::encode(hasher.finalize());
audit_log_initial.record(
"kernel",
openfang_runtime::audit::AuditAction::ConfigChange,
format!("HAND.toml load hand={hand_id} sha256={hash}"),
"ok",
);
}
let audit_log_for_cb = Arc::clone(&kernel.audit_log);
kernel
.hand_registry
.set_audit_callback(Arc::new(move |hand_id: &str, hash: &str| {
audit_log_for_cb.record(
"kernel",
openfang_runtime::audit::AuditAction::ConfigChange,
format!("HAND.toml reload hand={hand_id} sha256={hash}"),
"ok",
);
}));
}
// Restore persisted agents from SQLite
match kernel.memory.load_all_agents() {
Ok(agents) => {
@@ -1478,9 +1540,7 @@ impl OpenFangKernel {
}
}
if auto_spawned > 0 {
info!(
"Auto-spawned {auto_spawned} agent(s) from ~/.openfang/agents"
);
info!("Auto-spawned {auto_spawned} agent(s) from ~/.openfang/agents");
}
}
}
@@ -1631,15 +1691,27 @@ impl OpenFangKernel {
// Apply global budget defaults to agent resource quotas
apply_budget_defaults(&self.config.budget, &mut manifest.resources);
// Create workspace directory for the agent (name-based, so SOUL.md survives recreation)
// Agent private state always lives under ~/.openfang/workspaces/{name}/.
// This is name-based so SOUL.md and per-agent memory survive recreation
// and never get dumped into a user-supplied workspace path. See #1097.
let state_dir = manifest
.state_dir
.clone()
.unwrap_or_else(|| self.config.effective_workspaces_dir().join(&name));
// The user-facing workspace defaults to the state_dir when the manifest
// does not specify one. When the user sets `workspace = "/path"` in
// agent.toml we leave that path alone — only data/, output/, skills/
// get created lazily so private state never pollutes the target dir.
let workspace_dir = manifest
.workspace
.clone()
.unwrap_or_else(|| self.config.effective_workspaces_dir().join(&name));
.unwrap_or_else(|| state_dir.clone());
ensure_state_dir(&state_dir, &workspace_dir)?;
ensure_workspace(&workspace_dir)?;
if manifest.generate_identity_files {
generate_identity_files(&workspace_dir, &manifest);
generate_identity_files(&state_dir, &manifest);
}
manifest.state_dir = Some(state_dir);
manifest.workspace = Some(workspace_dir);
// Register capabilities
@@ -2049,16 +2121,30 @@ impl OpenFangKernel {
let (tx, rx) = tokio::sync::mpsc::channel::<StreamEvent>(64);
let mut manifest = entry.manifest.clone();
// Lazy backfill: create workspace for existing agents spawned before workspaces
if manifest.workspace.is_none() {
let workspace_dir = self.config.effective_workspaces_dir().join(&manifest.name);
// Lazy backfill: create state_dir and workspace for existing agents
// spawned before this field existed. Private state always lives under
// ~/.openfang/workspaces/{name}/; the user-facing workspace defaults
// to the same path unless the manifest already pinned one. See #1097.
if manifest.state_dir.is_none() {
let state_dir = self.config.effective_workspaces_dir().join(&manifest.name);
let workspace_dir = manifest
.workspace
.clone()
.unwrap_or_else(|| state_dir.clone());
if let Err(e) = ensure_state_dir(&state_dir, &workspace_dir) {
warn!(agent_id = %agent_id, "Failed to backfill state_dir (streaming): {e}");
}
if let Err(e) = ensure_workspace(&workspace_dir) {
warn!(agent_id = %agent_id, "Failed to backfill workspace (streaming): {e}");
} else {
manifest.state_dir = Some(state_dir);
manifest.workspace = Some(workspace_dir);
let _ = self
.registry
.update_workspace(agent_id, manifest.workspace.clone());
let _ = self
.registry
.update_state_dir(agent_id, manifest.state_dir.clone());
}
}
@@ -2129,17 +2215,17 @@ impl OpenFangKernel {
},
workspace_path: manifest.workspace.as_ref().map(|p| p.display().to_string()),
soul_md: manifest
.workspace
.state_dir
.as_ref()
.and_then(|w| read_identity_file(w, "SOUL.md")),
.and_then(|s| read_identity_file(s, "SOUL.md")),
user_md: manifest
.workspace
.state_dir
.as_ref()
.and_then(|w| read_identity_file(w, "USER.md")),
.and_then(|s| read_identity_file(s, "USER.md")),
memory_md: manifest
.workspace
.state_dir
.as_ref()
.and_then(|w| read_identity_file(w, "MEMORY.md")),
.and_then(|s| read_identity_file(s, "MEMORY.md")),
canonical_context: self
.memory
.canonical_context(agent_id, None)
@@ -2154,27 +2240,27 @@ impl OpenFangKernel {
.unwrap_or(false),
is_autonomous: manifest.autonomous.is_some(),
agents_md: manifest
.workspace
.state_dir
.as_ref()
.and_then(|w| read_identity_file(w, "AGENTS.md")),
.and_then(|s| read_identity_file(s, "AGENTS.md")),
bootstrap_md: manifest
.workspace
.state_dir
.as_ref()
.and_then(|w| read_identity_file(w, "BOOTSTRAP.md")),
.and_then(|s| read_identity_file(s, "BOOTSTRAP.md")),
workspace_context: manifest.workspace.as_ref().map(|w| {
let mut ws_ctx =
openfang_runtime::workspace_context::WorkspaceContext::detect(w);
ws_ctx.build_context_section()
}),
identity_md: manifest
.workspace
.state_dir
.as_ref()
.and_then(|w| read_identity_file(w, "IDENTITY.md")),
.and_then(|s| read_identity_file(s, "IDENTITY.md")),
heartbeat_md: if manifest.autonomous.is_some() {
manifest
.workspace
.state_dir
.as_ref()
.and_then(|w| read_identity_file(w, "HEARTBEAT.md"))
.and_then(|s| read_identity_file(s, "HEARTBEAT.md"))
} else {
None
},
@@ -2315,15 +2401,17 @@ impl OpenFangKernel {
}
}
// Write JSONL session mirror to workspace
if let Some(ref workspace) = manifest.workspace {
// Write JSONL session mirror and daily memory log to the
// agent's private state directory, not the user-facing
// workspace. See issue #1097.
if let Some(ref state_dir) = manifest.state_dir {
if let Err(e) =
memory.write_jsonl_mirror(&session, &workspace.join("sessions"))
memory.write_jsonl_mirror(&session, &state_dir.join("sessions"))
{
warn!("Failed to write JSONL session mirror (streaming): {e}");
}
// Append daily memory log (best-effort)
append_daily_memory_log(workspace, &result.response);
append_daily_memory_log(state_dir, &result.response);
}
kernel_clone
@@ -2608,17 +2696,30 @@ impl OpenFangKernel {
// Apply model routing if configured (disabled in Stable mode)
let mut manifest = entry.manifest.clone();
// Lazy backfill: create workspace for existing agents spawned before workspaces
if manifest.workspace.is_none() {
let workspace_dir = self.config.effective_workspaces_dir().join(&manifest.name);
// Lazy backfill: create state_dir and workspace for existing agents.
// Private state lives under ~/.openfang/workspaces/{name}/. User-facing
// workspace stays at whatever the manifest pinned (or defaults to the
// state_dir). See issue #1097.
if manifest.state_dir.is_none() {
let state_dir = self.config.effective_workspaces_dir().join(&manifest.name);
let workspace_dir = manifest
.workspace
.clone()
.unwrap_or_else(|| state_dir.clone());
if let Err(e) = ensure_state_dir(&state_dir, &workspace_dir) {
warn!(agent_id = %agent_id, "Failed to backfill state_dir: {e}");
}
if let Err(e) = ensure_workspace(&workspace_dir) {
warn!(agent_id = %agent_id, "Failed to backfill workspace: {e}");
} else {
manifest.state_dir = Some(state_dir);
manifest.workspace = Some(workspace_dir);
// Persist updated workspace in registry
let _ = self
.registry
.update_workspace(agent_id, manifest.workspace.clone());
let _ = self
.registry
.update_state_dir(agent_id, manifest.state_dir.clone());
}
}
@@ -2697,17 +2798,17 @@ impl OpenFangKernel {
},
workspace_path: manifest.workspace.as_ref().map(|p| p.display().to_string()),
soul_md: manifest
.workspace
.state_dir
.as_ref()
.and_then(|w| read_identity_file(w, "SOUL.md")),
.and_then(|s| read_identity_file(s, "SOUL.md")),
user_md: manifest
.workspace
.state_dir
.as_ref()
.and_then(|w| read_identity_file(w, "USER.md")),
.and_then(|s| read_identity_file(s, "USER.md")),
memory_md: manifest
.workspace
.state_dir
.as_ref()
.and_then(|w| read_identity_file(w, "MEMORY.md")),
.and_then(|s| read_identity_file(s, "MEMORY.md")),
canonical_context: self
.memory
.canonical_context(agent_id, None)
@@ -2722,27 +2823,27 @@ impl OpenFangKernel {
.unwrap_or(false),
is_autonomous: manifest.autonomous.is_some(),
agents_md: manifest
.workspace
.state_dir
.as_ref()
.and_then(|w| read_identity_file(w, "AGENTS.md")),
.and_then(|s| read_identity_file(s, "AGENTS.md")),
bootstrap_md: manifest
.workspace
.state_dir
.as_ref()
.and_then(|w| read_identity_file(w, "BOOTSTRAP.md")),
.and_then(|s| read_identity_file(s, "BOOTSTRAP.md")),
workspace_context: manifest.workspace.as_ref().map(|w| {
let mut ws_ctx =
openfang_runtime::workspace_context::WorkspaceContext::detect(w);
ws_ctx.build_context_section()
}),
identity_md: manifest
.workspace
.state_dir
.as_ref()
.and_then(|w| read_identity_file(w, "IDENTITY.md")),
.and_then(|s| read_identity_file(s, "IDENTITY.md")),
heartbeat_md: if manifest.autonomous.is_some() {
manifest
.workspace
.state_dir
.as_ref()
.and_then(|w| read_identity_file(w, "HEARTBEAT.md"))
.and_then(|s| read_identity_file(s, "HEARTBEAT.md"))
} else {
None
},
@@ -2880,16 +2981,17 @@ impl OpenFangKernel {
}
}
// Write JSONL session mirror to workspace
if let Some(ref workspace) = manifest.workspace {
// Write JSONL session mirror and daily memory log to the agent's
// private state directory, not the user-facing workspace. See #1097.
if let Some(ref state_dir) = manifest.state_dir {
if let Err(e) = self
.memory
.write_jsonl_mirror(&session, &workspace.join("sessions"))
.write_jsonl_mirror(&session, &state_dir.join("sessions"))
{
warn!("Failed to write JSONL session mirror: {e}");
}
// Append daily memory log (best-effort)
append_daily_memory_log(workspace, &result.response);
append_daily_memory_log(state_dir, &result.response);
}
// Record usage in the metering engine (uses catalog pricing as single source of truth)
@@ -5216,26 +5318,93 @@ impl OpenFangKernel {
}
/// Collect every provider ID the operator has actually referenced in
/// their effective config — the default model, every fallback chain
/// entry, every `[provider_urls]` key, and every registered agent's
/// manifest provider (including per-agent `fallback_models`). Used by
/// the local provider probe loop so that we don't spam `WARN Local
/// provider offline` for providers the user never asked about (#1031).
/// their effective config. Walks the surfaces below so the local
/// provider probe loop does not spam `WARN Local provider offline`
/// for providers the user never asked about (#1031, #1188):
///
/// 1. Default model (boot config + hot-reload override).
/// 2. Global fallback chain (boot config + hot-reload override).
/// 3. Explicit `[provider_urls]` keys.
/// 4. Every registered agent manifest provider + per-agent
/// `fallback_models`.
/// 5. Catalog-resolved aliases — model names on default/fallback/manifest
/// that resolve to a different concrete provider via the catalog.
/// 6. Per-channel `overrides.model` for every enabled channel adapter,
/// resolved through the model catalog.
/// 7. Bundled and user-installed skills — tags that match a known
/// provider ID, and `config` variables whose `env` matches a known
/// provider's `api_key_env`.
/// 8. MCP server configs — `env` entries that match a known provider's
/// `api_key_env`.
fn referenced_providers(&self) -> std::collections::HashSet<String> {
let mut set: std::collections::HashSet<String> = std::collections::HashSet::new();
// Snapshot catalog lookups up front so we don't keep the lock across
// long iterations. Provider IDs are lowercased model-side already.
let (provider_ids, env_to_provider) = {
let catalog = self
.model_catalog
.read()
.unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner());
let ids: std::collections::HashSet<String> = catalog
.list_providers()
.iter()
.map(|p| p.id.clone())
.collect();
// Multi-valued: several providers may share the same api_key_env
// (e.g. both `openai` and `codex` use OPENAI_API_KEY). Using a
// plain HashMap silently dropped earlier providers — broke #1188.
let mut env_map: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for p in catalog.list_providers() {
if p.api_key_env.is_empty() {
continue;
}
env_map
.entry(p.api_key_env.to_ascii_uppercase())
.or_default()
.push(p.id.clone());
}
(ids, env_map)
};
// Resolve a model name through the catalog and add the concrete
// provider it lives on. Lets us catch alias-only references where
// the surrounding `provider` field is "default" or empty (#1188).
let add_model = |set: &mut std::collections::HashSet<String>, name: &str| {
if name.is_empty() || name == "default" {
return;
}
let catalog = self
.model_catalog
.read()
.unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner());
if let Some(entry) = catalog.find_model(name) {
let p = &entry.provider;
if !p.is_empty() && p != "default" {
set.insert(p.clone());
}
}
};
// Default model — respect hot-reloaded override.
let override_guard = self
.default_model_override
.read()
.unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner());
let dm_provider = override_guard
let (dm_provider, dm_model) = override_guard
.as_ref()
.map(|dm| dm.provider.clone())
.unwrap_or_else(|| self.config.default_model.provider.clone());
.map(|dm| (dm.provider.clone(), dm.model.clone()))
.unwrap_or_else(|| {
(
self.config.default_model.provider.clone(),
self.config.default_model.model.clone(),
)
});
if !dm_provider.is_empty() && dm_provider != "default" {
set.insert(dm_provider);
}
add_model(&mut set, &dm_model);
drop(override_guard);
// Global fallback chain — respect hot-reloaded override.
@@ -5250,6 +5419,7 @@ impl OpenFangKernel {
if !fb.provider.is_empty() && fb.provider != "default" {
set.insert(fb.provider.clone());
}
add_model(&mut set, &fb.model);
}
drop(fb_override);
@@ -5264,10 +5434,109 @@ impl OpenFangKernel {
if !p.is_empty() && p != "default" {
set.insert(p.clone());
}
add_model(&mut set, &entry.manifest.model.model);
for fb in &entry.manifest.fallback_models {
if !fb.provider.is_empty() && fb.provider != "default" {
set.insert(fb.provider.clone());
}
add_model(&mut set, &fb.model);
}
}
// Channel adapters — each enabled channel may pin `overrides.model`
// to a specific model, which resolves to a concrete provider through
// the catalog. Skip when no override is set.
let ch = &self.config.channels;
let channel_overrides: [Option<&openfang_types::config::ChannelOverrides>; 43] = [
ch.telegram.as_ref().map(|c| &c.overrides),
ch.discord.as_ref().map(|c| &c.overrides),
ch.slack.as_ref().map(|c| &c.overrides),
ch.whatsapp.as_ref().map(|c| &c.overrides),
ch.signal.as_ref().map(|c| &c.overrides),
ch.matrix.as_ref().map(|c| &c.overrides),
ch.email.as_ref().map(|c| &c.overrides),
ch.teams.as_ref().map(|c| &c.overrides),
ch.mattermost.as_ref().map(|c| &c.overrides),
ch.irc.as_ref().map(|c| &c.overrides),
ch.google_chat.as_ref().map(|c| &c.overrides),
ch.twitch.as_ref().map(|c| &c.overrides),
ch.rocketchat.as_ref().map(|c| &c.overrides),
ch.zulip.as_ref().map(|c| &c.overrides),
ch.xmpp.as_ref().map(|c| &c.overrides),
ch.line.as_ref().map(|c| &c.overrides),
ch.viber.as_ref().map(|c| &c.overrides),
ch.messenger.as_ref().map(|c| &c.overrides),
ch.reddit.as_ref().map(|c| &c.overrides),
ch.mastodon.as_ref().map(|c| &c.overrides),
ch.bluesky.as_ref().map(|c| &c.overrides),
ch.feishu.as_ref().map(|c| &c.overrides),
ch.revolt.as_ref().map(|c| &c.overrides),
ch.nextcloud.as_ref().map(|c| &c.overrides),
ch.guilded.as_ref().map(|c| &c.overrides),
ch.keybase.as_ref().map(|c| &c.overrides),
ch.threema.as_ref().map(|c| &c.overrides),
ch.nostr.as_ref().map(|c| &c.overrides),
ch.webex.as_ref().map(|c| &c.overrides),
ch.pumble.as_ref().map(|c| &c.overrides),
ch.flock.as_ref().map(|c| &c.overrides),
ch.twist.as_ref().map(|c| &c.overrides),
ch.mumble.as_ref().map(|c| &c.overrides),
ch.dingtalk.as_ref().map(|c| &c.overrides),
ch.dingtalk_stream.as_ref().map(|c| &c.overrides),
ch.discourse.as_ref().map(|c| &c.overrides),
ch.gitter.as_ref().map(|c| &c.overrides),
ch.ntfy.as_ref().map(|c| &c.overrides),
ch.gotify.as_ref().map(|c| &c.overrides),
ch.webhook.as_ref().map(|c| &c.overrides),
ch.linkedin.as_ref().map(|c| &c.overrides),
ch.wecom.as_ref().map(|c| &c.overrides),
ch.mqtt.as_ref().map(|c| &c.overrides),
];
for overrides in channel_overrides.iter().flatten() {
if let Some(model) = overrides.model.as_deref() {
add_model(&mut set, model);
}
}
// Skills — bundled + user-installed. Two indirect provider hints:
// 1. Tag matching a known provider ID (e.g. tag "openai" on a
// skill that drives the OpenAI API).
// 2. A declared config variable whose `env` matches a known
// provider's `api_key_env` (e.g. env = "OPENAI_API_KEY"
// → openai).
let skill_registry = self
.skill_registry
.read()
.unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner());
for skill in skill_registry.list() {
for tag in &skill.manifest.skill.tags {
let lower = tag.to_ascii_lowercase();
if provider_ids.contains(&lower) {
set.insert(lower);
}
}
for var in skill.manifest.config.values() {
if let Some(env_name) = var.env.as_deref() {
if let Some(providers) = env_to_provider.get(&env_name.to_ascii_uppercase()) {
for provider in providers {
set.insert(provider.clone());
}
}
}
}
}
drop(skill_registry);
// MCP server configs — each entry's `env` allowlist may include a
// provider's API key env var, which is enough evidence the operator
// wired that provider into their MCP server.
for server in &self.config.mcp_servers {
for env_name in &server.env {
if let Some(providers) = env_to_provider.get(&env_name.to_ascii_uppercase()) {
for provider in providers {
set.insert(provider.clone());
}
}
}
}
@@ -6155,7 +6424,16 @@ impl OpenFangKernel {
summary.push_str(&format!("- {name}: {desc} [tools: {}]\n", tools.join(", ")));
}
}
summary.push_str("Use these skill tools when they match the user's request.");
// Issue #1038: skill directories (e.g. ~/.openfang/skills/) live OUTSIDE
// the workspace sandbox. Tell the agent to use the dedicated skill_*
// tools instead of falling back to file_read / shell_exec to inspect them.
summary.push_str(
"Use these skill tools when they match the user's request. \
To inspect a skill's full instructions, call skill_describe with the skill name \
do NOT use file_read or shell_exec on the skills directory, those paths are \
outside the agent workspace and will fail. \
Use skill_list to enumerate skills and skill_execute to run a skill's tool.",
);
summary
}
@@ -7685,6 +7963,7 @@ mod tests {
autonomous: None,
pinned_model: None,
workspace: None,
state_dir: None,
generate_identity_files: true,
exec_policy: None,
tool_allowlist: vec![],
@@ -7728,6 +8007,7 @@ mod tests {
autonomous: None,
pinned_model: None,
workspace: Some(std::path::PathBuf::from("/var/lib/openfang/agents/demo")),
state_dir: None,
generate_identity_files: true,
exec_policy: Some(ExecPolicy::default()),
tool_allowlist: vec![],
@@ -7744,8 +8024,7 @@ mod tests {
assert_eq!(merged.description, "new", "TOML edits must apply");
assert_eq!(
merged.workspace,
entry.workspace,
merged.workspace, entry.workspace,
"kernel-assigned workspace must survive a TOML edit that omits it"
);
assert!(
@@ -7779,6 +8058,7 @@ mod tests {
autonomous: None,
pinned_model: None,
workspace: Some(std::path::PathBuf::from("/old")),
state_dir: None,
generate_identity_files: true,
exec_policy: None,
tool_allowlist: vec![],
@@ -7835,6 +8115,7 @@ mod tests {
autonomous: None,
pinned_model: None,
workspace: None,
state_dir: None,
generate_identity_files: true,
exec_policy: Some(cached_policy.clone()),
tool_allowlist: vec![],
@@ -7948,6 +8229,7 @@ mod tests {
autonomous: None,
pinned_model: None,
workspace: None,
state_dir: None,
generate_identity_files: true,
exec_policy: None,
tool_allowlist: vec![],
@@ -8157,9 +8439,7 @@ mod tests {
let first_instance_id = instance.instance_id;
// Sanity: hand is Active and re-activation is rejected.
assert!(kernel
.activate_hand("lead", HashMap::new(), None)
.is_err());
assert!(kernel.activate_hand("lead", HashMap::new(), None).is_err());
// Simulate what POST /api/agents/{id}/stop now does for a hand-owned
// agent: look up the instance and deactivate the hand (which also
@@ -8599,7 +8879,8 @@ mod tests {
"fallback timeout edits must be hot-reloadable"
);
assert!(
plan.hot_actions.contains(&HotAction::ReloadFallbackProviders),
plan.hot_actions
.contains(&HotAction::ReloadFallbackProviders),
"ReloadFallbackProviders must be present in the plan"
);
@@ -8791,6 +9072,185 @@ mod tests {
kernel.shutdown();
}
// ----------------------------------------------------------------------
// Issue #1188: referenced_providers() must also walk MCP server configs,
// skill manifests, channel adapters, and catalog aliases. Otherwise the
// probe loop still spams "Local provider offline" for providers that are
// referenced indirectly. Each block below pins one new surface so a
// regression on any single surface fails its own assertion.
// ----------------------------------------------------------------------
#[test]
fn test_1188_referenced_providers_resolves_alias_to_provider() {
use openfang_types::config::DefaultModelConfig;
let tmp = tempfile::tempdir().unwrap();
let home_dir = tmp.path().join("openfang-1188-alias");
std::fs::create_dir_all(&home_dir).unwrap();
// Operator sets provider = "default" and picks the model by its
// builtin catalog alias "opus", which resolves to provider
// "anthropic". The literal provider field is "default", so the
// pre-fix walker would not have added anthropic.
let config = KernelConfig {
home_dir: home_dir.clone(),
data_dir: home_dir.join("data"),
default_model: DefaultModelConfig {
provider: "default".to_string(),
model: "opus".to_string(),
api_key_env: "ANTHROPIC_API_KEY".to_string(),
base_url: None,
subprocess_timeout_secs: None,
},
..KernelConfig::default()
};
let kernel = OpenFangKernel::boot_with_config(config).expect("kernel boots");
let referenced = kernel.referenced_providers();
assert!(
referenced.contains("anthropic"),
"alias 'opus' must resolve to anthropic ({referenced:?})"
);
kernel.shutdown();
}
#[test]
fn test_1188_referenced_providers_walks_channel_overrides() {
use openfang_types::config::{
ChannelOverrides, ChannelsConfig, DefaultModelConfig, TelegramConfig,
};
let tmp = tempfile::tempdir().unwrap();
let home_dir = tmp.path().join("openfang-1188-channel");
std::fs::create_dir_all(&home_dir).unwrap();
let overrides = ChannelOverrides {
model: Some("opus".to_string()),
..ChannelOverrides::default()
};
let config = KernelConfig {
home_dir: home_dir.clone(),
data_dir: home_dir.join("data"),
default_model: DefaultModelConfig {
provider: "groq".to_string(),
model: "llama-3.1-70b".to_string(),
api_key_env: "GROQ_API_KEY".to_string(),
base_url: None,
subprocess_timeout_secs: None,
},
channels: ChannelsConfig {
telegram: Some(TelegramConfig {
overrides,
..TelegramConfig::default()
}),
..ChannelsConfig::default()
},
..KernelConfig::default()
};
let kernel = OpenFangKernel::boot_with_config(config).expect("kernel boots");
let referenced = kernel.referenced_providers();
assert!(
referenced.contains("anthropic"),
"channel override 'opus' must pull in anthropic ({referenced:?})"
);
kernel.shutdown();
}
#[test]
fn test_1188_referenced_providers_walks_mcp_env() {
use openfang_types::config::{DefaultModelConfig, McpServerConfigEntry, McpTransportEntry};
let tmp = tempfile::tempdir().unwrap();
let home_dir = tmp.path().join("openfang-1188-mcp");
std::fs::create_dir_all(&home_dir).unwrap();
// MCP server passes through OPENAI_API_KEY, which is the api_key_env
// for the openai provider, so openai must be considered referenced.
let config = KernelConfig {
home_dir: home_dir.clone(),
data_dir: home_dir.join("data"),
default_model: DefaultModelConfig {
provider: "groq".to_string(),
model: "llama-3.1-70b".to_string(),
api_key_env: "GROQ_API_KEY".to_string(),
base_url: None,
subprocess_timeout_secs: None,
},
mcp_servers: vec![McpServerConfigEntry {
name: "openai-proxy".to_string(),
transport: McpTransportEntry::Stdio {
command: "node".to_string(),
args: vec!["proxy.js".to_string()],
},
timeout_secs: 30,
env: vec!["OPENAI_API_KEY".to_string()],
headers: vec![],
}],
..KernelConfig::default()
};
let kernel = OpenFangKernel::boot_with_config(config).expect("kernel boots");
let referenced = kernel.referenced_providers();
assert!(
referenced.contains("openai"),
"MCP env OPENAI_API_KEY must pull in openai ({referenced:?})"
);
kernel.shutdown();
}
#[test]
fn test_1188_referenced_providers_walks_skill_tags() {
use openfang_types::config::DefaultModelConfig;
let tmp = tempfile::tempdir().unwrap();
let home_dir = tmp.path().join("openfang-1188-skill");
std::fs::create_dir_all(&home_dir).unwrap();
let config = KernelConfig {
home_dir: home_dir.clone(),
data_dir: home_dir.join("data"),
default_model: DefaultModelConfig {
provider: "groq".to_string(),
model: "llama-3.1-70b".to_string(),
api_key_env: "GROQ_API_KEY".to_string(),
base_url: None,
subprocess_timeout_secs: None,
},
..KernelConfig::default()
};
let kernel = OpenFangKernel::boot_with_config(config).expect("kernel boots");
// Drop a minimal skill manifest with a tag matching a known
// provider ID, then load it through the kernel's registry.
let skill_dir = home_dir.join("skills").join("openai-helper");
std::fs::create_dir_all(&skill_dir).unwrap();
let manifest_toml = r#"
[skill]
name = "openai-helper"
version = "0.1.0"
description = "test skill"
tags = ["openai"]
[runtime]
type = "promptonly"
"#;
std::fs::write(skill_dir.join("skill.toml"), manifest_toml).unwrap();
{
let mut reg = kernel.skill_registry.write().unwrap();
reg.load_skill(&skill_dir).expect("skill loads");
}
let referenced = kernel.referenced_providers();
assert!(
referenced.contains("openai"),
"skill tag 'openai' must pull in openai ({referenced:?})"
);
kernel.shutdown();
}
// ----------------------------------------------------------------------
// Issue #1140: agents placed at ~/.openfang/agents/<name>/agent.toml
// must auto-spawn on boot so they appear in the chat tab.
@@ -8878,4 +9338,78 @@ system_prompt = "You are a test agent."
kernel2.shutdown();
}
/// Regression for #1097: when a user points an agent's workspace at an
/// existing directory like `~/Documents`, the runtime must NOT scaffold
/// private state into that directory. Identity files (SOUL.md, AGENT.json,
/// etc.) and `sessions/` / `memory/` / `logs/` must land in the agent's
/// private state directory; only the lightweight user-facing layout
/// (`data/`, `output/`, `skills/`) may appear in the workspace.
#[test]
fn test_workspace_outside_openfang_stays_clean() {
use tempfile::TempDir;
let user_workspace = TempDir::new().expect("temp user workspace");
let state_dir = TempDir::new().expect("temp state dir");
// Pre-populate the user workspace with an unrelated file to make sure
// we don't trample existing contents either.
std::fs::write(user_workspace.path().join("pre-existing.txt"), b"hello")
.expect("write pre-existing file");
let manifest = AgentManifest {
name: "ws-test".to_string(),
description: "x".to_string(),
..AgentManifest::default()
};
// Simulate the spawn path: state dir gets the private layout, user
// workspace only gets the lightweight subdirs.
ensure_state_dir(state_dir.path(), user_workspace.path()).expect("ensure_state_dir");
ensure_workspace(user_workspace.path()).expect("ensure_workspace");
generate_identity_files(state_dir.path(), &manifest);
// Private state files must live in state_dir.
for fname in &[
"AGENT.json",
"SOUL.md",
"USER.md",
"MEMORY.md",
"AGENTS.md",
"BOOTSTRAP.md",
"IDENTITY.md",
] {
assert!(
state_dir.path().join(fname).exists(),
"{fname} should be created in state_dir"
);
assert!(
!user_workspace.path().join(fname).exists(),
"{fname} must NOT pollute the user-facing workspace (issue #1097)"
);
}
for subdir in &["sessions", "memory", "logs"] {
assert!(
state_dir.path().join(subdir).is_dir(),
"{subdir}/ should be created in state_dir"
);
assert!(
!user_workspace.path().join(subdir).exists(),
"{subdir}/ must NOT pollute the user-facing workspace (issue #1097)"
);
}
// The user-facing workspace gets only the lightweight layout.
for subdir in &["data", "output", "skills"] {
assert!(
user_workspace.path().join(subdir).is_dir(),
"{subdir}/ should be created in workspace"
);
}
// The pre-existing file must still be intact.
let contents = std::fs::read_to_string(user_workspace.path().join("pre-existing.txt"))
.expect("read pre-existing");
assert_eq!(contents, "hello", "must not overwrite user files");
}
}
+6
View File
@@ -234,6 +234,12 @@ pub struct BudgetStatus {
/// Order matters: more specific patterns must come before generic ones
/// (e.g. "gpt-4o-mini" before "gpt-4o", "gpt-4.1-mini" before "gpt-4.1").
fn estimate_cost_rates(model: &str) -> (f64, f64) {
// ── Requesty (issue #995) ──────────────────────────────────
// Router-style gateway. IDs are `requesty/<upstream>/<model>` and
// resolve via substring match on the upstream model name below
// (e.g. "sonnet", "gpt-4o", "gemini", "deepseek", "llama").
// No early-return here — fall through to upstream patterns.
// ── Anthropic ──────────────────────────────────────────────
if model.contains("haiku") {
return (0.25, 1.25);
+18
View File
@@ -134,6 +134,23 @@ impl AgentRegistry {
Ok(())
}
/// Update an agent's private state directory path. The state directory
/// holds identity files, sessions, and per-agent memory and is always
/// kept separate from the user-facing workspace. See issue #1097.
pub fn update_state_dir(
&self,
id: AgentId,
state_dir: Option<std::path::PathBuf>,
) -> OpenFangResult<()> {
let mut entry = self
.agents
.get_mut(&id)
.ok_or_else(|| OpenFangError::AgentNotFound(id.to_string()))?;
entry.manifest.state_dir = state_dir;
entry.last_active = chrono::Utc::now();
Ok(())
}
/// Update an agent's visual identity (emoji, avatar, color).
pub fn update_identity(
&self,
@@ -391,6 +408,7 @@ mod tests {
autonomous: None,
pinned_model: None,
workspace: None,
state_dir: None,
generate_identity_files: true,
exec_policy: None,
tool_allowlist: vec![],
+1
View File
@@ -176,6 +176,7 @@ impl SetupWizard {
autonomous: None,
pinned_model: None,
workspace: None,
state_dir: None,
generate_identity_files: true,
profile: None,
fallback_models: vec![],
+55 -13
View File
@@ -69,8 +69,7 @@ fn env_timeout_secs(var: &str) -> Option<u64> {
fn tool_timeout_for(tool_name: &str) -> Option<Duration> {
let secs = match tool_name {
"agent_send" | "agent_spawn" => {
env_timeout_secs("OPENFANG_AGENT_TOOL_TIMEOUT_SECS")
.unwrap_or(AGENT_TOOL_TIMEOUT_SECS)
env_timeout_secs("OPENFANG_AGENT_TOOL_TIMEOUT_SECS").unwrap_or(AGENT_TOOL_TIMEOUT_SECS)
}
_ => env_timeout_secs("OPENFANG_TOOL_TIMEOUT_SECS").unwrap_or(TOOL_TIMEOUT_SECS),
};
@@ -158,22 +157,30 @@ fn build_assistant_message_preserving_thinking(
response_blocks: &[ContentBlock],
final_text: &str,
) -> Message {
let has_thinking = response_blocks
.iter()
.any(|b| matches!(b, ContentBlock::Thinking { .. }));
if !has_thinking {
// Key on either Thinking or RedactedThinking — Anthropic/Bedrock both
// reject extended-thinking history that drops the redacted variant, so a
// turn that contains only RedactedThinking must still be preserved.
let has_reasoning = response_blocks.iter().any(|b| {
matches!(
b,
ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. }
)
});
if !has_reasoning {
return Message::assistant(final_text.to_string());
}
// Preserve order: Thinking blocks first (in original order), then a
// single Text block carrying `final_text`. Tool blocks aren't expected
// here (StopReason::EndTurn path), but copy them through if present so
// we don't drop information.
// Preserve order: Thinking / RedactedThinking blocks first (in original
// order), then a single Text block carrying `final_text`. Tool blocks
// aren't expected here (StopReason::EndTurn path), but copy them through
// if present so we don't drop information.
let mut blocks: Vec<ContentBlock> = Vec::with_capacity(response_blocks.len() + 1);
let mut emitted_text = false;
for b in response_blocks {
match b {
ContentBlock::Thinking { .. } => blocks.push(b.clone()),
ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. } => {
blocks.push(b.clone())
}
ContentBlock::Text { .. } if !emitted_text => {
blocks.push(ContentBlock::Text {
text: final_text.to_string(),
@@ -3263,7 +3270,9 @@ mod tests {
assert_eq!(blocks.len(), 2, "must preserve thinking + text");
match &blocks[0] {
ContentBlock::Thinking {
thinking, signature, ..
thinking,
signature,
..
} => {
assert_eq!(thinking, "Let me reason carefully...");
assert_eq!(signature.as_deref(), Some("sig_anthropic_xyz"));
@@ -3359,7 +3368,10 @@ mod tests {
let has_redacted = blocks
.iter()
.any(|b| matches!(b, ContentBlock::RedactedThinking { .. }));
assert!(has_thinking, "Thinking block must be preserved on MaxTokens");
assert!(
has_thinking,
"Thinking block must be preserved on MaxTokens"
);
assert!(
has_redacted,
"RedactedThinking block must be preserved on MaxTokens"
@@ -3380,6 +3392,36 @@ mod tests {
assert_eq!(saved_text, Some(final_text));
}
/// Issue #1187 — a turn that contains only `RedactedThinking` (no
/// `Thinking` block) must still trigger the block-preserving path. The
/// previous gate keyed solely on `Thinking`, so redacted-only turns were
/// downgraded to plain text and the encrypted blob was lost on the next
/// request, which Anthropic/Bedrock reject.
#[test]
fn test_build_assistant_message_preserves_redacted_only() {
let response_blocks = vec![
ContentBlock::RedactedThinking {
data: "encrypted_only".to_string(),
},
ContentBlock::Text {
text: "Answer".to_string(),
provider_metadata: None,
},
];
let msg = build_assistant_message_preserving_thinking(&response_blocks, "Answer");
let blocks = match &msg.content {
MessageContent::Blocks(b) => b,
other => panic!("expected Blocks content for redacted-only turn, got {other:?}"),
};
let has_redacted = blocks.iter().any(
|b| matches!(b, ContentBlock::RedactedThinking { data } if data == "encrypted_only"),
);
assert!(
has_redacted,
"RedactedThinking-only turn must be preserved as Blocks"
);
}
#[test]
fn test_retry_constants() {
assert_eq!(MAX_RETRIES, 3);
@@ -189,7 +189,9 @@ enum ContentBlockAccum {
/// Redacted (encrypted) thinking block streamed from Anthropic.
/// The opaque `data` blob arrives on `content_block_start` and must be
/// persisted so the next turn can echo it back verbatim.
RedactedThinking { data: String },
RedactedThinking {
data: String,
},
}
#[async_trait]
@@ -454,10 +456,8 @@ impl LlmDriver for AnthropicDriver {
"thinking" => {
// Some API versions ship the signature on
// content_block_start instead of as a delta.
let initial_sig = block["signature"]
.as_str()
.unwrap_or("")
.to_string();
let initial_sig =
block["signature"].as_str().unwrap_or("").to_string();
blocks.push(ContentBlockAccum::Thinking {
thinking: String::new(),
signature: initial_sig,
@@ -470,10 +470,7 @@ impl LlmDriver for AnthropicDriver {
// verbatim so we can echo it back on the
// next request — API rejects history
// that strips redacted_thinking blocks.
let data = block["data"]
.as_str()
.unwrap_or("")
.to_string();
let data = block["data"].as_str().unwrap_or("").to_string();
blocks.push(ContentBlockAccum::RedactedThinking { data });
}
_ => {}
@@ -751,9 +748,7 @@ fn convert_message(msg: &Message) -> ApiMessage {
if data.is_empty() {
None
} else {
Some(ApiContentBlock::RedactedThinking {
data: data.clone(),
})
Some(ApiContentBlock::RedactedThinking { data: data.clone() })
}
}
ContentBlock::Unknown => None,
+77 -5
View File
@@ -92,6 +92,20 @@ enum BedrockContentBlock {
#[serde(rename = "toolResult")]
tool_result: BedrockToolResult,
},
// Bedrock Converse representation of Anthropic's `redacted_thinking`.
// The encrypted blob is echoed back verbatim under
// reasoningContent.redactedContent so Claude extended-thinking history
// is not rejected on resubmission.
ReasoningContent {
#[serde(rename = "reasoningContent")]
reasoning_content: BedrockReasoningContent,
},
}
#[derive(Debug, Serialize)]
struct BedrockReasoningContent {
#[serde(rename = "redactedContent")]
redacted_content: String,
}
#[derive(Debug, Serialize)]
@@ -299,11 +313,22 @@ fn convert_content_block(block: &ContentBlock) -> Option<BedrockContentBlock> {
},
},
}),
// Image, Thinking, RedactedThinking, and Unknown are not supported — silently drop
ContentBlock::Image { .. }
| ContentBlock::Thinking { .. }
| ContentBlock::RedactedThinking { .. }
| ContentBlock::Unknown => None,
// Echo redacted_thinking verbatim. Bedrock Converse rejects history
// that drops these blocks on Claude extended-thinking models, mirroring
// the anthropic.rs path. Drop empty blobs (e.g. interrupted stream).
ContentBlock::RedactedThinking { data } => {
if data.is_empty() {
None
} else {
Some(BedrockContentBlock::ReasoningContent {
reasoning_content: BedrockReasoningContent {
redacted_content: data.clone(),
},
})
}
}
// Image, Thinking, and Unknown are not supported — silently drop
ContentBlock::Image { .. } | ContentBlock::Thinking { .. } | ContentBlock::Unknown => None,
}
}
@@ -1126,6 +1151,53 @@ mod tests {
assert!(text_at_3 >= 1);
}
/// Issue #1187 — Bedrock Converse history must preserve
/// `redacted_thinking` blocks on Claude extended-thinking models.
/// A message containing only RedactedThinking must round-trip through
/// `convert_content_block` without being silently dropped, and the wire
/// format must use `reasoningContent.redactedContent`.
#[test]
fn test_bedrock_redacted_thinking_round_trip() {
let msg = Message::assistant_with_blocks(vec![ContentBlock::RedactedThinking {
data: "encrypted-blob-abc123".to_string(),
}]);
let bedrock_blocks = convert_message_content(&msg.content);
assert_eq!(
bedrock_blocks.len(),
1,
"RedactedThinking must survive convert_content_block"
);
match &bedrock_blocks[0] {
BedrockContentBlock::ReasoningContent { reasoning_content } => {
assert_eq!(reasoning_content.redacted_content, "encrypted-blob-abc123");
}
other => panic!("expected ReasoningContent block, got {other:?}"),
}
// Wire format check: serialized JSON must carry
// reasoningContent.redactedContent so Bedrock accepts the history.
let json = serde_json::to_value(&bedrock_blocks[0]).unwrap();
assert_eq!(
json["reasoningContent"]["redactedContent"],
"encrypted-blob-abc123"
);
}
/// Empty RedactedThinking blobs (interrupted stream) must be dropped on
/// outbound, matching the anthropic.rs behavior.
#[test]
fn test_bedrock_redacted_thinking_empty_dropped() {
let msg = Message::assistant_with_blocks(vec![ContentBlock::RedactedThinking {
data: String::new(),
}]);
let bedrock_blocks = convert_message_content(&msg.content);
assert!(
bedrock_blocks.is_empty(),
"empty redacted_thinking must be dropped"
);
}
#[test]
fn test_validate_tool_pairing_noop_on_correct() {
// already correct 2-for-2 → no change
+193 -4
View File
@@ -21,9 +21,9 @@ use openfang_types::model_catalog::{
HUGGINGFACE_BASE_URL, KIMI_CODING_BASE_URL, LEMONADE_BASE_URL, LMSTUDIO_BASE_URL,
MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, NOVITA_BASE_URL, NVIDIA_NIM_BASE_URL,
OLLAMA_BASE_URL, OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL,
QWEN_BASE_URL, REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL,
VLLM_BASE_URL, VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL,
ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
QWEN_BASE_URL, REPLICATE_BASE_URL, REQUESTY_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL,
VENICE_BASE_URL, VLLM_BASE_URL, VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL,
ZAI_BASE_URL, ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
};
use std::sync::Arc;
@@ -35,6 +35,64 @@ struct ProviderDefaults {
key_required: bool,
}
/// Resolve an OpenAI-compatible base URL for a local/self-hosted provider from
/// well-known environment variables. Returns `None` if no override is set.
///
/// This lets users point Ollama / LM Studio / vLLM / Lemonade at a remote host
/// (VPS, LXC, another box on the LAN) without editing `~/.openfang/config.toml`.
///
/// Recognised variables:
/// - `ollama` → `OLLAMA_BASE_URL`, then `OLLAMA_HOST` (Ollama CLI convention)
/// - `lmstudio` → `LMSTUDIO_BASE_URL`, then `LMSTUDIO_HOST`
/// - `vllm` → `VLLM_BASE_URL`, then `VLLM_HOST`
/// - `lemonade` → `LEMONADE_BASE_URL`, then `LEMONADE_HOST`
///
/// `*_HOST` values may omit the scheme and the `/v1` suffix
/// (e.g. `OLLAMA_HOST=192.168.1.50:11434`); both are normalised.
pub fn local_provider_url_from_env(provider: &str) -> Option<String> {
fn read(var: &str) -> Option<String> {
std::env::var(var)
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
}
/// Normalise a host-style value into a full OpenAI-compatible base URL.
/// - Adds `http://` if no scheme is present.
/// - Appends `/v1` if not already present in the path.
fn normalize(raw: &str) -> String {
let mut url = if raw.contains("://") {
raw.trim_end_matches('/').to_string()
} else {
format!("http://{}", raw.trim_end_matches('/'))
};
// Add /v1 suffix if missing (OpenAI-compatible endpoints expect it).
// Be lenient: accept either `/v1` or `/v1/` already in place, and also
// `/openai/v1` style proxies.
let lower = url.to_lowercase();
if !lower.ends_with("/v1") && !lower.contains("/v1/") {
url.push_str("/v1");
}
url
}
let (primary, host_fallback) = match provider {
"ollama" => ("OLLAMA_BASE_URL", "OLLAMA_HOST"),
"lmstudio" => ("LMSTUDIO_BASE_URL", "LMSTUDIO_HOST"),
"vllm" => ("VLLM_BASE_URL", "VLLM_HOST"),
"lemonade" => ("LEMONADE_BASE_URL", "LEMONADE_HOST"),
_ => return None,
};
if let Some(v) = read(primary) {
return Some(normalize(&v));
}
if let Some(v) = read(host_fallback) {
return Some(normalize(&v));
}
None
}
/// Get defaults for known providers.
fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
match provider {
@@ -48,6 +106,11 @@ fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
api_key_env: "OPENROUTER_API_KEY",
key_required: true,
}),
"requesty" => Some(ProviderDefaults {
base_url: REQUESTY_BASE_URL,
api_key_env: "REQUESTY_API_KEY",
key_required: true,
}),
"deepseek" => Some(ProviderDefaults {
base_url: DEEPSEEK_BASE_URL,
api_key_env: "DEEPSEEK_API_KEY",
@@ -473,9 +536,14 @@ pub fn create_driver(config: &DriverConfig) -> Result<Arc<dyn LlmDriver>, LlmErr
)));
}
// Precedence for the base URL:
// 1. Explicit `DriverConfig.base_url` (from config.toml or `[provider_urls]`)
// 2. Well-known env vars for local providers (`OLLAMA_HOST`, etc.) — issue #1154
// 3. Hard-coded provider default (localhost for ollama/lmstudio/vllm/lemonade)
let base_url = config
.base_url
.clone()
.or_else(|| local_provider_url_from_env(provider))
.unwrap_or_else(|| defaults.base_url.to_string());
return Ok(Arc::new(openai::OpenAIDriver::new(api_key, base_url)));
@@ -629,13 +697,24 @@ pub fn known_providers() -> &'static [&'static str] {
]
}
/// Cross-module env-var serialisation lock for tests that mutate process env.
///
/// Several tests in this crate (drivers, model_catalog) set/unset the same
/// `OLLAMA_*` / `LMSTUDIO_*` env vars and would race under cargo's parallel
/// test runner. Anything that mutates those vars must hold this lock.
#[cfg(test)]
pub(crate) fn env_lock_for_tests() -> &'static std::sync::Mutex<()> {
use std::ops::Deref;
tests::ENV_LOCK.deref()
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsString;
use std::sync::{LazyLock, Mutex};
static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
pub(super) static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
struct EnvVarGuard {
key: &'static str,
@@ -1118,4 +1197,114 @@ mod tests {
"unparseable env override should fall through to config field"
);
}
// ── Issue #1154: env-var URL overrides for local providers ──
#[test]
fn test_local_url_env_ollama_host_normalised() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _g1 = EnvVarGuard::remove("OLLAMA_BASE_URL");
let _g2 = EnvVarGuard::set("OLLAMA_HOST", "192.168.1.50:11434");
let url = local_provider_url_from_env("ollama").expect("env should resolve");
assert_eq!(url, "http://192.168.1.50:11434/v1");
}
#[test]
fn test_local_url_env_ollama_base_url_wins() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _g1 = EnvVarGuard::set("OLLAMA_BASE_URL", "https://llm.example.com/v1");
let _g2 = EnvVarGuard::set("OLLAMA_HOST", "should-be-ignored:11434");
let url = local_provider_url_from_env("ollama").expect("env should resolve");
assert_eq!(url, "https://llm.example.com/v1");
}
#[test]
fn test_local_url_env_lmstudio() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _g1 = EnvVarGuard::remove("LMSTUDIO_BASE_URL");
let _g2 = EnvVarGuard::set("LMSTUDIO_HOST", "http://10.0.0.5:1234");
let url = local_provider_url_from_env("lmstudio").expect("env should resolve");
assert_eq!(url, "http://10.0.0.5:1234/v1");
}
#[test]
fn test_local_url_env_vllm() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _g1 = EnvVarGuard::remove("VLLM_BASE_URL");
let _g2 = EnvVarGuard::set("VLLM_HOST", "vps.internal:8000");
let url = local_provider_url_from_env("vllm").expect("env should resolve");
assert_eq!(url, "http://vps.internal:8000/v1");
}
#[test]
fn test_local_url_env_unset_returns_none() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _g1 = EnvVarGuard::remove("OLLAMA_BASE_URL");
let _g2 = EnvVarGuard::remove("OLLAMA_HOST");
assert!(local_provider_url_from_env("ollama").is_none());
}
#[test]
fn test_local_url_env_only_for_local_providers() {
// Cloud providers should never resolve via these helpers — they have
// their own *_API_KEY conventions and a fixed cloud base URL.
assert!(local_provider_url_from_env("openai").is_none());
assert!(local_provider_url_from_env("anthropic").is_none());
assert!(local_provider_url_from_env("groq").is_none());
}
#[test]
fn test_local_url_env_preserves_existing_v1_suffix() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _g1 = EnvVarGuard::set("OLLAMA_BASE_URL", "http://1.2.3.4:11434/v1");
let _g2 = EnvVarGuard::remove("OLLAMA_HOST");
let url = local_provider_url_from_env("ollama").expect("env should resolve");
assert_eq!(url, "http://1.2.3.4:11434/v1");
}
#[test]
fn test_create_driver_ollama_uses_env_host() {
// End-to-end: when no explicit base_url and no OLLAMA_API_KEY, the
// driver should be constructed pointed at the env-supplied host.
// (We can't introspect the OpenAIDriver's base_url directly, but
// construction succeeds — separate unit covers URL resolution.)
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _g1 = EnvVarGuard::remove("OLLAMA_BASE_URL");
let _g2 = EnvVarGuard::set("OLLAMA_HOST", "10.20.30.40:11434");
let _g3 = EnvVarGuard::remove("OLLAMA_API_KEY");
let config = DriverConfig {
provider: "ollama".to_string(),
api_key: None,
base_url: None,
skip_permissions: true,
subprocess_timeout_secs: None,
};
let driver = create_driver(&config);
assert!(
driver.is_ok(),
"ollama with OLLAMA_HOST set and no API key should construct: {:?}",
driver.err()
);
}
#[test]
fn test_create_driver_lmstudio_no_key_no_env_still_works() {
// Pre-#1154 regression guard: lmstudio with no env vars and no API key
// should still construct (falls back to localhost default).
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _g1 = EnvVarGuard::remove("LMSTUDIO_BASE_URL");
let _g2 = EnvVarGuard::remove("LMSTUDIO_HOST");
let _g3 = EnvVarGuard::remove("LMSTUDIO_API_KEY");
let config = DriverConfig {
provider: "lmstudio".to_string(),
api_key: None,
base_url: None,
skip_permissions: true,
subprocess_timeout_secs: None,
};
let driver = create_driver(&config);
assert!(driver.is_ok(), "lmstudio default should construct");
}
}
+40 -44
View File
@@ -460,8 +460,8 @@ impl LlmDriver for OpenAIDriver {
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
reasoning: None,
});
reasoning: None,
});
}
// Convert messages
@@ -474,8 +474,8 @@ impl LlmDriver for OpenAIDriver {
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
reasoning: None,
});
reasoning: None,
});
}
(Role::User, MessageContent::Text(text)) => {
oai_messages.push(OaiMessage {
@@ -484,8 +484,8 @@ impl LlmDriver for OpenAIDriver {
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
reasoning: None,
});
reasoning: None,
});
}
(Role::Assistant, MessageContent::Text(text)) => {
oai_messages.push(OaiMessage {
@@ -494,8 +494,8 @@ impl LlmDriver for OpenAIDriver {
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
reasoning: None,
});
reasoning: None,
});
}
(Role::User, MessageContent::Blocks(blocks)) => {
// Handle tool results and images in user messages
@@ -519,8 +519,8 @@ impl LlmDriver for OpenAIDriver {
tool_calls: None,
tool_call_id: Some(tool_use_id.clone()),
reasoning_content: None,
reasoning: None,
});
reasoning: None,
});
}
ContentBlock::Text { text, .. } => {
parts.push(OaiContentPart::Text { text: text.clone() });
@@ -543,8 +543,8 @@ impl LlmDriver for OpenAIDriver {
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
reasoning: None,
});
reasoning: None,
});
}
}
(Role::Assistant, MessageContent::Blocks(blocks)) => {
@@ -915,8 +915,8 @@ impl LlmDriver for OpenAIDriver {
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
reasoning: None,
});
reasoning: None,
});
}
for msg in &request.messages {
@@ -928,8 +928,8 @@ impl LlmDriver for OpenAIDriver {
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
reasoning: None,
});
reasoning: None,
});
}
(Role::User, MessageContent::Text(text)) => {
oai_messages.push(OaiMessage {
@@ -938,8 +938,8 @@ impl LlmDriver for OpenAIDriver {
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
reasoning: None,
});
reasoning: None,
});
}
(Role::Assistant, MessageContent::Text(text)) => {
oai_messages.push(OaiMessage {
@@ -948,8 +948,8 @@ impl LlmDriver for OpenAIDriver {
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
reasoning: None,
});
reasoning: None,
});
}
(Role::User, MessageContent::Blocks(blocks)) => {
for block in blocks {
@@ -969,8 +969,8 @@ impl LlmDriver for OpenAIDriver {
tool_calls: None,
tool_call_id: Some(tool_use_id.clone()),
reasoning_content: None,
reasoning: None,
});
reasoning: None,
});
}
}
}
@@ -1492,7 +1492,9 @@ impl LlmDriver for OpenAIDriver {
// non-zero output_tokens so the agent loop doesn't misclassify
// this as a "silent failure" and loop unnecessarily.
if !content.is_empty() && usage.input_tokens == 0 && usage.output_tokens == 0 {
debug!("Stream has content but no usage stats — setting synthetic output_tokens=1");
debug!(
"Stream has content but no usage stats — setting synthetic output_tokens=1"
);
usage.output_tokens = 1;
}
@@ -1956,10 +1958,7 @@ mod tests {
/// the upstream server is pre- or post-vLLM 0.19.
#[test]
fn test_assemble_emits_both_reasoning_fields_for_vllm_compat() {
let driver = OpenAIDriver::new(
"test".to_string(),
"http://localhost:8000/v1".to_string(),
);
let driver = OpenAIDriver::new("test".to_string(), "http://localhost:8000/v1".to_string());
let blocks = vec![
ContentBlock::Thinking {
thinking: "MARKER-vllm-019".to_string(),
@@ -1994,8 +1993,7 @@ mod tests {
/// change in #1157.
#[test]
fn test_assemble_no_reasoning_fields_for_plain_model() {
let driver =
OpenAIDriver::new("test".to_string(), "https://api.openai.com/v1".to_string());
let driver = OpenAIDriver::new("test".to_string(), "https://api.openai.com/v1".to_string());
let blocks = vec![ContentBlock::Text {
text: "hi".to_string(),
provider_metadata: None,
@@ -2014,18 +2012,14 @@ mod tests {
/// understand the new name.
#[test]
fn test_assemble_moonshot_keeps_legacy_field_only() {
let driver = OpenAIDriver::new(
"test".to_string(),
"https://api.moonshot.cn/v1".to_string(),
);
let blocks = vec![
ContentBlock::ToolUse {
id: "call_1".to_string(),
name: "search".to_string(),
input: serde_json::json!({"q": "x"}),
provider_metadata: None,
},
];
let driver =
OpenAIDriver::new("test".to_string(), "https://api.moonshot.cn/v1".to_string());
let blocks = vec![ContentBlock::ToolUse {
id: "call_1".to_string(),
name: "search".to_string(),
input: serde_json::json!({"q": "x"}),
provider_metadata: None,
}];
let msg = assemble_assistant_message(&blocks, "kimi-k2", &driver);
assert_eq!(
msg.reasoning_content.as_deref(),
@@ -2174,7 +2168,10 @@ mod tests {
Some(OaiMessageContent::Text(t)) => t,
_ => panic!("expected text content"),
};
assert_eq!(content, "answer", "visible content must not include <think>");
assert_eq!(
content, "answer",
"visible content must not include <think>"
);
assert_eq!(
msg.reasoning_content.as_deref(),
Some("internal chain-of-thought"),
@@ -2186,8 +2183,7 @@ mod tests {
/// assistant message — preserve the legacy shape.
#[test]
fn test_assemble_assistant_no_thinking_is_plain() {
let driver =
OpenAIDriver::new("test".to_string(), "https://api.openai.com/v1".to_string());
let driver = OpenAIDriver::new("test".to_string(), "https://api.openai.com/v1".to_string());
let blocks = vec![ContentBlock::Text {
text: "Hi.".to_string(),
provider_metadata: None,
@@ -258,7 +258,6 @@ fn host_net_fetch(state: &GuestState, params: &serde_json::Value) -> serde_json:
})
}
// ---------------------------------------------------------------------------
// Shell (capability-checked)
// ---------------------------------------------------------------------------
@@ -561,7 +560,10 @@ mod tests {
assert!(web_fetch::check_ssrf("http://127.0.0.1:8080/secret", &no_allow).is_err());
assert!(web_fetch::check_ssrf("http://localhost:3000/api", &no_allow).is_err());
assert!(web_fetch::check_ssrf("http://169.254.169.254/metadata", &no_allow).is_err());
assert!(web_fetch::check_ssrf("http://metadata.google.internal/v1/instance", &no_allow).is_err());
assert!(
web_fetch::check_ssrf("http://metadata.google.internal/v1/instance", &no_allow)
.is_err()
);
// These were previously missing from host_functions — now covered:
assert!(web_fetch::check_ssrf("http://[::1]:8080/secret", &no_allow).is_err());
assert!(web_fetch::check_ssrf("http://100.100.100.200/metadata", &no_allow).is_err());
+174 -13
View File
@@ -10,8 +10,8 @@ use openfang_types::model_catalog::{
HUGGINGFACE_BASE_URL, KIMI_CODING_BASE_URL, LEMONADE_BASE_URL, LMSTUDIO_BASE_URL,
MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, NVIDIA_NIM_BASE_URL, OLLAMA_BASE_URL,
OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL,
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL,
VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL,
REPLICATE_BASE_URL, REQUESTY_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL,
VLLM_BASE_URL, VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL,
ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
};
use std::collections::HashMap;
@@ -339,6 +339,27 @@ impl ModelCatalog {
}
}
/// Apply environment-variable URL overrides for local providers.
///
/// Honours the same env vars the drivers respect (see
/// `drivers::local_provider_url_from_env`): `OLLAMA_HOST` / `OLLAMA_BASE_URL`,
/// `LMSTUDIO_HOST` / `LMSTUDIO_BASE_URL`, `VLLM_HOST` / `VLLM_BASE_URL`,
/// `LEMONADE_HOST` / `LEMONADE_BASE_URL`. This keeps the dashboard's
/// "Providers" view in sync with what the driver actually connects to,
/// without requiring users to edit `config.toml` for remote local-LLM hosts
/// (VPS, LXC, LAN). See issue #1154.
pub fn apply_local_env_overrides(&mut self) {
for provider in ["ollama", "lmstudio", "vllm", "lemonade"] {
if let Some(url) = crate::drivers::local_provider_url_from_env(provider) {
if let Some(p) = self.providers.iter_mut().find(|p| p.id == provider) {
p.base_url = url;
// A custom host indicates intentional setup, surface it as configured.
p.auth_status = AuthStatus::Configured;
}
}
}
}
/// Apply a batch of provider URL overrides from config.
///
/// Each entry maps a provider ID to a custom base URL.
@@ -604,6 +625,15 @@ fn builtin_providers() -> Vec<ProviderInfo> {
auth_status: AuthStatus::Missing,
model_count: 0,
},
ProviderInfo {
id: "requesty".into(),
display_name: "Requesty".into(),
api_key_env: "REQUESTY_API_KEY".into(),
base_url: REQUESTY_BASE_URL.into(),
key_required: true,
auth_status: AuthStatus::Missing,
model_count: 0,
},
ProviderInfo {
id: "mistral".into(),
display_name: "Mistral AI".into(),
@@ -1022,10 +1052,7 @@ fn builtin_aliases() -> HashMap<String, String> {
),
("free", "openrouter/meta-llama/llama-3.3-70b-instruct:free"),
("free-reasoning", "openrouter/deepseek/deepseek-r1:free"),
(
"openrouter/free-coder",
"openrouter/qwen/qwen3-coder:free",
),
("openrouter/free-coder", "openrouter/qwen/qwen3-coder:free"),
(
"openrouter/free-large",
"openrouter/openai/gpt-oss-120b:free",
@@ -2053,6 +2080,80 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
aliases: vec!["hunter-alpha".into()],
},
// ══════════════════════════════════════════════════════════════
// Requesty (5) — router-style OpenAI-compatible gateway (issue #995)
// Hundreds of upstream models accessible via https://router.requesty.ai/v1
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "requesty/anthropic/claude-sonnet-4".into(),
display_name: "Claude Sonnet 4 (Requesty)".into(),
provider: "requesty".into(),
tier: ModelTier::Smart,
context_window: 200_000,
max_output_tokens: 64_000,
input_cost_per_m: 3.0,
output_cost_per_m: 15.0,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "requesty/openai/gpt-4o".into(),
display_name: "GPT-4o (Requesty)".into(),
provider: "requesty".into(),
tier: ModelTier::Smart,
context_window: 128_000,
max_output_tokens: 16_384,
input_cost_per_m: 2.5,
output_cost_per_m: 10.0,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "requesty/google/gemini-2.5-flash".into(),
display_name: "Gemini 2.5 Flash (Requesty)".into(),
provider: "requesty".into(),
tier: ModelTier::Smart,
context_window: 1_048_576,
max_output_tokens: 65_536,
input_cost_per_m: 0.15,
output_cost_per_m: 0.60,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "requesty/deepseek/deepseek-chat".into(),
display_name: "DeepSeek V3 (Requesty)".into(),
provider: "requesty".into(),
tier: ModelTier::Smart,
context_window: 128_000,
max_output_tokens: 32_768,
input_cost_per_m: 0.14,
output_cost_per_m: 0.28,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "requesty/meta-llama/llama-3.3-70b-instruct".into(),
display_name: "Llama 3.3 70B (Requesty)".into(),
provider: "requesty".into(),
tier: ModelTier::Balanced,
context_window: 128_000,
max_output_tokens: 32_768,
input_cost_per_m: 0.39,
output_cost_per_m: 0.39,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
// ══════════════════════════════════════════════════════════════
// Mistral (6)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
@@ -3982,7 +4083,7 @@ mod tests {
#[test]
fn test_catalog_has_providers() {
let catalog = ModelCatalog::new();
assert_eq!(catalog.list_providers().len(), 41);
assert_eq!(catalog.list_providers().len(), 42);
}
#[test]
@@ -4611,9 +4712,9 @@ mod tests {
#[test]
fn test_openrouter_free_alias_supports_tools() {
let catalog = ModelCatalog::new();
let entry = catalog.find_model("openrouter/free").expect(
"openrouter/free alias must resolve to a known model",
);
let entry = catalog
.find_model("openrouter/free")
.expect("openrouter/free alias must resolve to a known model");
assert_eq!(entry.provider, "openrouter");
assert!(
entry.supports_tools,
@@ -4626,9 +4727,7 @@ mod tests {
#[test]
fn test_openrouter_free_short_alias_supports_tools() {
let catalog = ModelCatalog::new();
let entry = catalog
.find_model("free")
.expect("free alias must resolve");
let entry = catalog.find_model("free").expect("free alias must resolve");
assert_eq!(entry.provider, "openrouter");
assert!(
entry.supports_tools,
@@ -4702,4 +4801,66 @@ mod tests {
"qwen-2.5-7b-instruct:free has no tool-supporting free endpoint"
);
}
// ── Requesty provider (issue #995) ────────────────────────────────────
/// Requesty must be registered as a provider with the correct base URL
/// and env var, and at least one of its catalog models must resolve.
#[test]
fn test_requesty_provider_and_models_present() {
let catalog = ModelCatalog::new();
let provider = catalog
.list_providers()
.iter()
.find(|p| p.id == "requesty")
.expect("requesty provider must be registered");
assert_eq!(provider.display_name, "Requesty");
assert_eq!(provider.api_key_env, "REQUESTY_API_KEY");
assert_eq!(provider.base_url, "https://router.requesty.ai/v1");
assert!(provider.key_required);
assert!(
provider.model_count >= 1,
"requesty must have at least one model in catalog"
);
let entry = catalog
.find_model("requesty/anthropic/claude-sonnet-4")
.expect("requesty/anthropic/claude-sonnet-4 must resolve");
assert_eq!(entry.provider, "requesty");
assert!(entry.supports_tools);
}
// ── Issue #1154: env-var overrides for local provider URLs ──
/// Local guard so this catalog test doesn't clash with the driver tests
/// that touch the same env vars. We acquire the cross-module lock from
/// the drivers module to serialise.
#[test]
fn test_apply_local_env_overrides_ollama() {
// Serialise with driver-side env tests that touch OLLAMA_*.
let _lock = crate::drivers::env_lock_for_tests()
.lock()
.unwrap_or_else(|e| e.into_inner());
let prev_base = std::env::var_os("OLLAMA_BASE_URL");
let prev_host = std::env::var_os("OLLAMA_HOST");
std::env::remove_var("OLLAMA_BASE_URL");
std::env::set_var("OLLAMA_HOST", "172.16.0.10:11434");
let mut catalog = ModelCatalog::new();
catalog.apply_local_env_overrides();
let ollama = catalog.get_provider("ollama").unwrap();
assert_eq!(ollama.base_url, "http://172.16.0.10:11434/v1");
assert_eq!(ollama.auth_status, AuthStatus::Configured);
// Restore env
if let Some(v) = prev_base {
std::env::set_var("OLLAMA_BASE_URL", v);
}
if let Some(v) = prev_host {
std::env::set_var("OLLAMA_HOST", v);
} else {
std::env::remove_var("OLLAMA_HOST");
}
}
}
+288 -32
View File
@@ -478,6 +478,11 @@ pub async fn execute_tool(
}
},
// Skill introspection tools (issue #1038)
"skill_list" => tool_skill_list(skill_registry),
"skill_describe" => tool_skill_describe(input, skill_registry),
"skill_execute" => tool_skill_execute(input, skill_registry).await,
// Canvas / A2UI tool
"canvas_present" => tool_canvas_present(input, workspace_root).await,
@@ -1308,6 +1313,42 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
"required": ["html"]
}),
},
// --- Skill introspection tools (issue #1038) ---
// These let the agent discover and read installed skills without
// touching the filesystem. Global skills live at ~/.openfang/skills/
// which is outside the workspace sandbox — file_read cannot reach them.
ToolDefinition {
name: "skill_list".to_string(),
description: "List all installed skills available to this agent. Returns name, version, description, runtime type, and provided tool names. Use this instead of file_list on the skills directory.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {}
}),
},
ToolDefinition {
name: "skill_describe".to_string(),
description: "Read the full description (SKILL.md body / prompt context) of an installed skill by name. Use this instead of file_read on a skill's SKILL.md file — global skills live outside the workspace sandbox and cannot be read with file_read.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"name": { "type": "string", "description": "The skill name (as returned by skill_list)" }
},
"required": ["name"]
}),
},
ToolDefinition {
name: "skill_execute".to_string(),
description: "Execute a tool provided by an installed skill. For code-runtime skills (Python/Node/Shell) this invokes the underlying script. For prompt-only skills this returns the skill's instruction body so the agent can follow it using built-in tools.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"skill": { "type": "string", "description": "The skill name (as returned by skill_list)" },
"tool": { "type": "string", "description": "Optional name of a tool the skill provides. Omit to invoke the skill's default behavior (returns SKILL.md body for prompt-only skills)." },
"input": { "type": "object", "description": "Optional JSON input for the skill tool" }
},
"required": ["skill"]
}),
},
]
}
@@ -3078,8 +3119,7 @@ async fn tool_image_generate(
// Closes #1051: route to a local OpenAI-compatible image generation
// service when `media.image_gen_base_url` is set.
let base_url_override = media_engine
.and_then(|e| e.config().image_gen_base_url.as_deref());
let base_url_override = media_engine.and_then(|e| e.config().image_gen_base_url.as_deref());
let result = crate::image_gen::generate_image(&request, base_url_override).await?;
// Save images to workspace if available
@@ -3537,6 +3577,165 @@ async fn tool_canvas_present(
serde_json::to_string_pretty(&response).map_err(|e| format!("Serialize error: {e}"))
}
// ---------------------------------------------------------------------------
// Skill introspection tools (issue #1038)
//
// Global skills live at ~/.openfang/skills/ which is outside the agent
// workspace sandbox. Without these tools the LLM falls back to file_read /
// shell_exec to inspect SKILL.md files — which fail with path-resolution
// errors because file_read is workspace-scoped. These tools surface the
// already-loaded skill registry directly to the agent.
// ---------------------------------------------------------------------------
/// List all skills available to this agent, with their provided tool names.
fn tool_skill_list(skill_registry: Option<&SkillRegistry>) -> Result<String, String> {
let registry = match skill_registry {
Some(r) => r,
None => return Ok("No skill registry available.".to_string()),
};
let skills = registry.list();
if skills.is_empty() {
return Ok("No skills installed. Install skills via the dashboard or `openfang skill install <name>`.".to_string());
}
let entries: Vec<serde_json::Value> = skills
.iter()
.map(|s| {
let tool_names: Vec<String> = s
.manifest
.tools
.provided
.iter()
.map(|t| t.name.clone())
.collect();
serde_json::json!({
"name": s.manifest.skill.name,
"version": s.manifest.skill.version,
"description": s.manifest.skill.description,
"runtime": format!("{:?}", s.manifest.runtime.runtime_type),
"enabled": s.enabled,
"tools": tool_names,
"has_prompt_context": s.manifest.prompt_context.as_ref().is_some_and(|c| !c.is_empty()),
})
})
.collect();
serde_json::to_string_pretty(&serde_json::json!({
"count": entries.len(),
"skills": entries,
}))
.map_err(|e| format!("Serialize error: {e}"))
}
/// Return the full description (SKILL.md body) of a named skill.
fn tool_skill_describe(
input: &serde_json::Value,
skill_registry: Option<&SkillRegistry>,
) -> Result<String, String> {
let name = input["name"]
.as_str()
.ok_or("Missing 'name' parameter")?
.trim();
let registry = skill_registry.ok_or("No skill registry available")?;
let skill = registry.get(name).ok_or_else(|| {
format!("Skill '{name}' not found. Use skill_list to see installed skills.")
})?;
let body = skill
.manifest
.prompt_context
.clone()
.unwrap_or_else(|| "(No prompt context body — this skill provides executable tools only. Use skill_execute or call its tools directly.)".to_string());
let tool_names: Vec<String> = skill
.manifest
.tools
.provided
.iter()
.map(|t| t.name.clone())
.collect();
let response = serde_json::json!({
"name": skill.manifest.skill.name,
"version": skill.manifest.skill.version,
"description": skill.manifest.skill.description,
"runtime": format!("{:?}", skill.manifest.runtime.runtime_type),
"tools": tool_names,
"body": body,
});
serde_json::to_string_pretty(&response).map_err(|e| format!("Serialize error: {e}"))
}
/// Execute a skill's tool, or for prompt-only skills return the description body.
async fn tool_skill_execute(
input: &serde_json::Value,
skill_registry: Option<&SkillRegistry>,
) -> Result<String, String> {
let skill_name = input["skill"]
.as_str()
.ok_or("Missing 'skill' parameter")?
.trim();
let registry = skill_registry.ok_or("No skill registry available")?;
let skill = registry.get(skill_name).ok_or_else(|| {
format!("Skill '{skill_name}' not found. Use skill_list to see installed skills.")
})?;
// If no tool name was given, default behavior depends on runtime.
// For prompt-only skills, return the SKILL.md body (most useful response
// for issue #1038's daily-journal style skills).
let tool_name = input["tool"].as_str().map(|s| s.trim());
let tool_input = input.get("input").cloned().unwrap_or(serde_json::json!({}));
let resolved_tool = match tool_name {
Some(t) if !t.is_empty() => t.to_string(),
_ => {
// No tool specified — return SKILL.md body so the agent can act on it.
if let Some(ref body) = skill.manifest.prompt_context {
if !body.is_empty() {
let response = serde_json::json!({
"skill": skill.manifest.skill.name,
"mode": "prompt_context",
"body": body,
"note": "This is a prompt-only skill. Follow the instructions in 'body' using your built-in tools.",
});
return serde_json::to_string_pretty(&response)
.map_err(|e| format!("Serialize error: {e}"));
}
}
// Fall through: pick the first provided tool if any
skill
.manifest
.tools
.provided
.first()
.map(|t| t.name.clone())
.ok_or_else(|| {
format!("Skill '{skill_name}' provides no tools and has no prompt body.")
})?
}
};
match openfang_skills::loader::execute_skill_tool(
&skill.manifest,
&skill.path,
&resolved_tool,
&tool_input,
)
.await
{
Ok(result) => {
let content = serde_json::to_string_pretty(&serde_json::json!({
"skill": skill.manifest.skill.name,
"tool": resolved_tool,
"output": result.output,
"is_error": result.is_error,
}))
.unwrap_or_else(|_| result.output.to_string());
if result.is_error {
Err(content)
} else {
Ok(content)
}
}
Err(e) => Err(format!("Skill execution failed: {e}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -3610,6 +3809,67 @@ mod tests {
assert!(names.contains(&"docker_exec"));
// Canvas tool
assert!(names.contains(&"canvas_present"));
// 3 skill introspection tools (issue #1038)
assert!(names.contains(&"skill_list"));
assert!(names.contains(&"skill_describe"));
assert!(names.contains(&"skill_execute"));
}
/// Issue #1038: skill_list, skill_describe, skill_execute work without
/// touching the filesystem so global skills (outside the workspace
/// sandbox) are reachable by the agent.
#[tokio::test]
async fn test_skill_tools_no_filesystem_access() {
use openfang_skills::registry::SkillRegistry;
use tempfile::TempDir;
// Build a skills directory containing one prompt-only SKILL.md skill
// (mirroring the user's daily-journal scenario from #1038).
let global_dir = TempDir::new().unwrap();
let skill_dir = global_dir.path().join("daily-journal");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: daily-journal\ndescription: Keep a daily journal\n---\n\
# Daily Journal\n\nWrite one paragraph per day about what you learned.",
)
.unwrap();
let mut registry = SkillRegistry::new(global_dir.path().to_path_buf());
registry.load_all().unwrap();
assert_eq!(registry.count(), 1);
// skill_list returns the global skill without any filesystem call
let list_out = tool_skill_list(Some(&registry)).unwrap();
assert!(list_out.contains("daily-journal"));
assert!(list_out.contains("Keep a daily journal"));
// skill_describe returns the SKILL.md body — no file_read needed
let desc_out = tool_skill_describe(
&serde_json::json!({ "name": "daily-journal" }),
Some(&registry),
)
.unwrap();
assert!(desc_out.contains("Daily Journal"));
assert!(desc_out.contains("Write one paragraph"));
// skill_execute on a prompt-only skill returns the body in 'prompt_context' mode
let exec_out = tool_skill_execute(
&serde_json::json!({ "skill": "daily-journal" }),
Some(&registry),
)
.await
.unwrap();
assert!(exec_out.contains("prompt_context"));
assert!(exec_out.contains("Daily Journal"));
// skill_describe on a missing skill returns a helpful error
let missing = tool_skill_describe(
&serde_json::json!({ "name": "no-such-skill" }),
Some(&registry),
);
assert!(missing.is_err());
assert!(missing.unwrap_err().contains("not found"));
}
#[test]
@@ -3756,14 +4016,14 @@ mod tests {
async fn test_create_directory_creates_nested() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
let result = tool_create_directory(
&serde_json::json!({"path": "a/b/c"}),
Some(root),
)
.await;
let result = tool_create_directory(&serde_json::json!({"path": "a/b/c"}), Some(root)).await;
assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
let expected = root.join("a").join("b").join("c");
assert!(expected.is_dir(), "Expected directory to exist: {}", expected.display());
assert!(
expected.is_dir(),
"Expected directory to exist: {}",
expected.display()
);
}
#[tokio::test]
@@ -3771,18 +4031,10 @@ mod tests {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
// First create
let r1 = tool_create_directory(
&serde_json::json!({"path": "data/logs"}),
Some(root),
)
.await;
let r1 = tool_create_directory(&serde_json::json!({"path": "data/logs"}), Some(root)).await;
assert!(r1.is_ok());
// Second create on existing dir should also succeed
let r2 = tool_create_directory(
&serde_json::json!({"path": "data/logs"}),
Some(root),
)
.await;
let r2 = tool_create_directory(&serde_json::json!({"path": "data/logs"}), Some(root)).await;
assert!(r2.is_ok(), "Expected idempotent success, got: {:?}", r2);
}
@@ -3802,23 +4054,27 @@ mod tests {
"test-id",
"create_directory",
&serde_json::json!({"path": "nested/folder"}),
None, // kernel
None, // allowed_tools
None, // caller_agent_id
None, // skill_registry
None, // mcp_connections
None, // web_ctx
None, // browser_ctx
None, // allowed_env_vars
None, // kernel
None, // allowed_tools
None, // caller_agent_id
None, // skill_registry
None, // mcp_connections
None, // web_ctx
None, // browser_ctx
None, // allowed_env_vars
Some(root.as_path()), // workspace_root
None, // media_engine
None, // exec_policy
None, // tts_engine
None, // docker_config
None, // process_manager
None, // media_engine
None, // exec_policy
None, // tts_engine
None, // docker_config
None, // process_manager
)
.await;
assert!(!result.is_error, "Expected success, got: {}", result.content);
assert!(
!result.is_error,
"Expected success, got: {}",
result.content
);
assert!(root.join("nested").join("folder").is_dir());
}
+45 -3
View File
@@ -547,9 +547,24 @@ impl ClawHubClient {
};
info!(slug, sha256 = %sha256, "Downloaded skill");
// Create skill directory
let skill_dir = target_dir.join(slug);
std::fs::create_dir_all(&skill_dir)?;
// Codex Finding 1 (#1170 followup): extract into a private staging
// directory and atomically rename into the final location only after
// enforce_require_signed passes. Without this, a local writer with
// access to target_dir/<slug>/ can swap files between extraction
// and the binding check (the TOCTOU window).
let final_dir = target_dir.join(slug);
let staging_dir = target_dir.join(format!(
".staging-{}-{}-{}",
slug,
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
));
// Pre-clean any leftover staging dir from a prior crashed install.
let _ = std::fs::remove_dir_all(&staging_dir);
std::fs::create_dir_all(&staging_dir)?;
// Use the staging path for all extraction/conversion work.
// After enforcement passes the function renames it to final_dir.
let skill_dir = staging_dir.clone();
// Detect content type and extract accordingly
let content_str = String::from_utf8_lossy(&bytes);
@@ -665,6 +680,33 @@ impl ClawHubClient {
return Err(e);
}
// Step 9: Atomic rename staging -> final. Closes the TOCTOU window
// between extraction and enforcement (Codex Finding 1). If the final
// location already exists from a prior install, replace it
// atomically (where the filesystem allows) or fall back to
// remove-then-rename.
if final_dir.exists() {
if let Err(e) = std::fs::remove_dir_all(&final_dir) {
let _ = std::fs::remove_dir_all(&skill_dir);
return Err(SkillError::SecurityBlocked(format!(
"install: failed to clear existing {} before atomic rename: {e}",
final_dir.display()
)));
}
}
if let Err(e) = std::fs::rename(&skill_dir, &final_dir) {
let _ = std::fs::remove_dir_all(&skill_dir);
return Err(SkillError::SecurityBlocked(format!(
"install: atomic rename {} -> {} failed: {e}",
skill_dir.display(),
final_dir.display()
)));
}
// From this point on the skill lives at final_dir, not staging.
// (Variable kept for clarity even though unread; future tracing
// additions reference it.)
let _skill_dir = final_dir;
let result = ClawHubInstallResult {
skill_name: manifest.skill.name.clone(),
version: manifest.skill.version.clone(),
+252 -11
View File
@@ -60,11 +60,76 @@ impl InstallOptions {
/// Well-known filenames the installer searches for a detached signature
/// envelope, in priority order.
const SIGNATURE_CANDIDATES: &[&str] = &[
"signature.json",
"skill.toml.sig.json",
"SKILL.md.sig.json",
];
const SIGNATURE_CANDIDATES: &[&str] =
&["signature.json", "skill.toml.sig.json", "SKILL.md.sig.json"];
/// Normalize a manifest text for content-binding comparison.
///
/// Strips a UTF-8 BOM if present and converts CRLF to LF. Without this,
/// a Windows checkout with git autocrlf would write `\r\n` line endings
/// to disk while the signed envelope captured `\n`, and the literal
/// byte-equality check would reject an otherwise-valid signed manifest.
/// Applied symmetrically to both sides of the binding compare.
fn normalize_manifest_text(text: &str) -> String {
let trimmed = text.strip_prefix('\u{feff}').unwrap_or(text);
trimmed.replace("\r\n", "\n")
}
/// Verify that a path resolves to a regular file *inside* `dir`, not a
/// symlink and not an escape via `..` or canonicalization. Returns
/// `Ok(true)` if the entry is safe to open, `Ok(false)` if it doesn't
/// exist, and `Err` if the entry exists but fails the safety check.
///
/// Without this, an attacker shipping a crafted archive could place
/// `signature.json` or `skill.toml` as a symlink pointing outside the
/// skill directory and redirect manifest/envelope reads — bypassing the
/// intent of the binding enforcement.
fn safe_regular_file_in(dir: &Path, name: &str) -> Result<bool, SkillError> {
let path = dir.join(name);
let md = match std::fs::symlink_metadata(&path) {
Ok(md) => md,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(e) => {
return Err(SkillError::SecurityBlocked(format!(
"require_signed: failed to stat {} for safety check: {e}",
path.display()
)))
}
};
if md.file_type().is_symlink() {
return Err(SkillError::SecurityBlocked(format!(
"require_signed: refusing to follow symlink at {} \
(skill bundles must ship regular files only)",
path.display()
)));
}
if !md.is_file() {
return Err(SkillError::SecurityBlocked(format!(
"require_signed: {} is not a regular file",
path.display()
)));
}
let canon_dir = std::fs::canonicalize(dir).map_err(|e| {
SkillError::SecurityBlocked(format!(
"require_signed: cannot canonicalize {}: {e}",
dir.display()
))
})?;
let canon_path = std::fs::canonicalize(&path).map_err(|e| {
SkillError::SecurityBlocked(format!(
"require_signed: cannot canonicalize {}: {e}",
path.display()
))
})?;
if !canon_path.starts_with(&canon_dir) {
return Err(SkillError::SecurityBlocked(format!(
"require_signed: {} escapes skill directory {}",
canon_path.display(),
canon_dir.display()
)));
}
Ok(true)
}
/// Locate a `SignedManifest` envelope inside `skill_dir`, if any.
///
@@ -73,10 +138,10 @@ const SIGNATURE_CANDIDATES: &[&str] = &[
/// malformed envelope is a stronger signal than an absent one.
pub fn load_signature(skill_dir: &Path) -> Result<Option<SignedManifest>, SkillError> {
for name in SIGNATURE_CANDIDATES {
let path = skill_dir.join(name);
if !path.exists() {
if !safe_regular_file_in(skill_dir, name)? {
continue;
}
let path = skill_dir.join(name);
let raw = std::fs::read_to_string(&path)?;
let envelope: SignedManifest = serde_json::from_str(&raw).map_err(|e| {
SkillError::InvalidManifest(format!(
@@ -99,10 +164,7 @@ pub fn load_signature(skill_dir: &Path) -> Result<Option<SignedManifest>, SkillE
/// Returns `SkillError::SecurityBlocked` when enforcement is on and the
/// skill fails any of those checks. On failure the caller is expected to
/// remove `skill_dir` to keep the skills directory clean.
pub fn enforce_require_signed(
skill_dir: &Path,
opts: &InstallOptions,
) -> Result<(), SkillError> {
pub fn enforce_require_signed(skill_dir: &Path, opts: &InstallOptions) -> Result<(), SkillError> {
if !opts.require_signed {
return Ok(());
}
@@ -124,6 +186,60 @@ pub fn enforce_require_signed(
)));
}
// Bind the signature to the installed bytes.
//
// envelope.verify() only proves the envelope's signature matches its own
// embedded `manifest` text. Without comparing that text to the actual
// skill.toml / SKILL.md / package.json on disk, an attacker could ship
// a benign signed envelope alongside malicious skill files and pass the
// check.
//
// Defense layers (Codex Findings 1-4 from audit of 4c496be):
// - Symlink + canonicalization checks: redirect-via-symlink closed
// - CRLF/BOM normalization: Windows false-reject closed
// - package.json in candidate list: OpenClaw gap closed
// - clawhub::install_with_options now uses staging-then-atomic-rename
// so this function runs inside a private dir not yet visible at the
// final install path. That closes the TOCTOU window where a local
// writer could swap files between extract and enforce.
//
// Read every candidate manifest file in the installed dir and require
// that at least one byte-matches envelope.manifest after BOM strip +
// CRLF→LF normalization (applied symmetrically to both sides).
// `package.json` is included because openclaw_compat treats it as a
// valid SKILL manifest source.
const MANIFEST_CANDIDATES: &[&str] = &["skill.toml", "SKILL.md", "skill.md", "package.json"];
let normalized_envelope = normalize_manifest_text(&envelope.manifest);
let mut bound = false;
for name in MANIFEST_CANDIDATES {
if !safe_regular_file_in(skill_dir, name)? {
continue;
}
let path = skill_dir.join(name);
match std::fs::read_to_string(&path) {
Ok(actual) => {
if normalize_manifest_text(&actual) == normalized_envelope {
bound = true;
break;
}
}
Err(e) => {
return Err(SkillError::SecurityBlocked(format!(
"require_signed: failed to read {} for binding check: {e}",
path.display()
)));
}
}
}
if !bound {
return Err(SkillError::SecurityBlocked(format!(
"require_signed: signed envelope content does not match any \
installed manifest file in {} (signature was valid but the \
skill payload on disk differs from what was signed)",
skill_dir.display()
)));
}
if !opts.allowed_signer_keys.is_empty() {
let actual = hex::encode(&envelope.signer_public_key);
let actual_lower = actual.to_lowercase();
@@ -278,6 +394,51 @@ entry = "main.py"
}
}
/// Critical: a valid signature for a different manifest must NOT pass
/// when the on-disk skill.toml differs. Without binding the envelope to
/// the installed bytes, an attacker could ship a benign signed envelope
/// next to malicious skill files.
#[test]
fn require_signed_on_rejects_signature_unbound_to_disk() {
let dir = TempDir::new().unwrap();
// Sign a BENIGN manifest body but never write that text to disk.
let benign_toml = r#"name = "benign"
version = "0.1.0"
description = "Looks fine."
[runtime]
type = "python"
entry = "main.py"
"#;
let signing_key = SigningKey::generate(&mut OsRng);
let envelope =
SignedManifest::sign(benign_toml.to_string(), &signing_key, "trusted-signer");
write_signature(dir.path(), &envelope, "signature.json");
// Write a DIFFERENT (malicious) skill.toml on disk.
let evil_toml = r#"name = "evil"
version = "0.1.0"
description = "Backdoor."
[runtime]
type = "python"
entry = "rm-rf.py"
"#;
std::fs::write(dir.path().join("skill.toml"), evil_toml).unwrap();
let opts = InstallOptions::require_signed();
let err = enforce_require_signed(dir.path(), &opts).unwrap_err();
match err {
SkillError::SecurityBlocked(msg) => {
assert!(
msg.contains("does not match any") || msg.contains("payload on disk differs"),
"got: {msg}"
);
}
other => panic!("expected SecurityBlocked, got {other:?}"),
}
}
#[test]
fn load_signature_returns_none_when_absent() {
let dir = TempDir::new().unwrap();
@@ -296,4 +457,84 @@ entry = "main.py"
let loaded = load_signature(dir.path()).unwrap().unwrap();
assert_eq!(loaded.signer_id, "alt-name-signer");
}
// Codex audit Finding 2: CRLF/BOM normalization. A Windows checkout
// may write \r\n line endings to disk; the signed envelope captured
// \n. Literal byte equality would false-reject. Both sides are
// normalize_manifest_text-ed before compare.
#[test]
fn require_signed_accepts_crlf_disk_when_envelope_is_lf() {
let dir = TempDir::new().unwrap();
let lf_toml = "[skill]\nname = \"x\"\nversion = \"0.1\"\n[runtime]\ntype = \"python\"\nentry = \"main.py\"\n";
let crlf_toml = lf_toml.replace('\n', "\r\n");
std::fs::write(dir.path().join("skill.toml"), &crlf_toml).unwrap();
let signing_key = SigningKey::generate(&mut OsRng);
let envelope = SignedManifest::sign(lf_toml, &signing_key, "lf-signer");
write_signature(dir.path(), &envelope, "signature.json");
let opts = InstallOptions::require_signed();
assert!(enforce_require_signed(dir.path(), &opts).is_ok());
}
#[test]
fn require_signed_accepts_utf8_bom_disk_when_envelope_is_clean() {
let dir = TempDir::new().unwrap();
let clean = "[skill]\nname = \"x\"\nversion = \"0.1\"\n[runtime]\ntype = \"python\"\nentry = \"main.py\"\n";
let with_bom = format!("\u{feff}{clean}");
std::fs::write(dir.path().join("skill.toml"), &with_bom).unwrap();
let signing_key = SigningKey::generate(&mut OsRng);
let envelope = SignedManifest::sign(clean, &signing_key, "bom-signer");
write_signature(dir.path(), &envelope, "signature.json");
let opts = InstallOptions::require_signed();
assert!(enforce_require_signed(dir.path(), &opts).is_ok());
}
// Codex audit Finding 3: package.json must be a valid binding candidate
// because openclaw_compat treats it as a SKILL manifest source.
#[test]
fn require_signed_binds_to_package_json() {
let dir = TempDir::new().unwrap();
let pkg = r#"{"name":"x","version":"0.1.0","openfang":{"skill":"x"}}"#;
std::fs::write(dir.path().join("package.json"), pkg).unwrap();
let signing_key = SigningKey::generate(&mut OsRng);
let envelope = SignedManifest::sign(pkg, &signing_key, "pkg-signer");
write_signature(dir.path(), &envelope, "signature.json");
let opts = InstallOptions::require_signed();
assert!(enforce_require_signed(dir.path(), &opts).is_ok());
}
// Codex audit Finding 4: symlinks for signature.json or manifest must
// be rejected even when they point to a valid file, to prevent crafted
// bundles from redirecting reads outside the skill directory.
#[cfg(unix)]
#[test]
fn require_signed_rejects_symlink_signature() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let toml = write_skill_toml(dir.path());
let signing_key = SigningKey::generate(&mut OsRng);
let envelope = SignedManifest::sign(toml, &signing_key, "symlink-signer");
let outside_sig = outside.path().join("real-sig.json");
std::fs::write(
&outside_sig,
serde_json::to_string_pretty(&envelope).unwrap(),
)
.unwrap();
symlink(&outside_sig, dir.path().join("signature.json")).unwrap();
let opts = InstallOptions::require_signed();
let err = enforce_require_signed(dir.path(), &opts).unwrap_err();
match err {
SkillError::SecurityBlocked(msg) => {
assert!(msg.contains("symlink"), "got: {msg}");
}
other => panic!("expected SecurityBlocked, got {other:?}"),
}
}
}
+17 -3
View File
@@ -474,11 +474,23 @@ pub struct AgentManifest {
/// Pinned model override (used in Stable mode).
#[serde(default)]
pub pinned_model: Option<String>,
/// Agent workspace directory. Auto-created on spawn.
/// Default: `{workspaces_dir}/{agent_name}-{agent_id_prefix}/`
/// Agent workspace directory. User-facing working area for output, data,
/// skills, context.md, and uploads. When the user sets this in agent.toml
/// (e.g. `workspace = "/home/me/Documents"`) the runtime treats it as the
/// agent's working directory and will NOT scaffold private state files
/// there. Multiple agents can share the same workspace to collaborate on
/// files. See issue #1097.
/// Default: `{workspaces_dir}/{agent_name}/` (same as state_dir).
#[serde(default)]
pub workspace: Option<PathBuf>,
/// Whether to generate workspace identity files (SOUL.md, USER.md, etc.) on creation.
/// Agent private state directory. Stores identity files (SOUL.md, etc.),
/// AGENT.json, sessions/, and the daily memory log. Always lives under
/// `~/.openfang/workspaces/{name}/` regardless of where the user-facing
/// workspace points. Auto-derived on spawn. See issue #1097.
#[serde(default)]
pub state_dir: Option<PathBuf>,
/// Whether to generate identity files (SOUL.md, USER.md, etc.) in
/// `state_dir` on creation.
#[serde(default = "default_true")]
pub generate_identity_files: bool,
/// Per-agent exec policy override. If None, uses global exec_policy.
@@ -550,6 +562,7 @@ impl Default for AgentManifest {
autonomous: None,
pinned_model: None,
workspace: None,
state_dir: None,
generate_identity_files: true,
exec_policy: None,
tool_allowlist: Vec::new(),
@@ -809,6 +822,7 @@ mod tests {
autonomous: None,
pinned_model: None,
workspace: None,
state_dir: None,
generate_identity_files: true,
exec_policy: None,
tool_allowlist: Vec::new(),
+28
View File
@@ -1481,6 +1481,10 @@ fn default_true() -> bool {
true
}
fn default_auto_thread() -> String {
"false".to_string()
}
fn default_thread_ttl() -> u64 {
24
}
@@ -1929,6 +1933,13 @@ pub struct TelegramConfig {
/// Allows channel_send(channel="telegram", message="...") without a recipient.
#[serde(default)]
pub default_chat_id: Option<String>,
/// Forum topic routing: maps `message_thread_id` to an agent name.
/// When a message arrives inside a Telegram forum topic whose thread id is
/// listed here, the bridge dispatches it to the named agent instead of the
/// default. Threads not listed fall back to `default_agent`.
/// Issue #780.
#[serde(default)]
pub thread_routes: HashMap<i64, String>,
/// Per-channel behavior overrides.
#[serde(default)]
pub overrides: ChannelOverrides,
@@ -1943,6 +1954,7 @@ impl Default for TelegramConfig {
poll_interval_secs: 1,
api_url: None,
default_chat_id: None,
thread_routes: HashMap::new(),
overrides: ChannelOverrides::default(),
}
}
@@ -1976,6 +1988,10 @@ pub struct DiscordConfig {
/// In these channels, the bot responds to all group messages without needing to be mentioned.
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
pub free_response_channels: Vec<String>,
/// Auto-thread behavior: "true" (always create thread), "false" (never), "smart" (only when @mentioned).
/// Default: "false"
#[serde(default = "default_auto_thread")]
pub auto_thread: String,
/// Per-channel behavior overrides.
#[serde(default)]
pub overrides: ChannelOverrides,
@@ -1992,6 +2008,7 @@ impl Default for DiscordConfig {
ignore_bots: true,
default_channel_id: None,
free_response_channels: vec![],
auto_thread: "false".to_string(),
overrides: ChannelOverrides::default(),
}
}
@@ -2119,6 +2136,13 @@ pub struct MatrixConfig {
pub user_id: String,
/// Env var name holding the access token.
pub access_token_env: String,
/// Env var name holding the MSC2918 refresh token (optional).
///
/// When set, the adapter auto-recovers from `M_UNKNOWN_TOKEN` 401 responses
/// by calling `POST /_matrix/client/v3/refresh`. Required for matrix.org
/// since the 2025-04-07 migration to MAS (Matrix Authentication Service).
#[serde(default)]
pub refresh_token_env: Option<String>,
/// Room IDs to listen in (empty = all joined rooms).
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
pub allowed_rooms: Vec<String>,
@@ -2138,6 +2162,7 @@ impl Default for MatrixConfig {
homeserver_url: "https://matrix.org".to_string(),
user_id: String::new(),
access_token_env: "MATRIX_ACCESS_TOKEN".to_string(),
refresh_token_env: None,
allowed_rooms: vec![],
default_agent: None,
auto_accept_invites: false,
@@ -4146,6 +4171,9 @@ mod tests {
let mx = MatrixConfig::default();
assert_eq!(mx.homeserver_url, "https://matrix.org");
assert_eq!(mx.access_token_env, "MATRIX_ACCESS_TOKEN");
// MSC2918 refresh token env defaults to None; operators opt in via
// `refresh_token_env = "MATRIX_REFRESH_TOKEN"` in config.toml.
assert!(mx.refresh_token_env.is_none());
assert!(mx.allowed_rooms.is_empty());
}
+7 -4
View File
@@ -434,7 +434,9 @@ mod tests {
let restored: ContentBlock = serde_json::from_str(&serialized).unwrap();
match restored {
ContentBlock::Thinking {
thinking, signature, ..
thinking,
signature,
..
} => {
assert_eq!(thinking, "Let me reason about this carefully...");
assert_eq!(
@@ -517,7 +519,9 @@ mod tests {
assert_eq!(blocks.len(), 2);
match &blocks[0] {
ContentBlock::Thinking {
thinking, signature, ..
thinking,
signature,
..
} => {
assert_eq!(thinking, "Internal reasoning");
assert_eq!(signature.as_deref(), Some("sig_xyz"));
@@ -547,8 +551,7 @@ mod tests {
fn test_provider_msg_id_is_recorded_but_not_primary() {
// The LLM-supplied identifier is preserved for debugging only — the
// server-generated `msg_id` is unchanged and remains the primary key.
let msg =
Message::assistant("hi").with_provider_msg_id("msg_abc123_anthropic_collidable");
let msg = Message::assistant("hi").with_provider_msg_id("msg_abc123_anthropic_collidable");
assert_eq!(
msg.provider_msg_id.as_deref(),
Some("msg_abc123_anthropic_collidable")
@@ -14,6 +14,7 @@ pub const GEMINI_BASE_URL: &str = "https://generativelanguage.googleapis.com";
pub const DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/v1";
pub const GROQ_BASE_URL: &str = "https://api.groq.com/openai/v1";
pub const OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
pub const REQUESTY_BASE_URL: &str = "https://router.requesty.ai/v1";
pub const MISTRAL_BASE_URL: &str = "https://api.mistral.ai/v1";
pub const TOGETHER_BASE_URL: &str = "https://api.together.xyz/v1";
pub const FIREWORKS_BASE_URL: &str = "https://api.fireworks.ai/inference/v1";