From c76048d549ee623f2947f95bcd86e5a79b7680c9 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Mon, 11 May 2026 10:54:07 -0400 Subject: [PATCH 01/43] feat(transports): add HTTP/WebSocket transport support Adds server-side HTTP/WebSocket transport support for exposing ACP agents over HTTP/WS, along with a client transport implementing ConnectTo for connecting to remote agents over HTTP/SSE. --- Cargo.lock | 707 +++++++++++++++++- Cargo.toml | 5 + md/SUMMARY.md | 4 + md/http-transport.md | 36 + md/introduction.md | 3 + src/agent-client-protocol-http/CHANGELOG.md | 6 + src/agent-client-protocol-http/Cargo.toml | 44 ++ src/agent-client-protocol-http/README.md | 8 + src/agent-client-protocol-http/src/client.rs | 408 ++++++++++ .../src/connection.rs | 304 ++++++++ .../src/http_server.rs | 225 ++++++ src/agent-client-protocol-http/src/lib.rs | 9 + .../src/protocol.rs | 31 + src/agent-client-protocol-http/src/server.rs | 124 +++ .../src/websocket_server.rs | 133 ++++ 15 files changed, 2012 insertions(+), 35 deletions(-) create mode 100644 md/http-transport.md create mode 100644 src/agent-client-protocol-http/CHANGELOG.md create mode 100644 src/agent-client-protocol-http/Cargo.toml create mode 100644 src/agent-client-protocol-http/README.md create mode 100644 src/agent-client-protocol-http/src/client.rs create mode 100644 src/agent-client-protocol-http/src/connection.rs create mode 100644 src/agent-client-protocol-http/src/http_server.rs create mode 100644 src/agent-client-protocol-http/src/lib.rs create mode 100644 src/agent-client-protocol-http/src/protocol.rs create mode 100644 src/agent-client-protocol-http/src/server.rs create mode 100644 src/agent-client-protocol-http/src/websocket_server.rs diff --git a/Cargo.lock b/Cargo.lock index 15e16b4e..47530ed3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -77,6 +77,31 @@ dependencies = [ "syn", ] +[[package]] +name = "agent-client-protocol-http" +version = "0.12.1" +dependencies = [ + "agent-client-protocol", + "agent-client-protocol-test", + "async-stream", + "axum", + "eventsource-stream", + "futures", + "jsonrpcmsg", + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-tungstenite", + "tokio-util", + "tower-http", + "tracing", + "tracing-subscriber", + "url", + "uuid", +] + [[package]] name = "agent-client-protocol-polyfill" version = "0.12.1" @@ -221,7 +246,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -232,7 +257,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -268,7 +293,7 @@ dependencies = [ "polling", "rustix", "slab", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -315,7 +340,7 @@ dependencies = [ "rustix", "signal-hook-registry", "slab", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -376,6 +401,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", + "axum-macros", + "base64", "bytes", "form_urlencoded", "futures-util", @@ -394,8 +421,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -421,6 +450,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "base64" version = "0.22.1" @@ -433,6 +473,15 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "blocking" version = "1.6.2" @@ -573,12 +622,31 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "darling" version = "0.23.0" @@ -613,6 +681,12 @@ dependencies = [ "syn", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "deranged" version = "0.5.8" @@ -646,11 +720,21 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -682,7 +766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -706,6 +790,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "eventsource-stream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" +dependencies = [ + "futures-core", + "nom", + "pin-project-lite", +] + [[package]] name = "expect-test" version = "1.5.1" @@ -863,6 +958,43 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -871,7 +1003,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -917,9 +1049,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -981,6 +1113,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1205,9 +1353,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ "cfg-if", "futures-util", @@ -1270,6 +1418,12 @@ version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -1287,9 +1441,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "mime" @@ -1297,6 +1451,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.2.0" @@ -1305,7 +1465,7 @@ checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1320,13 +1480,23 @@ dependencies = [ "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1462,7 +1632,7 @@ dependencies = [ "hermit-abi", "pin-project-lite", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1480,6 +1650,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1513,6 +1692,61 @@ dependencies = [ "windows", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -1522,12 +1756,47 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +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.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1586,6 +1855,47 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.4" @@ -1616,10 +1926,24 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rmcp" version = "1.7.0" @@ -1634,7 +1958,7 @@ dependencies = [ "pastey", "pin-project-lite", "process-wrap", - "reqwest", + "reqwest 0.13.4", "rmcp-macros", "schemars 1.2.1", "serde", @@ -1685,7 +2009,42 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -1859,6 +2218,17 @@ dependencies = [ "syn", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1909,7 +2279,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1967,6 +2337,12 @@ dependencies = [ "syn", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -2097,7 +2473,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2111,6 +2487,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -2122,6 +2508,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -2263,6 +2661,28 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2281,6 +2701,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -2311,7 +2737,7 @@ version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ - "getrandom", + "getrandom 0.4.2", "js-sys", "wasm-bindgen", ] @@ -2322,6 +2748,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "vte" version = "0.14.1" @@ -2366,9 +2798,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -2379,9 +2811,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.71" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ "js-sys", "wasm-bindgen", @@ -2389,9 +2821,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2399,9 +2831,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -2412,9 +2844,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -2441,6 +2873,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm-streams" version = "0.5.0" @@ -2468,14 +2913,33 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows" version = "0.62.2" @@ -2577,6 +3041,24 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -2586,6 +3068,39 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.2.1" @@ -2595,6 +3110,102 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -2718,6 +3329,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -2739,6 +3370,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/Cargo.toml b/Cargo.toml index 9a5c89c7..5ba0a327 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ # dev-dependency edges, but cargo publish still validates them. "src/agent-client-protocol-polyfill", "src/agent-client-protocol-conductor", + "src/agent-client-protocol-http", "src/agent-client-protocol-rmcp", "src/agent-client-protocol-test", "src/agent-client-protocol-trace-viewer", @@ -28,6 +29,7 @@ homepage = "https://github.com/agentclientprotocol/rust-sdk" agent-client-protocol = { path = "src/agent-client-protocol", version = "0.12.1" } agent-client-protocol-conductor = { path = "src/agent-client-protocol-conductor", version = "0.12.1" } agent-client-protocol-derive = { path = "src/agent-client-protocol-derive", version = "0.11.1" } +agent-client-protocol-http = { path = "src/agent-client-protocol-http", version = "0.12.1" } agent-client-protocol-polyfill = { path = "src/agent-client-protocol-polyfill", version = "0.12.1" } agent-client-protocol-rmcp = { path = "src/agent-client-protocol-rmcp", version = "0.11.2" } agent-client-protocol-test = { path = "src/agent-client-protocol-test" } @@ -63,6 +65,9 @@ clap = { version = "4.5", features = ["derive"] } # HTTP axum = "0.8" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +eventsource-stream = "0.2" +url = "2.5" async-process = "2" async-stream = "0.3.6" blocking = "1" diff --git a/md/SUMMARY.md b/md/SUMMARY.md index 67503646..2fbf430d 100644 --- a/md/SUMMARY.md +++ b/md/SUMMARY.md @@ -8,6 +8,10 @@ - [Protocol Reference](./protocol.md) - [Protocol V2](./protocol-v2.md) +# Transports + +- [HTTP / WebSocket Transport](./http-transport.md) + # Conductor (agent-client-protocol-conductor) - [Conductor Design](./conductor.md) diff --git a/md/http-transport.md b/md/http-transport.md new file mode 100644 index 00000000..80e9cb34 --- /dev/null +++ b/md/http-transport.md @@ -0,0 +1,36 @@ +# HTTP / WebSocket Transport + +`agent-client-protocol-http` exposes ACP agents over one `/acp` endpoint. + +- `POST /acp` with `initialize` creates a connection and returns `Acp-Connection-Id`. +- Later `POST /acp` requests include `Acp-Connection-Id`; session-scoped requests also include `Acp-Session-Id` or `params.sessionId`. +- `GET /acp` with `Accept: text/event-stream` streams agent messages over SSE. +- `GET /acp` with a WebSocket upgrade uses text frames for JSON-RPC messages. +- `DELETE /acp` tears down the connection. + +## Server + +```rust +use agent_client_protocol_http::AcpHttpServer; + +let app = AcpHttpServer::new(|| my_agent()).into_router(); +let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await?; +axum::serve(listener, app).await?; +``` + +## Client + +```rust +use agent_client_protocol_http::HttpClient; + +let transport = HttpClient::new("http://127.0.0.1:8080")?; +my_client().connect_to(transport).await?; +``` + +The same `HttpClient` also speaks WebSocket — pass a `ws://` or `wss://` URL +and it will open a single bidirectional connection instead of using POST + SSE: + +```rust +let transport = HttpClient::new("ws://127.0.0.1:8080")?; +my_client().connect_to(transport).await?; +``` diff --git a/md/introduction.md b/md/introduction.md index 220bdc54..dcced41f 100644 --- a/md/introduction.md +++ b/md/introduction.md @@ -22,6 +22,7 @@ The `agent-client-protocol` crate includes a [`concepts`](https://docs.rs/agent- src/ ├── agent-client-protocol/ # Core protocol SDK ├── agent-client-protocol-tokio/ # Tokio utilities (process spawning) +├── agent-client-protocol-http/ # HTTP/SSE/WebSocket transport ├── agent-client-protocol-rmcp/ # Integration with rmcp crate ├── agent-client-protocol-cookbook/ # Usage patterns (rendered as rustdoc) ├── agent-client-protocol-derive/ # Proc macros @@ -37,11 +38,13 @@ src/ graph TD acp[agent-client-protocol
Core SDK] tokio[agent-client-protocol-tokio
Process spawning] + http[agent-client-protocol-http
HTTP/SSE/WebSocket transport] rmcp[agent-client-protocol-rmcp
rmcp integration] conductor[agent-client-protocol-conductor
Proxy orchestration] cookbook[agent-client-protocol-cookbook
Usage patterns] tokio --> acp + http --> acp rmcp --> acp conductor --> acp conductor --> tokio diff --git a/src/agent-client-protocol-http/CHANGELOG.md b/src/agent-client-protocol-http/CHANGELOG.md new file mode 100644 index 00000000..865bde2e --- /dev/null +++ b/src/agent-client-protocol-http/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +## [Unreleased] + +### Added +- HTTP/SSE/WebSocket transport for ACP agents diff --git a/src/agent-client-protocol-http/Cargo.toml b/src/agent-client-protocol-http/Cargo.toml new file mode 100644 index 00000000..52ecdd39 --- /dev/null +++ b/src/agent-client-protocol-http/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "agent-client-protocol-http" +version = "0.12.1" +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "HTTP and WebSocket transport for the Agent Client Protocol (ACP)" +keywords = ["acp", "agent", "protocol", "http", "websocket"] +categories = ["development-tools", "web-programming::http-server"] + +[dependencies] +agent-client-protocol.workspace = true +jsonrpcmsg.workspace = true + +futures.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio = { workspace = true, features = ["sync", "rt", "macros", "time"] } +tokio-util.workspace = true +tracing.workspace = true +thiserror.workspace = true + +# Server +axum = { workspace = true, features = ["ws", "macros"] } +tower-http = { version = "0.6", features = ["cors"] } +async-stream = { workspace = true } +uuid = { workspace = true } + +# Client +reqwest = { workspace = true, features = ["stream"] } +eventsource-stream = { workspace = true } +url = { workspace = true } +tokio-tungstenite = "0.29" + +[dev-dependencies] +agent-client-protocol = { workspace = true } +agent-client-protocol-test.workspace = true +tokio = { workspace = true, features = ["full"] } +tracing-subscriber.workspace = true + +[lints] +workspace = true diff --git a/src/agent-client-protocol-http/README.md b/src/agent-client-protocol-http/README.md new file mode 100644 index 00000000..496e29e4 --- /dev/null +++ b/src/agent-client-protocol-http/README.md @@ -0,0 +1,8 @@ +# agent-client-protocol-http + +HTTP/WebSocket transport for ACP agents. + +- **Server**: `AcpHttpServer` exposes agents over HTTP + SSE with optional WebSocket upgrade +- **Client**: `HttpClient` connects to remote agents over HTTP + SSE + +See the [documentation](https://docs.rs/agent-client-protocol-http) for usage examples. diff --git a/src/agent-client-protocol-http/src/client.rs b/src/agent-client-protocol-http/src/client.rs new file mode 100644 index 00000000..2be1b308 --- /dev/null +++ b/src/agent-client-protocol-http/src/client.rs @@ -0,0 +1,408 @@ +use std::{collections::HashSet, sync::Arc}; + +use agent_client_protocol::{Agent, Channel, Client, ConnectTo, Error as AcpError, jsonrpcmsg}; +use futures::{SinkExt, StreamExt, future::BoxFuture}; +use jsonrpcmsg::Message; +use thiserror::Error; +use tokio::sync::Mutex; +use tokio_tungstenite::tungstenite::Message as WsMessage; +use tracing::{debug, error, trace, warn}; + +use crate::protocol::{ + HEADER_CONNECTION_ID, HEADER_SESSION_ID, is_initialize_request, method_requires_session_header, + session_id_from_params, +}; + +#[derive(Debug, Error)] +pub enum HttpClientError { + #[error("invalid URL: {0}")] + InvalidUrl(#[from] url::ParseError), + #[error("failed to build HTTP client: {0}")] + Reqwest(#[from] reqwest::Error), +} + +pub struct HttpClient { + endpoint: url::Url, + http: reqwest::Client, +} + +impl std::fmt::Debug for HttpClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HttpClient") + .field("endpoint", &self.endpoint.as_str()) + .finish_non_exhaustive() + } +} + +impl HttpClient { + pub fn new(base_url: impl AsRef) -> Result { + Self::with_client(base_url, reqwest::Client::new()) + } + + pub fn with_client( + base_url: impl AsRef, + http: reqwest::Client, + ) -> Result { + let mut endpoint = url::Url::parse(base_url.as_ref())?; + let path = endpoint.path().trim_end_matches('/').to_string(); + let path = if path.is_empty() { + "/acp".to_string() + } else if path.ends_with("/acp") { + path + } else { + format!("{path}/acp") + }; + endpoint.set_path(&path); + Ok(Self { endpoint, http }) + } + + fn is_websocket(&self) -> bool { + matches!(self.endpoint.scheme(), "ws" | "wss") + } +} + +impl ConnectTo for HttpClient { + async fn connect_to(self, client: impl ConnectTo) -> Result<(), AcpError> { + let (channel, transport) = ConnectTo::::into_channel_and_future(self); + match futures::future::select( + std::pin::pin!(client.connect_to(channel)), + std::pin::pin!(transport), + ) + .await + { + futures::future::Either::Left((result, _)) + | futures::future::Either::Right((result, _)) => result, + } + } + + fn into_channel_and_future(self) -> (Channel, BoxFuture<'static, Result<(), AcpError>>) { + let (caller, transport) = Channel::duplex(); + (caller, Box::pin(run(self, transport))) + } +} + +async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { + if client.is_websocket() { + return run_ws(client, channel).await; + } + let HttpClient { endpoint, http } = client; + let Channel { + rx: mut outgoing, + tx: incoming, + } = channel; + let (open_session_tx, mut open_session_rx) = futures::channel::mpsc::unbounded(); + let state = Arc::new(ClientState { + endpoint, + http, + connection_id: Mutex::new(None), + open_session_streams: Mutex::new(HashSet::new()), + incoming, + open_session_tx, + }); + let mut sse_tasks = Vec::new(); + + loop { + let msg = tokio::select! { + msg = outgoing.next() => match msg { + Some(Ok(msg)) => msg, + Some(Err(e)) => { + error!("upstream channel produced error: {e}"); + return Err(e); + } + None => break, + }, + Some(session_id) = open_session_rx.next() => { + spawn_sse(state.clone(), Some(session_id), &mut sse_tasks); + continue; + } + }; + + if state.connection_id.lock().await.is_none() { + if !is_initialize_request(&msg) { + return Err(AcpError::invalid_request() + .data("ACP HTTP transport: first message must be `initialize`")); + } + if let Err(e) = state.initialize(msg).await { + error!("initialize failed: {e}"); + return Err(AcpError::internal_error().data(format!("initialize: {e}"))); + } + spawn_sse(state.clone(), None, &mut sse_tasks); + continue; + } + + if let Message::Request(req) = &msg { + if let Some(session_id) = req.params.as_ref().and_then(session_id_from_params) { + if state + .open_session_streams + .lock() + .await + .insert(session_id.clone()) + { + spawn_sse(state.clone(), Some(session_id), &mut sse_tasks); + } + } + } + + if let Err(e) = state.post(msg).await { + error!("POST failed: {e}"); + return Err(AcpError::internal_error().data(format!("POST: {e}"))); + } + } + + state.delete().await; + for task in sse_tasks { + task.abort(); + } + Ok(()) +} + +fn spawn_sse( + state: Arc, + session_id: Option, + tasks: &mut Vec>, +) { + tasks.push(tokio::spawn(async move { + let label = session_id.clone(); + if let Err(e) = state.sse(session_id).await { + warn!(session_id = ?label, "SSE stream ended: {e}"); + } + })); +} + +struct ClientState { + endpoint: url::Url, + http: reqwest::Client, + connection_id: Mutex>, + open_session_streams: Mutex>, + incoming: futures::channel::mpsc::UnboundedSender>, + open_session_tx: futures::channel::mpsc::UnboundedSender, +} + +impl ClientState { + async fn initialize(&self, msg: Message) -> Result<(), String> { + let response = self + .http + .post(self.endpoint.clone()) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .json(&msg) + .send() + .await + .map_err(|e| e.to_string())?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("HTTP {status}: {body}")); + } + + let connection_id = response + .headers() + .get(HEADER_CONNECTION_ID) + .and_then(|v| v.to_str().ok()) + .map(String::from) + .ok_or_else(|| format!("server did not return {HEADER_CONNECTION_ID} header"))?; + let message = response + .json::() + .await + .map_err(|e| e.to_string())?; + + *self.connection_id.lock().await = Some(connection_id); + self.deliver(message); + Ok(()) + } + + async fn post(&self, msg: Message) -> Result<(), String> { + let session_id = match &msg { + Message::Request(req) => { + let session_id = req.params.as_ref().and_then(session_id_from_params); + if method_requires_session_header(&req.method) && session_id.is_none() { + return Err(format!( + "method `{}` requires sessionId in params", + req.method + )); + } + session_id + } + Message::Response(_) => None, + }; + let connection_id = self + .connection_id + .lock() + .await + .clone() + .ok_or_else(|| "POST attempted before initialize".to_string())?; + let mut request = self + .http + .post(self.endpoint.clone()) + .header("Accept", "application/json") + .header(HEADER_CONNECTION_ID, connection_id) + .json(&msg); + if let Some(session_id) = session_id { + request = request.header(HEADER_SESSION_ID, session_id); + } + + let response = request.send().await.map_err(|e| e.to_string())?; + if response.status().as_u16() != 202 && !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("HTTP {status}: {body}")); + } + Ok(()) + } + + async fn delete(&self) { + let Some(connection_id) = self.connection_id.lock().await.clone() else { + return; + }; + if let Err(e) = self + .http + .delete(self.endpoint.clone()) + .header(HEADER_CONNECTION_ID, connection_id) + .send() + .await + { + debug!("DELETE failed (ignored): {e}"); + } + } + + async fn sse(&self, session_id: Option) -> Result<(), String> { + let connection_id = self + .connection_id + .lock() + .await + .clone() + .ok_or_else(|| "SSE attempted before initialize".to_string())?; + let mut request = self + .http + .get(self.endpoint.clone()) + .header("Accept", "text/event-stream") + .header(HEADER_CONNECTION_ID, connection_id); + if let Some(session_id) = &session_id { + request = request.header(HEADER_SESSION_ID, session_id); + } + + let response = request.send().await.map_err(|e| e.to_string())?; + if !response.status().is_success() { + return Err(format!("HTTP {}", response.status())); + } + trace!(session_id = ?session_id, "SSE stream open"); + + let mut events = eventsource_stream::EventStream::new(response.bytes_stream()); + while let Some(event) = events.next().await { + let payload = event.map_err(|e| e.to_string())?.data; + if payload.is_empty() { + continue; + } + match serde_json::from_str::(&payload) { + Ok(msg) => { + if let Message::Response(response) = &msg { + if let Some(session_id) = response + .result + .as_ref() + .and_then(|r| r.get("sessionId")) + .and_then(|v| v.as_str()) + .map(String::from) + { + if self + .open_session_streams + .lock() + .await + .insert(session_id.clone()) + { + drop(self.open_session_tx.unbounded_send(session_id)); + } + } + } + self.deliver(msg); + } + Err(e) => warn!("SSE: malformed JSON-RPC payload: {e}"), + } + } + Ok(()) + } + + fn deliver(&self, msg: Message) { + if self.incoming.unbounded_send(Ok(msg)).is_err() { + debug!("upstream channel closed; dropping inbound message"); + } + } +} + +async fn run_ws(client: HttpClient, channel: Channel) -> Result<(), AcpError> { + let HttpClient { endpoint, .. } = client; + let Channel { + rx: mut outgoing, + tx: incoming, + } = channel; + + let (ws_stream, response) = tokio_tungstenite::connect_async(endpoint.as_str()) + .await + .map_err(|e| { + AcpError::internal_error().data(format!("WebSocket connect failed: {e}")) + })?; + trace!( + status = %response.status(), + "WebSocket connection established" + ); + let (mut ws_tx, mut ws_rx) = ws_stream.split(); + + loop { + tokio::select! { + msg = outgoing.next() => match msg { + Some(Ok(msg)) => { + let text = match serde_json::to_string(&msg) { + Ok(t) => t, + Err(e) => { + error!("failed to serialize outbound message: {e}"); + return Err(AcpError::internal_error() + .data(format!("serialize: {e}"))); + } + }; + if let Err(e) = ws_tx.send(WsMessage::Text(text.into())).await { + error!("WebSocket send failed: {e}"); + return Err(AcpError::internal_error() + .data(format!("ws send: {e}"))); + } + } + Some(Err(e)) => { + error!("upstream channel produced error: {e}"); + return Err(e); + } + None => break, + }, + frame = ws_rx.next() => match frame { + Some(Ok(WsMessage::Text(text))) => { + match serde_json::from_str::(text.as_str()) { + Ok(parsed) => { + if incoming.unbounded_send(Ok(parsed)).is_err() { + debug!("upstream channel closed; stopping WS reader"); + break; + } + } + Err(e) => warn!("WS: malformed JSON-RPC payload: {e}"), + } + } + Some(Ok(WsMessage::Binary(_))) => { + warn!("ignoring binary WebSocket frame (ACP uses text)"); + } + Some(Ok( + WsMessage::Ping(_) | WsMessage::Pong(_) | WsMessage::Frame(_), + )) => {} + Some(Ok(WsMessage::Close(frame))) => { + debug!("server closed WebSocket: {frame:?}"); + break; + } + Some(Err(e)) => { + error!("WebSocket receive error: {e}"); + return Err(AcpError::internal_error() + .data(format!("ws recv: {e}"))); + } + None => break, + }, + } + } + + drop(ws_tx.send(WsMessage::Close(None)).await); + Ok(()) +} diff --git a/src/agent-client-protocol-http/src/connection.rs b/src/agent-client-protocol-http/src/connection.rs new file mode 100644 index 00000000..2714a4fb --- /dev/null +++ b/src/agent-client-protocol-http/src/connection.rs @@ -0,0 +1,304 @@ +use std::{ + collections::{HashMap, VecDeque}, + sync::Arc, +}; + +use agent_client_protocol::{Channel, jsonrpcmsg}; +use futures::{SinkExt, StreamExt}; +use jsonrpcmsg::{Id, Message}; +use tokio::sync::{Mutex, RwLock, broadcast, mpsc}; +use tracing::{error, trace}; + +use crate::protocol::session_id_from_params; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +enum IdKey { + String(String), + Number(u64), +} + +impl IdKey { + fn new(id: &Id) -> Option { + match id { + Id::String(s) => Some(Self::String(s.clone())), + Id::Number(n) => Some(Self::Number(*n)), + Id::Null => None, + } + } +} + +#[derive(Clone, Debug)] +pub(crate) enum ResponseRoute { + Connection, + Session(String), +} + +struct OutboundStream { + tx: broadcast::Sender, + replay: Mutex>>, +} + +impl OutboundStream { + fn new() -> Self { + let (tx, _) = broadcast::channel(1024); + Self { + tx, + replay: Mutex::new(Some(VecDeque::new())), + } + } + + async fn push(&self, msg: String) { + let mut replay = self.replay.lock().await; + if let Some(replay) = replay.as_mut() { + if replay.len() == 1024 { + replay.pop_front(); + } + replay.push_back(msg); + } else { + drop(replay); + drop(self.tx.send(msg)); + } + } + + async fn subscribe(&self) -> (Vec, broadcast::Receiver) { + let mut replay = self.replay.lock().await; + let receiver = self.tx.subscribe(); + (replay.take().map(Vec::from).unwrap_or_default(), receiver) + } +} + +pub(crate) struct Connection { + inbound_tx: mpsc::UnboundedSender>, + outbound_rx: Mutex>>, + agent_handle: tokio::task::JoinHandle<()>, + router_handle: Mutex>>, + connection_stream: Arc, + session_streams: RwLock>>, + all_outbound: Arc, + pending_routes: Mutex>, +} + +impl Connection { + pub(crate) fn send_to_agent(&self, msg: Message) -> Result<(), &'static str> { + self.inbound_tx + .send(Ok(msg)) + .map_err(|_| "agent channel closed") + } + + pub(crate) async fn record_pending_route(&self, id: Id, route: ResponseRoute) { + if let Some(key) = IdKey::new(&id) { + self.pending_routes.lock().await.insert(key, route); + } + } + + pub(crate) async fn ensure_session(&self, session_id: &str) { + self.session_stream(session_id).await; + } + + pub(crate) async fn subscribe_connection_stream( + &self, + ) -> (Vec, broadcast::Receiver) { + self.connection_stream.subscribe().await + } + + pub(crate) async fn subscribe_session_stream( + &self, + session_id: &str, + ) -> (Vec, broadcast::Receiver) { + self.session_stream(session_id).await.subscribe().await + } + + pub(crate) async fn subscribe_all_outbound( + &self, + ) -> (Vec, broadcast::Receiver) { + self.all_outbound.subscribe().await + } + + async fn session_stream(&self, session_id: &str) -> Arc { + if let Some(stream) = self.session_streams.read().await.get(session_id) { + return stream.clone(); + } + + self.session_streams + .write() + .await + .entry(session_id.to_string()) + .or_insert_with(|| Arc::new(OutboundStream::new())) + .clone() + } + + pub(crate) async fn start_router(self: &Arc) { + let Some(mut rx) = self.outbound_rx.lock().await.take() else { + return; + }; + + let connection = self.clone(); + *self.router_handle.lock().await = Some(tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + connection.route_outbound(msg).await; + } + })); + } + + async fn route_outbound(&self, msg: Message) { + let serialized = match serde_json::to_string(&msg) { + Ok(s) => s, + Err(e) => { + error!("failed to serialize outbound JSON-RPC message: {e}"); + return; + } + }; + + self.all_outbound.push(serialized.clone()).await; + + let route = match &msg { + Message::Request(req) => req + .params + .as_ref() + .and_then(session_id_from_params) + .map_or(ResponseRoute::Connection, ResponseRoute::Session), + Message::Response(resp) => { + let route = match resp.id.as_ref().and_then(IdKey::new) { + Some(key) => self.pending_routes.lock().await.remove(&key), + None => None, + }; + route.unwrap_or(ResponseRoute::Connection) + } + }; + + match route { + ResponseRoute::Connection => { + trace!(target = "connection", "→ connection-scoped stream"); + self.connection_stream.push(serialized).await; + } + ResponseRoute::Session(sid) => { + trace!(target = %sid, "→ session-scoped stream"); + self.session_stream(&sid).await.push(serialized).await; + } + } + } + + pub(crate) async fn recv_initial(&self) -> Option { + let mut guard = self.outbound_rx.lock().await; + let rx = guard.as_mut()?; + serde_json::to_string(&rx.recv().await?).ok() + } + + pub(crate) async fn shutdown(&self) { + self.agent_handle.abort(); + if let Some(h) = self.router_handle.lock().await.take() { + h.abort(); + } + } +} + +pub(crate) struct ConnectionRegistry { + factory: Arc, + connections: RwLock>>, +} + +pub(crate) trait AgentFactory: Send + Sync + 'static { + fn spawn_agent( + &self, + ) -> ( + Channel, + futures::future::BoxFuture<'static, agent_client_protocol::Result<()>>, + ); +} + +impl AgentFactory for F +where + F: Fn() -> C + Send + Sync + 'static, + C: agent_client_protocol::ConnectTo, +{ + fn spawn_agent( + &self, + ) -> ( + Channel, + futures::future::BoxFuture<'static, agent_client_protocol::Result<()>>, + ) { + self().into_channel_and_future() + } +} + +impl ConnectionRegistry { + pub(crate) fn new(factory: Arc) -> Self { + Self { + factory, + connections: RwLock::new(HashMap::new()), + } + } + + pub(crate) async fn create_connection(&self) -> (String, Arc) { + let (mut channel, agent_future) = self.factory.spawn_agent(); + let (inbound_tx, mut inbound_rx) = + mpsc::unbounded_channel::>(); + let (outbound_tx, outbound_rx) = mpsc::unbounded_channel::(); + + let pump = async move { + let inbound = async { + while let Some(msg) = inbound_rx.recv().await { + if channel.tx.send(msg).await.is_err() { + break; + } + } + drop(channel.tx.close().await); + }; + let outbound = async { + while let Some(msg) = channel.rx.next().await { + match msg { + Ok(m) => { + if outbound_tx.send(m).is_err() { + break; + } + } + Err(e) => { + error!("agent emitted error: {e}"); + break; + } + } + } + }; + futures::join!(inbound, outbound); + }; + + let connection_id = uuid::Uuid::new_v4().to_string(); + let conn_id_for_task = connection_id.clone(); + let agent_handle = tokio::spawn(async move { + let agent = async move { + if let Err(e) = agent_future.await { + error!(connection_id = %conn_id_for_task, "ACP agent task error: {e}"); + } + }; + futures::pin_mut!(agent); + futures::pin_mut!(pump); + futures::future::select(agent, pump).await; + }); + + let connection = Arc::new(Connection { + inbound_tx, + outbound_rx: Mutex::new(Some(outbound_rx)), + agent_handle, + router_handle: Mutex::new(None), + connection_stream: Arc::new(OutboundStream::new()), + session_streams: RwLock::new(HashMap::new()), + all_outbound: Arc::new(OutboundStream::new()), + pending_routes: Mutex::new(HashMap::new()), + }); + + self.connections + .write() + .await + .insert(connection_id.clone(), connection.clone()); + + (connection_id, connection) + } + + pub(crate) async fn get(&self, connection_id: &str) -> Option> { + self.connections.read().await.get(connection_id).cloned() + } + + pub(crate) async fn remove(&self, connection_id: &str) -> Option> { + self.connections.write().await.remove(connection_id) + } +} diff --git a/src/agent-client-protocol-http/src/http_server.rs b/src/agent-client-protocol-http/src/http_server.rs new file mode 100644 index 00000000..6b015777 --- /dev/null +++ b/src/agent-client-protocol-http/src/http_server.rs @@ -0,0 +1,225 @@ +use std::{convert::Infallible, sync::Arc, time::Duration}; + +use axum::{ + body::Body, + extract::State, + http::{HeaderMap, HeaderValue, Request, StatusCode, header}, + response::{IntoResponse, Response, Sse, sse::Event}, +}; +use jsonrpcmsg::Message; +use tokio::sync::broadcast; +use tracing::{debug, error, info, trace}; + +use crate::{ + connection::{ConnectionRegistry, ResponseRoute}, + protocol::{ + EVENT_STREAM_MIME_TYPE, HEADER_CONNECTION_ID, HEADER_SESSION_ID, JSON_MIME_TYPE, + is_initialize_request, method_requires_session_header, session_id_from_params, + }, +}; + +pub(crate) async fn handle_post( + State(registry): State>, + request: Request, +) -> Response { + if !request + .headers() + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|ct| ct.starts_with(JSON_MIME_TYPE)) + { + return ( + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "Content-Type must be application/json", + ) + .into_response(); + } + + let connection_id = header_value(request.headers(), HEADER_CONNECTION_ID); + let session_id = header_value(request.headers(), HEADER_SESSION_ID); + let body = match axum::body::to_bytes(request.into_body(), usize::MAX).await { + Ok(body) => body, + Err(e) => { + error!("Failed to read request body: {e}"); + return StatusCode::BAD_REQUEST.into_response(); + } + }; + + if matches!(body.first(), Some(&b'[')) { + return StatusCode::NOT_IMPLEMENTED.into_response(); + } + + let message = match serde_json::from_slice::(&body) { + Ok(message) => message, + Err(e) => { + return (StatusCode::BAD_REQUEST, format!("Invalid JSON-RPC: {e}")).into_response(); + } + }; + + if is_initialize_request(&message) { + let (connection_id, connection) = registry.create_connection().await; + if connection.send_to_agent(message).is_err() { + registry.remove(&connection_id).await; + connection.shutdown().await; + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + + let Some(init_response) = connection.recv_initial().await else { + registry.remove(&connection_id).await; + connection.shutdown().await; + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "agent closed before initialize response", + ) + .into_response(); + }; + + connection.start_router().await; + info!(connection_id = %connection_id, "Initialize complete"); + return with_connection_header( + ( + StatusCode::OK, + [(header::CONTENT_TYPE, JSON_MIME_TYPE)], + init_response, + ) + .into_response(), + &connection_id, + ); + } + + let Some(connection_id) = connection_id else { + return (StatusCode::BAD_REQUEST, "Acp-Connection-Id header required").into_response(); + }; + let Some(connection) = registry.get(&connection_id).await else { + return StatusCode::NOT_FOUND.into_response(); + }; + + let route = match &message { + Message::Request(req) => match session_id + .or_else(|| req.params.as_ref().and_then(session_id_from_params)) + { + Some(session_id) => Some(ResponseRoute::Session(session_id)), + None if method_requires_session_header(&req.method) => { + return (StatusCode::BAD_REQUEST, "Acp-Session-Id header required").into_response(); + } + None => Some(ResponseRoute::Connection), + }, + Message::Response(_) => None, + }; + + if let Some(ResponseRoute::Session(session_id)) = &route { + connection.ensure_session(session_id).await; + } + if let (Message::Request(req), Some(route), Some(id)) = (&message, route, message_id(&message)) + { + connection.record_pending_route(id, route).await; + trace!(connection_id = %connection_id, method = %req.method, "POST → agent"); + } else { + trace!(connection_id = %connection_id, ?message, "POST → agent"); + } + + if connection.send_to_agent(message).is_err() { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + StatusCode::ACCEPTED.into_response() +} + +pub(crate) async fn handle_get( + registry: Arc, + request: Request, +) -> Response { + if !request + .headers() + .get(header::ACCEPT) + .and_then(|v| v.to_str().ok()) + .is_some_and(|accept| accept.contains(EVENT_STREAM_MIME_TYPE)) + { + return ( + StatusCode::NOT_ACCEPTABLE, + "client must accept text/event-stream", + ) + .into_response(); + } + + let Some(connection_id) = header_value(request.headers(), HEADER_CONNECTION_ID) else { + return (StatusCode::BAD_REQUEST, "Acp-Connection-Id header required").into_response(); + }; + let Some(connection) = registry.get(&connection_id).await else { + return StatusCode::NOT_FOUND.into_response(); + }; + + let session_id = header_value(request.headers(), HEADER_SESSION_ID); + let (replay, mut receiver) = match session_id.as_deref() { + Some(session_id) => connection.subscribe_session_stream(session_id).await, + None => connection.subscribe_connection_stream().await, + }; + let stream = async_stream::stream! { + for msg in replay { + trace!(payload = %msg, "SSE → client (replay)"); + yield Ok::<_, Infallible>(Event::default().data(msg)); + } + loop { + match receiver.recv().await { + Ok(msg) => { + trace!(payload = %msg, "SSE → client"); + yield Ok(Event::default().data(msg)); + } + Err(broadcast::error::RecvError::Lagged(n)) => debug!("SSE subscriber lagged {n} messages"), + Err(broadcast::error::RecvError::Closed) => break, + } + } + }; + + let mut response = with_connection_header( + Sse::new(stream) + .keep_alive( + axum::response::sse::KeepAlive::new() + .interval(Duration::from_secs(15)) + .text(""), + ) + .into_response(), + &connection_id, + ); + if let Some(session_id) = session_id { + if let Ok(value) = HeaderValue::from_str(&session_id) { + response.headers_mut().insert(HEADER_SESSION_ID, value); + } + } + response +} + +pub(crate) async fn handle_delete( + State(registry): State>, + request: Request, +) -> Response { + let Some(connection_id) = header_value(request.headers(), HEADER_CONNECTION_ID) else { + return (StatusCode::BAD_REQUEST, "Acp-Connection-Id header required").into_response(); + }; + let Some(connection) = registry.remove(&connection_id).await else { + return StatusCode::NOT_FOUND.into_response(); + }; + connection.shutdown().await; + info!(connection_id = %connection_id, "Connection terminated via DELETE"); + StatusCode::ACCEPTED.into_response() +} + +fn header_value(headers: &HeaderMap, name: &str) -> Option { + headers + .get(name) + .and_then(|v| v.to_str().ok()) + .map(String::from) +} + +fn message_id(message: &Message) -> Option { + match message { + Message::Request(req) => req.id.clone(), + Message::Response(_) => None, + } +} + +fn with_connection_header(mut response: Response, connection_id: &str) -> Response { + if let Ok(value) = HeaderValue::from_str(connection_id) { + response.headers_mut().insert(HEADER_CONNECTION_ID, value); + } + response +} diff --git a/src/agent-client-protocol-http/src/lib.rs b/src/agent-client-protocol-http/src/lib.rs new file mode 100644 index 00000000..11cef13f --- /dev/null +++ b/src/agent-client-protocol-http/src/lib.rs @@ -0,0 +1,9 @@ +mod client; +mod connection; +mod http_server; +mod protocol; +mod server; +mod websocket_server; + +pub use client::{HttpClient, HttpClientError}; +pub use server::{AcpHttpServer, ServerOptions}; diff --git a/src/agent-client-protocol-http/src/protocol.rs b/src/agent-client-protocol-http/src/protocol.rs new file mode 100644 index 00000000..77d7865e --- /dev/null +++ b/src/agent-client-protocol-http/src/protocol.rs @@ -0,0 +1,31 @@ +use jsonrpcmsg::{Message, Params}; + +pub(crate) const HEADER_CONNECTION_ID: &str = "Acp-Connection-Id"; +pub(crate) const HEADER_SESSION_ID: &str = "Acp-Session-Id"; +pub(crate) const EVENT_STREAM_MIME_TYPE: &str = "text/event-stream"; +pub(crate) const JSON_MIME_TYPE: &str = "application/json"; + +pub(crate) fn method_requires_session_header(method: &str) -> bool { + matches!( + method, + "session/prompt" + | "session/cancel" + | "session/load" + | "session/set_mode" + | "session/set_model" + ) +} + +pub(crate) fn is_initialize_request(msg: &Message) -> bool { + matches!(msg, Message::Request(req) if req.method == "initialize" && req.id.is_some()) +} + +pub(crate) fn session_id_from_params(params: &Params) -> Option { + match params { + Params::Object(map) => map + .get("sessionId") + .and_then(|v| v.as_str()) + .map(String::from), + Params::Array(_) => None, + } +} diff --git a/src/agent-client-protocol-http/src/server.rs b/src/agent-client-protocol-http/src/server.rs new file mode 100644 index 00000000..1afc92c1 --- /dev/null +++ b/src/agent-client-protocol-http/src/server.rs @@ -0,0 +1,124 @@ +use std::sync::Arc; + +use agent_client_protocol::{Client, ConnectTo}; +use axum::{ + Router, + extract::WebSocketUpgrade, + extract::ws::rejection::WebSocketUpgradeRejection, + http::{HeaderName, Method, header}, + response::Response, + routing::{delete, get, post}, +}; +use tower_http::cors::{Any, CorsLayer}; + +use crate::connection::ConnectionRegistry; + +#[derive(Debug, Clone)] +pub struct ServerOptions { + pub path: String, + pub permissive_cors: bool, + pub health_endpoint: bool, +} + +impl Default for ServerOptions { + fn default() -> Self { + Self { + path: "/acp".to_string(), + permissive_cors: true, + health_endpoint: true, + } + } +} + +pub struct AcpHttpServer { + registry: Arc, + options: ServerOptions, +} + +impl std::fmt::Debug for AcpHttpServer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AcpHttpServer") + .field("options", &self.options) + .finish_non_exhaustive() + } +} + +impl AcpHttpServer { + pub fn new(factory: F) -> Self + where + F: Fn() -> C + Send + Sync + 'static, + C: ConnectTo, + { + Self { + registry: Arc::new(ConnectionRegistry::new(Arc::new(factory))), + options: ServerOptions::default(), + } + } + + #[must_use] + pub fn with_options(mut self, options: ServerOptions) -> Self { + self.options = options; + self + } + + pub fn into_router(self) -> Router { + let registry = self.registry.clone(); + let path = self.options.path.clone(); + + let mut router = Router::new() + .route( + &path, + post(crate::http_server::handle_post).with_state(registry.clone()), + ) + .route(&path, get(handle_get).with_state(registry.clone())) + .route( + &path, + delete(crate::http_server::handle_delete).with_state(registry), + ); + + if self.options.health_endpoint { + router = router.route("/health", get(health)); + } + + if self.options.permissive_cors { + router = router.layer(default_cors()); + } + + router + } +} + +async fn health() -> &'static str { + "ok" +} + +fn default_cors() -> CorsLayer { + CorsLayer::new() + .allow_origin(Any) + .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS]) + .allow_headers([ + header::CONTENT_TYPE, + header::ACCEPT, + HeaderName::from_static("acp-connection-id"), + HeaderName::from_static("acp-session-id"), + header::SEC_WEBSOCKET_VERSION, + header::SEC_WEBSOCKET_KEY, + header::CONNECTION, + header::UPGRADE, + ]) + .expose_headers([ + HeaderName::from_static("acp-connection-id"), + HeaderName::from_static("acp-session-id"), + ]) +} + +async fn handle_get( + ws_upgrade: Result, + axum::extract::State(registry): axum::extract::State>, + request: axum::http::Request, +) -> Response { + match ws_upgrade { + Ok(ws) => crate::websocket_server::handle_ws_upgrade(registry, ws).await, + Err(_) => crate::http_server::handle_get(registry, request).await, + } +} diff --git a/src/agent-client-protocol-http/src/websocket_server.rs b/src/agent-client-protocol-http/src/websocket_server.rs new file mode 100644 index 00000000..b7e6932f --- /dev/null +++ b/src/agent-client-protocol-http/src/websocket_server.rs @@ -0,0 +1,133 @@ +use std::sync::Arc; + +use axum::{ + extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}, + http::HeaderValue, + response::Response, +}; +use futures::{SinkExt, StreamExt}; +use jsonrpcmsg::Message; +use tracing::{debug, error, info, trace, warn}; + +use crate::{ + connection::{ConnectionRegistry, ResponseRoute}, + protocol::{HEADER_CONNECTION_ID, session_id_from_params}, +}; + +pub(crate) async fn handle_ws_upgrade( + registry: Arc, + ws: WebSocketUpgrade, +) -> Response { + let (connection_id, connection) = registry.create_connection().await; + + connection.start_router().await; + + let conn_id_for_handler = connection_id.clone(); + let registry_for_handler = registry.clone(); + let mut response = ws.on_upgrade(move |socket| async move { + run_ws( + socket, + registry_for_handler, + conn_id_for_handler, + connection, + ) + .await; + }); + + if let Ok(v) = HeaderValue::from_str(&connection_id) { + response.headers_mut().insert(HEADER_CONNECTION_ID, v); + } + info!(connection_id = %connection_id, "WebSocket connection created"); + response +} + +async fn run_ws( + socket: WebSocket, + registry: Arc, + connection_id: String, + connection: Arc, +) { + let (mut ws_tx, mut ws_rx) = socket.split(); + let (replay, mut outbound_rx) = connection.subscribe_all_outbound().await; + + debug!(connection_id = %connection_id, "Starting WebSocket message loop"); + + for text in replay { + trace!(connection_id = %connection_id, payload = %text, "Agent → Client (replay): {} bytes", text.len()); + if ws_tx.send(WsMessage::Text(text.into())).await.is_err() { + error!(connection_id = %connection_id, "WebSocket send failed during replay"); + if let Some(conn) = registry.remove(&connection_id).await { + conn.shutdown().await; + } + return; + } + } + + loop { + tokio::select! { + msg_result = ws_rx.next() => { + match msg_result { + Some(Ok(WsMessage::Text(text))) => { + let text_str = text.to_string(); + trace!(connection_id = %connection_id, payload = %text_str, "Client → Agent: {} bytes", text_str.len()); + match serde_json::from_str::(&text_str) { + Ok(parsed) => { + if let Message::Request(req) = &parsed { + if let (Some(id), Some(params)) = (req.id.clone(), req.params.as_ref()) { + if let Some(sid) = session_id_from_params(params) { + connection.ensure_session(&sid).await; + connection + .record_pending_route(id, ResponseRoute::Session(sid)) + .await; + } + } + } + if connection.send_to_agent(parsed).is_err() { + error!(connection_id = %connection_id, "Agent channel closed"); + break; + } + } + Err(e) => { + warn!(connection_id = %connection_id, "Ignoring malformed JSON-RPC frame: {e}"); + } + } + } + Some(Ok(WsMessage::Close(frame))) => { + debug!(connection_id = %connection_id, "Client closed connection: {:?}", frame); + break; + } + Some(Ok(WsMessage::Ping(_) | WsMessage::Pong(_))) => {} + Some(Ok(WsMessage::Binary(_))) => { + warn!(connection_id = %connection_id, "Ignoring binary message (ACP uses text)"); + } + Some(Err(e)) => { + error!(connection_id = %connection_id, "WebSocket error: {e}"); + break; + } + None => break, + } + } + + recv = outbound_rx.recv() => { + match recv { + Ok(text) => { + trace!(connection_id = %connection_id, payload = %text, "Agent → Client: {} bytes", text.len()); + if ws_tx.send(WsMessage::Text(text.into())).await.is_err() { + error!(connection_id = %connection_id, "WebSocket send failed"); + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + warn!(connection_id = %connection_id, "WebSocket lagged {n} messages"); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + } + } + + debug!(connection_id = %connection_id, "Cleaning up WebSocket connection"); + if let Some(conn) = registry.remove(&connection_id).await { + conn.shutdown().await; + } +} From e608a863670dcd10f85c457f0c0642981c1dd2b4 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 16 Jun 2026 14:57:22 +0200 Subject: [PATCH 02/43] chore(deps): bump reqwest to 0.13 --- Cargo.lock | 318 +++++++++++++++++++++++++++++++++++++++++++---------- Cargo.toml | 2 +- 2 files changed, 259 insertions(+), 61 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b86bc539..5970e55c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -86,7 +86,7 @@ dependencies = [ "axum", "eventsource-stream", "futures", - "reqwest 0.12.28", + "reqwest", "serde", "serde_json", "thiserror", @@ -394,6 +394,28 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "axum" version = "0.8.9" @@ -523,6 +545,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -592,12 +616,31 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -616,6 +659,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -746,6 +799,12 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aeda16ab4059c5fd2a83f2b9c9e9c981327b18aa8e3b313f7e6563799d4f093e" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -843,6 +902,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.32" @@ -1125,7 +1190,6 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", ] [[package]] @@ -1350,6 +1414,65 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.99" @@ -1526,6 +1649,12 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "parking" version = "2.2.1" @@ -1707,6 +1836,7 @@ version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", @@ -1846,9 +1976,9 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" -version = "0.12.28" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", @@ -1867,9 +1997,9 @@ dependencies = [ "quinn", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", - "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -1880,42 +2010,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams 0.4.2", - "web-sys", - "webpki-roots", -] - -[[package]] -name = "reqwest" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" -dependencies = [ - "base64", - "bytes", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "serde", - "serde_json", - "sync_wrapper", - "tokio", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams 0.5.0", + "wasm-streams", "web-sys", ] @@ -1947,7 +2042,7 @@ dependencies = [ "pastey", "pin-project-lite", "process-wrap", - "reqwest 0.13.4", + "reqwest", "rmcp-macros", "schemars 1.2.1", "serde", @@ -2007,14 +2102,26 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "aws-lc-rs", "once_cell", - "ring", "rustls-pki-types", "rustls-webpki", "subtle", "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -2025,12 +2132,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -2048,6 +2183,24 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.9.0" @@ -2092,6 +2245,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -2250,6 +2426,22 @@ dependencies = [ "libc", ] +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -2752,6 +2944,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2862,19 +3064,6 @@ dependencies = [ "wasmparser", ] -[[package]] -name = "wasm-streams" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "wasm-streams" version = "0.5.0" @@ -2921,14 +3110,23 @@ dependencies = [ ] [[package]] -name = "webpki-roots" +name = "webpki-root-certs" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows" version = "0.62.2" @@ -3361,9 +3559,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index bca1f55e..ff528029 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,7 +64,7 @@ clap = { version = "4.5", features = ["derive"] } # HTTP axum = "0.8" -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +reqwest = { version = "0.13", default-features = false, features = ["rustls", "json"] } eventsource-stream = "0.2" url = "2.5" async-process = "2" From 3de2ba2fc3184be5c2ed154ea80fc6a523893602 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 16 Jun 2026 15:01:09 +0200 Subject: [PATCH 03/43] Clean up deps --- Cargo.lock | 2 +- Cargo.toml | 2 ++ src/agent-client-protocol-http/Cargo.toml | 8 +++----- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5970e55c..0d2d6179 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -78,7 +78,7 @@ dependencies = [ [[package]] name = "agent-client-protocol-http" -version = "0.13.1" +version = "0.1.0" dependencies = [ "agent-client-protocol", "agent-client-protocol-test", diff --git a/Cargo.toml b/Cargo.toml index ff528029..7f74b5d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ agent-client-protocol-schema = { version = "=0.13.6", features = ["tracing"] } # Core async runtime tokio = { version = "1.52", features = ["full"] } tokio-util = { version = "0.7", features = ["compat"] } +tokio-tungstenite = "0.29" # Serialization serde = { version = "1.0", features = ["derive", "rc"] } @@ -77,6 +78,7 @@ open = "5" rustc-hash = "2.1.1" shell-words = "1.1" strip-ansi-escapes = "0.2" +tower-http = "0.6" # Proc macros diff --git a/src/agent-client-protocol-http/Cargo.toml b/src/agent-client-protocol-http/Cargo.toml index fd80fd44..f28c2987 100644 --- a/src/agent-client-protocol-http/Cargo.toml +++ b/src/agent-client-protocol-http/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agent-client-protocol-http" -version = "0.13.1" +version = "0.1.0" edition.workspace = true authors.workspace = true license.workspace = true @@ -23,7 +23,7 @@ thiserror.workspace = true # Server axum = { workspace = true, features = ["ws", "macros"] } -tower-http = { version = "0.6", features = ["cors"] } +tower-http = { workspace = true, features = ["cors"] } async-stream = { workspace = true } uuid = { workspace = true } @@ -31,12 +31,10 @@ uuid = { workspace = true } reqwest = { workspace = true, features = ["stream"] } eventsource-stream = { workspace = true } url = { workspace = true } -tokio-tungstenite = "0.29" +tokio-tungstenite = { workspace = true } [dev-dependencies] -agent-client-protocol = { workspace = true } agent-client-protocol-test.workspace = true -tokio = { workspace = true, features = ["full"] } tracing-subscriber.workspace = true [lints] From 10521759af706ed5db3886abccf491447154c987 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 16 Jun 2026 15:03:19 +0200 Subject: [PATCH 04/43] More cleanup --- Cargo.lock | 2 -- Cargo.toml | 1 + src/agent-client-protocol-http/Cargo.toml | 2 -- 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0d2d6179..a78debd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -87,12 +87,10 @@ dependencies = [ "eventsource-stream", "futures", "reqwest", - "serde", "serde_json", "thiserror", "tokio", "tokio-tungstenite", - "tokio-util", "tower-http", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index 7f74b5d4..5c30e6ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ homepage = "https://github.com/agentclientprotocol/rust-sdk" agent-client-protocol = { path = "src/agent-client-protocol", version = "0.14.0" } agent-client-protocol-conductor = { path = "src/agent-client-protocol-conductor", version = "0.14.0" } agent-client-protocol-derive = { path = "src/agent-client-protocol-derive", version = "0.14.0" } +agent-client-protocol-http = { path = "src/agent-client-protocol-http", version = "0.1.0" } agent-client-protocol-polyfill = { path = "src/agent-client-protocol-polyfill", version = "0.14.0" } agent-client-protocol-rmcp = { path = "src/agent-client-protocol-rmcp", version = "0.14.0" } agent-client-protocol-test = { path = "src/agent-client-protocol-test" } diff --git a/src/agent-client-protocol-http/Cargo.toml b/src/agent-client-protocol-http/Cargo.toml index f28c2987..a2b37af7 100644 --- a/src/agent-client-protocol-http/Cargo.toml +++ b/src/agent-client-protocol-http/Cargo.toml @@ -14,10 +14,8 @@ categories = ["development-tools", "web-programming::http-server"] agent-client-protocol.workspace = true futures.workspace = true -serde.workspace = true serde_json.workspace = true tokio = { workspace = true, features = ["sync", "rt", "macros", "time"] } -tokio-util.workspace = true tracing.workspace = true thiserror.workspace = true From f92215a212ed5f414cce70d48d2700899e0c552f Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 16 Jun 2026 15:22:13 +0200 Subject: [PATCH 05/43] fix(http): Forward session header into params --- .../src/http_server.rs | 98 +++++++++++++++- .../src/protocol.rs | 107 +++++++++++++++++- 2 files changed, 199 insertions(+), 6 deletions(-) diff --git a/src/agent-client-protocol-http/src/http_server.rs b/src/agent-client-protocol-http/src/http_server.rs index 15706e84..69768140 100644 --- a/src/agent-client-protocol-http/src/http_server.rs +++ b/src/agent-client-protocol-http/src/http_server.rs @@ -14,8 +14,8 @@ use crate::{ connection::{ConnectionRegistry, ResponseRoute}, protocol::{ EVENT_STREAM_MIME_TYPE, HEADER_CONNECTION_ID, HEADER_SESSION_ID, JSON_MIME_TYPE, - is_initialize_request, method_for_message, method_requires_session_header, - session_id_from_message, + apply_session_header_to_message, is_initialize_request, method_for_message, + method_requires_session_header, session_id_from_message, }, }; @@ -50,7 +50,7 @@ pub(crate) async fn handle_post( return StatusCode::NOT_IMPLEMENTED.into_response(); } - let message = match serde_json::from_slice::(&body) { + let mut message = match serde_json::from_slice::(&body) { Ok(message) => message, Err(e) => { return (StatusCode::BAD_REQUEST, format!("Invalid JSON-RPC: {e}")).into_response(); @@ -95,8 +95,15 @@ pub(crate) async fn handle_post( return StatusCode::NOT_FOUND.into_response(); }; + if let Some(session_id) = &session_id + && method_for_message(&message).is_some() + && let Err(error) = apply_session_header_to_message(&mut message, session_id) + { + return (StatusCode::BAD_REQUEST, error).into_response(); + } + let route = match method_for_message(&message) { - Some(method) => match session_id.or_else(|| session_id_from_message(&message)) { + Some(method) => match session_id_from_message(&message) { Some(session_id) => Some(ResponseRoute::Session(session_id)), None if method_requires_session_header(method) => { return (StatusCode::BAD_REQUEST, "Acp-Session-Id header required").into_response(); @@ -214,3 +221,86 @@ fn with_connection_header(mut response: Response, connection_id: &str) -> Respon } response } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use agent_client_protocol::{Channel, RawJsonRpcMessage}; + use futures::{StreamExt, future::BoxFuture}; + use serde_json::json; + use tokio::{ + sync::mpsc, + time::{Duration, timeout}, + }; + + use super::*; + use crate::connection::AgentFactory; + + struct CapturingAgentFactory { + forwarded: mpsc::UnboundedSender, + } + + impl AgentFactory for CapturingAgentFactory { + fn spawn_agent( + &self, + ) -> ( + Channel, + BoxFuture<'static, agent_client_protocol::Result<()>>, + ) { + let (agent, transport) = Channel::duplex(); + let forwarded = self.forwarded.clone(); + let future = Box::pin(async move { + let Channel { + rx: mut incoming, + tx: _, + } = agent; + while let Some(message) = incoming.next().await { + if forwarded.send(message?).is_err() { + break; + } + } + Ok(()) + }); + + (transport, future) + } + } + + #[tokio::test] + async fn post_forwards_header_session_id_to_agent_params() { + let (forwarded_tx, mut forwarded_rx) = mpsc::unbounded_channel(); + let registry = Arc::new(ConnectionRegistry::new(Arc::new(CapturingAgentFactory { + forwarded: forwarded_tx, + }))); + let (connection_id, connection) = registry.create_connection().await; + let body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "session/prompt", + "params": { "prompt": [] } + }) + .to_string(); + let request = Request::builder() + .method("POST") + .uri("/acp") + .header(header::CONTENT_TYPE, JSON_MIME_TYPE) + .header(HEADER_CONNECTION_ID, connection_id.as_str()) + .header(HEADER_SESSION_ID, "session-1") + .body(Body::from(body)) + .unwrap(); + + let response = handle_post(State(registry), request).await; + + assert_eq!(response.status(), StatusCode::ACCEPTED); + let forwarded = timeout(Duration::from_secs(1), forwarded_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!( + session_id_from_message(&forwarded).as_deref(), + Some("session-1") + ); + connection.shutdown().await; + } +} diff --git a/src/agent-client-protocol-http/src/protocol.rs b/src/agent-client-protocol-http/src/protocol.rs index 28e55ff3..6f5afcac 100644 --- a/src/agent-client-protocol-http/src/protocol.rs +++ b/src/agent-client-protocol-http/src/protocol.rs @@ -1,7 +1,7 @@ use agent_client_protocol::{RawJsonRpcMessage, RawJsonRpcParams}; -pub(crate) const HEADER_CONNECTION_ID: &str = "Acp-Connection-Id"; -pub(crate) const HEADER_SESSION_ID: &str = "Acp-Session-Id"; +pub(crate) const HEADER_CONNECTION_ID: &str = "acp-connection-id"; +pub(crate) const HEADER_SESSION_ID: &str = "acp-session-id"; pub(crate) const EVENT_STREAM_MIME_TYPE: &str = "text/event-stream"; pub(crate) const JSON_MIME_TYPE: &str = "application/json"; @@ -48,3 +48,106 @@ pub(crate) fn session_id_from_message(msg: &RawJsonRpcMessage) -> Option RawJsonRpcMessage::Response(_) => None, } } + +pub(crate) fn apply_session_header_to_message( + msg: &mut RawJsonRpcMessage, + session_id: &str, +) -> Result<(), &'static str> { + match msg { + RawJsonRpcMessage::Request(req) => { + apply_session_header_to_params(&mut req.params, session_id) + } + RawJsonRpcMessage::Notification(notification) => { + apply_session_header_to_params(&mut notification.params, session_id) + } + RawJsonRpcMessage::Response(_) => Ok(()), + } +} + +fn apply_session_header_to_params( + params: &mut Option, + session_id: &str, +) -> Result<(), &'static str> { + match params { + Some(RawJsonRpcParams::Object(map)) => { + match map.get("sessionId") { + Some(serde_json::Value::String(existing)) if existing == session_id => {} + Some(serde_json::Value::String(_)) => { + return Err("Acp-Session-Id header does not match params.sessionId"); + } + Some(_) => return Err("params.sessionId must be a string"), + None => { + map.insert( + "sessionId".to_string(), + serde_json::Value::String(session_id.to_string()), + ); + } + } + Ok(()) + } + Some(RawJsonRpcParams::Array(_)) => Err("Acp-Session-Id header requires object params"), + None => { + let mut map = serde_json::Map::new(); + map.insert( + "sessionId".to_string(), + serde_json::Value::String(session_id.to_string()), + ); + *params = Some(RawJsonRpcParams::Object(map)); + Ok(()) + } + } +} + +#[cfg(test)] +mod tests { + use agent_client_protocol::schema::RequestId; + use axum::http::{HeaderMap, HeaderValue}; + use serde_json::json; + + use super::*; + + #[test] + fn custom_response_headers_are_valid_static_header_names() { + let mut headers = HeaderMap::new(); + + headers.insert(HEADER_CONNECTION_ID, HeaderValue::from_static("conn-1")); + headers.insert(HEADER_SESSION_ID, HeaderValue::from_static("session-1")); + + assert_eq!(headers[HEADER_CONNECTION_ID], "conn-1"); + assert_eq!(headers[HEADER_SESSION_ID], "session-1"); + } + + #[test] + fn session_header_is_inserted_into_object_params() { + let mut message = RawJsonRpcMessage::request( + "session/prompt".to_string(), + json!({ "prompt": [] }), + RequestId::Number(1), + ) + .unwrap(); + + apply_session_header_to_message(&mut message, "session-1").unwrap(); + + assert_eq!( + session_id_from_message(&message).as_deref(), + Some("session-1") + ); + } + + #[test] + fn session_header_conflict_is_rejected() { + let mut message = RawJsonRpcMessage::request( + "session/prompt".to_string(), + json!({ "sessionId": "session-1" }), + RequestId::Number(1), + ) + .unwrap(); + + let error = apply_session_header_to_message(&mut message, "session-2").unwrap_err(); + + assert_eq!( + error, + "Acp-Session-Id header does not match params.sessionId" + ); + } +} From 4302d3ff1b2cff5db94eab186eaecbfcbb6bf853 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 16 Jun 2026 15:28:55 +0200 Subject: [PATCH 06/43] build(deps): Enable rustls for tokio-tungstenite --- Cargo.lock | 24 ++++++++++++++++++++++++ Cargo.toml | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index a78debd5..03c0eb8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2695,8 +2695,12 @@ checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", + "rustls", + "rustls-pki-types", "tokio", + "tokio-rustls", "tungstenite", + "webpki-roots 0.26.11", ] [[package]] @@ -2852,6 +2856,8 @@ dependencies = [ "httparse", "log", "rand", + "rustls", + "rustls-pki-types", "sha1", "thiserror", ] @@ -3116,6 +3122,24 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi-util" version = "0.1.11" diff --git a/Cargo.toml b/Cargo.toml index 5c30e6ff..211e9723 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,7 +42,7 @@ agent-client-protocol-schema = { version = "=0.13.6", features = ["tracing"] } # Core async runtime tokio = { version = "1.52", features = ["full"] } tokio-util = { version = "0.7", features = ["compat"] } -tokio-tungstenite = "0.29" +tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] } # Serialization serde = { version = "1.0", features = ["derive", "rc"] } From b744df0ee5627bdb69c065b3d1c6796224fe5796 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 16 Jun 2026 15:52:37 +0200 Subject: [PATCH 07/43] fix(http): Delete connections after run errors --- src/agent-client-protocol-http/src/client.rs | 112 +++++++++++++++++-- 1 file changed, 104 insertions(+), 8 deletions(-) diff --git a/src/agent-client-protocol-http/src/client.rs b/src/agent-client-protocol-http/src/client.rs index c34f6695..205bbdb4 100644 --- a/src/agent-client-protocol-http/src/client.rs +++ b/src/agent-client-protocol-http/src/client.rs @@ -103,15 +103,15 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { }); let mut sse_tasks = Vec::new(); - loop { + let result = loop { let msg = tokio::select! { msg = outgoing.next() => match msg { Some(Ok(msg)) => msg, Some(Err(e)) => { error!("upstream channel produced error: {e}"); - return Err(e); + break Err(e); } - None => break, + None => break Ok(()), }, Some(session_id) = open_session_rx.next() => { spawn_sse(state.clone(), Some(session_id), &mut sse_tasks); @@ -121,12 +121,12 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { if state.connection_id.lock().await.is_none() { if !is_initialize_request(&msg) { - return Err(AcpError::invalid_request() + break Err(AcpError::invalid_request() .data("ACP HTTP transport: first message must be `initialize`")); } if let Err(e) = state.initialize(msg).await { error!("initialize failed: {e}"); - return Err(AcpError::internal_error().data(format!("initialize: {e}"))); + break Err(AcpError::internal_error().data(format!("initialize: {e}"))); } spawn_sse(state.clone(), None, &mut sse_tasks); continue; @@ -144,15 +144,15 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { if let Err(e) = state.post(msg).await { error!("POST failed: {e}"); - return Err(AcpError::internal_error().data(format!("POST: {e}"))); + break Err(AcpError::internal_error().data(format!("POST: {e}"))); } - } + }; state.delete().await; for task in sse_tasks { task.abort(); } - Ok(()) + result } fn spawn_sse( @@ -395,3 +395,99 @@ async fn run_ws(client: HttpClient, channel: Channel) -> Result<(), AcpError> { drop(ws_tx.send(WsMessage::Close(None)).await); Ok(()) } + +#[cfg(test)] +mod tests { + use std::{ + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, + }; + + use agent_client_protocol::schema::RequestId; + use axum::{ + Json, Router, + http::{HeaderMap, HeaderValue, StatusCode}, + response::IntoResponse, + routing::post, + }; + use serde_json::json; + use tokio::{net::TcpListener, time::timeout}; + + use super::*; + + #[tokio::test] + async fn post_error_deletes_initialized_connection() { + let delete_count = Arc::new(AtomicUsize::new(0)); + let delete_count_for_handler = delete_count.clone(); + let app = Router::new().route( + "/acp", + post(initialize_response).delete(move || { + let delete_count = delete_count_for_handler.clone(); + async move { + delete_count.fetch_add(1, Ordering::SeqCst); + StatusCode::ACCEPTED + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = HttpClient::new(format!("http://{addr}")).unwrap(); + let (mut caller, transport) = Channel::duplex(); + let transport = tokio::spawn(run(client, transport)); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "initialize".to_string(), + json!({}), + RequestId::Number(1), + ) + .unwrap())) + .unwrap(); + let init_response = timeout(Duration::from_secs(1), caller.rx.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(matches!(init_response, RawJsonRpcMessage::Response(_))); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "session/prompt".to_string(), + json!({}), + RequestId::Number(2), + ) + .unwrap())) + .unwrap(); + let error = timeout(Duration::from_secs(1), transport) + .await + .unwrap() + .unwrap() + .unwrap_err(); + + assert!(error.to_string().contains("POST")); + assert_eq!(delete_count.load(Ordering::SeqCst), 1); + + server.abort(); + } + + async fn initialize_response() -> impl IntoResponse { + let mut headers = HeaderMap::new(); + headers.insert(HEADER_CONNECTION_ID, HeaderValue::from_static("conn-1")); + ( + StatusCode::OK, + headers, + Json(RawJsonRpcMessage::response( + RequestId::Number(1), + Ok(json!({})), + )), + ) + } +} From bb7afce888a7fda681adce08b21c3f086e207720 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 16 Jun 2026 15:58:01 +0200 Subject: [PATCH 08/43] fix(http): Fail transport on SSE disconnect --- src/agent-client-protocol-http/src/client.rs | 116 +++++++++++++++++-- 1 file changed, 105 insertions(+), 11 deletions(-) diff --git a/src/agent-client-protocol-http/src/client.rs b/src/agent-client-protocol-http/src/client.rs index 205bbdb4..686edb67 100644 --- a/src/agent-client-protocol-http/src/client.rs +++ b/src/agent-client-protocol-http/src/client.rs @@ -4,7 +4,11 @@ use agent_client_protocol::{ Agent, Channel, Client, ConnectTo, Error as AcpError, RawJsonRpcMessage, schema::Response as RpcResponse, }; -use futures::{SinkExt, StreamExt, future::BoxFuture}; +use futures::{ + SinkExt, StreamExt, + channel::mpsc::{self, UnboundedSender}, + future::BoxFuture, +}; use thiserror::Error; use tokio::sync::Mutex; use tokio_tungstenite::tungstenite::Message as WsMessage; @@ -92,7 +96,8 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { rx: mut outgoing, tx: incoming, } = channel; - let (open_session_tx, mut open_session_rx) = futures::channel::mpsc::unbounded(); + let (open_session_tx, mut open_session_rx) = mpsc::unbounded(); + let (sse_failure_tx, mut sse_failure_rx) = mpsc::unbounded(); let state = Arc::new(ClientState { endpoint, http, @@ -114,9 +119,20 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { None => break Ok(()), }, Some(session_id) = open_session_rx.next() => { - spawn_sse(state.clone(), Some(session_id), &mut sse_tasks); + spawn_sse( + state.clone(), + Some(session_id), + sse_failure_tx.clone(), + &mut sse_tasks, + ); continue; } + Some(failure) = sse_failure_rx.next() => { + let scope = failure.session_id.as_deref().unwrap_or("connection"); + error!(session_id = ?failure.session_id, error = %failure.error, "SSE stream ended"); + break Err(AcpError::internal_error() + .data(format!("{scope} SSE stream ended: {}", failure.error))); + } }; if state.connection_id.lock().await.is_none() { @@ -128,7 +144,7 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { error!("initialize failed: {e}"); break Err(AcpError::internal_error().data(format!("initialize: {e}"))); } - spawn_sse(state.clone(), None, &mut sse_tasks); + spawn_sse(state.clone(), None, sse_failure_tx.clone(), &mut sse_tasks); continue; } @@ -139,7 +155,12 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { .await .insert(session_id.clone()) { - spawn_sse(state.clone(), Some(session_id), &mut sse_tasks); + spawn_sse( + state.clone(), + Some(session_id), + sse_failure_tx.clone(), + &mut sse_tasks, + ); } if let Err(e) = state.post(msg).await { @@ -155,16 +176,29 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { result } +#[derive(Debug)] +struct SseFailure { + session_id: Option, + error: String, +} + fn spawn_sse( state: Arc, session_id: Option, + failure_tx: UnboundedSender, tasks: &mut Vec>, ) { tasks.push(tokio::spawn(async move { let label = session_id.clone(); - if let Err(e) = state.sse(session_id).await { - warn!(session_id = ?label, "SSE stream ended: {e}"); - } + let error = match state.sse(session_id).await { + Ok(()) => "SSE stream closed".to_string(), + Err(e) => e, + }; + warn!(session_id = ?label, "SSE stream ended: {error}"); + drop(failure_tx.unbounded_send(SseFailure { + session_id: label, + error, + })); })); } @@ -174,7 +208,7 @@ struct ClientState { connection_id: Mutex>, open_session_streams: Mutex>, incoming: futures::channel::mpsc::UnboundedSender>, - open_session_tx: futures::channel::mpsc::UnboundedSender, + open_session_tx: mpsc::UnboundedSender, } impl ClientState { @@ -399,6 +433,7 @@ async fn run_ws(client: HttpClient, channel: Channel) -> Result<(), AcpError> { #[cfg(test)] mod tests { use std::{ + convert::Infallible, sync::{ Arc, atomic::{AtomicUsize, Ordering}, @@ -410,7 +445,7 @@ mod tests { use axum::{ Json, Router, http::{HeaderMap, HeaderValue, StatusCode}, - response::IntoResponse, + response::{IntoResponse, Sse, sse::Event}, routing::post, }; use serde_json::json; @@ -424,7 +459,7 @@ mod tests { let delete_count_for_handler = delete_count.clone(); let app = Router::new().route( "/acp", - post(initialize_response).delete(move || { + post(initialize_response).get(pending_sse).delete(move || { let delete_count = delete_count_for_handler.clone(); async move { delete_count.fetch_add(1, Ordering::SeqCst); @@ -478,6 +513,57 @@ mod tests { server.abort(); } + #[tokio::test] + async fn connection_sse_disconnect_fails_transport() { + let delete_count = Arc::new(AtomicUsize::new(0)); + let delete_count_for_handler = delete_count.clone(); + let app = Router::new().route( + "/acp", + post(initialize_response).get(closed_sse).delete(move || { + let delete_count = delete_count_for_handler.clone(); + async move { + delete_count.fetch_add(1, Ordering::SeqCst); + StatusCode::ACCEPTED + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = HttpClient::new(format!("http://{addr}")).unwrap(); + let (mut caller, transport) = Channel::duplex(); + let transport = tokio::spawn(run(client, transport)); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "initialize".to_string(), + json!({}), + RequestId::Number(1), + ) + .unwrap())) + .unwrap(); + let init_response = timeout(Duration::from_secs(1), caller.rx.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(matches!(init_response, RawJsonRpcMessage::Response(_))); + + let error = timeout(Duration::from_secs(1), transport) + .await + .unwrap() + .unwrap() + .unwrap_err(); + + assert!(error.to_string().contains("SSE")); + assert_eq!(delete_count.load(Ordering::SeqCst), 1); + + server.abort(); + } + async fn initialize_response() -> impl IntoResponse { let mut headers = HeaderMap::new(); headers.insert(HEADER_CONNECTION_ID, HeaderValue::from_static("conn-1")); @@ -490,4 +576,12 @@ mod tests { )), ) } + + async fn pending_sse() -> Sse>> { + Sse::new(futures::stream::pending()) + } + + async fn closed_sse() -> StatusCode { + StatusCode::OK + } } From 12f119513b2635d510a0040d2a1221668dbfbf01 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 16 Jun 2026 16:11:07 +0200 Subject: [PATCH 09/43] fix(http): Limit POST request body size --- md/http-transport.md | 2 + .../src/http_server.rs | 58 ++++++++++++++++++- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/md/http-transport.md b/md/http-transport.md index 80e9cb34..fd9dfe67 100644 --- a/md/http-transport.md +++ b/md/http-transport.md @@ -8,6 +8,8 @@ - `GET /acp` with a WebSocket upgrade uses text frames for JSON-RPC messages. - `DELETE /acp` tears down the connection. +`POST /acp` request bodies are limited to 16 MiB. + ## Server ```rust diff --git a/src/agent-client-protocol-http/src/http_server.rs b/src/agent-client-protocol-http/src/http_server.rs index 69768140..afb2796b 100644 --- a/src/agent-client-protocol-http/src/http_server.rs +++ b/src/agent-client-protocol-http/src/http_server.rs @@ -1,4 +1,4 @@ -use std::{convert::Infallible, sync::Arc, time::Duration}; +use std::{convert::Infallible, error::Error as _, sync::Arc, time::Duration}; use agent_client_protocol::RawJsonRpcMessage; use axum::{ @@ -19,6 +19,8 @@ use crate::{ }, }; +const MAX_POST_BODY_BYTES: usize = 16 * 1024 * 1024; + pub(crate) async fn handle_post( State(registry): State>, request: Request, @@ -38,10 +40,17 @@ pub(crate) async fn handle_post( let connection_id = header_value(request.headers(), HEADER_CONNECTION_ID); let session_id = header_value(request.headers(), HEADER_SESSION_ID); - let body = match axum::body::to_bytes(request.into_body(), usize::MAX).await { + if content_length_exceeds_limit(request.headers()) { + return post_body_too_large_response(); + } + + let body = match axum::body::to_bytes(request.into_body(), MAX_POST_BODY_BYTES).await { Ok(body) => body, Err(e) => { error!("Failed to read request body: {e}"); + if is_body_limit_error(&e) { + return post_body_too_large_response(); + } return StatusCode::BAD_REQUEST.into_response(); } }; @@ -222,6 +231,29 @@ fn with_connection_header(mut response: Response, connection_id: &str) -> Respon response } +fn content_length_exceeds_limit(headers: &HeaderMap) -> bool { + headers + .get(header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .is_some_and(|length| length > MAX_POST_BODY_BYTES) +} + +fn is_body_limit_error(error: &axum::Error) -> bool { + let mut source = error.source(); + while let Some(error) = source { + if error.to_string() == "length limit exceeded" { + return true; + } + source = error.source(); + } + false +} + +fn post_body_too_large_response() -> Response { + (StatusCode::PAYLOAD_TOO_LARGE, "POST body too large").into_response() +} + #[cfg(test)] mod tests { use std::sync::Arc; @@ -267,6 +299,28 @@ mod tests { } } + #[tokio::test] + async fn post_rejects_declared_body_larger_than_limit() { + let (forwarded_tx, _forwarded_rx) = mpsc::unbounded_channel(); + let registry = Arc::new(ConnectionRegistry::new(Arc::new(CapturingAgentFactory { + forwarded: forwarded_tx, + }))); + let request = Request::builder() + .method("POST") + .uri("/acp") + .header(header::CONTENT_TYPE, JSON_MIME_TYPE) + .header( + header::CONTENT_LENGTH, + (MAX_POST_BODY_BYTES + 1).to_string(), + ) + .body(Body::from("{}")) + .unwrap(); + + let response = handle_post(State(registry), request).await; + + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + } + #[tokio::test] async fn post_forwards_header_session_id_to_agent_params() { let (forwarded_tx, mut forwarded_rx) = mpsc::unbounded_channel(); From fc9ec9781c131d246505861c8d39601908de544c Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 16 Jun 2026 16:44:16 +0200 Subject: [PATCH 10/43] fix(http): Close connections when transport drops --- src/agent-client-protocol-http/src/client.rs | 286 +++++++++++++++---- 1 file changed, 229 insertions(+), 57 deletions(-) diff --git a/src/agent-client-protocol-http/src/client.rs b/src/agent-client-protocol-http/src/client.rs index 686edb67..51fe3c4b 100644 --- a/src/agent-client-protocol-http/src/client.rs +++ b/src/agent-client-protocol-http/src/client.rs @@ -1,4 +1,7 @@ -use std::{collections::HashSet, sync::Arc}; +use std::{ + collections::HashSet, + sync::{Arc, Mutex as StdMutex}, +}; use agent_client_protocol::{ Agent, Channel, Client, ConnectTo, Error as AcpError, RawJsonRpcMessage, @@ -98,15 +101,14 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { } = channel; let (open_session_tx, mut open_session_rx) = mpsc::unbounded(); let (sse_failure_tx, mut sse_failure_rx) = mpsc::unbounded(); + let connection = HttpConnection::new(endpoint, http); let state = Arc::new(ClientState { - endpoint, - http, - connection_id: Mutex::new(None), + connection: connection.clone(), open_session_streams: Mutex::new(HashSet::new()), incoming, open_session_tx, }); - let mut sse_tasks = Vec::new(); + let mut lifecycle = HttpTransportLifecycle::new(connection); let result = loop { let msg = tokio::select! { @@ -119,11 +121,10 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { None => break Ok(()), }, Some(session_id) = open_session_rx.next() => { - spawn_sse( + lifecycle.spawn_sse( state.clone(), Some(session_id), sse_failure_tx.clone(), - &mut sse_tasks, ); continue; } @@ -135,7 +136,7 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { } }; - if state.connection_id.lock().await.is_none() { + if state.connection.connection_id().is_none() { if !is_initialize_request(&msg) { break Err(AcpError::invalid_request() .data("ACP HTTP transport: first message must be `initialize`")); @@ -144,7 +145,7 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { error!("initialize failed: {e}"); break Err(AcpError::internal_error().data(format!("initialize: {e}"))); } - spawn_sse(state.clone(), None, sse_failure_tx.clone(), &mut sse_tasks); + lifecycle.spawn_sse(state.clone(), None, sse_failure_tx.clone()); continue; } @@ -155,12 +156,7 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { .await .insert(session_id.clone()) { - spawn_sse( - state.clone(), - Some(session_id), - sse_failure_tx.clone(), - &mut sse_tasks, - ); + lifecycle.spawn_sse(state.clone(), Some(session_id), sse_failure_tx.clone()); } if let Err(e) = state.post(msg).await { @@ -169,10 +165,7 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { } }; - state.delete().await; - for task in sse_tasks { - task.abort(); - } + lifecycle.close().await; result } @@ -182,13 +175,125 @@ struct SseFailure { error: String, } +#[derive(Clone, Debug)] +struct HttpConnection { + endpoint: url::Url, + http: reqwest::Client, + connection_id: Arc>>, +} + +impl HttpConnection { + fn new(endpoint: url::Url, http: reqwest::Client) -> Self { + Self { + endpoint, + http, + connection_id: Arc::new(StdMutex::new(None)), + } + } + + fn post(&self) -> reqwest::RequestBuilder { + self.http.post(self.endpoint.clone()) + } + + fn get(&self) -> reqwest::RequestBuilder { + self.http.get(self.endpoint.clone()) + } + + fn set_connection_id(&self, connection_id: String) { + *self.connection_id.lock().expect("mutex poisoned") = Some(connection_id); + } + + fn connection_id(&self) -> Option { + self.connection_id.lock().expect("mutex poisoned").clone() + } + + fn take_connection_id(&self) -> Option { + self.connection_id.lock().expect("mutex poisoned").take() + } + + async fn close(&self) { + let Some(task) = self.spawn_close_task() else { + return; + }; + if let Err(e) = task.await { + debug!("DELETE task failed (ignored): {e}"); + } + } + + fn spawn_close(&self) { + drop(self.spawn_close_task()); + } + + fn spawn_close_task(&self) -> Option> { + let Some(connection_id) = self.take_connection_id() else { + return None; + }; + let http = self.http.clone(); + let endpoint = self.endpoint.clone(); + match tokio::runtime::Handle::try_current() { + Ok(handle) => Some(handle.spawn(Self::send_close(http, endpoint, connection_id))), + Err(e) => { + debug!("failed to spawn HTTP DELETE: {e}"); + None + } + } + } + + async fn send_close(http: reqwest::Client, endpoint: url::Url, connection_id: String) { + if let Err(e) = http + .delete(endpoint) + .header(HEADER_CONNECTION_ID, connection_id) + .send() + .await + { + debug!("DELETE failed (ignored): {e}"); + } + } +} + +#[derive(Debug)] +struct HttpTransportLifecycle { + connection: HttpConnection, + sse_tasks: SseTasks, +} + +impl HttpTransportLifecycle { + fn new(connection: HttpConnection) -> Self { + Self { + connection, + sse_tasks: SseTasks::default(), + } + } + + fn spawn_sse( + &mut self, + state: Arc, + session_id: Option, + failure_tx: UnboundedSender, + ) { + self.sse_tasks + .push(spawn_sse(state, session_id, failure_tx)); + } + + async fn close(&mut self) { + self.connection.close().await; + self.sse_tasks.abort_all(); + } +} + +impl Drop for HttpTransportLifecycle { + fn drop(&mut self) { + self.sse_tasks.abort_all(); + self.connection.spawn_close(); + } +} + fn spawn_sse( state: Arc, session_id: Option, failure_tx: UnboundedSender, - tasks: &mut Vec>, -) { - tasks.push(tokio::spawn(async move { +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { let label = session_id.clone(); let error = match state.sse(session_id).await { Ok(()) => "SSE stream closed".to_string(), @@ -199,13 +304,28 @@ fn spawn_sse( session_id: label, error, })); - })); + }) +} + +#[derive(Debug, Default)] +struct SseTasks { + handles: Vec>, +} + +impl SseTasks { + fn push(&mut self, handle: tokio::task::JoinHandle<()>) { + self.handles.push(handle); + } + + fn abort_all(&mut self) { + for handle in self.handles.drain(..) { + handle.abort(); + } + } } struct ClientState { - endpoint: url::Url, - http: reqwest::Client, - connection_id: Mutex>, + connection: HttpConnection, open_session_streams: Mutex>, incoming: futures::channel::mpsc::UnboundedSender>, open_session_tx: mpsc::UnboundedSender, @@ -214,8 +334,8 @@ struct ClientState { impl ClientState { async fn initialize(&self, msg: RawJsonRpcMessage) -> Result<(), String> { let response = self - .http - .post(self.endpoint.clone()) + .connection + .post() .header("Content-Type", "application/json") .header("Accept", "application/json") .json(&msg) @@ -240,7 +360,7 @@ impl ClientState { .await .map_err(|e| e.to_string())?; - *self.connection_id.lock().await = Some(connection_id); + self.connection.set_connection_id(connection_id); self.deliver(message); Ok(()) } @@ -257,14 +377,12 @@ impl ClientState { None => None, }; let connection_id = self - .connection_id - .lock() - .await - .clone() + .connection + .connection_id() .ok_or_else(|| "POST attempted before initialize".to_string())?; let mut request = self - .http - .post(self.endpoint.clone()) + .connection + .post() .header("Accept", "application/json") .header(HEADER_CONNECTION_ID, connection_id) .json(&msg); @@ -281,31 +399,14 @@ impl ClientState { Ok(()) } - async fn delete(&self) { - let Some(connection_id) = self.connection_id.lock().await.clone() else { - return; - }; - if let Err(e) = self - .http - .delete(self.endpoint.clone()) - .header(HEADER_CONNECTION_ID, connection_id) - .send() - .await - { - debug!("DELETE failed (ignored): {e}"); - } - } - async fn sse(&self, session_id: Option) -> Result<(), String> { let connection_id = self - .connection_id - .lock() - .await - .clone() + .connection + .connection_id() .ok_or_else(|| "SSE attempted before initialize".to_string())?; let mut request = self - .http - .get(self.endpoint.clone()) + .connection + .get() .header("Accept", "text/event-stream") .header(HEADER_CONNECTION_ID, connection_id); if let Some(session_id) = &session_id { @@ -449,7 +550,10 @@ mod tests { routing::post, }; use serde_json::json; - use tokio::{net::TcpListener, time::timeout}; + use tokio::{ + net::TcpListener, + time::{sleep, timeout}, + }; use super::*; @@ -564,6 +668,74 @@ mod tests { server.abort(); } + #[tokio::test] + async fn dropped_transport_future_deletes_initialized_connection() { + let delete_count = Arc::new(AtomicUsize::new(0)); + let delete_count_for_handler = delete_count.clone(); + let app = Router::new().route( + "/acp", + post(initialize_response).get(pending_sse).delete(move || { + let delete_count = delete_count_for_handler.clone(); + async move { + delete_count.fetch_add(1, Ordering::SeqCst); + StatusCode::ACCEPTED + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = HttpClient::new(format!("http://{addr}")).unwrap(); + let (mut caller, transport) = Channel::duplex(); + let mut transport = Box::pin(run(client, transport)); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "initialize".to_string(), + json!({}), + RequestId::Number(1), + ) + .unwrap())) + .unwrap(); + let init_response = timeout(Duration::from_secs(1), async { + loop { + tokio::select! { + result = &mut transport => { + panic!("transport ended before initialize response: {result:?}"); + } + msg = caller.rx.next() => { + break msg.unwrap().unwrap(); + } + } + } + }) + .await + .unwrap(); + assert!(matches!(init_response, RawJsonRpcMessage::Response(_))); + + drop(transport); + wait_for_delete(&delete_count).await; + + server.abort(); + } + + async fn wait_for_delete(delete_count: &AtomicUsize) { + timeout(Duration::from_secs(1), async { + loop { + if delete_count.load(Ordering::SeqCst) > 0 { + break; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + assert_eq!(delete_count.load(Ordering::SeqCst), 1); + } + async fn initialize_response() -> impl IntoResponse { let mut headers = HeaderMap::new(); headers.insert(HEADER_CONNECTION_ID, HeaderValue::from_static("conn-1")); From b52d221a29435e5c6ed84e7d9bb25e905e8277fd Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 16 Jun 2026 17:05:02 +0200 Subject: [PATCH 11/43] fix(http): Close streams when agents exit --- .../src/connection.rs | 132 +++++++++++++++--- .../src/http_server.rs | 23 ++- .../src/websocket_server.rs | 10 ++ 3 files changed, 139 insertions(+), 26 deletions(-) diff --git a/src/agent-client-protocol-http/src/connection.rs b/src/agent-client-protocol-http/src/connection.rs index 840560ff..302053de 100644 --- a/src/agent-client-protocol-http/src/connection.rs +++ b/src/agent-client-protocol-http/src/connection.rs @@ -1,12 +1,12 @@ use std::{ collections::{HashMap, VecDeque}, - sync::Arc, + sync::{Arc, Weak}, }; use agent_client_protocol::{Channel, RawJsonRpcMessage, schema::RequestId}; use futures::{SinkExt, StreamExt}; -use tokio::sync::{Mutex, RwLock, broadcast, mpsc}; -use tracing::{error, trace}; +use tokio::sync::{Mutex, RwLock, broadcast, mpsc, watch}; +use tracing::{debug, error, trace}; use crate::protocol::session_id_from_message; @@ -53,8 +53,9 @@ impl OutboundStream { pub(crate) struct Connection { inbound_tx: mpsc::UnboundedSender>, outbound_rx: Mutex>>, - agent_handle: tokio::task::JoinHandle<()>, + agent_handle: Mutex>>, router_handle: Mutex>>, + closed_tx: watch::Sender, connection_stream: Arc, session_streams: RwLock>>, all_outbound: Arc, @@ -97,6 +98,10 @@ impl Connection { self.all_outbound.subscribe().await } + pub(crate) fn subscribe_closed(&self) -> watch::Receiver { + self.closed_tx.subscribe() + } + async fn session_stream(&self, session_id: &str) -> Arc { if let Some(stream) = self.session_streams.read().await.get(session_id) { return stream.clone(); @@ -167,16 +172,23 @@ impl Connection { } pub(crate) async fn shutdown(&self) { - self.agent_handle.abort(); + self.close_streams(); + if let Some(h) = self.agent_handle.lock().await.take() { + h.abort(); + } if let Some(h) = self.router_handle.lock().await.take() { h.abort(); } } + + fn close_streams(&self) { + self.closed_tx.send_replace(true); + } } pub(crate) struct ConnectionRegistry { factory: Arc, - connections: RwLock>>, + connections: Arc>>>, } pub(crate) trait AgentFactory: Send + Sync + 'static { @@ -207,7 +219,7 @@ impl ConnectionRegistry { pub(crate) fn new(factory: Arc) -> Self { Self { factory, - connections: RwLock::new(HashMap::new()), + connections: Arc::new(RwLock::new(HashMap::new())), } } @@ -216,6 +228,7 @@ impl ConnectionRegistry { let (inbound_tx, mut inbound_rx) = mpsc::unbounded_channel::>(); let (outbound_tx, outbound_rx) = mpsc::unbounded_channel::(); + let (closed_tx, _) = watch::channel(false); let pump = async move { let inbound = async { @@ -245,23 +258,12 @@ impl ConnectionRegistry { }; let connection_id = uuid::Uuid::new_v4().to_string(); - let conn_id_for_task = connection_id.clone(); - let agent_handle = tokio::spawn(async move { - let agent = async move { - if let Err(e) = agent_future.await { - error!(connection_id = %conn_id_for_task, "ACP agent task error: {e}"); - } - }; - futures::pin_mut!(agent); - futures::pin_mut!(pump); - futures::future::select(agent, pump).await; - }); - let connection = Arc::new(Connection { inbound_tx, outbound_rx: Mutex::new(Some(outbound_rx)), - agent_handle, + agent_handle: Mutex::new(None), router_handle: Mutex::new(None), + closed_tx, connection_stream: Arc::new(OutboundStream::new()), session_streams: RwLock::new(HashMap::new()), all_outbound: Arc::new(OutboundStream::new()), @@ -273,6 +275,26 @@ impl ConnectionRegistry { .await .insert(connection_id.clone(), connection.clone()); + let conn_id_for_task = connection_id.clone(); + let connections = self.connections.clone(); + let connection_for_task = Arc::downgrade(&connection); + let agent_handle = tokio::spawn(async move { + let conn_id_for_agent = conn_id_for_task.clone(); + let agent = async move { + if let Err(e) = agent_future.await { + error!(connection_id = %conn_id_for_agent, "ACP agent task error: {e}"); + } + }; + futures::pin_mut!(agent); + futures::pin_mut!(pump); + futures::future::select(agent, pump).await; + debug!(connection_id = %conn_id_for_task, "HTTP ACP connection task ended"); + connections.write().await.remove(&conn_id_for_task); + close_connection_task(connection_for_task).await; + }); + + *connection.agent_handle.lock().await = Some(agent_handle); + (connection_id, connection) } @@ -285,9 +307,79 @@ impl ConnectionRegistry { } } +async fn close_connection_task(connection: Weak) { + let Some(connection) = connection.upgrade() else { + return; + }; + connection.close_streams(); + if let Some(h) = connection.router_handle.lock().await.take() { + h.abort(); + } +} + fn pending_route_key(id: &RequestId) -> Option { match id { RequestId::Null => None, RequestId::Number(_) | RequestId::Str(_) => Some(id.clone()), } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use futures::future::BoxFuture; + use tokio::{ + sync::Notify, + time::{Duration, sleep, timeout}, + }; + + use super::*; + + struct ExitingAgentFactory { + exit: Arc, + } + + impl AgentFactory for ExitingAgentFactory { + fn spawn_agent( + &self, + ) -> ( + Channel, + BoxFuture<'static, agent_client_protocol::Result<()>>, + ) { + let (agent, transport) = Channel::duplex(); + let exit = self.exit.clone(); + let future = Box::pin(async move { + exit.notified().await; + drop(agent); + Ok(()) + }); + + (transport, future) + } + } + + #[tokio::test] + async fn agent_exit_removes_connection_and_closes_streams() { + let exit = Arc::new(Notify::new()); + let registry = + ConnectionRegistry::new(Arc::new(ExitingAgentFactory { exit: exit.clone() })); + let (connection_id, connection) = registry.create_connection().await; + + assert!(registry.get(&connection_id).await.is_some()); + + exit.notify_one(); + timeout(Duration::from_secs(1), async { + loop { + if registry.get(&connection_id).await.is_none() { + break; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + + assert!(*connection.subscribe_closed().borrow()); + } +} diff --git a/src/agent-client-protocol-http/src/http_server.rs b/src/agent-client-protocol-http/src/http_server.rs index afb2796b..1d117ec8 100644 --- a/src/agent-client-protocol-http/src/http_server.rs +++ b/src/agent-client-protocol-http/src/http_server.rs @@ -167,19 +167,30 @@ pub(crate) async fn handle_get( Some(session_id) => connection.subscribe_session_stream(session_id).await, None => connection.subscribe_connection_stream().await, }; + let mut closed = connection.subscribe_closed(); let stream = async_stream::stream! { for msg in replay { trace!(payload = %msg, "SSE → client (replay)"); yield Ok::<_, Infallible>(Event::default().data(msg)); } loop { - match receiver.recv().await { - Ok(msg) => { - trace!(payload = %msg, "SSE → client"); - yield Ok(Event::default().data(msg)); + if *closed.borrow() { + break; + } + tokio::select! { + recv = receiver.recv() => match recv { + Ok(msg) => { + trace!(payload = %msg, "SSE → client"); + yield Ok(Event::default().data(msg)); + } + Err(broadcast::error::RecvError::Lagged(n)) => debug!("SSE subscriber lagged {n} messages"), + Err(broadcast::error::RecvError::Closed) => break, + }, + changed = closed.changed() => { + if changed.is_err() || *closed.borrow() { + break; + } } - Err(broadcast::error::RecvError::Lagged(n)) => debug!("SSE subscriber lagged {n} messages"), - Err(broadcast::error::RecvError::Closed) => break, } } }; diff --git a/src/agent-client-protocol-http/src/websocket_server.rs b/src/agent-client-protocol-http/src/websocket_server.rs index daa5f60d..809c3acd 100644 --- a/src/agent-client-protocol-http/src/websocket_server.rs +++ b/src/agent-client-protocol-http/src/websocket_server.rs @@ -49,6 +49,7 @@ async fn run_ws( ) { let (mut ws_tx, mut ws_rx) = socket.split(); let (replay, mut outbound_rx) = connection.subscribe_all_outbound().await; + let mut closed = connection.subscribe_closed(); debug!(connection_id = %connection_id, "Starting WebSocket message loop"); @@ -64,6 +65,9 @@ async fn run_ws( } loop { + if *closed.borrow() { + break; + } tokio::select! { msg_result = ws_rx.next() => { match msg_result { @@ -124,6 +128,12 @@ async fn run_ws( Err(tokio::sync::broadcast::error::RecvError::Closed) => break, } } + + changed = closed.changed() => { + if changed.is_err() || *closed.borrow() { + break; + } + } } } From 988df3948120a40e21d069dd5c92e7e112781679 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 16 Jun 2026 17:10:27 +0200 Subject: [PATCH 12/43] fix(http): Reject connections on initialize errors --- src/agent-client-protocol-http/src/client.rs | 18 ++- .../src/connection.rs | 9 +- .../src/http_server.rs | 107 ++++++++++++++++-- 3 files changed, 110 insertions(+), 24 deletions(-) diff --git a/src/agent-client-protocol-http/src/client.rs b/src/agent-client-protocol-http/src/client.rs index 51fe3c4b..218a7aed 100644 --- a/src/agent-client-protocol-http/src/client.rs +++ b/src/agent-client-protocol-http/src/client.rs @@ -225,9 +225,7 @@ impl HttpConnection { } fn spawn_close_task(&self) -> Option> { - let Some(connection_id) = self.take_connection_id() else { - return None; - }; + let connection_id = self.take_connection_id()?; let http = self.http.clone(); let endpoint = self.endpoint.clone(); match tokio::runtime::Handle::try_current() { @@ -701,14 +699,12 @@ mod tests { .unwrap())) .unwrap(); let init_response = timeout(Duration::from_secs(1), async { - loop { - tokio::select! { - result = &mut transport => { - panic!("transport ended before initialize response: {result:?}"); - } - msg = caller.rx.next() => { - break msg.unwrap().unwrap(); - } + tokio::select! { + result = &mut transport => { + panic!("transport ended before initialize response: {result:?}"); + } + msg = caller.rx.next() => { + msg.unwrap().unwrap() } } }) diff --git a/src/agent-client-protocol-http/src/connection.rs b/src/agent-client-protocol-http/src/connection.rs index 302053de..d5a41aba 100644 --- a/src/agent-client-protocol-http/src/connection.rs +++ b/src/agent-client-protocol-http/src/connection.rs @@ -165,10 +165,10 @@ impl Connection { } } - pub(crate) async fn recv_initial(&self) -> Option { + pub(crate) async fn recv_initial(&self) -> Option { let mut guard = self.outbound_rx.lock().await; let rx = guard.as_mut()?; - serde_json::to_string(&rx.recv().await?).ok() + rx.recv().await } pub(crate) async fn shutdown(&self) { @@ -305,6 +305,11 @@ impl ConnectionRegistry { pub(crate) async fn remove(&self, connection_id: &str) -> Option> { self.connections.write().await.remove(connection_id) } + + #[cfg(test)] + pub(crate) async fn len(&self) -> usize { + self.connections.read().await.len() + } } async fn close_connection_task(connection: Weak) { diff --git a/src/agent-client-protocol-http/src/http_server.rs b/src/agent-client-protocol-http/src/http_server.rs index 1d117ec8..e6cd3424 100644 --- a/src/agent-client-protocol-http/src/http_server.rs +++ b/src/agent-client-protocol-http/src/http_server.rs @@ -1,6 +1,6 @@ use std::{convert::Infallible, error::Error as _, sync::Arc, time::Duration}; -use agent_client_protocol::RawJsonRpcMessage; +use agent_client_protocol::{RawJsonRpcMessage, schema::Response as RpcResponse}; use axum::{ body::Body, extract::State, @@ -83,18 +83,29 @@ pub(crate) async fn handle_post( ) .into_response(); }; + let initialize_failed = matches!( + init_response, + RawJsonRpcMessage::Response(RpcResponse::Error { .. }) + ); + let init_response = match serde_json::to_string(&init_response) { + Ok(response) => response, + Err(e) => { + registry.remove(&connection_id).await; + connection.shutdown().await; + error!("failed to serialize initialize response: {e}"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + if initialize_failed { + registry.remove(&connection_id).await; + connection.shutdown().await; + info!(connection_id = %connection_id, "Initialize rejected"); + return json_response(init_response); + } connection.start_router().await; info!(connection_id = %connection_id, "Initialize complete"); - return with_connection_header( - ( - StatusCode::OK, - [(header::CONTENT_TYPE, JSON_MIME_TYPE)], - init_response, - ) - .into_response(), - &connection_id, - ); + return with_connection_header(json_response(init_response), &connection_id); } let Some(connection_id) = connection_id else { @@ -242,6 +253,15 @@ fn with_connection_header(mut response: Response, connection_id: &str) -> Respon response } +fn json_response(body: String) -> Response { + ( + StatusCode::OK, + [(header::CONTENT_TYPE, JSON_MIME_TYPE)], + body, + ) + .into_response() +} + fn content_length_exceeds_limit(headers: &HeaderMap) -> bool { headers .get(header::CONTENT_LENGTH) @@ -269,7 +289,7 @@ fn post_body_too_large_response() -> Response { mod tests { use std::sync::Arc; - use agent_client_protocol::{Channel, RawJsonRpcMessage}; + use agent_client_protocol::{Channel, RawJsonRpcMessage, schema::RequestId}; use futures::{StreamExt, future::BoxFuture}; use serde_json::json; use tokio::{ @@ -310,6 +330,34 @@ mod tests { } } + struct RejectingInitializeAgentFactory; + + impl AgentFactory for RejectingInitializeAgentFactory { + fn spawn_agent( + &self, + ) -> ( + Channel, + BoxFuture<'static, agent_client_protocol::Result<()>>, + ) { + let (mut agent, transport) = Channel::duplex(); + let future = Box::pin(async move { + if let Some(Ok(RawJsonRpcMessage::Request(request))) = agent.rx.next().await { + agent + .tx + .unbounded_send(Ok(RawJsonRpcMessage::response( + request.id, + Err(agent_client_protocol::Error::invalid_request() + .data("initialize rejected")), + ))) + .unwrap(); + } + std::future::pending::>().await + }); + + (transport, future) + } + } + #[tokio::test] async fn post_rejects_declared_body_larger_than_limit() { let (forwarded_tx, _forwarded_rx) = mpsc::unbounded_channel(); @@ -332,6 +380,43 @@ mod tests { assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); } + #[tokio::test] + async fn initialize_error_response_rejects_connection() { + let registry = Arc::new(ConnectionRegistry::new(Arc::new( + RejectingInitializeAgentFactory, + ))); + let body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {} + }) + .to_string(); + let request = Request::builder() + .method("POST") + .uri("/acp") + .header(header::CONTENT_TYPE, JSON_MIME_TYPE) + .body(Body::from(body)) + .unwrap(); + + let response = handle_post(State(registry.clone()), request).await; + + assert_eq!(response.status(), StatusCode::OK); + assert!(response.headers().get(HEADER_CONNECTION_ID).is_none()); + assert_eq!(registry.len().await, 0); + let body = axum::body::to_bytes(response.into_body(), 1024) + .await + .unwrap(); + let message = serde_json::from_slice::(&body).unwrap(); + assert!(matches!( + message, + RawJsonRpcMessage::Response(RpcResponse::Error { + id: RequestId::Number(1), + .. + }) + )); + } + #[tokio::test] async fn post_forwards_header_session_id_to_agent_params() { let (forwarded_tx, mut forwarded_rx) = mpsc::unbounded_channel(); From 9b2d2835d769760c96e3c3e7e6f36039303318e8 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 17 Jun 2026 10:34:37 +0200 Subject: [PATCH 13/43] fix(http): close slow outbound subscribers --- .../src/connection.rs | 62 +++++++++++++------ .../src/http_server.rs | 48 ++++++++++++-- .../src/websocket_server.rs | 7 +-- 3 files changed, 86 insertions(+), 31 deletions(-) diff --git a/src/agent-client-protocol-http/src/connection.rs b/src/agent-client-protocol-http/src/connection.rs index d5a41aba..b7290e89 100644 --- a/src/agent-client-protocol-http/src/connection.rs +++ b/src/agent-client-protocol-http/src/connection.rs @@ -5,7 +5,7 @@ use std::{ use agent_client_protocol::{Channel, RawJsonRpcMessage, schema::RequestId}; use futures::{SinkExt, StreamExt}; -use tokio::sync::{Mutex, RwLock, broadcast, mpsc, watch}; +use tokio::sync::{Mutex, RwLock, mpsc, watch}; use tracing::{debug, error, trace}; use crate::protocol::session_id_from_message; @@ -17,39 +17,58 @@ pub(crate) enum ResponseRoute { } struct OutboundStream { - tx: broadcast::Sender, - replay: Mutex>>, + state: Mutex, +} + +struct OutboundStreamState { + replay: Option>, + subscribers: Vec>, } impl OutboundStream { fn new() -> Self { - let (tx, _) = broadcast::channel(1024); Self { - tx, - replay: Mutex::new(Some(VecDeque::new())), + state: Mutex::new(OutboundStreamState { + replay: Some(VecDeque::new()), + subscribers: Vec::new(), + }), } } async fn push(&self, msg: String) { - let mut replay = self.replay.lock().await; - if let Some(replay) = replay.as_mut() { - if replay.len() == 1024 { + let mut state = self.state.lock().await; + if let Some(replay) = state.replay.as_mut() { + if replay.len() == OUTBOUND_STREAM_CAPACITY { replay.pop_front(); } replay.push_back(msg); } else { - drop(replay); - drop(self.tx.send(msg)); + state + .subscribers + .retain(|subscriber| match subscriber.try_send(msg.clone()) { + Ok(()) => true, + Err(mpsc::error::TrySendError::Full(_)) => { + debug!("outbound subscriber queue full; closing subscriber stream"); + false + } + Err(mpsc::error::TrySendError::Closed(_)) => false, + }); } } - async fn subscribe(&self) -> (Vec, broadcast::Receiver) { - let mut replay = self.replay.lock().await; - let receiver = self.tx.subscribe(); - (replay.take().map(Vec::from).unwrap_or_default(), receiver) + async fn subscribe(&self) -> (Vec, mpsc::Receiver) { + let mut state = self.state.lock().await; + let (tx, receiver) = mpsc::channel(OUTBOUND_STREAM_CAPACITY); + state.subscribers.push(tx); + ( + state.replay.take().map(Vec::from).unwrap_or_default(), + receiver, + ) } } +pub(crate) const OUTBOUND_STREAM_CAPACITY: usize = 1024; + pub(crate) struct Connection { inbound_tx: mpsc::UnboundedSender>, outbound_rx: Mutex>>, @@ -81,20 +100,18 @@ impl Connection { pub(crate) async fn subscribe_connection_stream( &self, - ) -> (Vec, broadcast::Receiver) { + ) -> (Vec, mpsc::Receiver) { self.connection_stream.subscribe().await } pub(crate) async fn subscribe_session_stream( &self, session_id: &str, - ) -> (Vec, broadcast::Receiver) { + ) -> (Vec, mpsc::Receiver) { self.session_stream(session_id).await.subscribe().await } - pub(crate) async fn subscribe_all_outbound( - &self, - ) -> (Vec, broadcast::Receiver) { + pub(crate) async fn subscribe_all_outbound(&self) -> (Vec, mpsc::Receiver) { self.all_outbound.subscribe().await } @@ -102,6 +119,11 @@ impl Connection { self.closed_tx.subscribe() } + #[cfg(test)] + pub(crate) async fn push_connection_stream_for_test(&self, msg: String) { + self.connection_stream.push(msg).await; + } + async fn session_stream(&self, session_id: &str) -> Arc { if let Some(stream) = self.session_streams.read().await.get(session_id) { return stream.clone(); diff --git a/src/agent-client-protocol-http/src/http_server.rs b/src/agent-client-protocol-http/src/http_server.rs index e6cd3424..cf96ee52 100644 --- a/src/agent-client-protocol-http/src/http_server.rs +++ b/src/agent-client-protocol-http/src/http_server.rs @@ -7,8 +7,7 @@ use axum::{ http::{HeaderMap, HeaderValue, Request, StatusCode, header}, response::{IntoResponse, Response, Sse, sse::Event}, }; -use tokio::sync::broadcast; -use tracing::{debug, error, info, trace}; +use tracing::{error, info, trace}; use crate::{ connection::{ConnectionRegistry, ResponseRoute}, @@ -190,12 +189,11 @@ pub(crate) async fn handle_get( } tokio::select! { recv = receiver.recv() => match recv { - Ok(msg) => { + Some(msg) => { trace!(payload = %msg, "SSE → client"); yield Ok(Event::default().data(msg)); } - Err(broadcast::error::RecvError::Lagged(n)) => debug!("SSE subscriber lagged {n} messages"), - Err(broadcast::error::RecvError::Closed) => break, + None => break, }, changed = closed.changed() => { if changed.is_err() || *closed.borrow() { @@ -298,7 +296,7 @@ mod tests { }; use super::*; - use crate::connection::AgentFactory; + use crate::connection::{AgentFactory, OUTBOUND_STREAM_CAPACITY}; struct CapturingAgentFactory { forwarded: mpsc::UnboundedSender, @@ -417,6 +415,44 @@ mod tests { )); } + #[tokio::test] + async fn sse_closes_slow_subscriber_before_skipping_messages() { + let (forwarded_tx, _forwarded_rx) = mpsc::unbounded_channel(); + let registry = Arc::new(ConnectionRegistry::new(Arc::new(CapturingAgentFactory { + forwarded: forwarded_tx, + }))); + let (connection_id, connection) = registry.create_connection().await; + let request = Request::builder() + .method("GET") + .uri("/acp") + .header(header::ACCEPT, EVENT_STREAM_MIME_TYPE) + .header(HEADER_CONNECTION_ID, connection_id.as_str()) + .body(Body::empty()) + .unwrap(); + let response = handle_get(registry, request).await; + assert_eq!(response.status(), StatusCode::OK); + + for i in 0..=OUTBOUND_STREAM_CAPACITY { + connection + .push_connection_stream_for_test(format!("message-{i}")) + .await; + } + + let body = timeout( + Duration::from_secs(1), + axum::body::to_bytes(response.into_body(), 1024 * 1024), + ) + .await + .unwrap() + .unwrap(); + let body = String::from_utf8(body.to_vec()).unwrap(); + assert!(body.contains("message-0")); + assert!(body.contains(&format!("message-{}", OUTBOUND_STREAM_CAPACITY - 1))); + assert!(!body.contains(&format!("message-{OUTBOUND_STREAM_CAPACITY}"))); + + connection.shutdown().await; + } + #[tokio::test] async fn post_forwards_header_session_id_to_agent_params() { let (forwarded_tx, mut forwarded_rx) = mpsc::unbounded_channel(); diff --git a/src/agent-client-protocol-http/src/websocket_server.rs b/src/agent-client-protocol-http/src/websocket_server.rs index 809c3acd..1a35638a 100644 --- a/src/agent-client-protocol-http/src/websocket_server.rs +++ b/src/agent-client-protocol-http/src/websocket_server.rs @@ -115,17 +115,14 @@ async fn run_ws( recv = outbound_rx.recv() => { match recv { - Ok(text) => { + Some(text) => { trace!(connection_id = %connection_id, payload = %text, "Agent → Client: {} bytes", text.len()); if ws_tx.send(WsMessage::Text(text.into())).await.is_err() { error!(connection_id = %connection_id, "WebSocket send failed"); break; } } - Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { - warn!(connection_id = %connection_id, "WebSocket lagged {n} messages"); - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + None => break, } } From 2fbe09a2c004979fb2fbed447f7077b235320fab Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 17 Jun 2026 10:59:48 +0200 Subject: [PATCH 14/43] fix(http): handle rejected initialize without SSE --- src/agent-client-protocol-http/src/client.rs | 101 +++++++++++++++++-- 1 file changed, 93 insertions(+), 8 deletions(-) diff --git a/src/agent-client-protocol-http/src/client.rs b/src/agent-client-protocol-http/src/client.rs index 218a7aed..a19f13c2 100644 --- a/src/agent-client-protocol-http/src/client.rs +++ b/src/agent-client-protocol-http/src/client.rs @@ -141,11 +141,16 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { break Err(AcpError::invalid_request() .data("ACP HTTP transport: first message must be `initialize`")); } - if let Err(e) = state.initialize(msg).await { - error!("initialize failed: {e}"); - break Err(AcpError::internal_error().data(format!("initialize: {e}"))); + match state.initialize(msg).await { + Ok(InitializeOutcome::Connected) => { + lifecycle.spawn_sse(state.clone(), None, sse_failure_tx.clone()); + } + Ok(InitializeOutcome::Rejected) => {} + Err(e) => { + error!("initialize failed: {e}"); + break Err(AcpError::internal_error().data(format!("initialize: {e}"))); + } } - lifecycle.spawn_sse(state.clone(), None, sse_failure_tx.clone()); continue; } @@ -329,8 +334,14 @@ struct ClientState { open_session_tx: mpsc::UnboundedSender, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum InitializeOutcome { + Connected, + Rejected, +} + impl ClientState { - async fn initialize(&self, msg: RawJsonRpcMessage) -> Result<(), String> { + async fn initialize(&self, msg: RawJsonRpcMessage) -> Result { let response = self .connection .post() @@ -351,16 +362,26 @@ impl ClientState { .headers() .get(HEADER_CONNECTION_ID) .and_then(|v| v.to_str().ok()) - .map(String::from) - .ok_or_else(|| format!("server did not return {HEADER_CONNECTION_ID} header"))?; + .map(String::from); let message = response .json::() .await .map_err(|e| e.to_string())?; + if matches!( + message, + RawJsonRpcMessage::Response(RpcResponse::Error { .. }) + ) { + self.deliver(message); + return Ok(InitializeOutcome::Rejected); + } + + let connection_id = connection_id + .ok_or_else(|| format!("server did not return {HEADER_CONNECTION_ID} header"))?; + self.connection.set_connection_id(connection_id); self.deliver(message); - Ok(()) + Ok(InitializeOutcome::Connected) } async fn post(&self, msg: RawJsonRpcMessage) -> Result<(), String> { @@ -718,6 +739,63 @@ mod tests { server.abort(); } + #[tokio::test] + async fn initialize_error_without_connection_id_is_delivered_without_sse() { + let get_count = Arc::new(AtomicUsize::new(0)); + let get_count_for_handler = get_count.clone(); + let app = Router::new().route( + "/acp", + post(initialize_error_response).get(move || { + let get_count = get_count_for_handler.clone(); + async move { + get_count.fetch_add(1, Ordering::SeqCst); + pending_sse().await + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = HttpClient::new(format!("http://{addr}")).unwrap(); + let (mut caller, transport) = Channel::duplex(); + let transport = tokio::spawn(run(client, transport)); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "initialize".to_string(), + json!({}), + RequestId::Number(1), + ) + .unwrap())) + .unwrap(); + let init_response = timeout(Duration::from_secs(1), caller.rx.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + + assert!(matches!( + init_response, + RawJsonRpcMessage::Response(RpcResponse::Error { + id: RequestId::Number(1), + .. + }) + )); + assert_eq!(get_count.load(Ordering::SeqCst), 0); + + drop(caller); + timeout(Duration::from_secs(1), transport) + .await + .unwrap() + .unwrap() + .unwrap(); + + server.abort(); + } + async fn wait_for_delete(delete_count: &AtomicUsize) { timeout(Duration::from_secs(1), async { loop { @@ -745,6 +823,13 @@ mod tests { ) } + async fn initialize_error_response() -> Json { + Json(RawJsonRpcMessage::response( + RequestId::Number(1), + Err(AcpError::invalid_request().data("initialize rejected")), + )) + } + async fn pending_sse() -> Sse>> { Sse::new(futures::stream::pending()) } From 23a98ae8def30f582a6bac969ee1c4a40fc5fb86 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 17 Jun 2026 11:28:17 +0200 Subject: [PATCH 15/43] feat(http): Add configurable CORS options --- md/http-transport.md | 14 ++ src/agent-client-protocol-http/README.md | 3 + src/agent-client-protocol-http/src/lib.rs | 2 +- src/agent-client-protocol-http/src/server.rs | 132 +++++++++++++++++-- 4 files changed, 137 insertions(+), 14 deletions(-) diff --git a/md/http-transport.md b/md/http-transport.md index fd9dfe67..0b70577d 100644 --- a/md/http-transport.md +++ b/md/http-transport.md @@ -20,6 +20,20 @@ let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await?; axum::serve(listener, app).await?; ``` +Cross-origin browser access is disabled by default. Enable it by allowlisting +the browser origins that should be able to access the ACP endpoint: + +```rust +use agent_client_protocol_http::{AcpHttpServer, CorsOptions, ServerOptions}; + +let app = AcpHttpServer::new(|| my_agent()) + .with_options(ServerOptions { + cors: CorsOptions::allow_origins(["http://localhost:5173"])?, + ..ServerOptions::default() + }) + .into_router(); +``` + ## Client ```rust diff --git a/src/agent-client-protocol-http/README.md b/src/agent-client-protocol-http/README.md index 496e29e4..29e69137 100644 --- a/src/agent-client-protocol-http/README.md +++ b/src/agent-client-protocol-http/README.md @@ -5,4 +5,7 @@ HTTP/WebSocket transport for ACP agents. - **Server**: `AcpHttpServer` exposes agents over HTTP + SSE with optional WebSocket upgrade - **Client**: `HttpClient` connects to remote agents over HTTP + SSE +Cross-origin browser access is disabled by default. Configure `ServerOptions` +with `CorsOptions::allow_origins(...)` to allow specific browser origins. + See the [documentation](https://docs.rs/agent-client-protocol-http) for usage examples. diff --git a/src/agent-client-protocol-http/src/lib.rs b/src/agent-client-protocol-http/src/lib.rs index 11cef13f..42373f7f 100644 --- a/src/agent-client-protocol-http/src/lib.rs +++ b/src/agent-client-protocol-http/src/lib.rs @@ -6,4 +6,4 @@ mod server; mod websocket_server; pub use client::{HttpClient, HttpClientError}; -pub use server::{AcpHttpServer, ServerOptions}; +pub use server::{AcpHttpServer, CorsOptions, ServerOptions}; diff --git a/src/agent-client-protocol-http/src/server.rs b/src/agent-client-protocol-http/src/server.rs index 1afc92c1..a097c73f 100644 --- a/src/agent-client-protocol-http/src/server.rs +++ b/src/agent-client-protocol-http/src/server.rs @@ -5,18 +5,18 @@ use axum::{ Router, extract::WebSocketUpgrade, extract::ws::rejection::WebSocketUpgradeRejection, - http::{HeaderName, Method, header}, - response::Response, + http::{HeaderName, HeaderValue, Method, StatusCode, header, header::InvalidHeaderValue}, + response::{IntoResponse, Response}, routing::{delete, get, post}, }; -use tower_http::cors::{Any, CorsLayer}; +use tower_http::cors::{AllowOrigin, CorsLayer}; use crate::connection::ConnectionRegistry; #[derive(Debug, Clone)] pub struct ServerOptions { pub path: String, - pub permissive_cors: bool, + pub cors: CorsOptions, pub health_endpoint: bool, } @@ -24,12 +24,69 @@ impl Default for ServerOptions { fn default() -> Self { Self { path: "/acp".to_string(), - permissive_cors: true, + cors: CorsOptions::default(), health_endpoint: true, } } } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum CorsOptions { + #[default] + Disabled, + AllowOrigins(Vec), + AllowAnyOrigin, +} + +impl CorsOptions { + #[must_use] + pub fn disabled() -> Self { + Self::Disabled + } + + #[must_use] + pub fn allow_any_origin() -> Self { + Self::AllowAnyOrigin + } + + pub fn allow_origins(origins: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + origins + .into_iter() + .map(|origin| HeaderValue::from_str(origin.as_ref())) + .collect::, _>>() + .map(Self::AllowOrigins) + } + + fn allow_origin_layer(&self) -> Option { + match self { + Self::Disabled => None, + Self::AllowOrigins(origins) => Some(AllowOrigin::list(origins.clone())), + Self::AllowAnyOrigin => Some(AllowOrigin::mirror_request()), + } + } + + fn allows_origin(&self, origin: Option<&HeaderValue>) -> bool { + let Some(origin) = origin else { + return true; + }; + match self { + Self::Disabled => false, + Self::AllowOrigins(origins) => origins.iter().any(|allowed| allowed == origin), + Self::AllowAnyOrigin => true, + } + } +} + +#[derive(Clone)] +struct ServerState { + registry: Arc, + cors: CorsOptions, +} + pub struct AcpHttpServer { registry: Arc, options: ServerOptions, @@ -64,13 +121,18 @@ impl AcpHttpServer { pub fn into_router(self) -> Router { let registry = self.registry.clone(); let path = self.options.path.clone(); + let cors = self.options.cors.clone(); + let state = ServerState { + registry: registry.clone(), + cors: cors.clone(), + }; let mut router = Router::new() .route( &path, post(crate::http_server::handle_post).with_state(registry.clone()), ) - .route(&path, get(handle_get).with_state(registry.clone())) + .route(&path, get(handle_get).with_state(state)) .route( &path, delete(crate::http_server::handle_delete).with_state(registry), @@ -80,8 +142,8 @@ impl AcpHttpServer { router = router.route("/health", get(health)); } - if self.options.permissive_cors { - router = router.layer(default_cors()); + if let Some(allow_origin) = cors.allow_origin_layer() { + router = router.layer(default_cors(allow_origin)); } router @@ -92,9 +154,9 @@ async fn health() -> &'static str { "ok" } -fn default_cors() -> CorsLayer { +fn default_cors(allow_origin: AllowOrigin) -> CorsLayer { CorsLayer::new() - .allow_origin(Any) + .allow_origin(allow_origin) .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS]) .allow_headers([ header::CONTENT_TYPE, @@ -114,11 +176,55 @@ fn default_cors() -> CorsLayer { async fn handle_get( ws_upgrade: Result, - axum::extract::State(registry): axum::extract::State>, + axum::extract::State(state): axum::extract::State, request: axum::http::Request, ) -> Response { match ws_upgrade { - Ok(ws) => crate::websocket_server::handle_ws_upgrade(registry, ws).await, - Err(_) => crate::http_server::handle_get(registry, request).await, + Ok(ws) => { + if !state + .cors + .allows_origin(request.headers().get(header::ORIGIN)) + { + return (StatusCode::FORBIDDEN, "WebSocket origin not allowed").into_response(); + } + crate::websocket_server::handle_ws_upgrade(state.registry, ws).await + } + Err(_) => crate::http_server::handle_get(state.registry, request).await, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cors_is_disabled_by_default() { + assert_eq!(ServerOptions::default().cors, CorsOptions::Disabled); + } + + #[test] + fn disabled_cors_rejects_browser_origin_for_websockets() { + let origin = HeaderValue::from_static("http://localhost:5173"); + + assert!(CorsOptions::disabled().allows_origin(None)); + assert!(!CorsOptions::disabled().allows_origin(Some(&origin))); + } + + #[test] + fn cors_allowlist_matches_configured_origins() { + let allowed = HeaderValue::from_static("http://localhost:5173"); + let denied = HeaderValue::from_static("http://localhost:3000"); + let cors = CorsOptions::allow_origins(["http://localhost:5173"]).unwrap(); + + assert!(cors.allows_origin(None)); + assert!(cors.allows_origin(Some(&allowed))); + assert!(!cors.allows_origin(Some(&denied))); + } + + #[test] + fn explicit_allow_any_origin_accepts_browser_origins() { + let origin = HeaderValue::from_static("https://example.com"); + + assert!(CorsOptions::allow_any_origin().allows_origin(Some(&origin))); } } From a945d649999bbd0747133570aa68736d22d0ad96 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 17 Jun 2026 11:41:36 +0200 Subject: [PATCH 16/43] fix(http): Defer WebSocket connection creation --- src/agent-client-protocol-http/src/connection.rs | 13 +++++++++++-- src/agent-client-protocol-http/src/server.rs | 2 +- .../src/websocket_server.rs | 13 +++++++------ 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/agent-client-protocol-http/src/connection.rs b/src/agent-client-protocol-http/src/connection.rs index b7290e89..54b905b2 100644 --- a/src/agent-client-protocol-http/src/connection.rs +++ b/src/agent-client-protocol-http/src/connection.rs @@ -245,7 +245,17 @@ impl ConnectionRegistry { } } + pub(crate) fn next_connection_id() -> String { + uuid::Uuid::new_v4().to_string() + } + pub(crate) async fn create_connection(&self) -> (String, Arc) { + let connection_id = Self::next_connection_id(); + let connection = self.create_connection_with_id(connection_id.clone()).await; + (connection_id, connection) + } + + pub(crate) async fn create_connection_with_id(&self, connection_id: String) -> Arc { let (mut channel, agent_future) = self.factory.spawn_agent(); let (inbound_tx, mut inbound_rx) = mpsc::unbounded_channel::>(); @@ -279,7 +289,6 @@ impl ConnectionRegistry { futures::join!(inbound, outbound); }; - let connection_id = uuid::Uuid::new_v4().to_string(); let connection = Arc::new(Connection { inbound_tx, outbound_rx: Mutex::new(Some(outbound_rx)), @@ -317,7 +326,7 @@ impl ConnectionRegistry { *connection.agent_handle.lock().await = Some(agent_handle); - (connection_id, connection) + connection } pub(crate) async fn get(&self, connection_id: &str) -> Option> { diff --git a/src/agent-client-protocol-http/src/server.rs b/src/agent-client-protocol-http/src/server.rs index a097c73f..a3109973 100644 --- a/src/agent-client-protocol-http/src/server.rs +++ b/src/agent-client-protocol-http/src/server.rs @@ -187,7 +187,7 @@ async fn handle_get( { return (StatusCode::FORBIDDEN, "WebSocket origin not allowed").into_response(); } - crate::websocket_server::handle_ws_upgrade(state.registry, ws).await + crate::websocket_server::handle_ws_upgrade(state.registry, ws) } Err(_) => crate::http_server::handle_get(state.registry, request).await, } diff --git a/src/agent-client-protocol-http/src/websocket_server.rs b/src/agent-client-protocol-http/src/websocket_server.rs index 1a35638a..c2eb30d3 100644 --- a/src/agent-client-protocol-http/src/websocket_server.rs +++ b/src/agent-client-protocol-http/src/websocket_server.rs @@ -14,17 +14,19 @@ use crate::{ protocol::{HEADER_CONNECTION_ID, session_id_from_message}, }; -pub(crate) async fn handle_ws_upgrade( +pub(crate) fn handle_ws_upgrade( registry: Arc, ws: WebSocketUpgrade, ) -> Response { - let (connection_id, connection) = registry.create_connection().await; - - connection.start_router().await; - + let connection_id = ConnectionRegistry::next_connection_id(); let conn_id_for_handler = connection_id.clone(); let registry_for_handler = registry.clone(); let mut response = ws.on_upgrade(move |socket| async move { + let connection = registry_for_handler + .create_connection_with_id(conn_id_for_handler.clone()) + .await; + connection.start_router().await; + info!(connection_id = %conn_id_for_handler, "WebSocket connection created"); run_ws( socket, registry_for_handler, @@ -37,7 +39,6 @@ pub(crate) async fn handle_ws_upgrade( if let Ok(v) = HeaderValue::from_str(&connection_id) { response.headers_mut().insert(HEADER_CONNECTION_ID, v); } - info!(connection_id = %connection_id, "WebSocket connection created"); response } From 377f65d9e796461247de8189db147ec7ebe56d9b Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 17 Jun 2026 12:20:35 +0200 Subject: [PATCH 17/43] chore: allow additional dependency licenses --- deny.toml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/deny.toml b/deny.toml index 9e0d7fc2..4b85d722 100644 --- a/deny.toml +++ b/deny.toml @@ -90,6 +90,8 @@ ignore = [ # [possible values: any SPDX 3.11 short identifier (+ optional exception)]. allow = [ "MIT", + "MIT-0", + "ISC", "Apache-2.0", "Apache-2.0 WITH LLVM-exception", "Unicode-3.0", @@ -103,9 +105,11 @@ confidence-threshold = 0.8 # Allow 1 or more licenses on a per-crate basis, so that particular licenses # aren't accepted for every possible crate as with the normal allow list exceptions = [ - # Each entry is the crate and version constraint, and its specific allow - # list - #{ allow = ["Zlib"], crate = "adler32" }, + # Static Mozilla/WebPKI root certificate data bundles. CDLA-Permissive-2.0 + # is a permissive data license, so keep it scoped to certificate roots + # instead of allowing it for arbitrary code crates. + { allow = ["CDLA-Permissive-2.0"], crate = "webpki-root-certs" }, + { allow = ["CDLA-Permissive-2.0"], crate = "webpki-roots" }, ] # Some crates don't have (easily) machine readable licensing information, From 5161ed83913fddac0f8af2dacc377d1e134ada91 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 17 Jun 2026 12:30:09 +0200 Subject: [PATCH 18/43] feat(http): add explicit endpoint client constructors --- src/agent-client-protocol-http/src/client.rs | 74 ++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/src/agent-client-protocol-http/src/client.rs b/src/agent-client-protocol-http/src/client.rs index a19f13c2..ee2dac34 100644 --- a/src/agent-client-protocol-http/src/client.rs +++ b/src/agent-client-protocol-http/src/client.rs @@ -44,10 +44,26 @@ impl std::fmt::Debug for HttpClient { } impl HttpClient { + /// Create a client from a base URL and target the standard ACP endpoint. + /// + /// If the URL path is empty, `/acp` is used. Otherwise `/acp` is appended + /// unless the path already ends with `/acp`. pub fn new(base_url: impl AsRef) -> Result { Self::with_client(base_url, reqwest::Client::new()) } + /// Create a client that targets the exact endpoint URL. + /// + /// Use this when connecting to a server configured with a custom + /// [`ServerOptions::path`](crate::ServerOptions::path). + pub fn with_endpoint(endpoint: impl AsRef) -> Result { + Self::with_endpoint_and_client(endpoint, reqwest::Client::new()) + } + + /// Create a client with a custom HTTP client and the standard ACP endpoint. + /// + /// If the URL path is empty, `/acp` is used. Otherwise `/acp` is appended + /// unless the path already ends with `/acp`. pub fn with_client( base_url: impl AsRef, http: reqwest::Client, @@ -65,6 +81,18 @@ impl HttpClient { Ok(Self { endpoint, http }) } + /// Create a client with a custom HTTP client and exact endpoint URL. + /// + /// Use this when connecting to a server configured with a custom + /// [`ServerOptions::path`](crate::ServerOptions::path). + pub fn with_endpoint_and_client( + endpoint: impl AsRef, + http: reqwest::Client, + ) -> Result { + let endpoint = url::Url::parse(endpoint.as_ref())?; + Ok(Self { endpoint, http }) + } + fn is_websocket(&self) -> bool { matches!(self.endpoint.scheme(), "ws" | "wss") } @@ -576,6 +604,52 @@ mod tests { use super::*; + #[test] + fn new_targets_standard_acp_endpoint() { + assert_eq!( + HttpClient::new("http://example.com") + .unwrap() + .endpoint + .as_str(), + "http://example.com/acp" + ); + assert_eq!( + HttpClient::new("http://example.com/proxy") + .unwrap() + .endpoint + .as_str(), + "http://example.com/proxy/acp" + ); + assert_eq!( + HttpClient::new("http://example.com/proxy/acp") + .unwrap() + .endpoint + .as_str(), + "http://example.com/proxy/acp" + ); + } + + #[test] + fn with_endpoint_preserves_explicit_endpoint_path() { + assert_eq!( + HttpClient::with_endpoint("http://example.com/agent") + .unwrap() + .endpoint + .as_str(), + "http://example.com/agent" + ); + assert_eq!( + HttpClient::with_endpoint_and_client( + "ws://example.com/custom/acp?token=abc", + reqwest::Client::new(), + ) + .unwrap() + .endpoint + .as_str(), + "ws://example.com/custom/acp?token=abc" + ); + } + #[tokio::test] async fn post_error_deletes_initialized_connection() { let delete_count = Arc::new(AtomicUsize::new(0)); From b076ebd119f84991196b402e7159d710f3c486a0 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 17 Jun 2026 12:50:28 +0200 Subject: [PATCH 19/43] fix(http): Drain outbound messages on agent exit --- .../src/connection.rs | 111 ++++++++++++++---- 1 file changed, 89 insertions(+), 22 deletions(-) diff --git a/src/agent-client-protocol-http/src/connection.rs b/src/agent-client-protocol-http/src/connection.rs index 54b905b2..6a992d17 100644 --- a/src/agent-client-protocol-http/src/connection.rs +++ b/src/agent-client-protocol-http/src/connection.rs @@ -256,37 +256,43 @@ impl ConnectionRegistry { } pub(crate) async fn create_connection_with_id(&self, connection_id: String) -> Arc { - let (mut channel, agent_future) = self.factory.spawn_agent(); + let (channel, agent_future) = self.factory.spawn_agent(); let (inbound_tx, mut inbound_rx) = mpsc::unbounded_channel::>(); let (outbound_tx, outbound_rx) = mpsc::unbounded_channel::(); let (closed_tx, _) = watch::channel(false); - let pump = async move { - let inbound = async { - while let Some(msg) = inbound_rx.recv().await { - if channel.tx.send(msg).await.is_err() { - break; - } + let Channel { + rx: mut agent_rx, + tx: mut agent_tx, + } = channel; + let inbound = async move { + while let Some(msg) = inbound_rx.recv().await { + if agent_tx.send(msg).await.is_err() { + break; } - drop(channel.tx.close().await); - }; - let outbound = async { - while let Some(msg) = channel.rx.next().await { - match msg { - Ok(m) => { - if outbound_tx.send(m).is_err() { - break; - } - } - Err(e) => { - error!("agent emitted error: {e}"); + } + drop(agent_tx.close().await); + }; + let (inbound_abort, inbound_abort_registration) = futures::future::AbortHandle::new_pair(); + let inbound = futures::future::Abortable::new(inbound, inbound_abort_registration); + let outbound = async move { + while let Some(msg) = agent_rx.next().await { + match msg { + Ok(m) => { + if outbound_tx.send(m).is_err() { break; } } + Err(e) => { + error!("agent emitted error: {e}"); + break; + } } - }; - futures::join!(inbound, outbound); + } + }; + let pump = async move { + let (_inbound_result, ()) = futures::join!(inbound, outbound); }; let connection = Arc::new(Connection { @@ -318,7 +324,13 @@ impl ConnectionRegistry { }; futures::pin_mut!(agent); futures::pin_mut!(pump); - futures::future::select(agent, pump).await; + match futures::future::select(agent, pump).await { + futures::future::Either::Left(((), pump)) => { + inbound_abort.abort(); + pump.await; + } + futures::future::Either::Right(((), _agent)) => {} + } debug!(connection_id = %conn_id_for_task, "HTTP ACP connection task ended"); connections.write().await.remove(&conn_id_for_task); close_connection_task(connection_for_task).await; @@ -395,6 +407,31 @@ mod tests { } } + struct RespondThenExitAgentFactory; + + impl AgentFactory for RespondThenExitAgentFactory { + fn spawn_agent( + &self, + ) -> ( + Channel, + BoxFuture<'static, agent_client_protocol::Result<()>>, + ) { + let (agent, transport) = Channel::duplex(); + let future = Box::pin(async move { + agent + .tx + .unbounded_send(Ok(RawJsonRpcMessage::response( + RequestId::Number(1), + Ok(serde_json::json!({ "done": true })), + ))) + .unwrap(); + Ok(()) + }); + + (transport, future) + } + } + #[tokio::test] async fn agent_exit_removes_connection_and_closes_streams() { let exit = Arc::new(Notify::new()); @@ -418,4 +455,34 @@ mod tests { assert!(*connection.subscribe_closed().borrow()); } + + #[tokio::test] + async fn agent_exit_drains_buffered_outbound_messages() { + let registry = ConnectionRegistry::new(Arc::new(RespondThenExitAgentFactory)); + let (connection_id, connection) = registry.create_connection().await; + + let message = timeout(Duration::from_secs(1), connection.recv_initial()) + .await + .unwrap() + .expect("buffered response should be forwarded before teardown"); + + assert!(matches!( + message, + RawJsonRpcMessage::Response(agent_client_protocol::schema::Response::Result { + id: RequestId::Number(1), + .. + }) + )); + timeout(Duration::from_secs(1), async { + loop { + if registry.get(&connection_id).await.is_none() { + break; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + assert!(*connection.subscribe_closed().borrow()); + } } From e6b39ee107527d08aab8b6b82ba027feadbdcf3e Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 17 Jun 2026 14:39:17 +0200 Subject: [PATCH 20/43] fix(http): Fail transport on malformed SSE JSON --- src/agent-client-protocol-http/src/client.rs | 62 +++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/src/agent-client-protocol-http/src/client.rs b/src/agent-client-protocol-http/src/client.rs index ee2dac34..68075b34 100644 --- a/src/agent-client-protocol-http/src/client.rs +++ b/src/agent-client-protocol-http/src/client.rs @@ -489,7 +489,7 @@ impl ClientState { } self.deliver(msg); } - Err(e) => warn!("SSE: malformed JSON-RPC payload: {e}"), + Err(e) => return Err(format!("malformed JSON-RPC payload: {e}")), } } Ok(()) @@ -761,6 +761,59 @@ mod tests { server.abort(); } + #[tokio::test] + async fn malformed_sse_json_fails_transport() { + let delete_count = Arc::new(AtomicUsize::new(0)); + let delete_count_for_handler = delete_count.clone(); + let app = Router::new().route( + "/acp", + post(initialize_response) + .get(malformed_sse) + .delete(move || { + let delete_count = delete_count_for_handler.clone(); + async move { + delete_count.fetch_add(1, Ordering::SeqCst); + StatusCode::ACCEPTED + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = HttpClient::new(format!("http://{addr}")).unwrap(); + let (mut caller, transport) = Channel::duplex(); + let transport = tokio::spawn(run(client, transport)); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "initialize".to_string(), + json!({}), + RequestId::Number(1), + ) + .unwrap())) + .unwrap(); + let init_response = timeout(Duration::from_secs(1), caller.rx.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(matches!(init_response, RawJsonRpcMessage::Response(_))); + + let error = timeout(Duration::from_secs(1), transport) + .await + .unwrap() + .unwrap() + .unwrap_err(); + + assert!(error.to_string().contains("malformed JSON-RPC payload")); + assert_eq!(delete_count.load(Ordering::SeqCst), 1); + + server.abort(); + } + #[tokio::test] async fn dropped_transport_future_deletes_initialized_connection() { let delete_count = Arc::new(AtomicUsize::new(0)); @@ -908,6 +961,13 @@ mod tests { Sse::new(futures::stream::pending()) } + async fn malformed_sse() -> Sse>> { + let invalid = futures::stream::once(async { + Ok::<_, Infallible>(Event::default().data("{not json")) + }); + Sse::new(invalid.chain(futures::stream::pending())) + } + async fn closed_sse() -> StatusCode { StatusCode::OK } From 8483856da8fce6c8763509ca0839a9db9ca8b780 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 17 Jun 2026 14:42:08 +0200 Subject: [PATCH 21/43] test: Cover all session-scoped HTTP methods --- .../src/http_server.rs | 33 +++++++++++++++++++ .../src/protocol.rs | 24 ++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/agent-client-protocol-http/src/http_server.rs b/src/agent-client-protocol-http/src/http_server.rs index cf96ee52..482bceb5 100644 --- a/src/agent-client-protocol-http/src/http_server.rs +++ b/src/agent-client-protocol-http/src/http_server.rs @@ -489,4 +489,37 @@ mod tests { ); connection.shutdown().await; } + + #[tokio::test] + async fn post_rejects_session_scoped_method_without_session_id() { + let (forwarded_tx, mut forwarded_rx) = mpsc::unbounded_channel(); + let registry = Arc::new(ConnectionRegistry::new(Arc::new(CapturingAgentFactory { + forwarded: forwarded_tx, + }))); + let (connection_id, connection) = registry.create_connection().await; + let body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "session/delete", + "params": {} + }) + .to_string(); + let request = Request::builder() + .method("POST") + .uri("/acp") + .header(header::CONTENT_TYPE, JSON_MIME_TYPE) + .header(HEADER_CONNECTION_ID, connection_id.as_str()) + .body(Body::from(body)) + .unwrap(); + + let response = handle_post(State(registry), request).await; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = axum::body::to_bytes(response.into_body(), 1024) + .await + .unwrap(); + assert_eq!(body.as_ref(), b"Acp-Session-Id header required"); + assert!(forwarded_rx.try_recv().is_err()); + connection.shutdown().await; + } } diff --git a/src/agent-client-protocol-http/src/protocol.rs b/src/agent-client-protocol-http/src/protocol.rs index 6f5afcac..8997f929 100644 --- a/src/agent-client-protocol-http/src/protocol.rs +++ b/src/agent-client-protocol-http/src/protocol.rs @@ -10,7 +10,11 @@ pub(crate) fn method_requires_session_header(method: &str) -> bool { method, "session/prompt" | "session/cancel" + | "session/close" + | "session/delete" | "session/load" + | "session/resume" + | "session/set_config_option" | "session/set_mode" | "session/set_model" ) @@ -150,4 +154,24 @@ mod tests { "Acp-Session-Id header does not match params.sessionId" ); } + + #[test] + fn all_session_scoped_client_methods_require_session_header() { + for method in [ + "session/cancel", + "session/close", + "session/delete", + "session/load", + "session/prompt", + "session/resume", + "session/set_config_option", + "session/set_mode", + "session/set_model", + ] { + assert!( + method_requires_session_header(method), + "{method} should require Acp-Session-Id or params.sessionId" + ); + } + } } From 12768a9f081f5d9ba38e344a14fea5bbb86fa3c0 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 17 Jun 2026 15:00:36 +0200 Subject: [PATCH 22/43] feat(http): forward cancel request feature --- md/http-transport.md | 19 +++++ src/agent-client-protocol-http/CHANGELOG.md | 1 + src/agent-client-protocol-http/Cargo.toml | 4 + src/agent-client-protocol-http/README.md | 3 + src/agent-client-protocol-http/src/client.rs | 85 +++++++++++++++++++ .../src/connection.rs | 64 ++++++++++++++ .../src/http_server.rs | 35 ++++++++ .../src/protocol.rs | 69 +++++++++++++++ 8 files changed, 280 insertions(+) diff --git a/md/http-transport.md b/md/http-transport.md index 0b70577d..4edc58b8 100644 --- a/md/http-transport.md +++ b/md/http-transport.md @@ -10,6 +10,25 @@ `POST /acp` request bodies are limited to 16 MiB. +## Request Cancellation + +Request cancellation is available when the transport crate forwards the core SDK +feature: + +```toml +agent-client-protocol-http = { version = "...", features = ["unstable_cancel_request"] } +``` + +`$/cancel_request` is connection-scoped. The HTTP transport does not apply +`Acp-Session-Id` to cancellation notifications, and routes outgoing +cancellation notifications over the connection stream rather than a session +stream. + +WebSocket connections can carry cancellation at any point after the socket is +open. With HTTP + SSE, cancellation can be sent after `initialize` completes and +the client has received `Acp-Connection-Id`; an in-flight `initialize` request +cannot be cancelled with a hop-local `$/cancel_request` on this transport shape. + ## Server ```rust diff --git a/src/agent-client-protocol-http/CHANGELOG.md b/src/agent-client-protocol-http/CHANGELOG.md index 865bde2e..c0329dcf 100644 --- a/src/agent-client-protocol-http/CHANGELOG.md +++ b/src/agent-client-protocol-http/CHANGELOG.md @@ -4,3 +4,4 @@ ### Added - HTTP/SSE/WebSocket transport for ACP agents +- `unstable_cancel_request` feature forwarding for core SDK request cancellation diff --git a/src/agent-client-protocol-http/Cargo.toml b/src/agent-client-protocol-http/Cargo.toml index a2b37af7..cadafe83 100644 --- a/src/agent-client-protocol-http/Cargo.toml +++ b/src/agent-client-protocol-http/Cargo.toml @@ -10,6 +10,10 @@ description = "HTTP and WebSocket transport for the Agent Client Protocol (ACP)" keywords = ["acp", "agent", "protocol", "http", "websocket"] categories = ["development-tools", "web-programming::http-server"] +[features] +default = [] +unstable_cancel_request = ["agent-client-protocol/unstable_cancel_request"] + [dependencies] agent-client-protocol.workspace = true diff --git a/src/agent-client-protocol-http/README.md b/src/agent-client-protocol-http/README.md index 29e69137..29219d8d 100644 --- a/src/agent-client-protocol-http/README.md +++ b/src/agent-client-protocol-http/README.md @@ -8,4 +8,7 @@ HTTP/WebSocket transport for ACP agents. Cross-origin browser access is disabled by default. Configure `ServerOptions` with `CorsOptions::allow_origins(...)` to allow specific browser origins. +Enable the `unstable_cancel_request` feature to forward core SDK request +cancellation support through this transport. + See the [documentation](https://docs.rs/agent-client-protocol-http) for usage examples. diff --git a/src/agent-client-protocol-http/src/client.rs b/src/agent-client-protocol-http/src/client.rs index 68075b34..09a56a81 100644 --- a/src/agent-client-protocol-http/src/client.rs +++ b/src/agent-client-protocol-http/src/client.rs @@ -650,6 +650,91 @@ mod tests { ); } + #[tokio::test] + async fn post_sends_cancel_request_without_session_header() { + let (capture_tx, mut capture_rx) = tokio::sync::mpsc::unbounded_channel(); + let post_count = Arc::new(AtomicUsize::new(0)); + let app = Router::new().route( + "/acp", + post({ + let capture_tx = capture_tx.clone(); + let post_count = post_count.clone(); + move |headers: HeaderMap, Json(message): Json| { + let capture_tx = capture_tx.clone(); + let post_count = post_count.clone(); + async move { + if post_count.fetch_add(1, Ordering::SeqCst) == 0 { + return initialize_response().await.into_response(); + } + + capture_tx + .send((headers.get(HEADER_SESSION_ID).cloned(), message)) + .unwrap(); + StatusCode::ACCEPTED.into_response() + } + } + }) + .get(pending_sse) + .delete(|| async { StatusCode::ACCEPTED }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = HttpClient::new(format!("http://{addr}")).unwrap(); + let (mut caller, transport) = Channel::duplex(); + let transport = tokio::spawn(run(client, transport)); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "initialize".to_string(), + json!({}), + RequestId::Number(1), + ) + .unwrap())) + .unwrap(); + let init_response = timeout(Duration::from_secs(1), caller.rx.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(matches!(init_response, RawJsonRpcMessage::Response(_))); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::notification( + "$/cancel_request".to_string(), + json!({ + "requestId": 2, + "sessionId": "session-1" + }), + ) + .unwrap())) + .unwrap(); + + let (session_header, message) = timeout(Duration::from_secs(1), capture_rx.recv()) + .await + .unwrap() + .unwrap(); + assert!(session_header.is_none()); + assert!(matches!( + message, + RawJsonRpcMessage::Notification(notification) + if notification.method.as_ref() == "$/cancel_request" + )); + + drop(caller); + timeout(Duration::from_secs(1), transport) + .await + .unwrap() + .unwrap() + .unwrap(); + + server.abort(); + } + #[tokio::test] async fn post_error_deletes_initialized_connection() { let delete_count = Arc::new(AtomicUsize::new(0)); diff --git a/src/agent-client-protocol-http/src/connection.rs b/src/agent-client-protocol-http/src/connection.rs index 6a992d17..9fa12c27 100644 --- a/src/agent-client-protocol-http/src/connection.rs +++ b/src/agent-client-protocol-http/src/connection.rs @@ -432,6 +432,31 @@ mod tests { } } + struct SendThenWaitAgentFactory { + message: RawJsonRpcMessage, + exit: Arc, + } + + impl AgentFactory for SendThenWaitAgentFactory { + fn spawn_agent( + &self, + ) -> ( + Channel, + BoxFuture<'static, agent_client_protocol::Result<()>>, + ) { + let (agent, transport) = Channel::duplex(); + let message = self.message.clone(); + let exit = self.exit.clone(); + let future = Box::pin(async move { + agent.tx.unbounded_send(Ok(message)).unwrap(); + exit.notified().await; + Ok(()) + }); + + (transport, future) + } + } + #[tokio::test] async fn agent_exit_removes_connection_and_closes_streams() { let exit = Arc::new(Notify::new()); @@ -485,4 +510,43 @@ mod tests { .unwrap(); assert!(*connection.subscribe_closed().borrow()); } + + #[tokio::test] + async fn protocol_level_notification_routes_to_connection_stream() { + let exit = Arc::new(Notify::new()); + let message = RawJsonRpcMessage::notification( + "$/cancel_request".to_string(), + serde_json::json!({ + "requestId": 1, + "sessionId": "session-1" + }), + ) + .unwrap(); + let registry = ConnectionRegistry::new(Arc::new(SendThenWaitAgentFactory { + message, + exit: exit.clone(), + })); + let (_connection_id, connection) = registry.create_connection().await; + let (_connection_replay, mut connection_rx) = + connection.subscribe_connection_stream().await; + let (_session_replay, mut session_rx) = + connection.subscribe_session_stream("session-1").await; + + connection.start_router().await; + + let text = timeout(Duration::from_secs(1), connection_rx.recv()) + .await + .unwrap() + .expect("protocol-level notification should reach connection stream"); + let routed = serde_json::from_str::(&text).unwrap(); + assert!(matches!( + routed, + RawJsonRpcMessage::Notification(notification) + if notification.method.as_ref() == "$/cancel_request" + )); + assert!(session_rx.try_recv().is_err()); + + exit.notify_one(); + connection.shutdown().await; + } } diff --git a/src/agent-client-protocol-http/src/http_server.rs b/src/agent-client-protocol-http/src/http_server.rs index 482bceb5..f2942d77 100644 --- a/src/agent-client-protocol-http/src/http_server.rs +++ b/src/agent-client-protocol-http/src/http_server.rs @@ -490,6 +490,41 @@ mod tests { connection.shutdown().await; } + #[tokio::test] + async fn post_does_not_apply_session_header_to_cancel_request() { + let (forwarded_tx, mut forwarded_rx) = mpsc::unbounded_channel(); + let registry = Arc::new(ConnectionRegistry::new(Arc::new(CapturingAgentFactory { + forwarded: forwarded_tx, + }))); + let (connection_id, connection) = registry.create_connection().await; + let body = json!({ + "jsonrpc": "2.0", + "method": "$/cancel_request", + "params": { "requestId": 1 } + }) + .to_string(); + let request = Request::builder() + .method("POST") + .uri("/acp") + .header(header::CONTENT_TYPE, JSON_MIME_TYPE) + .header(HEADER_CONNECTION_ID, connection_id.as_str()) + .header(HEADER_SESSION_ID, "session-1") + .body(Body::from(body)) + .unwrap(); + + let response = handle_post(State(registry), request).await; + + assert_eq!(response.status(), StatusCode::ACCEPTED); + let forwarded = timeout(Duration::from_secs(1), forwarded_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(session_id_from_message(&forwarded), None); + let value = serde_json::to_value(forwarded).unwrap(); + assert_eq!(value["params"], json!({ "requestId": 1 })); + connection.shutdown().await; + } + #[tokio::test] async fn post_rejects_session_scoped_method_without_session_id() { let (forwarded_tx, mut forwarded_rx) = mpsc::unbounded_channel(); diff --git a/src/agent-client-protocol-http/src/protocol.rs b/src/agent-client-protocol-http/src/protocol.rs index 8997f929..8825fad0 100644 --- a/src/agent-client-protocol-http/src/protocol.rs +++ b/src/agent-client-protocol-http/src/protocol.rs @@ -32,6 +32,33 @@ pub(crate) fn method_for_message(msg: &RawJsonRpcMessage) -> Option<&str> { } } +pub(crate) fn is_connection_scoped_protocol_message(msg: &RawJsonRpcMessage) -> bool { + method_for_message(msg).is_some_and(|method| method.starts_with("$/")) + || is_cancel_request_message(msg) +} + +#[cfg(feature = "unstable_cancel_request")] +fn is_cancel_request_message(msg: &RawJsonRpcMessage) -> bool { + let RawJsonRpcMessage::Notification(notification) = msg else { + return false; + }; + let params = notification + .params + .clone() + .map_or(serde_json::Value::Null, RawJsonRpcParams::into_value); + let Ok(notification) = + agent_client_protocol::UntypedMessage::new(notification.method.as_ref(), params) + else { + return false; + }; + agent_client_protocol::is_cancel_request_notification(¬ification) +} + +#[cfg(not(feature = "unstable_cancel_request"))] +fn is_cancel_request_message(_msg: &RawJsonRpcMessage) -> bool { + false +} + pub(crate) fn session_id_from_params(params: &RawJsonRpcParams) -> Option { match params { RawJsonRpcParams::Object(map) => map @@ -43,6 +70,10 @@ pub(crate) fn session_id_from_params(params: &RawJsonRpcParams) -> Option Option { + if is_connection_scoped_protocol_message(msg) { + return None; + } + match msg { RawJsonRpcMessage::Request(req) => req.params.as_ref().and_then(session_id_from_params), RawJsonRpcMessage::Notification(notification) => notification @@ -57,6 +88,10 @@ pub(crate) fn apply_session_header_to_message( msg: &mut RawJsonRpcMessage, session_id: &str, ) -> Result<(), &'static str> { + if is_connection_scoped_protocol_message(msg) { + return Ok(()); + } + match msg { RawJsonRpcMessage::Request(req) => { apply_session_header_to_params(&mut req.params, session_id) @@ -155,6 +190,40 @@ mod tests { ); } + #[test] + fn protocol_level_message_ignores_session_header() { + let mut message = RawJsonRpcMessage::notification( + "$/cancel_request".to_string(), + json!({ "requestId": 1 }), + ) + .unwrap(); + + apply_session_header_to_message(&mut message, "session-1").unwrap(); + + assert_eq!(session_id_from_message(&message), None); + let value = serde_json::to_value(message).unwrap(); + assert!(value["params"].get("sessionId").is_none()); + } + + #[cfg(feature = "unstable_cancel_request")] + #[test] + fn successor_wrapped_cancel_request_ignores_session_header() { + let mut message = RawJsonRpcMessage::notification( + "_proxy/successor".to_string(), + json!({ + "method": "$/cancel_request", + "params": { "requestId": 1 } + }), + ) + .unwrap(); + + apply_session_header_to_message(&mut message, "session-1").unwrap(); + + assert_eq!(session_id_from_message(&message), None); + let value = serde_json::to_value(message).unwrap(); + assert!(value["params"].get("sessionId").is_none()); + } + #[test] fn all_session_scoped_client_methods_require_session_header() { for method in [ From e4c744fc170af1626d7d3c9d7daa4db82e54a5e7 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 17 Jun 2026 15:31:32 +0200 Subject: [PATCH 23/43] fix(http): Report WebSocket JSON parse errors --- src/agent-client-protocol-http/src/client.rs | 65 +++++++- .../src/websocket_server.rs | 149 +++++++++++++++++- 2 files changed, 210 insertions(+), 4 deletions(-) diff --git a/src/agent-client-protocol-http/src/client.rs b/src/agent-client-protocol-http/src/client.rs index 09a56a81..e8c7fc07 100644 --- a/src/agent-client-protocol-http/src/client.rs +++ b/src/agent-client-protocol-http/src/client.rs @@ -551,7 +551,18 @@ async fn run_ws(client: HttpClient, channel: Channel) -> Result<(), AcpError> { break; } } - Err(e) => warn!("WS: malformed JSON-RPC payload: {e}"), + Err(e) => { + let message = format!("malformed JSON-RPC payload: {e}"); + warn!("WS: {message}"); + if incoming + .unbounded_send(Err(AcpError::parse_error().data(message))) + .is_err() + { + debug!("upstream channel closed; stopping WS reader"); + break; + } + continue; + } } } Some(Ok(WsMessage::Binary(_))) => { @@ -592,9 +603,10 @@ mod tests { use agent_client_protocol::schema::RequestId; use axum::{ Json, Router, + extract::{WebSocketUpgrade, ws::Message as AxumWsMessage}, http::{HeaderMap, HeaderValue, StatusCode}, response::{IntoResponse, Sse, sse::Event}, - routing::post, + routing::{get, post}, }; use serde_json::json; use tokio::{ @@ -899,6 +911,42 @@ mod tests { server.abort(); } + #[tokio::test] + async fn malformed_ws_json_reports_parse_error_and_continues() { + let app = Router::new().route("/acp", get(malformed_then_valid_ws)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = HttpClient::new(format!("ws://{addr}")).unwrap(); + let (mut caller, transport) = Channel::duplex(); + let transport = tokio::spawn(run(client, transport)); + + let error = timeout(Duration::from_secs(1), caller.rx.next()) + .await + .unwrap() + .unwrap() + .unwrap_err(); + assert!(error.to_string().contains("malformed JSON-RPC payload")); + + let message = timeout(Duration::from_secs(1), caller.rx.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(matches!(message, RawJsonRpcMessage::Response(_))); + + drop(caller); + timeout(Duration::from_secs(1), transport) + .await + .unwrap() + .unwrap() + .unwrap(); + + server.abort(); + } + #[tokio::test] async fn dropped_transport_future_deletes_initialized_connection() { let delete_count = Arc::new(AtomicUsize::new(0)); @@ -1053,6 +1101,19 @@ mod tests { Sse::new(invalid.chain(futures::stream::pending())) } + async fn malformed_then_valid_ws(ws: WebSocketUpgrade) -> impl IntoResponse { + ws.on_upgrade(|mut socket| async move { + drop(socket.send(AxumWsMessage::Text("{not json".into())).await); + let valid = serde_json::to_string(&RawJsonRpcMessage::response( + RequestId::Number(1), + Ok(json!({})), + )) + .unwrap(); + drop(socket.send(AxumWsMessage::Text(valid.into())).await); + futures::future::pending::<()>().await; + }) + } + async fn closed_sse() -> StatusCode { StatusCode::OK } diff --git a/src/agent-client-protocol-http/src/websocket_server.rs b/src/agent-client-protocol-http/src/websocket_server.rs index c2eb30d3..fe9ce436 100644 --- a/src/agent-client-protocol-http/src/websocket_server.rs +++ b/src/agent-client-protocol-http/src/websocket_server.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use agent_client_protocol::RawJsonRpcMessage; +use agent_client_protocol::{Error as AcpError, RawJsonRpcMessage, schema::RequestId}; use axum::{ extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}, http::HeaderValue, @@ -94,7 +94,24 @@ async fn run_ws( } } Err(e) => { - warn!(connection_id = %connection_id, "Ignoring malformed JSON-RPC frame: {e}"); + let message = format!("malformed JSON-RPC payload: {e}"); + warn!(connection_id = %connection_id, "Returning parse error for malformed JSON-RPC frame: {e}"); + let response = RawJsonRpcMessage::response( + RequestId::Null, + Err(AcpError::parse_error().data(message)), + ); + let text = match serde_json::to_string(&response) { + Ok(text) => text, + Err(e) => { + error!(connection_id = %connection_id, "Failed to serialize parse error response: {e}"); + break; + } + }; + if ws_tx.send(WsMessage::Text(text.into())).await.is_err() { + error!(connection_id = %connection_id, "WebSocket send failed"); + break; + } + continue; } } } @@ -140,3 +157,131 @@ async fn run_ws( conn.shutdown().await; } } + +#[cfg(test)] +mod tests { + use agent_client_protocol::{ + Channel, + schema::{RequestId, Response as RpcResponse}, + }; + use axum::{Router, extract::WebSocketUpgrade, routing::get}; + use futures::{SinkExt as _, StreamExt as _, future::BoxFuture}; + use serde_json::json; + use tokio::{ + net::TcpListener, + sync::mpsc, + time::{Duration, timeout}, + }; + use tokio_tungstenite::{connect_async, tungstenite::Message as ClientWsMessage}; + + use crate::connection::{AgentFactory, ConnectionRegistry}; + + use super::*; + + struct CapturingAgentFactory { + forwarded: mpsc::UnboundedSender, + } + + impl AgentFactory for CapturingAgentFactory { + fn spawn_agent( + &self, + ) -> ( + Channel, + BoxFuture<'static, agent_client_protocol::Result<()>>, + ) { + let (agent, transport) = Channel::duplex(); + let forwarded = self.forwarded.clone(); + let future = Box::pin(async move { + let Channel { + rx: mut incoming, + tx: _, + } = agent; + while let Some(message) = incoming.next().await { + if forwarded.send(message?).is_err() { + break; + } + } + Ok(()) + }); + + (transport, future) + } + } + + #[tokio::test] + async fn malformed_ws_frame_returns_parse_error_response_and_continues() { + let (forwarded_tx, mut forwarded_rx) = mpsc::unbounded_channel(); + let registry = Arc::new(ConnectionRegistry::new(Arc::new(CapturingAgentFactory { + forwarded: forwarded_tx, + }))); + let app = Router::new().route( + "/acp", + get({ + let registry = registry.clone(); + move |ws: WebSocketUpgrade| { + let registry = registry.clone(); + async move { handle_ws_upgrade(registry, ws) } + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let (mut client, _) = connect_async(format!("ws://{addr}/acp")).await.unwrap(); + + client + .send(ClientWsMessage::Text("{not json".into())) + .await + .unwrap(); + + let frame = timeout(Duration::from_secs(1), client.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + let ClientWsMessage::Text(text) = frame else { + panic!("expected text frame: {frame:?}"); + }; + let value: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(value["id"], serde_json::Value::Null); + assert_eq!(value["error"]["code"], -32700); + assert!( + value["error"]["data"] + .as_str() + .unwrap() + .contains("malformed JSON-RPC payload") + ); + + let parsed = serde_json::from_value::(value).unwrap(); + assert!(matches!( + parsed, + RawJsonRpcMessage::Response(RpcResponse::Error { + id: RequestId::Null, + .. + }) + )); + + let notification = + RawJsonRpcMessage::notification("test/method".to_string(), json!({})).unwrap(); + client + .send(ClientWsMessage::Text( + serde_json::to_string(¬ification).unwrap().into(), + )) + .await + .unwrap(); + + let forwarded = timeout(Duration::from_secs(1), forwarded_rx.recv()) + .await + .unwrap() + .unwrap(); + assert!(matches!( + forwarded, + RawJsonRpcMessage::Notification(notification) + if notification.method.as_ref() == "test/method" + )); + + server.abort(); + } +} From b36378314645684215c6e0556de372b31a877a06 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 17 Jun 2026 16:10:15 +0200 Subject: [PATCH 24/43] fix(http): Separate HTTP and WebSocket outbound streams --- .../src/connection.rs | 294 +++++++++++++++--- .../src/websocket_server.rs | 12 +- 2 files changed, 249 insertions(+), 57 deletions(-) diff --git a/src/agent-client-protocol-http/src/connection.rs b/src/agent-client-protocol-http/src/connection.rs index 9fa12c27..888731ff 100644 --- a/src/agent-client-protocol-http/src/connection.rs +++ b/src/agent-client-protocol-http/src/connection.rs @@ -16,6 +16,21 @@ pub(crate) enum ResponseRoute { Session(String), } +enum OutboundTransport { + Http(HttpOutbound), + WebSocket(WebSocketOutbound), +} + +struct HttpOutbound { + connection_stream: Arc, + session_streams: RwLock>>, + pending_routes: Mutex>, +} + +struct WebSocketOutbound { + all_outbound: Arc, +} + struct OutboundStream { state: Mutex, } @@ -75,10 +90,7 @@ pub(crate) struct Connection { agent_handle: Mutex>>, router_handle: Mutex>>, closed_tx: watch::Sender, - connection_stream: Arc, - session_streams: RwLock>>, - all_outbound: Arc, - pending_routes: Mutex>, + outbound_transport: OutboundTransport, } impl Connection { @@ -89,30 +101,32 @@ impl Connection { } pub(crate) async fn record_pending_route(&self, id: RequestId, route: ResponseRoute) { - if let Some(key) = pending_route_key(&id) { - self.pending_routes.lock().await.insert(key, route); - } + self.outbound_transport + .record_pending_route(id, route) + .await; } pub(crate) async fn ensure_session(&self, session_id: &str) { - self.session_stream(session_id).await; + self.outbound_transport.ensure_session(session_id).await; } pub(crate) async fn subscribe_connection_stream( &self, ) -> (Vec, mpsc::Receiver) { - self.connection_stream.subscribe().await + self.outbound_transport.subscribe_connection_stream().await } pub(crate) async fn subscribe_session_stream( &self, session_id: &str, ) -> (Vec, mpsc::Receiver) { - self.session_stream(session_id).await.subscribe().await + self.outbound_transport + .subscribe_session_stream(session_id) + .await } pub(crate) async fn subscribe_all_outbound(&self) -> (Vec, mpsc::Receiver) { - self.all_outbound.subscribe().await + self.outbound_transport.subscribe_all_outbound().await } pub(crate) fn subscribe_closed(&self) -> watch::Receiver { @@ -121,20 +135,9 @@ impl Connection { #[cfg(test)] pub(crate) async fn push_connection_stream_for_test(&self, msg: String) { - self.connection_stream.push(msg).await; - } - - async fn session_stream(&self, session_id: &str) -> Arc { - if let Some(stream) = self.session_streams.read().await.get(session_id) { - return stream.clone(); - } - - self.session_streams - .write() - .await - .entry(session_id.to_string()) - .or_insert_with(|| Arc::new(OutboundStream::new())) - .clone() + self.outbound_transport + .push_connection_stream_for_test(msg) + .await; } pub(crate) async fn start_router(self: &Arc) { @@ -150,6 +153,89 @@ impl Connection { })); } + async fn route_outbound(&self, msg: RawJsonRpcMessage) { + self.outbound_transport.route_outbound(msg).await; + } + + pub(crate) async fn recv_initial(&self) -> Option { + let mut guard = self.outbound_rx.lock().await; + let rx = guard.as_mut()?; + rx.recv().await + } + + pub(crate) async fn shutdown(&self) { + self.close_streams(); + if let Some(h) = self.agent_handle.lock().await.take() { + h.abort(); + } + if let Some(h) = self.router_handle.lock().await.take() { + h.abort(); + } + } + + fn close_streams(&self) { + self.closed_tx.send_replace(true); + } +} + +impl OutboundTransport { + fn http() -> Self { + Self::Http(HttpOutbound::new()) + } + + fn websocket() -> Self { + Self::WebSocket(WebSocketOutbound::new()) + } + + async fn record_pending_route(&self, id: RequestId, route: ResponseRoute) { + let Self::Http(http) = self else { + return; + }; + + http.record_pending_route(id, route).await; + } + + async fn ensure_session(&self, session_id: &str) { + let Self::Http(http) = self else { + return; + }; + + http.ensure_session(session_id).await; + } + + async fn subscribe_connection_stream(&self) -> (Vec, mpsc::Receiver) { + match self { + Self::Http(http) => http.connection_stream.subscribe().await, + Self::WebSocket(_) => empty_subscription(), + } + } + + async fn subscribe_session_stream( + &self, + session_id: &str, + ) -> (Vec, mpsc::Receiver) { + match self { + Self::Http(http) => http.session_stream(session_id).await.subscribe().await, + Self::WebSocket(_) => empty_subscription(), + } + } + + async fn subscribe_all_outbound(&self) -> (Vec, mpsc::Receiver) { + match self { + Self::Http(_) => empty_subscription(), + Self::WebSocket(websocket) => websocket.all_outbound.subscribe().await, + } + } + + #[cfg(test)] + async fn push_connection_stream_for_test(&self, msg: String) { + let Self::Http(http) = self else { + return; + }; + + http.connection_stream.push(msg).await; + } + async fn route_outbound(&self, msg: RawJsonRpcMessage) { let serialized = match serde_json::to_string(&msg) { Ok(s) => s, @@ -159,11 +245,49 @@ impl Connection { } }; - self.all_outbound.push(serialized.clone()).await; + match self { + Self::Http(http) => http.route_outbound(&msg, serialized).await, + Self::WebSocket(websocket) => websocket.all_outbound.push(serialized).await, + } + } +} + +impl HttpOutbound { + fn new() -> Self { + Self { + connection_stream: Arc::new(OutboundStream::new()), + session_streams: RwLock::new(HashMap::new()), + pending_routes: Mutex::new(HashMap::new()), + } + } + + async fn record_pending_route(&self, id: RequestId, route: ResponseRoute) { + if let Some(key) = pending_route_key(&id) { + self.pending_routes.lock().await.insert(key, route); + } + } - let route = match &msg { + async fn ensure_session(&self, session_id: &str) { + self.session_stream(session_id).await; + } + + async fn session_stream(&self, session_id: &str) -> Arc { + if let Some(stream) = self.session_streams.read().await.get(session_id) { + return stream.clone(); + } + + self.session_streams + .write() + .await + .entry(session_id.to_string()) + .or_insert_with(|| Arc::new(OutboundStream::new())) + .clone() + } + + async fn route_outbound(&self, msg: &RawJsonRpcMessage, serialized: String) { + let route = match msg { RawJsonRpcMessage::Request(_) | RawJsonRpcMessage::Notification(_) => { - session_id_from_message(&msg) + session_id_from_message(msg) .map_or(ResponseRoute::Connection, ResponseRoute::Session) } RawJsonRpcMessage::Response(_) => { @@ -186,26 +310,19 @@ impl Connection { } } } +} - pub(crate) async fn recv_initial(&self) -> Option { - let mut guard = self.outbound_rx.lock().await; - let rx = guard.as_mut()?; - rx.recv().await - } - - pub(crate) async fn shutdown(&self) { - self.close_streams(); - if let Some(h) = self.agent_handle.lock().await.take() { - h.abort(); - } - if let Some(h) = self.router_handle.lock().await.take() { - h.abort(); +impl WebSocketOutbound { + fn new() -> Self { + Self { + all_outbound: Arc::new(OutboundStream::new()), } } +} - fn close_streams(&self) { - self.closed_tx.send_replace(true); - } +fn empty_subscription() -> (Vec, mpsc::Receiver) { + let (_tx, rx) = mpsc::channel(1); + (Vec::new(), rx) } pub(crate) struct ConnectionRegistry { @@ -256,6 +373,23 @@ impl ConnectionRegistry { } pub(crate) async fn create_connection_with_id(&self, connection_id: String) -> Arc { + self.create_connection_with_transport(connection_id, OutboundTransport::http()) + .await + } + + pub(crate) async fn create_websocket_connection_with_id( + &self, + connection_id: String, + ) -> Arc { + self.create_connection_with_transport(connection_id, OutboundTransport::websocket()) + .await + } + + async fn create_connection_with_transport( + &self, + connection_id: String, + outbound_transport: OutboundTransport, + ) -> Arc { let (channel, agent_future) = self.factory.spawn_agent(); let (inbound_tx, mut inbound_rx) = mpsc::unbounded_channel::>(); @@ -301,10 +435,7 @@ impl ConnectionRegistry { agent_handle: Mutex::new(None), router_handle: Mutex::new(None), closed_tx, - connection_stream: Arc::new(OutboundStream::new()), - session_streams: RwLock::new(HashMap::new()), - all_outbound: Arc::new(OutboundStream::new()), - pending_routes: Mutex::new(HashMap::new()), + outbound_transport, }); self.connections @@ -549,4 +680,71 @@ mod tests { exit.notify_one(); connection.shutdown().await; } + + #[tokio::test] + async fn http_connection_does_not_retain_all_outbound_replay() { + let exit = Arc::new(Notify::new()); + let message = + RawJsonRpcMessage::notification("test/method".to_string(), serde_json::json!({})) + .unwrap(); + let registry = ConnectionRegistry::new(Arc::new(SendThenWaitAgentFactory { + message, + exit: exit.clone(), + })); + let (_connection_id, connection) = registry.create_connection().await; + let (_connection_replay, mut connection_rx) = + connection.subscribe_connection_stream().await; + + connection.start_router().await; + + let text = timeout(Duration::from_secs(1), connection_rx.recv()) + .await + .unwrap() + .expect("message should reach HTTP connection stream"); + assert!(serde_json::from_str::(&text).is_ok()); + + let (all_replay, mut all_rx) = connection.subscribe_all_outbound().await; + assert!(all_replay.is_empty()); + assert!(all_rx.try_recv().is_err()); + + exit.notify_one(); + connection.shutdown().await; + } + + #[tokio::test] + async fn websocket_connection_does_not_retain_http_stream_replay() { + let exit = Arc::new(Notify::new()); + let message = RawJsonRpcMessage::notification( + "test/method".to_string(), + serde_json::json!({ "sessionId": "session-1" }), + ) + .unwrap(); + let registry = ConnectionRegistry::new(Arc::new(SendThenWaitAgentFactory { + message, + exit: exit.clone(), + })); + let connection = registry + .create_websocket_connection_with_id("conn-1".to_string()) + .await; + let (_all_replay, mut all_rx) = connection.subscribe_all_outbound().await; + + connection.start_router().await; + + let text = timeout(Duration::from_secs(1), all_rx.recv()) + .await + .unwrap() + .expect("message should reach WebSocket all-outbound stream"); + assert!(serde_json::from_str::(&text).is_ok()); + + let (connection_replay, mut connection_rx) = connection.subscribe_connection_stream().await; + let (session_replay, mut session_rx) = + connection.subscribe_session_stream("session-1").await; + assert!(connection_replay.is_empty()); + assert!(session_replay.is_empty()); + assert!(connection_rx.try_recv().is_err()); + assert!(session_rx.try_recv().is_err()); + + exit.notify_one(); + connection.shutdown().await; + } } diff --git a/src/agent-client-protocol-http/src/websocket_server.rs b/src/agent-client-protocol-http/src/websocket_server.rs index fe9ce436..21ff7d5a 100644 --- a/src/agent-client-protocol-http/src/websocket_server.rs +++ b/src/agent-client-protocol-http/src/websocket_server.rs @@ -10,7 +10,7 @@ use futures::{SinkExt, StreamExt}; use tracing::{debug, error, info, trace, warn}; use crate::{ - connection::{ConnectionRegistry, ResponseRoute}, + connection::ConnectionRegistry, protocol::{HEADER_CONNECTION_ID, session_id_from_message}, }; @@ -23,7 +23,7 @@ pub(crate) fn handle_ws_upgrade( let registry_for_handler = registry.clone(); let mut response = ws.on_upgrade(move |socket| async move { let connection = registry_for_handler - .create_connection_with_id(conn_id_for_handler.clone()) + .create_websocket_connection_with_id(conn_id_for_handler.clone()) .await; connection.start_router().await; info!(connection_id = %conn_id_for_handler, "WebSocket connection created"); @@ -78,14 +78,8 @@ async fn run_ws( match serde_json::from_str::(&text_str) { Ok(parsed) => { if let Some(sid) = session_id_from_message(&parsed) { - connection.ensure_session(&sid).await; if let RawJsonRpcMessage::Request(req) = &parsed { - connection - .record_pending_route( - req.id.clone(), - ResponseRoute::Session(sid), - ) - .await; + trace!(connection_id = %connection_id, session_id = %sid, request_id = ?req.id, "Client → Agent (session)"); } } if connection.send_to_agent(parsed).is_err() { From fb04b2913f5b5c5566b67d8f8c10225c77f8e829 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 17 Jun 2026 17:29:14 +0200 Subject: [PATCH 25/43] fix(http): Abort inbound task on agent error --- .../src/connection.rs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/src/agent-client-protocol-http/src/connection.rs b/src/agent-client-protocol-http/src/connection.rs index 888731ff..2c2b91e2 100644 --- a/src/agent-client-protocol-http/src/connection.rs +++ b/src/agent-client-protocol-http/src/connection.rs @@ -410,6 +410,7 @@ impl ConnectionRegistry { }; let (inbound_abort, inbound_abort_registration) = futures::future::AbortHandle::new_pair(); let inbound = futures::future::Abortable::new(inbound, inbound_abort_registration); + let inbound_abort_for_outbound = inbound_abort.clone(); let outbound = async move { while let Some(msg) = agent_rx.next().await { match msg { @@ -420,6 +421,7 @@ impl ConnectionRegistry { } Err(e) => { error!("agent emitted error: {e}"); + inbound_abort_for_outbound.abort(); break; } } @@ -563,6 +565,34 @@ mod tests { } } + struct ErrorThenWaitAgentFactory { + emit: Arc, + } + + impl AgentFactory for ErrorThenWaitAgentFactory { + fn spawn_agent( + &self, + ) -> ( + Channel, + BoxFuture<'static, agent_client_protocol::Result<()>>, + ) { + let (agent, transport) = Channel::duplex(); + let emit = self.emit.clone(); + let future = Box::pin(async move { + emit.notified().await; + agent + .tx + .unbounded_send(Err( + agent_client_protocol::Error::parse_error().data("transport parse error") + )) + .unwrap(); + std::future::pending::>().await + }); + + (transport, future) + } + } + struct SendThenWaitAgentFactory { message: RawJsonRpcMessage, exit: Arc, @@ -612,6 +642,33 @@ mod tests { assert!(*connection.subscribe_closed().borrow()); } + #[tokio::test] + async fn agent_error_removes_connection_and_closes_streams() { + let emit = Arc::new(Notify::new()); + let registry = + ConnectionRegistry::new(Arc::new(ErrorThenWaitAgentFactory { emit: emit.clone() })); + let (connection_id, connection) = registry.create_connection().await; + let mut closed = connection.subscribe_closed(); + + assert!(registry.get(&connection_id).await.is_some()); + + connection.start_router().await; + emit.notify_one(); + + timeout(Duration::from_secs(1), async { + loop { + if *closed.borrow() { + break; + } + closed.changed().await.unwrap(); + } + }) + .await + .unwrap(); + + assert!(registry.get(&connection_id).await.is_none()); + } + #[tokio::test] async fn agent_exit_drains_buffered_outbound_messages() { let registry = ConnectionRegistry::new(Arc::new(RespondThenExitAgentFactory)); From 932ed4990ac2880b04b351a57108c4b33cf7b298 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 17 Jun 2026 17:29:36 +0200 Subject: [PATCH 26/43] chore: exclude HTTP crate from releases --- .release-plz.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.release-plz.toml b/.release-plz.toml index 5e113808..d84a6d08 100644 --- a/.release-plz.toml +++ b/.release-plz.toml @@ -16,6 +16,11 @@ name = "agent-client-protocol" git_tag_name = "v{{ version }}" version_group = "acp" +[[package]] +name = "agent-client-protocol-http" +publish = false +release = false + [[package]] name = "agent-client-protocol-derive" version_group = "acp" From aec45fef520e00472f2f7e5c34552685415c9e53 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 17 Jun 2026 17:29:58 +0200 Subject: [PATCH 27/43] Add to releases --- .release-plz.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.release-plz.toml b/.release-plz.toml index d84a6d08..6fa4b8ab 100644 --- a/.release-plz.toml +++ b/.release-plz.toml @@ -18,8 +18,6 @@ version_group = "acp" [[package]] name = "agent-client-protocol-http" -publish = false -release = false [[package]] name = "agent-client-protocol-derive" From 5d7dc5169622b43233339b7130cb3bec36cf716d Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 17 Jun 2026 18:00:58 +0200 Subject: [PATCH 28/43] fix(http): Clean up cancelled initialize requests --- .release-plz.toml | 1 + .../src/http_server.rs | 147 ++++++++++++++++-- 2 files changed, 138 insertions(+), 10 deletions(-) diff --git a/.release-plz.toml b/.release-plz.toml index 6fa4b8ab..49945006 100644 --- a/.release-plz.toml +++ b/.release-plz.toml @@ -16,6 +16,7 @@ name = "agent-client-protocol" git_tag_name = "v{{ version }}" version_group = "acp" +# Experimental HTTP/WebSocket transport; keep independent versioning for now. [[package]] name = "agent-client-protocol-http" diff --git a/src/agent-client-protocol-http/src/http_server.rs b/src/agent-client-protocol-http/src/http_server.rs index f2942d77..78c996b4 100644 --- a/src/agent-client-protocol-http/src/http_server.rs +++ b/src/agent-client-protocol-http/src/http_server.rs @@ -10,7 +10,7 @@ use axum::{ use tracing::{error, info, trace}; use crate::{ - connection::{ConnectionRegistry, ResponseRoute}, + connection::{Connection, ConnectionRegistry, ResponseRoute}, protocol::{ EVENT_STREAM_MIME_TYPE, HEADER_CONNECTION_ID, HEADER_SESSION_ID, JSON_MIME_TYPE, apply_session_header_to_message, is_initialize_request, method_for_message, @@ -67,15 +67,15 @@ pub(crate) async fn handle_post( if is_initialize_request(&message) { let (connection_id, connection) = registry.create_connection().await; + let initialize_cleanup = + InitializeCleanup::new(registry.clone(), connection_id.clone(), connection.clone()); if connection.send_to_agent(message).is_err() { - registry.remove(&connection_id).await; - connection.shutdown().await; + initialize_cleanup.cleanup().await; return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } let Some(init_response) = connection.recv_initial().await else { - registry.remove(&connection_id).await; - connection.shutdown().await; + initialize_cleanup.cleanup().await; return ( StatusCode::INTERNAL_SERVER_ERROR, "agent closed before initialize response", @@ -89,20 +89,19 @@ pub(crate) async fn handle_post( let init_response = match serde_json::to_string(&init_response) { Ok(response) => response, Err(e) => { - registry.remove(&connection_id).await; - connection.shutdown().await; + initialize_cleanup.cleanup().await; error!("failed to serialize initialize response: {e}"); return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } }; if initialize_failed { - registry.remove(&connection_id).await; - connection.shutdown().await; + initialize_cleanup.cleanup().await; info!(connection_id = %connection_id, "Initialize rejected"); return json_response(init_response); } connection.start_router().await; + initialize_cleanup.disarm(); info!(connection_id = %connection_id, "Initialize complete"); return with_connection_header(json_response(init_response), &connection_id); } @@ -148,6 +147,56 @@ pub(crate) async fn handle_post( StatusCode::ACCEPTED.into_response() } +struct InitializeCleanup { + registry: Option>, + connection_id: String, + connection: Arc, +} + +impl InitializeCleanup { + fn new( + registry: Arc, + connection_id: String, + connection: Arc, + ) -> Self { + Self { + registry: Some(registry), + connection_id, + connection, + } + } + + async fn cleanup(mut self) { + self.cleanup_inner().await; + } + + fn disarm(mut self) { + self.registry.take(); + } + + async fn cleanup_inner(&mut self) { + let Some(registry) = self.registry.take() else { + return; + }; + registry.remove(&self.connection_id).await; + self.connection.shutdown().await; + } +} + +impl Drop for InitializeCleanup { + fn drop(&mut self) { + let Some(registry) = self.registry.take() else { + return; + }; + let connection_id = self.connection_id.clone(); + let connection = self.connection.clone(); + tokio::spawn(async move { + registry.remove(&connection_id).await; + connection.shutdown().await; + }); + } +} + pub(crate) async fn handle_get( registry: Arc, request: Request, @@ -292,7 +341,7 @@ mod tests { use serde_json::json; use tokio::{ sync::mpsc, - time::{Duration, timeout}, + time::{Duration, sleep, timeout}, }; use super::*; @@ -356,6 +405,29 @@ mod tests { } } + struct PendingInitializeAgentFactory; + + impl AgentFactory for PendingInitializeAgentFactory { + fn spawn_agent( + &self, + ) -> ( + Channel, + BoxFuture<'static, agent_client_protocol::Result<()>>, + ) { + let (agent, transport) = Channel::duplex(); + let future = Box::pin(async move { + let Channel { + rx: mut incoming, + tx: _outgoing, + } = agent; + drop(incoming.next().await); + std::future::pending::>().await + }); + + (transport, future) + } + } + #[tokio::test] async fn post_rejects_declared_body_larger_than_limit() { let (forwarded_tx, _forwarded_rx) = mpsc::unbounded_channel(); @@ -415,6 +487,61 @@ mod tests { )); } + #[tokio::test] + async fn cancelled_initialize_cleans_up_connection() { + let registry = Arc::new(ConnectionRegistry::new(Arc::new( + PendingInitializeAgentFactory, + ))); + let body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {} + }) + .to_string(); + let request = Request::builder() + .method("POST") + .uri("/acp") + .header(header::CONTENT_TYPE, JSON_MIME_TYPE) + .body(Body::from(body)) + .unwrap(); + + { + let initialize = handle_post(State(registry.clone()), request); + tokio::pin!(initialize); + timeout(Duration::from_secs(1), async { + loop { + tokio::select! { + response = &mut initialize => { + panic!( + "initialize completed unexpectedly with {}", + response.status() + ); + } + _ = sleep(Duration::from_millis(10)) => { + if registry.len().await == 1 { + break; + } + } + } + } + }) + .await + .unwrap(); + } + + timeout(Duration::from_secs(1), async { + loop { + if registry.len().await == 0 { + break; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + } + #[tokio::test] async fn sse_closes_slow_subscriber_before_skipping_messages() { let (forwarded_tx, _forwarded_rx) = mpsc::unbounded_channel(); From 95f910eb1f03b41cef257798ee114cdfc8df255f Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 18 Jun 2026 11:02:52 +0200 Subject: [PATCH 29/43] fix(http): Only open SSE streams for new sessions --- src/agent-client-protocol-http/src/client.rs | 289 ++++++++++++++++--- 1 file changed, 253 insertions(+), 36 deletions(-) diff --git a/src/agent-client-protocol-http/src/client.rs b/src/agent-client-protocol-http/src/client.rs index e8c7fc07..9b1e77bb 100644 --- a/src/agent-client-protocol-http/src/client.rs +++ b/src/agent-client-protocol-http/src/client.rs @@ -1,11 +1,11 @@ use std::{ - collections::HashSet, + collections::{HashMap, HashSet}, sync::{Arc, Mutex as StdMutex}, }; use agent_client_protocol::{ Agent, Channel, Client, ConnectTo, Error as AcpError, RawJsonRpcMessage, - schema::Response as RpcResponse, + schema::{RequestId, Response as RpcResponse}, }; use futures::{ SinkExt, StreamExt, @@ -127,14 +127,14 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { rx: mut outgoing, tx: incoming, } = channel; - let (open_session_tx, mut open_session_rx) = mpsc::unbounded(); let (sse_failure_tx, mut sse_failure_rx) = mpsc::unbounded(); + let (sse_event_tx, mut sse_event_rx) = mpsc::unbounded::(); let connection = HttpConnection::new(endpoint, http); let state = Arc::new(ClientState { connection: connection.clone(), open_session_streams: Mutex::new(HashSet::new()), + pending_requests: Mutex::new(HashMap::new()), incoming, - open_session_tx, }); let mut lifecycle = HttpTransportLifecycle::new(connection); @@ -148,12 +148,16 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { } None => break Ok(()), }, - Some(session_id) = open_session_rx.next() => { - lifecycle.spawn_sse( - state.clone(), - Some(session_id), - sse_failure_tx.clone(), - ); + Some(event) = sse_event_rx.next() => { + state.deliver(event.message); + if let Some(session_id) = event.open_session_id { + lifecycle.spawn_sse( + state.clone(), + Some(session_id), + sse_event_tx.clone(), + sse_failure_tx.clone(), + ); + } continue; } Some(failure) = sse_failure_rx.next() => { @@ -171,7 +175,12 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { } match state.initialize(msg).await { Ok(InitializeOutcome::Connected) => { - lifecycle.spawn_sse(state.clone(), None, sse_failure_tx.clone()); + lifecycle.spawn_sse( + state.clone(), + None, + sse_event_tx.clone(), + sse_failure_tx.clone(), + ); } Ok(InitializeOutcome::Rejected) => {} Err(e) => { @@ -189,7 +198,12 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { .await .insert(session_id.clone()) { - lifecycle.spawn_sse(state.clone(), Some(session_id), sse_failure_tx.clone()); + lifecycle.spawn_sse( + state.clone(), + Some(session_id), + sse_event_tx.clone(), + sse_failure_tx.clone(), + ); } if let Err(e) = state.post(msg).await { @@ -208,6 +222,12 @@ struct SseFailure { error: String, } +#[derive(Debug)] +struct SseMessage { + message: RawJsonRpcMessage, + open_session_id: Option, +} + #[derive(Clone, Debug)] struct HttpConnection { endpoint: url::Url, @@ -300,10 +320,11 @@ impl HttpTransportLifecycle { &mut self, state: Arc, session_id: Option, + event_tx: UnboundedSender, failure_tx: UnboundedSender, ) { self.sse_tasks - .push(spawn_sse(state, session_id, failure_tx)); + .push(spawn_sse(state, session_id, event_tx, failure_tx)); } async fn close(&mut self) { @@ -322,11 +343,12 @@ impl Drop for HttpTransportLifecycle { fn spawn_sse( state: Arc, session_id: Option, + event_tx: UnboundedSender, failure_tx: UnboundedSender, ) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { let label = session_id.clone(); - let error = match state.sse(session_id).await { + let error = match state.sse(session_id, event_tx).await { Ok(()) => "SSE stream closed".to_string(), Err(e) => e, }; @@ -358,8 +380,8 @@ impl SseTasks { struct ClientState { connection: HttpConnection, open_session_streams: Mutex>, + pending_requests: Mutex>, incoming: futures::channel::mpsc::UnboundedSender>, - open_session_tx: mpsc::UnboundedSender, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -437,8 +459,23 @@ impl ClientState { request = request.header(HEADER_SESSION_ID, session_id); } - let response = request.send().await.map_err(|e| e.to_string())?; + let pending_request = pending_request_for_message(&msg); + if let Some((id, method)) = &pending_request { + self.pending_requests + .lock() + .await + .insert(id.clone(), method.clone()); + } + + let response = match request.send().await { + Ok(response) => response, + Err(e) => { + self.remove_pending_request(pending_request.as_ref()).await; + return Err(e.to_string()); + } + }; if response.status().as_u16() != 202 && !response.status().is_success() { + self.remove_pending_request(pending_request.as_ref()).await; let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(format!("HTTP {status}: {body}")); @@ -446,7 +483,11 @@ impl ClientState { Ok(()) } - async fn sse(&self, session_id: Option) -> Result<(), String> { + async fn sse( + &self, + session_id: Option, + event_tx: UnboundedSender, + ) -> Result<(), String> { let connection_id = self .connection .connection_id() @@ -468,33 +509,69 @@ impl ClientState { let mut events = eventsource_stream::EventStream::new(response.bytes_stream()); while let Some(event) = events.next().await { - let payload = event.map_err(|e| e.to_string())?.data; + let event = event.map_err(|e| e.to_string())?; + let payload = event.data; if payload.is_empty() { continue; } - match serde_json::from_str::(&payload) { - Ok(msg) => { - if let RawJsonRpcMessage::Response(RpcResponse::Result { result, .. }) = &msg - && let Some(session_id) = result - .get("sessionId") - .and_then(|v| v.as_str()) - .map(String::from) - && self - .open_session_streams - .lock() - .await - .insert(session_id.clone()) - { - drop(self.open_session_tx.unbounded_send(session_id)); - } - self.deliver(msg); - } - Err(e) => return Err(format!("malformed JSON-RPC payload: {e}")), + let msg = serde_json::from_str::(&payload) + .map_err(|e| format!("malformed JSON-RPC payload: {e}"))?; + let open_session_id = self.session_to_open_for_response(&msg).await; + + if event_tx + .unbounded_send(SseMessage { + message: msg, + open_session_id, + }) + .is_err() + { + return Err("upstream channel closed".to_string()); } } Ok(()) } + async fn remove_pending_request(&self, pending_request: Option<&(RequestId, String)>) { + if let Some((id, _)) = pending_request { + self.pending_requests.lock().await.remove(id); + } + } + + async fn session_to_open_for_response(&self, msg: &RawJsonRpcMessage) -> Option { + let RawJsonRpcMessage::Response(response) = msg else { + return None; + }; + let Some(id) = msg.response_id().and_then(pending_request_key) else { + return None; + }; + let method = self.pending_requests.lock().await.remove(&id); + + let Some("session/new") = method.as_deref() else { + return None; + }; + let RpcResponse::Result { result, .. } = response else { + return None; + }; + let Some(session_id) = result + .get("sessionId") + .and_then(|v| v.as_str()) + .map(String::from) + else { + return None; + }; + + if self + .open_session_streams + .lock() + .await + .insert(session_id.clone()) + { + Some(session_id) + } else { + None + } + } + fn deliver(&self, msg: RawJsonRpcMessage) { if self.incoming.unbounded_send(Ok(msg)).is_err() { debug!("upstream channel closed; dropping inbound message"); @@ -502,6 +579,20 @@ impl ClientState { } } +fn pending_request_for_message(msg: &RawJsonRpcMessage) -> Option<(RequestId, String)> { + let RawJsonRpcMessage::Request(request) = msg else { + return None; + }; + pending_request_key(&request.id).map(|id| (id, request.method.to_string())) +} + +fn pending_request_key(id: &RequestId) -> Option { + match id { + RequestId::Null => None, + RequestId::Number(_) | RequestId::Str(_) => Some(id.clone()), + } +} + async fn run_ws(client: HttpClient, channel: Channel) -> Result<(), AcpError> { let HttpClient { endpoint, .. } = client; let Channel { @@ -747,6 +838,128 @@ mod tests { server.abort(); } + #[tokio::test] + async fn custom_response_with_session_id_does_not_open_session_sse() { + let (get_tx, mut get_rx) = tokio::sync::mpsc::unbounded_channel(); + let response_ready = Arc::new(tokio::sync::Notify::new()); + let post_count = Arc::new(AtomicUsize::new(0)); + let app = Router::new().route( + "/acp", + post({ + let post_count = post_count.clone(); + let response_ready = response_ready.clone(); + move |Json(_message): Json| { + let post_count = post_count.clone(); + let response_ready = response_ready.clone(); + async move { + if post_count.fetch_add(1, Ordering::SeqCst) == 0 { + return initialize_response().await.into_response(); + } + + response_ready.notify_waiters(); + StatusCode::ACCEPTED.into_response() + } + } + }) + .get({ + let get_tx = get_tx.clone(); + let response_ready = response_ready.clone(); + move |headers: HeaderMap| { + let get_tx = get_tx.clone(); + let response_ready = response_ready.clone(); + async move { + let session_header = headers + .get(HEADER_SESSION_ID) + .and_then(|value| value.to_str().ok()) + .map(String::from); + get_tx.send(session_header).unwrap(); + + let stream = async_stream::stream! { + response_ready.notified().await; + yield Ok::<_, Infallible>(sse_event( + RawJsonRpcMessage::response( + RequestId::Number(2), + Ok(json!({ "sessionId": "session-1" })), + ), + )); + futures::future::pending::<()>().await; + }; + Sse::new(stream) + } + } + }) + .delete(|| async { StatusCode::ACCEPTED }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = HttpClient::new(format!("http://{addr}")).unwrap(); + let (mut caller, transport) = Channel::duplex(); + let transport = tokio::spawn(run(client, transport)); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "initialize".to_string(), + json!({}), + RequestId::Number(1), + ) + .unwrap())) + .unwrap(); + let init_response = timeout(Duration::from_secs(1), caller.rx.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(matches!(init_response, RawJsonRpcMessage::Response(_))); + + let connection_sse_header = timeout(Duration::from_secs(1), get_rx.recv()) + .await + .unwrap() + .unwrap(); + assert!(connection_sse_header.is_none()); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "custom/sessionish".to_string(), + json!({}), + RequestId::Number(2), + ) + .unwrap())) + .unwrap(); + let response = timeout(Duration::from_secs(1), caller.rx.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(matches!( + response, + RawJsonRpcMessage::Response(RpcResponse::Result { + id: RequestId::Number(2), + .. + }) + )); + + assert!( + timeout(Duration::from_millis(100), get_rx.recv()) + .await + .is_err(), + "custom response must not open a session SSE stream" + ); + + drop(caller); + timeout(Duration::from_secs(1), transport) + .await + .unwrap() + .unwrap() + .unwrap(); + + server.abort(); + } + #[tokio::test] async fn post_error_deletes_initialized_connection() { let delete_count = Arc::new(AtomicUsize::new(0)); @@ -1094,6 +1307,10 @@ mod tests { Sse::new(futures::stream::pending()) } + fn sse_event(message: RawJsonRpcMessage) -> Event { + Event::default().data(serde_json::to_string(&message).unwrap()) + } + async fn malformed_sse() -> Sse>> { let invalid = futures::stream::once(async { Ok::<_, Infallible>(Event::default().data("{not json")) From 5651d7e2149406605f15a77260a622c8e788749b Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 18 Jun 2026 11:19:04 +0200 Subject: [PATCH 30/43] Clippy --- src/agent-client-protocol-http/src/client.rs | 12 +++--------- src/agent-client-protocol-http/src/http_server.rs | 2 +- .../src/websocket_server.rs | 6 ++---- 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/agent-client-protocol-http/src/client.rs b/src/agent-client-protocol-http/src/client.rs index 9b1e77bb..07106f5c 100644 --- a/src/agent-client-protocol-http/src/client.rs +++ b/src/agent-client-protocol-http/src/client.rs @@ -541,9 +541,7 @@ impl ClientState { let RawJsonRpcMessage::Response(response) = msg else { return None; }; - let Some(id) = msg.response_id().and_then(pending_request_key) else { - return None; - }; + let id = msg.response_id().and_then(pending_request_key)?; let method = self.pending_requests.lock().await.remove(&id); let Some("session/new") = method.as_deref() else { @@ -552,13 +550,10 @@ impl ClientState { let RpcResponse::Result { result, .. } = response else { return None; }; - let Some(session_id) = result + let session_id = result .get("sessionId") .and_then(|v| v.as_str()) - .map(String::from) - else { - return None; - }; + .map(String::from)?; if self .open_session_streams @@ -652,7 +647,6 @@ async fn run_ws(client: HttpClient, channel: Channel) -> Result<(), AcpError> { debug!("upstream channel closed; stopping WS reader"); break; } - continue; } } } diff --git a/src/agent-client-protocol-http/src/http_server.rs b/src/agent-client-protocol-http/src/http_server.rs index 78c996b4..b5f35625 100644 --- a/src/agent-client-protocol-http/src/http_server.rs +++ b/src/agent-client-protocol-http/src/http_server.rs @@ -518,7 +518,7 @@ mod tests { response.status() ); } - _ = sleep(Duration::from_millis(10)) => { + () = sleep(Duration::from_millis(10)) => { if registry.len().await == 1 { break; } diff --git a/src/agent-client-protocol-http/src/websocket_server.rs b/src/agent-client-protocol-http/src/websocket_server.rs index 21ff7d5a..72e5237b 100644 --- a/src/agent-client-protocol-http/src/websocket_server.rs +++ b/src/agent-client-protocol-http/src/websocket_server.rs @@ -77,11 +77,10 @@ async fn run_ws( trace!(connection_id = %connection_id, payload = %text_str, "Client → Agent: {} bytes", text_str.len()); match serde_json::from_str::(&text_str) { Ok(parsed) => { - if let Some(sid) = session_id_from_message(&parsed) { - if let RawJsonRpcMessage::Request(req) = &parsed { + if let Some(sid) = session_id_from_message(&parsed) + && let RawJsonRpcMessage::Request(req) = &parsed { trace!(connection_id = %connection_id, session_id = %sid, request_id = ?req.id, "Client → Agent (session)"); } - } if connection.send_to_agent(parsed).is_err() { error!(connection_id = %connection_id, "Agent channel closed"); break; @@ -105,7 +104,6 @@ async fn run_ws( error!(connection_id = %connection_id, "WebSocket send failed"); break; } - continue; } } } From 92ad80c64465d93dc3053fa682a358b76b61730a Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 18 Jun 2026 11:34:21 +0200 Subject: [PATCH 31/43] feat(http): add client and server features --- md/http-transport.md | 15 ++++- src/agent-client-protocol-http/Cargo.toml | 60 ++++++++++++++----- src/agent-client-protocol-http/README.md | 8 +++ src/agent-client-protocol-http/src/lib.rs | 10 ++++ .../src/protocol.rs | 11 +++- 5 files changed, 87 insertions(+), 17 deletions(-) diff --git a/md/http-transport.md b/md/http-transport.md index 4edc58b8..f1c2565e 100644 --- a/md/http-transport.md +++ b/md/http-transport.md @@ -10,13 +10,26 @@ `POST /acp` request bodies are limited to 16 MiB. +## Features + +The crate does not enable either transport side by default. Opt into only the side(s) you need. + +```toml +agent-client-protocol-http = { version = "...", features = ["client"] } +agent-client-protocol-http = { version = "...", features = ["server"] } +agent-client-protocol-http = { version = "...", features = ["client", "server"] } +``` + +The `client` feature exposes `HttpClient`. The `server` feature exposes +`AcpHttpServer`, `ServerOptions`, and `CorsOptions`. + ## Request Cancellation Request cancellation is available when the transport crate forwards the core SDK feature: ```toml -agent-client-protocol-http = { version = "...", features = ["unstable_cancel_request"] } +agent-client-protocol-http = { version = "...", features = ["client", "server", "unstable_cancel_request"] } ``` `$/cancel_request` is connection-scoped. The HTTP transport does not apply diff --git a/src/agent-client-protocol-http/Cargo.toml b/src/agent-client-protocol-http/Cargo.toml index cadafe83..33962e10 100644 --- a/src/agent-client-protocol-http/Cargo.toml +++ b/src/agent-client-protocol-http/Cargo.toml @@ -10,33 +10,63 @@ description = "HTTP and WebSocket transport for the Agent Client Protocol (ACP)" keywords = ["acp", "agent", "protocol", "http", "websocket"] categories = ["development-tools", "web-programming::http-server"] +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + [features] default = [] -unstable_cancel_request = ["agent-client-protocol/unstable_cancel_request"] +client = [ + "dep:agent-client-protocol", + "dep:eventsource-stream", + "dep:futures", + "dep:reqwest", + "dep:serde_json", + "dep:thiserror", + "dep:tokio", + "dep:tokio-tungstenite", + "dep:tracing", + "dep:url", +] +server = [ + "dep:agent-client-protocol", + "dep:async-stream", + "dep:axum", + "dep:futures", + "dep:serde_json", + "dep:tokio", + "dep:tower-http", + "dep:tracing", + "dep:uuid", +] +unstable_cancel_request = ["agent-client-protocol?/unstable_cancel_request"] [dependencies] -agent-client-protocol.workspace = true +agent-client-protocol = { workspace = true, optional = true } -futures.workspace = true -serde_json.workspace = true -tokio = { workspace = true, features = ["sync", "rt", "macros", "time"] } -tracing.workspace = true -thiserror.workspace = true +futures = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } +thiserror = { workspace = true, optional = true } +tokio = { workspace = true, features = ["sync", "rt", "macros", "time"], optional = true } +tracing = { workspace = true, optional = true } # Server -axum = { workspace = true, features = ["ws", "macros"] } -tower-http = { workspace = true, features = ["cors"] } -async-stream = { workspace = true } -uuid = { workspace = true } +async-stream = { workspace = true, optional = true } +axum = { workspace = true, features = ["ws", "macros"], optional = true } +tower-http = { workspace = true, features = ["cors"], optional = true } +uuid = { workspace = true, optional = true } # Client -reqwest = { workspace = true, features = ["stream"] } -eventsource-stream = { workspace = true } -url = { workspace = true } -tokio-tungstenite = { workspace = true } +eventsource-stream = { workspace = true, optional = true } +reqwest = { workspace = true, features = ["stream"], optional = true } +tokio-tungstenite = { workspace = true, optional = true } +url = { workspace = true, optional = true } [dev-dependencies] agent-client-protocol-test.workspace = true +async-stream.workspace = true +axum = { workspace = true, features = ["ws", "macros"] } +tokio-tungstenite.workspace = true tracing-subscriber.workspace = true [lints] diff --git a/src/agent-client-protocol-http/README.md b/src/agent-client-protocol-http/README.md index 29219d8d..30f910ed 100644 --- a/src/agent-client-protocol-http/README.md +++ b/src/agent-client-protocol-http/README.md @@ -5,6 +5,14 @@ HTTP/WebSocket transport for ACP agents. - **Server**: `AcpHttpServer` exposes agents over HTTP + SSE with optional WebSocket upgrade - **Client**: `HttpClient` connects to remote agents over HTTP + SSE +The crate does not enable either transport side by default. Opt into the +surface you need: + +```toml +agent-client-protocol-http = { version = "...", features = ["client"] } +agent-client-protocol-http = { version = "...", features = ["server"] } +``` + Cross-origin browser access is disabled by default. Configure `ServerOptions` with `CorsOptions::allow_origins(...)` to allow specific browser origins. diff --git a/src/agent-client-protocol-http/src/lib.rs b/src/agent-client-protocol-http/src/lib.rs index 42373f7f..854c12a5 100644 --- a/src/agent-client-protocol-http/src/lib.rs +++ b/src/agent-client-protocol-http/src/lib.rs @@ -1,9 +1,19 @@ +#![cfg_attr(docsrs, feature(doc_cfg))] + +#[cfg(feature = "client")] mod client; +#[cfg(feature = "server")] mod connection; +#[cfg(feature = "server")] mod http_server; +#[cfg(any(feature = "client", feature = "server"))] mod protocol; +#[cfg(feature = "server")] mod server; +#[cfg(feature = "server")] mod websocket_server; +#[cfg(feature = "client")] pub use client::{HttpClient, HttpClientError}; +#[cfg(feature = "server")] pub use server::{AcpHttpServer, CorsOptions, ServerOptions}; diff --git a/src/agent-client-protocol-http/src/protocol.rs b/src/agent-client-protocol-http/src/protocol.rs index 8825fad0..f6d9e375 100644 --- a/src/agent-client-protocol-http/src/protocol.rs +++ b/src/agent-client-protocol-http/src/protocol.rs @@ -2,7 +2,9 @@ use agent_client_protocol::{RawJsonRpcMessage, RawJsonRpcParams}; pub(crate) const HEADER_CONNECTION_ID: &str = "acp-connection-id"; pub(crate) const HEADER_SESSION_ID: &str = "acp-session-id"; +#[cfg(feature = "server")] pub(crate) const EVENT_STREAM_MIME_TYPE: &str = "text/event-stream"; +#[cfg(feature = "server")] pub(crate) const JSON_MIME_TYPE: &str = "application/json"; pub(crate) fn method_requires_session_header(method: &str) -> bool { @@ -84,6 +86,7 @@ pub(crate) fn session_id_from_message(msg: &RawJsonRpcMessage) -> Option } } +#[cfg(feature = "server")] pub(crate) fn apply_session_header_to_message( msg: &mut RawJsonRpcMessage, session_id: &str, @@ -103,6 +106,7 @@ pub(crate) fn apply_session_header_to_message( } } +#[cfg(feature = "server")] fn apply_session_header_to_params( params: &mut Option, session_id: &str, @@ -139,8 +143,10 @@ fn apply_session_header_to_params( #[cfg(test)] mod tests { + #[cfg(feature = "server")] use agent_client_protocol::schema::RequestId; use axum::http::{HeaderMap, HeaderValue}; + #[cfg(feature = "server")] use serde_json::json; use super::*; @@ -156,6 +162,7 @@ mod tests { assert_eq!(headers[HEADER_SESSION_ID], "session-1"); } + #[cfg(feature = "server")] #[test] fn session_header_is_inserted_into_object_params() { let mut message = RawJsonRpcMessage::request( @@ -173,6 +180,7 @@ mod tests { ); } + #[cfg(feature = "server")] #[test] fn session_header_conflict_is_rejected() { let mut message = RawJsonRpcMessage::request( @@ -190,6 +198,7 @@ mod tests { ); } + #[cfg(feature = "server")] #[test] fn protocol_level_message_ignores_session_header() { let mut message = RawJsonRpcMessage::notification( @@ -205,7 +214,7 @@ mod tests { assert!(value["params"].get("sessionId").is_none()); } - #[cfg(feature = "unstable_cancel_request")] + #[cfg(all(feature = "server", feature = "unstable_cancel_request"))] #[test] fn successor_wrapped_cancel_request_ignores_session_header() { let mut message = RawJsonRpcMessage::notification( From 2c65d5ab2f2d4e384721ba7dc296130ad1549a84 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 18 Jun 2026 11:52:53 +0200 Subject: [PATCH 32/43] chore(deps): narrow tokio feature usage --- Cargo.lock | 134 +++++++----- Cargo.toml | 4 +- .../Cargo.toml | 2 +- src/agent-client-protocol-http/Cargo.toml | 7 +- src/agent-client-protocol-http/src/client.rs | 207 +++++++++--------- src/agent-client-protocol-polyfill/Cargo.toml | 2 +- src/agent-client-protocol-rmcp/Cargo.toml | 2 +- src/agent-client-protocol-test/Cargo.toml | 2 +- .../Cargo.toml | 2 +- src/agent-client-protocol/Cargo.toml | 2 +- src/yopo/Cargo.toml | 2 +- 11 files changed, 206 insertions(+), 160 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 03c0eb8e..245e152e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -657,6 +657,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -809,6 +819,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -885,6 +904,12 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -1070,6 +1095,25 @@ dependencies = [ "wasip3", ] +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1164,6 +1208,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1208,9 +1253,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1513,15 +1560,6 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - [[package]] name = "log" version = "0.4.32" @@ -1659,29 +1697,6 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - [[package]] name = "pastey" version = "0.2.3" @@ -1914,15 +1929,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - [[package]] name = "ref-cast" version = "1.0.25" @@ -1980,8 +1986,10 @@ checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", + "encoding_rs", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", @@ -1990,6 +1998,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime", "percent-encoding", "pin-project-lite", "quinn", @@ -2136,7 +2145,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "jni", "log", @@ -2237,12 +2246,6 @@ dependencies = [ "syn", ] -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - [[package]] name = "security-framework" version = "3.7.0" @@ -2250,7 +2253,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -2554,6 +2557,27 @@ dependencies = [ "syn", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -2647,7 +2671,6 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -3232,6 +3255,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index 211e9723..6b53e383 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,7 @@ yopo = { package = "agent-client-protocol-yopo", path = "src/yopo" } agent-client-protocol-schema = { version = "=0.13.6", features = ["tracing"] } # Core async runtime -tokio = { version = "1.52", features = ["full"] } +tokio = { version = "1.52", default-features = false } tokio-util = { version = "0.7", features = ["compat"] } tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] } @@ -66,7 +66,7 @@ clap = { version = "4.5", features = ["derive"] } # HTTP axum = "0.8" -reqwest = { version = "0.13", default-features = false, features = ["rustls", "json"] } +reqwest = { version = "0.13", features = ["json"] } eventsource-stream = "0.2" url = "2.5" async-process = "2" diff --git a/src/agent-client-protocol-conductor/Cargo.toml b/src/agent-client-protocol-conductor/Cargo.toml index f31ec43c..67f4e157 100644 --- a/src/agent-client-protocol-conductor/Cargo.toml +++ b/src/agent-client-protocol-conductor/Cargo.toml @@ -33,7 +33,7 @@ rustc-hash.workspace = true serde.workspace = true serde_json.workspace = true strip-ansi-escapes.workspace = true -tokio.workspace = true +tokio = { workspace = true, features = ["fs", "io-util", "macros", "rt", "rt-multi-thread", "sync", "time"] } tokio-util.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/src/agent-client-protocol-http/Cargo.toml b/src/agent-client-protocol-http/Cargo.toml index 33962e10..9c7e717b 100644 --- a/src/agent-client-protocol-http/Cargo.toml +++ b/src/agent-client-protocol-http/Cargo.toml @@ -24,6 +24,7 @@ client = [ "dep:serde_json", "dep:thiserror", "dep:tokio", + "tokio/rt", "dep:tokio-tungstenite", "dep:tracing", "dep:url", @@ -35,6 +36,9 @@ server = [ "dep:futures", "dep:serde_json", "dep:tokio", + "tokio/macros", + "tokio/rt", + "tokio/sync", "dep:tower-http", "dep:tracing", "dep:uuid", @@ -47,7 +51,7 @@ agent-client-protocol = { workspace = true, optional = true } futures = { workspace = true, optional = true } serde_json = { workspace = true, optional = true } thiserror = { workspace = true, optional = true } -tokio = { workspace = true, features = ["sync", "rt", "macros", "time"], optional = true } +tokio = { workspace = true, optional = true } tracing = { workspace = true, optional = true } # Server @@ -66,6 +70,7 @@ url = { workspace = true, optional = true } agent-client-protocol-test.workspace = true async-stream.workspace = true axum = { workspace = true, features = ["ws", "macros"] } +tokio = { workspace = true, features = ["macros", "net", "rt", "sync", "time"] } tokio-tungstenite.workspace = true tracing-subscriber.workspace = true diff --git a/src/agent-client-protocol-http/src/client.rs b/src/agent-client-protocol-http/src/client.rs index 07106f5c..55815dcb 100644 --- a/src/agent-client-protocol-http/src/client.rs +++ b/src/agent-client-protocol-http/src/client.rs @@ -10,10 +10,10 @@ use agent_client_protocol::{ use futures::{ SinkExt, StreamExt, channel::mpsc::{self, UnboundedSender}, - future::BoxFuture, + future::{BoxFuture, FutureExt}, + pin_mut, }; use thiserror::Error; -use tokio::sync::Mutex; use tokio_tungstenite::tungstenite::Message as WsMessage; use tracing::{debug, error, trace, warn}; @@ -130,17 +130,30 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { let (sse_failure_tx, mut sse_failure_rx) = mpsc::unbounded(); let (sse_event_tx, mut sse_event_rx) = mpsc::unbounded::(); let connection = HttpConnection::new(endpoint, http); - let state = Arc::new(ClientState { + let mut state = ClientState { connection: connection.clone(), - open_session_streams: Mutex::new(HashSet::new()), - pending_requests: Mutex::new(HashMap::new()), + open_session_streams: HashSet::new(), + pending_requests: HashMap::new(), incoming, - }); + }; let mut lifecycle = HttpTransportLifecycle::new(connection); let result = loop { - let msg = tokio::select! { - msg = outgoing.next() => match msg { + let event = { + let outgoing_next = outgoing.next().fuse(); + let sse_event_next = sse_event_rx.next().fuse(); + let sse_failure_next = sse_failure_rx.next().fuse(); + pin_mut!(outgoing_next, sse_event_next, sse_failure_next); + + futures::select! { + msg = outgoing_next => HttpLoopEvent::Outgoing(msg), + event = sse_event_next => HttpLoopEvent::SseEvent(event), + failure = sse_failure_next => HttpLoopEvent::SseFailure(failure), + } + }; + + let msg = match event { + HttpLoopEvent::Outgoing(msg) => match msg { Some(Ok(msg)) => msg, Some(Err(e)) => { error!("upstream channel produced error: {e}"); @@ -148,11 +161,14 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { } None => break Ok(()), }, - Some(event) = sse_event_rx.next() => { + HttpLoopEvent::SseEvent(event) => { + let Some(event) = event else { + continue; + }; + let open_session_id = state.session_to_open_for_response(&event.message); state.deliver(event.message); - if let Some(session_id) = event.open_session_id { + if let Some(session_id) = open_session_id { lifecycle.spawn_sse( - state.clone(), Some(session_id), sse_event_tx.clone(), sse_failure_tx.clone(), @@ -160,7 +176,10 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { } continue; } - Some(failure) = sse_failure_rx.next() => { + HttpLoopEvent::SseFailure(failure) => { + let Some(failure) = failure else { + continue; + }; let scope = failure.session_id.as_deref().unwrap_or("connection"); error!(session_id = ?failure.session_id, error = %failure.error, "SSE stream ended"); break Err(AcpError::internal_error() @@ -175,12 +194,7 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { } match state.initialize(msg).await { Ok(InitializeOutcome::Connected) => { - lifecycle.spawn_sse( - state.clone(), - None, - sse_event_tx.clone(), - sse_failure_tx.clone(), - ); + lifecycle.spawn_sse(None, sse_event_tx.clone(), sse_failure_tx.clone()); } Ok(InitializeOutcome::Rejected) => {} Err(e) => { @@ -192,14 +206,9 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { } if let Some(session_id) = session_id_from_message(&msg) - && state - .open_session_streams - .lock() - .await - .insert(session_id.clone()) + && state.open_session_streams.insert(session_id.clone()) { lifecycle.spawn_sse( - state.clone(), Some(session_id), sse_event_tx.clone(), sse_failure_tx.clone(), @@ -216,6 +225,12 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { result } +enum HttpLoopEvent { + Outgoing(Option>), + SseEvent(Option), + SseFailure(Option), +} + #[derive(Debug)] struct SseFailure { session_id: Option, @@ -225,7 +240,6 @@ struct SseFailure { #[derive(Debug)] struct SseMessage { message: RawJsonRpcMessage, - open_session_id: Option, } #[derive(Clone, Debug)] @@ -318,13 +332,16 @@ impl HttpTransportLifecycle { fn spawn_sse( &mut self, - state: Arc, session_id: Option, event_tx: UnboundedSender, failure_tx: UnboundedSender, ) { - self.sse_tasks - .push(spawn_sse(state, session_id, event_tx, failure_tx)); + self.sse_tasks.push(spawn_sse( + self.connection.clone(), + session_id, + event_tx, + failure_tx, + )); } async fn close(&mut self) { @@ -341,14 +358,14 @@ impl Drop for HttpTransportLifecycle { } fn spawn_sse( - state: Arc, + connection: HttpConnection, session_id: Option, event_tx: UnboundedSender, failure_tx: UnboundedSender, ) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { let label = session_id.clone(); - let error = match state.sse(session_id, event_tx).await { + let error = match read_sse(connection, session_id, event_tx).await { Ok(()) => "SSE stream closed".to_string(), Err(e) => e, }; @@ -379,8 +396,8 @@ impl SseTasks { struct ClientState { connection: HttpConnection, - open_session_streams: Mutex>, - pending_requests: Mutex>, + open_session_streams: HashSet, + pending_requests: HashMap, incoming: futures::channel::mpsc::UnboundedSender>, } @@ -434,7 +451,7 @@ impl ClientState { Ok(InitializeOutcome::Connected) } - async fn post(&self, msg: RawJsonRpcMessage) -> Result<(), String> { + async fn post(&mut self, msg: RawJsonRpcMessage) -> Result<(), String> { let session_id = match method_for_message(&msg) { Some(method) => { let session_id = session_id_from_message(&msg); @@ -461,21 +478,18 @@ impl ClientState { let pending_request = pending_request_for_message(&msg); if let Some((id, method)) = &pending_request { - self.pending_requests - .lock() - .await - .insert(id.clone(), method.clone()); + self.pending_requests.insert(id.clone(), method.clone()); } let response = match request.send().await { Ok(response) => response, Err(e) => { - self.remove_pending_request(pending_request.as_ref()).await; + self.remove_pending_request(pending_request.as_ref()); return Err(e.to_string()); } }; if response.status().as_u16() != 202 && !response.status().is_success() { - self.remove_pending_request(pending_request.as_ref()).await; + self.remove_pending_request(pending_request.as_ref()); let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(format!("HTTP {status}: {body}")); @@ -483,66 +497,18 @@ impl ClientState { Ok(()) } - async fn sse( - &self, - session_id: Option, - event_tx: UnboundedSender, - ) -> Result<(), String> { - let connection_id = self - .connection - .connection_id() - .ok_or_else(|| "SSE attempted before initialize".to_string())?; - let mut request = self - .connection - .get() - .header("Accept", "text/event-stream") - .header(HEADER_CONNECTION_ID, connection_id); - if let Some(session_id) = &session_id { - request = request.header(HEADER_SESSION_ID, session_id); - } - - let response = request.send().await.map_err(|e| e.to_string())?; - if !response.status().is_success() { - return Err(format!("HTTP {}", response.status())); - } - trace!(session_id = ?session_id, "SSE stream open"); - - let mut events = eventsource_stream::EventStream::new(response.bytes_stream()); - while let Some(event) = events.next().await { - let event = event.map_err(|e| e.to_string())?; - let payload = event.data; - if payload.is_empty() { - continue; - } - let msg = serde_json::from_str::(&payload) - .map_err(|e| format!("malformed JSON-RPC payload: {e}"))?; - let open_session_id = self.session_to_open_for_response(&msg).await; - - if event_tx - .unbounded_send(SseMessage { - message: msg, - open_session_id, - }) - .is_err() - { - return Err("upstream channel closed".to_string()); - } - } - Ok(()) - } - - async fn remove_pending_request(&self, pending_request: Option<&(RequestId, String)>) { + fn remove_pending_request(&mut self, pending_request: Option<&(RequestId, String)>) { if let Some((id, _)) = pending_request { - self.pending_requests.lock().await.remove(id); + self.pending_requests.remove(id); } } - async fn session_to_open_for_response(&self, msg: &RawJsonRpcMessage) -> Option { + fn session_to_open_for_response(&mut self, msg: &RawJsonRpcMessage) -> Option { let RawJsonRpcMessage::Response(response) = msg else { return None; }; let id = msg.response_id().and_then(pending_request_key)?; - let method = self.pending_requests.lock().await.remove(&id); + let method = self.pending_requests.remove(&id); let Some("session/new") = method.as_deref() else { return None; @@ -555,12 +521,7 @@ impl ClientState { .and_then(|v| v.as_str()) .map(String::from)?; - if self - .open_session_streams - .lock() - .await - .insert(session_id.clone()) - { + if self.open_session_streams.insert(session_id.clone()) { Some(session_id) } else { None @@ -574,6 +535,48 @@ impl ClientState { } } +async fn read_sse( + connection: HttpConnection, + session_id: Option, + event_tx: UnboundedSender, +) -> Result<(), String> { + let connection_id = connection + .connection_id() + .ok_or_else(|| "SSE attempted before initialize".to_string())?; + let mut request = connection + .get() + .header("Accept", "text/event-stream") + .header(HEADER_CONNECTION_ID, connection_id); + if let Some(session_id) = &session_id { + request = request.header(HEADER_SESSION_ID, session_id); + } + + let response = request.send().await.map_err(|e| e.to_string())?; + if !response.status().is_success() { + return Err(format!("HTTP {}", response.status())); + } + trace!(session_id = ?session_id, "SSE stream open"); + + let mut events = eventsource_stream::EventStream::new(response.bytes_stream()); + while let Some(event) = events.next().await { + let event = event.map_err(|e| e.to_string())?; + let payload = event.data; + if payload.is_empty() { + continue; + } + let msg = serde_json::from_str::(&payload) + .map_err(|e| format!("malformed JSON-RPC payload: {e}"))?; + + if event_tx + .unbounded_send(SseMessage { message: msg }) + .is_err() + { + return Err("upstream channel closed".to_string()); + } + } + Ok(()) +} + fn pending_request_for_message(msg: &RawJsonRpcMessage) -> Option<(RequestId, String)> { let RawJsonRpcMessage::Request(request) = msg else { return None; @@ -605,8 +608,12 @@ async fn run_ws(client: HttpClient, channel: Channel) -> Result<(), AcpError> { let (mut ws_tx, mut ws_rx) = ws_stream.split(); loop { - tokio::select! { - msg = outgoing.next() => match msg { + let outgoing_next = outgoing.next().fuse(); + let frame_next = ws_rx.next().fuse(); + pin_mut!(outgoing_next, frame_next); + + futures::select! { + msg = outgoing_next => match msg { Some(Ok(msg)) => { let text = match serde_json::to_string(&msg) { Ok(t) => t, @@ -628,7 +635,7 @@ async fn run_ws(client: HttpClient, channel: Channel) -> Result<(), AcpError> { } None => break, }, - frame = ws_rx.next() => match frame { + frame = frame_next => match frame { Some(Ok(WsMessage::Text(text))) => { match serde_json::from_str::(text.as_str()) { Ok(parsed) => { diff --git a/src/agent-client-protocol-polyfill/Cargo.toml b/src/agent-client-protocol-polyfill/Cargo.toml index e9f824d7..a5dc9305 100644 --- a/src/agent-client-protocol-polyfill/Cargo.toml +++ b/src/agent-client-protocol-polyfill/Cargo.toml @@ -20,7 +20,7 @@ futures-concurrency.workspace = true rustc-hash.workspace = true serde_json.workspace = true thiserror = "2.0" -tokio.workspace = true +tokio = { workspace = true, features = ["net"] } tokio-util.workspace = true tracing.workspace = true uuid.workspace = true diff --git a/src/agent-client-protocol-rmcp/Cargo.toml b/src/agent-client-protocol-rmcp/Cargo.toml index 1856d6c2..be528781 100644 --- a/src/agent-client-protocol-rmcp/Cargo.toml +++ b/src/agent-client-protocol-rmcp/Cargo.toml @@ -18,7 +18,7 @@ rmcp.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true -tokio.workspace = true +tokio = { workspace = true, features = ["io-std", "io-util", "macros", "rt-multi-thread"] } tokio-util.workspace = true [dev-dependencies] diff --git a/src/agent-client-protocol-test/Cargo.toml b/src/agent-client-protocol-test/Cargo.toml index 58f39438..cc65e3a2 100644 --- a/src/agent-client-protocol-test/Cargo.toml +++ b/src/agent-client-protocol-test/Cargo.toml @@ -24,7 +24,7 @@ rmcp = { workspace = true, features = ["server", "client", "transport-child-proc schemars.workspace = true serde.workspace = true serde_json.workspace = true -tokio.workspace = true +tokio = { workspace = true, features = ["io-std", "macros", "process", "rt-multi-thread"] } tokio-util.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/src/agent-client-protocol-trace-viewer/Cargo.toml b/src/agent-client-protocol-trace-viewer/Cargo.toml index e89e3e08..b5fafb9c 100644 --- a/src/agent-client-protocol-trace-viewer/Cargo.toml +++ b/src/agent-client-protocol-trace-viewer/Cargo.toml @@ -24,7 +24,7 @@ axum.workspace = true clap.workspace = true open.workspace = true serde_json.workspace = true -tokio.workspace = true +tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread"] } [lints] workspace = true diff --git a/src/agent-client-protocol/Cargo.toml b/src/agent-client-protocol/Cargo.toml index 9a1ecc00..c6836ea0 100644 --- a/src/agent-client-protocol/Cargo.toml +++ b/src/agent-client-protocol/Cargo.toml @@ -56,7 +56,7 @@ shell-words.workspace = true agent-client-protocol-test.workspace = true clap.workspace = true expect-test.workspace = true -tokio.workspace = true +tokio = { workspace = true, features = ["io-std", "io-util", "macros", "rt", "rt-multi-thread", "sync", "time"] } tokio-util.workspace = true [lints] diff --git a/src/yopo/Cargo.toml b/src/yopo/Cargo.toml index 9ed3fc2b..40326763 100644 --- a/src/yopo/Cargo.toml +++ b/src/yopo/Cargo.toml @@ -20,7 +20,7 @@ path = "src/main.rs" [dependencies] agent-client-protocol.workspace = true clap.workspace = true -tokio.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } tracing.workspace = true tracing-subscriber.workspace = true From 1934b35804981ca9af69f7a4309953e88a6f7a9a Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 18 Jun 2026 12:23:36 +0200 Subject: [PATCH 33/43] fix(http): disable reqwest default TLS features --- Cargo.lock | 86 +----------------- Cargo.toml | 4 +- src/agent-client-protocol-http/Cargo.toml | 2 +- src/agent-client-protocol-http/src/client.rs | 91 +++++++++----------- 4 files changed, 44 insertions(+), 139 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 245e152e..6132bff1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -657,16 +657,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation" version = "0.10.1" @@ -819,15 +809,6 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -904,12 +885,6 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" @@ -1095,25 +1070,6 @@ dependencies = [ "wasip3", ] -[[package]] -name = "h2" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap 2.14.0", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.12.3" @@ -1208,7 +1164,6 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", "http", "http-body", "httparse", @@ -1253,11 +1208,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", - "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -1986,10 +1939,8 @@ checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", - "encoding_rs", "futures-core", "futures-util", - "h2", "http", "http-body", "http-body-util", @@ -1998,7 +1949,6 @@ dependencies = [ "hyper-util", "js-sys", "log", - "mime", "percent-encoding", "pin-project-lite", "quinn", @@ -2145,7 +2095,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ - "core-foundation 0.10.1", + "core-foundation", "core-foundation-sys", "jni", "log", @@ -2253,7 +2203,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags", - "core-foundation 0.10.1", + "core-foundation", "core-foundation-sys", "libc", "security-framework-sys", @@ -2557,27 +2507,6 @@ dependencies = [ "syn", ] -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "thiserror" version = "2.0.18" @@ -3255,17 +3184,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - [[package]] name = "windows-result" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index 6b53e383..3217a8a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,7 +42,7 @@ agent-client-protocol-schema = { version = "=0.13.6", features = ["tracing"] } # Core async runtime tokio = { version = "1.52", default-features = false } tokio-util = { version = "0.7", features = ["compat"] } -tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] } +tokio-tungstenite = { version = "0.29", default-features = false, features = ["rustls-tls-webpki-roots"] } # Serialization serde = { version = "1.0", features = ["derive", "rc"] } @@ -66,7 +66,7 @@ clap = { version = "4.5", features = ["derive"] } # HTTP axum = "0.8" -reqwest = { version = "0.13", features = ["json"] } +reqwest = { version = "0.13", default-features = false, features = ["rustls", "json"] } eventsource-stream = "0.2" url = "2.5" async-process = "2" diff --git a/src/agent-client-protocol-http/Cargo.toml b/src/agent-client-protocol-http/Cargo.toml index 9c7e717b..98875277 100644 --- a/src/agent-client-protocol-http/Cargo.toml +++ b/src/agent-client-protocol-http/Cargo.toml @@ -63,7 +63,7 @@ uuid = { workspace = true, optional = true } # Client eventsource-stream = { workspace = true, optional = true } reqwest = { workspace = true, features = ["stream"], optional = true } -tokio-tungstenite = { workspace = true, optional = true } +tokio-tungstenite = { workspace = true, features = ["connect"], optional = true } url = { workspace = true, optional = true } [dev-dependencies] diff --git a/src/agent-client-protocol-http/src/client.rs b/src/agent-client-protocol-http/src/client.rs index 55815dcb..fa0f555c 100644 --- a/src/agent-client-protocol-http/src/client.rs +++ b/src/agent-client-protocol-http/src/client.rs @@ -12,6 +12,7 @@ use futures::{ channel::mpsc::{self, UnboundedSender}, future::{BoxFuture, FutureExt}, pin_mut, + stream::FuturesUnordered, }; use thiserror::Error; use tokio_tungstenite::tungstenite::Message as WsMessage; @@ -127,7 +128,6 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { rx: mut outgoing, tx: incoming, } = channel; - let (sse_failure_tx, mut sse_failure_rx) = mpsc::unbounded(); let (sse_event_tx, mut sse_event_rx) = mpsc::unbounded::(); let connection = HttpConnection::new(endpoint, http); let mut state = ClientState { @@ -142,7 +142,7 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { let event = { let outgoing_next = outgoing.next().fuse(); let sse_event_next = sse_event_rx.next().fuse(); - let sse_failure_next = sse_failure_rx.next().fuse(); + let sse_failure_next = lifecycle.next_sse_failure().fuse(); pin_mut!(outgoing_next, sse_event_next, sse_failure_next); futures::select! { @@ -168,18 +168,11 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { let open_session_id = state.session_to_open_for_response(&event.message); state.deliver(event.message); if let Some(session_id) = open_session_id { - lifecycle.spawn_sse( - Some(session_id), - sse_event_tx.clone(), - sse_failure_tx.clone(), - ); + lifecycle.start_sse(Some(session_id), sse_event_tx.clone()); } continue; } HttpLoopEvent::SseFailure(failure) => { - let Some(failure) = failure else { - continue; - }; let scope = failure.session_id.as_deref().unwrap_or("connection"); error!(session_id = ?failure.session_id, error = %failure.error, "SSE stream ended"); break Err(AcpError::internal_error() @@ -194,7 +187,7 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { } match state.initialize(msg).await { Ok(InitializeOutcome::Connected) => { - lifecycle.spawn_sse(None, sse_event_tx.clone(), sse_failure_tx.clone()); + lifecycle.start_sse(None, sse_event_tx.clone()); } Ok(InitializeOutcome::Rejected) => {} Err(e) => { @@ -208,11 +201,7 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { if let Some(session_id) = session_id_from_message(&msg) && state.open_session_streams.insert(session_id.clone()) { - lifecycle.spawn_sse( - Some(session_id), - sse_event_tx.clone(), - sse_failure_tx.clone(), - ); + lifecycle.start_sse(Some(session_id), sse_event_tx.clone()); } if let Err(e) = state.post(msg).await { @@ -228,7 +217,7 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { enum HttpLoopEvent { Outgoing(Option>), SseEvent(Option), - SseFailure(Option), + SseFailure(SseFailure), } #[derive(Debug)] @@ -279,27 +268,24 @@ impl HttpConnection { } async fn close(&self) { - let Some(task) = self.spawn_close_task() else { + let Some(connection_id) = self.take_connection_id() else { return; }; - if let Err(e) = task.await { - debug!("DELETE task failed (ignored): {e}"); - } + Self::send_close(self.http.clone(), self.endpoint.clone(), connection_id).await; } fn spawn_close(&self) { - drop(self.spawn_close_task()); - } - - fn spawn_close_task(&self) -> Option> { - let connection_id = self.take_connection_id()?; + let Some(connection_id) = self.take_connection_id() else { + return; + }; let http = self.http.clone(); let endpoint = self.endpoint.clone(); match tokio::runtime::Handle::try_current() { - Ok(handle) => Some(handle.spawn(Self::send_close(http, endpoint, connection_id))), + Ok(handle) => { + drop(handle.spawn(Self::send_close(http, endpoint, connection_id))); + } Err(e) => { debug!("failed to spawn HTTP DELETE: {e}"); - None } } } @@ -330,18 +316,13 @@ impl HttpTransportLifecycle { } } - fn spawn_sse( - &mut self, - session_id: Option, - event_tx: UnboundedSender, - failure_tx: UnboundedSender, - ) { - self.sse_tasks.push(spawn_sse( - self.connection.clone(), - session_id, - event_tx, - failure_tx, - )); + fn start_sse(&mut self, session_id: Option, event_tx: UnboundedSender) { + self.sse_tasks + .push(run_sse(self.connection.clone(), session_id, event_tx)); + } + + async fn next_sse_failure(&mut self) -> SseFailure { + self.sse_tasks.next_failure().await } async fn close(&mut self) { @@ -357,41 +338,47 @@ impl Drop for HttpTransportLifecycle { } } -fn spawn_sse( +fn run_sse( connection: HttpConnection, session_id: Option, event_tx: UnboundedSender, - failure_tx: UnboundedSender, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { +) -> BoxFuture<'static, SseFailure> { + Box::pin(async move { let label = session_id.clone(); let error = match read_sse(connection, session_id, event_tx).await { Ok(()) => "SSE stream closed".to_string(), Err(e) => e, }; warn!(session_id = ?label, "SSE stream ended: {error}"); - drop(failure_tx.unbounded_send(SseFailure { + SseFailure { session_id: label, error, - })); + } }) } #[derive(Debug, Default)] struct SseTasks { - handles: Vec>, + handles: FuturesUnordered>, } impl SseTasks { - fn push(&mut self, handle: tokio::task::JoinHandle<()>) { - self.handles.push(handle); + fn push(&mut self, task: BoxFuture<'static, SseFailure>) { + self.handles.push(task); } - fn abort_all(&mut self) { - for handle in self.handles.drain(..) { - handle.abort(); + async fn next_failure(&mut self) -> SseFailure { + loop { + if let Some(failure) = self.handles.next().await { + return failure; + } + futures::future::pending::<()>().await; } } + + fn abort_all(&mut self) { + self.handles = FuturesUnordered::new(); + } } struct ClientState { From ee0d73cc9ce59dfed170c8f626734eb22ecdefb5 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 18 Jun 2026 12:52:46 +0200 Subject: [PATCH 34/43] Clean up dependency graph --- Cargo.lock | 34 +++++++++++-------- Cargo.toml | 2 +- deny.toml | 3 -- src/agent-client-protocol-http/Cargo.toml | 6 ++-- src/agent-client-protocol-http/src/client.rs | 6 ++-- .../src/websocket_server.rs | 4 +-- 6 files changed, 29 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6132bff1..98a3c392 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -83,6 +83,7 @@ dependencies = [ "agent-client-protocol", "agent-client-protocol-test", "async-stream", + "async-tungstenite", "axum", "eventsource-stream", "futures", @@ -90,7 +91,6 @@ dependencies = [ "serde_json", "thiserror", "tokio", - "tokio-tungstenite", "tower-http", "tracing", "tracing-subscriber", @@ -380,6 +380,25 @@ dependencies = [ "syn", ] +[[package]] +name = "async-tungstenite" +version = "0.34.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8447f02eaa65412035e2d3eeaa3fc82bbb8d7137c84c5976b4af685136012ee9" +dependencies = [ + "atomic-waker", + "futures-core", + "futures-io", + "futures-task", + "log", + "pin-project-lite", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -2647,12 +2666,8 @@ checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", - "rustls", - "rustls-pki-types", "tokio", - "tokio-rustls", "tungstenite", - "webpki-roots 0.26.11", ] [[package]] @@ -3074,15 +3089,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.7", -] - [[package]] name = "webpki-roots" version = "1.0.7" diff --git a/Cargo.toml b/Cargo.toml index 3217a8a7..130ccd14 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,7 +42,7 @@ agent-client-protocol-schema = { version = "=0.13.6", features = ["tracing"] } # Core async runtime tokio = { version = "1.52", default-features = false } tokio-util = { version = "0.7", features = ["compat"] } -tokio-tungstenite = { version = "0.29", default-features = false, features = ["rustls-tls-webpki-roots"] } +async-tungstenite = { version = "0.34.1", default-features = false, features = ["tokio-rustls-webpki-roots"] } # Serialization serde = { version = "1.0", features = ["derive", "rc"] } diff --git a/deny.toml b/deny.toml index 4b85d722..4bf484fd 100644 --- a/deny.toml +++ b/deny.toml @@ -90,7 +90,6 @@ ignore = [ # [possible values: any SPDX 3.11 short identifier (+ optional exception)]. allow = [ "MIT", - "MIT-0", "ISC", "Apache-2.0", "Apache-2.0 WITH LLVM-exception", @@ -108,8 +107,6 @@ exceptions = [ # Static Mozilla/WebPKI root certificate data bundles. CDLA-Permissive-2.0 # is a permissive data license, so keep it scoped to certificate roots # instead of allowing it for arbitrary code crates. - { allow = ["CDLA-Permissive-2.0"], crate = "webpki-root-certs" }, - { allow = ["CDLA-Permissive-2.0"], crate = "webpki-roots" }, ] # Some crates don't have (easily) machine readable licensing information, diff --git a/src/agent-client-protocol-http/Cargo.toml b/src/agent-client-protocol-http/Cargo.toml index 98875277..7b2506d0 100644 --- a/src/agent-client-protocol-http/Cargo.toml +++ b/src/agent-client-protocol-http/Cargo.toml @@ -23,9 +23,9 @@ client = [ "dep:reqwest", "dep:serde_json", "dep:thiserror", + "dep:async-tungstenite", "dep:tokio", "tokio/rt", - "dep:tokio-tungstenite", "dep:tracing", "dep:url", ] @@ -61,9 +61,9 @@ tower-http = { workspace = true, features = ["cors"], optional = true } uuid = { workspace = true, optional = true } # Client +async-tungstenite = { workspace = true, optional = true } eventsource-stream = { workspace = true, optional = true } reqwest = { workspace = true, features = ["stream"], optional = true } -tokio-tungstenite = { workspace = true, features = ["connect"], optional = true } url = { workspace = true, optional = true } [dev-dependencies] @@ -71,7 +71,7 @@ agent-client-protocol-test.workspace = true async-stream.workspace = true axum = { workspace = true, features = ["ws", "macros"] } tokio = { workspace = true, features = ["macros", "net", "rt", "sync", "time"] } -tokio-tungstenite.workspace = true +async-tungstenite.workspace = true tracing-subscriber.workspace = true [lints] diff --git a/src/agent-client-protocol-http/src/client.rs b/src/agent-client-protocol-http/src/client.rs index fa0f555c..5b13a5d1 100644 --- a/src/agent-client-protocol-http/src/client.rs +++ b/src/agent-client-protocol-http/src/client.rs @@ -7,15 +7,15 @@ use agent_client_protocol::{ Agent, Channel, Client, ConnectTo, Error as AcpError, RawJsonRpcMessage, schema::{RequestId, Response as RpcResponse}, }; +use async_tungstenite::tungstenite::Message as WsMessage; use futures::{ - SinkExt, StreamExt, + StreamExt, channel::mpsc::{self, UnboundedSender}, future::{BoxFuture, FutureExt}, pin_mut, stream::FuturesUnordered, }; use thiserror::Error; -use tokio_tungstenite::tungstenite::Message as WsMessage; use tracing::{debug, error, trace, warn}; use crate::protocol::{ @@ -585,7 +585,7 @@ async fn run_ws(client: HttpClient, channel: Channel) -> Result<(), AcpError> { tx: incoming, } = channel; - let (ws_stream, response) = tokio_tungstenite::connect_async(endpoint.as_str()) + let (ws_stream, response) = async_tungstenite::tokio::connect_async(endpoint.as_str()) .await .map_err(|e| AcpError::internal_error().data(format!("WebSocket connect failed: {e}")))?; trace!( diff --git a/src/agent-client-protocol-http/src/websocket_server.rs b/src/agent-client-protocol-http/src/websocket_server.rs index 72e5237b..279d6ee3 100644 --- a/src/agent-client-protocol-http/src/websocket_server.rs +++ b/src/agent-client-protocol-http/src/websocket_server.rs @@ -156,15 +156,15 @@ mod tests { Channel, schema::{RequestId, Response as RpcResponse}, }; + use async_tungstenite::{tokio::connect_async, tungstenite::Message as ClientWsMessage}; use axum::{Router, extract::WebSocketUpgrade, routing::get}; - use futures::{SinkExt as _, StreamExt as _, future::BoxFuture}; + use futures::{StreamExt as _, future::BoxFuture}; use serde_json::json; use tokio::{ net::TcpListener, sync::mpsc, time::{Duration, timeout}, }; - use tokio_tungstenite::{connect_async, tungstenite::Message as ClientWsMessage}; use crate::connection::{AgentFactory, ConnectionRegistry}; From fac64f75925c51b3a54e66ff396ca9eab4413f09 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 18 Jun 2026 13:06:31 +0200 Subject: [PATCH 35/43] Cleanup deny toml --- deny.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/deny.toml b/deny.toml index 4bf484fd..acde6c96 100644 --- a/deny.toml +++ b/deny.toml @@ -104,9 +104,9 @@ confidence-threshold = 0.8 # Allow 1 or more licenses on a per-crate basis, so that particular licenses # aren't accepted for every possible crate as with the normal allow list exceptions = [ - # Static Mozilla/WebPKI root certificate data bundles. CDLA-Permissive-2.0 - # is a permissive data license, so keep it scoped to certificate roots - # instead of allowing it for arbitrary code crates. + # Each entry is the crate and version constraint, and its specific allow + # list + #{ allow = ["Zlib"], crate = "adler32" }, ] # Some crates don't have (easily) machine readable licensing information, From 702b9754130bb90f9705d5c1d042edb13e218891 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 18 Jun 2026 13:07:15 +0200 Subject: [PATCH 36/43] cleanup changlog --- src/agent-client-protocol-http/CHANGELOG.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/agent-client-protocol-http/CHANGELOG.md b/src/agent-client-protocol-http/CHANGELOG.md index c0329dcf..1a588142 100644 --- a/src/agent-client-protocol-http/CHANGELOG.md +++ b/src/agent-client-protocol-http/CHANGELOG.md @@ -1,7 +1,3 @@ # Changelog ## [Unreleased] - -### Added -- HTTP/SSE/WebSocket transport for ACP agents -- `unstable_cancel_request` feature forwarding for core SDK request cancellation From 64475c24ea7c45228c0fea7bb29f82627cb5c3ce Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 18 Jun 2026 13:16:00 +0200 Subject: [PATCH 37/43] fix(http): Keep SSE active during POST requests --- src/agent-client-protocol-http/src/client.rs | 322 +++++++++++++++++-- 1 file changed, 301 insertions(+), 21 deletions(-) diff --git a/src/agent-client-protocol-http/src/client.rs b/src/agent-client-protocol-http/src/client.rs index 5b13a5d1..32522393 100644 --- a/src/agent-client-protocol-http/src/client.rs +++ b/src/agent-client-protocol-http/src/client.rs @@ -137,18 +137,21 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { incoming, }; let mut lifecycle = HttpTransportLifecycle::new(connection); + let mut pending_posts = FuturesUnordered::new(); let result = loop { let event = { let outgoing_next = outgoing.next().fuse(); let sse_event_next = sse_event_rx.next().fuse(); let sse_failure_next = lifecycle.next_sse_failure().fuse(); - pin_mut!(outgoing_next, sse_event_next, sse_failure_next); + let post_next = next_post_completion(&mut pending_posts).fuse(); + pin_mut!(outgoing_next, sse_event_next, sse_failure_next, post_next); futures::select! { msg = outgoing_next => HttpLoopEvent::Outgoing(msg), event = sse_event_next => HttpLoopEvent::SseEvent(event), failure = sse_failure_next => HttpLoopEvent::SseFailure(failure), + post = post_next => HttpLoopEvent::Post(post), } }; @@ -178,6 +181,18 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { break Err(AcpError::internal_error() .data(format!("{scope} SSE stream ended: {}", failure.error))); } + HttpLoopEvent::Post(completed) => { + let CompletedPost { + pending_request, + result, + } = completed; + if let Err(e) = result { + state.remove_pending_request(pending_request.as_ref()); + error!("POST failed: {e}"); + break Err(AcpError::internal_error().data(format!("POST: {e}"))); + } + continue; + } }; if state.connection.connection_id().is_none() { @@ -204,9 +219,12 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { lifecycle.start_sse(Some(session_id), sse_event_tx.clone()); } - if let Err(e) = state.post(msg).await { - error!("POST failed: {e}"); - break Err(AcpError::internal_error().data(format!("POST: {e}"))); + match state.prepare_post(msg) { + Ok(post) => pending_posts.push(post.into_completion()), + Err(e) => { + error!("POST failed: {e}"); + break Err(AcpError::internal_error().data(format!("POST: {e}"))); + } } }; @@ -218,6 +236,7 @@ enum HttpLoopEvent { Outgoing(Option>), SseEvent(Option), SseFailure(SseFailure), + Post(CompletedPost), } #[derive(Debug)] @@ -267,11 +286,24 @@ impl HttpConnection { self.connection_id.lock().expect("mutex poisoned").take() } + fn clear_connection_id(&self, expected: &str) { + let mut connection_id = self.connection_id.lock().expect("mutex poisoned"); + if connection_id.as_deref() == Some(expected) { + *connection_id = None; + } + } + async fn close(&self) { - let Some(connection_id) = self.take_connection_id() else { + let Some(connection_id) = self.connection_id() else { return; }; - Self::send_close(self.http.clone(), self.endpoint.clone(), connection_id).await; + Self::send_close( + self.http.clone(), + self.endpoint.clone(), + connection_id.clone(), + ) + .await; + self.clear_connection_id(&connection_id); } fn spawn_close(&self) { @@ -388,6 +420,44 @@ struct ClientState { incoming: futures::channel::mpsc::UnboundedSender>, } +struct PendingPost { + pending_request: Option<(RequestId, String)>, + response: BoxFuture<'static, Result<(), String>>, +} + +impl PendingPost { + fn into_completion(self) -> BoxFuture<'static, CompletedPost> { + let Self { + pending_request, + response, + } = self; + async move { + CompletedPost { + pending_request, + result: response.await, + } + } + .boxed() + } +} + +#[derive(Debug)] +struct CompletedPost { + pending_request: Option<(RequestId, String)>, + result: Result<(), String>, +} + +async fn next_post_completion( + pending_posts: &mut FuturesUnordered>, +) -> CompletedPost { + loop { + if let Some(completed) = pending_posts.next().await { + return completed; + } + futures::future::pending::<()>().await; + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum InitializeOutcome { Connected, @@ -438,7 +508,7 @@ impl ClientState { Ok(InitializeOutcome::Connected) } - async fn post(&mut self, msg: RawJsonRpcMessage) -> Result<(), String> { + fn prepare_post(&mut self, msg: RawJsonRpcMessage) -> Result { let session_id = match method_for_message(&msg) { Some(method) => { let session_id = session_id_from_message(&msg); @@ -468,20 +538,19 @@ impl ClientState { self.pending_requests.insert(id.clone(), method.clone()); } - let response = match request.send().await { - Ok(response) => response, - Err(e) => { - self.remove_pending_request(pending_request.as_ref()); - return Err(e.to_string()); + let response = async move { + let response = request.send().await.map_err(|e| e.to_string())?; + if response.status().as_u16() != 202 && !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("HTTP {status}: {body}")); } + Ok(()) }; - if response.status().as_u16() != 202 && !response.status().is_success() { - self.remove_pending_request(pending_request.as_ref()); - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(format!("HTTP {status}: {body}")); - } - Ok(()) + Ok(PendingPost { + pending_request, + response: response.boxed(), + }) } fn remove_pending_request(&mut self, pending_request: Option<&(RequestId, String)>) { @@ -690,6 +759,7 @@ mod tests { use serde_json::json; use tokio::{ net::TcpListener, + sync::Notify, time::{sleep, timeout}, }; @@ -948,6 +1018,159 @@ mod tests { server.abort(); } + #[tokio::test] + async fn sse_continues_while_post_is_pending() { + let post_started = Arc::new(Notify::new()); + let callback_response_seen = Arc::new(Notify::new()); + let sse_started = Arc::new(Notify::new()); + let (callback_tx, mut callback_rx) = tokio::sync::mpsc::unbounded_channel(); + let app = Router::new().route( + "/acp", + post({ + let post_started = post_started.clone(); + let callback_response_seen = callback_response_seen.clone(); + let callback_tx = callback_tx.clone(); + move |Json(message): Json| { + let post_started = post_started.clone(); + let callback_response_seen = callback_response_seen.clone(); + let callback_tx = callback_tx.clone(); + async move { + if is_initialize_request(&message) { + return initialize_response().await.into_response(); + } + + match &message { + RawJsonRpcMessage::Request(request) + if request.method.as_ref() == "custom/slow" => + { + post_started.notify_waiters(); + callback_response_seen.notified().await; + StatusCode::ACCEPTED.into_response() + } + RawJsonRpcMessage::Response( + RpcResponse::Result { + id: RequestId::Number(99), + .. + } + | RpcResponse::Error { + id: RequestId::Number(99), + .. + }, + ) => { + callback_tx.send(message).unwrap(); + callback_response_seen.notify_waiters(); + StatusCode::ACCEPTED.into_response() + } + _ => StatusCode::ACCEPTED.into_response(), + } + } + } + }) + .get({ + let post_started = post_started.clone(); + let sse_started = sse_started.clone(); + move || { + let post_started = post_started.clone(); + let sse_started = sse_started.clone(); + async move { + let stream = async_stream::stream! { + sse_started.notify_waiters(); + post_started.notified().await; + yield Ok::<_, Infallible>(sse_event( + RawJsonRpcMessage::request( + "client/callback".to_string(), + json!({}), + RequestId::Number(99), + ) + .unwrap(), + )); + futures::future::pending::<()>().await; + }; + Sse::new(stream) + } + } + }) + .delete(|| async { StatusCode::ACCEPTED }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = HttpClient::new(format!("http://{addr}")).unwrap(); + let (mut caller, transport) = Channel::duplex(); + let transport = tokio::spawn(run(client, transport)); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "initialize".to_string(), + json!({}), + RequestId::Number(1), + ) + .unwrap())) + .unwrap(); + let init_response = timeout(Duration::from_secs(1), caller.rx.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(matches!(init_response, RawJsonRpcMessage::Response(_))); + timeout(Duration::from_secs(1), sse_started.notified()) + .await + .unwrap(); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "custom/slow".to_string(), + json!({}), + RequestId::Number(2), + ) + .unwrap())) + .unwrap(); + + let callback = timeout(Duration::from_secs(1), caller.rx.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(matches!( + callback, + RawJsonRpcMessage::Request(request) + if request.method.as_ref() == "client/callback" + && request.id == RequestId::Number(99) + )); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::response( + RequestId::Number(99), + Ok(json!({})), + ))) + .unwrap(); + let callback_response = timeout(Duration::from_secs(1), callback_rx.recv()) + .await + .unwrap() + .unwrap(); + assert!(matches!( + callback_response, + RawJsonRpcMessage::Response(RpcResponse::Result { + id: RequestId::Number(99), + .. + }) + )); + + drop(caller); + timeout(Duration::from_secs(1), transport) + .await + .unwrap() + .unwrap() + .unwrap(); + + server.abort(); + } + #[tokio::test] async fn post_error_deletes_initialized_connection() { let delete_count = Arc::new(AtomicUsize::new(0)); @@ -1200,6 +1423,59 @@ mod tests { server.abort(); } + #[tokio::test] + async fn dropped_transport_during_close_retries_delete() { + let delete_count = Arc::new(AtomicUsize::new(0)); + let delete_count_for_handler = delete_count.clone(); + let release_delete = Arc::new(Notify::new()); + let release_delete_for_handler = release_delete.clone(); + let app = Router::new().route( + "/acp", + post(initialize_response).get(pending_sse).delete(move || { + let delete_count = delete_count_for_handler.clone(); + let release_delete = release_delete_for_handler.clone(); + async move { + delete_count.fetch_add(1, Ordering::SeqCst); + release_delete.notified().await; + StatusCode::ACCEPTED + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = HttpClient::new(format!("http://{addr}")).unwrap(); + let (mut caller, transport) = Channel::duplex(); + let transport = tokio::spawn(run(client, transport)); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "initialize".to_string(), + json!({}), + RequestId::Number(1), + ) + .unwrap())) + .unwrap(); + let init_response = timeout(Duration::from_secs(1), caller.rx.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(matches!(init_response, RawJsonRpcMessage::Response(_))); + + drop(caller); + wait_for_delete_count(&delete_count, 1).await; + transport.abort(); + wait_for_delete_count(&delete_count, 2).await; + release_delete.notify_waiters(); + drop(transport.await); + + server.abort(); + } + #[tokio::test] async fn initialize_error_without_connection_id_is_delivered_without_sse() { let get_count = Arc::new(AtomicUsize::new(0)); @@ -1258,9 +1534,14 @@ mod tests { } async fn wait_for_delete(delete_count: &AtomicUsize) { + wait_for_delete_count(delete_count, 1).await; + assert_eq!(delete_count.load(Ordering::SeqCst), 1); + } + + async fn wait_for_delete_count(delete_count: &AtomicUsize, expected: usize) { timeout(Duration::from_secs(1), async { loop { - if delete_count.load(Ordering::SeqCst) > 0 { + if delete_count.load(Ordering::SeqCst) >= expected { break; } sleep(Duration::from_millis(10)).await; @@ -1268,7 +1549,6 @@ mod tests { }) .await .unwrap(); - assert_eq!(delete_count.load(Ordering::SeqCst), 1); } async fn initialize_response() -> impl IntoResponse { From f7d66801f0c9c069fed8af1ed0ecd21ba18e10c3 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 18 Jun 2026 13:37:15 +0200 Subject: [PATCH 38/43] fix(http): Open session SSE after fork responses --- src/agent-client-protocol-http/src/client.rs | 129 ++++++++++++++++++- 1 file changed, 127 insertions(+), 2 deletions(-) diff --git a/src/agent-client-protocol-http/src/client.rs b/src/agent-client-protocol-http/src/client.rs index 32522393..bd12e954 100644 --- a/src/agent-client-protocol-http/src/client.rs +++ b/src/agent-client-protocol-http/src/client.rs @@ -566,9 +566,9 @@ impl ClientState { let id = msg.response_id().and_then(pending_request_key)?; let method = self.pending_requests.remove(&id); - let Some("session/new") = method.as_deref() else { + if !method.as_deref().is_some_and(is_session_opening_method) { return None; - }; + } let RpcResponse::Result { result, .. } = response else { return None; }; @@ -591,6 +591,10 @@ impl ClientState { } } +fn is_session_opening_method(method: &str) -> bool { + matches!(method, "session/new" | "session/fork") +} + async fn read_sse( connection: HttpConnection, session_id: Option, @@ -1018,6 +1022,127 @@ mod tests { server.abort(); } + #[tokio::test] + async fn fork_response_with_session_id_opens_session_sse() { + let (get_tx, mut get_rx) = tokio::sync::mpsc::unbounded_channel(); + let response_ready = Arc::new(tokio::sync::Notify::new()); + let post_count = Arc::new(AtomicUsize::new(0)); + let app = Router::new().route( + "/acp", + post({ + let post_count = post_count.clone(); + let response_ready = response_ready.clone(); + move |Json(_message): Json| { + let post_count = post_count.clone(); + let response_ready = response_ready.clone(); + async move { + if post_count.fetch_add(1, Ordering::SeqCst) == 0 { + return initialize_response().await.into_response(); + } + + response_ready.notify_waiters(); + StatusCode::ACCEPTED.into_response() + } + } + }) + .get({ + let get_tx = get_tx.clone(); + let response_ready = response_ready.clone(); + move |headers: HeaderMap| { + let get_tx = get_tx.clone(); + let response_ready = response_ready.clone(); + async move { + let session_header = headers + .get(HEADER_SESSION_ID) + .and_then(|value| value.to_str().ok()) + .map(String::from); + get_tx.send(session_header).unwrap(); + + let stream = async_stream::stream! { + response_ready.notified().await; + yield Ok::<_, Infallible>(sse_event( + RawJsonRpcMessage::response( + RequestId::Number(2), + Ok(json!({ "sessionId": "forked-session" })), + ), + )); + futures::future::pending::<()>().await; + }; + Sse::new(stream) + } + } + }) + .delete(|| async { StatusCode::ACCEPTED }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = HttpClient::new(format!("http://{addr}")).unwrap(); + let (mut caller, transport) = Channel::duplex(); + let transport = tokio::spawn(run(client, transport)); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "initialize".to_string(), + json!({}), + RequestId::Number(1), + ) + .unwrap())) + .unwrap(); + let init_response = timeout(Duration::from_secs(1), caller.rx.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(matches!(init_response, RawJsonRpcMessage::Response(_))); + + let connection_sse_header = timeout(Duration::from_secs(1), get_rx.recv()) + .await + .unwrap() + .unwrap(); + assert!(connection_sse_header.is_none()); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "session/fork".to_string(), + json!({}), + RequestId::Number(2), + ) + .unwrap())) + .unwrap(); + let response = timeout(Duration::from_secs(1), caller.rx.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(matches!( + response, + RawJsonRpcMessage::Response(RpcResponse::Result { + id: RequestId::Number(2), + .. + }) + )); + + let fork_sse_header = timeout(Duration::from_secs(1), get_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(fork_sse_header.as_deref(), Some("forked-session")); + + drop(caller); + timeout(Duration::from_secs(1), transport) + .await + .unwrap() + .unwrap() + .unwrap(); + + server.abort(); + } + #[tokio::test] async fn sse_continues_while_post_is_pending() { let post_started = Arc::new(Notify::new()); From 8f3cb7b74aba4864cc34e06574a64db535978a07 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 18 Jun 2026 13:43:57 +0200 Subject: [PATCH 39/43] fix(http): Preserve outbound POST order --- src/agent-client-protocol-http/src/client.rs | 165 +++++++++++++++++-- 1 file changed, 152 insertions(+), 13 deletions(-) diff --git a/src/agent-client-protocol-http/src/client.rs b/src/agent-client-protocol-http/src/client.rs index bd12e954..90f25d9d 100644 --- a/src/agent-client-protocol-http/src/client.rs +++ b/src/agent-client-protocol-http/src/client.rs @@ -1,5 +1,5 @@ use std::{ - collections::{HashMap, HashSet}, + collections::{HashMap, HashSet, VecDeque}, sync::{Arc, Mutex as StdMutex}, }; @@ -137,21 +137,30 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { incoming, }; let mut lifecycle = HttpTransportLifecycle::new(connection); - let mut pending_posts = FuturesUnordered::new(); + let mut ordered_posts = PostQueue::default(); + let mut response_posts = PostQueue::default(); let result = loop { let event = { let outgoing_next = outgoing.next().fuse(); let sse_event_next = sse_event_rx.next().fuse(); let sse_failure_next = lifecycle.next_sse_failure().fuse(); - let post_next = next_post_completion(&mut pending_posts).fuse(); - pin_mut!(outgoing_next, sse_event_next, sse_failure_next, post_next); + let ordered_post_next = ordered_posts.next_completion().fuse(); + let response_post_next = response_posts.next_completion().fuse(); + pin_mut!( + outgoing_next, + sse_event_next, + sse_failure_next, + ordered_post_next, + response_post_next + ); futures::select! { msg = outgoing_next => HttpLoopEvent::Outgoing(msg), event = sse_event_next => HttpLoopEvent::SseEvent(event), failure = sse_failure_next => HttpLoopEvent::SseFailure(failure), - post = post_next => HttpLoopEvent::Post(post), + post = ordered_post_next => HttpLoopEvent::Post(post), + post = response_post_next => HttpLoopEvent::Post(post), } }; @@ -219,8 +228,12 @@ async fn run(client: HttpClient, channel: Channel) -> Result<(), AcpError> { lifecycle.start_sse(Some(session_id), sse_event_tx.clone()); } + let is_response = matches!(msg, RawJsonRpcMessage::Response(_)); match state.prepare_post(msg) { - Ok(post) => pending_posts.push(post.into_completion()), + // Responses answer SSE-delivered callbacks and must not be blocked + // behind a POST that may be waiting for that callback response. + Ok(post) if is_response => response_posts.push(post), + Ok(post) => ordered_posts.push(post), Err(e) => { error!("POST failed: {e}"); break Err(AcpError::internal_error().data(format!("POST: {e}"))); @@ -447,14 +460,36 @@ struct CompletedPost { result: Result<(), String>, } -async fn next_post_completion( - pending_posts: &mut FuturesUnordered>, -) -> CompletedPost { - loop { - if let Some(completed) = pending_posts.next().await { - return completed; +#[derive(Default)] +struct PostQueue { + queued: VecDeque, + in_flight: Option>, +} + +impl PostQueue { + fn push(&mut self, post: PendingPost) { + self.queued.push_back(post); + self.start_next(); + } + + async fn next_completion(&mut self) -> CompletedPost { + loop { + self.start_next(); + if let Some(in_flight) = self.in_flight.as_mut() { + let completed = in_flight.await; + self.in_flight = None; + return completed; + } + futures::future::pending::<()>().await; + } + } + + fn start_next(&mut self) { + if self.in_flight.is_none() + && let Some(post) = self.queued.pop_front() + { + self.in_flight = Some(post.into_completion()); } - futures::future::pending::<()>().await; } } @@ -1143,6 +1178,110 @@ mod tests { server.abort(); } + #[tokio::test] + async fn outbound_posts_are_sent_in_order() { + let first_started = Arc::new(Notify::new()); + let release_first = Arc::new(Notify::new()); + let second_seen = Arc::new(Notify::new()); + let app = Router::new().route( + "/acp", + post({ + let first_started = first_started.clone(); + let release_first = release_first.clone(); + let second_seen = second_seen.clone(); + move |Json(message): Json| { + let first_started = first_started.clone(); + let release_first = release_first.clone(); + let second_seen = second_seen.clone(); + async move { + if is_initialize_request(&message) { + return initialize_response().await.into_response(); + } + + match method_for_message(&message) { + Some("custom/first") => { + first_started.notify_one(); + release_first.notified().await; + } + Some("custom/second") => { + second_seen.notify_one(); + } + _ => {} + } + StatusCode::ACCEPTED.into_response() + } + } + }) + .get(pending_sse) + .delete(|| async { StatusCode::ACCEPTED }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = HttpClient::new(format!("http://{addr}")).unwrap(); + let (mut caller, transport) = Channel::duplex(); + let transport = tokio::spawn(run(client, transport)); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "initialize".to_string(), + json!({}), + RequestId::Number(1), + ) + .unwrap())) + .unwrap(); + let init_response = timeout(Duration::from_secs(1), caller.rx.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(matches!(init_response, RawJsonRpcMessage::Response(_))); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::notification( + "custom/first".to_string(), + json!({}), + ) + .unwrap())) + .unwrap(); + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::notification( + "custom/second".to_string(), + json!({}), + ) + .unwrap())) + .unwrap(); + + timeout(Duration::from_secs(1), first_started.notified()) + .await + .unwrap(); + assert!( + timeout(Duration::from_millis(100), second_seen.notified()) + .await + .is_err(), + "second POST must not be sent while the first POST is pending" + ); + + release_first.notify_one(); + timeout(Duration::from_secs(1), second_seen.notified()) + .await + .unwrap(); + + drop(caller); + timeout(Duration::from_secs(1), transport) + .await + .unwrap() + .unwrap() + .unwrap(); + + server.abort(); + } + #[tokio::test] async fn sse_continues_while_post_is_pending() { let post_started = Arc::new(Notify::new()); From 89b96cc47c39b592ab29de68a7880950583ad2c2 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 18 Jun 2026 14:41:47 +0200 Subject: [PATCH 40/43] fix(http): close rejected initialize connections --- src/agent-client-protocol-http/src/client.rs | 69 +++++++++++++++++--- 1 file changed, 61 insertions(+), 8 deletions(-) diff --git a/src/agent-client-protocol-http/src/client.rs b/src/agent-client-protocol-http/src/client.rs index 90f25d9d..30364652 100644 --- a/src/agent-client-protocol-http/src/client.rs +++ b/src/agent-client-protocol-http/src/client.rs @@ -511,17 +511,21 @@ impl ClientState { .await .map_err(|e| e.to_string())?; + let connection_id = response + .headers() + .get(HEADER_CONNECTION_ID) + .and_then(|v| v.to_str().ok()) + .map(String::from); + if let Some(connection_id) = &connection_id { + self.connection.set_connection_id(connection_id.clone()); + } + if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(format!("HTTP {status}: {body}")); } - let connection_id = response - .headers() - .get(HEADER_CONNECTION_ID) - .and_then(|v| v.to_str().ok()) - .map(String::from); let message = response .json::() .await @@ -532,13 +536,12 @@ impl ClientState { RawJsonRpcMessage::Response(RpcResponse::Error { .. }) ) { self.deliver(message); + self.connection.close().await; return Ok(InitializeOutcome::Rejected); } - let connection_id = connection_id + connection_id .ok_or_else(|| format!("server did not return {HEADER_CONNECTION_ID} header"))?; - - self.connection.set_connection_id(connection_id); self.deliver(message); Ok(InitializeOutcome::Connected) } @@ -1797,6 +1800,50 @@ mod tests { server.abort(); } + #[tokio::test] + async fn malformed_initialize_body_with_connection_id_is_deleted() { + let delete_count = Arc::new(AtomicUsize::new(0)); + let delete_count_for_handler = delete_count.clone(); + let app = Router::new().route( + "/acp", + post(malformed_initialize_response).delete(move || { + let delete_count = delete_count_for_handler.clone(); + async move { + delete_count.fetch_add(1, Ordering::SeqCst); + StatusCode::ACCEPTED + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = HttpClient::new(format!("http://{addr}")).unwrap(); + let (caller, transport) = Channel::duplex(); + let transport = tokio::spawn(run(client, transport)); + + caller + .tx + .unbounded_send(Ok(RawJsonRpcMessage::request( + "initialize".to_string(), + json!({}), + RequestId::Number(1), + ) + .unwrap())) + .unwrap(); + let error = timeout(Duration::from_secs(1), transport) + .await + .unwrap() + .unwrap() + .unwrap_err(); + + assert!(error.to_string().contains("initialize")); + wait_for_delete(&delete_count).await; + + server.abort(); + } + async fn wait_for_delete(delete_count: &AtomicUsize) { wait_for_delete_count(delete_count, 1).await; assert_eq!(delete_count.load(Ordering::SeqCst), 1); @@ -1835,6 +1882,12 @@ mod tests { )) } + async fn malformed_initialize_response() -> impl IntoResponse { + let mut headers = HeaderMap::new(); + headers.insert(HEADER_CONNECTION_ID, HeaderValue::from_static("conn-1")); + (StatusCode::OK, headers, "{not json") + } + async fn pending_sse() -> Sse>> { Sse::new(futures::stream::pending()) } From 2b46bc41ca0373a201e0cb2bfa51b9ec87807602 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 18 Jun 2026 15:03:04 +0200 Subject: [PATCH 41/43] fix(http): Require session header for session/fork --- src/agent-client-protocol-http/src/client.rs | 25 ++++++---- .../src/http_server.rs | 47 ++++++++++--------- .../src/protocol.rs | 2 + 3 files changed, 44 insertions(+), 30 deletions(-) diff --git a/src/agent-client-protocol-http/src/client.rs b/src/agent-client-protocol-http/src/client.rs index 30364652..386c0fe2 100644 --- a/src/agent-client-protocol-http/src/client.rs +++ b/src/agent-client-protocol-http/src/client.rs @@ -1094,16 +1094,19 @@ mod tests { .get(HEADER_SESSION_ID) .and_then(|value| value.to_str().ok()) .map(String::from); + let is_connection_stream = session_header.is_none(); get_tx.send(session_header).unwrap(); let stream = async_stream::stream! { - response_ready.notified().await; - yield Ok::<_, Infallible>(sse_event( - RawJsonRpcMessage::response( - RequestId::Number(2), - Ok(json!({ "sessionId": "forked-session" })), - ), - )); + if is_connection_stream { + response_ready.notified().await; + yield Ok::<_, Infallible>(sse_event( + RawJsonRpcMessage::response( + RequestId::Number(2), + Ok(json!({ "sessionId": "forked-session" })), + ), + )); + } futures::future::pending::<()>().await; }; Sse::new(stream) @@ -1147,11 +1150,17 @@ mod tests { .tx .unbounded_send(Ok(RawJsonRpcMessage::request( "session/fork".to_string(), - json!({}), + json!({ "sessionId": "source-session" }), RequestId::Number(2), ) .unwrap())) .unwrap(); + let source_sse_header = timeout(Duration::from_secs(1), get_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(source_sse_header.as_deref(), Some("source-session")); + let response = timeout(Duration::from_secs(1), caller.rx.next()) .await .unwrap() diff --git a/src/agent-client-protocol-http/src/http_server.rs b/src/agent-client-protocol-http/src/http_server.rs index b5f35625..d22c890a 100644 --- a/src/agent-client-protocol-http/src/http_server.rs +++ b/src/agent-client-protocol-http/src/http_server.rs @@ -659,29 +659,32 @@ mod tests { forwarded: forwarded_tx, }))); let (connection_id, connection) = registry.create_connection().await; - let body = json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "session/delete", - "params": {} - }) - .to_string(); - let request = Request::builder() - .method("POST") - .uri("/acp") - .header(header::CONTENT_TYPE, JSON_MIME_TYPE) - .header(HEADER_CONNECTION_ID, connection_id.as_str()) - .body(Body::from(body)) - .unwrap(); - - let response = handle_post(State(registry), request).await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - let body = axum::body::to_bytes(response.into_body(), 1024) - .await - .unwrap(); - assert_eq!(body.as_ref(), b"Acp-Session-Id header required"); - assert!(forwarded_rx.try_recv().is_err()); + for method in ["session/delete", "session/fork"] { + let body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": {} + }) + .to_string(); + let request = Request::builder() + .method("POST") + .uri("/acp") + .header(header::CONTENT_TYPE, JSON_MIME_TYPE) + .header(HEADER_CONNECTION_ID, connection_id.as_str()) + .body(Body::from(body)) + .unwrap(); + + let response = handle_post(State(registry.clone()), request).await; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{method}"); + let body = axum::body::to_bytes(response.into_body(), 1024) + .await + .unwrap(); + assert_eq!(body.as_ref(), b"Acp-Session-Id header required", "{method}"); + assert!(forwarded_rx.try_recv().is_err(), "{method}"); + } connection.shutdown().await; } } diff --git a/src/agent-client-protocol-http/src/protocol.rs b/src/agent-client-protocol-http/src/protocol.rs index f6d9e375..1c746cf9 100644 --- a/src/agent-client-protocol-http/src/protocol.rs +++ b/src/agent-client-protocol-http/src/protocol.rs @@ -14,6 +14,7 @@ pub(crate) fn method_requires_session_header(method: &str) -> bool { | "session/cancel" | "session/close" | "session/delete" + | "session/fork" | "session/load" | "session/resume" | "session/set_config_option" @@ -239,6 +240,7 @@ mod tests { "session/cancel", "session/close", "session/delete", + "session/fork", "session/load", "session/prompt", "session/resume", From d78efe536ee8128f7d57fa1c770812a4987568c6 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 18 Jun 2026 15:44:48 +0200 Subject: [PATCH 42/43] Fix doclinks --- src/agent-client-protocol-http/src/client.rs | 4 ++-- src/agent-client-protocol/src/jsonrpc.rs | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/agent-client-protocol-http/src/client.rs b/src/agent-client-protocol-http/src/client.rs index 386c0fe2..8db84181 100644 --- a/src/agent-client-protocol-http/src/client.rs +++ b/src/agent-client-protocol-http/src/client.rs @@ -56,7 +56,7 @@ impl HttpClient { /// Create a client that targets the exact endpoint URL. /// /// Use this when connecting to a server configured with a custom - /// [`ServerOptions::path`](crate::ServerOptions::path). + /// `ServerOptions::path`. pub fn with_endpoint(endpoint: impl AsRef) -> Result { Self::with_endpoint_and_client(endpoint, reqwest::Client::new()) } @@ -85,7 +85,7 @@ impl HttpClient { /// Create a client with a custom HTTP client and exact endpoint URL. /// /// Use this when connecting to a server configured with a custom - /// [`ServerOptions::path`](crate::ServerOptions::path). + /// `ServerOptions::path`. pub fn with_endpoint_and_client( endpoint: impl AsRef, http: reqwest::Client, diff --git a/src/agent-client-protocol/src/jsonrpc.rs b/src/agent-client-protocol/src/jsonrpc.rs index 7de317c8..bb465545 100644 --- a/src/agent-client-protocol/src/jsonrpc.rs +++ b/src/agent-client-protocol/src/jsonrpc.rs @@ -3957,8 +3957,7 @@ impl SentRequest { /// cancels the incoming request, the cancellation is forwarded to the /// outgoing request, and the downstream response (normal data or a /// cancellation error) is still forwarded back. This is equivalent to - /// registering the responder's marker with - /// [`forward_cancellation_from`](Self::forward_cancellation_from). + /// registering the responder's marker with `forward_cancellation_from`. #[track_caller] pub fn forward_response_to(self, responder: Responder) -> Result<(), crate::Error> where From 941d9023578deabed6aff79c3622a65a5bb16b2f Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 18 Jun 2026 16:19:19 +0200 Subject: [PATCH 43/43] fix(http): Fail transport on peer WebSocket close --- md/http-transport.md | 26 ++++++++++++++- src/agent-client-protocol-http/src/client.rs | 35 ++++++++++++++++++-- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/md/http-transport.md b/md/http-transport.md index f1c2565e..64d75b17 100644 --- a/md/http-transport.md +++ b/md/http-transport.md @@ -4,12 +4,36 @@ - `POST /acp` with `initialize` creates a connection and returns `Acp-Connection-Id`. - Later `POST /acp` requests include `Acp-Connection-Id`; session-scoped requests also include `Acp-Session-Id` or `params.sessionId`. -- `GET /acp` with `Accept: text/event-stream` streams agent messages over SSE. +- `GET /acp` with `Accept: text/event-stream` streams agent messages over SSE. Use a connection-level stream for connection-scoped messages and per-session streams for session-scoped messages. - `GET /acp` with a WebSocket upgrade uses text frames for JSON-RPC messages. - `DELETE /acp` tears down the connection. `POST /acp` request bodies are limited to 16 MiB. +## HTTP + SSE Streams + +After `initialize`, clients should open a connection-level SSE stream: + +- `GET /acp` +- `Accept: text/event-stream` +- `Acp-Connection-Id: ` +- no `Acp-Session-Id` + +This stream carries connection-scoped messages. + +Session-scoped messages are routed to session-specific SSE streams. For each +active session, clients should also open: + +- `GET /acp` +- `Accept: text/event-stream` +- `Acp-Connection-Id: ` +- `Acp-Session-Id: ` + +Open a session stream before sending methods such as `session/prompt`, +`session/load`, `session/resume`, or other session-scoped requests. When a +`session/new` or `session/fork` response returns a new `sessionId`, open an SSE +stream for that returned session before expecting updates or responses for it. + ## Features The crate does not enable either transport side by default. Opt into only the side(s) you need. diff --git a/src/agent-client-protocol-http/src/client.rs b/src/agent-client-protocol-http/src/client.rs index 8db84181..8bb92340 100644 --- a/src/agent-client-protocol-http/src/client.rs +++ b/src/agent-client-protocol-http/src/client.rs @@ -763,14 +763,17 @@ async fn run_ws(client: HttpClient, channel: Channel) -> Result<(), AcpError> { )) => {} Some(Ok(WsMessage::Close(frame))) => { debug!("server closed WebSocket: {frame:?}"); - break; + return Err(AcpError::internal_error() + .data(format!("WebSocket closed by peer: {frame:?}"))); } Some(Err(e)) => { error!("WebSocket receive error: {e}"); return Err(AcpError::internal_error() .data(format!("ws recv: {e}"))); } - None => break, + None => { + return Err(AcpError::internal_error().data("WebSocket stream ended")); + } }, } } @@ -1647,6 +1650,28 @@ mod tests { server.abort(); } + #[tokio::test] + async fn peer_ws_close_fails_transport() { + let app = Router::new().route("/acp", get(close_ws)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = HttpClient::new(format!("ws://{addr}")).unwrap(); + let (_caller, transport) = Channel::duplex(); + let transport = tokio::spawn(run(client, transport)); + + let error = timeout(Duration::from_secs(1), transport) + .await + .unwrap() + .unwrap() + .unwrap_err(); + assert!(error.to_string().contains("WebSocket closed by peer")); + + server.abort(); + } + #[tokio::test] async fn dropped_transport_future_deletes_initialized_connection() { let delete_count = Arc::new(AtomicUsize::new(0)); @@ -1925,6 +1950,12 @@ mod tests { }) } + async fn close_ws(ws: WebSocketUpgrade) -> impl IntoResponse { + ws.on_upgrade(|mut socket| async move { + drop(socket.send(AxumWsMessage::Close(None)).await); + }) + } + async fn closed_sse() -> StatusCode { StatusCode::OK }