From 1f7e7b824abab825e1b56b714d496de3dd03abb1 Mon Sep 17 00:00:00 2001 From: zhangxinlu Date: Sat, 7 Mar 2026 20:39:54 +0800 Subject: [PATCH] feat: feishu interactive card approval for agent permission requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an agent requests human approval (e.g. to execute a shell command), send an interactive Feishu card with Approve/Reject buttons instead of requiring the user to type /approve manually. Key changes: - Approval notification: ApprovalManager broadcasts new requests via tokio broadcast channel; bridge layer subscribes and pushes cards to the last active Feishu user in real time - Concurrent message dispatch: each inbound message now spawns its own task so a long-running agent call (blocked on approval) no longer prevents the user from sending /approve - Feishu card callback parsing: support both card.action.trigger and application.bot.menu_v6 event types; button clicks are converted to /approve or /reject commands - Feishu WebSocket receive mode: full implementation with reconnect backoff, protobuf frame decoding, ping/pong heartbeat, and multi-frame message reassembly - exec_policy smart approval: allowlisted commands skip approval (fast path); unlisted commands escalate to approval instead of hard-blocking; approved commands are persisted to config.toml - Hand agent model restore: use tag-based detection instead of hardcoded name; always re-apply default_model on DB restore - Cross-compilation fix (openfang-cli/Cargo.toml, Cross.toml): add openssl vendored feature and rustls-tls for reqwest to fix `cross build --target x86_64-unknown-linux-musl` failure — the musl Docker container lacks system OpenSSL, so vendored mode downloads and statically compiles OpenSSL from source --- Cargo.lock | 671 ++++---- Cargo.toml | 5 + Cross.toml | 6 + crates/openfang-api/src/channel_bridge.rs | 6 + crates/openfang-channels/Cargo.toml | 1 + crates/openfang-channels/src/bridge.rs | 110 +- crates/openfang-channels/src/feishu.rs | 1511 ++++++++++++++--- crates/openfang-channels/src/gotify.rs | 6 + crates/openfang-channels/src/nextcloud.rs | 7 + crates/openfang-channels/src/telegram.rs | 7 + crates/openfang-channels/src/twist.rs | 6 + crates/openfang-channels/src/types.rs | 8 + crates/openfang-cli/Cargo.toml | 4 +- crates/openfang-kernel/Cargo.toml | 1 + crates/openfang-kernel/src/approval.rs | 27 + crates/openfang-kernel/src/kernel.rs | 139 +- crates/openfang-runtime/src/drivers/openai.rs | 11 +- crates/openfang-runtime/src/kernel_handle.rs | 12 + .../src/subprocess_sandbox.rs | 3 +- crates/openfang-runtime/src/tool_runner.rs | 75 +- 20 files changed, 2027 insertions(+), 589 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0e58a8267..e32901e08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -221,9 +221,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.40" +version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d67d43201f4d20c78bcda740c142ca52482d81da80681533d33bf3f0596c8e2" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" dependencies = [ "compression-codecs", "compression-core", @@ -258,7 +258,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.3", + "rustix 1.1.4", "slab", "windows-sys 0.61.2", ] @@ -289,7 +289,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.3", + "rustix 1.1.4", ] [[package]] @@ -315,7 +315,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.3", + "rustix 1.1.4", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -735,9 +735,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.43" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", @@ -931,9 +931,9 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "core-graphics" -version = "0.24.0" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.11.0", "core-foundation", @@ -973,36 +973,36 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" -version = "0.128.3" +version = "0.128.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0377b13bf002a0774fcccac4f1102a10f04893d24060cf4b7350c87e4cbb647c" +checksum = "50a04121a197fde2fe896f8e7cac9812fc41ed6ee9c63e1906090f9f497845f6" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.128.3" +version = "0.128.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfa027979140d023b25bf7509fb7ede3a54c3d3871fb5ead4673c4b633f671a2" +checksum = "a09e699a94f477303820fb2167024f091543d6240783a2d3b01a3f21c42bc744" dependencies = [ "cranelift-srcgen", ] [[package]] name = "cranelift-bforest" -version = "0.128.3" +version = "0.128.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "618e4da87d9179a70b3c2f664451ca8898987aa6eb9f487d16988588b5d8cc40" +checksum = "f07732c662a9755529e332d86f8c5842171f6e98ba4d5976a178043dad838654" dependencies = [ "cranelift-entity", ] [[package]] name = "cranelift-bitset" -version = "0.128.3" +version = "0.128.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db53764b5dad233b37b8f5dc54d3caa9900c54579195e00f17ea21f03f71aaa7" +checksum = "18391da761cf362a06def7a7cf11474d79e55801dd34c2e9ba105b33dc0aef88" dependencies = [ "serde", "serde_derive", @@ -1010,9 +1010,9 @@ dependencies = [ [[package]] name = "cranelift-codegen" -version = "0.128.3" +version = "0.128.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae927f1d8c0abddaa863acd201471d56e7fc6c3925104f4861ed4dc3e28b421" +checksum = "0b3a09b3042c69810d255aef59ddc3b3e4c0644d1d90ecfd6e3837798cc88a3c" dependencies = [ "bumpalo", "cranelift-assembler-x64", @@ -1037,9 +1037,9 @@ dependencies = [ [[package]] name = "cranelift-codegen-meta" -version = "0.128.3" +version = "0.128.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fcf1e3e6757834bd2584f4cbff023fcc198e9279dcb5d684b4bb27a9b19f54" +checksum = "75817926ec812241889208d1b190cadb7fedded4592a4bb01b8524babb9e4849" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -1050,24 +1050,24 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" -version = "0.128.3" +version = "0.128.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "205dcb9e6ccf9d368b7466be675ff6ee54a63e36da6fe20e72d45169cf6fd254" +checksum = "859158f87a59476476eda3884d883c32e08a143cf3d315095533b362a3250a63" [[package]] name = "cranelift-control" -version = "0.128.3" +version = "0.128.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "108eca9fcfe86026054f931eceaf57b722c1b97464bf8265323a9b5877238817" +checksum = "03b65a9aec442d715cbf54d14548b8f395476c09cef7abe03e104a378291ab88" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.128.3" +version = "0.128.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d96496910065d3165f84ff8e1e393916f4c086f88ac8e1b407678bc78735aa" +checksum = "8334c99a7e86060c24028732efd23bac84585770dcb752329c69f135d64f2fc1" dependencies = [ "cranelift-bitset", "serde", @@ -1076,9 +1076,9 @@ dependencies = [ [[package]] name = "cranelift-frontend" -version = "0.128.3" +version = "0.128.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e303983ad7e23c850f24d9c41fc3cb346e1b930f066d3966545e4c98dac5c9fb" +checksum = "43ac6c095aa5b3e845d7ca3461e67e2b65249eb5401477a5ff9100369b745111" dependencies = [ "cranelift-codegen", "log", @@ -1088,15 +1088,15 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.128.3" +version = "0.128.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24b0cf8d867d891245836cac7abafb0a5b0ea040a019d720702b3b8bcba40bfa" +checksum = "69d3d992870ed4f0f2e82e2175275cb3a123a46e9660c6558c46417b822c91fa" [[package]] name = "cranelift-native" -version = "0.128.3" +version = "0.128.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e24b641e315443e27807b69c440fe766737d7e718c68beb665a2d69259c77bf3" +checksum = "ee32e36beaf80f309edb535274cfe0349e1c5cf5799ba2d9f42e828285c6b52e" dependencies = [ "cranelift-codegen", "libc", @@ -1105,9 +1105,9 @@ dependencies = [ [[package]] name = "cranelift-srcgen" -version = "0.128.3" +version = "0.128.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e378a54e7168a689486d67ee1f818b7e5356e54ae51a1d7a53f4f13f7f8b7a" +checksum = "903adeaf4938e60209a97b53a2e4326cd2d356aab9764a1934630204bae381c9" [[package]] name = "crc32fast" @@ -1509,17 +1509,11 @@ dependencies = [ "winapi", ] -[[package]] -name = "dispatch" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" - [[package]] name = "dispatch2" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.11.0", "block2", @@ -1720,9 +1714,9 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "erased-serde" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" dependencies = [ "serde", "serde_core", @@ -2154,7 +2148,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.3", + "rustix 1.1.4", "windows-link 0.2.1", ] @@ -2191,20 +2185,20 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -2292,7 +2286,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" dependencies = [ "heck 0.4.1", - "proc-macro-crate 2.0.2", + "proc-macro-crate 2.0.0", "proc-macro-error", "proc-macro2", "quote", @@ -2628,7 +2622,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.2", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -2899,9 +2893,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" @@ -3039,9 +3033,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.87" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93f0862381daaec758576dcc22eb7bbf4d7efd67328553f3b45a412a51a3fb21" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" dependencies = [ "once_cell", "wasm-bindgen", @@ -3137,7 +3131,7 @@ dependencies = [ "percent-encoding", "quoted_printable", "rustls", - "socket2 0.6.2", + "socket2 0.6.3", "tokio", "tokio-rustls", "url", @@ -3205,13 +3199,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.12" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ "bitflags 2.11.0", "libc", - "redox_syscall 0.7.1", + "plain", + "redox_syscall 0.7.3", ] [[package]] @@ -3233,9 +3228,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -3281,9 +3276,9 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mac-notification-sys" -version = "0.6.9" +version = "0.6.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621" +checksum = "26053f9919b5b032f327ab94d830f2465c4c88138e9df23c8fcd305060a9b28b" dependencies = [ "cc", "objc2", @@ -3369,7 +3364,7 @@ version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" dependencies = [ - "rustix 1.1.3", + "rustix 1.1.4", ] [[package]] @@ -3399,9 +3394,9 @@ dependencies = [ [[package]] name = "minisign-verify" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e856fdd13623a2f5f2f54676a4ee49502a96a80ef4a62bcedd23d52427c44d43" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" [[package]] name = "miniz_oxide" @@ -3572,9 +3567,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" [[package]] name = "num-traits" @@ -3601,7 +3596,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -3609,9 +3604,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", "objc2-exception-helper", @@ -3625,38 +3620,8 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.11.0", "block2", - "libc", "objc2", - "objc2-cloud-kit", - "objc2-core-data", "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-image", - "objc2-core-text", - "objc2-core-video", - "objc2-foundation", - "objc2-quartz-core", -] - -[[package]] -name = "objc2-cloud-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" -dependencies = [ - "bitflags 2.11.0", - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-data" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" -dependencies = [ - "bitflags 2.11.0", - "objc2", "objc2-foundation", ] @@ -3684,41 +3649,6 @@ dependencies = [ "objc2-io-surface", ] -[[package]] -name = "objc2-core-image" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-text" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" -dependencies = [ - "bitflags 2.11.0", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", -] - -[[package]] -name = "objc2-core-video" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" -dependencies = [ - "bitflags 2.11.0", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-io-surface", -] - [[package]] name = "objc2-encode" version = "4.1.0" @@ -3758,16 +3688,6 @@ dependencies = [ "objc2-core-foundation", ] -[[package]] -name = "objc2-javascript-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586" -dependencies = [ - "objc2", - "objc2-core-foundation", -] - [[package]] name = "objc2-osa-kit" version = "0.3.2" @@ -3792,17 +3712,6 @@ dependencies = [ "objc2-foundation", ] -[[package]] -name = "objc2-security" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" -dependencies = [ - "bitflags 2.11.0", - "objc2", - "objc2-core-foundation", -] - [[package]] name = "objc2-ui-kit" version = "0.3.2" @@ -3827,8 +3736,6 @@ dependencies = [ "objc2-app-kit", "objc2-core-foundation", "objc2-foundation", - "objc2-javascript-core", - "objc2-security", ] [[package]] @@ -3875,7 +3782,7 @@ dependencies = [ [[package]] name = "openfang-api" -version = "0.3.25" +version = "0.3.26" dependencies = [ "async-trait", "axum", @@ -3903,7 +3810,7 @@ dependencies = [ "tokio", "tokio-stream", "tokio-test", - "toml 0.8.2", + "toml 0.8.23", "tower", "tower-http", "tracing", @@ -3912,7 +3819,7 @@ dependencies = [ [[package]] name = "openfang-channels" -version = "0.3.25" +version = "0.3.26" dependencies = [ "async-trait", "axum", @@ -3928,6 +3835,7 @@ dependencies = [ "mailparse", "native-tls", "openfang-types", + "prost", "reqwest 0.12.28", "serde", "serde_json", @@ -3944,7 +3852,7 @@ dependencies = [ [[package]] name = "openfang-cli" -version = "0.3.25" +version = "0.3.26" dependencies = [ "clap", "clap_complete", @@ -3957,12 +3865,13 @@ dependencies = [ "openfang-runtime", "openfang-skills", "openfang-types", + "openssl", "ratatui", "reqwest 0.12.28", "serde", "serde_json", "tokio", - "toml 0.8.2", + "toml 0.8.23", "tracing", "tracing-subscriber", "uuid", @@ -3971,7 +3880,7 @@ dependencies = [ [[package]] name = "openfang-desktop" -version = "0.3.25" +version = "0.3.26" dependencies = [ "axum", "open", @@ -3990,14 +3899,14 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-updater", "tokio", - "toml 0.8.2", + "toml 0.8.23", "tracing", "tracing-subscriber", ] [[package]] name = "openfang-extensions" -version = "0.3.25" +version = "0.3.26" dependencies = [ "aes-gcm", "argon2", @@ -4016,7 +3925,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-test", - "toml 0.8.2", + "toml 0.8.23", "tracing", "url", "uuid", @@ -4025,7 +3934,7 @@ dependencies = [ [[package]] name = "openfang-hands" -version = "0.3.25" +version = "0.3.26" dependencies = [ "chrono", "dashmap", @@ -4035,14 +3944,14 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio-test", - "toml 0.8.2", + "toml 0.8.23", "tracing", "uuid", ] [[package]] name = "openfang-kernel" -version = "0.3.25" +version = "0.3.26" dependencies = [ "async-trait", "chrono", @@ -4070,7 +3979,8 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-test", - "toml 0.8.2", + "toml 0.8.23", + "toml_edit 0.20.7", "tracing", "tracing-subscriber", "uuid", @@ -4078,7 +3988,7 @@ dependencies = [ [[package]] name = "openfang-memory" -version = "0.3.25" +version = "0.3.26" dependencies = [ "async-trait", "chrono", @@ -4097,7 +4007,7 @@ dependencies = [ [[package]] name = "openfang-migrate" -version = "0.3.25" +version = "0.3.26" dependencies = [ "chrono", "dirs 6.0.0", @@ -4108,7 +4018,7 @@ dependencies = [ "serde_yaml", "tempfile", "thiserror 2.0.18", - "toml 0.8.2", + "toml 0.8.23", "tracing", "uuid", "walkdir", @@ -4116,7 +4026,7 @@ dependencies = [ [[package]] name = "openfang-runtime" -version = "0.3.25" +version = "0.3.26" dependencies = [ "anyhow", "async-trait", @@ -4148,7 +4058,7 @@ dependencies = [ [[package]] name = "openfang-skills" -version = "0.3.25" +version = "0.3.26" dependencies = [ "chrono", "hex", @@ -4162,7 +4072,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-test", - "toml 0.8.2", + "toml 0.8.23", "tracing", "uuid", "walkdir", @@ -4171,7 +4081,7 @@ dependencies = [ [[package]] name = "openfang-types" -version = "0.3.25" +version = "0.3.26" dependencies = [ "async-trait", "chrono", @@ -4184,13 +4094,13 @@ dependencies = [ "serde_json", "sha2", "thiserror 2.0.18", - "toml 0.8.2", + "toml 0.8.23", "uuid", ] [[package]] name = "openfang-wire" -version = "0.3.25" +version = "0.3.26" dependencies = [ "async-trait", "chrono", @@ -4241,6 +4151,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-src" +version = "300.5.5+3.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709" +dependencies = [ + "cc", +] + [[package]] name = "openssl-sys" version = "0.9.111" @@ -4249,6 +4168,7 @@ checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" dependencies = [ "cc", "libc", + "openssl-src", "pkg-config", "vcpkg", ] @@ -4565,9 +4485,9 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" @@ -4577,9 +4497,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "piper" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ "atomic-waker", "fastrand", @@ -4602,6 +4522,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plist" version = "1.8.0" @@ -4651,7 +4577,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.3", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -4737,21 +4663,20 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "2.0.2" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8" dependencies = [ - "toml_datetime 0.6.3", - "toml_edit 0.20.2", + "toml_edit 0.20.7", ] [[package]] name = "proc-macro-crate" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.23.10+spec-1.0.0", + "toml_edit 0.25.4+spec-1.1.0", ] [[package]] @@ -4793,6 +4718,29 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "psm" version = "0.1.30" @@ -4805,9 +4753,9 @@ dependencies = [ [[package]] name = "pulley-interpreter" -version = "41.0.3" +version = "41.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01051a5b172e07f9197b85060e6583b942aec679dac08416647bf7e7dc916b65" +checksum = "e9812652c1feb63cf39f8780cecac154a32b22b3665806c733cd4072547233a4" dependencies = [ "cranelift-bitset", "log", @@ -4817,9 +4765,9 @@ dependencies = [ [[package]] name = "pulley-macros" -version = "41.0.3" +version = "41.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cf194f5b1a415ef3a44ee35056f4009092cc4038a9f7e3c7c1e392f48ee7dbb" +checksum = "56000349b6896e3d44286eb9c330891237f40b27fd43c1ccc84547d0b463cb40" dependencies = [ "proc-macro2", "quote", @@ -4828,12 +4776,9 @@ dependencies = [ [[package]] name = "pxfm" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" -dependencies = [ - "num-traits", -] +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" [[package]] name = "quanta" @@ -4881,7 +4826,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.2", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -4918,16 +4863,16 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.2", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -4944,6 +4889,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.7.3" @@ -5130,9 +5081,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.1" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" dependencies = [ "bitflags 2.11.0", ] @@ -5224,9 +5175,9 @@ checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[package]] name = "regex-syntax" -version = "0.8.9" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" @@ -5418,22 +5369,22 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.36" +version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "log", "once_cell", @@ -5762,9 +5713,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.16.1" +version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" +checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" dependencies = [ "base64 0.22.1", "chrono", @@ -5781,9 +5732,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.16.1" +version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" +checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" dependencies = [ "darling 0.21.3", "proc-macro2", @@ -5990,12 +5941,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6211,29 +6162,28 @@ dependencies = [ "cfg-expr", "heck 0.5.0", "pkg-config", - "toml 0.8.2", + "toml 0.8.23", "version-compare", ] [[package]] name = "tao" -version = "0.34.5" +version = "0.34.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" +checksum = "6e06d52c379e63da659a483a958110bbde891695a0ecb53e48cc7786d5eda7bb" dependencies = [ "bitflags 2.11.0", "block2", "core-foundation", "core-graphics", "crossbeam-channel", - "dispatch", + "dispatch2", "dlopen2", "dpi", "gdkwayland-sys", "gdkx11-sys", "gtk", "jni", - "lazy_static", "libc", "log", "ndk", @@ -6245,7 +6195,6 @@ dependencies = [ "once_cell", "parking_lot", "raw-window-handle", - "scopeguard", "tao-macros", "unicode-segmentation", "url", @@ -6291,9 +6240,9 @@ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" [[package]] name = "tauri" -version = "2.10.2" +version = "2.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "463ae8677aa6d0f063a900b9c41ecd4ac2b7ca82f0b058cc4491540e55b20129" +checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d" dependencies = [ "anyhow", "bytes", @@ -6343,9 +6292,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.5.5" +version = "2.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca7bd893329425df750813e95bd2b643d5369d929438da96d5bbb7cc2c918f74" +checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" dependencies = [ "anyhow", "cargo_toml", @@ -6365,9 +6314,9 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.5.4" +version = "2.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aac423e5859d9f9ccdd32e3cf6a5866a15bedbf25aa6630bcb2acde9468f6ae3" +checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" dependencies = [ "base64 0.22.1", "brotli", @@ -6392,9 +6341,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.5.4" +version = "2.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b6a1bd2861ff0c8766b1d38b32a6a410f6dc6532d4ef534c47cfb2236092f59" +checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -6406,9 +6355,9 @@ dependencies = [ [[package]] name = "tauri-plugin" -version = "2.5.3" +version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692a77abd8b8773e107a42ec0e05b767b8d2b7ece76ab36c6c3947e34df9f53f" +checksum = "ddde7d51c907b940fb573006cdda9a642d6a7c8153657e88f8a5c3c9290cd4aa" dependencies = [ "anyhow", "glob", @@ -6580,9 +6529,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.10.0" +version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b885ffeac82b00f1f6fd292b6e5aabfa7435d537cef57d11e38a489956535651" +checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2" dependencies = [ "cookie", "dpi", @@ -6605,9 +6554,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.10.0" +version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5204682391625e867d16584fedc83fc292fb998814c9f7918605c789cd876314" +checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e" dependencies = [ "gtk", "http", @@ -6615,7 +6564,6 @@ dependencies = [ "log", "objc2", "objc2-app-kit", - "objc2-foundation", "once_cell", "percent-encoding", "raw-window-handle", @@ -6632,9 +6580,9 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcd169fccdff05eff2c1033210b9b94acd07a47e6fa9a3431cf09cfd4f01c87e" +checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d" dependencies = [ "anyhow", "brotli", @@ -6693,14 +6641,14 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.25.0" +version = "3.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", - "getrandom 0.4.1", + "getrandom 0.4.2", "once_cell", - "rustix 1.1.3", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -6775,9 +6723,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" dependencies = [ "deranged", "itoa", @@ -6790,15 +6738,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" dependencies = [ "num-conv", "time-core", @@ -6831,9 +6779,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.49.0" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ "bytes", "libc", @@ -6841,16 +6789,16 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", @@ -6932,14 +6880,14 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.2" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned 0.6.9", - "toml_datetime 0.6.3", - "toml_edit 0.20.2", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -6954,14 +6902,14 @@ dependencies = [ "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 0.7.14", + "winnow 0.7.15", ] [[package]] name = "toml_datetime" -version = "0.6.3" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ "serde", ] @@ -6975,6 +6923,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_datetime" +version = "1.0.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.19.15" @@ -6982,33 +6939,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ "indexmap 2.13.0", - "toml_datetime 0.6.3", + "toml_datetime 0.6.11", "winnow 0.5.40", ] [[package]] name = "toml_edit" -version = "0.20.2" +version = "0.20.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap 2.13.0", "serde", "serde_spanned 0.6.9", - "toml_datetime 0.6.3", - "winnow 0.5.40", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", ] [[package]] name = "toml_edit" -version = "0.23.10+spec-1.0.0" +version = "0.25.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" dependencies = [ "indexmap 2.13.0", - "toml_datetime 0.7.5+spec-1.1.0", + "toml_datetime 1.0.0+spec-1.1.0", "toml_parser", - "winnow 0.7.14", + "winnow 0.7.15", ] [[package]] @@ -7017,9 +6986,15 @@ version = "1.0.9+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" dependencies = [ - "winnow 0.7.14", + "winnow 0.7.15", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" version = "1.0.6+spec-1.1.0" @@ -7237,13 +7212,13 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "uds_windows" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +checksum = "51b70b87d15e91f553711b40df3048faf27a7a04e01e0ddc0cf9309f0af7c2ca" dependencies = [ "memoffset", "tempfile", - "winapi", + "windows-sys 0.61.2", ] [[package]] @@ -7407,11 +7382,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.21.0" +version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ - "getrandom 0.4.1", + "getrandom 0.4.2", "js-sys", "serde_core", "wasm-bindgen", @@ -7512,9 +7487,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.110" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1de241cdc66a9d91bd84f097039eb140cdc6eec47e0cdbaf9d932a1dd6c35866" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" dependencies = [ "cfg-if", "once_cell", @@ -7525,9 +7500,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.60" +version = "0.4.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a42e96ea38f49b191e08a1bab66c7ffdba24b06f9995b39a9dd60222e5b6f1da" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" dependencies = [ "cfg-if", "futures-util", @@ -7539,9 +7514,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.110" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e12fdf6649048f2e3de6d7d5ff3ced779cdedee0e0baffd7dff5cdfa3abc8a52" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -7549,9 +7524,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.110" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e63d1795c565ac3462334c1e396fd46dbf481c40f51f5072c310717bc4fb309" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" dependencies = [ "bumpalo", "proc-macro2", @@ -7562,9 +7537,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.110" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f9cdac23a5ce71f6bf9f8824898a501e511892791ea2a0c6b8568c68b9cb53" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" dependencies = [ "unicode-ident", ] @@ -7707,9 +7682,9 @@ dependencies = [ [[package]] name = "wasmtime" -version = "41.0.3" +version = "41.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a19f56cece843fa95dd929f5568ff8739c7e3873b530ceea9eda2aa02a0b4142" +checksum = "e2a83182bf04af87571b4c642300479501684f26bab5597f68f68cded5b098fd" dependencies = [ "addr2line", "anyhow", @@ -7734,7 +7709,7 @@ dependencies = [ "postcard", "pulley-interpreter", "rayon", - "rustix 1.1.3", + "rustix 1.1.4", "semver", "serde", "serde_derive", @@ -7764,9 +7739,9 @@ dependencies = [ [[package]] name = "wasmtime-environ" -version = "41.0.3" +version = "41.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bf9dff572c950258548cbbaf39033f68f8dcd0b43b22e80def9fe12d532d3e5" +checksum = "cb201c41aa23a3642365cfb2e4a183573d85127a3c9d528f56b9997c984541ab" dependencies = [ "anyhow", "cpp_demangle", @@ -7791,15 +7766,15 @@ dependencies = [ [[package]] name = "wasmtime-internal-cache" -version = "41.0.3" +version = "41.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f52a985f5b5dae53147fc596f3a313c334e2c24fd1ba708634e1382f6ecd727" +checksum = "fb5b3069d1a67ba5969d0eb1ccd7e141367d4e713f4649aa90356c98e8f19bea" dependencies = [ "base64 0.22.1", "directories-next", "log", "postcard", - "rustix 1.1.3", + "rustix 1.1.4", "serde", "serde_derive", "sha2", @@ -7811,9 +7786,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-macro" -version = "41.0.3" +version = "41.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7920dc7dcb608352f5fe93c52582e65075b7643efc5dac3fc717c1645a8d29a0" +checksum = "0c924400db7b6ca996fef1b23beb0f41d5c809836b1ec60fc25b4057e2d25d9b" dependencies = [ "anyhow", "proc-macro2", @@ -7826,15 +7801,15 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-util" -version = "41.0.3" +version = "41.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "066f5aed35aa60580a2ac0df145c0f0d4b04319862fee1d6036693e1cca43a12" +checksum = "7d3f65daf4bf3d74ca2fbbe20af0589c42e2b398a073486451425d94fd4afef4" [[package]] name = "wasmtime-internal-cranelift" -version = "41.0.3" +version = "41.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afb8002dc415b7773d7949ee360c05ee8f91627ec25a7a0b01ee03831bdfdda1" +checksum = "633e889cdae76829738db0114ab3b02fce51ea4a1cd9675a67a65fce92e8b418" dependencies = [ "cfg-if", "cranelift-codegen", @@ -7859,14 +7834,14 @@ dependencies = [ [[package]] name = "wasmtime-internal-fiber" -version = "41.0.3" +version = "41.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f9c562c5a272bc9f615d8f0c085a4360bafa28eef9aa5947e63d204b1129b22" +checksum = "deb126adc5d0c72695cfb77260b357f1b81705a0f8fa30b3944e7c2219c17341" dependencies = [ "cc", "cfg-if", "libc", - "rustix 1.1.3", + "rustix 1.1.4", "wasmtime-environ", "wasmtime-internal-versioned-export-macros", "windows-sys 0.61.2", @@ -7874,21 +7849,21 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-debug" -version = "41.0.3" +version = "41.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db673148f26e1211db3913c12c75594be9e3858a71fa297561e9162b1a49cfb0" +checksum = "8e66ff7f90a8002187691ff6237ffd09f954a0ebb9de8b2ff7f5c62632134120" dependencies = [ "cc", "object", - "rustix 1.1.3", + "rustix 1.1.4", "wasmtime-internal-versioned-export-macros", ] [[package]] name = "wasmtime-internal-jit-icache-coherence" -version = "41.0.3" +version = "41.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bada5ca1cc47df7d14100e2254e187c2486b426df813cea2dd2553a7469f7674" +checksum = "4b96df23179ae16d54fb3a420f84ffe4383ec9dd06fad3e5bc782f85f66e8e08" dependencies = [ "anyhow", "cfg-if", @@ -7898,24 +7873,24 @@ dependencies = [ [[package]] name = "wasmtime-internal-math" -version = "41.0.3" +version = "41.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf6f615d528eda9adc6eefb062135f831b5215c348f4c3ec3e143690c730605b" +checksum = "86d1380926682b44c383e9a67f47e7a95e60c6d3fa8c072294dab2c7de6168a0" dependencies = [ "libm", ] [[package]] name = "wasmtime-internal-slab" -version = "41.0.3" +version = "41.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da169d4f789b586e1b2612ba8399c653ed5763edf3e678884ba785bb151d018f" +checksum = "9b63cbea1c0192c7feb7c0dfb35f47166988a3742f29f46b585ef57246c65764" [[package]] name = "wasmtime-internal-unwinder" -version = "41.0.3" +version = "41.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4888301f3393e4e8c75c938cce427293fade300fee3fc8fd466fdf3e54ae068e" +checksum = "f25c392c7e5fb891a7416e3c34cfbd148849271e8c58744fda875dde4bec4d6a" dependencies = [ "cfg-if", "cranelift-codegen", @@ -7926,9 +7901,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-versioned-export-macros" -version = "41.0.3" +version = "41.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63ba3124cc2cbcd362672f9f077303ccc4cd61daa908f73447b7fdaece75ff9f" +checksum = "70f8b9796a3f0451a7b702508b303d654de640271ac80287176de222f187a237" dependencies = [ "proc-macro2", "quote", @@ -7937,9 +7912,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-winch" -version = "41.0.3" +version = "41.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90a4182515dabba776656de4ebd62efad03399e261cf937ecccb838ce8823534" +checksum = "c0063e61f1d0b2c20e9cfc58361a6513d074a23c80b417aac3033724f51648a0" dependencies = [ "cranelift-codegen", "gimli", @@ -7954,9 +7929,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-wit-bindgen" -version = "41.0.3" +version = "41.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87acbd416227cdd279565ba49e57cf7f08d112657c3b3f39b70250acdfd094fe" +checksum = "587699ca7cae16b4a234ffcc834f37e75675933d533809919b52975f5609e2ef" dependencies = [ "anyhow", "bitflags 2.11.0", @@ -7989,9 +7964,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.87" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2c7c5718134e770ee62af3b6b4a84518ec10101aad610c024b64d6ff29bb1ff" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" dependencies = [ "js-sys", "wasm-bindgen", @@ -8138,9 +8113,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "winch-codegen" -version = "41.0.3" +version = "41.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4f31dcfdfaf9d6df9e1124d7c8ee6fc29af5b99b89d11ae731c138e0f5bd77b" +checksum = "c55de3ac5b8bd71e5f6c87a9e511dd3ceb194bdb58183c6a7bf21cd8c0e46fbc" dependencies = [ "anyhow", "cranelift-assembler-x64", @@ -8579,9 +8554,9 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.14" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" dependencies = [ "memchr", ] @@ -8790,7 +8765,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "gethostname", - "rustix 1.1.3", + "rustix 1.1.4", "x11rb-protocol", ] @@ -8807,7 +8782,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.3", + "rustix 1.1.4", ] [[package]] @@ -8818,7 +8793,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xtask" -version = "0.3.25" +version = "0.3.26" [[package]] name = "yoke" @@ -8865,14 +8840,14 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix 1.1.3", + "rustix 1.1.4", "serde", "serde_repr", "tracing", "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 0.7.14", + "winnow 0.7.15", "zbus_macros", "zbus_names", "zvariant", @@ -8884,7 +8859,7 @@ version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -8900,24 +8875,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", - "winnow 0.7.14", + "winnow 0.7.15", "zvariant", ] [[package]] name = "zerocopy" -version = "0.8.39" +version = "0.8.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.39" +version = "0.8.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" dependencies = [ "proc-macro2", "quote", @@ -9082,7 +9057,7 @@ dependencies = [ "endi", "enumflags2", "serde", - "winnow 0.7.14", + "winnow 0.7.15", "zvariant_derive", "zvariant_utils", ] @@ -9093,7 +9068,7 @@ version = "5.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -9110,5 +9085,5 @@ dependencies = [ "quote", "serde", "syn 2.0.117", - "winnow 0.7.14", + "winnow 0.7.15", ] diff --git a/Cargo.toml b/Cargo.toml index 4592c0ef9..1ed924b1f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,8 @@ tokio-stream = "0.1" serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" +toml_edit = "0.20" +prost = "0.13" rmp-serde = "1" # Error handling @@ -141,8 +143,11 @@ mailparse = "0.15" tokio-test = "0.4" tempfile = "3" +openssl = { version = "0.10", features = ["vendored"] } + [profile.release] lto = true codegen-units = 1 strip = true opt-level = 3 + diff --git a/Cross.toml b/Cross.toml index 14450c288..50d849288 100644 --- a/Cross.toml +++ b/Cross.toml @@ -3,3 +3,9 @@ pre-build = [ "dpkg --add-architecture $CROSS_DEB_ARCH", "apt-get update && apt-get install --assume-yes libssl-dev:$CROSS_DEB_ARCH" ] + +[build.env] +passthrough = [ + "OPENSSL_STATIC", + "OPENSSL_NO_VENDOR", +] diff --git a/crates/openfang-api/src/channel_bridge.rs b/crates/openfang-api/src/channel_bridge.rs index b72180670..054d21712 100644 --- a/crates/openfang-api/src/channel_bridge.rs +++ b/crates/openfang-api/src/channel_bridge.rs @@ -555,6 +555,12 @@ impl ChannelBridgeHandle for KernelBridgeAdapter { } } + fn approval_pending_rx( + &self, + ) -> Option> { + Some(self.kernel.approval_manager.subscribe()) + } + async fn list_approvals_text(&self) -> String { let pending = self.kernel.approval_manager.list_pending(); if pending.is_empty() { diff --git a/crates/openfang-channels/Cargo.toml b/crates/openfang-channels/Cargo.toml index 49e572507..b0f78c337 100644 --- a/crates/openfang-channels/Cargo.toml +++ b/crates/openfang-channels/Cargo.toml @@ -14,6 +14,7 @@ chrono = { workspace = true } dashmap = { workspace = true } async-trait = { workspace = true } futures = { workspace = true } +prost = { workspace = true } reqwest = { workspace = true } tokio-stream = { workspace = true } tracing = { workspace = true } diff --git a/crates/openfang-channels/src/bridge.rs b/crates/openfang-channels/src/bridge.rs index 1daedb947..bc676039e 100644 --- a/crates/openfang-channels/src/bridge.rs +++ b/crates/openfang-channels/src/bridge.rs @@ -90,6 +90,18 @@ pub trait ChannelBridgeHandle: Send + Sync { "Hand listing not available.".to_string() } + /// Subscribe to approval pending notifications from the kernel. + /// + /// Returns a broadcast receiver that fires each time an agent submits an + /// approval request. The channel bridge uses this to send a real-time + /// notification to the user so they can respond with `/approve `. + /// Default implementation returns `None` (no kernel integration). + fn approval_pending_rx( + &self, + ) -> Option> { + None + } + /// Authorize a channel user for an action. /// /// Returns Ok(()) if the user is allowed, Err(reason) if denied. @@ -275,6 +287,52 @@ impl BridgeManager { let adapter_clone = adapter.clone(); let mut shutdown = self.shutdown_rx.clone(); + // Track the most recent sender so the approval notifier knows who to notify. + let (last_sender_tx, last_sender_rx) = + tokio::sync::watch::channel::>(None); + + // Approval notification task: listens for new pending approval requests and + // immediately sends a message to the last active user so they can /approve . + if let Some(mut approval_rx) = handle.approval_pending_rx() { + let notify_adapter = adapter.clone(); + let mut notify_shutdown = self.shutdown_rx.clone(); + tokio::spawn(async move { + loop { + tokio::select! { + result = approval_rx.recv() => { + match result { + Ok(req) => { + let user_opt = last_sender_rx.borrow().clone(); + if let Some(user) = user_opt { + // Send interactive card with approve/reject buttons + let _ = notify_adapter + .send(&user, crate::types::ChannelContent::ApprovalRequest { + request_id: req.id.to_string(), + agent_id: req.agent_id, + tool_name: req.tool_name, + action_summary: req.action_summary, + }) + .await; + } else { + warn!("Approval pending but no active user to notify on {}", notify_adapter.name()); + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + warn!("Approval notification receiver lagged by {n} messages"); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + break; + } + } + } + _ = notify_shutdown.changed() => { + if *notify_shutdown.borrow() { break; } + } + } + } + }); + } + let task = tokio::spawn(async move { let mut stream = std::pin::pin!(stream); loop { @@ -282,13 +340,25 @@ impl BridgeManager { msg = stream.next() => { match msg { Some(message) => { - dispatch_message( - &message, - &handle, - &router, - adapter_clone.as_ref(), - &rate_limiter, - ).await; + // Update the last active sender for approval notifications. + let _ = last_sender_tx.send(Some(message.sender.clone())); + + // Spawn each message in its own task so that a long-running + // agent call (e.g. blocked on approval) does not prevent the + // user from sending `/approve ` or other commands. + let h = handle.clone(); + let r = router.clone(); + let ac = adapter_clone.clone(); + let rl = rate_limiter.clone(); + tokio::spawn(async move { + dispatch_message( + &message, + &h, + &r, + ac, + &rl, + ).await; + }); } None => { info!("Channel adapter {} stream ended", adapter_clone.name()); @@ -366,7 +436,7 @@ async fn dispatch_message( message: &ChannelMessage, handle: &Arc, router: &Arc, - adapter: &dyn ChannelAdapter, + adapter: Arc, rate_limiter: &ChannelRateLimiter, ) { let ct_str = channel_type_str(&message.channel); @@ -440,7 +510,7 @@ async fn dispatch_message( if let Err(msg) = rate_limiter.check(ct_str, &message.sender.platform_id, ov.rate_limit_per_user) { - send_response(adapter, &message.sender, msg, thread_id, output_format).await; + send_response(adapter.as_ref(), &message.sender, msg, thread_id, output_format).await; return; } } @@ -450,7 +520,7 @@ async fn dispatch_message( ChannelContent::Text(t) => t.clone(), ChannelContent::Command { name, args } => { let result = handle_command(name, args, handle, router, &message.sender).await; - send_response(adapter, &message.sender, result, thread_id, output_format).await; + send_response(adapter.as_ref(), &message.sender, result, thread_id, output_format).await; return; } ChannelContent::Image { ref url, ref caption } => { @@ -469,6 +539,10 @@ async fn dispatch_message( ChannelContent::Location { lat, lon } => { format!("[User shared location: {lat}, {lon}]") } + ChannelContent::ApprovalRequest { .. } => { + // Approval requests are sent outbound only, never inbound from users + return; + } }; // Check if it's a slash command embedded in text (e.g. "/agents") @@ -512,7 +586,7 @@ async fn dispatch_message( | "a2a" ) { let result = handle_command(cmd, &args, handle, router, &message.sender).await; - send_response(adapter, &message.sender, result, thread_id, output_format).await; + send_response(adapter.as_ref(), &message.sender, result, thread_id, output_format).await; return; } // Other slash commands pass through to the agent @@ -528,7 +602,7 @@ async fn dispatch_message( .await { send_response( - adapter, + adapter.as_ref(), &message.sender, format!("Access denied: {denied}"), thread_id, @@ -579,7 +653,7 @@ async fn dispatch_message( } let combined = responses.join("\n\n"); - send_response(adapter, &message.sender, combined, thread_id, output_format).await; + send_response(adapter.as_ref(), &message.sender, combined, thread_id, output_format).await; return; } } @@ -612,7 +686,7 @@ async fn dispatch_message( } None => { send_response( - adapter, + adapter.as_ref(), &message.sender, "No agents available. Start the dashboard at http://127.0.0.1:4200 to create one.".to_string(), thread_id, @@ -630,7 +704,7 @@ async fn dispatch_message( .await { send_response( - adapter, + adapter.as_ref(), &message.sender, format!("Access denied: {denied}"), thread_id, @@ -643,7 +717,7 @@ async fn dispatch_message( // Auto-reply check — if enabled, the engine decides whether to process this message. // If auto-reply is enabled but suppressed for this message, skip agent call entirely. if let Some(reply) = handle.check_auto_reply(agent_id, &text).await { - send_response(adapter, &message.sender, reply, thread_id, output_format).await; + send_response(adapter.as_ref(), &message.sender, reply, thread_id, output_format).await; handle .record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None) .await; @@ -656,7 +730,7 @@ async fn dispatch_message( // Send to agent and relay response match handle.send_message(agent_id, &text).await { Ok(response) => { - send_response(adapter, &message.sender, response, thread_id, output_format).await; + send_response(adapter.as_ref(), &message.sender, response, thread_id, output_format).await; handle .record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None) .await; @@ -665,7 +739,7 @@ async fn dispatch_message( warn!("Agent error for {agent_id}: {e}"); let err_msg = format!("Agent error: {e}"); send_response( - adapter, + adapter.as_ref(), &message.sender, err_msg.clone(), thread_id, diff --git a/crates/openfang-channels/src/feishu.rs b/crates/openfang-channels/src/feishu.rs index 7f4290477..ddf1e16ee 100644 --- a/crates/openfang-channels/src/feishu.rs +++ b/crates/openfang-channels/src/feishu.rs @@ -1,8 +1,11 @@ //! Feishu/Lark Open Platform channel adapter. //! -//! Uses the Feishu Open API for sending messages and a webhook HTTP server for -//! receiving inbound events. Authentication is performed via a tenant access token -//! obtained from `https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal`. +//! Uses the Feishu Open API for sending messages. Supports two modes for receiving inbound events: +//! 1. Webhook mode: HTTP server for receiving event callbacks +//! 2. WebSocket mode: WebSocket long connection for receiving events (no public IP required) +//! +//! Authentication is performed via a tenant access token obtained from +//! `https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal`. //! The token is cached and refreshed automatically (2-hour expiry). use crate::types::{ @@ -10,13 +13,16 @@ use crate::types::{ }; use async_trait::async_trait; use chrono::Utc; -use futures::Stream; +use futures::{SinkExt, Stream, StreamExt}; +use prost::Message as ProstMessage; use std::collections::HashMap; use std::pin::Pin; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::{mpsc, watch, RwLock}; -use tracing::{info, warn}; +use tokio_tungstenite::{connect_async, tungstenite::protocol::Message}; +use tracing::{debug, error, info, warn}; +use url::Url; use zeroize::Zeroizing; /// Feishu tenant access token endpoint. @@ -29,27 +35,82 @@ const FEISHU_SEND_URL: &str = "https://open.feishu.cn/open-apis/im/v1/messages"; /// Feishu bot info endpoint. const FEISHU_BOT_INFO_URL: &str = "https://open.feishu.cn/open-apis/bot/v3/info"; +/// Feishu websocket endpoint discovery API. +const FEISHU_WS_ENDPOINT_URL: &str = "https://open.feishu.cn/callback/ws/endpoint"; + /// Maximum Feishu message text length (characters). const MAX_MESSAGE_LEN: usize = 4096; /// Token refresh buffer — refresh 5 minutes before actual expiry. const TOKEN_REFRESH_BUFFER_SECS: u64 = 300; +const INITIAL_BACKOFF: Duration = Duration::from_secs(1); +const MAX_BACKOFF: Duration = Duration::from_secs(60); +const DEFAULT_WS_PING_INTERVAL_SECS: u64 = 30; + +/// Feishu websocket frame header. +#[derive(Clone, PartialEq, ::prost::Message)] +struct FeishuWsHeader { + #[prost(string, tag = "1")] + key: String, + #[prost(string, tag = "2")] + value: String, +} + +/// Feishu websocket frame (pbbp2.proto compatible). +#[derive(Clone, PartialEq, ::prost::Message)] +struct FeishuWsFrame { + #[prost(uint64, tag = "1")] + seq_id: u64, + #[prost(uint64, tag = "2")] + log_id: u64, + #[prost(int32, tag = "3")] + service: i32, + #[prost(int32, tag = "4")] + method: i32, + #[prost(message, repeated, tag = "5")] + headers: Vec, + #[prost(string, optional, tag = "6")] + payload_encoding: Option, + #[prost(string, optional, tag = "7")] + payload_type: Option, + #[prost(bytes, optional, tag = "8")] + payload: Option>, + #[prost(string, optional, tag = "9")] + log_id_new: Option, +} + +#[derive(Debug, Clone)] +struct FeishuWsEndpoint { + url: String, + ping_interval_secs: u64, +} + +/// Feishu connection mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FeishuConnectionMode { + /// Webhook mode: HTTP server receives event callbacks. + Webhook, + /// WebSocket mode: Long connection receives events (no public IP required). + WebSocket, +} + /// Feishu/Lark Open Platform adapter. /// -/// Inbound messages arrive via a webhook HTTP server that receives event -/// callbacks from the Feishu platform. Outbound messages are sent via the -/// Feishu IM API with a tenant access token for authentication. +/// Inbound messages arrive via either a webhook HTTP server or WebSocket long connection. +/// Outbound messages are sent via the Feishu IM API with a tenant access token for authentication. pub struct FeishuAdapter { /// Feishu app ID. app_id: String, /// SECURITY: Feishu app secret, zeroized on drop. app_secret: Zeroizing, - /// Port on which the inbound webhook HTTP server listens. + /// Connection mode (Webhook or WebSocket). + connection_mode: FeishuConnectionMode, + /// Port on which the inbound webhook HTTP server listens (Webhook mode only). webhook_port: u16, - /// Optional verification token for webhook event validation. + /// Optional verification token for webhook event validation (Webhook mode only). verification_token: Option, - /// Optional encrypt key for webhook event decryption. + /// Optional encrypt key for webhook event decryption (Webhook mode only). encrypt_key: Option, /// HTTP client for API calls. client: reqwest::Client, @@ -61,7 +122,7 @@ pub struct FeishuAdapter { } impl FeishuAdapter { - /// Create a new Feishu adapter. + /// Create a new Feishu adapter in Webhook mode. /// /// # Arguments /// * `app_id` - Feishu application ID. @@ -72,6 +133,7 @@ impl FeishuAdapter { Self { app_id, app_secret: Zeroizing::new(app_secret), + connection_mode: FeishuConnectionMode::Webhook, webhook_port, verification_token: None, encrypt_key: None, @@ -82,7 +144,7 @@ impl FeishuAdapter { } } - /// Create a new Feishu adapter with webhook verification. + /// Create a new Feishu adapter in Webhook mode with verification. pub fn with_verification( app_id: String, app_secret: String, @@ -96,9 +158,31 @@ impl FeishuAdapter { adapter } + /// Create a new Feishu adapter in WebSocket mode. + /// + /// WebSocket mode does not require a public IP or webhook configuration. + /// + /// # Arguments + /// * `app_id` - Feishu application ID. + /// * `app_secret` - Feishu application secret. + pub fn new_websocket(app_id: String, app_secret: String) -> Self { + let (shutdown_tx, shutdown_rx) = watch::channel(false); + Self { + app_id, + app_secret: Zeroizing::new(app_secret), + connection_mode: FeishuConnectionMode::WebSocket, + webhook_port: 0, + verification_token: None, + encrypt_key: None, + client: reqwest::Client::new(), + shutdown_tx: Arc::new(shutdown_tx), + shutdown_rx, + cached_token: Arc::new(RwLock::new(None)), + } + } + /// Obtain a valid tenant access token, refreshing if expired or missing. async fn get_token(&self) -> Result> { - // Check cache first { let guard = self.cached_token.read().await; if let Some((ref token, expiry)) = *guard { @@ -108,7 +192,6 @@ impl FeishuAdapter { } } - // Fetch a new tenant access token let body = serde_json::json!({ "app_id": self.app_id, "app_secret": self.app_secret.as_str(), @@ -140,7 +223,6 @@ impl FeishuAdapter { .to_string(); let expire = resp_body["expire"].as_u64().unwrap_or(7200); - // Cache with safety buffer let expiry = Instant::now() + Duration::from_secs(expire.saturating_sub(TOKEN_REFRESH_BUFFER_SECS)); *self.cached_token.write().await = Some((tenant_access_token.clone(), expiry)); @@ -227,26 +309,67 @@ impl FeishuAdapter { Ok(()) } - /// Reply to a message in a thread. - #[allow(dead_code)] - async fn api_reply_message( + /// Send an interactive card with approve/reject buttons. + async fn api_send_card( &self, - message_id: &str, - text: &str, + receive_id: &str, + request_id: &str, + agent_id: &str, + tool_name: &str, + action_summary: &str, ) -> Result<(), Box> { let token = self.get_token().await?; - let url = format!( - "https://open.feishu.cn/open-apis/im/v1/messages/{}/reply", - message_id - ); + let url = format!("{}?receive_id_type=chat_id", FEISHU_SEND_URL); - let content = serde_json::json!({ - "text": text, + // Build interactive card JSON + let card = serde_json::json!({ + "config": { + "wide_screen_mode": true + }, + "header": { + "title": { "tag": "plain_text", "content": "⏳ 待审批请求" }, + "template": "blue" + }, + "elements": [ + { + "tag": "div", + "text": { + "tag": "plain_text", + "content": format!("Agent: {}\n操作: {} — {}", agent_id, tool_name, action_summary) + } + }, + { + "tag": "div", + "text": { + "tag": "plain_text", + "content": format!("ID: `{}`", &request_id[..8.min(request_id.len())]), + "language": "markdown" + } + }, + { + "tag": "action", + "actions": [ + { + "tag": "button", + "text": { "tag": "plain_text", "content": "✅ 批准" }, + "type": "primary", + "value": { "action": "approve", "request_id": request_id } + }, + { + "tag": "button", + "text": { "tag": "plain_text", "content": "❌ 拒绝" }, + "type": "danger", + "value": { "action": "reject", "request_id": request_id } + } + ] + } + ] }); let body = serde_json::json!({ - "msg_type": "text", - "content": content.to_string(), + "receive_id": receive_id, + "msg_type": "interactive", + "content": card.to_string(), }); let resp = self @@ -260,134 +383,29 @@ impl FeishuAdapter { if !resp.status().is_success() { let status = resp.status(); let resp_body = resp.text().await.unwrap_or_default(); - return Err(format!("Feishu reply message error {status}: {resp_body}").into()); + warn!("Feishu card send error {status}: {resp_body}"); + // Fall back to text message + return self.api_send_message( + receive_id, + "chat_id", + &format!("待审批请求 [{}]\nAgent: {}\n操作: {} — {}\n\n回复 /approve {} 或 /reject {}", + &request_id[..8.min(request_id.len())], + agent_id, + tool_name, + action_summary, + &request_id[..8.min(request_id.len())], + &request_id[..8.min(request_id.len())] + ) + ).await; } - Ok(()) } -} - -/// Parse a Feishu webhook event into a `ChannelMessage`. -/// -/// Handles `im.message.receive_v1` events with text message type. -fn parse_feishu_event(event: &serde_json::Value) -> Option { - // Feishu v2 event schema - let header = event.get("header")?; - let event_type = header["event_type"].as_str().unwrap_or(""); - - if event_type != "im.message.receive_v1" { - return None; - } - - let event_data = event.get("event")?; - let message = event_data.get("message")?; - let sender = event_data.get("sender")?; - - let msg_type = message["message_type"].as_str().unwrap_or(""); - if msg_type != "text" { - return None; - } - - // Parse the content JSON string - let content_str = message["content"].as_str().unwrap_or("{}"); - let content_json: serde_json::Value = serde_json::from_str(content_str).unwrap_or_default(); - let text = content_json["text"].as_str().unwrap_or(""); - if text.is_empty() { - return None; - } - - let message_id = message["message_id"].as_str().unwrap_or("").to_string(); - let chat_id = message["chat_id"].as_str().unwrap_or("").to_string(); - let chat_type = message["chat_type"].as_str().unwrap_or("p2p"); - let root_id = message["root_id"].as_str().map(|s| s.to_string()); - - let sender_id = sender - .get("sender_id") - .and_then(|s| s.get("open_id")) - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let sender_type = sender["sender_type"].as_str().unwrap_or("user"); - - // Skip bot messages - if sender_type == "bot" { - return None; - } - - let is_group = chat_type == "group"; - - let msg_content = if text.starts_with('/') { - let parts: Vec<&str> = text.splitn(2, ' ').collect(); - let cmd_name = parts[0].trim_start_matches('/'); - let args: Vec = parts - .get(1) - .map(|a| a.split_whitespace().map(String::from).collect()) - .unwrap_or_default(); - ChannelContent::Command { - name: cmd_name.to_string(), - args, - } - } else { - ChannelContent::Text(text.to_string()) - }; - - let mut metadata = HashMap::new(); - metadata.insert( - "chat_id".to_string(), - serde_json::Value::String(chat_id.clone()), - ); - metadata.insert( - "message_id".to_string(), - serde_json::Value::String(message_id.clone()), - ); - metadata.insert( - "chat_type".to_string(), - serde_json::Value::String(chat_type.to_string()), - ); - metadata.insert( - "sender_id".to_string(), - serde_json::Value::String(sender_id.clone()), - ); - if let Some(mentions) = message.get("mentions") { - metadata.insert("mentions".to_string(), mentions.clone()); - } - - Some(ChannelMessage { - channel: ChannelType::Custom("feishu".to_string()), - platform_message_id: message_id, - sender: ChannelUser { - platform_id: chat_id, - display_name: sender_id, - openfang_user: None, - }, - content: msg_content, - target_agent: None, - timestamp: Utc::now(), - is_group, - thread_id: root_id, - metadata, - }) -} - -#[async_trait] -impl ChannelAdapter for FeishuAdapter { - fn name(&self) -> &str { - "feishu" - } - - fn channel_type(&self) -> ChannelType { - ChannelType::Custom("feishu".to_string()) - } - async fn start( + /// Start webhook server (Webhook mode). + async fn start_webhook( &self, - ) -> Result + Send>>, Box> - { - // Validate credentials - let bot_name = self.validate().await?; - info!("Feishu adapter authenticated as {bot_name}"); - - let (tx, rx) = mpsc::channel::(256); + tx: mpsc::Sender, + ) -> Result<(), Box> { let port = self.webhook_port; let verification_token = self.verification_token.clone(); let mut shutdown_rx = self.shutdown_rx.clone(); @@ -405,9 +423,7 @@ impl ChannelAdapter for FeishuAdapter { let vt = Arc::clone(&vt); let tx = Arc::clone(&tx); async move { - // Handle URL verification challenge if let Some(challenge) = body.0.get("challenge") { - // Verify token if configured if let Some(ref expected_token) = *vt { let token = body.0["token"].as_str().unwrap_or(""); if token != expected_token { @@ -426,19 +442,20 @@ impl ChannelAdapter for FeishuAdapter { ); } - // Handle event callback - if let Some(schema) = body.0["schema"].as_str() { + let parsed = if let Some(schema) = body.0["schema"].as_str() { if schema == "2.0" { - // V2 event format if let Some(msg) = parse_feishu_event(&body.0) { let _ = tx.send(msg).await; + true + } else { + false } + } else { + false } } else { - // V1 event format (legacy) let event_type = body.0["event"]["type"].as_str().unwrap_or(""); if event_type == "message" { - // Legacy format handling let event = &body.0["event"]; let text = event["text"].as_str().unwrap_or(""); if !text.is_empty() { @@ -489,13 +506,18 @@ impl ChannelAdapter for FeishuAdapter { }; let _ = tx.send(channel_msg).await; + true + } else { + false } + } else { + false } - } + }; ( axum::http::StatusCode::OK, - axum::Json(serde_json::json!({})), + axum::Json(build_feishu_webhook_response(&body.0, parsed)), ) } } @@ -527,56 +549,704 @@ impl ChannelAdapter for FeishuAdapter { } }); - Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx))) + Ok(()) } - async fn send( + /// Start WebSocket connection loop (WebSocket mode). + async fn start_websocket_loop( &self, - user: &ChannelUser, - content: ChannelContent, + tx: mpsc::Sender, ) -> Result<(), Box> { - match content { - ChannelContent::Text(text) => { - // Use chat_id as receive_id with chat_id type - self.api_send_message(&user.platform_id, "chat_id", &text) - .await?; - } - _ => { - self.api_send_message(&user.platform_id, "chat_id", "(Unsupported content type)") - .await?; + let adapter = Arc::new(self.clone_adapter()); + + tokio::spawn(async move { + info!("Starting Feishu WebSocket mode"); + let mut shutdown_rx = adapter.shutdown_rx.clone(); + let mut backoff = INITIAL_BACKOFF; + + loop { + if *shutdown_rx.borrow() { + break; + } + + if let Err(e) = Self::run_websocket_inner(adapter.clone(), tx.clone()).await { + error!("Feishu WebSocket error: {e}"); + } else { + info!("Feishu WebSocket connection closed"); + } + + if *shutdown_rx.borrow() { + break; + } + + warn!("Feishu WebSocket reconnecting in {backoff:?}"); + tokio::select! { + _ = tokio::time::sleep(backoff) => {} + _ = shutdown_rx.changed() => { + if *shutdown_rx.borrow() { + break; + } + } + } + + backoff = (backoff * 2).min(MAX_BACKOFF); } - } + + info!("Feishu WebSocket loop stopped"); + }); + Ok(()) } - async fn send_typing(&self, _user: &ChannelUser) -> Result<(), Box> { - // Feishu does not support typing indicators via REST API + fn clone_adapter(&self) -> FeishuAdapterClone { + FeishuAdapterClone { + app_id: self.app_id.clone(), + app_secret: self.app_secret.clone(), + client: self.client.clone(), + shutdown_rx: self.shutdown_rx.clone(), + } + } + + async fn run_websocket_inner( + adapter: Arc, + tx: mpsc::Sender, + ) -> Result<(), Box> { + let endpoint = adapter.get_websocket_endpoint().await?; + let ws_url = endpoint.url; + let service_id = parse_service_id(&ws_url); + + info!("Connecting to Feishu WebSocket endpoint: {ws_url}"); + let (ws_stream, _) = connect_async(&ws_url).await?; + info!("Feishu WebSocket connected successfully"); + + let (mut write, mut read) = ws_stream.split(); + let mut shutdown_rx = adapter.shutdown_rx.clone(); + let mut ping_interval = + tokio::time::interval(Duration::from_secs(endpoint.ping_interval_secs)); + // consume first immediate tick + ping_interval.tick().await; + + let mut frame_parts: HashMap>> = HashMap::new(); + + loop { + tokio::select! { + msg = read.next() => { + match msg { + Some(Ok(Message::Binary(data))) => { + let frame = match FeishuWsFrame::decode(data.as_slice()) { + Ok(f) => f, + Err(e) => { + warn!("Feishu WS decode frame failed: {e}"); + continue; + } + }; + + match frame.method { + 0 => { + if let Some(new_interval) = parse_pong_interval(&frame) { + if new_interval > 0 { + debug!("Feishu WS update ping interval to {}s", new_interval); + ping_interval = tokio::time::interval(Duration::from_secs(new_interval)); + ping_interval.tick().await; + } + } + } + 1 => { + Self::handle_data_frame(frame, &mut write, &tx, &mut frame_parts).await?; + } + method => { + debug!("Feishu WS unhandled frame method: {method}"); + } + } + } + Some(Ok(Message::Text(text))) => { + // Feishu WS should be binary protobuf frames; keep this for diagnostics. + debug!("Feishu WS unexpected text message: {text}"); + } + Some(Ok(Message::Close(frame))) => { + info!("Feishu WebSocket closed by server: {frame:?}"); + break; + } + Some(Ok(Message::Ping(payload))) => { + let _ = write.send(Message::Pong(payload)).await; + } + Some(Ok(Message::Pong(_))) => { + debug!("Feishu WebSocket pong"); + } + Some(Ok(_)) => {} + Some(Err(e)) => return Err(format!("Feishu WebSocket stream error: {e}").into()), + None => break, + } + } + _ = ping_interval.tick() => { + let ping_frame = build_ping_frame(service_id); + write.send(Message::Binary(ping_frame.encode_to_vec())).await?; + } + _ = shutdown_rx.changed() => { + if *shutdown_rx.borrow() { + info!("Feishu WebSocket shutting down"); + let _ = write.close().await; + break; + } + } + } + } + Ok(()) } - async fn stop(&self) -> Result<(), Box> { - let _ = self.shutdown_tx.send(true); + async fn handle_data_frame( + mut frame: FeishuWsFrame, + write: &mut S, + tx: &mpsc::Sender, + frame_parts: &mut HashMap>>, + ) -> Result<(), Box> + where + S: SinkExt + Unpin, + >::Error: std::error::Error + Send + Sync + 'static, + { + let frame_type = ws_header(&frame.headers, "type").unwrap_or_default(); + if frame_type != "event" && frame_type != "card" { + return Ok(()); + } + + let payload = match frame.payload.take() { + Some(p) => p, + None => return Ok(()), + }; + + let payload = match combine_payload(&frame.headers, payload, frame_parts) { + Some(p) => p, + None => return Ok(()), + }; + + let mut code = 200; + match serde_json::from_slice::(&payload) { + Ok(event) => { + if let Some(msg) = parse_feishu_event(&event) { + if tx.send(msg).await.is_err() { + return Ok(()); + } + } + } + Err(e) => { + warn!("Feishu WS event payload parse failed: {e}"); + code = 500; + } + } + + let ack_frame = build_ack_frame(&frame, code); + write + .send(Message::Binary(ack_frame.encode_to_vec())) + .await?; Ok(()) } } -#[cfg(test)] -mod tests { - use super::*; +/// Cloneable Feishu adapter parts for use in async tasks. +struct FeishuAdapterClone { + app_id: String, + app_secret: Zeroizing, + client: reqwest::Client, + shutdown_rx: watch::Receiver, +} - #[test] - fn test_feishu_adapter_creation() { - let adapter = - FeishuAdapter::new("cli_abc123".to_string(), "app-secret-456".to_string(), 9000); - assert_eq!(adapter.name(), "feishu"); - assert_eq!( - adapter.channel_type(), - ChannelType::Custom("feishu".to_string()) - ); - assert_eq!(adapter.webhook_port, 9000); - } +impl FeishuAdapterClone { + /// Get WebSocket endpoint from Feishu API. + async fn get_websocket_endpoint(&self) -> Result> { + let resp = self + .client + .post(FEISHU_WS_ENDPOINT_URL) + .json(&serde_json::json!({ + "AppID": self.app_id, + "AppSecret": self.app_secret.as_str(), + })) + .send() + .await?; - #[test] + if !resp.status().is_success() { + let status = resp.status(); + let resp_body = resp.text().await.unwrap_or_default(); + return Err( + format!("Feishu WebSocket endpoint request failed {status}: {resp_body}").into(), + ); + } + + let resp_body: serde_json::Value = resp.json().await?; + parse_ws_endpoint_response(&resp_body) + } +} + +fn parse_ws_endpoint_response( + resp_body: &serde_json::Value, +) -> Result> { + let code = resp_body["code"].as_i64().unwrap_or(-1); + if code != 0 { + let msg = resp_body["msg"].as_str().unwrap_or("unknown error"); + return Err(format!("Feishu WebSocket endpoint error: {msg}").into()); + } + + let data = &resp_body["data"]; + let ws_url = data + .get("url") + .or_else(|| data.get("URL")) + .and_then(|v| v.as_str()) + .ok_or("Missing WebSocket URL in response")? + .to_string(); + + let ping_interval = data + .get("client_config") + .or_else(|| data.get("ClientConfig")) + .and_then(|cfg| cfg.get("ping_interval").or_else(|| cfg.get("PingInterval"))) + .and_then(|v| v.as_u64()) + .filter(|v| *v > 0) + .unwrap_or(DEFAULT_WS_PING_INTERVAL_SECS); + + Ok(FeishuWsEndpoint { + url: ws_url, + ping_interval_secs: ping_interval, + }) +} + +fn parse_service_id(ws_url: &str) -> i32 { + Url::parse(ws_url) + .ok() + .and_then(|u| { + u.query_pairs() + .find(|(k, _)| k == "service_id") + .and_then(|(_, v)| v.parse::().ok()) + }) + .unwrap_or(0) +} + +fn ws_header(headers: &[FeishuWsHeader], key: &str) -> Option { + headers + .iter() + .find(|h| h.key == key) + .map(|h| h.value.clone()) +} + +fn parse_pong_interval(frame: &FeishuWsFrame) -> Option { + let frame_type = ws_header(&frame.headers, "type")?; + if frame_type != "pong" { + return None; + } + + let payload = frame.payload.as_ref()?; + let value: serde_json::Value = serde_json::from_slice(payload).ok()?; + value + .get("ping_interval") + .or_else(|| value.get("PingInterval")) + .and_then(|v| v.as_u64()) +} + +fn combine_payload( + headers: &[FeishuWsHeader], + payload: Vec, + frame_parts: &mut HashMap>>, +) -> Option> { + let sum = ws_header(headers, "sum") + .and_then(|v| v.parse::().ok()) + .unwrap_or(1); + if sum <= 1 { + return Some(payload); + } + + let seq = ws_header(headers, "seq") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + let msg_id = ws_header(headers, "message_id")?; + + if seq >= sum { + return None; + } + + let entry = frame_parts + .entry(msg_id.clone()) + .or_insert_with(|| vec![Vec::new(); sum]); + + if entry.len() != sum { + *entry = vec![Vec::new(); sum]; + } + + entry[seq] = payload; + + if entry.iter().any(|part| part.is_empty()) { + return None; + } + + let mut combined = Vec::new(); + for part in entry.iter() { + combined.extend_from_slice(part); + } + frame_parts.remove(&msg_id); + Some(combined) +} + +fn build_ping_frame(service_id: i32) -> FeishuWsFrame { + FeishuWsFrame { + seq_id: 0, + log_id: 0, + service: service_id, + method: 0, + headers: vec![FeishuWsHeader { + key: "type".to_string(), + value: "ping".to_string(), + }], + payload_encoding: None, + payload_type: None, + payload: None, + log_id_new: None, + } +} + +fn build_ack_frame(request: &FeishuWsFrame, code: u16) -> FeishuWsFrame { + let payload = serde_json::json!({ + "code": code, + "headers": {}, + "data": [] + }); + + FeishuWsFrame { + seq_id: request.seq_id, + log_id: request.log_id, + service: request.service, + method: request.method, + headers: request.headers.clone(), + payload_encoding: None, + payload_type: None, + payload: Some(serde_json::to_vec(&payload).unwrap_or_default()), + log_id_new: request.log_id_new.clone(), + } +} + +/// Parse a Feishu webhook event into a `ChannelMessage`. +fn parse_feishu_event(event: &serde_json::Value) -> Option { + parse_feishu_text_message_event(event).or_else(|| parse_feishu_card_action_event(event)) +} + +fn parse_feishu_text_message_event(event: &serde_json::Value) -> Option { + let header = event.get("header")?; + let event_type = header["event_type"].as_str().unwrap_or(""); + + if event_type != "im.message.receive_v1" { + return None; + } + + let event_data = event.get("event")?; + let message = event_data.get("message")?; + let sender = event_data.get("sender")?; + + let msg_type = message["message_type"].as_str().unwrap_or(""); + if msg_type != "text" { + return None; + } + + let content_str = message["content"].as_str().unwrap_or("{}"); + let content_json: serde_json::Value = serde_json::from_str(content_str).unwrap_or_default(); + let text = content_json["text"].as_str().unwrap_or(""); + if text.is_empty() { + return None; + } + + let message_id = message["message_id"].as_str().unwrap_or("").to_string(); + let chat_id = message["chat_id"].as_str().unwrap_or("").to_string(); + let chat_type = message["chat_type"].as_str().unwrap_or("p2p"); + let root_id = message["root_id"].as_str().map(|s| s.to_string()); + + let sender_id = sender + .get("sender_id") + .and_then(|s| s.get("open_id")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let sender_type = sender["sender_type"].as_str().unwrap_or("user"); + + if sender_type == "bot" { + return None; + } + + let is_group = chat_type == "group"; + + let msg_content = if text.starts_with('/') { + let parts: Vec<&str> = text.splitn(2, ' ').collect(); + let cmd_name = parts[0].trim_start_matches('/'); + let args: Vec = parts + .get(1) + .map(|a| a.split_whitespace().map(String::from).collect()) + .unwrap_or_default(); + ChannelContent::Command { + name: cmd_name.to_string(), + args, + } + } else { + ChannelContent::Text(text.to_string()) + }; + + let mut metadata = HashMap::new(); + metadata.insert( + "chat_id".to_string(), + serde_json::Value::String(chat_id.clone()), + ); + metadata.insert( + "message_id".to_string(), + serde_json::Value::String(message_id.clone()), + ); + metadata.insert( + "chat_type".to_string(), + serde_json::Value::String(chat_type.to_string()), + ); + metadata.insert( + "sender_id".to_string(), + serde_json::Value::String(sender_id.clone()), + ); + if let Some(mentions) = message.get("mentions") { + metadata.insert("mentions".to_string(), mentions.clone()); + } + + Some(ChannelMessage { + channel: ChannelType::Custom("feishu".to_string()), + platform_message_id: message_id, + sender: ChannelUser { + platform_id: chat_id, + display_name: sender_id, + openfang_user: None, + }, + content: msg_content, + target_agent: None, + timestamp: Utc::now(), + is_group, + thread_id: root_id, + metadata, + }) +} + +fn parse_feishu_card_action_event(event: &serde_json::Value) -> Option { + let event_type = event + .get("header") + .and_then(|h| h.get("event_type")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if event_type != "application.bot.menu_v6" && event_type != "card.action.trigger" { + return None; + } + + let action_value = event + .get("event")? + .get("action")? + .get("value")?; + + let action = action_value.get("action")?.as_str()?; + if action != "approve" && action != "reject" { + return None; + } + + let request_id = action_value.get("request_id")?.as_str()?; + if request_id.is_empty() { + return None; + } + + let event_data = event.get("event")?; + let context = event_data.get("context"); + + let chat_id = event_data + .get("open_chat_id") + .or_else(|| context.and_then(|c| c.get("open_chat_id"))) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let message_id = event_data + .get("open_message_id") + .or_else(|| context.and_then(|c| c.get("open_message_id"))) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let operator_id = event_data + .get("operator") + .and_then(|v| { + // card.action.trigger: operator.open_id + // application.bot.menu_v6: operator.operator_id.open_id + v.get("open_id") + .or_else(|| v.get("operator_id").and_then(|oid| oid.get("open_id"))) + }) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let mut metadata = HashMap::new(); + metadata.insert( + "event_source".to_string(), + serde_json::Value::String("feishu_card_action".to_string()), + ); + if !message_id.is_empty() { + metadata.insert( + "open_message_id".to_string(), + serde_json::Value::String(message_id.clone()), + ); + } + if !operator_id.is_empty() { + metadata.insert( + "sender_id".to_string(), + serde_json::Value::String(operator_id.clone()), + ); + } + if !chat_id.is_empty() { + metadata.insert( + "chat_id".to_string(), + serde_json::Value::String(chat_id.clone()), + ); + } + + Some(ChannelMessage { + channel: ChannelType::Custom("feishu".to_string()), + platform_message_id: if message_id.is_empty() { + format!("card-action-{action}-{request_id}") + } else { + message_id + }, + sender: ChannelUser { + platform_id: chat_id, + display_name: operator_id, + openfang_user: None, + }, + content: ChannelContent::Command { + name: action.to_string(), + args: vec![request_id.to_string()], + }, + target_agent: None, + timestamp: Utc::now(), + is_group: true, + thread_id: None, + metadata, + }) +} + +fn build_feishu_webhook_response(body: &serde_json::Value, parsed: bool) -> serde_json::Value { + if !parsed { + return serde_json::json!({}); + } + + let event_type = body + .get("header") + .and_then(|h| h.get("event_type")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if event_type == "application.bot.menu_v6" || event_type == "card.action.trigger" { + serde_json::json!({ "code": 0 }) + } else { + serde_json::json!({}) + } +} + +#[async_trait] +impl ChannelAdapter for FeishuAdapter { + fn name(&self) -> &str { + "feishu" + } + + fn channel_type(&self) -> ChannelType { + ChannelType::Custom("feishu".to_string()) + } + + async fn start( + &self, + ) -> Result + Send>>, Box> + { + let bot_name = self.validate().await?; + info!("Feishu adapter authenticated as {bot_name}"); + + let (tx, rx) = mpsc::channel::(256); + + match self.connection_mode { + FeishuConnectionMode::Webhook => self.start_webhook(tx).await?, + FeishuConnectionMode::WebSocket => self.start_websocket_loop(tx).await?, + } + + Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx))) + } + + async fn send( + &self, + user: &ChannelUser, + content: ChannelContent, + ) -> Result<(), Box> { + match content { + ChannelContent::Text(text) => { + self.api_send_message(&user.platform_id, "chat_id", &text) + .await?; + } + ChannelContent::ApprovalRequest { + request_id, + agent_id, + tool_name, + action_summary, + } => { + self.api_send_card( + &user.platform_id, + &request_id, + &agent_id, + &tool_name, + &action_summary, + ) + .await?; + } + _ => { + self.api_send_message(&user.platform_id, "chat_id", "(Unsupported content type)") + .await?; + } + } + Ok(()) + } + + async fn send_typing(&self, _user: &ChannelUser) -> Result<(), Box> { + Ok(()) + } + + async fn stop(&self) -> Result<(), Box> { + let _ = self.shutdown_tx.send(true); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn header(key: &str, value: &str) -> FeishuWsHeader { + FeishuWsHeader { + key: key.to_string(), + value: value.to_string(), + } + } + + #[test] + fn test_feishu_adapter_creation() { + let adapter = + FeishuAdapter::new("cli_abc123".to_string(), "app-secret-456".to_string(), 9000); + assert_eq!(adapter.name(), "feishu"); + assert_eq!( + adapter.channel_type(), + ChannelType::Custom("feishu".to_string()) + ); + assert_eq!(adapter.webhook_port, 9000); + assert_eq!(adapter.connection_mode, FeishuConnectionMode::Webhook); + } + + #[test] + fn test_feishu_websocket_adapter_creation() { + let adapter = + FeishuAdapter::new_websocket("cli_abc123".to_string(), "app-secret-456".to_string()); + assert_eq!(adapter.name(), "feishu"); + assert_eq!( + adapter.channel_type(), + ChannelType::Custom("feishu".to_string()) + ); + assert_eq!(adapter.connection_mode, FeishuConnectionMode::WebSocket); + } + + #[test] fn test_feishu_with_verification() { let adapter = FeishuAdapter::with_verification( "cli_abc123".to_string(), @@ -595,6 +1265,69 @@ mod tests { assert_eq!(adapter.app_id, "cli_test"); } + #[test] + fn test_parse_ws_endpoint_response_lowercase() { + let body = serde_json::json!({ + "code": 0, + "msg": "ok", + "data": { + "url": "wss://example/ws?service_id=123", + "client_config": { + "ping_interval": 42 + } + } + }); + + let endpoint = parse_ws_endpoint_response(&body).unwrap(); + assert_eq!(endpoint.url, "wss://example/ws?service_id=123"); + assert_eq!(endpoint.ping_interval_secs, 42); + } + + #[test] + fn test_parse_ws_endpoint_response_uppercase() { + let body = serde_json::json!({ + "code": 0, + "msg": "ok", + "data": { + "URL": "wss://example/ws?service_id=321", + "ClientConfig": { + "PingInterval": 24 + } + } + }); + + let endpoint = parse_ws_endpoint_response(&body).unwrap(); + assert_eq!(endpoint.url, "wss://example/ws?service_id=321"); + assert_eq!(endpoint.ping_interval_secs, 24); + } + + #[test] + fn test_combine_payload_multi_package() { + let mut frame_parts = HashMap::new(); + + let headers_1 = vec![ + header("message_id", "msg-1"), + header("sum", "2"), + header("seq", "0"), + ]; + let headers_2 = vec![ + header("message_id", "msg-1"), + header("sum", "2"), + header("seq", "1"), + ]; + + let r1 = combine_payload(&headers_1, b"Hello ".to_vec(), &mut frame_parts); + assert!(r1.is_none()); + let r2 = combine_payload(&headers_2, b"World".to_vec(), &mut frame_parts).unwrap(); + assert_eq!(r2, b"Hello World".to_vec()); + } + + #[test] + fn test_parse_service_id() { + assert_eq!(parse_service_id("wss://foo/bar?service_id=123"), 123); + assert_eq!(parse_service_id("wss://foo/bar"), 0); + } + #[test] fn test_parse_feishu_event_v2_text() { let event = serde_json::json!({ @@ -796,4 +1529,394 @@ mod tests { let msg = parse_feishu_event(&event).unwrap(); assert_eq!(msg.thread_id, Some("om_root1".to_string())); } + + #[test] + fn test_parse_feishu_event_text_command_message() { + let event = serde_json::json!({ + "header": { "event_type": "im.message.receive_v1" }, + "event": { + "message": { + "message_type": "text", + "message_id": "om_x", + "chat_id": "oc_x", + "chat_type": "group", + "content": "{\"text\":\"/approve abc123\"}" + }, + "sender": { + "sender_id": { "open_id": "ou_x" }, + "sender_type": "user" + } + } + }); + + let msg = parse_feishu_event(&event).expect("message parsed"); + match msg.content { + ChannelContent::Command { name, args } => { + assert_eq!(name, "approve"); + assert_eq!(args, vec!["abc123"]); + } + other => panic!("unexpected content: {other:?}"), + } + } + + #[test] + fn test_parse_feishu_event_card_approve_action() { + let event = serde_json::json!({ + "schema": "2.0", + "header": { "event_type": "application.bot.menu_v6" }, + "event": { + "operator": { + "operator_id": { "open_id": "ou_operator" } + }, + "token": "card_callback_token", + "open_message_id": "om_card", + "open_chat_id": "oc_group", + "action": { + "value": { + "action": "approve", + "request_id": "550e8400-e29b-41d4-a716-446655440000" + } + } + } + }); + + let msg = parse_feishu_event(&event).expect("card callback parsed"); + match msg.content { + ChannelContent::Command { name, args } => { + assert_eq!(name, "approve"); + assert_eq!(args, vec!["550e8400-e29b-41d4-a716-446655440000"]); + } + other => panic!("unexpected content: {other:?}"), + } + assert_eq!(msg.sender.platform_id, "oc_group"); + } + + #[test] + fn test_parse_feishu_event_card_reject_action() { + let event = serde_json::json!({ + "schema": "2.0", + "header": { "event_type": "application.bot.menu_v6" }, + "event": { + "operator": { + "operator_id": { "open_id": "ou_operator" } + }, + "open_message_id": "om_card", + "open_chat_id": "oc_group", + "action": { + "value": { + "action": "reject", + "request_id": "deadbeef" + } + } + } + }); + + let msg = parse_feishu_event(&event).expect("card callback parsed"); + match msg.content { + ChannelContent::Command { name, args } => { + assert_eq!(name, "reject"); + assert_eq!(args, vec!["deadbeef"]); + } + other => panic!("unexpected content: {other:?}"), + } + } + + #[test] + fn test_parse_feishu_event_card_action_preserves_metadata() { + let event = serde_json::json!({ + "schema": "2.0", + "header": { "event_type": "application.bot.menu_v6" }, + "event": { + "operator": { + "operator_id": { "open_id": "ou_operator" } + }, + "open_message_id": "om_card", + "open_chat_id": "oc_group", + "action": { + "value": { + "action": "reject", + "request_id": "deadbeef" + } + } + } + }); + + let msg = parse_feishu_event(&event).expect("callback parsed"); + assert_eq!(msg.metadata["open_message_id"], "om_card"); + assert_eq!(msg.metadata["event_source"], "feishu_card_action"); + } + + #[test] + fn test_parse_feishu_event_card_action_invalid_payload() { + let event = serde_json::json!({ + "schema": "2.0", + "header": { "event_type": "application.bot.menu_v6" }, + "event": { + "operator": { + "operator_id": { "open_id": "ou_operator" } + }, + "open_chat_id": "oc_group", + "action": { + "value": { + "action": "approve" + } + } + } + }); + + assert!(parse_feishu_event(&event).is_none()); + } + + #[test] + fn test_feishu_webhook_response_for_card_action_acknowledges_success() { + let event = serde_json::json!({ + "schema": "2.0", + "header": { "event_type": "application.bot.menu_v6" }, + "event": { + "action": { "value": { "action": "approve", "request_id": "abc123" } }, + "open_chat_id": "oc_group" + } + }); + + let response = build_feishu_webhook_response(&event, true); + assert_eq!(response["code"], 0); + } + + #[test] + fn test_feishu_webhook_response_for_text_event_is_empty() { + let event = serde_json::json!({ + "schema": "2.0", + "header": { "event_type": "im.message.receive_v1" }, + "event": { + "message": { + "message_type": "text", + "message_id": "om_x", + "chat_id": "oc_x", + "chat_type": "group", + "content": "{\"text\":\"hello\"}" + }, + "sender": { + "sender_id": { "open_id": "ou_x" }, + "sender_type": "user" + } + } + }); + + let response = build_feishu_webhook_response(&event, true); + assert_eq!(response, serde_json::json!({})); + } + + #[test] + fn test_parse_feishu_event_card_action_trigger_approve() { + let event = serde_json::json!({ + "schema": "2.0", + "header": { "event_type": "card.action.trigger" }, + "event": { + "operator": { + "operator_id": { "open_id": "ou_operator" } + }, + "open_message_id": "om_card", + "open_chat_id": "oc_group", + "action": { + "value": { + "action": "approve", + "request_id": "abc123" + } + } + } + }); + + let msg = parse_feishu_event(&event).expect("callback parsed"); + match msg.content { + ChannelContent::Command { name, args } => { + assert_eq!(name, "approve"); + assert_eq!(args, vec!["abc123"]); + } + other => panic!("unexpected content: {other:?}"), + } + } + + #[test] + fn test_parse_feishu_event_card_action_trigger_preserves_metadata() { + let event = serde_json::json!({ + "schema": "2.0", + "header": { "event_type": "card.action.trigger" }, + "event": { + "operator": { + "operator_id": { "open_id": "ou_operator" } + }, + "open_message_id": "om_card", + "open_chat_id": "oc_group", + "action": { + "value": { + "action": "reject", + "request_id": "deadbeef" + } + } + } + }); + + let msg = parse_feishu_event(&event).expect("callback parsed"); + assert_eq!(msg.metadata["event_source"], "feishu_card_action"); + assert_eq!(msg.metadata["open_message_id"], "om_card"); + } + + #[test] + fn test_parse_feishu_event_ignores_unknown_card_callback_type() { + let event = serde_json::json!({ + "schema": "2.0", + "header": { "event_type": "card.action.unknown" }, + "event": { + "action": { + "value": { + "action": "approve", + "request_id": "abc123" + } + } + } + }); + + assert!(parse_feishu_event(&event).is_none()); + } + + #[test] + fn test_feishu_webhook_response_for_card_action_trigger() { + let event = serde_json::json!({ + "schema": "2.0", + "header": { "event_type": "card.action.trigger" }, + "event": { + "action": { "value": { "action": "approve", "request_id": "abc123" } }, + "open_chat_id": "oc_group" + } + }); + + let response = build_feishu_webhook_response(&event, true); + assert_eq!(response["code"], 0); + } + + #[test] + fn test_build_ack_frame_returns_empty_success_payload() { + let request = FeishuWsFrame { + seq_id: 1, + log_id: 2, + service: 3, + method: 1, + headers: vec![header("type", "event")], + payload_encoding: None, + payload_type: None, + payload: None, + log_id_new: None, + }; + + let frame = build_ack_frame(&request, 200); + let payload: serde_json::Value = + serde_json::from_slice(frame.payload.as_ref().unwrap()).unwrap(); + assert_eq!(payload["code"], 200); + assert_eq!(payload["data"], serde_json::json!([])); + } + + #[tokio::test] + async fn test_handle_data_frame_dispatches_card_action_trigger() { + use prost::Message as ProstMessage; + + let card_event = serde_json::json!({ + "schema": "2.0", + "header": { "event_type": "card.action.trigger" }, + "event": { + "operator": { "operator_id": { "open_id": "ou_op" } }, + "open_message_id": "om_card", + "open_chat_id": "oc_chat", + "action": { + "value": { + "action": "approve", + "request_id": "req_ws_1" + } + } + } + }); + let payload_bytes = serde_json::to_vec(&card_event).unwrap(); + + let frame = FeishuWsFrame { + seq_id: 10, + log_id: 20, + service: 1, + method: 0, + headers: vec![header("type", "card")], + payload_encoding: None, + payload_type: None, + payload: Some(payload_bytes), + log_id_new: None, + }; + + let (tx, mut rx) = mpsc::channel::(16); + let mut frame_parts = HashMap::new(); + + // Use futures unbounded channel as sink for ACK frames + let (mut ws_tx, mut ws_rx) = + futures::channel::mpsc::unbounded::(); + + FeishuAdapter::handle_data_frame(frame, &mut ws_tx, &tx, &mut frame_parts) + .await + .expect("handle_data_frame should succeed"); + + // Verify: message dispatched to channel + let msg = rx.try_recv().expect("should have received a channel message"); + match msg.content { + ChannelContent::Command { name, args } => { + assert_eq!(name, "approve"); + assert_eq!(args, vec!["req_ws_1"]); + } + other => panic!("unexpected content: {other:?}"), + } + + // Verify: ACK frame was sent back + let ack_msg = ws_rx.try_recv().expect("should have ACK frame"); + if let tokio_tungstenite::tungstenite::Message::Binary(data) = ack_msg { + let ack_frame = FeishuWsFrame::decode(data.as_ref()).unwrap(); + let ack_payload: serde_json::Value = + serde_json::from_slice(ack_frame.payload.as_ref().unwrap()).unwrap(); + assert_eq!(ack_payload["code"], 200); + } else { + panic!("expected binary ACK message"); + } + } + + #[test] + fn test_parse_feishu_event_card_action_trigger_context_nested() { + // Real card.action.trigger events nest open_chat_id under event.context + let event = serde_json::json!({ + "schema": "2.0", + "header": { "event_type": "card.action.trigger" }, + "event": { + "operator": { + "open_id": "ou_operator" + }, + "action": { + "value": { + "action": "approve", + "request_id": "real_req_1" + }, + "tag": "button" + }, + "host": "im_message", + "context": { + "open_message_id": "om_real_card", + "open_chat_id": "oc_real_chat" + } + } + }); + + let msg = parse_feishu_event(&event).expect("callback parsed"); + match msg.content { + ChannelContent::Command { name, args } => { + assert_eq!(name, "approve"); + assert_eq!(args, vec!["real_req_1"]); + } + other => panic!("unexpected content: {other:?}"), + } + // sender.platform_id must be the chat_id for send() to work + assert_eq!(msg.sender.platform_id, "oc_real_chat"); + assert_eq!(msg.metadata["open_message_id"], "om_real_card"); + assert_eq!(msg.metadata["event_source"], "feishu_card_action"); + } } diff --git a/crates/openfang-channels/src/gotify.rs b/crates/openfang-channels/src/gotify.rs index c0d93b333..0fe7d63d4 100644 --- a/crates/openfang-channels/src/gotify.rs +++ b/crates/openfang-channels/src/gotify.rs @@ -312,6 +312,12 @@ impl ChannelAdapter for GotifyAdapter { ) -> Result<(), Box> { let text = match content { ChannelContent::Text(t) => t, + ChannelContent::ApprovalRequest { request_id, agent_id, tool_name, action_summary } => { + format!( + "⏳ 待审批\nAgent: {}\n操作: {} — {}\nID: {}\n/reject {}", + agent_id, tool_name, action_summary, &request_id[..8], &request_id[..8] + ) + } _ => "(Unsupported content type)".to_string(), }; self.api_send_message("OpenFang", &text, 5).await diff --git a/crates/openfang-channels/src/nextcloud.rs b/crates/openfang-channels/src/nextcloud.rs index e39392544..9884be117 100644 --- a/crates/openfang-channels/src/nextcloud.rs +++ b/crates/openfang-channels/src/nextcloud.rs @@ -414,6 +414,13 @@ impl ChannelAdapter for NextcloudAdapter { ChannelContent::Text(text) => { self.api_send_message(&user.platform_id, &text).await?; } + ChannelContent::ApprovalRequest { request_id, agent_id, tool_name, action_summary } => { + let text = format!( + "⏳ 待审批\nAgent: {}\n操作: {} — {}\nID: {}\n/reject {}", + agent_id, tool_name, action_summary, &request_id[..8], &request_id[..8] + ); + self.api_send_message(&user.platform_id, &text).await?; + } _ => { self.api_send_message(&user.platform_id, "(Unsupported content type)") .await?; diff --git a/crates/openfang-channels/src/telegram.rs b/crates/openfang-channels/src/telegram.rs index 1469d1af1..314f9ecc3 100644 --- a/crates/openfang-channels/src/telegram.rs +++ b/crates/openfang-channels/src/telegram.rs @@ -429,6 +429,13 @@ impl ChannelAdapter for TelegramAdapter { let text = format!("/{name} {}", args.join(" ")); self.api_send_message(chat_id, text.trim()).await?; } + ChannelContent::ApprovalRequest { request_id, agent_id, tool_name, action_summary } => { + let text = format!( + "⏳ 待审批\nAgent: {}\n操作: {} — {}\nID: {}\n\n/reject {}", + agent_id, tool_name, action_summary, &request_id[..8], &request_id[..8] + ); + self.api_send_message(chat_id, &text).await?; + } } Ok(()) } diff --git a/crates/openfang-channels/src/twist.rs b/crates/openfang-channels/src/twist.rs index d935475ec..6352464ec 100644 --- a/crates/openfang-channels/src/twist.rs +++ b/crates/openfang-channels/src/twist.rs @@ -525,6 +525,12 @@ impl ChannelAdapter for TwistAdapter { ) -> Result<(), Box> { let text = match content { ChannelContent::Text(text) => text, + ChannelContent::ApprovalRequest { request_id, agent_id, tool_name, action_summary } => { + format!( + "⏳ 待审批\nAgent: {}\n操作: {} — {}\nID: {}\n/reject {}", + agent_id, tool_name, action_summary, &request_id[..8], &request_id[..8] + ) + } _ => "(Unsupported content type)".to_string(), }; diff --git a/crates/openfang-channels/src/types.rs b/crates/openfang-channels/src/types.rs index bfd3fe1b2..8c2527a3d 100644 --- a/crates/openfang-channels/src/types.rs +++ b/crates/openfang-channels/src/types.rs @@ -61,6 +61,14 @@ pub enum ChannelContent { name: String, args: Vec, }, + /// Approval request with action buttons (approve/reject). + /// Fields: request_id, agent_id, tool_name, action_summary + ApprovalRequest { + request_id: String, + agent_id: String, + tool_name: String, + action_summary: String, + }, } /// A unified message from any channel. diff --git a/crates/openfang-cli/Cargo.toml b/crates/openfang-cli/Cargo.toml index 057e28a9a..6bd815f67 100644 --- a/crates/openfang-cli/Cargo.toml +++ b/crates/openfang-cli/Cargo.toml @@ -26,8 +26,10 @@ serde = { workspace = true } serde_json = { workspace = true } toml = { workspace = true } dirs = { workspace = true } -reqwest = { workspace = true, features = ["blocking"] } +reqwest = { workspace = true, default-features = false, features = ["blocking", "rustls-tls"] } openfang-runtime = { path = "../openfang-runtime" } uuid = { workspace = true } ratatui = { workspace = true } colored = { workspace = true } +openssl = { version = "0.10", features = ["vendored"] } + diff --git a/crates/openfang-kernel/Cargo.toml b/crates/openfang-kernel/Cargo.toml index b7074a855..c557aa130 100644 --- a/crates/openfang-kernel/Cargo.toml +++ b/crates/openfang-kernel/Cargo.toml @@ -18,6 +18,7 @@ tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } toml = { workspace = true } +toml_edit = { workspace = true } dashmap = { workspace = true } crossbeam = { workspace = true } tracing = { workspace = true } diff --git a/crates/openfang-kernel/src/approval.rs b/crates/openfang-kernel/src/approval.rs index 8b270eb10..6a25023e1 100644 --- a/crates/openfang-kernel/src/approval.rs +++ b/crates/openfang-kernel/src/approval.rs @@ -5,16 +5,27 @@ use dashmap::DashMap; use openfang_types::approval::{ ApprovalDecision, ApprovalPolicy, ApprovalRequest, ApprovalResponse, RiskLevel, }; +use tokio::sync::broadcast; use tracing::{debug, info, warn}; use uuid::Uuid; /// Max pending requests per agent. const MAX_PENDING_PER_AGENT: usize = 5; +/// Broadcast channel capacity for approval notifications. +const NOTIFY_CAPACITY: usize = 32; + /// Manages approval requests with oneshot channels for blocking resolution. pub struct ApprovalManager { pending: DashMap, policy: std::sync::RwLock, + /// Broadcast channel — fires when a new approval request is submitted. + /// Channel adapters subscribe to this to notify users in real time. + notification_tx: broadcast::Sender, + /// Runtime-approved base commands (e.g. "openfang", "curl"). + /// Populated when a user approves an unlisted command; persisted to config.toml + /// but also kept here so the same session doesn't ask again. + pub runtime_allowed_cmds: dashmap::DashSet, } struct PendingRequest { @@ -24,12 +35,24 @@ struct PendingRequest { impl ApprovalManager { pub fn new(policy: ApprovalPolicy) -> Self { + let (notification_tx, _) = broadcast::channel(NOTIFY_CAPACITY); Self { pending: DashMap::new(), policy: std::sync::RwLock::new(policy), + notification_tx, + runtime_allowed_cmds: dashmap::DashSet::new(), } } + /// Subscribe to approval pending notifications. + /// + /// Each new approval request is broadcast to all subscribers immediately + /// before the agent blocks. Channel adapters use this to notify users in + /// real time so they can send `/approve ` without polling. + pub fn subscribe(&self) -> broadcast::Receiver { + self.notification_tx.subscribe() + } + /// Check if a tool requires approval based on current policy. pub fn requires_approval(&self, tool_name: &str) -> bool { let policy = self.policy.read().unwrap_or_else(|e| e.into_inner()); @@ -52,6 +75,10 @@ impl ApprovalManager { let timeout = std::time::Duration::from_secs(req.timeout_secs); let id = req.id; + // Notify subscribers BEFORE inserting into pending map / blocking. + // This lets channel adapters send a real-time notification to the user. + let _ = self.notification_tx.send(req.clone()); + let (tx, rx) = tokio::sync::oneshot::channel(); self.pending.insert( id, diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index 88e0baf44..8b83aff9c 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -1045,9 +1045,12 @@ impl OpenFangKernel { || restored_entry.manifest.model.provider == "default"; let is_default_model = restored_entry.manifest.model.model.is_empty() || restored_entry.manifest.model.model == "default"; - let is_auto_spawned = restored_entry.name == "assistant" - && restored_entry.manifest.description == "General-purpose assistant"; - if is_default_provider && is_default_model || is_auto_spawned { + // Hand agents always declare `model = "default"` in their HAND.toml, + // but the DB stores the resolved name from when they were spawned. + // Always re-apply the current default_model for hand agents so that + // changing default_model in config.toml takes effect after restart. + let is_hand_agent = restored_entry.tags.iter().any(|t| t.starts_with("hand:")); + if is_default_provider && is_default_model || is_hand_agent { if !dm.provider.is_empty() { restored_entry.manifest.model.provider = dm.provider.clone(); } @@ -1055,9 +1058,14 @@ impl OpenFangKernel { restored_entry.manifest.model.model = dm.model.clone(); } if !dm.api_key_env.is_empty() { - restored_entry.manifest.model.api_key_env = Some(dm.api_key_env.clone()); + // For hand agents always override; for default agents only if unset + if is_hand_agent || restored_entry.manifest.model.api_key_env.is_none() { + restored_entry.manifest.model.api_key_env = Some(dm.api_key_env.clone()); + } } - if dm.base_url.is_some() { + if dm.base_url.is_some() + && (is_hand_agent || restored_entry.manifest.model.base_url.is_none()) + { restored_entry.manifest.model.base_url.clone_from(&dm.base_url); } } @@ -1151,7 +1159,7 @@ impl OpenFangKernel { if manifest.exec_policy.is_none() { manifest.exec_policy = Some(self.config.exec_policy.clone()); } - info!(agent = %name, id = %agent_id, exec_mode = ?manifest.exec_policy.as_ref().map(|p| &p.mode), "Agent exec_policy resolved"); + info!(agent = %name, id = %agent_id, exec_mode = ?manifest.exec_policy.as_ref().map(|p| &p.mode), exec_cmds = ?manifest.exec_policy.as_ref().map(|p| &p.allowed_commands), "Agent exec_policy resolved"); // Overlay kernel default_model onto agent if agent didn't explicitly choose. // Treat empty or "default" as "use the kernel's configured default_model". @@ -3928,21 +3936,65 @@ impl OpenFangKernel { let mut chain: Vec<(std::sync::Arc, String)> = vec![(primary.clone(), String::new())]; for fb in &manifest.fallback_models { - let config = DriverConfig { - provider: fb.provider.clone(), - api_key: fb - .api_key_env + // Resolve "default" sentinel: inherit the kernel's configured default provider/model + let is_default_provider = + fb.provider.is_empty() || fb.provider == "default"; + let is_default_model = fb.model.is_empty() || fb.model == "default"; + let (resolved_provider, resolved_model) = if is_default_provider || is_default_model { + let override_guard = self + .default_model_override + .read() + .unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner()); + let dm = override_guard .as_ref() - .and_then(|env| std::env::var(env).ok()), + .unwrap_or(&self.config.default_model); + let provider = if is_default_provider { + dm.provider.clone() + } else { + fb.provider.clone() + }; + let model = if is_default_model { + dm.model.clone() + } else { + fb.model.clone() + }; + (provider, model) + } else { + (fb.provider.clone(), fb.model.clone()) + }; + + let api_key = if is_default_provider { + // For default provider, prefer agent's api_key_env hint, then fall back to + // the kernel's default key env var + let override_guard = self + .default_model_override + .read() + .unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner()); + let dm = override_guard + .as_ref() + .unwrap_or(&self.config.default_model); + fb.api_key_env + .as_ref() + .and_then(|env| std::env::var(env).ok()) + .or_else(|| std::env::var(&dm.api_key_env).ok()) + } else { + fb.api_key_env + .as_ref() + .and_then(|env| std::env::var(env).ok()) + }; + + let config = DriverConfig { + provider: resolved_provider.clone(), + api_key, base_url: fb .base_url .clone() - .or_else(|| self.config.provider_urls.get(&fb.provider).cloned()), + .or_else(|| self.config.provider_urls.get(&resolved_provider).cloned()), }; match drivers::create_driver(&config) { - Ok(d) => chain.push((d, fb.model.clone())), + Ok(d) => chain.push((d, resolved_model)), Err(e) => { - warn!("Fallback driver '{}' failed to init: {e}", fb.provider); + warn!("Fallback driver '{}' failed to init: {e}", resolved_provider); } } } @@ -5228,6 +5280,65 @@ impl KernelHandle for OpenFangKernel { Ok(decision == ApprovalDecision::Approved) } + fn is_cmd_approved(&self, _agent_id: &str, base_cmd: &str) -> bool { + // Check static allowlist from config + if self.config.exec_policy.allowed_commands.iter().any(|c| c == base_cmd) { + return true; + } + // Check runtime-approved set (approved this session, pending restart pickup) + self.approval_manager.runtime_allowed_cmds.contains(base_cmd) + } + + async fn persist_cmd_approval(&self, base_cmd: &str) -> Result<(), String> { + // Skip if already approved (static or runtime) + if self.is_cmd_approved("", base_cmd) { + return Ok(()); + } + + // Add to runtime set so the current session doesn't ask again + self.approval_manager.runtime_allowed_cmds.insert(base_cmd.to_string()); + + // Update in-memory config (unsafe cell-bypass via interior mutability workaround: + // KernelConfig.exec_policy is not behind a lock, so we patch config.toml on disk + // and rely on the fact that exec_policy is checked fresh each call from the manifest). + // Write the updated allowlist to config.toml so future restarts pick it up. + let config_path = self.config.home_dir.join("config.toml"); + let raw = std::fs::read_to_string(&config_path) + .map_err(|e| format!("Failed to read config.toml: {e}"))?; + + let mut doc = raw + .parse::() + .map_err(|e| format!("Failed to parse config.toml: {e}"))?; + + // Ensure [exec_policy] table exists + if !doc.contains_key("exec_policy") { + doc["exec_policy"] = toml_edit::table(); + } + let ep = doc["exec_policy"] + .as_table_mut() + .ok_or("exec_policy is not a table")?; + + // Ensure allowed_commands array exists + if !ep.contains_key("allowed_commands") { + ep["allowed_commands"] = toml_edit::value(toml_edit::Array::new()); + } + let arr = ep["allowed_commands"] + .as_array_mut() + .ok_or("allowed_commands is not an array")?; + + // Add if not already present + let already = arr.iter().any(|v: &toml_edit::Value| v.as_str() == Some(base_cmd)); + if !already { + arr.push(base_cmd); + } + + std::fs::write(&config_path, doc.to_string()) + .map_err(|e| format!("Failed to write config.toml: {e}"))?; + + info!(base_cmd, "Persisted exec_policy.allowed_commands entry to config.toml"); + Ok(()) + } + fn list_a2a_agents(&self) -> Vec<(String, String)> { let agents = self .a2a_external_agents diff --git a/crates/openfang-runtime/src/drivers/openai.rs b/crates/openfang-runtime/src/drivers/openai.rs index 212122f01..a677f7593 100644 --- a/crates/openfang-runtime/src/drivers/openai.rs +++ b/crates/openfang-runtime/src/drivers/openai.rs @@ -58,10 +58,17 @@ struct OaiRequest { } /// Returns true if a model uses `max_completion_tokens` instead of `max_tokens`. +/// +/// Only matches canonical OpenAI model families. Custom/proxy model names that +/// happen to start with "gpt-5" (e.g. "gpt-5.3-codex") are excluded because +/// third-party endpoints typically only support `max_tokens`. fn uses_completion_tokens(model: &str) -> bool { let m = model.to_lowercase(); - m.starts_with("gpt-5") - || m.starts_with("gpt5") + // Only match clean GPT-5 IDs: "gpt-5", "gpt-5-mini", etc. + // Exclude names with version dots like "gpt-5.3-codex" (proxy/custom models). + let is_gpt5 = (m == "gpt-5" || m.starts_with("gpt-5-") || m == "gpt5" || m.starts_with("gpt5-")) + && !m.contains('.'); + is_gpt5 || m.starts_with("o1") || m.starts_with("o3") || m.starts_with("o4") diff --git a/crates/openfang-runtime/src/kernel_handle.rs b/crates/openfang-runtime/src/kernel_handle.rs index 1bb2e76eb..f7b095ad0 100644 --- a/crates/openfang-runtime/src/kernel_handle.rs +++ b/crates/openfang-runtime/src/kernel_handle.rs @@ -134,6 +134,18 @@ pub trait KernelHandle: Send + Sync { Ok(true) // Default: auto-approve } + /// Check whether a base command has already been persistently approved for an agent. + /// Returns true if the command is in the exec allowlist (no approval needed). + fn is_cmd_approved(&self, _agent_id: &str, _base_cmd: &str) -> bool { + false + } + + /// Persist approval of a base command: adds it to exec_policy.allowed_commands + /// in memory and saves to config.toml so it survives restarts. + async fn persist_cmd_approval(&self, _base_cmd: &str) -> Result<(), String> { + Ok(()) + } + /// List available Hands and their activation status. async fn hand_list(&self) -> Result, String> { Err("Hands system not available".to_string()) diff --git a/crates/openfang-runtime/src/subprocess_sandbox.rs b/crates/openfang-runtime/src/subprocess_sandbox.rs index 3e3bce4f0..2764739e8 100644 --- a/crates/openfang-runtime/src/subprocess_sandbox.rs +++ b/crates/openfang-runtime/src/subprocess_sandbox.rs @@ -105,7 +105,7 @@ fn extract_base_command(cmd: &str) -> &str { /// Extract all commands from a shell command string. /// Handles pipes (`|`), semicolons (`;`), `&&`, and `||`. -fn extract_all_commands(command: &str) -> Vec<&str> { +pub(crate) fn extract_all_commands(command: &str) -> Vec<&str> { let mut commands = Vec::new(); // Split on pipe, semicolon, &&, || // We need to split carefully: first split on ; and &&/||, then on | @@ -153,6 +153,7 @@ pub fn validate_command_allowlist(command: &str, policy: &ExecPolicy) -> Result< } ExecSecurityMode::Allowlist => { let base_commands = extract_all_commands(command); + tracing::debug!(?base_commands, ?policy.allowed_commands, "Checking allowlist"); for base in &base_commands { // Check safe_bins first if policy.safe_bins.iter().any(|sb| sb == base) { diff --git a/crates/openfang-runtime/src/tool_runner.rs b/crates/openfang-runtime/src/tool_runner.rs index f37db523e..69896c2c4 100644 --- a/crates/openfang-runtime/src/tool_runner.rs +++ b/crates/openfang-runtime/src/tool_runner.rs @@ -5,6 +5,7 @@ use crate::kernel_handle::KernelHandle; use crate::mcp; +use crate::subprocess_sandbox::validate_command_allowlist; use crate::web_search::{parse_ddg_results, WebToolsContext}; use openfang_skills::registry::SkillRegistry; use openfang_types::taint::{TaintLabel, TaintSink, TaintedValue}; @@ -131,7 +132,34 @@ pub async fn execute_tool( } } + // For shell_exec: check exec_policy BEFORE the approval gate. + // - If command is in allowlist, skip approval entirely (fast path) + // - If NOT in allowlist, escalate to approval gate (line ~280) + // This avoids asking for approval for commands that are already pre-approved. + let requires_approval_bypass_check = if tool_name == "shell_exec" { + let command = input.get("command").and_then(|v| v.as_str()).unwrap_or(""); + if let Some(policy) = exec_policy { + // Check if command passes allowlist WITHOUT triggering approval + if validate_command_allowlist(command, policy).is_ok() { + // Command is in allowlist - skip approval entirely + tracing::debug!(command, "Shell command in allowlist - bypassing approval"); + false // doesn't require approval + } else { + // Command NOT in allowlist - will need approval + true // requires approval (will be checked below) + } + } else { + // No exec_policy = allow all, no approval needed + false + } + } else { + // Non-shell_exec tools: use normal approval logic + true + }; + // Approval gate: check if this tool requires human approval before execution + // Skip this check for shell_exec if already validated by exec_policy allowlist above + if requires_approval_bypass_check { if let Some(kh) = kernel { if kh.requires_approval(tool_name) { let agent_id_str = caller_agent_id.unwrap_or("unknown"); @@ -144,6 +172,16 @@ pub async fn execute_tool( match kh.request_approval(agent_id_str, tool_name, &summary).await { Ok(true) => { debug!(tool_name, "Approval granted — proceeding with execution"); + // Persist approved shell commands to config.toml + if tool_name == "shell_exec" { + if let Some(cmd) = input.get("command").and_then(|v| v.as_str()) { + for base in crate::subprocess_sandbox::extract_all_commands(cmd) { + if let Err(e) = kh.persist_cmd_approval(base).await { + warn!(base_cmd = %base, error = %e, "Failed to persist command approval"); + } + } + } + } } Ok(false) => { warn!(tool_name, "Approval denied — blocking tool execution"); @@ -167,6 +205,7 @@ pub async fn execute_tool( } } } + } debug!(tool_name, "Executing tool"); let result = match tool_name { @@ -207,25 +246,39 @@ pub async fn execute_tool( } // Shell tool — exec policy + taint check + // NOTE: Early check at line ~140 already handled allowlist bypass and approval escalation. + // This block only handles edge cases: deny mode, or no kernel available. "shell_exec" => { let command = input["command"].as_str().unwrap_or(""); - // Exec policy enforcement + // Only check for edge cases - main flow handled by early check if let Some(policy) = exec_policy { if let Err(reason) = crate::subprocess_sandbox::validate_command_allowlist(command, policy) { - return ToolResult { - tool_use_id: tool_use_id.to_string(), - content: format!( - "shell_exec blocked: {reason}. Current exec_policy.mode = '{:?}'. \ - To allow shell commands, set exec_policy.mode = 'full' in the agent manifest or config.toml.", - policy.mode - ), - is_error: true, - }; + // Deny mode: hard block + if matches!(policy.mode, openfang_types::config::ExecSecurityMode::Deny) { + return ToolResult { + tool_use_id: tool_use_id.to_string(), + content: format!( + "shell_exec blocked: {reason}. Set exec_policy.mode = 'allowlist' or 'full'.", + ), + is_error: true, + }; + } + // No kernel: hard block (no approval system available) + if kernel.is_none() { + return ToolResult { + tool_use_id: tool_use_id.to_string(), + content: format!( + "shell_exec blocked: {reason}. Add '{command}' to exec_policy.allowed_commands.", + ), + is_error: true, + }; + } + // Kernel exists: early check already handled approval } } - // Skip taint check for Full exec policy (e.g. hand agents that need curl for APIs) + // Skip taint check for Full exec policy let is_full_exec = exec_policy .is_some_and(|p| p.mode == openfang_types::config::ExecSecurityMode::Full); if !is_full_exec {