diff --git a/Cargo.lock b/Cargo.lock index b18b225b..371b03d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,6 +43,20 @@ dependencies = [ "subtle", ] +[[package]] +name = "agent-client-protocol-schema" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca98360c7bb8cc97d7acd49e2a8a851c3f7bee6b2f0535036d8ab86b5fcd223d" +dependencies = [ + "anyhow", + "derive_more", + "serde", + "serde_json", + "serde_with", + "strum", +] + [[package]] name = "ahash" version = "0.7.8" @@ -305,7 +319,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ "annotate-snippets", - "bitflags", + "bitflags 2.13.1", "cexpr", "clang-sys", "itertools", @@ -317,6 +331,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.1" @@ -411,6 +431,15 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -644,6 +673,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.1" @@ -752,8 +790,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -770,13 +818,37 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn 2.0.117", ] @@ -787,6 +859,37 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "deranged" version = "0.5.8" @@ -794,6 +897,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", + "serde_core", ] [[package]] @@ -811,7 +915,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn 2.0.117", @@ -827,6 +931,29 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", + "unicode-xid", +] + [[package]] name = "digest" version = "0.10.7" @@ -844,7 +971,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags", + "bitflags 2.13.1", "block2", "libc", "objc2", @@ -882,7 +1009,7 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80bc8c5c6c2941f70a55c15f8d9f00f9710ebda3ffda98075f996a0e6c92756f" dependencies = [ - "bitflags", + "bitflags 2.13.1", "bytemuck", "drm-ffi", "drm-fourcc", @@ -896,7 +1023,7 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a41816e58f47f49acfd956651055ddcf137c4882c2098c30c448817af21183a" dependencies = [ - "bitflags", + "bitflags 2.13.1", "bytemuck", "bytemuck_derive", "drm-ffi", @@ -937,6 +1064,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.16.0" @@ -1203,7 +1336,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce852e998d3ca5e4a97014fb31c940dc5ef344ec7d364984525fd11e8a547e6a" dependencies = [ - "bitflags", + "bitflags 2.13.1", "drm 0.14.1", "drm-fourcc", "gbm-sys", @@ -1332,7 +1465,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -1696,6 +1829,17 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1747,6 +1891,59 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.21.1" @@ -1910,7 +2107,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2909f3be29d674e7f10604aff18d1bbe1bb03c4cd61c8a8ba19c0b1d162f7d4e" dependencies = [ - "bitflags", + "bitflags 2.13.1", "cc", "cookie-factory", "libc", @@ -2136,7 +2333,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87" dependencies = [ - "bitflags", + "bitflags 2.13.1", "byteorder", "derive_builder", "getset", @@ -2217,7 +2414,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags", + "bitflags 2.13.1", "block2", "libc", "objc2", @@ -2238,7 +2435,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69282c2b5bc58fba07cb9de2113619532eb551e98efe3d8d695509ef45fbd53b" dependencies = [ - "bitflags", + "bitflags 2.13.1", "libc", "objc2", "objc2-core-foundation", @@ -2253,7 +2450,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "478ae33fcac9df0a18db8302387c666b8ef08a3e2d62b510ca4fc278a384b6c0" dependencies = [ - "bitflags", + "bitflags 2.13.1", "block2", "dispatch2", "objc2", @@ -2285,7 +2482,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags", + "bitflags 2.13.1", "objc2", "objc2-foundation", ] @@ -2308,7 +2505,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ - "bitflags", + "bitflags 2.13.1", "objc2", ] @@ -2318,7 +2515,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ - "bitflags", + "bitflags 2.13.1", "objc2", "objc2-foundation", ] @@ -2329,7 +2526,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.13.1", "block2", "dispatch2", "libc", @@ -2342,7 +2539,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags", + "bitflags 2.13.1", "block2", "dispatch2", "libc", @@ -2368,7 +2565,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d" dependencies = [ - "bitflags", + "bitflags 2.13.1", "block2", "dispatch2", "objc2", @@ -2396,7 +2593,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags", + "bitflags 2.13.1", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -2408,7 +2605,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ - "bitflags", + "bitflags 2.13.1", "block2", "objc2", "objc2-core-foundation", @@ -2429,7 +2626,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags", + "bitflags 2.13.1", "block2", "libc", "objc2", @@ -2453,7 +2650,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags", + "bitflags 2.13.1", "objc2", "objc2-core-foundation", ] @@ -2476,7 +2673,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ - "bitflags", + "bitflags 2.13.1", "objc2", "objc2-foundation", ] @@ -2487,7 +2684,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags", + "bitflags 2.13.1", "objc2", "objc2-foundation", ] @@ -2498,7 +2695,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" dependencies = [ - "bitflags", + "bitflags 2.13.1", "objc2", "objc2-core-foundation", ] @@ -2629,7 +2826,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8585aba8a52ad74ccc633b8e293c1dc4277976bd5d510b925533f34fd6685f38" dependencies = [ - "bitflags", + "bitflags 2.13.1", "libc", "libspa", "libspa-sys", @@ -2660,7 +2857,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", @@ -2693,6 +2890,21 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2995,7 +3207,27 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.1", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] @@ -3166,7 +3398,7 @@ version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ - "bitflags", + "bitflags 2.13.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -3246,7 +3478,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -3259,7 +3491,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -3422,7 +3654,7 @@ dependencies = [ "compact_str", "futures-util", "http", - "indexmap", + "indexmap 2.14.0", "inventory", "mime-infer", "parking_lot", @@ -3513,7 +3745,7 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", - "indexmap", + "indexmap 2.14.0", "mime", "mime-infer", "multimap", @@ -3594,6 +3826,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -3618,7 +3874,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -3689,6 +3945,7 @@ version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -3702,7 +3959,7 @@ version = "0.9.42" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e408f29489b5fd500fab51ff1484fc859bb655f32c671f307dcd733b72e8168c" dependencies = [ - "indexmap", + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -3750,6 +4007,39 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3877,6 +4167,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" @@ -3942,7 +4253,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags", + "bitflags 2.13.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -4185,7 +4496,7 @@ version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde_core", "serde_spanned 1.1.1", "toml_datetime 1.1.1+spec-1.1.0", @@ -4218,7 +4529,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", @@ -4232,7 +4543,7 @@ version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ - "indexmap", + "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "winnow 1.0.3", @@ -4282,7 +4593,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.13.1", "bytes", "futures-util", "http", @@ -4431,6 +4742,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + [[package]] name = "unicode-width" version = "0.2.2" @@ -4641,7 +4958,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] @@ -4665,9 +4982,9 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.13.1", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver", ] @@ -4691,7 +5008,7 @@ version = "0.31.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ - "bitflags", + "bitflags 2.13.1", "rustix 1.1.4", "wayland-backend", "wayland-scanner", @@ -4703,7 +5020,7 @@ version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ - "bitflags", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-scanner", @@ -4715,7 +5032,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -4739,7 +5056,7 @@ version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dde9c29be0f723a573977de51ee455bf3dfa03652730a74f9dd3b337e374d75" dependencies = [ - "bitflags", + "bitflags 2.13.1", "downcast-rs", "rustix 1.1.4", "wayland-backend", @@ -4891,6 +5208,7 @@ dependencies = [ name = "webcodex-runner" version = "0.3.8" dependencies = [ + "agent-client-protocol-schema", "base64", "block2", "chrono", @@ -5409,7 +5727,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -5439,8 +5757,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", - "indexmap", + "bitflags 2.13.1", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -5459,7 +5777,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", @@ -5521,7 +5839,7 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6c2ad15e0e922856ee89afe862b8992334bbe7953adad56cd1199358cb30566" dependencies = [ - "bitflags", + "bitflags 2.13.1", "libc", "quick-xml", ] diff --git a/crates/webcodex-agent-config/src/lib.rs b/crates/webcodex-agent-config/src/lib.rs index 4ceb6043..188332ab 100644 --- a/crates/webcodex-agent-config/src/lib.rs +++ b/crates/webcodex-agent-config/src/lib.rs @@ -278,6 +278,9 @@ pub fn generated_agent_config_toml(opts: &AgentInitOptions) -> Result CliAction { let mut oauth_redirect_uri = None; let mut oauth_computer_permissions = false; let mut oauth_local_mcp = false; + let mut oauth_coding_agent = false; let mut username = None; let mut project = PathBuf::from("."); let mut profile = None; @@ -424,6 +425,7 @@ fn parse_connect(args: &[String]) -> CliAction { }, "--oauth-computer-permissions" => oauth_computer_permissions = true, "--oauth-local-mcp" => oauth_local_mcp = true, + "--oauth-coding-agent" => oauth_coding_agent = true, "--user" | "--username" => match take(&mut index) { Some(value) => username = Some(value), None => return cli_parse_error(format!("{arg} requires a value")), @@ -495,6 +497,9 @@ fn parse_connect(args: &[String]) -> CliAction { if oauth_local_mcp { return cli_parse_error("--oauth-local-mcp requires --auth oauth".to_string()); } + if oauth_coding_agent { + return cli_parse_error("--oauth-coding-agent requires --auth oauth".to_string()); + } if key.is_some() || key_file.is_some() { return cli_parse_error( "--auth managed-oauth cannot be combined with --key or --key-file".to_string(), @@ -518,6 +523,9 @@ fn parse_connect(args: &[String]) -> CliAction { if oauth_local_mcp { return cli_parse_error("--oauth-local-mcp requires --auth oauth".to_string()); } + if oauth_coding_agent { + return cli_parse_error("--oauth-coding-agent requires --auth oauth".to_string()); + } if oauth_redirect_uri.is_some() || username.is_some() { return cli_parse_error( "--oauth-redirect-uri requires --auth oauth or managed-oauth; --user requires --auth managed-oauth" @@ -546,6 +554,7 @@ fn parse_connect(args: &[String]) -> CliAction { oauth_redirect_uri, oauth_computer_permissions, oauth_local_mcp, + oauth_coding_agent, username, project, profile, diff --git a/crates/webcodex-cli/src/webcodex_cli/connect/oauth.rs b/crates/webcodex-cli/src/webcodex_cli/connect/oauth.rs index 4ce67e25..950bafc5 100644 --- a/crates/webcodex-cli/src/webcodex_cli/connect/oauth.rs +++ b/crates/webcodex-cli/src/webcodex_cli/connect/oauth.rs @@ -951,6 +951,7 @@ mod tests { oauth_redirect_uri: Some("https://client.example/callback".to_string()), oauth_computer_permissions: false, oauth_local_mcp: false, + oauth_coding_agent: false, username: None, project: PathBuf::from("."), profile: None, diff --git a/crates/webcodex-cli/src/webcodex_cli/connect/profile.rs b/crates/webcodex-cli/src/webcodex_cli/connect/profile.rs index b5156192..fb8345a0 100644 --- a/crates/webcodex-cli/src/webcodex_cli/connect/profile.rs +++ b/crates/webcodex-cli/src/webcodex_cli/connect/profile.rs @@ -31,6 +31,7 @@ pub(crate) struct ConnectOptions { pub(crate) oauth_redirect_uri: Option, pub(crate) oauth_computer_permissions: bool, pub(crate) oauth_local_mcp: bool, + pub(crate) oauth_coding_agent: bool, pub(crate) username: Option, pub(crate) project: PathBuf, pub(crate) profile: Option, @@ -773,6 +774,7 @@ mod tests { oauth_redirect_uri: None, oauth_computer_permissions: false, oauth_local_mcp: false, + oauth_coding_agent: false, username: None, project: project.clone(), profile: None, diff --git a/crates/webcodex-cli/src/webcodex_cli/connect/shared_key_oauth.rs b/crates/webcodex-cli/src/webcodex_cli/connect/shared_key_oauth.rs index 0d4cfec7..5b26aea1 100644 --- a/crates/webcodex-cli/src/webcodex_cli/connect/shared_key_oauth.rs +++ b/crates/webcodex-cli/src/webcodex_cli/connect/shared_key_oauth.rs @@ -12,6 +12,7 @@ const BRIDGE_PROFILE_VERSION: u32 = 1; const BRIDGE_PROFILE_PREFIX: &str = "shared-key-oauth-"; const BRIDGE_SECRET_DISCLOSED_PREFIX: &str = ".shared-key-oauth-secret-disclosed-"; const LOCAL_MCP_SCOPE: &str = "mcp:local"; +const CODING_AGENT_SCOPE: &str = "coding_agent:run"; const BRIDGE_BASELINE_SCOPES: &[&str] = &[ "runtime:read", "project:read", @@ -55,6 +56,8 @@ struct SharedKeyOAuthProfile { computer_permissions_enabled: bool, #[serde(default)] local_mcp_enabled: bool, + #[serde(default)] + coding_agent_enabled: bool, } #[derive(Debug, Clone)] @@ -127,10 +130,10 @@ fn scope_list_is_unique(scopes: &[String]) -> bool { == scopes.len() } -fn without_local_mcp(scopes: &[String]) -> Vec { +fn without_optional_class_scopes(scopes: &[String]) -> Vec { scopes .iter() - .filter(|scope| scope.as_str() != LOCAL_MCP_SCOPE) + .filter(|scope| !matches!(scope.as_str(), LOCAL_MCP_SCOPE | CODING_AGENT_SCOPE)) .cloned() .collect() } @@ -188,7 +191,14 @@ fn profile_scope_ceiling_is_valid(profile: &SharedKeyOAuthProfile) -> bool { if local_mcp_present != profile.local_mcp_enabled { return false; } - let authority_scopes = without_local_mcp(&profile.allowed_scopes); + let coding_agent_present = profile + .allowed_scopes + .iter() + .any(|scope| scope == CODING_AGENT_SCOPE); + if coding_agent_present != profile.coding_agent_enabled { + return false; + } + let authority_scopes = without_optional_class_scopes(&profile.allowed_scopes); if profile.computer_permissions_enabled { computer_enabled_scope_ceiling_is_valid(&authority_scopes) } else { @@ -282,6 +292,7 @@ async fn provision_client( "previous_allowed_scopes": existing.map(|profile| profile.allowed_scopes.as_slice()), "computer_permissions": opts.oauth_computer_permissions, "local_mcp": opts.oauth_local_mcp, + "coding_agent": opts.oauth_coding_agent, }), }) .await?; @@ -315,7 +326,16 @@ async fn provision_client( .to_string(), ); } - let authority_scopes = without_local_mcp(&allowed_scopes); + let coding_agent_present = allowed_scopes + .iter() + .any(|scope| scope == CODING_AGENT_SCOPE); + if coding_agent_present != opts.oauth_coding_agent { + return Err( + "Server changed coding-agent OAuth authority without matching the explicit connect opt-in" + .to_string(), + ); + } + let authority_scopes = without_optional_class_scopes(&allowed_scopes); if opts.oauth_computer_permissions { if !computer_enabled_scope_ceiling_is_valid(&authority_scopes) { return Err( @@ -323,7 +343,7 @@ async fn provision_client( ); } let expected_scopes = if let Some(existing) = existing { - computer_enabled_scope_ceiling_from_existing(&without_local_mcp(&existing.allowed_scopes)) + computer_enabled_scope_ceiling_from_existing(&without_optional_class_scopes(&existing.allowed_scopes)) .ok_or_else(|| { "existing shared-key OAuth profile cannot be safely upgraded to Computer permissions" .to_string() @@ -348,7 +368,7 @@ async fn provision_client( ); } let expected_scopes = existing - .map(|profile| without_local_mcp(&profile.allowed_scopes)) + .map(|profile| without_optional_class_scopes(&profile.allowed_scopes)) .unwrap_or_else(|| { BRIDGE_BASELINE_SCOPES .iter() @@ -381,6 +401,7 @@ async fn provision_client( updated.allowed_scopes = allowed_scopes; updated.computer_permissions_enabled = opts.oauth_computer_permissions; updated.local_mcp_enabled = opts.oauth_local_mcp; + updated.coding_agent_enabled = opts.oauth_coding_agent; let changed = updated != *existing; return Ok((updated, changed)); } @@ -400,6 +421,7 @@ async fn provision_client( allowed_scopes, computer_permissions_enabled: opts.oauth_computer_permissions, local_mcp_enabled: opts.oauth_local_mcp, + coding_agent_enabled: opts.oauth_coding_agent, }, true, )) @@ -488,6 +510,12 @@ pub(super) async fn finish_shared_key_oauth_connect( .to_string(), ); } + if existing.coding_agent_enabled && !opts.oauth_coding_agent { + return Err( + "this shared-key OAuth profile already has coding-agent authority enabled; reconnect with --oauth-coding-agent to reuse it, or use a different profile/redirect URI" + .to_string(), + ); + } } let metadata = fetch_metadata(opts, server_url).await?; let (oauth, created_or_rotated) = provision_client( @@ -554,6 +582,7 @@ mod tests { oauth_redirect_uri: Some("https://chatgpt.example/callback".to_string()), oauth_computer_permissions: false, oauth_local_mcp: false, + oauth_coding_agent: false, username: None, project: PathBuf::from("."), profile: None, @@ -689,6 +718,7 @@ mod tests { allowed_scopes: vec!["runtime:read".to_string(), "project:read".to_string()], computer_permissions_enabled: false, local_mcp_enabled: false, + coding_agent_enabled: false, }; let (upgraded, changed) = provision_client( &opts, @@ -820,6 +850,7 @@ mod tests { allowed_scopes: vec!["runtime:read".to_string(), "project:read".to_string()], computer_permissions_enabled: false, local_mcp_enabled: false, + coding_agent_enabled: false, }; assert!(profile_scope_ceiling_is_valid(&baseline)); @@ -905,6 +936,7 @@ mod tests { allowed_scopes: vec!["runtime:read".to_string()], computer_permissions_enabled: false, local_mcp_enabled: false, + coding_agent_enabled: false, }; let state_path = Path::new("/protected/profile/shared-key-oauth.toml"); let first = bridge_client_secret_line(&oauth, true, state_path); diff --git a/crates/webcodex-cli/src/webcodex_cli/tests/connect.rs b/crates/webcodex-cli/src/webcodex_cli/tests/connect.rs index 6742abb1..3d199200 100644 --- a/crates/webcodex-cli/src/webcodex_cli/tests/connect.rs +++ b/crates/webcodex-cli/src/webcodex_cli/tests/connect.rs @@ -282,6 +282,7 @@ async fn connect_rejects_invalid_url_and_missing_project_before_network_or_write oauth_redirect_uri: None, oauth_computer_permissions: false, oauth_local_mcp: false, + oauth_coding_agent: false, username: None, project: tmp.path().join("missing"), profile: None, diff --git a/crates/webcodex-cli/src/webcodex_cli/usage.rs b/crates/webcodex-cli/src/webcodex_cli/usage.rs index 7d380b97..261cfcab 100644 --- a/crates/webcodex-cli/src/webcodex_cli/usage.rs +++ b/crates/webcodex-cli/src/webcodex_cli/usage.rs @@ -53,6 +53,7 @@ Options:\n\ --oauth-computer-permissions\n\ Allow ordinary OAuth browser consent to offer optional Computer permissions\n\ --oauth-local-mcp Explicitly allow this OAuth client to request mcp:local authority\n\ + --oauth-coding-agent Explicitly allow this OAuth client to request coding_agent:run authority\n\ --user USER Select a logged-in managed user; managed-oauth only\n\ --key KEY Shared key (use --key-file to avoid shell history)\n\ --key-file PATH Read the shared key from a file\n\ @@ -68,7 +69,8 @@ browser authorize page; ChatGPT receives OAuth client credentials/tokens, never Without explicit opt-ins the bridge keeps the direct shared-key model-facing baseline.\n\ --oauth-computer-permissions adds only the fixed launch/display/pointer/clipboard Computer\n\ ceiling; browser checkboxes decide the actual grant. --oauth-local-mcp adds class-level\n\ -mcp:local authority for Runner-owned MCP providers in this shared-key group. Existing\n\ +mcp:local authority for Runner-owned MCP providers in this shared-key group.\n\ +--oauth-coding-agent adds only coding_agent:run delegated coding-agent authority. Existing\n\ clients are never widened implicitly. managed-oauth remains a separate managed-user flow.\n" } diff --git a/crates/webcodex-core/src/coding_agent.rs b/crates/webcodex-core/src/coding_agent.rs new file mode 100644 index 00000000..1dbb57c3 --- /dev/null +++ b/crates/webcodex-core/src/coding_agent.rs @@ -0,0 +1,1103 @@ +//! Closed transport-neutral protocol for Runner-owned ACP coding-agent runs. +//! +//! Raw ACP JSON-RPC never crosses the Server↔Runner boundary. The Runner owns +//! ACP methods, request ids, private session ids, executable/argv/environment, +//! and protocol callbacks; the Server sees only the bounded typed structures in +//! this module. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +pub const CODING_AGENT_MAX_PROVIDERS: usize = 8; +pub const CODING_AGENT_MAX_PROVIDER_ID_BYTES: usize = 64; +pub const CODING_AGENT_MAX_PROVIDER_NAME_BYTES: usize = 128; +pub const CODING_AGENT_MAX_RUN_ID_BYTES: usize = 96; +pub const CODING_AGENT_MAX_INTENT_FINGERPRINT_BYTES: usize = 96; +pub const CODING_AGENT_MAX_PROJECT_ID_BYTES: usize = 512; +pub const CODING_AGENT_MAX_PROJECT_ROOT_BYTES: usize = 4096; +pub const CODING_AGENT_MAX_INSTRUCTION_BYTES: usize = 64 * 1024; +pub const CODING_AGENT_MAX_CONFIG_OPTIONS: usize = 32; +pub const CODING_AGENT_MAX_CONFIG_KEY_BYTES: usize = 128; +pub const CODING_AGENT_MAX_CONFIG_VALUE_BYTES: usize = 4096; +pub const CODING_AGENT_MAX_EVENT_TEXT_BYTES: usize = 16 * 1024; +pub const CODING_AGENT_MAX_ERROR_KIND_BYTES: usize = 64; +pub const CODING_AGENT_MAX_ERROR_MESSAGE_BYTES: usize = 16 * 1024; +pub const CODING_AGENT_MAX_EVENT_METADATA_BYTES: usize = 1024; +pub const CODING_AGENT_MAX_TERMINAL_METADATA_BYTES: usize = 1024; +pub const CODING_AGENT_MAX_EVENTS_PER_RESPONSE: usize = 64; +pub const CODING_AGENT_MAX_RETAINED_EVENTS: usize = 256; +pub const CODING_AGENT_MAX_INVENTORY_RUNS: usize = 128; +pub const CODING_AGENT_TIMEOUT_MIN_SECS: u64 = 1; +pub const CODING_AGENT_TIMEOUT_MAX_SECS: u64 = 3600; +pub const CODING_AGENT_OBSERVE_WAIT_MAX_SECS: u64 = 60; + +pub const CODING_AGENT_STOP_REASON_END_TURN: &str = "end_turn"; +pub const CODING_AGENT_STOP_REASON_CANCELLED: &str = "cancelled"; +pub const CODING_AGENT_STOP_REASON_MAX_TOKENS: &str = "max_tokens"; +pub const CODING_AGENT_STOP_REASON_MAX_TURN_REQUESTS: &str = "max_turn_requests"; +pub const CODING_AGENT_STOP_REASON_REFUSAL: &str = "refusal"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CodingAgentProvider { + pub provider_id: String, + pub provider_instance_id: String, + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CodingAgentRunState { + Starting, + Running, + WaitingPermission, + Completed, + Failed, + Cancelled, + Lost, +} + +impl CodingAgentRunState { + pub fn terminal(&self) -> bool { + matches!( + self, + Self::Completed | Self::Failed | Self::Cancelled | Self::Lost + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CodingAgentExecutionState { + NotStarted, + Started, + OutcomeUnknown, + Completed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CodingAgentDispatchState { + NotStarted, + OutcomeUnknown, + Completed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum CodingAgentConfigValue { + String(String), + Bool(bool), + Integer(i64), +} + +impl CodingAgentConfigValue { + pub fn serialized_len(&self) -> usize { + serde_json::to_vec(self) + .map(|value| value.len()) + .unwrap_or(usize::MAX) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CodingAgentEventKind { + AgentMessage, + Reasoning, + Plan, + ToolActivity, + FileChange, + TerminalActivity, + Usage, + PermissionRequest, + Terminal, +} + +impl CodingAgentEventKind { + /// Canonical model-facing and wire vocabulary. Keep this exhaustive match + /// aligned with the serde snake_case representation instead of relying on + /// Debug formatting, which does not preserve word boundaries. + pub const fn as_str(&self) -> &'static str { + match self { + Self::AgentMessage => "agent_message", + Self::Reasoning => "reasoning", + Self::Plan => "plan", + Self::ToolActivity => "tool_activity", + Self::FileChange => "file_change", + Self::TerminalActivity => "terminal_activity", + Self::Usage => "usage", + Self::PermissionRequest => "permission_request", + Self::Terminal => "terminal", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CodingAgentUsage { + /// Stable ACP v1 `usage_update.used`: tokens currently in context. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub used_tokens: Option, + /// Stable ACP v1 `usage_update.size`: total context-window size in tokens. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_window_tokens: Option, + /// Optional cumulative session cost rendered as bounded decimal text to + /// keep this transport type Eq/JSON-stable without exposing raw metadata. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost_amount: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost_currency: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CodingAgentEvent { + pub sequence: u64, + pub kind: CodingAgentEventKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CodingAgentTerminal { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stop_reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, + pub completed_at: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CodingAgentRunSnapshot { + pub run_id: String, + pub intent_fingerprint: String, + /// Domain-separated hash of the stable authenticated caller identity. + pub authority_fingerprint: String, + pub runtime_project_id: String, + pub provider_id: String, + pub provider_instance_id: String, + pub state: CodingAgentRunState, + pub execution_state: CodingAgentExecutionState, + pub observation_revision: u64, + pub created_at: i64, + pub updated_at: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub terminal: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct CodingAgentRunInventory { + #[serde(default)] + pub runs: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CodingAgentStartRequest { + pub run_id: String, + pub intent_fingerprint: String, + pub authority_fingerprint: String, + pub runtime_project_id: String, + pub project_root: String, + pub provider_id: String, + pub provider_instance_id: String, + pub instruction: String, + #[serde(default)] + pub config: BTreeMap, + pub timeout_secs: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CodingAgentObserveRequest { + pub run_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub after_sequence: Option, + pub limit: usize, + pub wait_secs: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CodingAgentCancelRequest { + pub run_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)] +pub enum CodingAgentRequest { + Start(CodingAgentStartRequest), + Observe(CodingAgentObserveRequest), + Cancel(CodingAgentCancelRequest), +} + +impl CodingAgentRequest { + pub fn run_id(&self) -> &str { + match self { + Self::Start(request) => &request.run_id, + Self::Observe(request) => &request.run_id, + Self::Cancel(request) => &request.run_id, + } + } + + pub fn provider_binding(&self) -> Option<(&str, &str)> { + match self { + Self::Start(request) => Some((&request.provider_id, &request.provider_instance_id)), + Self::Observe(_) | Self::Cancel(_) => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CodingAgentObserveResult { + pub run: CodingAgentRunSnapshot, + pub events: Vec, + pub first_retained_sequence: u64, + pub next_sequence: u64, + pub has_more: bool, + pub history_lost: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum CodingAgentResponsePayload { + Start { + run: CodingAgentRunSnapshot, + }, + Observe { + observation: CodingAgentObserveResult, + }, + Cancel { + run: CodingAgentRunSnapshot, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CodingAgentError { + pub code: String, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub failure_kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recovery_kind: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CodingAgentResponse { + pub dispatch_state: CodingAgentDispatchState, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payload: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl CodingAgentResponse { + pub fn success(payload: CodingAgentResponsePayload) -> Self { + Self { + dispatch_state: CodingAgentDispatchState::Completed, + payload: Some(payload), + error: None, + } + } + + pub fn error( + dispatch_state: CodingAgentDispatchState, + code: impl Into, + message: impl Into, + failure_kind: Option<&str>, + recovery_kind: Option<&str>, + ) -> Self { + Self { + dispatch_state, + payload: None, + error: Some(CodingAgentError { + code: code.into(), + message: message.into(), + failure_kind: failure_kind.map(str::to_string), + recovery_kind: recovery_kind.map(str::to_string), + }), + } + } +} + +pub fn validate_provider_id(value: &str) -> Result<(), String> { + validate_identifier( + value, + "provider_id", + CODING_AGENT_MAX_PROVIDER_ID_BYTES, + true, + ) +} + +pub fn validate_provider_instance_id(value: &str) -> Result<(), String> { + validate_identifier( + value, + "provider_instance_id", + CODING_AGENT_MAX_PROVIDER_ID_BYTES, + false, + ) +} + +pub fn validate_run_id(value: &str) -> Result<(), String> { + if !value.starts_with("wc_agent_run_") { + return Err("run_id must use the wc_agent_run_ namespace".to_string()); + } + validate_identifier(value, "run_id", CODING_AGENT_MAX_RUN_ID_BYTES, false) +} + +pub fn validate_intent_fingerprint(value: &str) -> Result<(), String> { + validate_identifier( + value, + "intent_fingerprint", + CODING_AGENT_MAX_INTENT_FINGERPRINT_BYTES, + false, + ) +} + +pub fn validate_authority_fingerprint(value: &str) -> Result<(), String> { + if !value.starts_with("auth_") { + return Err("authority_fingerprint must use the auth_ namespace".to_string()); + } + validate_identifier(value, "authority_fingerprint", 80, false) +} + +pub fn validate_request(request: &CodingAgentRequest) -> Result<(), String> { + validate_run_id(request.run_id())?; + match request { + CodingAgentRequest::Start(request) => { + validate_intent_fingerprint(&request.intent_fingerprint)?; + validate_authority_fingerprint(&request.authority_fingerprint)?; + if request.runtime_project_id.trim().is_empty() + || request.runtime_project_id.len() > CODING_AGENT_MAX_PROJECT_ID_BYTES + { + return Err("runtime_project_id is invalid".to_string()); + } + if request.project_root.is_empty() + || request.project_root.len() > CODING_AGENT_MAX_PROJECT_ROOT_BYTES + || request.project_root.contains('\0') + { + return Err("project_root is invalid".to_string()); + } + validate_provider_id(&request.provider_id)?; + validate_provider_instance_id(&request.provider_instance_id)?; + if request.instruction.is_empty() + || request.instruction.len() > CODING_AGENT_MAX_INSTRUCTION_BYTES + || request.instruction.contains('\0') + { + return Err("instruction is invalid".to_string()); + } + if request.config.len() > CODING_AGENT_MAX_CONFIG_OPTIONS { + return Err("too many config overrides".to_string()); + } + for (key, value) in &request.config { + if key.is_empty() + || key.len() > CODING_AGENT_MAX_CONFIG_KEY_BYTES + || key.contains(['\0', '\r', '\n']) + || value.serialized_len() > CODING_AGENT_MAX_CONFIG_VALUE_BYTES + { + return Err("config override is invalid".to_string()); + } + } + if !(CODING_AGENT_TIMEOUT_MIN_SECS..=CODING_AGENT_TIMEOUT_MAX_SECS) + .contains(&request.timeout_secs) + { + return Err("timeout_secs is outside the supported range".to_string()); + } + } + CodingAgentRequest::Observe(request) => { + if request.limit == 0 || request.limit > CODING_AGENT_MAX_EVENTS_PER_RESPONSE { + return Err("observe limit is outside the supported range".to_string()); + } + if request.wait_secs > CODING_AGENT_OBSERVE_WAIT_MAX_SECS { + return Err("observe wait_secs is outside the supported range".to_string()); + } + } + CodingAgentRequest::Cancel(_) => {} + } + Ok(()) +} + +pub fn validate_response_for_request( + request: &CodingAgentRequest, + response: &CodingAgentResponse, +) -> Result<(), String> { + if response.payload.is_some() == response.error.is_some() { + return Err( + "CodingAgentRun response must contain exactly one payload or error".to_string(), + ); + } + let Some(payload) = response.payload.as_ref() else { + let error = response + .error + .as_ref() + .expect("response payload/error exclusivity checked above"); + validate_coding_agent_error(error)?; + return Ok(()); + }; + let run = match (request, payload) { + (CodingAgentRequest::Start(_), CodingAgentResponsePayload::Start { run }) => run, + (CodingAgentRequest::Observe(_), CodingAgentResponsePayload::Observe { observation }) => { + if observation.events.len() > CODING_AGENT_MAX_EVENTS_PER_RESPONSE { + return Err("CodingAgentRun response contains too many events".to_string()); + } + let mut previous = None; + for event in &observation.events { + if event + .text + .as_ref() + .is_some_and(|text| text.len() > CODING_AGENT_MAX_EVENT_TEXT_BYTES) + || previous.is_some_and(|sequence| event.sequence <= sequence) + { + return Err( + "CodingAgentRun response event bounds/order are invalid".to_string() + ); + } + if event + .label + .as_ref() + .is_some_and(|value| value.len() > CODING_AGENT_MAX_EVENT_METADATA_BYTES) + || event + .status + .as_ref() + .is_some_and(|value| value.len() > CODING_AGENT_MAX_EVENT_METADATA_BYTES) + || event.usage.as_ref().is_some_and(|usage| { + usage.cost_amount.as_ref().is_some_and(|value| { + value.len() > CODING_AGENT_MAX_EVENT_METADATA_BYTES + }) || usage.cost_currency.as_ref().is_some_and(|value| { + value.len() > CODING_AGENT_MAX_EVENT_METADATA_BYTES + }) + }) + { + return Err("CodingAgentRun response event metadata is too large".to_string()); + } + previous = Some(event.sequence); + } + if observation.first_retained_sequence == 0 + || observation.next_sequence.saturating_add(1) < observation.first_retained_sequence + { + return Err("CodingAgentRun response retention metadata is invalid".to_string()); + } + &observation.run + } + (CodingAgentRequest::Cancel(_), CodingAgentResponsePayload::Cancel { run }) => run, + _ => { + return Err("CodingAgentRun response kind does not match request operation".to_string()) + } + }; + if run.run_id != request.run_id() { + return Err("CodingAgentRun response run_id does not match request".to_string()); + } + validate_coding_agent_run_snapshot(run)?; + Ok(()) +} + +/// Validate the bounded identity and semantic state matrix of one Runner-owned +/// CodingAgentRun snapshot. This is the canonical Server/Runner reconciliation +/// contract: structurally decodable snapshots that contradict the closed ACP v1 +/// terminal truth fail closed before they can become retry/recovery authority. +pub fn validate_coding_agent_run_snapshot(run: &CodingAgentRunSnapshot) -> Result<(), String> { + validate_run_id(&run.run_id)?; + validate_intent_fingerprint(&run.intent_fingerprint)?; + validate_authority_fingerprint(&run.authority_fingerprint)?; + validate_provider_id(&run.provider_id)?; + validate_provider_instance_id(&run.provider_instance_id)?; + if run.runtime_project_id.trim().is_empty() + || run.runtime_project_id.len() > CODING_AGENT_MAX_PROJECT_ID_BYTES + { + return Err("CodingAgentRun snapshot project id is invalid".to_string()); + } + + if let Some(terminal) = run.terminal.as_ref() { + if terminal + .stop_reason + .as_ref() + .is_some_and(|value| value.len() > CODING_AGENT_MAX_TERMINAL_METADATA_BYTES) + || terminal + .error_code + .as_ref() + .is_some_and(|value| value.len() > CODING_AGENT_MAX_TERMINAL_METADATA_BYTES) + || terminal + .message + .as_ref() + .is_some_and(|value| value.len() > CODING_AGENT_MAX_ERROR_MESSAGE_BYTES) + { + return Err("CodingAgentRun snapshot terminal metadata is too large".to_string()); + } + if let Some(error_code) = terminal.error_code.as_deref() { + validate_low_cardinality_kind( + error_code, + "CodingAgentRun terminal error code", + CODING_AGENT_MAX_TERMINAL_METADATA_BYTES, + )?; + } + } + + match run.state { + CodingAgentRunState::Starting => { + if run.execution_state != CodingAgentExecutionState::NotStarted + || run.terminal.is_some() + { + return Err( + "starting CodingAgentRun snapshot is semantically inconsistent".to_string(), + ); + } + } + CodingAgentRunState::Running => { + if !matches!( + run.execution_state, + CodingAgentExecutionState::OutcomeUnknown | CodingAgentExecutionState::Started + ) || run.terminal.is_some() + { + return Err( + "running CodingAgentRun snapshot is semantically inconsistent".to_string(), + ); + } + } + CodingAgentRunState::WaitingPermission => { + if run.execution_state != CodingAgentExecutionState::Started || run.terminal.is_some() { + return Err( + "waiting_permission CodingAgentRun snapshot is semantically inconsistent" + .to_string(), + ); + } + } + CodingAgentRunState::Completed => { + let terminal = terminal_for_state(run, CodingAgentExecutionState::Completed)?; + if terminal.stop_reason.as_deref() != Some(CODING_AGENT_STOP_REASON_END_TURN) + || terminal.error_code.is_some() + { + return Err("completed CodingAgentRun snapshot lacks end_turn truth".to_string()); + } + } + CodingAgentRunState::Cancelled => { + let terminal = run.terminal.as_ref().ok_or_else(|| { + "cancelled CodingAgentRun snapshot lacks terminal metadata".to_string() + })?; + match run.execution_state { + CodingAgentExecutionState::NotStarted => { + if terminal.stop_reason.is_some() + || terminal.error_code.is_some() + || terminal.message.as_deref().is_none_or(str::is_empty) + { + return Err( + "pre-prompt cancelled CodingAgentRun snapshot claims ACP terminal truth" + .to_string(), + ); + } + } + CodingAgentExecutionState::Completed => { + if terminal.stop_reason.as_deref() != Some(CODING_AGENT_STOP_REASON_CANCELLED) + || terminal.error_code.is_some() + { + return Err( + "post-prompt cancelled CodingAgentRun snapshot lacks cancelled truth" + .to_string(), + ); + } + } + _ => { + return Err( + "cancelled CodingAgentRun snapshot has inconsistent execution state" + .to_string(), + ) + } + } + } + CodingAgentRunState::Failed => { + let terminal = run.terminal.as_ref().ok_or_else(|| { + "failed CodingAgentRun snapshot lacks terminal metadata".to_string() + })?; + match terminal.stop_reason.as_deref() { + Some( + reason @ (CODING_AGENT_STOP_REASON_MAX_TOKENS + | CODING_AGENT_STOP_REASON_MAX_TURN_REQUESTS + | CODING_AGENT_STOP_REASON_REFUSAL), + ) => { + if run.execution_state != CodingAgentExecutionState::Completed + || terminal.error_code.as_deref() != Some(reason) + { + return Err( + "failed CodingAgentRun ACP terminal truth is inconsistent".to_string() + ); + } + } + None => { + if !matches!( + run.execution_state, + CodingAgentExecutionState::NotStarted + | CodingAgentExecutionState::Completed + ) || terminal.error_code.is_none() + { + return Err( + "failed CodingAgentRun internal terminal truth is inconsistent" + .to_string(), + ); + } + } + Some(_) => { + return Err( + "CodingAgentRun snapshot has unknown or contradictory stop_reason" + .to_string(), + ) + } + } + } + CodingAgentRunState::Lost => { + let terminal = terminal_for_state(run, CodingAgentExecutionState::OutcomeUnknown)?; + if terminal.stop_reason.is_some() || terminal.error_code.is_none() { + return Err( + "lost CodingAgentRun snapshot claims definite terminal truth".to_string(), + ); + } + } + } + Ok(()) +} + +fn terminal_for_state( + run: &CodingAgentRunSnapshot, + execution_state: CodingAgentExecutionState, +) -> Result<&CodingAgentTerminal, String> { + if run.execution_state != execution_state { + return Err("terminal CodingAgentRun execution_state is inconsistent".to_string()); + } + run.terminal + .as_ref() + .ok_or_else(|| "terminal CodingAgentRun snapshot lacks terminal metadata".to_string()) +} + +fn validate_coding_agent_error(error: &CodingAgentError) -> Result<(), String> { + validate_low_cardinality_kind( + &error.code, + "CodingAgentRun error code", + CODING_AGENT_MAX_ERROR_KIND_BYTES, + )?; + if error.message.len() > CODING_AGENT_MAX_ERROR_MESSAGE_BYTES { + return Err("CodingAgentRun error message is too large".to_string()); + } + if let Some(failure_kind) = error.failure_kind.as_deref() { + validate_low_cardinality_kind( + failure_kind, + "CodingAgentRun failure kind", + CODING_AGENT_MAX_ERROR_KIND_BYTES, + )?; + } + if let Some(recovery_kind) = error.recovery_kind.as_deref() { + if !matches!( + recovery_kind, + "fix_input" + | "retry_same" + | "reobserve" + | "reconcile" + | "wait" + | "user_action" + | "none" + ) { + return Err("CodingAgentRun recovery kind is invalid".to_string()); + } + } + Ok(()) +} + +fn validate_low_cardinality_kind(value: &str, field: &str, max_bytes: usize) -> Result<(), String> { + if value.is_empty() + || value.len() > max_bytes + || !value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') + { + return Err(format!("{field} is invalid")); + } + Ok(()) +} + +fn validate_identifier( + value: &str, + field: &str, + max_bytes: usize, + lowercase_only: bool, +) -> Result<(), String> { + if value.is_empty() || value.len() > max_bytes { + return Err(format!("{field} must contain 1..={max_bytes} bytes")); + } + let valid = value.bytes().all(|byte| { + (if lowercase_only { + byte.is_ascii_lowercase() + } else { + byte.is_ascii_alphanumeric() + }) || byte.is_ascii_digit() + || matches!(byte, b'_' | b'-' | b'.') + }); + if !valid { + return Err(format!("{field} contains unsupported characters")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_request() -> CodingAgentRequest { + CodingAgentRequest::Start(CodingAgentStartRequest { + run_id: "wc_agent_run_0123456789abcdef".to_string(), + intent_fingerprint: "cafebabe".to_string(), + authority_fingerprint: "auth_0123456789abcdef".to_string(), + runtime_project_id: "agent:test:demo".to_string(), + project_root: "/tmp/demo".to_string(), + provider_id: "codex".to_string(), + provider_instance_id: "provider_123".to_string(), + instruction: "inspect the repository".to_string(), + config: BTreeMap::new(), + timeout_secs: 60, + }) + } + + #[test] + fn event_kind_model_vocabulary_is_exact_snake_case() { + let cases = [ + (CodingAgentEventKind::AgentMessage, "agent_message"), + (CodingAgentEventKind::Reasoning, "reasoning"), + (CodingAgentEventKind::Plan, "plan"), + (CodingAgentEventKind::ToolActivity, "tool_activity"), + (CodingAgentEventKind::FileChange, "file_change"), + (CodingAgentEventKind::TerminalActivity, "terminal_activity"), + (CodingAgentEventKind::Usage, "usage"), + ( + CodingAgentEventKind::PermissionRequest, + "permission_request", + ), + (CodingAgentEventKind::Terminal, "terminal"), + ]; + for (kind, expected) in cases { + assert_eq!(kind.as_str(), expected); + assert_eq!(serde_json::to_value(&kind).unwrap(), expected); + } + } + + #[test] + fn response_validation_closes_error_and_model_facing_string_bounds() { + let request = test_request(); + let valid_error = CodingAgentResponse::error( + CodingAgentDispatchState::NotStarted, + "coding_agent_unavailable", + "provider unavailable", + Some("unavailable"), + Some("reobserve"), + ); + validate_response_for_request(&request, &valid_error).unwrap(); + + let mut invalid = valid_error.clone(); + invalid.error.as_mut().unwrap().code = "PRIVATE arbitrary / path".to_string(); + assert!(validate_response_for_request(&request, &invalid).is_err()); + + let mut invalid = valid_error.clone(); + invalid.error.as_mut().unwrap().recovery_kind = Some("blind_retry".to_string()); + assert!(validate_response_for_request(&request, &invalid).is_err()); + + let mut invalid = valid_error; + invalid.error.as_mut().unwrap().message = + "x".repeat(CODING_AGENT_MAX_ERROR_MESSAGE_BYTES + 1); + assert!(validate_response_for_request(&request, &invalid).is_err()); + + let run = CodingAgentRunSnapshot { + run_id: request.run_id().to_string(), + intent_fingerprint: "cafebabe".to_string(), + authority_fingerprint: "auth_0123456789abcdef".to_string(), + runtime_project_id: "agent:test:demo".to_string(), + provider_id: "codex".to_string(), + provider_instance_id: "provider_123".to_string(), + state: CodingAgentRunState::Running, + execution_state: CodingAgentExecutionState::Started, + observation_revision: 1, + created_at: 1, + updated_at: 1, + terminal: None, + }; + let response = CodingAgentResponse::success(CodingAgentResponsePayload::Observe { + observation: CodingAgentObserveResult { + run, + events: vec![CodingAgentEvent { + sequence: 1, + kind: CodingAgentEventKind::ToolActivity, + text: None, + label: None, + status: Some("x".repeat(CODING_AGENT_MAX_EVENT_METADATA_BYTES + 1)), + usage: None, + }], + first_retained_sequence: 1, + next_sequence: 1, + has_more: false, + history_lost: false, + }, + }); + let observe_request = CodingAgentRequest::Observe(CodingAgentObserveRequest { + run_id: request.run_id().to_string(), + after_sequence: None, + limit: 8, + wait_secs: 0, + }); + assert!(validate_response_for_request(&observe_request, &response).is_err()); + } + + fn snapshot( + state: CodingAgentRunState, + execution_state: CodingAgentExecutionState, + stop_reason: Option<&str>, + error_code: Option<&str>, + ) -> CodingAgentRunSnapshot { + CodingAgentRunSnapshot { + run_id: "wc_agent_run_semantic_matrix".to_string(), + intent_fingerprint: "cafebabe".to_string(), + authority_fingerprint: "auth_0123456789abcdef".to_string(), + runtime_project_id: "agent:test:demo".to_string(), + provider_id: "codex".to_string(), + provider_instance_id: "provider_123".to_string(), + state, + execution_state, + observation_revision: 1, + created_at: 1, + updated_at: 1, + terminal: stop_reason.or(error_code).map(|_| CodingAgentTerminal { + stop_reason: stop_reason.map(str::to_string), + error_code: error_code.map(str::to_string), + message: None, + completed_at: 1, + }), + } + } + + #[test] + fn run_snapshot_semantic_matrix_is_closed_and_fail_closed() { + for valid in [ + snapshot( + CodingAgentRunState::Starting, + CodingAgentExecutionState::NotStarted, + None, + None, + ), + snapshot( + CodingAgentRunState::Running, + CodingAgentExecutionState::OutcomeUnknown, + None, + None, + ), + snapshot( + CodingAgentRunState::Running, + CodingAgentExecutionState::Started, + None, + None, + ), + snapshot( + CodingAgentRunState::WaitingPermission, + CodingAgentExecutionState::Started, + None, + None, + ), + snapshot( + CodingAgentRunState::Completed, + CodingAgentExecutionState::Completed, + Some(CODING_AGENT_STOP_REASON_END_TURN), + None, + ), + snapshot( + CodingAgentRunState::Cancelled, + CodingAgentExecutionState::Completed, + Some(CODING_AGENT_STOP_REASON_CANCELLED), + None, + ), + { + let mut run = snapshot( + CodingAgentRunState::Cancelled, + CodingAgentExecutionState::NotStarted, + None, + None, + ); + run.terminal = Some(CodingAgentTerminal { + stop_reason: None, + error_code: None, + message: Some("ACP prompt was not dispatched".to_string()), + completed_at: 1, + }); + run + }, + snapshot( + CodingAgentRunState::Failed, + CodingAgentExecutionState::Completed, + Some(CODING_AGENT_STOP_REASON_MAX_TOKENS), + Some(CODING_AGENT_STOP_REASON_MAX_TOKENS), + ), + snapshot( + CodingAgentRunState::Failed, + CodingAgentExecutionState::Completed, + Some(CODING_AGENT_STOP_REASON_MAX_TURN_REQUESTS), + Some(CODING_AGENT_STOP_REASON_MAX_TURN_REQUESTS), + ), + snapshot( + CodingAgentRunState::Failed, + CodingAgentExecutionState::Completed, + Some(CODING_AGENT_STOP_REASON_REFUSAL), + Some(CODING_AGENT_STOP_REASON_REFUSAL), + ), + snapshot( + CodingAgentRunState::Failed, + CodingAgentExecutionState::NotStarted, + None, + Some("setup_failed"), + ), + snapshot( + CodingAgentRunState::Failed, + CodingAgentExecutionState::Completed, + None, + Some("prompt_error"), + ), + snapshot( + CodingAgentRunState::Lost, + CodingAgentExecutionState::OutcomeUnknown, + None, + Some("coding_agent_transport_lost"), + ), + ] { + validate_coding_agent_run_snapshot(&valid).unwrap(); + } + + let invalid = [ + snapshot( + CodingAgentRunState::Completed, + CodingAgentExecutionState::Completed, + Some(CODING_AGENT_STOP_REASON_REFUSAL), + Some(CODING_AGENT_STOP_REASON_REFUSAL), + ), + snapshot( + CodingAgentRunState::Failed, + CodingAgentExecutionState::Completed, + Some(CODING_AGENT_STOP_REASON_END_TURN), + Some(CODING_AGENT_STOP_REASON_END_TURN), + ), + snapshot( + CodingAgentRunState::Cancelled, + CodingAgentExecutionState::Completed, + Some(CODING_AGENT_STOP_REASON_MAX_TOKENS), + Some(CODING_AGENT_STOP_REASON_MAX_TOKENS), + ), + snapshot( + CodingAgentRunState::Cancelled, + CodingAgentExecutionState::NotStarted, + Some(CODING_AGENT_STOP_REASON_CANCELLED), + None, + ), + snapshot( + CodingAgentRunState::Cancelled, + CodingAgentExecutionState::NotStarted, + None, + None, + ), + snapshot( + CodingAgentRunState::Lost, + CodingAgentExecutionState::OutcomeUnknown, + Some(CODING_AGENT_STOP_REASON_END_TURN), + None, + ), + snapshot( + CodingAgentRunState::Running, + CodingAgentExecutionState::Completed, + None, + None, + ), + snapshot( + CodingAgentRunState::WaitingPermission, + CodingAgentExecutionState::OutcomeUnknown, + None, + None, + ), + snapshot( + CodingAgentRunState::Failed, + CodingAgentExecutionState::Completed, + Some("future_stop_reason"), + Some("future_stop_reason"), + ), + ]; + for invalid in invalid { + assert!( + validate_coding_agent_run_snapshot(&invalid).is_err(), + "{invalid:?}" + ); + } + + let mut nonterminal_with_terminal = snapshot( + CodingAgentRunState::Running, + CodingAgentExecutionState::Started, + None, + None, + ); + nonterminal_with_terminal.terminal = Some(CodingAgentTerminal { + stop_reason: None, + error_code: Some("impossible".to_string()), + message: None, + completed_at: 1, + }); + assert!(validate_coding_agent_run_snapshot(&nonterminal_with_terminal).is_err()); + + let mut terminal_without_metadata = snapshot( + CodingAgentRunState::Completed, + CodingAgentExecutionState::Completed, + Some(CODING_AGENT_STOP_REASON_END_TURN), + None, + ); + terminal_without_metadata.terminal = None; + assert!(validate_coding_agent_run_snapshot(&terminal_without_metadata).is_err()); + } + + #[test] + fn response_validation_reuses_snapshot_semantics() { + let request = test_request(); + let mut contradictory = snapshot( + CodingAgentRunState::Completed, + CodingAgentExecutionState::Completed, + Some(CODING_AGENT_STOP_REASON_REFUSAL), + Some(CODING_AGENT_STOP_REASON_REFUSAL), + ); + contradictory.run_id = request.run_id().to_string(); + let response = + CodingAgentResponse::success(CodingAgentResponsePayload::Start { run: contradictory }); + assert!(validate_response_for_request(&request, &response).is_err()); + } + + #[test] + fn request_round_trip_is_closed_and_bounded() { + let request = CodingAgentRequest::Start(CodingAgentStartRequest { + run_id: "wc_agent_run_0123456789abcdef".to_string(), + intent_fingerprint: "cafebabe".to_string(), + authority_fingerprint: "auth_0123456789abcdef".to_string(), + runtime_project_id: "agent:test:demo".to_string(), + project_root: "/tmp/demo".to_string(), + provider_id: "codex".to_string(), + provider_instance_id: "provider_123".to_string(), + instruction: "inspect the repository".to_string(), + config: BTreeMap::new(), + timeout_secs: 60, + }); + validate_request(&request).unwrap(); + let json = serde_json::to_string(&request).unwrap(); + assert!(!json.contains("method")); + assert!(!json.contains("argv")); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + request + ); + } +} diff --git a/crates/webcodex-core/src/lib.rs b/crates/webcodex-core/src/lib.rs index eb16d664..ea836d32 100644 --- a/crates/webcodex-core/src/lib.rs +++ b/crates/webcodex-core/src/lib.rs @@ -3,6 +3,7 @@ pub mod apply_edits_shared; pub mod artifact_policy; pub mod build_info; +pub mod coding_agent; pub mod lsp_bridge; pub mod mcp_gateway; pub mod sensitive_paths; diff --git a/crates/webcodex-core/src/shell_protocol.rs b/crates/webcodex-core/src/shell_protocol.rs index 2f304ede..98cf2766 100644 --- a/crates/webcodex-core/src/shell_protocol.rs +++ b/crates/webcodex-core/src/shell_protocol.rs @@ -307,6 +307,7 @@ pub fn shell_computer_request_payload_max_bytes(kind: &str) -> usize { } } pub const SHELL_CLIENT_CAPABILITY_JOB_STATE_RECONCILIATION: &str = "job_state_reconciliation"; +pub const SHELL_CLIENT_CAPABILITY_CODING_AGENT_RUNS: &str = "coding_agent_runs"; pub const SHELL_CLIENT_CAPABILITY_NAMES: &[&str] = &[ SHELL_CLIENT_CAPABILITY_SHELL, SHELL_CLIENT_CAPABILITY_FILE_READ, @@ -346,6 +347,7 @@ pub const SHELL_CLIENT_CAPABILITY_NAMES: &[&str] = &[ SHELL_CLIENT_CAPABILITY_COMPUTER_ACCESSIBILITY_OBSERVE, SHELL_CLIENT_CAPABILITY_COMPUTER_ELEMENT_STATE, SHELL_CLIENT_CAPABILITY_JOB_STATE_RECONCILIATION, + SHELL_CLIENT_CAPABILITY_CODING_AGENT_RUNS, SHELL_CLIENT_CAPABILITY_COMPUTER_CONTROL, SHELL_CLIENT_CAPABILITY_COMPUTER_SCROLL_TO_ELEMENT, SHELL_CLIENT_CAPABILITY_COMPUTER_KEY_INPUT, @@ -558,6 +560,10 @@ pub struct ShellClientCapabilities { /// submits a complete active inventory at register/re-register time. #[serde(default, skip_serializing_if = "is_false")] pub job_state_reconciliation: bool, + /// Runner-owned ACP coding-agent execution with closed typed Run lifecycle. + /// Missing on older Runners is false and is never inferred from shell/MCP. + #[serde(default, skip_serializing_if = "is_false")] + pub coding_agent_runs: bool, } /// Bounded, non-secret status for the agent's active configuration generation. @@ -630,6 +636,7 @@ impl Default for ShellClientCapabilities { computer_window_activate: false, computer_text_input: false, job_state_reconciliation: false, + coding_agent_runs: false, } } } @@ -917,6 +924,14 @@ pub struct ShellClientRegisterRequest { /// absent for older runners. #[serde(default, skip_serializing_if = "Option::is_none")] pub job_inventory: Option, + /// Bounded non-secret startup-owned ACP provider inventory. Executable, + /// argv, environment, credentials, PID, and private ACP session ids are + /// never projected to the Server. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub coding_agent_providers: Option>, + /// Complete active plus bounded recent-terminal CodingAgentRun inventory. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub coding_agent_inventory: Option, } /// Non-secret runner build identity for mixed-version diagnostics. @@ -1054,6 +1069,11 @@ pub struct ShellClientView { pub connected: bool, pub last_seen: i64, pub capabilities: ShellClientCapabilities, + /// Bounded sanitized startup-owned ACP provider inventory. Logical ids are + /// model-visible planning metadata; executable/argv/env/PID/private ACP ids + /// never enter this view. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub coding_agent_providers: Option>, pub pending_requests: usize, #[serde(default)] pub projects: Vec, @@ -1514,6 +1534,10 @@ pub struct ShellAgentShellRequest { /// separate from shell/file fields and never carries arbitrary JSON-RPC. #[serde(default, skip_serializing_if = "Option::is_none")] pub mcp_gateway: Option, + /// Closed typed ACP CodingAgentRun operation. Raw ACP JSON-RPC stays local + /// to the Runner and is never accepted through this transport. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub coding_agent: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -1573,6 +1597,10 @@ pub struct ShellAgentResultPayload { /// `mcp_gateway` field was present. #[serde(default, skip_serializing_if = "Option::is_none")] pub mcp_gateway: Option, + /// Typed CodingAgentRun result. Present only for a request whose + /// `coding_agent` field was present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub coding_agent: Option, } impl From for ShellAgentResultPayload { @@ -1581,6 +1609,7 @@ impl From for ShellAgentResultPayload { result, command_execution_state: None, mcp_gateway: None, + coding_agent: None, } } } @@ -2801,6 +2830,7 @@ mod envelope_tests { sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, } } @@ -2862,6 +2892,7 @@ mod envelope_tests { sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, } } @@ -2988,12 +3019,15 @@ mod envelope_tests { computer_window_activate: false, computer_text_input: false, job_state_reconciliation: false, + coding_agent_runs: false, }), projects: None, agent_protocol_version: Some(AGENT_PROTOCOL_VERSION_WEBSOCKET_V1.to_string()), policy: None, job_concurrency_limit: Some(4), job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, } } @@ -3420,6 +3454,7 @@ mod envelope_tests { sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, }; let env = AgentEnvelope::Request { request }; @@ -3912,6 +3947,7 @@ mod envelope_tests { }, command_execution_state: Some(ShellCommandExecutionState::Completed), mcp_gateway: None, + coding_agent: None, }, }; let json = result_env.to_json().unwrap(); @@ -4266,6 +4302,8 @@ mod envelope_tests { policy: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, }, auth_token: Some("wc_agent_secret".to_string()), }; diff --git a/crates/webcodex-runner/Cargo.toml b/crates/webcodex-runner/Cargo.toml index 10781c41..a74f466c 100644 --- a/crates/webcodex-runner/Cargo.toml +++ b/crates/webcodex-runner/Cargo.toml @@ -5,6 +5,7 @@ license.workspace = true edition.workspace = true [dependencies] +agent-client-protocol-schema = { version = "1.7.0", default-features = false } webcodex-agent-config = { path = "../webcodex-agent-config" } webcodex-core = { path = "../webcodex-core" } webcodex-persistent-shell = { path = "../webcodex-persistent-shell" } diff --git a/crates/webcodex-runner/src/main.rs b/crates/webcodex-runner/src/main.rs index cb2bf1dd..ec0d5cfa 100644 --- a/crates/webcodex-runner/src/main.rs +++ b/crates/webcodex-runner/src/main.rs @@ -2132,7 +2132,13 @@ fn build_register_request_with_provider_status( u64, ) { let hot = runtime.snapshot(); - let capabilities = agent_register_capabilities(cfg); + let mut capabilities = agent_register_capabilities(cfg); + let coding_agent_providers = runtime + .coding_agents() + .map(|manager| manager.providers()) + .unwrap_or_default(); + let coding_agent_inventory = runtime.coding_agents().map(|manager| manager.inventory()); + capabilities.coding_agent_runs = !coding_agent_providers.is_empty(); let (mut tool_providers, revision) = hot.external_tools.registration_status(); tool_providers.config_reload = hot.reload_status(); ( @@ -2163,6 +2169,9 @@ fn build_register_request_with_provider_status( } else { Some(job_inventory) }, + coding_agent_providers: (!coding_agent_providers.is_empty()) + .then_some(coding_agent_providers), + coding_agent_inventory, }, Arc::clone(&hot.external_tools), revision, diff --git a/crates/webcodex-runner/src/main_tests.rs b/crates/webcodex-runner/src/main_tests.rs index d3564352..09d2942b 100644 --- a/crates/webcodex-runner/src/main_tests.rs +++ b/crates/webcodex-runner/src/main_tests.rs @@ -90,6 +90,7 @@ fn test_config(projects_dir: PathBuf) -> AgentConfig { quic: None, tool_providers: Default::default(), mcp_gateway: Default::default(), + acp: Default::default(), } } @@ -455,6 +456,7 @@ fn shell_job_request(cwd: &Path, command: &str) -> ShellAgentShellRequest { sandbox: None, job_context: Some(test_job_context(cwd, Vec::new())), mcp_gateway: None, + coding_agent: None, persistent_shell: None, } } @@ -550,6 +552,7 @@ fn json_file_op_request( sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, } } @@ -1238,6 +1241,7 @@ fn job_manager_stop_all_clears_queue_and_requests_running_stop() { sandbox: None, job_context: Some(test_job_context(tmp.path(), Vec::new())), mcp_gateway: None, + coding_agent: None, persistent_shell: None, }; let mut rejected_request = request.clone(); @@ -1338,6 +1342,7 @@ fn project_request(kind: &str, payload: serde_json::Value) -> ShellAgentShellReq sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, } } diff --git a/crates/webcodex-runner/src/main_tests/apply_text_edits.rs b/crates/webcodex-runner/src/main_tests/apply_text_edits.rs index 6b4146e6..8a86c4af 100644 --- a/crates/webcodex-runner/src/main_tests/apply_text_edits.rs +++ b/crates/webcodex-runner/src/main_tests/apply_text_edits.rs @@ -49,6 +49,7 @@ fn apply_text_edits_request( sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, } } diff --git a/crates/webcodex-runner/src/main_tests/dispatch_file.rs b/crates/webcodex-runner/src/main_tests/dispatch_file.rs index 1d61145e..a22ae581 100644 --- a/crates/webcodex-runner/src/main_tests/dispatch_file.rs +++ b/crates/webcodex-runner/src/main_tests/dispatch_file.rs @@ -100,6 +100,7 @@ fn dispatch_request_edit_routes_to_file_handler() { sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, }; let pdir = projects_dir(&cfg).unwrap(); diff --git a/crates/webcodex-runner/src/main_tests/dispatch_shell.rs b/crates/webcodex-runner/src/main_tests/dispatch_shell.rs index 7e9e1f49..f26e9f52 100644 --- a/crates/webcodex-runner/src/main_tests/dispatch_shell.rs +++ b/crates/webcodex-runner/src/main_tests/dispatch_shell.rs @@ -44,6 +44,7 @@ fn dispatch_request_run_shell_sends_result_over_sink() { sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, }; let ran = dispatch_request( @@ -115,6 +116,7 @@ fn dispatch_request_detached_process_job_enters_job_manager_without_generic_resu sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, }; @@ -197,6 +199,7 @@ fn dispatch_request_internal_search_uses_posix_runtime_not_configured_shell_pars sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, }; @@ -274,6 +277,7 @@ fn dispatch_request_internal_posix_script_ignores_configured_shell_parser() { sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, }; @@ -342,6 +346,7 @@ fn dispatch_request_run_shell_rejects_oversized_wire_command_before_start() { sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, }; @@ -444,6 +449,7 @@ fn dispatch_request_structured_process_uses_typed_argv_and_never_shell_fallback( sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, }; assert!(dispatch_request( @@ -500,6 +506,7 @@ fn dispatch_request_structured_process_uses_typed_argv_and_never_shell_fallback( sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, }; assert!(dispatch_request( @@ -575,6 +582,7 @@ fn dispatch_request_structured_script_uses_typed_file_and_never_shell_fallback() sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, }; let mut malformed = request.clone(); diff --git a/crates/webcodex-runner/src/main_tests/file_read.rs b/crates/webcodex-runner/src/main_tests/file_read.rs index 6e4e4b15..f21317dd 100644 --- a/crates/webcodex-runner/src/main_tests/file_read.rs +++ b/crates/webcodex-runner/src/main_tests/file_read.rs @@ -33,6 +33,7 @@ fn file_read_request( sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, } } diff --git a/crates/webcodex-runner/src/webcodex_runner/artifacts.rs b/crates/webcodex-runner/src/webcodex_runner/artifacts.rs index b0efd3e4..3c0420fb 100644 --- a/crates/webcodex-runner/src/webcodex_runner/artifacts.rs +++ b/crates/webcodex-runner/src/webcodex_runner/artifacts.rs @@ -2675,6 +2675,7 @@ mod tests { sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, } } diff --git a/crates/webcodex-runner/src/webcodex_runner/coding_agent.rs b/crates/webcodex-runner/src/webcodex_runner/coding_agent.rs new file mode 100644 index 00000000..cc2211da --- /dev/null +++ b/crates/webcodex-runner/src/webcodex_runner/coding_agent.rs @@ -0,0 +1,4904 @@ +use super::config::{AcpAgentConfig, AcpConfig}; +use super::projects::load_agent_project_summaries_from_dir; +use super::shell::canonicalize_existing; +use super::shutdown::{ActivityTracker, BackgroundThreads}; +use agent_client_protocol_schema::v1::{ + NewSessionResponse, PromptResponse, RequestPermissionRequest, SessionConfigKind, + SessionConfigOption, SessionConfigSelectOptions, SetSessionConfigOptionResponse, StopReason, +}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, HashMap, VecDeque}; +use std::fs::{self, File}; +use std::io::{BufRead, BufReader, Read, Write}; +#[cfg(windows)] +use std::os::windows::ffi::OsStrExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; +use std::sync::{Arc, Condvar, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; +use uuid::Uuid; +use webcodex_agent_config::paths::paths_equal; +#[cfg(all(test, unix))] +use webcodex_core::coding_agent::CodingAgentCancelRequest; +use webcodex_core::coding_agent::{ + validate_coding_agent_run_snapshot, validate_request, CodingAgentConfigValue, + CodingAgentDispatchState, CodingAgentEvent, CodingAgentEventKind, CodingAgentExecutionState, + CodingAgentObserveResult, CodingAgentProvider, CodingAgentRequest, CodingAgentResponse, + CodingAgentResponsePayload, CodingAgentRunInventory, CodingAgentRunSnapshot, + CodingAgentRunState, CodingAgentTerminal, CodingAgentUsage, + CODING_AGENT_MAX_EVENTS_PER_RESPONSE, CODING_AGENT_MAX_INVENTORY_RUNS, + CODING_AGENT_MAX_RETAINED_EVENTS, CODING_AGENT_STOP_REASON_CANCELLED, + CODING_AGENT_STOP_REASON_END_TURN, CODING_AGENT_STOP_REASON_MAX_TOKENS, + CODING_AGENT_STOP_REASON_MAX_TURN_REQUESTS, CODING_AGENT_STOP_REASON_REFUSAL, +}; +use webcodex_process::ManagedChild; +#[cfg(windows)] +use windows_sys::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, +}; + +const STORE_SCHEMA_VERSION: u32 = 1; +const STORE_FILE: &str = "state.json"; +const STORE_MAX_BYTES: usize = 64 * 1024; +const STORE_RETENTION_SECS: i64 = 15 * 60; +const ACP_MESSAGE_MAX_BYTES: usize = 1024 * 1024; +const ACP_SETUP_TIMEOUT: Duration = Duration::from_secs(30); +const ACP_CANCEL_GRACE: Duration = Duration::from_secs(5); +const ACP_POLL: Duration = Duration::from_millis(25); +const ACP_IO_CLEANUP_TIMEOUT: Duration = Duration::from_secs(2); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum DurableDispatchPhase { + BeforePromptBarrier, + PromptDispatchMayHaveOccurred, + Terminal, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct DurableRunRecord { + schema_version: u32, + run_id: String, + intent_fingerprint: String, + authority_fingerprint: String, + runtime_project_id: String, + provider_id: String, + provider_instance_id: String, + state: CodingAgentRunState, + execution_state: CodingAgentExecutionState, + dispatch_phase: DurableDispatchPhase, + created_at: i64, + updated_at: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + terminal: Option, +} + +impl DurableRunRecord { + fn snapshot(&self, observation_revision: u64) -> CodingAgentRunSnapshot { + CodingAgentRunSnapshot { + run_id: self.run_id.clone(), + intent_fingerprint: self.intent_fingerprint.clone(), + authority_fingerprint: self.authority_fingerprint.clone(), + runtime_project_id: self.runtime_project_id.clone(), + provider_id: self.provider_id.clone(), + provider_instance_id: self.provider_instance_id.clone(), + state: self.state.clone(), + execution_state: self.execution_state, + observation_revision, + created_at: self.created_at, + updated_at: self.updated_at, + terminal: self.terminal.clone(), + } + } +} + +#[derive(Debug, Clone)] +struct DurableRunStore { + root: PathBuf, +} + +impl DurableRunStore { + fn default_root(client_id: &str, server_url: &str) -> Result { + let server_url = server_url.trim().trim_end_matches('/'); + if client_id.trim().is_empty() || server_url.is_empty() { + return Err("ACP durable store requires non-empty Runner identity".to_string()); + } + let mut hasher = Sha256::new(); + hasher.update(b"webcodex-coding-agent-store-runner-v1\0"); + hasher.update(client_id.as_bytes()); + hasher.update(b"\0"); + hasher.update(server_url.as_bytes()); + let namespace = format!("{:x}", hasher.finalize()); + Ok( + webcodex_agent_config::paths::default_client_state_base_dir()? + .join("runner-coding-agent-runs-v1") + .join(namespace), + ) + } + + fn run_dir(&self, run_id: &str) -> PathBuf { + let mut hasher = Sha256::new(); + hasher.update(run_id.as_bytes()); + self.root.join(format!("{:x}", hasher.finalize())) + } + + fn state_path(&self, run_id: &str) -> PathBuf { + self.run_dir(run_id).join(STORE_FILE) + } + + fn read(&self, run_id: &str) -> Result, String> { + let path = self.state_path(run_id); + let mut file = match File::open(&path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return match fs::symlink_metadata(self.run_dir(run_id)) { + Ok(_) => Err( + "ACP Run state is missing from an existing Run state directory".to_string(), + ), + Err(dir_error) if dir_error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(dir_error) => { + Err(format!("failed to inspect ACP Run state dir: {dir_error}")) + } + }; + } + Err(error) => return Err(format!("failed to open ACP Run state: {error}")), + }; + let len = file + .metadata() + .map_err(|error| format!("failed to inspect ACP Run state: {error}"))? + .len() as usize; + if len == 0 || len > STORE_MAX_BYTES { + return Err("ACP Run state has invalid bounded size".to_string()); + } + let mut bytes = Vec::with_capacity(len); + file.read_to_end(&mut bytes) + .map_err(|error| format!("failed to read ACP Run state: {error}"))?; + let record: DurableRunRecord = + serde_json::from_slice(&bytes).map_err(|_| "ACP Run state is malformed".to_string())?; + validate_durable_record(&record)?; + if record.run_id != run_id + || self.run_dir(&record.run_id) != path.parent().unwrap_or(Path::new("")) + { + return Err("ACP Run state identity mismatch".to_string()); + } + Ok(Some(record)) + } + + fn write(&self, record: &DurableRunRecord) -> Result<(), String> { + validate_durable_record(record)?; + let bytes = + serde_json::to_vec(record).map_err(|_| "failed to encode ACP Run state".to_string())?; + if bytes.len() > STORE_MAX_BYTES { + return Err("ACP Run state exceeds durable bound".to_string()); + } + let dir = self.run_dir(&record.run_id); + fs::create_dir_all(&dir) + .map_err(|error| format!("failed to create ACP Run state dir: {error}"))?; + let temp = dir.join(format!("state.{}.tmp", Uuid::new_v4().simple())); + let state_path = self.state_path(&record.run_id); + let result = (|| { + let mut file = File::options() + .write(true) + .create_new(true) + .open(&temp) + .map_err(|error| format!("failed to create ACP Run temp state: {error}"))?; + file.write_all(&bytes) + .and_then(|_| file.sync_all()) + .map_err(|error| format!("failed to persist ACP Run state: {error}"))?; + drop(file); + publish_state_file(&temp, &state_path)?; + sync_parent(&dir)?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temp); + } + result + } + + fn scan(&self) -> Result, String> { + let entries = match fs::read_dir(&self.root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(format!("failed to list ACP Run state: {error}")), + }; + let mut records = Vec::new(); + let mut entry_count = 0usize; + for entry in entries { + let entry = + entry.map_err(|error| format!("failed to inspect ACP Run state: {error}"))?; + let ty = entry + .file_type() + .map_err(|error| format!("failed to inspect ACP Run state: {error}"))?; + if !ty.is_dir() || ty.is_symlink() { + return Err("ACP Run state root contains an unexpected entry".to_string()); + } + entry_count = entry_count.saturating_add(1); + if entry_count > CODING_AGENT_MAX_INVENTORY_RUNS { + return Err("ACP Run durable state exceeds bounded record count".to_string()); + } + let path = entry.path().join(STORE_FILE); + let bytes = match fs::read(&path) { + Ok(bytes) => bytes, + Err(error) => { + tracing::error!(state_path = %path.display(), error = %error, "ACP Run durable state unavailable during recovery; preserving tombstone"); + continue; + } + }; + if bytes.is_empty() || bytes.len() > STORE_MAX_BYTES { + tracing::error!(state_path = %path.display(), "ACP Run durable state has invalid bounded size; preserving tombstone"); + continue; + } + let record: DurableRunRecord = match serde_json::from_slice(&bytes) { + Ok(record) => record, + Err(_) => { + tracing::error!(state_path = %path.display(), "ACP Run durable state is malformed; preserving tombstone"); + continue; + } + }; + if let Err(error) = validate_durable_record(&record) { + tracing::error!(state_path = %path.display(), error = %error, "ACP Run durable state is invalid; preserving tombstone"); + continue; + } + if self.run_dir(&record.run_id) != entry.path() { + tracing::error!(state_path = %path.display(), "ACP Run durable state directory identity mismatch; preserving tombstone"); + continue; + } + records.push(record); + } + records.sort_by(|a, b| a.run_id.cmp(&b.run_id)); + Ok(records) + } + + fn remove(&self, run_id: &str) { + let _ = fs::remove_dir_all(self.run_dir(run_id)); + } +} + +#[cfg(unix)] +fn publish_state_file(temp: &Path, state_path: &Path) -> Result<(), String> { + fs::rename(temp, state_path) + .map_err(|error| format!("failed to publish ACP Run state: {error}")) +} + +#[cfg(windows)] +fn publish_state_file(temp: &Path, state_path: &Path) -> Result<(), String> { + let from = temp + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let to = state_path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let retry_deadline = Instant::now() + Duration::from_millis(500); + loop { + if unsafe { + MoveFileExW( + from.as_ptr(), + to.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + } != 0 + { + return Ok(()); + } + let error = std::io::Error::last_os_error(); + let retryable = matches!(error.raw_os_error(), Some(5) | Some(32) | Some(33)); + if !retryable || Instant::now() >= retry_deadline { + return Err(format!("failed to publish ACP Run state: {error}")); + } + thread::sleep(Duration::from_millis(10)); + } +} + +#[cfg(not(any(unix, windows)))] +fn publish_state_file(_temp: &Path, _state_path: &Path) -> Result<(), String> { + Err("ACP Run durable state is unsupported on this platform".to_string()) +} + +fn sync_parent(_dir: &Path) -> Result<(), String> { + #[cfg(unix)] + { + File::open(_dir) + .and_then(|file| file.sync_all()) + .map_err(|error| format!("failed to sync ACP Run state dir: {error}"))?; + } + Ok(()) +} + +fn validate_durable_record(record: &DurableRunRecord) -> Result<(), String> { + if record.schema_version != STORE_SCHEMA_VERSION { + return Err("ACP Run durable record schema is invalid".to_string()); + } + validate_coding_agent_run_snapshot(&record.snapshot(0)) + .map_err(|error| format!("ACP Run durable snapshot is invalid: {error}"))?; + if (record.dispatch_phase == DurableDispatchPhase::Terminal) != record.state.terminal() { + return Err("ACP Run durable phase/state terminal truth is inconsistent".to_string()); + } + Ok(()) +} + +#[derive(Debug)] +struct ProviderEntry { + config: AcpAgentConfig, + instance_id: String, +} + +#[derive(Debug)] +struct LiveRunState { + snapshot: CodingAgentRunSnapshot, + events: VecDeque, + first_retained_sequence: u64, + next_sequence: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PromptDispatchGateState { + PrePrompt, + PromptDispatchMayHaveOccurred, +} + +#[derive(Debug)] +struct RunEntry { + state: Mutex, + changed: Condvar, + cancel_requested: AtomicBool, + prompt_dispatch: Mutex, +} + +impl RunEntry { + fn new(snapshot: CodingAgentRunSnapshot) -> Self { + Self { + state: Mutex::new(LiveRunState { + snapshot, + events: VecDeque::new(), + first_retained_sequence: 1, + next_sequence: 1, + }), + changed: Condvar::new(), + cancel_requested: AtomicBool::new(false), + prompt_dispatch: Mutex::new(PromptDispatchGateState::PrePrompt), + } + } + + fn snapshot(&self) -> CodingAgentRunSnapshot { + self.state.lock().unwrap().snapshot.clone() + } + + fn update_snapshot(&self, mut update: impl FnMut(&mut CodingAgentRunSnapshot)) { + let mut state = self.state.lock().unwrap(); + update(&mut state.snapshot); + state.snapshot.observation_revision = state.snapshot.observation_revision.saturating_add(1); + state.snapshot.updated_at = now(); + self.changed.notify_all(); + } + + fn push_event(&self, mut event: CodingAgentEvent) { + let mut state = self.state.lock().unwrap(); + event.sequence = state.next_sequence; + state.next_sequence = state.next_sequence.saturating_add(1); + state.events.push_back(event); + while state.events.len() > CODING_AGENT_MAX_RETAINED_EVENTS { + state.events.pop_front(); + state.first_retained_sequence = state.first_retained_sequence.saturating_add(1); + } + state.snapshot.observation_revision = state.snapshot.observation_revision.saturating_add(1); + state.snapshot.updated_at = now(); + self.changed.notify_all(); + } + + fn observe( + &self, + after: Option, + limit: usize, + wait_secs: u64, + ) -> Result { + let deadline = Instant::now() + Duration::from_secs(wait_secs); + let mut state = self.state.lock().unwrap(); + let latest_emitted_sequence = state.next_sequence.saturating_sub(1); + if after.is_some_and(|sequence| sequence > latest_emitted_sequence) { + return Err(format!( + "CodingAgentRun observation cursor {sequence} is ahead of latest emitted sequence {latest_emitted_sequence}", + sequence = after.unwrap_or_default() + )); + } + loop { + let cursor = after.unwrap_or_else(|| state.first_retained_sequence.saturating_sub(1)); + let changed = + state.next_sequence > cursor.saturating_add(1) || state.snapshot.state.terminal(); + if changed || wait_secs == 0 { + break; + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + let (next, _) = self.changed.wait_timeout(state, remaining).unwrap(); + state = next; + } + let requested = after.unwrap_or_else(|| state.first_retained_sequence.saturating_sub(1)); + let history_lost = + after.is_some_and(|value| value.saturating_add(1) < state.first_retained_sequence); + let effective = requested.max(state.first_retained_sequence.saturating_sub(1)); + let mut events = state + .events + .iter() + .filter(|event| event.sequence > effective) + .take(limit) + .cloned() + .collect::>(); + let last = events + .last() + .map(|event| event.sequence) + .unwrap_or(effective); + let has_more = state.events.iter().any(|event| event.sequence > last); + if events.len() > CODING_AGENT_MAX_EVENTS_PER_RESPONSE { + events.truncate(CODING_AGENT_MAX_EVENTS_PER_RESPONSE); + } + Ok(CodingAgentObserveResult { + run: state.snapshot.clone(), + events, + first_retained_sequence: state.first_retained_sequence, + next_sequence: last, + has_more, + history_lost, + }) + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct CodingAgentWorkerDrain { + pub(crate) resources: usize, + pub(crate) timed_out: usize, + pub(crate) panicked: usize, +} + +#[derive(Debug)] +pub(crate) struct CodingAgentManager { + client_id: String, + providers: BTreeMap>, + max_concurrent_runs: usize, + permission_timeout: Duration, + store: DurableRunStore, + admission: Mutex<()>, + runs: Mutex>>, + accepting: AtomicBool, + workers: ActivityTracker, + worker_threads: BackgroundThreads, + #[cfg(test)] + admission_test_barrier: Mutex>>, + #[cfg(test)] + admission_after_accepting_test_barrier: Mutex>>, + #[cfg(test)] + admission_after_accepting_test_reached: AtomicBool, + #[cfg(test)] + prompt_dispatch_test_barrier: Mutex>>, + #[cfg(test)] + prompt_after_barrier_test_delay: Mutex>, + #[cfg(test)] + initial_claim_writes: std::sync::atomic::AtomicUsize, +} + +impl CodingAgentManager { + pub(crate) fn new( + config: &AcpConfig, + client_id: &str, + server_url: &str, + ) -> Result, String> { + let mut providers = BTreeMap::new(); + for provider in &config.agents { + providers.insert( + provider.id.clone(), + Arc::new(ProviderEntry { + config: provider.clone(), + instance_id: format!("acp_{}", Uuid::new_v4().simple()), + }), + ); + } + let manager = Arc::new(Self { + client_id: client_id.to_string(), + providers, + max_concurrent_runs: config.max_concurrent_runs, + permission_timeout: Duration::from_secs(config.permission_timeout_secs), + store: DurableRunStore { + root: DurableRunStore::default_root(client_id, server_url)?, + }, + admission: Mutex::new(()), + runs: Mutex::new(HashMap::new()), + accepting: AtomicBool::new(true), + workers: ActivityTracker::default(), + worker_threads: BackgroundThreads::default(), + #[cfg(test)] + admission_test_barrier: Mutex::new(None), + #[cfg(test)] + admission_after_accepting_test_barrier: Mutex::new(None), + #[cfg(test)] + admission_after_accepting_test_reached: AtomicBool::new(false), + #[cfg(test)] + prompt_dispatch_test_barrier: Mutex::new(None), + #[cfg(test)] + prompt_after_barrier_test_delay: Mutex::new(None), + #[cfg(test)] + initial_claim_writes: std::sync::atomic::AtomicUsize::new(0), + }); + manager.recover()?; + Ok(manager) + } + + #[cfg(test)] + fn with_store(config: &AcpConfig, root: PathBuf) -> Result, String> { + let mut providers = BTreeMap::new(); + for provider in &config.agents { + providers.insert( + provider.id.clone(), + Arc::new(ProviderEntry { + config: provider.clone(), + instance_id: format!("acp_{}", Uuid::new_v4().simple()), + }), + ); + } + let manager = Arc::new(Self { + client_id: "test".to_string(), + providers, + max_concurrent_runs: config.max_concurrent_runs, + permission_timeout: Duration::from_secs(config.permission_timeout_secs), + store: DurableRunStore { root }, + admission: Mutex::new(()), + runs: Mutex::new(HashMap::new()), + accepting: AtomicBool::new(true), + workers: ActivityTracker::default(), + worker_threads: BackgroundThreads::default(), + #[cfg(test)] + admission_test_barrier: Mutex::new(None), + #[cfg(test)] + admission_after_accepting_test_barrier: Mutex::new(None), + #[cfg(test)] + admission_after_accepting_test_reached: AtomicBool::new(false), + #[cfg(test)] + prompt_dispatch_test_barrier: Mutex::new(None), + #[cfg(test)] + prompt_after_barrier_test_delay: Mutex::new(None), + #[cfg(test)] + initial_claim_writes: std::sync::atomic::AtomicUsize::new(0), + }); + manager.recover()?; + Ok(manager) + } + + pub(crate) fn providers(&self) -> Vec { + self.providers + .values() + .map(|provider| CodingAgentProvider { + provider_id: provider.config.id.clone(), + provider_instance_id: provider.instance_id.clone(), + name: provider.config.name.clone(), + }) + .collect() + } + + pub(crate) fn inventory(&self) -> CodingAgentRunInventory { + self.cleanup_expired(); + let mut runs = self + .runs + .lock() + .unwrap() + .values() + .map(|entry| entry.snapshot()) + .collect::>(); + runs.sort_by(|a, b| a.run_id.cmp(&b.run_id)); + runs.truncate(CODING_AGENT_MAX_INVENTORY_RUNS); + CodingAgentRunInventory { runs } + } + + pub(crate) fn stop_accepting(&self) { + // Publish shutdown intent before taking the admission fence. A Start that + // already owns admission may finish publishing its authoritative RunEntry, + // but it cannot cross the prompt gate after this store becomes visible. + self.accepting.store(false, Ordering::Release); + let _admission = self.admission.lock().unwrap(); + let entries = self + .runs + .lock() + .unwrap() + .iter() + .map(|(run_id, entry)| (run_id.clone(), Arc::clone(entry))) + .collect::>(); + for (run_id, entry) in entries { + if !entry.snapshot().state.terminal() { + let _ = self.request_cancel(&run_id, &entry); + } + } + } + + pub(crate) fn worker_count(&self) -> usize { + self.workers.active().max(self.worker_threads.pending()) + } + + pub(crate) fn drain_workers_until(&self, deadline: Instant) -> CodingAgentWorkerDrain { + let resources = self.worker_count(); + let workers_done = self.workers.wait_until(deadline); + let joined = self.worker_threads.join_until(deadline); + CodingAgentWorkerDrain { + resources, + timed_out: joined.timed_out.max(usize::from(!workers_done)), + panicked: joined.panicked, + } + } + + pub(crate) fn handle( + self: &Arc, + request: CodingAgentRequest, + projects_dir: &Path, + ) -> CodingAgentResponse { + if let Err(error) = validate_request(&request) { + return response_error( + CodingAgentDispatchState::NotStarted, + "invalid_coding_agent_request", + error, + "invalid_input", + "fix_input", + ); + } + match request { + CodingAgentRequest::Start(request) => self.start(request, projects_dir), + CodingAgentRequest::Observe(request) => { + let entry = self.runs.lock().unwrap().get(&request.run_id).cloned(); + match entry { + Some(entry) => match entry.observe( + request.after_sequence, + request.limit, + request.wait_secs, + ) { + Ok(observation) => { + CodingAgentResponse::success(CodingAgentResponsePayload::Observe { + observation, + }) + } + Err(error) => response_error( + CodingAgentDispatchState::NotStarted, + "invalid_coding_agent_observation_cursor", + error, + "invalid_input", + "fix_input", + ), + }, + None => response_error( + CodingAgentDispatchState::NotStarted, + "unknown_coding_agent_run", + "CodingAgentRun is not retained by this Runner", + "not_found", + "reobserve", + ), + } + } + CodingAgentRequest::Cancel(request) => { + let entry = self.runs.lock().unwrap().get(&request.run_id).cloned(); + match entry { + Some(entry) => { + CodingAgentResponse::success(CodingAgentResponsePayload::Cancel { + run: self.request_cancel(&request.run_id, &entry), + }) + } + None => response_error( + CodingAgentDispatchState::NotStarted, + "unknown_coding_agent_run", + "CodingAgentRun is not retained by this Runner", + "not_found", + "reobserve", + ), + } + } + } + } + + fn request_cancel(&self, run_id: &str, entry: &Arc) -> CodingAgentRunSnapshot { + let prompt_gate = entry.prompt_dispatch.lock().unwrap(); + let current = entry.snapshot(); + if current.state.terminal() { + return current; + } + entry.cancel_requested.store(true, Ordering::Release); + entry.changed.notify_all(); + if *prompt_gate == PromptDispatchGateState::PrePrompt { + self.finish_pre_prompt_cancelled(run_id, entry); + } + entry.snapshot() + } + + fn finish_pre_prompt_cancelled(&self, run_id: &str, entry: &Arc) { + let terminal = CodingAgentTerminal { + stop_reason: None, + error_code: None, + message: Some("ACP prompt was not dispatched; CodingAgentRun was cancelled before prompt dispatch".to_string()), + completed_at: now(), + }; + let _ = self.persist_phase( + run_id, + entry, + DurableDispatchPhase::Terminal, + CodingAgentRunState::Cancelled, + CodingAgentExecutionState::NotStarted, + Some(terminal.clone()), + ); + entry.push_event(CodingAgentEvent { + sequence: 0, + kind: CodingAgentEventKind::Terminal, + text: terminal.message.clone(), + label: None, + status: Some("cancelled".to_string()), + usage: None, + }); + } + + fn pre_prompt_interrupted(&self, run_id: &str, entry: &Arc) -> bool { + if !self.accepting.load(Ordering::Acquire) && !entry.snapshot().state.terminal() { + let _ = self.request_cancel(run_id, entry); + } + entry.snapshot().state.terminal() + } + + fn setup_timeout(&self, run_id: &str, entry: &Arc) { + self.setup_failure( + run_id, + entry, + "coding_agent_setup_timeout", + "CodingAgentRun total deadline expired before ACP prompt dispatch", + ); + } + + fn pre_prompt_should_stop( + &self, + run_id: &str, + entry: &Arc, + run_deadline: Instant, + ) -> bool { + if self.pre_prompt_interrupted(run_id, entry) { + return true; + } + if remaining_run_budget(run_deadline).is_none() { + self.setup_timeout(run_id, entry); + return true; + } + false + } + + fn write_pre_prompt_frame( + &self, + run_id: &str, + entry: &Arc, + child: &mut ManagedChild, + outbound: &mut AcpOutboundWriter, + frame: std::io::Result>, + run_deadline: Instant, + failure_code: &str, + failure_message: &str, + ) -> bool { + let frame = match frame { + Ok(frame) => frame, + Err(error) => { + self.setup_failure( + run_id, + entry, + failure_code, + &format!("{failure_message}: {error}"), + ); + self.terminate_run_io(child, outbound); + return false; + } + }; + let pending = match outbound.start_frame(frame) { + Ok(pending) => pending, + Err(error) => { + if !self.pre_prompt_should_stop(run_id, entry, run_deadline) { + self.setup_failure( + run_id, + entry, + failure_code, + &format!("{failure_message}: {error}"), + ); + } + self.terminate_run_io(child, outbound); + return false; + } + }; + match wait_outbound_write( + pending, + run_deadline, + Some(&entry.cancel_requested), + Some(&self.accepting), + ) { + OutboundWriteOutcome::Written => true, + OutboundWriteOutcome::Failed(error) => { + if !self.pre_prompt_should_stop(run_id, entry, run_deadline) { + self.setup_failure( + run_id, + entry, + failure_code, + &format!("{failure_message}: {error}"), + ); + } + self.terminate_run_io(child, outbound); + false + } + OutboundWriteOutcome::Interrupted(OutboundInterruption::Deadline) => { + self.setup_timeout(run_id, entry); + self.terminate_run_io(child, outbound); + false + } + OutboundWriteOutcome::Interrupted( + OutboundInterruption::Cancelled | OutboundInterruption::Shutdown, + ) => { + let _ = self.pre_prompt_interrupted(run_id, entry); + self.terminate_run_io(child, outbound); + false + } + } + } + + fn terminate_run_io(&self, child: &mut ManagedChild, outbound: &mut AcpOutboundWriter) { + let _ = child.terminate_tree(); + outbound.close(); + let deadline = Instant::now() + ACP_IO_CLEANUP_TIMEOUT; + let _ = outbound.wait_finished_until(deadline); + let remaining = deadline.saturating_duration_since(Instant::now()); + if !remaining.is_zero() { + let _ = child.wait_tree_exit(remaining); + } + let _ = child.try_wait(); + let _ = self.worker_threads.reap_finished(); + } + + fn cleanup_run_io(&self, child: &mut ManagedChild, outbound: &mut AcpOutboundWriter) { + outbound.close(); + let graceful_deadline = Instant::now() + ACP_IO_CLEANUP_TIMEOUT; + loop { + if child.try_wait().ok().flatten().is_some() { + break; + } + if Instant::now() >= graceful_deadline { + let _ = child.terminate_tree(); + break; + } + thread::sleep(ACP_POLL); + } + let forced_deadline = Instant::now() + ACP_IO_CLEANUP_TIMEOUT; + let _ = outbound.wait_finished_until(forced_deadline); + let remaining = forced_deadline.saturating_duration_since(Instant::now()); + if !remaining.is_zero() { + let _ = child.wait_tree_exit(remaining); + } + let _ = child.try_wait(); + let _ = self.worker_threads.reap_finished(); + } + + fn write_post_prompt_frame( + &self, + run_id: &str, + entry: &Arc, + child: &mut ManagedChild, + outbound: &mut AcpOutboundWriter, + frame: std::io::Result>, + deadline: Instant, + observe_cancel: bool, + uncertainty_code: &str, + ) -> bool { + let frame = match frame { + Ok(frame) => frame, + Err(_) => { + self.terminate_run_io(child, outbound); + self.mark_lost(run_id, entry, uncertainty_code); + return false; + } + }; + let pending = match outbound.start_frame(frame) { + Ok(pending) => pending, + Err(_) => { + self.terminate_run_io(child, outbound); + self.mark_lost(run_id, entry, uncertainty_code); + return false; + } + }; + let cancelled = observe_cancel.then_some(&entry.cancel_requested); + match wait_outbound_write(pending, deadline, cancelled, Some(&self.accepting)) { + OutboundWriteOutcome::Written => true, + OutboundWriteOutcome::Failed(_) + | OutboundWriteOutcome::Interrupted( + OutboundInterruption::Cancelled + | OutboundInterruption::Shutdown + | OutboundInterruption::Deadline, + ) => { + self.terminate_run_io(child, outbound); + self.mark_lost(run_id, entry, uncertainty_code); + false + } + } + } + + fn start( + self: &Arc, + request: webcodex_core::coding_agent::CodingAgentStartRequest, + projects_dir: &Path, + ) -> CodingAgentResponse { + #[cfg(test)] + { + let barrier = self.admission_test_barrier.lock().unwrap().clone(); + if let Some(barrier) = barrier { + barrier.wait(); + } + } + // Admission is the authoritative process-local fence for both idempotent + // run identity and max_concurrent_runs. It intentionally ends once the + // durable BeforePromptBarrier claim and in-memory RunEntry both exist; + // provider execution is never serialized by this lock. + let admission = self.admission.lock().unwrap(); + if !self.accepting.load(Ordering::Acquire) { + return response_error( + CodingAgentDispatchState::NotStarted, + "coding_agent_stopping", + "Runner is stopping", + "unavailable", + "wait", + ); + } + #[cfg(test)] + { + self.admission_after_accepting_test_reached + .store(true, Ordering::SeqCst); + let barrier = self + .admission_after_accepting_test_barrier + .lock() + .unwrap() + .clone(); + if let Some(barrier) = barrier { + barrier.wait(); + } + } + self.cleanup_expired(); + if let Some(existing) = self.runs.lock().unwrap().get(&request.run_id).cloned() { + let snapshot = existing.snapshot(); + if snapshot.intent_fingerprint != request.intent_fingerprint { + return response_error( + CodingAgentDispatchState::NotStarted, + "idempotency_conflict", + "run_id already belongs to a different CodingAgentRun intent", + "invalid_input", + "fix_input", + ); + } + return CodingAgentResponse::success(CodingAgentResponsePayload::Start { + run: snapshot, + }); + } + let Some(provider) = self.providers.get(&request.provider_id).cloned() else { + return response_error( + CodingAgentDispatchState::NotStarted, + "coding_agent_provider_unavailable", + "configured ACP provider is unavailable", + "unavailable", + "reobserve", + ); + }; + if provider.instance_id != request.provider_instance_id { + return response_error( + CodingAgentDispatchState::NotStarted, + "stale_coding_agent_provider", + "ACP provider instance was replaced", + "stale_state", + "reobserve", + ); + } + if !project_binding_matches( + projects_dir, + &self.client_id, + &request.runtime_project_id, + &request.project_root, + ) { + return response_error( + CodingAgentDispatchState::NotStarted, + "stale_coding_agent_project", + "registered writable Project binding no longer matches start intent", + "stale_state", + "reobserve", + ); + } + let active = self + .runs + .lock() + .unwrap() + .values() + .filter(|entry| !entry.snapshot().state.terminal()) + .count(); + if active >= self.max_concurrent_runs { + return response_error( + CodingAgentDispatchState::NotStarted, + "coding_agent_capacity_full", + "Runner ACP concurrency is full", + "capacity", + "wait", + ); + } + let environment = match resolve_environment(&provider.config) { + Ok(environment) => environment, + Err(error) => { + return response_error( + CodingAgentDispatchState::NotStarted, + "coding_agent_environment_unavailable", + error, + "configuration", + "retry_same", + ) + } + }; + match self.store.read(&request.run_id) { + Ok(Some(record)) => { + let snapshot = record.snapshot(0); + if snapshot.intent_fingerprint != request.intent_fingerprint { + return response_error( + CodingAgentDispatchState::NotStarted, + "idempotency_conflict", + "durable run_id belongs to a different CodingAgentRun intent", + "invalid_input", + "fix_input", + ); + } + let entry = Arc::new(RunEntry::new(snapshot.clone())); + self.runs + .lock() + .unwrap() + .insert(request.run_id.clone(), entry); + return CodingAgentResponse::success(CodingAgentResponsePayload::Start { + run: snapshot, + }); + } + Ok(None) => {} + Err(error) => { + return response_error( + CodingAgentDispatchState::OutcomeUnknown, + "coding_agent_durable_state_unavailable", + error, + "durable_state_unavailable", + "reconcile", + ); + } + } + + let timestamp = now(); + let record = DurableRunRecord { + schema_version: STORE_SCHEMA_VERSION, + run_id: request.run_id.clone(), + intent_fingerprint: request.intent_fingerprint.clone(), + authority_fingerprint: request.authority_fingerprint.clone(), + runtime_project_id: request.runtime_project_id.clone(), + provider_id: request.provider_id.clone(), + provider_instance_id: request.provider_instance_id.clone(), + state: CodingAgentRunState::Starting, + execution_state: CodingAgentExecutionState::NotStarted, + dispatch_phase: DurableDispatchPhase::BeforePromptBarrier, + created_at: timestamp, + updated_at: timestamp, + terminal: None, + }; + #[cfg(test)] + self.initial_claim_writes + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if let Err(error) = self.store.write(&record) { + // Admission has not launched the ACP turn yet. Remove any partial + // directory/state residue so the documented retry_same response + // cannot later be mistaken for a previously dispatched Run. + self.store.remove(&request.run_id); + return response_error( + CodingAgentDispatchState::NotStarted, + "coding_agent_admission_persist_failed", + error, + "io", + "retry_same", + ); + } + let entry = Arc::new(RunEntry::new(record.snapshot(0))); + self.runs + .lock() + .unwrap() + .insert(request.run_id.clone(), Arc::clone(&entry)); + let worker_guard = self.workers.enter(); + drop(admission); + let _ = self.worker_threads.reap_finished(); + let run_id = request.run_id.clone(); + let thread_entry = Arc::clone(&entry); + let thread_manager = Arc::clone(self); + let (start_tx, start_rx) = mpsc::sync_channel(0); + let spawn_result = thread::Builder::new() + .name(format!( + "wc-acp-{}", + run_id.chars().take(24).collect::() + )) + .spawn(move || { + let _worker_guard = worker_guard; + if start_rx.recv().is_ok() { + thread_manager.run_turn(request, provider, environment, thread_entry); + } + }); + match spawn_result { + Ok(handle) => { + self.worker_threads.register(handle); + let _ = start_tx.send(()); + } + Err(error) => { + manager_finish_setup_failure( + self, + &run_id, + &entry, + "coding_agent_thread_spawn_failed", + &error.to_string(), + ); + } + } + CodingAgentResponse::success(CodingAgentResponsePayload::Start { + run: entry.snapshot(), + }) + } + + fn run_turn( + self: Arc, + request: webcodex_core::coding_agent::CodingAgentStartRequest, + provider: Arc, + environment: Vec<(String, std::ffi::OsString)>, + entry: Arc, + ) { + let run_deadline = Instant::now() + Duration::from_secs(request.timeout_secs); + if self.pre_prompt_should_stop(&request.run_id, &entry, run_deadline) { + return; + } + let mut command = Command::new(&provider.config.executable); + command.args(&provider.config.args).env_clear(); + for (key, value) in environment { + command.env(key, value); + } + command.current_dir(&request.project_root); + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = match ManagedChild::spawn(&mut command) { + Ok(child) => child, + Err(error) => { + self.setup_failure( + &request.run_id, + &entry, + "coding_agent_spawn_failed", + &error.to_string(), + ); + return; + } + }; + if self.pre_prompt_should_stop(&request.run_id, &entry, run_deadline) { + let _ = child.terminate_tree(); + let _ = child.wait(); + return; + } + let stdin = match child.child_mut().stdin.take() { + Some(stdin) => stdin, + None => { + self.setup_failure( + &request.run_id, + &entry, + "coding_agent_stdio_unavailable", + "ACP stdin unavailable", + ); + let _ = child.terminate_tree(); + return; + } + }; + let stdout = match child.child_mut().stdout.take() { + Some(stdout) => stdout, + None => { + self.setup_failure( + &request.run_id, + &entry, + "coding_agent_stdio_unavailable", + "ACP stdout unavailable", + ); + let _ = child.terminate_tree(); + return; + } + }; + let stderr = child.child_mut().stderr.take(); + if let Some(stderr) = stderr { + let _ = thread::Builder::new() + .name("wc-acp-stderr".to_string()) + .spawn(move || { + let _ = std::io::copy(&mut BufReader::new(stderr), &mut std::io::sink()); + }); + } + let mut outbound = match AcpOutboundWriter::spawn(stdin, &self.worker_threads) { + Ok(outbound) => outbound, + Err(error) => { + self.setup_failure( + &request.run_id, + &entry, + "coding_agent_writer_unavailable", + &error.to_string(), + ); + let _ = child.terminate_tree(); + let _ = child.wait_tree_exit(ACP_IO_CLEANUP_TIMEOUT); + let _ = child.try_wait(); + return; + } + }; + let (tx, rx) = mpsc::sync_channel(32); + let _reader = match thread::Builder::new() + .name("wc-acp-stdout".to_string()) + .spawn(move || { + let mut reader = BufReader::new(stdout); + loop { + let mut line = String::new(); + match reader.read_line(&mut line) { + Ok(0) => { + let _ = tx.send(ReaderEvent::Eof); + break; + } + Ok(_) if line.len() <= ACP_MESSAGE_MAX_BYTES => { + let value = serde_json::from_str::(&line) + .map(ReaderEvent::Message) + .unwrap_or(ReaderEvent::Malformed); + if tx.send(value).is_err() { + break; + } + } + Ok(_) => { + let _ = tx.send(ReaderEvent::TooLarge); + break; + } + Err(_) => { + let _ = tx.send(ReaderEvent::Io); + break; + } + } + } + }) { + Ok(handle) => handle, + Err(error) => { + self.setup_failure( + &request.run_id, + &entry, + "coding_agent_reader_unavailable", + &error.to_string(), + ); + self.terminate_run_io(&mut child, &mut outbound); + return; + } + }; + + let mut next_id = 1u64; + if self.pre_prompt_should_stop(&request.run_id, &entry, run_deadline) { + self.terminate_run_io(&mut child, &mut outbound); + return; + } + let initialize_id = next_id; + next_id += 1; + if !self.write_pre_prompt_frame( + &request.run_id, + &entry, + &mut child, + &mut outbound, + request_frame( + initialize_id, + "initialize", + json!({ + "protocolVersion": 1, + "clientCapabilities": {}, + "clientInfo": {"name":"webcodex-runner","version":env!("CARGO_PKG_VERSION")} + }), + ), + run_deadline, + "coding_agent_initialize_write_failed", + "failed to write initialize", + ) { + return; + } + let Some(initialize_wait) = bounded_setup_wait(run_deadline) else { + self.setup_timeout(&request.run_id, &entry); + self.terminate_run_io(&mut child, &mut outbound); + return; + }; + let initialize = match wait_response( + &rx, + initialize_id, + initialize_wait, + Some(&entry.cancel_requested), + ) { + Ok(value) => value, + Err(error) => { + if !self.pre_prompt_should_stop(&request.run_id, &entry, run_deadline) { + self.setup_failure( + &request.run_id, + &entry, + "coding_agent_initialize_failed", + &error, + ); + } + self.terminate_run_io(&mut child, &mut outbound); + return; + } + }; + if self.pre_prompt_should_stop(&request.run_id, &entry, run_deadline) { + self.terminate_run_io(&mut child, &mut outbound); + return; + } + if initialize.get("protocolVersion").and_then(Value::as_u64) != Some(1) { + self.setup_failure( + &request.run_id, + &entry, + "coding_agent_protocol_version_unsupported", + "ACP v1 was not negotiated", + ); + self.terminate_run_io(&mut child, &mut outbound); + return; + } + + if self.pre_prompt_should_stop(&request.run_id, &entry, run_deadline) { + self.terminate_run_io(&mut child, &mut outbound); + return; + } + + let new_id = next_id; + next_id += 1; + if !self.write_pre_prompt_frame( + &request.run_id, + &entry, + &mut child, + &mut outbound, + request_frame( + new_id, + "session/new", + json!({ + "cwd": request.project_root, + "mcpServers": [] + }), + ), + run_deadline, + "coding_agent_session_new_write_failed", + "failed to write session/new", + ) { + return; + } + let Some(session_new_wait) = bounded_setup_wait(run_deadline) else { + self.setup_timeout(&request.run_id, &entry); + self.terminate_run_io(&mut child, &mut outbound); + return; + }; + let new_value = + match wait_response(&rx, new_id, session_new_wait, Some(&entry.cancel_requested)) { + Ok(value) => value, + Err(error) => { + if !self.pre_prompt_should_stop(&request.run_id, &entry, run_deadline) { + self.setup_failure( + &request.run_id, + &entry, + "coding_agent_session_new_failed", + &error, + ); + } + self.terminate_run_io(&mut child, &mut outbound); + return; + } + }; + if self.pre_prompt_should_stop(&request.run_id, &entry, run_deadline) { + self.terminate_run_io(&mut child, &mut outbound); + return; + } + let new_session: NewSessionResponse = match serde_json::from_value(new_value) { + Ok(response) => response, + Err(_) => { + self.setup_failure( + &request.run_id, + &entry, + "coding_agent_session_new_invalid", + "invalid ACP session/new result", + ); + self.terminate_run_io(&mut child, &mut outbound); + return; + } + }; + let session_id = new_session.session_id.to_string(); + let mut advertised = new_session.config_options.unwrap_or_default(); + + for (key, value) in &request.config { + if self.pre_prompt_should_stop(&request.run_id, &entry, run_deadline) { + self.terminate_run_io(&mut child, &mut outbound); + return; + } + if !provider + .config + .allowed_config_options + .iter() + .any(|allowed| allowed == key) + { + self.setup_failure( + &request.run_id, + &entry, + "coding_agent_config_not_allowed", + "ACP config override is not operator-allowed", + ); + self.terminate_run_io(&mut child, &mut outbound); + return; + } + if !config_override_is_valid(&advertised, key, value) { + self.setup_failure( + &request.run_id, + &entry, + "coding_agent_config_invalid", + "ACP config override is not currently advertised/legal", + ); + self.terminate_run_io(&mut child, &mut outbound); + return; + } + let config_id = next_id; + next_id += 1; + let params = match config_params(&session_id, key, value) { + Some(params) => params, + None => { + self.setup_failure( + &request.run_id, + &entry, + "coding_agent_config_invalid", + "ACP config value type is unsupported by stable v1", + ); + self.terminate_run_io(&mut child, &mut outbound); + return; + } + }; + if !self.write_pre_prompt_frame( + &request.run_id, + &entry, + &mut child, + &mut outbound, + request_frame(config_id, "session/set_config_option", params), + run_deadline, + "coding_agent_config_write_failed", + "failed to write session/set_config_option", + ) { + return; + } + let Some(config_wait) = bounded_setup_wait(run_deadline) else { + self.setup_timeout(&request.run_id, &entry); + self.terminate_run_io(&mut child, &mut outbound); + return; + }; + let result = + match wait_response(&rx, config_id, config_wait, Some(&entry.cancel_requested)) { + Ok(result) => result, + Err(error) => { + if !self.pre_prompt_should_stop(&request.run_id, &entry, run_deadline) { + self.setup_failure( + &request.run_id, + &entry, + "coding_agent_config_failed", + &error, + ); + } + self.terminate_run_io(&mut child, &mut outbound); + return; + } + }; + if self.pre_prompt_should_stop(&request.run_id, &entry, run_deadline) { + self.terminate_run_io(&mut child, &mut outbound); + return; + } + let refreshed: SetSessionConfigOptionResponse = match serde_json::from_value(result) { + Ok(result) => result, + Err(_) => { + self.setup_failure( + &request.run_id, + &entry, + "coding_agent_config_invalid_response", + "invalid refreshed ACP config options", + ); + self.terminate_run_io(&mut child, &mut outbound); + return; + } + }; + advertised = refreshed.config_options; + if !config_override_is_current(&advertised, key, value) { + self.setup_failure( + &request.run_id, + &entry, + "coding_agent_config_not_applied", + "ACP config override was not reflected by provider", + ); + self.terminate_run_io(&mut child, &mut outbound); + return; + } + } + + #[cfg(test)] + { + let barrier = self.prompt_dispatch_test_barrier.lock().unwrap().clone(); + if let Some(barrier) = barrier { + barrier.wait(); + } + } + let prompt_id = next_id; + let prompt_frame = match request_frame( + prompt_id, + "session/prompt", + json!({ + "sessionId": session_id, + "prompt": [{"type":"text","text":request.instruction}] + }), + ) { + Ok(frame) => frame, + Err(error) => { + self.setup_failure( + &request.run_id, + &entry, + "coding_agent_prompt_write_failed", + &error.to_string(), + ); + self.terminate_run_io(&mut child, &mut outbound); + return; + } + }; + let mut prompt_gate = entry.prompt_dispatch.lock().unwrap(); + if entry.snapshot().state.terminal() + || entry.cancel_requested.load(Ordering::Acquire) + || !self.accepting.load(Ordering::Acquire) + { + if !entry.snapshot().state.terminal() { + entry.cancel_requested.store(true, Ordering::Release); + self.finish_pre_prompt_cancelled(&request.run_id, &entry); + } + drop(prompt_gate); + self.terminate_run_io(&mut child, &mut outbound); + return; + } + + if remaining_run_budget(run_deadline).is_none() { + self.setup_timeout(&request.run_id, &entry); + drop(prompt_gate); + self.terminate_run_io(&mut child, &mut outbound); + return; + } + + // Irreversible uncertainty barrier: durable state is committed before the + // first byte of session/prompt can be handed to the sole stdin writer. + if let Err(error) = self.persist_phase( + &request.run_id, + &entry, + DurableDispatchPhase::PromptDispatchMayHaveOccurred, + CodingAgentRunState::Running, + CodingAgentExecutionState::OutcomeUnknown, + None, + ) { + self.setup_failure( + &request.run_id, + &entry, + "coding_agent_dispatch_barrier_failed", + &error, + ); + drop(prompt_gate); + self.terminate_run_io(&mut child, &mut outbound); + return; + } + #[cfg(test)] + { + let delay = *self.prompt_after_barrier_test_delay.lock().unwrap(); + if let Some(delay) = delay { + thread::sleep(delay); + } + } + if remaining_run_budget(run_deadline).is_none() { + self.setup_timeout(&request.run_id, &entry); + drop(prompt_gate); + self.terminate_run_io(&mut child, &mut outbound); + return; + } + // Shutdown intent may become visible while the durable uncertainty barrier + // is being written. The gate is still pre-prompt, so overwrite the durable + // barrier with truthful cancelled/not_started state before writer handoff. + if !self.accepting.load(Ordering::Acquire) { + entry.cancel_requested.store(true, Ordering::Release); + self.finish_pre_prompt_cancelled(&request.run_id, &entry); + drop(prompt_gate); + self.terminate_run_io(&mut child, &mut outbound); + return; + } + + // Mark possible dispatch before the writer can consume any prompt byte. + // If the bounded queue handoff itself fails, no prompt byte was writable, + // so restore the in-memory gate while it is still exclusively held. + *prompt_gate = PromptDispatchGateState::PromptDispatchMayHaveOccurred; + let prompt_pending = match outbound.start_frame(prompt_frame) { + Ok(pending) => pending, + Err(error) => { + *prompt_gate = PromptDispatchGateState::PrePrompt; + self.setup_failure( + &request.run_id, + &entry, + "coding_agent_prompt_write_failed", + &error, + ); + drop(prompt_gate); + self.terminate_run_io(&mut child, &mut outbound); + return; + } + }; + // The authoritative possible-dispatch boundary is the successful writer + // handoff. Never retain this gate while waiting on ChildStdin backpressure. + drop(prompt_gate); + match wait_outbound_write( + prompt_pending, + run_deadline, + Some(&entry.cancel_requested), + Some(&self.accepting), + ) { + OutboundWriteOutcome::Written => { + entry.update_snapshot(|snapshot| { + snapshot.execution_state = CodingAgentExecutionState::Started + }); + let _ = self.persist_from_entry( + &request.run_id, + &entry, + DurableDispatchPhase::PromptDispatchMayHaveOccurred, + ); + } + OutboundWriteOutcome::Failed(_) + | OutboundWriteOutcome::Interrupted( + OutboundInterruption::Cancelled + | OutboundInterruption::Shutdown + | OutboundInterruption::Deadline, + ) => { + self.terminate_run_io(&mut child, &mut outbound); + self.mark_lost( + &request.run_id, + &entry, + "coding_agent_prompt_write_uncertain", + ); + return; + } + } + + let mut cancel_sent = false; + let mut cancel_deadline = None; + loop { + if entry.cancel_requested.load(Ordering::Acquire) + || Instant::now() >= run_deadline + || !self.accepting.load(Ordering::Acquire) + { + if !cancel_sent { + let deadline = + *cancel_deadline.get_or_insert_with(|| Instant::now() + ACP_CANCEL_GRACE); + if !self.write_post_prompt_frame( + &request.run_id, + &entry, + &mut child, + &mut outbound, + notification_frame("session/cancel", json!({"sessionId":session_id})), + deadline, + false, + "coding_agent_cancel_write_uncertain", + ) { + return; + } + cancel_sent = true; + } + } + if cancel_deadline.is_some_and(|deadline| Instant::now() >= deadline) { + self.terminate_run_io(&mut child, &mut outbound); + self.mark_lost( + &request.run_id, + &entry, + "coding_agent_cancel_terminal_missing", + ); + return; + } + match rx.recv_timeout(ACP_POLL) { + Ok(ReaderEvent::Message(message)) => { + if message.get("method").and_then(Value::as_str) == Some("session/update") { + if let Some(event) = normalize_update(&message) { + entry.push_event(event); + } + continue; + } + if message.get("method").and_then(Value::as_str) + == Some("session/request_permission") + { + let Some(id) = message.get("id").and_then(Value::as_u64) else { + self.terminate_run_io(&mut child, &mut outbound); + self.mark_lost( + &request.run_id, + &entry, + "coding_agent_permission_id_invalid", + ); + return; + }; + let params = message.get("params").cloned().unwrap_or(Value::Null); + if serde_json::from_value::(params.clone()) + .is_err() + { + self.terminate_run_io(&mut child, &mut outbound); + self.mark_lost( + &request.run_id, + &entry, + "coding_agent_permission_invalid", + ); + return; + } + entry.push_event(permission_event(¶ms)); + entry.update_snapshot(|snapshot| { + snapshot.state = CodingAgentRunState::WaitingPermission + }); + let permission_deadline = + (Instant::now() + self.permission_timeout).min(run_deadline); + while Instant::now() < permission_deadline + && !entry.cancel_requested.load(Ordering::Acquire) + && self.accepting.load(Ordering::Acquire) + { + thread::sleep(ACP_POLL); + } + let lifecycle_interrupted = entry.cancel_requested.load(Ordering::Acquire) + || Instant::now() >= run_deadline + || !self.accepting.load(Ordering::Acquire); + let response_deadline = if lifecycle_interrupted { + *cancel_deadline + .get_or_insert_with(|| Instant::now() + ACP_CANCEL_GRACE) + } else { + (Instant::now() + ACP_CANCEL_GRACE).min(run_deadline) + }; + // P1 never selects an allow/reject option. Cancelled is + // the only fail-closed ACP outcome emitted by WebCodex. + if !self.write_post_prompt_frame( + &request.run_id, + &entry, + &mut child, + &mut outbound, + result_frame(id, json!({"outcome":{"outcome":"cancelled"}})), + response_deadline, + !lifecycle_interrupted, + "coding_agent_permission_response_uncertain", + ) { + return; + } + entry.update_snapshot(|snapshot| { + snapshot.state = CodingAgentRunState::Running + }); + if (entry.cancel_requested.load(Ordering::Acquire) + || Instant::now() >= run_deadline + || !self.accepting.load(Ordering::Acquire)) + && !cancel_sent + { + let deadline = *cancel_deadline + .get_or_insert_with(|| Instant::now() + ACP_CANCEL_GRACE); + if !self.write_post_prompt_frame( + &request.run_id, + &entry, + &mut child, + &mut outbound, + notification_frame( + "session/cancel", + json!({"sessionId":session_id}), + ), + deadline, + false, + "coding_agent_cancel_write_uncertain", + ) { + return; + } + cancel_sent = true; + } + continue; + } + if message.get("method").is_some() { + let deadline = *cancel_deadline + .get_or_insert_with(|| Instant::now() + ACP_CANCEL_GRACE); + if let Some(id) = message.get("id").and_then(Value::as_u64) { + if !self.write_post_prompt_frame( + &request.run_id, + &entry, + &mut child, + &mut outbound, + error_frame(id, -32601, "unsupported ACP client request"), + deadline, + true, + "coding_agent_transport_lost", + ) { + return; + } + } + if !cancel_sent { + if !self.write_post_prompt_frame( + &request.run_id, + &entry, + &mut child, + &mut outbound, + notification_frame( + "session/cancel", + json!({"sessionId":session_id}), + ), + deadline, + false, + "coding_agent_cancel_write_uncertain", + ) { + return; + } + cancel_sent = true; + } + continue; + } + if message.get("id").and_then(Value::as_u64) == Some(prompt_id) { + if let Some(error) = message.get("error") { + self.finish_failed( + &request.run_id, + &entry, + "prompt_error", + bounded_json_summary(error), + ); + self.cleanup_run_io(&mut child, &mut outbound); + return; + } + let Some(result) = message.get("result").cloned() else { + self.finish_failed( + &request.run_id, + &entry, + "invalid_prompt_response", + "missing prompt result".to_string(), + ); + self.cleanup_run_io(&mut child, &mut outbound); + return; + }; + let response: PromptResponse = match serde_json::from_value(result) { + Ok(response) => response, + Err(_) => { + self.finish_failed( + &request.run_id, + &entry, + "unknown_stop_reason", + "invalid or unknown ACP stopReason".to_string(), + ); + self.cleanup_run_io(&mut child, &mut outbound); + return; + } + }; + match response.stop_reason { + StopReason::EndTurn => self.finish_terminal( + &request.run_id, + &entry, + CodingAgentRunState::Completed, + CODING_AGENT_STOP_REASON_END_TURN, + None, + ), + StopReason::Cancelled => self.finish_terminal( + &request.run_id, + &entry, + CodingAgentRunState::Cancelled, + CODING_AGENT_STOP_REASON_CANCELLED, + None, + ), + StopReason::MaxTokens => self.finish_terminal( + &request.run_id, + &entry, + CodingAgentRunState::Failed, + CODING_AGENT_STOP_REASON_MAX_TOKENS, + Some("ACP turn reached max tokens"), + ), + StopReason::MaxTurnRequests => self.finish_terminal( + &request.run_id, + &entry, + CodingAgentRunState::Failed, + CODING_AGENT_STOP_REASON_MAX_TURN_REQUESTS, + Some("ACP turn reached max requests"), + ), + StopReason::Refusal => self.finish_terminal( + &request.run_id, + &entry, + CodingAgentRunState::Failed, + CODING_AGENT_STOP_REASON_REFUSAL, + Some("ACP agent refused the turn"), + ), + _ => self.finish_failed( + &request.run_id, + &entry, + "unknown_stop_reason", + "unknown ACP stop reason".to_string(), + ), + } + self.cleanup_run_io(&mut child, &mut outbound); + return; + } + } + Ok( + ReaderEvent::Eof + | ReaderEvent::Malformed + | ReaderEvent::TooLarge + | ReaderEvent::Io, + ) => { + self.terminate_run_io(&mut child, &mut outbound); + self.mark_lost(&request.run_id, &entry, "coding_agent_transport_lost"); + return; + } + Err(RecvTimeoutError::Timeout) => { + if child.try_wait().ok().flatten().is_some() { + outbound.close(); + let _ = + outbound.wait_finished_until(Instant::now() + ACP_IO_CLEANUP_TIMEOUT); + let _ = self.worker_threads.reap_finished(); + self.mark_lost(&request.run_id, &entry, "coding_agent_process_exited"); + return; + } + } + Err(RecvTimeoutError::Disconnected) => { + self.terminate_run_io(&mut child, &mut outbound); + self.mark_lost(&request.run_id, &entry, "coding_agent_transport_lost"); + return; + } + } + } + } + + fn recover(&self) -> Result<(), String> { + let mut map = self.runs.lock().unwrap(); + for mut record in self.store.scan()? { + if record.dispatch_phase != DurableDispatchPhase::Terminal { + let message = if record.dispatch_phase + == DurableDispatchPhase::PromptDispatchMayHaveOccurred + { + "Runner restarted after prompt dispatch uncertainty barrier" + } else { + "Runner restarted before prompt dispatch barrier" + }; + let state = if record.dispatch_phase + == DurableDispatchPhase::PromptDispatchMayHaveOccurred + { + CodingAgentRunState::Lost + } else { + CodingAgentRunState::Failed + }; + let execution = if state == CodingAgentRunState::Lost { + CodingAgentExecutionState::OutcomeUnknown + } else { + CodingAgentExecutionState::NotStarted + }; + record.state = state.clone(); + record.execution_state = execution; + record.dispatch_phase = DurableDispatchPhase::Terminal; + record.updated_at = now(); + record.terminal = Some(CodingAgentTerminal { + stop_reason: None, + error_code: Some( + if state == CodingAgentRunState::Lost { + "runner_restart_uncertain" + } else { + "runner_restart_not_started" + } + .to_string(), + ), + message: Some(message.to_string()), + completed_at: now(), + }); + self.store.write(&record)?; + } + map.insert( + record.run_id.clone(), + Arc::new(RunEntry::new(record.snapshot(0))), + ); + } + Ok(()) + } + + fn cleanup_expired(&self) { + let cutoff = now().saturating_sub(STORE_RETENTION_SECS); + let expired = { + let map = self.runs.lock().unwrap(); + map.iter() + .filter(|(_, entry)| { + let snapshot = entry.snapshot(); + snapshot.state.terminal() && snapshot.updated_at < cutoff + }) + .map(|(run_id, _)| run_id.clone()) + .collect::>() + }; + if expired.is_empty() { + return; + } + let mut map = self.runs.lock().unwrap(); + for run_id in expired { + map.remove(&run_id); + self.store.remove(&run_id); + } + } + + fn setup_failure(&self, run_id: &str, entry: &Arc, code: &str, message: &str) { + let terminal = CodingAgentTerminal { + stop_reason: None, + error_code: Some(code.to_string()), + message: Some(bounded_text(message)), + completed_at: now(), + }; + let _ = self.persist_phase( + run_id, + entry, + DurableDispatchPhase::Terminal, + CodingAgentRunState::Failed, + CodingAgentExecutionState::NotStarted, + Some(terminal.clone()), + ); + entry.push_event(CodingAgentEvent { + sequence: 0, + kind: CodingAgentEventKind::Terminal, + text: terminal.message.clone(), + label: terminal.error_code.clone(), + status: Some("failed".to_string()), + usage: None, + }); + } + + fn finish_failed(&self, run_id: &str, entry: &Arc, code: &str, message: String) { + let terminal = CodingAgentTerminal { + stop_reason: None, + error_code: Some(code.to_string()), + message: Some(bounded_text(&message)), + completed_at: now(), + }; + let _ = self.persist_phase( + run_id, + entry, + DurableDispatchPhase::Terminal, + CodingAgentRunState::Failed, + CodingAgentExecutionState::Completed, + Some(terminal.clone()), + ); + entry.push_event(CodingAgentEvent { + sequence: 0, + kind: CodingAgentEventKind::Terminal, + text: terminal.message.clone(), + label: terminal.error_code.clone(), + status: Some("failed".to_string()), + usage: None, + }); + } + + fn finish_terminal( + &self, + run_id: &str, + entry: &Arc, + state: CodingAgentRunState, + stop_reason: &str, + message: Option<&str>, + ) { + let terminal = CodingAgentTerminal { + stop_reason: Some(stop_reason.to_string()), + error_code: if state == CodingAgentRunState::Failed { + Some(stop_reason.to_string()) + } else { + None + }, + message: message.map(bounded_text), + completed_at: now(), + }; + let _ = self.persist_phase( + run_id, + entry, + DurableDispatchPhase::Terminal, + state.clone(), + CodingAgentExecutionState::Completed, + Some(terminal.clone()), + ); + entry.push_event(CodingAgentEvent { + sequence: 0, + kind: CodingAgentEventKind::Terminal, + text: message.map(bounded_text), + label: Some(stop_reason.to_string()), + status: Some(format!("{:?}", state).to_ascii_lowercase()), + usage: None, + }); + } + + fn mark_lost(&self, run_id: &str, entry: &Arc, code: &str) { + let terminal = CodingAgentTerminal { + stop_reason: None, + error_code: Some(code.to_string()), + message: Some( + "ACP prompt outcome is unknown; prompt must not be redispatched".to_string(), + ), + completed_at: now(), + }; + let _ = self.persist_phase( + run_id, + entry, + DurableDispatchPhase::Terminal, + CodingAgentRunState::Lost, + CodingAgentExecutionState::OutcomeUnknown, + Some(terminal.clone()), + ); + entry.push_event(CodingAgentEvent { + sequence: 0, + kind: CodingAgentEventKind::Terminal, + text: terminal.message.clone(), + label: terminal.error_code.clone(), + status: Some("lost".to_string()), + usage: None, + }); + } + + fn persist_phase( + &self, + run_id: &str, + entry: &Arc, + phase: DurableDispatchPhase, + state: CodingAgentRunState, + execution_state: CodingAgentExecutionState, + terminal: Option, + ) -> Result<(), String> { + entry.update_snapshot(|snapshot| { + snapshot.state = state.clone(); + snapshot.execution_state = execution_state; + snapshot.terminal = terminal.clone(); + }); + self.persist_from_entry(run_id, entry, phase) + } + + fn persist_from_entry( + &self, + run_id: &str, + entry: &Arc, + phase: DurableDispatchPhase, + ) -> Result<(), String> { + let snapshot = entry.snapshot(); + let record = DurableRunRecord { + schema_version: STORE_SCHEMA_VERSION, + run_id: snapshot.run_id, + intent_fingerprint: snapshot.intent_fingerprint, + authority_fingerprint: snapshot.authority_fingerprint, + runtime_project_id: snapshot.runtime_project_id, + provider_id: snapshot.provider_id, + provider_instance_id: snapshot.provider_instance_id, + state: snapshot.state, + execution_state: snapshot.execution_state, + dispatch_phase: phase, + created_at: snapshot.created_at, + updated_at: snapshot.updated_at, + terminal: snapshot.terminal, + }; + if record.run_id != run_id { + return Err("ACP Run identity changed unexpectedly".to_string()); + } + self.store.write(&record) + } +} + +fn manager_finish_setup_failure( + manager: &Arc, + run_id: &str, + entry: &Arc, + code: &str, + message: &str, +) { + manager.setup_failure(run_id, entry, code, message); +} + +struct OutboundWriteRequest { + frame: Vec, + completion: mpsc::SyncSender>, +} + +struct PendingOutboundWrite { + completion: Receiver>, +} + +struct AcpOutboundWriter { + requests: Option>, + finished: Arc<(Mutex, Condvar)>, +} + +struct OutboundWriterFinished(Arc<(Mutex, Condvar)>); + +impl Drop for OutboundWriterFinished { + fn drop(&mut self) { + let (finished, changed) = &*self.0; + let mut finished = finished + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *finished = true; + changed.notify_all(); + } +} + +impl AcpOutboundWriter { + fn spawn( + mut sink: W, + threads: &BackgroundThreads, + ) -> std::io::Result { + let (requests, receiver) = mpsc::sync_channel::(1); + let finished = Arc::new((Mutex::new(false), Condvar::new())); + let thread_finished = Arc::clone(&finished); + let handle = thread::Builder::new() + .name("wc-acp-stdin".to_string()) + .spawn(move || { + let _finished = OutboundWriterFinished(thread_finished); + while let Ok(request) = receiver.recv() { + let result = sink + .write_all(&request.frame) + .and_then(|_| sink.flush()) + .map_err(|error| error.to_string()); + let failed = result.is_err(); + let _ = request.completion.send(result); + if failed { + break; + } + } + })?; + threads.register(handle); + Ok(Self { + requests: Some(requests), + finished, + }) + } + + fn start_frame(&self, frame: Vec) -> Result { + let requests = self + .requests + .as_ref() + .ok_or_else(|| "ACP outbound writer is closed".to_string())?; + let (completion, receiver) = mpsc::sync_channel(1); + requests + .try_send(OutboundWriteRequest { frame, completion }) + .map_err(|error| match error { + mpsc::TrySendError::Full(_) => "ACP outbound writer is busy".to_string(), + mpsc::TrySendError::Disconnected(_) => { + "ACP outbound writer is unavailable".to_string() + } + })?; + Ok(PendingOutboundWrite { + completion: receiver, + }) + } + + fn close(&mut self) { + self.requests.take(); + } + + fn wait_finished_until(&self, deadline: Instant) -> bool { + let (finished, changed) = &*self.finished; + let mut finished = finished + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + while !*finished { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return false; + } + let (next, timed_out) = changed + .wait_timeout(finished, remaining.min(ACP_POLL)) + .unwrap_or_else(|poisoned| poisoned.into_inner()); + finished = next; + if timed_out.timed_out() && Instant::now() >= deadline { + return false; + } + } + true + } +} + +impl Drop for AcpOutboundWriter { + fn drop(&mut self) { + self.close(); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OutboundInterruption { + Cancelled, + Shutdown, + Deadline, +} + +enum OutboundWriteOutcome { + Written, + Failed(String), + Interrupted(OutboundInterruption), +} + +#[derive(Debug)] +enum ReaderEvent { + Message(Value), + Eof, + Malformed, + TooLarge, + Io, +} + +fn remaining_run_budget(run_deadline: Instant) -> Option { + let remaining = run_deadline.saturating_duration_since(Instant::now()); + (!remaining.is_zero()).then_some(remaining) +} + +fn bounded_setup_wait(run_deadline: Instant) -> Option { + remaining_run_budget(run_deadline).map(|remaining| remaining.min(ACP_SETUP_TIMEOUT)) +} + +fn wait_outbound_write( + pending: PendingOutboundWrite, + deadline: Instant, + cancelled: Option<&AtomicBool>, + accepting: Option<&AtomicBool>, +) -> OutboundWriteOutcome { + loop { + match pending.completion.try_recv() { + Ok(Ok(())) => return OutboundWriteOutcome::Written, + Ok(Err(error)) => return OutboundWriteOutcome::Failed(error), + Err(mpsc::TryRecvError::Disconnected) => { + return OutboundWriteOutcome::Failed( + "ACP outbound writer disconnected before acknowledgement".to_string(), + ); + } + Err(mpsc::TryRecvError::Empty) => {} + } + if cancelled.is_some_and(|flag| flag.load(Ordering::Acquire)) { + return OutboundWriteOutcome::Interrupted(OutboundInterruption::Cancelled); + } + if accepting.is_some_and(|flag| !flag.load(Ordering::Acquire)) { + return OutboundWriteOutcome::Interrupted(OutboundInterruption::Shutdown); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return OutboundWriteOutcome::Interrupted(OutboundInterruption::Deadline); + } + match pending.completion.recv_timeout(remaining.min(ACP_POLL)) { + Ok(Ok(())) => return OutboundWriteOutcome::Written, + Ok(Err(error)) => return OutboundWriteOutcome::Failed(error), + Err(RecvTimeoutError::Timeout) => continue, + Err(RecvTimeoutError::Disconnected) => { + return OutboundWriteOutcome::Failed( + "ACP outbound writer disconnected before acknowledgement".to_string(), + ); + } + } + } +} + +fn wait_response( + rx: &Receiver, + id: u64, + timeout: Duration, + interrupted: Option<&AtomicBool>, +) -> Result { + let deadline = Instant::now() + timeout; + loop { + if interrupted.is_some_and(|flag| flag.load(Ordering::Acquire)) { + return Err("ACP setup interrupted".to_string()); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err("ACP request timed out".to_string()); + } + match rx.recv_timeout(remaining.min(Duration::from_millis(200))) { + Ok(ReaderEvent::Message(message)) => { + if message.get("id").and_then(Value::as_u64) != Some(id) { + continue; + } + if let Some(error) = message.get("error") { + return Err(format!( + "ACP request failed: {}", + bounded_json_summary(error) + )); + } + return message + .get("result") + .cloned() + .ok_or_else(|| "ACP response missing result".to_string()); + } + Ok( + ReaderEvent::Eof | ReaderEvent::Malformed | ReaderEvent::TooLarge | ReaderEvent::Io, + ) => return Err("ACP transport failed".to_string()), + Err(RecvTimeoutError::Timeout) => continue, + Err(RecvTimeoutError::Disconnected) => { + return Err("ACP transport disconnected".to_string()) + } + } + } +} + +fn request_frame(id: u64, method: &str, params: Value) -> std::io::Result> { + frame_message(&json!({"jsonrpc":"2.0","id":id,"method":method,"params":params})) +} + +fn notification_frame(method: &str, params: Value) -> std::io::Result> { + frame_message(&json!({"jsonrpc":"2.0","method":method,"params":params})) +} + +fn result_frame(id: u64, result: Value) -> std::io::Result> { + frame_message(&json!({"jsonrpc":"2.0","id":id,"result":result})) +} + +fn error_frame(id: u64, code: i64, message: &str) -> std::io::Result> { + frame_message(&json!({"jsonrpc":"2.0","id":id,"error":{"code":code,"message":message}})) +} + +fn frame_message(value: &Value) -> std::io::Result> { + let mut bytes = serde_json::to_vec(value).map_err(std::io::Error::other)?; + if bytes.len() > ACP_MESSAGE_MAX_BYTES { + return Err(std::io::Error::other("ACP message too large")); + } + bytes.push(b'\n'); + Ok(bytes) +} + +fn project_binding_matches( + projects_dir: &Path, + client_id: &str, + runtime_project_id: &str, + root: &str, +) -> bool { + let Ok(requested_root) = canonicalize_existing(Path::new(root)) else { + return false; + }; + if !requested_root.is_dir() { + return false; + } + load_agent_project_summaries_from_dir(projects_dir) + .into_iter() + .any(|project| { + if format!("agent:{client_id}:{}", project.id) != runtime_project_id + || !project.allow_patch + || project.disabled + { + return false; + } + canonicalize_existing(Path::new(&project.path)) + .ok() + .filter(|registered_root| registered_root.is_dir()) + .is_some_and(|registered_root| paths_equal(®istered_root, &requested_root)) + }) +} + +fn resolve_environment( + provider: &AcpAgentConfig, +) -> Result, String> { + let mut result = Vec::with_capacity(provider.env_from_env.len()); + for (destination, source) in &provider.env_from_env { + let Some(value) = std::env::var_os(source) else { + return Err(format!( + "required ACP environment source '{source}' is missing" + )); + }; + result.push((destination.clone(), value)); + } + Ok(result) +} + +fn config_override_is_valid( + options: &[SessionConfigOption], + key: &str, + value: &CodingAgentConfigValue, +) -> bool { + let Some(option) = options.iter().find(|option| option.id.to_string() == key) else { + return false; + }; + match (&option.kind, value) { + (SessionConfigKind::Boolean(_), CodingAgentConfigValue::Bool(_)) => true, + (SessionConfigKind::Select(select), CodingAgentConfigValue::String(requested)) => { + match &select.options { + SessionConfigSelectOptions::Ungrouped(options) => options + .iter() + .any(|option| option.value.to_string() == *requested), + SessionConfigSelectOptions::Grouped(groups) => groups + .iter() + .flat_map(|group| group.options.iter()) + .any(|option| option.value.to_string() == *requested), + _ => false, + } + } + _ => false, + } +} + +fn config_override_is_current( + options: &[SessionConfigOption], + key: &str, + value: &CodingAgentConfigValue, +) -> bool { + let Some(option) = options.iter().find(|option| option.id.to_string() == key) else { + return false; + }; + match (&option.kind, value) { + (SessionConfigKind::Boolean(current), CodingAgentConfigValue::Bool(requested)) => { + current.current_value == *requested + } + (SessionConfigKind::Select(current), CodingAgentConfigValue::String(requested)) => { + current.current_value.to_string() == *requested + } + _ => false, + } +} + +fn config_params(session_id: &str, key: &str, value: &CodingAgentConfigValue) -> Option { + match value { + CodingAgentConfigValue::String(value) => { + Some(json!({"sessionId":session_id,"configId":key,"value":value})) + } + CodingAgentConfigValue::Bool(value) => { + Some(json!({"sessionId":session_id,"configId":key,"type":"boolean","value":value})) + } + CodingAgentConfigValue::Integer(_) => None, + } +} + +fn normalize_update(message: &Value) -> Option { + let update = message.get("params")?.get("update")?; + let kind = update.get("sessionUpdate")?.as_str()?; + let text = update + .get("content") + .and_then(|content| content.get("text")) + .and_then(Value::as_str) + .map(bounded_text); + match kind { + "agent_message_chunk" => Some(event(CodingAgentEventKind::AgentMessage, text, None, None)), + "agent_thought_chunk" => Some(event(CodingAgentEventKind::Reasoning, text, None, None)), + "plan" => Some(event( + CodingAgentEventKind::Plan, + None, + Some("plan".to_string()), + Some("updated".to_string()), + )), + "tool_call" | "tool_call_update" => { + let label = update + .get("title") + .and_then(Value::as_str) + .map(bounded_text) + .or_else(|| update.get("kind").and_then(Value::as_str).map(bounded_text)); + let status = update + .get("status") + .and_then(Value::as_str) + .map(bounded_text); + let event_kind = match update.get("kind").and_then(Value::as_str) { + Some("edit") | Some("delete") | Some("move") => CodingAgentEventKind::FileChange, + Some("execute") => CodingAgentEventKind::TerminalActivity, + _ => CodingAgentEventKind::ToolActivity, + }; + Some(event(event_kind, None, label, status)) + } + "usage_update" => { + let usage = CodingAgentUsage { + used_tokens: update.get("used").and_then(Value::as_u64), + context_window_tokens: update.get("size").and_then(Value::as_u64), + cost_amount: update + .pointer("/cost/amount") + .and_then(Value::as_f64) + .map(|amount| amount.to_string()), + cost_currency: update + .pointer("/cost/currency") + .and_then(Value::as_str) + .map(bounded_text), + }; + Some(CodingAgentEvent { + sequence: 0, + kind: CodingAgentEventKind::Usage, + text: None, + label: None, + status: None, + usage: Some(usage), + }) + } + _ => None, + } +} + +fn permission_event(params: &Value) -> CodingAgentEvent { + let label = params + .pointer("/toolCall/title") + .and_then(Value::as_str) + .map(bounded_text); + let count = params + .get("options") + .and_then(Value::as_array) + .map(Vec::len) + .unwrap_or(0); + CodingAgentEvent { + sequence: 0, + kind: CodingAgentEventKind::PermissionRequest, + text: None, + label, + status: Some(format!("pending:{count}_options")), + usage: None, + } +} + +fn event( + kind: CodingAgentEventKind, + text: Option, + label: Option, + status: Option, +) -> CodingAgentEvent { + CodingAgentEvent { + sequence: 0, + kind, + text, + label, + status, + usage: None, + } +} + +fn bounded_text(value: &str) -> String { + const MAX: usize = webcodex_core::coding_agent::CODING_AGENT_MAX_EVENT_TEXT_BYTES; + const SUFFIX: &str = "…"; + if value.len() <= MAX { + return value.to_string(); + } + let mut end = MAX.saturating_sub(SUFFIX.len()); + while end > 0 && !value.is_char_boundary(end) { + end -= 1; + } + let mut bounded = String::with_capacity(MAX); + bounded.push_str(&value[..end]); + bounded.push_str(SUFFIX); + debug_assert!(bounded.len() <= MAX); + bounded +} +fn bounded_json_summary(value: &Value) -> String { + let text = serde_json::to_string(value).unwrap_or_else(|_| "invalid_json".to_string()); + bounded_text(&text) +} +fn now() -> i64 { + Utc::now().timestamp() +} + +fn response_error( + dispatch: CodingAgentDispatchState, + code: &str, + message: impl Into, + failure: &str, + recovery: &str, +) -> CodingAgentResponse { + CodingAgentResponse::error( + dispatch, + code, + message.into(), + Some(failure), + Some(recovery), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + use tempfile::TempDir; + + #[derive(Default)] + struct BlockingWriteState { + entered: bool, + released: bool, + } + + #[derive(Clone)] + struct BlockingWrite { + state: Arc<(Mutex, Condvar)>, + } + + impl Write for BlockingWrite { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let (state, changed) = &*self.state; + let mut state = state.lock().unwrap(); + state.entered = true; + changed.notify_all(); + while !state.released { + state = changed.wait(state).unwrap(); + } + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + fn wait_for_blocking_write(state: &Arc<(Mutex, Condvar)>) { + let (state, changed) = &**state; + let deadline = Instant::now() + Duration::from_secs(2); + let mut state = state.lock().unwrap(); + while !state.entered { + let remaining = deadline.saturating_duration_since(Instant::now()); + assert!(!remaining.is_zero(), "writer never entered blocking sink"); + let (next, _) = changed.wait_timeout(state, remaining).unwrap(); + state = next; + } + } + + fn release_blocking_write(state: &Arc<(Mutex, Condvar)>) { + let (state, changed) = &**state; + let mut state = state.lock().unwrap(); + state.released = true; + changed.notify_all(); + } + + fn fake_config(executable: String, args: Vec) -> AcpConfig { + AcpConfig { + max_concurrent_runs: 1, + permission_timeout_secs: 1, + agents: vec![AcpAgentConfig { + id: "codex".to_string(), + name: "Codex".to_string(), + executable, + args, + env_from_env: BTreeMap::new(), + allowed_config_options: vec!["mode".to_string()], + }], + } + } + + #[cfg(unix)] + fn fake_agent(temp: &TempDir, scenario: &str) -> (String, Vec) { + let path = temp.path().join("fake-acp.py"); + let script = r#"#!/usr/bin/env python3 +import json,os,sys,time,subprocess +scenario=sys.argv[1] +config_values={'one':'a','two':'a','three':'a','four':'a'} +log_path=os.path.join(os.path.dirname(__file__),'fake-acp.log') +def log(x): + with open(log_path,'a',encoding='utf-8') as f: f.write(json.dumps(x,separators=(',',':'))+'\n') +def send(x): + log({'send':x}); print(json.dumps(x),flush=True) +log({'startup_pid':os.getpid(),'env_keys':sorted(k for k in os.environ if k.startswith('WEBCODEX_TEST_ACP_') or k=='ACP_VISIBLE')}) +for line in sys.stdin: + m=json.loads(line); log({'recv':m}); method=m.get('method'); rid=m.get('id') + if method=='initialize': + if scenario=='crash_before_prompt': sys.exit(9) + if scenario in ('block_initialize','block_initialize_tree'): + if scenario=='block_initialize_tree': + child=subprocess.Popen(['/bin/sh','-c','sleep 60']); log({'descendant_pid':child.pid}) + ready=os.path.join(os.path.dirname(__file__),'initialize.ready') + release=os.path.join(os.path.dirname(__file__),'initialize.release') + open(ready,'w').close() + while not os.path.exists(release): time.sleep(0.01) + send({'jsonrpc':'2.0','id':rid,'result':{'protocolVersion':1,'agentCapabilities':{}}}) + elif method=='session/new': + if scenario=='slow_configs': + opts=[{'id':k,'name':k.title(),'type':'select','currentValue':config_values[k],'options':[{'value':'a','name':'A'},{'value':'b','name':'B'}]} for k in config_values] + else: + opts=[{'id':'mode','name':'Mode','type':'select','currentValue':'agent','options':[{'value':'agent','name':'Agent'},{'value':'read-only','name':'Read Only'}]}] + session_id='s'*70000 if scenario=='block_cancel_write' else 's1' + send({'jsonrpc':'2.0','id':rid,'result':{'sessionId':session_id,'configOptions':opts}}) + if scenario in ('block_after_session_new','block_after_session_new_tree'): + if scenario=='block_after_session_new_tree': + child=subprocess.Popen(['/bin/sh','-c','sleep 60']); log({'descendant_pid':child.pid}) + open(os.path.join(os.path.dirname(__file__),'stdin_stopped.ready'),'w').close() + while True: time.sleep(1) + elif method=='session/set_config_option': + if scenario=='slow_configs': + time.sleep(0.6) + k=m['params']['configId']; v=m['params']['value']; config_values[k]=v + opts=[{'id':key,'name':key.title(),'type':'select','currentValue':config_values[key],'options':[{'value':'a','name':'A'},{'value':'b','name':'B'}]} for key in config_values] + else: + v=m['params']['value']; opts=[{'id':'mode','name':'Mode','type':'select','currentValue':v,'options':[{'value':'agent','name':'Agent'},{'value':'read-only','name':'Read Only'}]}] + send({'jsonrpc':'2.0','id':rid,'result':{'configOptions':opts}}) + elif method=='session/prompt': + if scenario=='crash_after_prompt': sys.exit(7) + if scenario=='spawn_descendant': + child=subprocess.Popen(['/bin/sh','-c','sleep 60']); log({'descendant_pid':child.pid}) + if scenario=='block_cancel_write': + child=subprocess.Popen(['/bin/sh','-c','sleep 60']); log({'descendant_pid':child.pid}) + open(os.path.join(os.path.dirname(__file__),'prompt_read.ready'),'w').close() + while True: time.sleep(1) + send({'jsonrpc':'2.0','method':'session/update','params':{'sessionId':'s1','update':{'sessionUpdate':'agent_message_chunk','content':{'type':'text','text':'hello'}}}}) + send({'jsonrpc':'2.0','method':'session/update','params':{'sessionId':'s1','update':{'sessionUpdate':'agent_thought_chunk','content':{'type':'text','text':'thinking'}}}}) + send({'jsonrpc':'2.0','method':'session/update','params':{'sessionId':'s1','update':{'sessionUpdate':'plan','entries':[]}}}) + send({'jsonrpc':'2.0','method':'session/update','params':{'sessionId':'s1','update':{'sessionUpdate':'tool_call','toolCallId':'t1','title':'inspect','kind':'execute','status':'in_progress'}}}) + send({'jsonrpc':'2.0','method':'session/update','params':{'sessionId':'s1','update':{'sessionUpdate':'tool_call','toolCallId':'t2','title':'edit','kind':'edit','status':'in_progress'}}}) + send({'jsonrpc':'2.0','method':'session/update','params':{'sessionId':'s1','update':{'sessionUpdate':'usage_update','used':53,'size':200,'cost':{'amount':0.045,'currency':'USD'}}}}) + if scenario=='many_events': + for i in range(300): send({'jsonrpc':'2.0','method':'session/update','params':{'sessionId':'s1','update':{'sessionUpdate':'agent_message_chunk','content':{'type':'text','text':'m'+str(i)}}}}) + send({'jsonrpc':'2.0','id':rid,'result':{'stopReason':'end_turn'}}) + elif scenario=='permission': + send({'jsonrpc':'2.0','id':99,'method':'session/request_permission','params':{'sessionId':'s1','toolCall':{'toolCallId':'t1','title':'permission','status':'pending'},'options':[{'optionId':'allow','name':'Allow','kind':'allow_once'}]}}) + response=json.loads(sys.stdin.readline()); log({'recv':response}) + assert response['result']['outcome']['outcome']=='cancelled' + send({'jsonrpc':'2.0','id':rid,'result':{'stopReason':'cancelled'}}) + elif scenario=='permission_hold': + send({'jsonrpc':'2.0','id':99,'method':'session/request_permission','params':{'sessionId':'s1','toolCall':{'toolCallId':'t1','title':'permission','status':'pending'},'options':[{'optionId':'allow','name':'Allow','kind':'allow_once'}]}}) + response=json.loads(sys.stdin.readline()); log({'recv':response}) + assert response['result']['outcome']['outcome']=='cancelled' + cancel=json.loads(sys.stdin.readline()); log({'recv':cancel}); assert cancel.get('method')=='session/cancel' + send({'jsonrpc':'2.0','id':rid,'result':{'stopReason':'cancelled'}}) + elif scenario=='unsupported_callback': + send({'jsonrpc':'2.0','id':98,'method':'fs/read_text_file','params':{'path':'private'}}) + response=json.loads(sys.stdin.readline()); log({'recv':response}); assert response['error']['code']==-32601 + cancel=json.loads(sys.stdin.readline()); log({'recv':cancel}); assert cancel.get('method')=='session/cancel' + send({'jsonrpc':'2.0','id':rid,'result':{'stopReason':'cancelled'}}) + elif scenario=='wait_cancel': + while True: + x=json.loads(sys.stdin.readline()); log({'recv':x}) + if x.get('method')=='session/cancel': send({'jsonrpc':'2.0','id':rid,'result':{'stopReason':'cancelled'}}); break + else: + stop={'end':'end_turn','spawn_descendant':'end_turn','cancelled':'cancelled','max_tokens':'max_tokens','max_turn_requests':'max_turn_requests','refusal':'refusal','unknown':'future_reason'}.get(scenario,'end_turn') + send({'jsonrpc':'2.0','id':rid,'result':{'stopReason':stop}}) +"#; + fs::write(&path, script).unwrap(); + let mut perms = fs::metadata(&path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&path, perms).unwrap(); + ( + path.to_string_lossy().to_string(), + vec![scenario.to_string()], + ) + } + + #[cfg(unix)] + fn project_fixture(temp: &TempDir) -> PathBuf { + let root = temp.path().join("repo"); + fs::create_dir_all(&root).unwrap(); + let projects = temp.path().join("projects"); + fs::create_dir_all(&projects).unwrap(); + fs::write( + projects.join("p.toml"), + format!("id = \"demo\"\npath = {:?}\n", root.to_string_lossy()), + ) + .unwrap(); + projects + } + + #[cfg(unix)] + fn start_request( + manager: &CodingAgentManager, + root: &Path, + run: &str, + config: BTreeMap, + ) -> CodingAgentRequest { + let provider = manager.providers().remove(0); + CodingAgentRequest::Start(webcodex_core::coding_agent::CodingAgentStartRequest { + run_id: run.to_string(), + intent_fingerprint: "fingerprint".to_string(), + authority_fingerprint: "auth_test".to_string(), + runtime_project_id: "agent:test:demo".to_string(), + project_root: root.to_string_lossy().to_string(), + provider_id: "codex".to_string(), + provider_instance_id: provider.provider_instance_id, + instruction: "inspect".to_string(), + config, + timeout_secs: 10, + }) + } + + #[cfg(unix)] + fn start_request_with_timeout( + manager: &CodingAgentManager, + root: &Path, + run: &str, + config: BTreeMap, + timeout_secs: u64, + ) -> CodingAgentRequest { + let mut request = start_request(manager, root, run, config); + let CodingAgentRequest::Start(start) = &mut request else { + unreachable!(); + }; + start.timeout_secs = timeout_secs; + request + } + + #[cfg(unix)] + fn max_instruction_request( + manager: &CodingAgentManager, + root: &Path, + run: &str, + timeout_secs: u64, + ) -> CodingAgentRequest { + let mut request = + start_request_with_timeout(manager, root, run, BTreeMap::new(), timeout_secs); + let CodingAgentRequest::Start(start) = &mut request else { + unreachable!(); + }; + start.instruction = + "x".repeat(webcodex_core::coding_agent::CODING_AGENT_MAX_INSTRUCTION_BYTES); + let frame = request_frame( + 3, + "session/prompt", + json!({ + "sessionId":"s1", + "prompt":[{"type":"text","text":start.instruction.clone()}] + }), + ) + .unwrap(); + assert!( + frame.len() > 64 * 1024, + "max legal prompt frame must exceed the measured special Linux pipe capacity" + ); + request + } + + #[cfg(unix)] + fn wait_for_prompt_handoff(manager: &CodingAgentManager, run: &str) { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let entry = manager.runs.lock().unwrap().get(run).cloned().unwrap(); + if *entry.prompt_dispatch.lock().unwrap() + == PromptDispatchGateState::PromptDispatchMayHaveOccurred + { + return; + } + assert!( + Instant::now() < deadline, + "prompt was never handed to writer" + ); + thread::sleep(Duration::from_millis(5)); + } + } + + #[cfg(unix)] + fn run_scenario( + scenario: &str, + config: BTreeMap, + ) -> (Arc, String, CodingAgentObserveResult) { + let temp = TempDir::new().unwrap(); + let (exe, args) = fake_agent(&temp, scenario); + let cfg = fake_config(exe, args); + let projects = project_fixture(&temp); + let root = temp.path().join("repo"); + let store = temp.path().join("store"); + let manager = CodingAgentManager::with_store(&cfg, store).unwrap(); + let run = "wc_agent_run_0123456789abcdef".to_string(); + let response = manager.handle(start_request(&manager, &root, &run, config), &projects); + assert!(response.error.is_none(), "{:?}", response.error); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if manager + .runs + .lock() + .unwrap() + .get(&run) + .unwrap() + .snapshot() + .state + .terminal() + { + break; + } + assert!(Instant::now() < deadline); + thread::sleep(Duration::from_millis(20)); + } + let observation = manager + .runs + .lock() + .unwrap() + .get(&run) + .unwrap() + .observe(None, 64, 0) + .expect("retained fake Run cursor must be valid"); + std::mem::forget(temp); + (manager, run, observation) + } + + #[cfg(unix)] + fn wait_for_snapshot( + manager: &CodingAgentManager, + run: &str, + predicate: impl Fn(&CodingAgentRunSnapshot) -> bool, + ) -> CodingAgentRunSnapshot { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let snapshot = manager.runs.lock().unwrap().get(run).unwrap().snapshot(); + if predicate(&snapshot) { + return snapshot; + } + assert!( + Instant::now() < deadline, + "timed out waiting for CodingAgentRun state: {snapshot:?}" + ); + thread::sleep(Duration::from_millis(20)); + } + } + + #[cfg(unix)] + fn wire_log(temp: &TempDir) -> Vec { + let path = temp.path().join("fake-acp.log"); + if !path.exists() { + return Vec::new(); + } + fs::read_to_string(path) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() + } + + #[cfg(unix)] + fn received_methods(log: &[Value]) -> Vec { + log.iter() + .filter_map(|entry| entry.pointer("/recv/method").and_then(Value::as_str)) + .map(str::to_string) + .collect() + } + + #[cfg(unix)] + fn wait_for_received_method_count(temp: &TempDir, method: &str, expected: usize) { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let count = received_methods(&wire_log(temp)) + .iter() + .filter(|candidate| candidate.as_str() == method) + .count(); + if count >= expected { + return; + } + assert!( + Instant::now() < deadline, + "timed out waiting for {expected} {method} calls; observed {count}" + ); + thread::sleep(Duration::from_millis(20)); + } + } + + #[cfg(unix)] + fn wait_for_path(path: &Path) { + let deadline = Instant::now() + Duration::from_secs(5); + while !path.exists() { + assert!( + Instant::now() < deadline, + "timed out waiting for {}", + path.display() + ); + thread::sleep(Duration::from_millis(10)); + } + } + + #[cfg(target_os = "linux")] + fn wait_for_proc_exit(pid: u64) { + let proc_path = PathBuf::from(format!("/proc/{pid}")); + let deadline = Instant::now() + Duration::from_secs(3); + while proc_path.exists() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(20)); + } + assert!( + !proc_path.exists(), + "process {pid} survived CodingAgent worker drain" + ); + } + + fn successful_start_run_id(response: &CodingAgentResponse) -> Option { + match response.payload.as_ref() { + Some(CodingAgentResponsePayload::Start { run }) => Some(run.run_id.clone()), + _ => None, + } + } + + #[test] + fn outbound_writer_blocking_sink_does_not_block_lifecycle_owner() { + let state = Arc::new((Mutex::new(BlockingWriteState::default()), Condvar::new())); + let threads = BackgroundThreads::default(); + let mut writer = AcpOutboundWriter::spawn( + BlockingWrite { + state: Arc::clone(&state), + }, + &threads, + ) + .unwrap(); + let pending = writer.start_frame(vec![b'x'; 1024]).unwrap(); + wait_for_blocking_write(&state); + + let cancelled = AtomicBool::new(true); + let outcome = wait_outbound_write( + pending, + Instant::now() + Duration::from_secs(1), + Some(&cancelled), + None, + ); + assert!(matches!( + outcome, + OutboundWriteOutcome::Interrupted(OutboundInterruption::Cancelled) + )); + assert_eq!(threads.pending(), 1); + + // The production owner uses ManagedChild::terminate_tree to make a blocked + // pipe write return. Releasing this deterministic sink models that exact + // post-interruption effect without relying on pipe capacity or sleeps. + release_blocking_write(&state); + writer.close(); + assert!(writer.wait_finished_until(Instant::now() + Duration::from_secs(1))); + let joined = threads.join_until(Instant::now() + Duration::from_secs(1)); + assert_eq!(joined.timed_out, 0); + assert_eq!(joined.panicked, 0); + assert_eq!(threads.pending(), 0); + } + + #[test] + #[cfg(unix)] + fn acp_v1_sequence_cwd_config_and_normalized_updates_are_exact() { + let temp = TempDir::new().unwrap(); + let (exe, args) = fake_agent(&temp, "end"); + let cfg = fake_config(exe, args); + let projects = project_fixture(&temp); + let root = temp.path().join("repo"); + let manager = CodingAgentManager::with_store(&cfg, temp.path().join("store")).unwrap(); + let run = "wc_agent_run_sequence0001"; + let response = manager.handle( + start_request(&manager, &root, run, BTreeMap::new()), + &projects, + ); + assert!(response.error.is_none(), "{:?}", response.error); + let terminal = wait_for_snapshot(&manager, run, |snapshot| snapshot.state.terminal()); + assert_eq!(terminal.state, CodingAgentRunState::Completed); + + let log = wire_log(&temp); + assert_eq!( + received_methods(&log), + vec!["initialize", "session/new", "session/prompt"] + ); + let initialize = log + .iter() + .find_map(|entry| { + (entry.pointer("/recv/method").and_then(Value::as_str) == Some("initialize")) + .then(|| entry.pointer("/recv").unwrap()) + }) + .unwrap(); + assert_eq!( + initialize.pointer("/params/clientCapabilities"), + Some(&json!({})) + ); + let session_new = log + .iter() + .find_map(|entry| { + (entry.pointer("/recv/method").and_then(Value::as_str) == Some("session/new")) + .then(|| entry.pointer("/recv").unwrap()) + }) + .unwrap(); + assert_eq!( + session_new.pointer("/params/cwd").and_then(Value::as_str), + Some(root.to_string_lossy().as_ref()) + ); + assert_eq!(session_new.pointer("/params/mcpServers"), Some(&json!([]))); + + let observation = manager + .runs + .lock() + .unwrap() + .get(run) + .unwrap() + .observe(None, 64, 0) + .expect("retained fake Run cursor must be valid"); + for kind in [ + CodingAgentEventKind::AgentMessage, + CodingAgentEventKind::Reasoning, + CodingAgentEventKind::Plan, + CodingAgentEventKind::TerminalActivity, + CodingAgentEventKind::FileChange, + CodingAgentEventKind::Usage, + CodingAgentEventKind::Terminal, + ] { + assert!( + observation.events.iter().any(|event| event.kind == kind), + "missing {kind:?}" + ); + } + let usage = observation + .events + .iter() + .find(|event| event.kind == CodingAgentEventKind::Usage) + .and_then(|event| event.usage.as_ref()) + .unwrap(); + assert_eq!(usage.used_tokens, Some(53)); + assert_eq!(usage.context_window_tokens, Some(200)); + assert_eq!(usage.cost_amount.as_deref(), Some("0.045")); + assert_eq!(usage.cost_currency.as_deref(), Some("USD")); + } + + #[test] + #[cfg(unix)] + fn explicit_config_is_ordered_and_invalid_config_never_prompts() { + let temp = TempDir::new().unwrap(); + let (exe, args) = fake_agent(&temp, "end"); + let cfg = fake_config(exe, args); + let projects = project_fixture(&temp); + let root = temp.path().join("repo"); + let manager = CodingAgentManager::with_store(&cfg, temp.path().join("store")).unwrap(); + let run = "wc_agent_run_config000001"; + let response = manager.handle( + start_request( + &manager, + &root, + run, + BTreeMap::from([( + "mode".to_string(), + CodingAgentConfigValue::String("read-only".to_string()), + )]), + ), + &projects, + ); + assert!(response.error.is_none()); + wait_for_snapshot(&manager, run, |snapshot| snapshot.state.terminal()); + assert_eq!( + received_methods(&wire_log(&temp)), + vec![ + "initialize", + "session/new", + "session/set_config_option", + "session/prompt" + ] + ); + + let temp = TempDir::new().unwrap(); + let (exe, args) = fake_agent(&temp, "end"); + let cfg = fake_config(exe, args); + let projects = project_fixture(&temp); + let root = temp.path().join("repo"); + let manager = CodingAgentManager::with_store(&cfg, temp.path().join("store")).unwrap(); + let run = "wc_agent_run_badconfig001"; + let response = manager.handle( + start_request( + &manager, + &root, + run, + BTreeMap::from([( + "not-advertised".to_string(), + CodingAgentConfigValue::String("x".to_string()), + )]), + ), + &projects, + ); + assert!(response.error.is_none()); + let terminal = wait_for_snapshot(&manager, run, |snapshot| snapshot.state.terminal()); + assert_eq!(terminal.state, CodingAgentRunState::Failed); + assert_eq!( + terminal.execution_state, + CodingAgentExecutionState::NotStarted + ); + assert!(!received_methods(&wire_log(&temp)) + .iter() + .any(|method| method == "session/prompt")); + } + + #[test] + #[cfg(unix)] + fn cancel_permission_and_unsupported_requests_are_fail_closed() { + let temp = TempDir::new().unwrap(); + let (exe, args) = fake_agent(&temp, "permission_hold"); + let cfg = fake_config(exe, args); + let projects = project_fixture(&temp); + let root = temp.path().join("repo"); + let manager = CodingAgentManager::with_store(&cfg, temp.path().join("store")).unwrap(); + let run = "wc_agent_run_permcancel01"; + assert!(manager + .handle( + start_request(&manager, &root, run, BTreeMap::new()), + &projects + ) + .error + .is_none()); + wait_for_snapshot(&manager, run, |snapshot| { + snapshot.state == CodingAgentRunState::WaitingPermission + }); + let cancel = manager.handle( + CodingAgentRequest::Cancel(CodingAgentCancelRequest { + run_id: run.to_string(), + }), + &projects, + ); + assert!(cancel.error.is_none()); + let terminal = wait_for_snapshot(&manager, run, |snapshot| snapshot.state.terminal()); + assert_eq!(terminal.state, CodingAgentRunState::Cancelled); + let log = wire_log(&temp); + let permission_cancel = log + .iter() + .position(|entry| { + entry.pointer("/recv/id").and_then(Value::as_u64) == Some(99) + && entry + .pointer("/recv/result/outcome/outcome") + .and_then(Value::as_str) + == Some("cancelled") + }) + .unwrap(); + let prompt_cancel = log + .iter() + .position(|entry| { + entry.pointer("/recv/method").and_then(Value::as_str) == Some("session/cancel") + }) + .unwrap(); + assert!( + permission_cancel < prompt_cancel, + "pending permission must be completed before prompt cancel" + ); + + let temp = TempDir::new().unwrap(); + let (exe, args) = fake_agent(&temp, "unsupported_callback"); + let cfg = fake_config(exe, args); + let projects = project_fixture(&temp); + let root = temp.path().join("repo"); + let manager = CodingAgentManager::with_store(&cfg, temp.path().join("store")).unwrap(); + let run = "wc_agent_run_unsupported1"; + assert!(manager + .handle( + start_request(&manager, &root, run, BTreeMap::new()), + &projects + ) + .error + .is_none()); + let terminal = wait_for_snapshot(&manager, run, |snapshot| snapshot.state.terminal()); + assert_eq!(terminal.state, CodingAgentRunState::Cancelled); + let log = wire_log(&temp); + assert!(log.iter().any( + |entry| entry.pointer("/recv/error/code").and_then(Value::as_i64) == Some(-32601) + )); + assert!(log.iter().any( + |entry| entry.pointer("/recv/method").and_then(Value::as_str) == Some("session/cancel") + )); + } + + #[test] + fn bounded_text_never_exceeds_utf8_byte_budget() { + let max = webcodex_core::coding_agent::CODING_AGENT_MAX_EVENT_TEXT_BYTES; + let cases = [ + "a".repeat(max), + "a".repeat(max + 1), + "é".repeat(max / "é".len() + 2), + "€".repeat(max / "€".len() + 2), + "🦀".repeat(max / "🦀".len() + 2), + "z".repeat(max * 4), + ]; + for input in cases { + let output = bounded_text(&input); + assert!(output.len() <= max, "{} > {max}", output.len()); + assert!(std::str::from_utf8(output.as_bytes()).is_ok()); + if input.len() <= max { + assert_eq!(output, input); + } else { + assert!(output.ends_with('…')); + } + } + + let json = json!({"body": "界".repeat(max * 2)}); + let summary = bounded_json_summary(&json); + assert!(summary.len() <= max); + assert!(summary.ends_with('…')); + } + + #[test] + #[cfg(unix)] + fn event_ring_capacity_and_continuation_are_bounded() { + let temp = TempDir::new().unwrap(); + let (exe, args) = fake_agent(&temp, "many_events"); + let cfg = fake_config(exe, args); + let projects = project_fixture(&temp); + let root = temp.path().join("repo"); + let manager = CodingAgentManager::with_store(&cfg, temp.path().join("store")).unwrap(); + let run = "wc_agent_run_manyevents01"; + assert!(manager + .handle( + start_request(&manager, &root, run, BTreeMap::new()), + &projects + ) + .error + .is_none()); + wait_for_snapshot(&manager, run, |snapshot| snapshot.state.terminal()); + let entry = manager.runs.lock().unwrap().get(run).unwrap().clone(); + let first = entry.observe(Some(0), 32, 0).unwrap(); + assert!(first.run.state.terminal()); + assert!(first.history_lost); + assert!(first.has_more); + assert_eq!(first.events.len(), 32); + assert!(first.first_retained_sequence > 1); + let second = entry.observe(Some(first.next_sequence), 32, 0).unwrap(); + assert!(second.run.state.terminal()); + assert!(!second.events.is_empty()); + assert!(second.events.first().unwrap().sequence > first.events.last().unwrap().sequence); + + let latest = entry.state.lock().unwrap().next_sequence.saturating_sub(1); + let error = entry + .observe(Some(latest.saturating_add(1)), 32, 0) + .unwrap_err(); + assert!( + error.contains("ahead of latest emitted sequence"), + "{error}" + ); + let response = manager.handle( + CodingAgentRequest::Observe(webcodex_core::coding_agent::CodingAgentObserveRequest { + run_id: run.to_string(), + after_sequence: Some(latest.saturating_add(1)), + limit: 32, + wait_secs: 0, + }), + &projects, + ); + assert_eq!( + response.error.as_ref().map(|error| error.code.as_str()), + Some("invalid_coding_agent_observation_cursor") + ); + } + + #[test] + #[cfg(unix)] + fn concurrent_duplicate_start_admission_creates_exactly_one_prompt() { + let temp = TempDir::new().unwrap(); + let (exe, args) = fake_agent(&temp, "wait_cancel"); + let cfg = fake_config(exe, args); + let projects = project_fixture(&temp); + let root = temp.path().join("repo"); + let manager = CodingAgentManager::with_store(&cfg, temp.path().join("store")).unwrap(); + let run = "wc_agent_run_concurrentdup01"; + let request = start_request(&manager, &root, run, BTreeMap::new()); + *manager.admission_test_barrier.lock().unwrap() = + Some(Arc::new(std::sync::Barrier::new(2))); + + let first_manager = Arc::clone(&manager); + let first_projects = projects.clone(); + let first_request = request.clone(); + let first = thread::spawn(move || first_manager.handle(first_request, &first_projects)); + let second_manager = Arc::clone(&manager); + let second_projects = projects.clone(); + let second = thread::spawn(move || second_manager.handle(request, &second_projects)); + + let first = first.join().unwrap(); + let second = second.join().unwrap(); + *manager.admission_test_barrier.lock().unwrap() = None; + assert!(first.error.is_none(), "{:?}", first.error); + assert!(second.error.is_none(), "{:?}", second.error); + assert_eq!(successful_start_run_id(&first).as_deref(), Some(run)); + assert_eq!(successful_start_run_id(&second).as_deref(), Some(run)); + assert_eq!( + manager + .initial_claim_writes + .load(std::sync::atomic::Ordering::SeqCst), + 1, + "duplicate concurrent admission must publish only one initial durable claim" + ); + assert_eq!(manager.runs.lock().unwrap().len(), 1); + wait_for_received_method_count(&temp, "session/prompt", 1); + assert_eq!( + received_methods(&wire_log(&temp)) + .iter() + .filter(|method| method.as_str() == "session/prompt") + .count(), + 1, + "duplicate concurrent admission dispatched more than one ACP prompt" + ); + + manager.handle( + CodingAgentRequest::Cancel(CodingAgentCancelRequest { + run_id: run.to_string(), + }), + &projects, + ); + wait_for_snapshot(&manager, run, |snapshot| snapshot.state.terminal()); + } + + #[test] + #[cfg(unix)] + fn concurrent_capacity_admission_never_exceeds_configured_limit() { + let temp = TempDir::new().unwrap(); + let (exe, args) = fake_agent(&temp, "wait_cancel"); + let cfg = fake_config(exe, args); + let projects = project_fixture(&temp); + let root = temp.path().join("repo"); + let manager = CodingAgentManager::with_store(&cfg, temp.path().join("store")).unwrap(); + *manager.admission_test_barrier.lock().unwrap() = + Some(Arc::new(std::sync::Barrier::new(2))); + let first_request = start_request( + &manager, + &root, + "wc_agent_run_concurrentcap01", + BTreeMap::new(), + ); + let second_request = start_request( + &manager, + &root, + "wc_agent_run_concurrentcap02", + BTreeMap::new(), + ); + + let first_manager = Arc::clone(&manager); + let first_projects = projects.clone(); + let first = thread::spawn(move || first_manager.handle(first_request, &first_projects)); + let second_manager = Arc::clone(&manager); + let second_projects = projects.clone(); + let second = thread::spawn(move || second_manager.handle(second_request, &second_projects)); + + let responses = [first.join().unwrap(), second.join().unwrap()]; + *manager.admission_test_barrier.lock().unwrap() = None; + let successes = responses + .iter() + .filter_map(successful_start_run_id) + .collect::>(); + let capacity_failures = responses + .iter() + .filter(|response| { + response.error.as_ref().map(|error| error.code.as_str()) + == Some("coding_agent_capacity_full") + }) + .count(); + assert_eq!(successes.len(), 1, "exactly one Run may acquire the slot"); + assert_eq!( + capacity_failures, 1, + "the competing Run must fail capacity admission" + ); + assert_eq!( + manager + .initial_claim_writes + .load(std::sync::atomic::Ordering::SeqCst), + 1, + "capacity admission must publish only the winning durable claim" + ); + assert_eq!( + manager + .runs + .lock() + .unwrap() + .values() + .filter(|entry| !entry.snapshot().state.terminal()) + .count(), + 1, + "active Run count exceeded max_concurrent_runs=1" + ); + wait_for_received_method_count(&temp, "session/prompt", 1); + assert_eq!( + received_methods(&wire_log(&temp)) + .iter() + .filter(|method| method.as_str() == "session/prompt") + .count(), + 1 + ); + + let winner = &successes[0]; + manager.handle( + CodingAgentRequest::Cancel(CodingAgentCancelRequest { + run_id: winner.clone(), + }), + &projects, + ); + wait_for_snapshot(&manager, winner, |snapshot| snapshot.state.terminal()); + } + + #[test] + #[cfg(unix)] + fn initialize_wait_consumes_total_run_deadline() { + let temp = TempDir::new().unwrap(); + let (exe, args) = fake_agent(&temp, "block_initialize"); + let cfg = fake_config(exe, args); + let projects = project_fixture(&temp); + let root = temp.path().join("repo"); + let manager = CodingAgentManager::with_store(&cfg, temp.path().join("store")).unwrap(); + let run = "wc_agent_run_totalinitialize01"; + let started_at = Instant::now(); + let started = manager.handle( + start_request_with_timeout(&manager, &root, run, BTreeMap::new(), 1), + &projects, + ); + assert!(started.error.is_none(), "{:?}", started.error); + let snapshot = wait_for_snapshot(&manager, run, |snapshot| snapshot.state.terminal()); + assert!(started_at.elapsed() < Duration::from_secs(3)); + let drain = manager.drain_workers_until(Instant::now() + Duration::from_secs(3)); + assert_eq!(drain.timed_out, 0); + assert_eq!(snapshot.state, CodingAgentRunState::Failed); + assert_eq!( + snapshot.execution_state, + CodingAgentExecutionState::NotStarted + ); + assert_eq!( + snapshot + .terminal + .as_ref() + .and_then(|t| t.error_code.as_deref()), + Some("coding_agent_setup_timeout") + ); + assert_eq!( + received_methods(&wire_log(&temp)) + .iter() + .filter(|m| m.as_str() == "session/prompt") + .count(), + 0 + ); + } + + #[test] + #[cfg(unix)] + fn config_setup_cumulatively_consumes_total_run_deadline() { + let temp = TempDir::new().unwrap(); + let (exe, args) = fake_agent(&temp, "slow_configs"); + let mut cfg = fake_config(exe, args); + cfg.agents[0].allowed_config_options = ["one", "two", "three", "four"] + .into_iter() + .map(str::to_string) + .collect(); + let projects = project_fixture(&temp); + let root = temp.path().join("repo"); + let manager = CodingAgentManager::with_store(&cfg, temp.path().join("store")).unwrap(); + let run = "wc_agent_run_totalconfigs001"; + let config = ["one", "two", "three", "four"] + .into_iter() + .map(|key| { + ( + key.to_string(), + CodingAgentConfigValue::String("b".to_string()), + ) + }) + .collect(); + let started = manager.handle( + start_request_with_timeout(&manager, &root, run, config, 2), + &projects, + ); + assert!(started.error.is_none(), "{:?}", started.error); + let snapshot = wait_for_snapshot(&manager, run, |snapshot| snapshot.state.terminal()); + let drain = manager.drain_workers_until(Instant::now() + Duration::from_secs(3)); + assert_eq!(drain.timed_out, 0); + assert_eq!(snapshot.state, CodingAgentRunState::Failed); + assert_eq!( + snapshot.execution_state, + CodingAgentExecutionState::NotStarted + ); + assert_eq!( + snapshot + .terminal + .as_ref() + .and_then(|t| t.error_code.as_deref()), + Some("coding_agent_setup_timeout") + ); + let methods = received_methods(&wire_log(&temp)); + assert!( + methods + .iter() + .filter(|m| m.as_str() == "session/set_config_option") + .count() + >= 3, + "expected cumulative config setup before total deadline: {methods:?}" + ); + assert_eq!( + methods + .iter() + .filter(|m| m.as_str() == "session/prompt") + .count(), + 0 + ); + } + + #[test] + #[cfg(unix)] + fn deadline_after_durable_prompt_barrier_still_prevents_prompt_write() { + let temp = TempDir::new().unwrap(); + let (exe, args) = fake_agent(&temp, "end"); + let cfg = fake_config(exe, args); + let projects = project_fixture(&temp); + let root = temp.path().join("repo"); + let manager = CodingAgentManager::with_store(&cfg, temp.path().join("store")).unwrap(); + let run = "wc_agent_run_deadlinebarrier01"; + *manager.prompt_after_barrier_test_delay.lock().unwrap() = + Some(Duration::from_millis(1100)); + let started = manager.handle( + start_request_with_timeout(&manager, &root, run, BTreeMap::new(), 1), + &projects, + ); + assert!(started.error.is_none(), "{:?}", started.error); + let snapshot = wait_for_snapshot(&manager, run, |snapshot| snapshot.state.terminal()); + *manager.prompt_after_barrier_test_delay.lock().unwrap() = None; + let drain = manager.drain_workers_until(Instant::now() + Duration::from_secs(3)); + assert_eq!(drain.timed_out, 0); + assert_eq!(snapshot.state, CodingAgentRunState::Failed); + assert_eq!( + snapshot.execution_state, + CodingAgentExecutionState::NotStarted + ); + assert_eq!( + snapshot + .terminal + .as_ref() + .and_then(|t| t.error_code.as_deref()), + Some("coding_agent_setup_timeout") + ); + assert_eq!( + received_methods(&wire_log(&temp)) + .iter() + .filter(|m| m.as_str() == "session/prompt") + .count(), + 0 + ); + } + + #[test] + #[cfg(unix)] + fn blocked_max_prompt_write_respects_total_deadline_and_reaps_tree() { + let temp = TempDir::new().unwrap(); + let (exe, args) = fake_agent(&temp, "block_after_session_new_tree"); + let cfg = fake_config(exe, args); + let projects = project_fixture(&temp); + let root = temp.path().join("repo"); + let manager = CodingAgentManager::with_store(&cfg, temp.path().join("store")).unwrap(); + let run = "wc_agent_run_promptbackpressure01"; + let started_at = Instant::now(); + let started = manager.handle(max_instruction_request(&manager, &root, run, 1), &projects); + assert!(started.error.is_none(), "{:?}", started.error); + wait_for_path(&temp.path().join("stdin_stopped.ready")); + wait_for_prompt_handoff(&manager, run); + let snapshot = wait_for_snapshot(&manager, run, |snapshot| snapshot.state.terminal()); + assert!( + started_at.elapsed() < Duration::from_secs(4), + "blocked prompt write escaped the total Run deadline" + ); + assert_eq!(snapshot.state, CodingAgentRunState::Lost); + assert_eq!( + snapshot.execution_state, + CodingAgentExecutionState::OutcomeUnknown + ); + assert_eq!( + *manager + .runs + .lock() + .unwrap() + .get(run) + .unwrap() + .prompt_dispatch + .lock() + .unwrap(), + PromptDispatchGateState::PromptDispatchMayHaveOccurred + ); + let log = wire_log(&temp); + let startup_pid = log + .iter() + .find_map(|entry| entry.get("startup_pid").and_then(Value::as_u64)) + .unwrap(); + let descendant_pid = log + .iter() + .find_map(|entry| entry.get("descendant_pid").and_then(Value::as_u64)) + .unwrap(); + let drain = manager.drain_workers_until(Instant::now() + Duration::from_secs(3)); + assert_eq!(drain.timed_out, 0); + assert_eq!(drain.panicked, 0); + assert_eq!(manager.worker_count(), 0); + #[cfg(target_os = "linux")] + { + wait_for_proc_exit(startup_pid); + wait_for_proc_exit(descendant_pid); + } + } + + #[test] + #[cfg(unix)] + fn cancel_returns_while_max_prompt_write_is_blocked() { + let temp = TempDir::new().unwrap(); + let (exe, args) = fake_agent(&temp, "block_after_session_new_tree"); + let cfg = fake_config(exe, args); + let projects = project_fixture(&temp); + let root = temp.path().join("repo"); + let manager = CodingAgentManager::with_store(&cfg, temp.path().join("store")).unwrap(); + let run = "wc_agent_run_cancelblockedprompt1"; + let started = manager.handle(max_instruction_request(&manager, &root, run, 10), &projects); + assert!(started.error.is_none(), "{:?}", started.error); + wait_for_path(&temp.path().join("stdin_stopped.ready")); + wait_for_prompt_handoff(&manager, run); + + let cancel_started = Instant::now(); + let cancelled = manager.handle( + CodingAgentRequest::Cancel(CodingAgentCancelRequest { + run_id: run.to_string(), + }), + &projects, + ); + assert!( + cancel_started.elapsed() < Duration::from_secs(1), + "Cancel waited for blocked ChildStdin write" + ); + let cancel_snapshot = match cancelled.payload.unwrap() { + CodingAgentResponsePayload::Cancel { run } => run, + other => panic!("unexpected cancel payload: {other:?}"), + }; + assert_ne!( + cancel_snapshot.execution_state, + CodingAgentExecutionState::NotStarted + ); + let snapshot = wait_for_snapshot(&manager, run, |snapshot| snapshot.state.terminal()); + assert_eq!(snapshot.state, CodingAgentRunState::Lost); + assert_eq!( + snapshot.execution_state, + CodingAgentExecutionState::OutcomeUnknown + ); + let log = wire_log(&temp); + let startup_pid = log + .iter() + .find_map(|entry| entry.get("startup_pid").and_then(Value::as_u64)) + .unwrap(); + let descendant_pid = log + .iter() + .find_map(|entry| entry.get("descendant_pid").and_then(Value::as_u64)) + .unwrap(); + let drain = manager.drain_workers_until(Instant::now() + Duration::from_secs(3)); + assert_eq!(drain.timed_out, 0); + assert_eq!(manager.worker_count(), 0); + #[cfg(target_os = "linux")] + { + wait_for_proc_exit(startup_pid); + wait_for_proc_exit(descendant_pid); + } + } + + #[test] + #[cfg(unix)] + fn shutdown_remains_bounded_while_max_prompt_write_is_blocked() { + let temp = TempDir::new().unwrap(); + let (exe, args) = fake_agent(&temp, "block_after_session_new_tree"); + let cfg = fake_config(exe, args); + let projects = project_fixture(&temp); + let root = temp.path().join("repo"); + let manager = CodingAgentManager::with_store(&cfg, temp.path().join("store")).unwrap(); + let run = "wc_agent_run_shutdownblockedprompt"; + let started = manager.handle(max_instruction_request(&manager, &root, run, 10), &projects); + assert!(started.error.is_none(), "{:?}", started.error); + wait_for_path(&temp.path().join("stdin_stopped.ready")); + wait_for_prompt_handoff(&manager, run); + + let stop_started = Instant::now(); + manager.stop_accepting(); + assert!( + stop_started.elapsed() < Duration::from_secs(1), + "stop_accepting waited for blocked ChildStdin write" + ); + let drain = manager.drain_workers_until(Instant::now() + Duration::from_secs(4)); + assert_eq!(drain.timed_out, 0); + assert_eq!(drain.panicked, 0); + assert_eq!(manager.worker_count(), 0); + let snapshot = manager.runs.lock().unwrap().get(run).unwrap().snapshot(); + assert_eq!(snapshot.state, CodingAgentRunState::Lost); + assert_eq!( + snapshot.execution_state, + CodingAgentExecutionState::OutcomeUnknown + ); + let log = wire_log(&temp); + let startup_pid = log + .iter() + .find_map(|entry| entry.get("startup_pid").and_then(Value::as_u64)) + .unwrap(); + let descendant_pid = log + .iter() + .find_map(|entry| entry.get("descendant_pid").and_then(Value::as_u64)) + .unwrap(); + #[cfg(target_os = "linux")] + { + wait_for_proc_exit(startup_pid); + wait_for_proc_exit(descendant_pid); + } + } + + #[test] + #[cfg(unix)] + fn blocked_cancel_notification_is_bounded_by_cancel_grace() { + let temp = TempDir::new().unwrap(); + let (exe, args) = fake_agent(&temp, "block_cancel_write"); + let cfg = fake_config(exe, args); + let projects = project_fixture(&temp); + let root = temp.path().join("repo"); + let manager = CodingAgentManager::with_store(&cfg, temp.path().join("store")).unwrap(); + let run = "wc_agent_run_blockedcancelwrite1"; + let started = manager.handle( + start_request_with_timeout(&manager, &root, run, BTreeMap::new(), 30), + &projects, + ); + assert!(started.error.is_none(), "{:?}", started.error); + wait_for_path(&temp.path().join("prompt_read.ready")); + wait_for_snapshot(&manager, run, |snapshot| { + snapshot.execution_state == CodingAgentExecutionState::Started + }); + assert!( + notification_frame("session/cancel", json!({"sessionId":"s".repeat(70_000)}),) + .unwrap() + .len() + > 64 * 1024, + "cancel backpressure fixture must exceed the measured special Linux pipe capacity" + ); + + let cancel_started = Instant::now(); + let cancelled = manager.handle( + CodingAgentRequest::Cancel(CodingAgentCancelRequest { + run_id: run.to_string(), + }), + &projects, + ); + assert!( + cancel_started.elapsed() < Duration::from_secs(1), + "Cancel waited on the later session/cancel write" + ); + let cancel_snapshot = match cancelled.payload.unwrap() { + CodingAgentResponsePayload::Cancel { run } => run, + other => panic!("unexpected cancel payload: {other:?}"), + }; + assert_ne!( + cancel_snapshot.execution_state, + CodingAgentExecutionState::NotStarted + ); + let terminal_started = Instant::now(); + let drain = manager.drain_workers_until( + Instant::now() + ACP_CANCEL_GRACE + ACP_IO_CLEANUP_TIMEOUT + Duration::from_secs(1), + ); + assert_eq!(drain.timed_out, 0); + assert_eq!(drain.panicked, 0); + let snapshot = manager.runs.lock().unwrap().get(run).unwrap().snapshot(); + assert!( + terminal_started.elapsed() + < ACP_CANCEL_GRACE + ACP_IO_CLEANUP_TIMEOUT + Duration::from_secs(1), + "blocked session/cancel escaped ACP_CANCEL_GRACE plus cleanup bound" + ); + assert_eq!(snapshot.state, CodingAgentRunState::Lost); + assert_eq!( + snapshot.execution_state, + CodingAgentExecutionState::OutcomeUnknown + ); + assert_eq!( + snapshot + .terminal + .as_ref() + .and_then(|terminal| terminal.error_code.as_deref()), + Some("coding_agent_cancel_write_uncertain") + ); + let log = wire_log(&temp); + let startup_pid = log + .iter() + .find_map(|entry| entry.get("startup_pid").and_then(Value::as_u64)) + .unwrap(); + let descendant_pid = log + .iter() + .find_map(|entry| entry.get("descendant_pid").and_then(Value::as_u64)) + .unwrap(); + assert_eq!(manager.worker_count(), 0); + #[cfg(target_os = "linux")] + { + wait_for_proc_exit(startup_pid); + wait_for_proc_exit(descendant_pid); + } + } + + #[test] + #[cfg(unix)] + fn cancel_during_initialize_never_dispatches_prompt() { + let temp = TempDir::new().unwrap(); + let (exe, args) = fake_agent(&temp, "block_initialize"); + let cfg = fake_config(exe, args); + let projects = project_fixture(&temp); + let root = temp.path().join("repo"); + let manager = CodingAgentManager::with_store(&cfg, temp.path().join("store")).unwrap(); + let run = "wc_agent_run_cancelinitialize01"; + let started = manager.handle( + start_request(&manager, &root, run, BTreeMap::new()), + &projects, + ); + assert!(started.error.is_none(), "{:?}", started.error); + wait_for_path(&temp.path().join("initialize.ready")); + + let cancelled = manager.handle( + CodingAgentRequest::Cancel(CodingAgentCancelRequest { + run_id: run.to_string(), + }), + &projects, + ); + let snapshot = match cancelled.payload.unwrap() { + CodingAgentResponsePayload::Cancel { run } => run, + other => panic!("unexpected cancel payload: {other:?}"), + }; + assert_eq!(snapshot.state, CodingAgentRunState::Cancelled); + assert_eq!( + snapshot.execution_state, + CodingAgentExecutionState::NotStarted + ); + assert_eq!( + snapshot + .terminal + .as_ref() + .and_then(|t| t.stop_reason.as_deref()), + None + ); + fs::write(temp.path().join("initialize.release"), b"release").unwrap(); + let drain = manager.drain_workers_until(Instant::now() + Duration::from_secs(3)); + assert_eq!(drain.timed_out, 0); + assert_eq!(drain.panicked, 0); + let methods = received_methods(&wire_log(&temp)); + assert_eq!( + methods + .iter() + .filter(|m| m.as_str() == "session/prompt") + .count(), + 0 + ); + assert_eq!( + methods + .iter() + .filter(|m| m.as_str() == "session/cancel") + .count(), + 0 + ); + let final_snapshot = manager.runs.lock().unwrap().get(run).unwrap().snapshot(); + assert_eq!(final_snapshot.state, CodingAgentRunState::Cancelled); + assert_eq!( + final_snapshot.execution_state, + CodingAgentExecutionState::NotStarted + ); + } + + #[test] + #[cfg(unix)] + fn cancel_and_prompt_gate_race_has_only_linearized_outcomes() { + let temp = TempDir::new().unwrap(); + let (exe, args) = fake_agent(&temp, "wait_cancel"); + let cfg = fake_config(exe, args); + let projects = project_fixture(&temp); + let root = temp.path().join("repo"); + let manager = CodingAgentManager::with_store(&cfg, temp.path().join("store")).unwrap(); + let run = "wc_agent_run_promptgaterace01"; + let race = Arc::new(std::sync::Barrier::new(2)); + *manager.prompt_dispatch_test_barrier.lock().unwrap() = Some(Arc::clone(&race)); + let started = manager.handle( + start_request(&manager, &root, run, BTreeMap::new()), + &projects, + ); + assert!(started.error.is_none(), "{:?}", started.error); + + let cancel_manager = Arc::clone(&manager); + let cancel_projects = projects.clone(); + let cancel_race = Arc::clone(&race); + let cancel = thread::spawn(move || { + cancel_race.wait(); + cancel_manager.handle( + CodingAgentRequest::Cancel(CodingAgentCancelRequest { + run_id: run.to_string(), + }), + &cancel_projects, + ) + }); + let cancelled = cancel.join().unwrap(); + assert!(cancelled.error.is_none(), "{:?}", cancelled.error); + *manager.prompt_dispatch_test_barrier.lock().unwrap() = None; + let final_snapshot = wait_for_snapshot(&manager, run, |snapshot| snapshot.state.terminal()); + let drain = manager.drain_workers_until(Instant::now() + Duration::from_secs(3)); + assert_eq!(drain.timed_out, 0); + let methods = received_methods(&wire_log(&temp)); + let prompts = methods + .iter() + .filter(|m| m.as_str() == "session/prompt") + .count(); + let cancels = methods + .iter() + .filter(|m| m.as_str() == "session/cancel") + .count(); + assert!( + prompts <= 1, + "prompt dispatched more than once: {methods:?}" + ); + match prompts { + 0 => { + assert_eq!(final_snapshot.state, CodingAgentRunState::Cancelled); + assert_eq!( + final_snapshot.execution_state, + CodingAgentExecutionState::NotStarted + ); + assert_eq!(cancels, 0); + } + 1 => { + assert_eq!(final_snapshot.state, CodingAgentRunState::Cancelled); + assert_eq!( + final_snapshot.execution_state, + CodingAgentExecutionState::Completed + ); + assert_eq!(cancels, 1); + } + _ => unreachable!(), + } + } + + #[test] + #[cfg(unix)] + fn shutdown_during_admission_catches_published_run_before_prompt() { + let temp = TempDir::new().unwrap(); + let (exe, args) = fake_agent(&temp, "wait_cancel"); + let cfg = fake_config(exe, args); + let projects = project_fixture(&temp); + let root = temp.path().join("repo"); + let manager = CodingAgentManager::with_store(&cfg, temp.path().join("store")).unwrap(); + let run = "wc_agent_run_shutdownadmission01"; + let publish_barrier = Arc::new(std::sync::Barrier::new(2)); + *manager + .admission_after_accepting_test_barrier + .lock() + .unwrap() = Some(Arc::clone(&publish_barrier)); + manager + .admission_after_accepting_test_reached + .store(false, Ordering::SeqCst); + + let start_manager = Arc::clone(&manager); + let start_projects = projects.clone(); + let request = start_request(&manager, &root, run, BTreeMap::new()); + let start = thread::spawn(move || start_manager.handle(request, &start_projects)); + let reached_deadline = Instant::now() + Duration::from_secs(5); + while !manager + .admission_after_accepting_test_reached + .load(Ordering::SeqCst) + { + assert!(Instant::now() < reached_deadline); + thread::sleep(Duration::from_millis(5)); + } + let shutdown_manager = Arc::clone(&manager); + let shutdown = thread::spawn(move || shutdown_manager.stop_accepting()); + let stopping_deadline = Instant::now() + Duration::from_secs(5); + while manager.accepting.load(Ordering::Acquire) { + assert!(Instant::now() < stopping_deadline); + thread::sleep(Duration::from_millis(5)); + } + publish_barrier.wait(); + let started = start.join().unwrap(); + assert!(started.error.is_none(), "{:?}", started.error); + shutdown.join().unwrap(); + *manager + .admission_after_accepting_test_barrier + .lock() + .unwrap() = None; + let drain = manager.drain_workers_until(Instant::now() + Duration::from_secs(3)); + assert_eq!(drain.timed_out, 0); + let final_snapshot = manager.runs.lock().unwrap().get(run).unwrap().snapshot(); + assert_eq!(final_snapshot.state, CodingAgentRunState::Cancelled); + assert_eq!( + final_snapshot.execution_state, + CodingAgentExecutionState::NotStarted + ); + assert_eq!( + received_methods(&wire_log(&temp)) + .iter() + .filter(|m| m.as_str() == "session/prompt") + .count(), + 0 + ); + } + + #[test] + #[cfg(unix)] + fn shutdown_drains_setup_worker_and_reaps_provider_tree() { + let temp = TempDir::new().unwrap(); + let (exe, args) = fake_agent(&temp, "block_initialize_tree"); + let cfg = fake_config(exe, args); + let projects = project_fixture(&temp); + let root = temp.path().join("repo"); + let manager = CodingAgentManager::with_store(&cfg, temp.path().join("store")).unwrap(); + let run = "wc_agent_run_shutdowndrain01"; + let started = manager.handle( + start_request(&manager, &root, run, BTreeMap::new()), + &projects, + ); + assert!(started.error.is_none(), "{:?}", started.error); + wait_for_path(&temp.path().join("initialize.ready")); + assert!(manager.worker_count() > 0); + let log = wire_log(&temp); + let startup_pid = log + .iter() + .find_map(|entry| entry.get("startup_pid").and_then(Value::as_u64)) + .unwrap(); + let descendant_pid = log + .iter() + .find_map(|entry| entry.get("descendant_pid").and_then(Value::as_u64)) + .unwrap(); + + manager.stop_accepting(); + fs::write(temp.path().join("initialize.release"), b"release").unwrap(); + let drain = manager.drain_workers_until(Instant::now() + Duration::from_secs(3)); + assert!(drain.resources > 0); + assert_eq!(drain.timed_out, 0); + assert_eq!(drain.panicked, 0); + assert_eq!(manager.worker_count(), 0); + let final_snapshot = manager.runs.lock().unwrap().get(run).unwrap().snapshot(); + assert_eq!(final_snapshot.state, CodingAgentRunState::Cancelled); + assert_eq!( + final_snapshot.execution_state, + CodingAgentExecutionState::NotStarted + ); + assert_eq!( + received_methods(&wire_log(&temp)) + .iter() + .filter(|m| m.as_str() == "session/prompt") + .count(), + 0 + ); + #[cfg(target_os = "linux")] + { + wait_for_proc_exit(startup_pid); + wait_for_proc_exit(descendant_pid); + } + } + + #[test] + #[cfg(unix)] + fn capacity_stale_provider_and_replay_are_fenced_before_duplicate_prompt() { + let temp = TempDir::new().unwrap(); + let (exe, args) = fake_agent(&temp, "wait_cancel"); + let cfg = fake_config(exe, args); + let projects = project_fixture(&temp); + let root = temp.path().join("repo"); + let manager = CodingAgentManager::with_store(&cfg, temp.path().join("store")).unwrap(); + let first_run = "wc_agent_run_capacity0001"; + assert!(manager + .handle( + start_request(&manager, &root, first_run, BTreeMap::new()), + &projects + ) + .error + .is_none()); + wait_for_snapshot(&manager, first_run, |snapshot| { + snapshot.state == CodingAgentRunState::Running + }); + let second = manager.handle( + start_request( + &manager, + &root, + "wc_agent_run_capacity0002", + BTreeMap::new(), + ), + &projects, + ); + assert_eq!( + second.error.as_ref().map(|error| error.code.as_str()), + Some("coding_agent_capacity_full") + ); + let mut stale = start_request(&manager, &root, "wc_agent_run_stale000001", BTreeMap::new()); + if let CodingAgentRequest::Start(request) = &mut stale { + request.provider_instance_id = "replaced-provider".to_string(); + } + let stale = manager.handle(stale, &projects); + assert_eq!( + stale.error.as_ref().map(|error| error.code.as_str()), + Some("stale_coding_agent_provider") + ); + manager.handle( + CodingAgentRequest::Cancel(CodingAgentCancelRequest { + run_id: first_run.to_string(), + }), + &projects, + ); + wait_for_snapshot(&manager, first_run, |snapshot| snapshot.state.terminal()); + + let temp = TempDir::new().unwrap(); + let (exe, args) = fake_agent(&temp, "end"); + let cfg = fake_config(exe, args); + let projects = project_fixture(&temp); + let root = temp.path().join("repo"); + let manager = CodingAgentManager::with_store(&cfg, temp.path().join("store")).unwrap(); + let run = "wc_agent_run_replay000001"; + let request = start_request(&manager, &root, run, BTreeMap::new()); + assert!(manager.handle(request.clone(), &projects).error.is_none()); + wait_for_snapshot(&manager, run, |snapshot| snapshot.state.terminal()); + assert!(manager.handle(request, &projects).error.is_none()); + assert_eq!( + received_methods(&wire_log(&temp)) + .iter() + .filter(|method| method.as_str() == "session/prompt") + .count(), + 1 + ); + let mut conflict = start_request(&manager, &root, run, BTreeMap::new()); + if let CodingAgentRequest::Start(request) = &mut conflict { + request.intent_fingerprint = "different-fingerprint".to_string(); + } + assert_eq!( + manager + .handle(conflict, &projects) + .error + .as_ref() + .map(|error| error.code.as_str()), + Some("idempotency_conflict") + ); + } + + #[test] + #[cfg(unix)] + fn corrupt_durable_record_after_possible_dispatch_fails_closed_without_redispatch() { + let temp = TempDir::new().unwrap(); + let (exe, args) = fake_agent(&temp, "end"); + let cfg = fake_config(exe, args); + let projects = project_fixture(&temp); + let root = temp.path().join("repo"); + let manager = CodingAgentManager::with_store(&cfg, temp.path().join("store")).unwrap(); + let run = "wc_agent_run_corruptdurable01"; + let request = start_request(&manager, &root, run, BTreeMap::new()); + assert!(manager.handle(request.clone(), &projects).error.is_none()); + wait_for_snapshot(&manager, run, |snapshot| snapshot.state.terminal()); + assert_eq!( + received_methods(&wire_log(&temp)) + .iter() + .filter(|method| method.as_str() == "session/prompt") + .count(), + 1 + ); + + manager.runs.lock().unwrap().remove(run); + fs::write(manager.store.state_path(run), b"{corrupt-json").unwrap(); + let corrupt = manager.handle(request.clone(), &projects); + assert_eq!( + corrupt.dispatch_state, + CodingAgentDispatchState::OutcomeUnknown + ); + assert_eq!( + corrupt.error.as_ref().map(|error| error.code.as_str()), + Some("coding_agent_durable_state_unavailable") + ); + + fs::remove_file(manager.store.state_path(run)).unwrap(); + let missing = manager.handle(request.clone(), &projects); + assert_eq!( + missing.dispatch_state, + CodingAgentDispatchState::OutcomeUnknown + ); + assert_eq!( + missing.error.as_ref().map(|error| error.code.as_str()), + Some("coding_agent_durable_state_unavailable") + ); + let store_root = manager.store.root.clone(); + drop(manager); + let restarted = CodingAgentManager::with_store(&cfg, store_root).unwrap(); + let after_restart = restarted.handle( + start_request(&restarted, &root, run, BTreeMap::new()), + &projects, + ); + assert_eq!( + after_restart.dispatch_state, + CodingAgentDispatchState::OutcomeUnknown + ); + assert_eq!( + after_restart + .error + .as_ref() + .map(|error| error.code.as_str()), + Some("coding_agent_durable_state_unavailable") + ); + assert_eq!( + received_methods(&wire_log(&temp)) + .iter() + .filter(|method| method.as_str() == "session/prompt") + .count(), + 1, + "missing or corrupt durable state after a possible prompt must never become retry authority" + ); + } + + #[test] + fn durable_store_replaces_existing_state_cross_platform() { + let temp = TempDir::new().unwrap(); + let store = DurableRunStore { + root: temp.path().join("store"), + }; + let timestamp = now(); + let mut record = DurableRunRecord { + schema_version: STORE_SCHEMA_VERSION, + run_id: "wc_agent_run_replace_state01".to_string(), + intent_fingerprint: "fingerprint".to_string(), + authority_fingerprint: "auth_replace".to_string(), + runtime_project_id: "agent:test:demo".to_string(), + provider_id: "codex".to_string(), + provider_instance_id: "acp_replace".to_string(), + state: CodingAgentRunState::Starting, + execution_state: CodingAgentExecutionState::NotStarted, + dispatch_phase: DurableDispatchPhase::BeforePromptBarrier, + created_at: timestamp, + updated_at: timestamp, + terminal: None, + }; + store.write(&record).unwrap(); + record.state = CodingAgentRunState::Running; + record.execution_state = CodingAgentExecutionState::OutcomeUnknown; + record.dispatch_phase = DurableDispatchPhase::PromptDispatchMayHaveOccurred; + record.updated_at = timestamp.saturating_add(1); + store.write(&record).unwrap(); + let restored = store.read(&record.run_id).unwrap().unwrap(); + assert_eq!( + restored.dispatch_phase, + DurableDispatchPhase::PromptDispatchMayHaveOccurred + ); + assert_eq!( + restored.execution_state, + CodingAgentExecutionState::OutcomeUnknown + ); + } + + #[test] + fn project_binding_requires_current_writable_registration() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("repo"); + let other = temp.path().join("other"); + fs::create_dir_all(&root).unwrap(); + fs::create_dir_all(&other).unwrap(); + let projects = temp.path().join("projects"); + fs::create_dir_all(&projects).unwrap(); + let config_path = projects.join("p.toml"); + let write_registration = |allow_patch: bool, disabled: bool, path: &Path| { + fs::write( + &config_path, + format!( + "id = \"demo\"\npath = {:?}\nallow_patch = {allow_patch}\ndisabled = {disabled}\n", + path.to_string_lossy() + ), + ) + .unwrap(); + }; + + write_registration(false, false, &root); + assert!(!project_binding_matches( + &projects, + "test", + "agent:test:demo", + root.to_string_lossy().as_ref() + )); + + write_registration(true, true, &root); + assert!(!project_binding_matches( + &projects, + "test", + "agent:test:demo", + root.to_string_lossy().as_ref() + )); + + write_registration(true, false, &root); + assert!(project_binding_matches( + &projects, + "test", + "agent:test:demo", + root.to_string_lossy().as_ref() + )); + assert!(!project_binding_matches( + &projects, + "test", + "agent:test:wrong", + root.to_string_lossy().as_ref() + )); + assert!(!project_binding_matches( + &projects, + "test", + "agent:test:demo", + other.to_string_lossy().as_ref() + )); + } + + #[cfg(unix)] + #[test] + fn project_binding_accepts_canonical_alias_and_rejects_symlink_retarget() { + use std::os::unix::fs::symlink; + + let temp = TempDir::new().unwrap(); + let first = temp.path().join("first"); + let second = temp.path().join("second"); + let alias = temp.path().join("repo-alias"); + fs::create_dir_all(&first).unwrap(); + fs::create_dir_all(&second).unwrap(); + symlink(&first, &alias).unwrap(); + let projects = temp.path().join("projects"); + fs::create_dir_all(&projects).unwrap(); + fs::write( + projects.join("p.toml"), + format!( + "id = \"demo\"\npath = {:?}\nallow_patch = true\n", + alias.to_string_lossy() + ), + ) + .unwrap(); + let canonical_first = canonicalize_existing(&first).unwrap(); + assert!(project_binding_matches( + &projects, + "test", + "agent:test:demo", + canonical_first.to_string_lossy().as_ref() + )); + + fs::remove_file(&alias).unwrap(); + symlink(&second, &alias).unwrap(); + assert!(!project_binding_matches( + &projects, + "test", + "agent:test:demo", + canonical_first.to_string_lossy().as_ref() + )); + } + + #[cfg(target_os = "macos")] + #[test] + fn project_binding_accepts_var_private_var_alias() { + assert_eq!( + canonicalize_existing(Path::new("/var")).unwrap(), + canonicalize_existing(Path::new("/private/var")).unwrap() + ); + let temp = TempDir::new().unwrap(); + let projects = temp.path().join("projects"); + fs::create_dir_all(&projects).unwrap(); + fs::write( + projects.join("p.toml"), + "id = \"demo\"\npath = \"/private/var\"\nallow_patch = true\n", + ) + .unwrap(); + assert!(project_binding_matches( + &projects, + "test", + "agent:test:demo", + "/var" + )); + } + + #[cfg(windows)] + #[test] + fn project_binding_accepts_windows_case_and_verbatim_disk_identity() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("RepoCase"); + fs::create_dir_all(&root).unwrap(); + let canonical = canonicalize_existing(&root).unwrap(); + let canonical_text = canonical.to_string_lossy().to_string(); + let plain = canonical_text + .strip_prefix(r"\\?\") + .unwrap_or(&canonical_text) + .to_string(); + let case_variant = plain.to_ascii_uppercase(); + let verbatim = format!(r"\\?\{plain}"); + let projects = temp.path().join("projects"); + fs::create_dir_all(&projects).unwrap(); + fs::write( + projects.join("p.toml"), + format!("id = \"demo\"\npath = {:?}\nallow_patch = true\n", plain), + ) + .unwrap(); + assert!(project_binding_matches( + &projects, + "test", + "agent:test:demo", + &case_variant + )); + assert!(project_binding_matches( + &projects, + "test", + "agent:test:demo", + &verbatim + )); + } + + #[test] + #[cfg(unix)] + fn child_environment_is_cleared_and_missing_mapping_never_spawns() { + let _guard = crate::tests::test_env_lock(); + let _env = crate::tests::EnvGuard::new() + .set("WEBCODEX_TEST_ACP_VISIBLE", "visible-value") + .set("WEBCODEX_TEST_ACP_HIDDEN", "must-not-reach-child"); + let temp = TempDir::new().unwrap(); + let (exe, args) = fake_agent(&temp, "end"); + let mut cfg = fake_config(exe, args); + cfg.agents[0].env_from_env = BTreeMap::from([( + "ACP_VISIBLE".to_string(), + "WEBCODEX_TEST_ACP_VISIBLE".to_string(), + )]); + let projects = project_fixture(&temp); + let root = temp.path().join("repo"); + let manager = CodingAgentManager::with_store(&cfg, temp.path().join("store")).unwrap(); + let run = "wc_agent_run_envclear0001"; + assert!(manager + .handle( + start_request(&manager, &root, run, BTreeMap::new()), + &projects + ) + .error + .is_none()); + wait_for_snapshot(&manager, run, |snapshot| snapshot.state.terminal()); + let startup = wire_log(&temp) + .into_iter() + .find(|entry| entry.get("env_keys").is_some()) + .unwrap(); + assert_eq!(startup["env_keys"], json!(["ACP_VISIBLE"])); + + let temp = TempDir::new().unwrap(); + let (exe, args) = fake_agent(&temp, "end"); + let mut cfg = fake_config(exe, args); + cfg.agents[0].env_from_env = BTreeMap::from([( + "ACP_VISIBLE".to_string(), + "WEBCODEX_TEST_ACP_MISSING".to_string(), + )]); + let projects = project_fixture(&temp); + let root = temp.path().join("repo"); + let manager = CodingAgentManager::with_store(&cfg, temp.path().join("store")).unwrap(); + let response = manager.handle( + start_request( + &manager, + &root, + "wc_agent_run_missingenv01", + BTreeMap::new(), + ), + &projects, + ); + assert_eq!( + response.error.as_ref().map(|error| error.code.as_str()), + Some("coding_agent_environment_unavailable") + ); + assert!( + wire_log(&temp).is_empty(), + "provider child must not start when an env source is missing" + ); + } + + #[test] + #[cfg(unix)] + fn pre_barrier_restart_is_not_started_and_child_tree_is_reaped() { + let temp = TempDir::new().unwrap(); + let (exe, args) = fake_agent(&temp, "end"); + let cfg = fake_config(exe, args); + let store_root = temp.path().join("store"); + fs::create_dir_all(&store_root).unwrap(); + let initial = CodingAgentManager::with_store(&cfg, store_root.clone()).unwrap(); + let provider = initial.providers().remove(0); + drop(initial); + let timestamp = now(); + DurableRunStore { + root: store_root.clone(), + } + .write(&DurableRunRecord { + schema_version: STORE_SCHEMA_VERSION, + run_id: "wc_agent_run_prebarrier01".to_string(), + intent_fingerprint: "fingerprint".to_string(), + authority_fingerprint: "auth_test".to_string(), + runtime_project_id: "agent:test:demo".to_string(), + provider_id: "codex".to_string(), + provider_instance_id: provider.provider_instance_id, + state: CodingAgentRunState::Starting, + execution_state: CodingAgentExecutionState::NotStarted, + dispatch_phase: DurableDispatchPhase::BeforePromptBarrier, + created_at: timestamp, + updated_at: timestamp, + terminal: None, + }) + .unwrap(); + let restarted = CodingAgentManager::with_store(&cfg, store_root).unwrap(); + let recovered = restarted + .runs + .lock() + .unwrap() + .get("wc_agent_run_prebarrier01") + .unwrap() + .snapshot(); + assert_eq!(recovered.state, CodingAgentRunState::Failed); + assert_eq!( + recovered.execution_state, + CodingAgentExecutionState::NotStarted + ); + + let temp = TempDir::new().unwrap(); + let (exe, args) = fake_agent(&temp, "spawn_descendant"); + let cfg = fake_config(exe, args); + let projects = project_fixture(&temp); + let root = temp.path().join("repo"); + let manager = CodingAgentManager::with_store(&cfg, temp.path().join("store")).unwrap(); + let run = "wc_agent_run_reaptree0001"; + assert!(manager + .handle( + start_request(&manager, &root, run, BTreeMap::new()), + &projects + ) + .error + .is_none()); + wait_for_snapshot(&manager, run, |snapshot| snapshot.state.terminal()); + let descendant = wire_log(&temp) + .iter() + .find_map(|entry| entry.get("descendant_pid").and_then(Value::as_u64)) + .unwrap(); + let proc_path = PathBuf::from(format!("/proc/{descendant}")); + let deadline = Instant::now() + Duration::from_secs(3); + while proc_path.exists() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(20)); + } + assert!( + !proc_path.exists(), + "ACP descendant process survived ManagedChild cleanup" + ); + } + + #[test] + #[ignore = "opt-in real Codex ACP dogfood; requires local Codex auth and network"] + #[cfg(unix)] + fn real_codex_acp_opt_in_dogfood() { + let temp = TempDir::new().unwrap(); + let root = std::env::current_dir().unwrap(); + let projects = temp.path().join("projects.d"); + fs::create_dir_all(&projects).unwrap(); + fs::write( + projects.join("dogfood.toml"), + format!("id = \"demo\"\npath = {:?}\n", root.to_string_lossy()), + ) + .unwrap(); + + let mut env_from_env = BTreeMap::new(); + // This list is dogfood/test-owned and intentionally explicit. Production + // providers inherit nothing unless the operator declares each mapping. + for name in [ + "HOME", + "PATH", + "USER", + "SHELL", + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "no_proxy", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + ] { + if std::env::var_os(name).is_some() { + env_from_env.insert(name.to_string(), name.to_string()); + } + } + let cfg = AcpConfig { + max_concurrent_runs: 1, + permission_timeout_secs: 3, + agents: vec![AcpAgentConfig { + id: "codex".to_string(), + name: "Codex ACP dogfood".to_string(), + executable: "npx".to_string(), + args: vec![ + "-y".to_string(), + "@agentclientprotocol/codex-acp".to_string(), + ], + env_from_env, + allowed_config_options: Vec::new(), + }], + }; + let manager = CodingAgentManager::with_store(&cfg, temp.path().join("store")).unwrap(); + let run = "wc_agent_run_realcodexdogfood01"; + let provider = manager.providers().remove(0); + let request = CodingAgentRequest::Start(webcodex_core::coding_agent::CodingAgentStartRequest { + run_id: run.to_string(), + intent_fingerprint: "real-codex-dogfood-v1".to_string(), + authority_fingerprint: "auth_real_codex_dogfood".to_string(), + runtime_project_id: "agent:test:demo".to_string(), + project_root: root.to_string_lossy().into_owned(), + provider_id: "codex".to_string(), + provider_instance_id: provider.provider_instance_id, + instruction: "Read Cargo.toml only and reply with the WebCodex package version in one short sentence. Do not modify files, run builds, install dependencies, or request elevated permissions.".to_string(), + config: BTreeMap::new(), + timeout_secs: 180, + }); + let admitted = manager.handle(request, &projects); + assert!( + admitted.error.is_none(), + "admission failed: {:?}", + admitted.error + ); + let deadline = Instant::now() + Duration::from_secs(150); + let terminal = loop { + let snapshot = manager.runs.lock().unwrap().get(run).unwrap().snapshot(); + if snapshot.state.terminal() { + break snapshot; + } + assert!( + Instant::now() < deadline, + "real Codex ACP dogfood timed out: {snapshot:?}" + ); + thread::sleep(Duration::from_millis(100)); + }; + let observation = manager + .runs + .lock() + .unwrap() + .get(run) + .unwrap() + .observe(None, 64, 0) + .expect("retained real Run cursor must be valid"); + assert_eq!( + terminal.state, + CodingAgentRunState::Completed, + "terminal={terminal:?}; events={:?}", + observation.events + ); + assert_eq!( + terminal + .terminal + .as_ref() + .and_then(|terminal| terminal.stop_reason.as_deref()), + Some("end_turn") + ); + assert!( + observation + .events + .iter() + .any(|event| event.kind == CodingAgentEventKind::AgentMessage), + "real Codex ACP produced no normalized agent message: {:?}", + observation.events + ); + assert!( + observation.events.iter().any(|event| matches!( + event.kind, + CodingAgentEventKind::ToolActivity + | CodingAgentEventKind::TerminalActivity + | CodingAgentEventKind::Usage + )), + "real Codex ACP produced no normalized activity: {:?}", + observation.events + ); + } + + #[test] + #[cfg(unix)] + fn fake_acp_normalizes_activity_and_terminal_matrix() { + let (_, _, obs) = run_scenario("end", BTreeMap::new()); + assert_eq!(obs.run.state, CodingAgentRunState::Completed); + assert!(obs + .events + .iter() + .any(|e| e.kind == CodingAgentEventKind::AgentMessage)); + assert!(obs + .events + .iter() + .any(|e| e.kind == CodingAgentEventKind::Reasoning)); + assert!(obs + .events + .iter() + .any(|e| e.kind == CodingAgentEventKind::TerminalActivity)); + for scenario in [ + "cancelled", + "max_tokens", + "max_turn_requests", + "refusal", + "unknown", + ] { + let (_, _, obs) = run_scenario(scenario, BTreeMap::new()); + if scenario == "cancelled" { + assert_eq!(obs.run.state, CodingAgentRunState::Cancelled); + } else { + assert_eq!(obs.run.state, CodingAgentRunState::Failed); + } + } + } + + #[test] + #[cfg(unix)] + fn config_and_permission_paths_are_fail_closed() { + let (_, _, obs) = run_scenario( + "end", + BTreeMap::from([( + "mode".to_string(), + CodingAgentConfigValue::String("read-only".to_string()), + )]), + ); + assert_eq!(obs.run.state, CodingAgentRunState::Completed); + let (_, _, permission) = run_scenario("permission", BTreeMap::new()); + assert_eq!(permission.run.state, CodingAgentRunState::Cancelled); + assert!(permission + .events + .iter() + .any(|e| e.kind == CodingAgentEventKind::PermissionRequest)); + } + + #[test] + #[cfg(unix)] + fn post_barrier_crash_is_lost_and_restart_never_redispatches() { + let (manager, run, obs) = run_scenario("crash_after_prompt", BTreeMap::new()); + assert_eq!(obs.run.state, CodingAgentRunState::Lost); + let cfg = AcpConfig { + max_concurrent_runs: manager.max_concurrent_runs, + permission_timeout_secs: 1, + agents: manager + .providers + .values() + .map(|p| p.config.clone()) + .collect(), + }; + let restarted = CodingAgentManager::with_store(&cfg, manager.store.root.clone()).unwrap(); + assert_eq!( + restarted + .runs + .lock() + .unwrap() + .get(&run) + .unwrap() + .snapshot() + .state, + CodingAgentRunState::Lost + ); + } + + #[test] + #[cfg(unix)] + fn durable_record_contains_no_prompt_or_event_bodies() { + let (manager, run, _) = run_scenario("end", BTreeMap::new()); + let bytes = fs::read(manager.store.state_path(&run)).unwrap(); + let text = String::from_utf8(bytes).unwrap(); + assert!(!text.contains("inspect")); + assert!(!text.contains("hello")); + assert!(!text.contains("thinking")); + } +} diff --git a/crates/webcodex-runner/src/webcodex_runner/computer_tests.rs b/crates/webcodex-runner/src/webcodex_runner/computer_tests.rs index cabac29b..e90fd076 100644 --- a/crates/webcodex-runner/src/webcodex_runner/computer_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/computer_tests.rs @@ -27,6 +27,7 @@ fn request(kind: &str, payload: &str) -> ShellAgentShellRequest { sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, } } diff --git a/crates/webcodex-runner/src/webcodex_runner/config.rs b/crates/webcodex-runner/src/webcodex_runner/config.rs index 9ec3a30c..6aca40b4 100644 --- a/crates/webcodex-runner/src/webcodex_runner/config.rs +++ b/crates/webcodex-runner/src/webcodex_runner/config.rs @@ -1,3 +1,4 @@ +use super::coding_agent::CodingAgentManager; use super::external_tools::ExternalToolRouter; use super::mcp_gateway::McpGatewayManager; use super::shutdown::lock_unpoison; @@ -76,6 +77,60 @@ pub(crate) struct AgentConfig { /// built-in MCP gateway. The public config section is `[mcp]`. #[serde(default, rename = "mcp")] pub(crate) mcp_gateway: McpGatewayConfig, + /// Startup/restart-owned ACP coding-agent providers. This is independent + /// from MCP tool providers and never accepts caller-controlled executable/env. + #[serde(default)] + pub(crate) acp: AcpConfig, +} + +const ACP_MAX_ENV_MAPPINGS: usize = 64; +const ACP_MAX_ENV_NAME_BYTES: usize = 256; +const ACP_MAX_ARGS: usize = 64; +const ACP_MAX_ARG_BYTES: usize = 4096; +const ACP_MAX_ARGS_BYTES: usize = 16 * 1024; + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub(crate) struct AcpConfig { + #[serde(default = "default_acp_max_concurrent_runs")] + pub(crate) max_concurrent_runs: usize, + #[serde(default = "default_acp_permission_timeout_secs")] + pub(crate) permission_timeout_secs: u64, + #[serde(default)] + pub(crate) agents: Vec, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub(crate) struct AcpAgentConfig { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) executable: String, + #[serde(default)] + pub(crate) args: Vec, + /// Explicit provider-env-key -> Runner-process-env-key mapping. The child + /// environment is cleared before these mappings are injected. + #[serde(default)] + pub(crate) env_from_env: BTreeMap, + /// Remote callers may override only live ACP config options whose ids are + /// explicitly named here. The live advertised option still validates value. + #[serde(default)] + pub(crate) allowed_config_options: Vec, +} + +fn default_acp_max_concurrent_runs() -> usize { + 1 +} +fn default_acp_permission_timeout_secs() -> u64 { + 5 +} + +impl Default for AcpConfig { + fn default() -> Self { + Self { + max_concurrent_runs: default_acp_max_concurrent_runs(), + permission_timeout_secs: default_acp_permission_timeout_secs(), + agents: Vec::new(), + } + } } const MCP_GATEWAY_MAX_ENV_MAPPINGS: usize = 64; @@ -385,6 +440,7 @@ impl HotAgentConfig { pub(crate) struct ReloadableAgentConfig { startup: AgentConfig, mcp_gateway: Arc, + coding_agents: Option>, /// Config file path used by `reload()`. Config reload is a Unix feature /// (Windows marks reload as unsupported and never stores the path), but /// the reload logic is exercised by cross-platform tests. @@ -406,8 +462,20 @@ impl ReloadableAgentConfig { let _ = &path; let current = Arc::new(HotAgentConfig::new(1, &startup, status)); let external_routers = vec![Arc::downgrade(¤t.external_tools)]; + let coding_agents = if startup.acp.agents.is_empty() { + None + } else { + match CodingAgentManager::new(&startup.acp, &startup.client_id, &startup.server_url) { + Ok(manager) => Some(manager), + Err(error) => { + tracing::error!(error = %error, "ACP coding-agent manager unavailable; ACP execution disabled fail closed"); + None + } + } + }; Self { mcp_gateway: Arc::new(McpGatewayManager::new(&startup.mcp_gateway)), + coding_agents, startup, #[cfg(any(unix, test))] path, @@ -428,6 +496,9 @@ impl ReloadableAgentConfig { pub(crate) fn begin_shutdown(&self) { self.stopping.store(true, Ordering::SeqCst); self.mcp_gateway.shutdown(); + if let Some(manager) = &self.coding_agents { + manager.stop_accepting(); + } } pub(crate) fn shutdown_flag(&self) -> &AtomicBool { @@ -438,6 +509,10 @@ impl ReloadableAgentConfig { &self.mcp_gateway } + pub(crate) fn coding_agents(&self) -> Option<&Arc> { + self.coding_agents.as_ref() + } + /// Startup-owned managed temporary-project root. Like `projects_dir`, a /// changed value is reported as restart-required so one running Runner /// cannot silently switch its project-registration boundary. @@ -548,6 +623,7 @@ pub(crate) fn restart_required_fields( hostname, host_context, max_concurrent_jobs, + acp, mcp_gateway, owner, poll_interval_ms, @@ -1017,9 +1093,139 @@ pub(crate) fn load_config(path: &Path) -> Result { } } validate_mcp_gateway_config(&cfg.mcp_gateway)?; + validate_acp_config(&cfg.acp)?; Ok(cfg) } +fn validate_acp_env_name(value: &str) -> Result<(), ()> { + if value.is_empty() + || value.len() > ACP_MAX_ENV_NAME_BYTES + || !value.is_ascii() + || value.contains('\0') + || value.contains('=') + { + return Err(()); + } + Ok(()) +} + +fn validate_acp_config(config: &AcpConfig) -> Result<(), String> { + use std::collections::HashSet; + use webcodex_core::coding_agent::{ + validate_provider_id, CODING_AGENT_MAX_CONFIG_KEY_BYTES, CODING_AGENT_MAX_PROVIDERS, + CODING_AGENT_MAX_PROVIDER_NAME_BYTES, + }; + + if !(1..=8).contains(&config.max_concurrent_runs) { + return Err("acp.max_concurrent_runs must be between 1 and 8".to_string()); + } + if !(1..=60).contains(&config.permission_timeout_secs) { + return Err("acp.permission_timeout_secs must be between 1 and 60".to_string()); + } + if config.agents.len() > CODING_AGENT_MAX_PROVIDERS { + return Err(format!( + "acp.agents may contain at most {CODING_AGENT_MAX_PROVIDERS} entries" + )); + } + let mut ids = HashSet::new(); + for agent in &config.agents { + validate_provider_id(&agent.id) + .map_err(|error| format!("ACP agent id is invalid: {error}"))?; + if agent.name.trim().is_empty() + || agent.name.len() > CODING_AGENT_MAX_PROVIDER_NAME_BYTES + || agent.name.chars().any(char::is_control) + { + return Err(format!("ACP agent '{}' name is invalid", agent.id)); + } + if !ids.insert(agent.id.as_str()) { + return Err("ACP agent ids must be unique".to_string()); + } + if agent.executable.is_empty() + || agent.executable.len() > 1024 + || agent.executable.contains('\0') + || !Path::new(&agent.executable).is_absolute() + { + return Err(format!( + "ACP agent '{}' executable must be an absolute path of at most 1024 bytes", + agent.id + )); + } + if agent.args.len() > ACP_MAX_ARGS { + return Err(format!( + "ACP agent '{}' args may contain at most {ACP_MAX_ARGS} entries", + agent.id + )); + } + let mut args_bytes = 0usize; + for arg in &agent.args { + if arg.len() > ACP_MAX_ARG_BYTES || arg.contains('\0') { + return Err(format!( + "ACP agent '{}' contains an invalid argument", + agent.id + )); + } + args_bytes = args_bytes.saturating_add(arg.len()).saturating_add(1); + } + if args_bytes > ACP_MAX_ARGS_BYTES { + return Err(format!( + "ACP agent '{}' args exceed {ACP_MAX_ARGS_BYTES} bytes", + agent.id + )); + } + if agent.env_from_env.len() > ACP_MAX_ENV_MAPPINGS { + return Err(format!( + "ACP agent '{}' env_from_env may contain at most {ACP_MAX_ENV_MAPPINGS} entries", + agent.id + )); + } + let mut destinations: Vec<&str> = Vec::new(); + for (destination, source) in &agent.env_from_env { + if validate_acp_env_name(destination).is_err() || validate_acp_env_name(source).is_err() + { + return Err(format!( + "ACP agent '{}' env_from_env contains an invalid environment variable name", + agent.id + )); + } + if super::shell::is_sensitive_env_key(destination) + || super::shell::is_sensitive_env_key(source) + { + return Err(format!( + "ACP agent '{}' env_from_env may not map WebCodex transport credentials", + agent.id + )); + } + if destinations + .iter() + .any(|existing| super::shell::env_keys_equal(existing, destination)) + { + return Err(format!("ACP agent '{}' env_from_env contains conflicting destination names for this platform", agent.id)); + } + destinations.push(destination); + } + if agent.allowed_config_options.len() > 64 { + return Err(format!( + "ACP agent '{}' allowed_config_options may contain at most 64 entries", + agent.id + )); + } + let mut config_ids = HashSet::new(); + for option in &agent.allowed_config_options { + if option.is_empty() + || option.len() > CODING_AGENT_MAX_CONFIG_KEY_BYTES + || option.contains(['\0', '\r', '\n']) + || !config_ids.insert(option.as_str()) + { + return Err(format!( + "ACP agent '{}' contains an invalid or duplicate allowed config option", + agent.id + )); + } + } + } + Ok(()) +} + fn validate_mcp_gateway_env_name(value: &str) -> Result<(), ()> { if value.is_empty() || value.len() > MCP_GATEWAY_MAX_ENV_NAME_BYTES diff --git a/crates/webcodex-runner/src/webcodex_runner/dispatch.rs b/crates/webcodex-runner/src/webcodex_runner/dispatch.rs index 1cee1bf8..7c3f2fd5 100644 --- a/crates/webcodex-runner/src/webcodex_runner/dispatch.rs +++ b/crates/webcodex-runner/src/webcodex_runner/dispatch.rs @@ -96,6 +96,44 @@ pub(crate) fn dispatch_request( if runtime.shutdown_flag().load(Ordering::SeqCst) { return Ok(false); } + if request.kind == "coding_agent" { + let request_id = request.request_id.clone(); + let response = match (runtime.coding_agents(), request.coding_agent) { + (Some(manager), Some(operation)) => manager.handle(operation, projects_dir), + (None, _) => webcodex_core::coding_agent::CodingAgentResponse::error( + webcodex_core::coding_agent::CodingAgentDispatchState::NotStarted, + "coding_agent_unavailable", + "Runner ACP coding-agent execution is not configured/available", + Some("unavailable"), + Some("reobserve"), + ), + (Some(_), None) => webcodex_core::coding_agent::CodingAgentResponse::error( + webcodex_core::coding_agent::CodingAgentDispatchState::NotStarted, + "invalid_coding_agent_request", + "Typed CodingAgentRun operation is required; request was not started", + Some("invalid_input"), + Some("fix_input"), + ), + }; + return sink + .submit_coding_agent_result(request_id, response) + .map(|_| true); + } + if request.coding_agent.is_some() { + let result = CommandResult { + exit_code: None, + stdout: None, + stderr: None, + duration_ms: Some(0), + error: Some( + "invalid_request: coding_agent payload is valid only for coding_agent requests; command was not started" + .to_string(), + ), + }; + return sink + .submit_result_with_metadata(request.request_id, result, config, runtime) + .map(|_| true); + } if request.kind == "mcp_gateway" { let request_id = request.request_id.clone(); let response = match request.mcp_gateway { diff --git a/crates/webcodex-runner/src/webcodex_runner/external_tools_tests.rs b/crates/webcodex-runner/src/webcodex_runner/external_tools_tests.rs index 3722bf29..80e39648 100644 --- a/crates/webcodex-runner/src/webcodex_runner/external_tools_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/external_tools_tests.rs @@ -229,6 +229,7 @@ fn agent_request( sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, } } diff --git a/crates/webcodex-runner/src/webcodex_runner/lsp/navigation_tests.rs b/crates/webcodex-runner/src/webcodex_runner/lsp/navigation_tests.rs index 17d1b7b4..cf00a1ae 100644 --- a/crates/webcodex-runner/src/webcodex_runner/lsp/navigation_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/lsp/navigation_tests.rs @@ -46,6 +46,7 @@ fn shell_lsp_request(payload: AgentLspPayload) -> ShellAgentShellRequest { sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, } } @@ -659,6 +660,7 @@ fn status_does_not_start_server_and_unavailable_succeeds() { sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, }; let result = handle_lsp_request(&policy, &projects_dir, &supervisor, &req); @@ -1544,6 +1546,7 @@ fn missing_lsp_payload_returns_structured_error() { sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, }; let result = handle_lsp_request( @@ -1592,6 +1595,7 @@ fn lsp_request_ignores_command_field() { sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, }; let result = handle_lsp_request( diff --git a/crates/webcodex-runner/src/webcodex_runner/mod.rs b/crates/webcodex-runner/src/webcodex_runner/mod.rs index 32aed624..8549c1c5 100644 --- a/crates/webcodex-runner/src/webcodex_runner/mod.rs +++ b/crates/webcodex-runner/src/webcodex_runner/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod artifacts; pub(crate) mod checkpoints; +pub(crate) mod coding_agent; pub(crate) mod computer; pub(crate) mod config; pub(crate) mod detached_job; diff --git a/crates/webcodex-runner/src/webcodex_runner/persistent_shell.rs b/crates/webcodex-runner/src/webcodex_runner/persistent_shell.rs index 9594f368..44a7fd9a 100644 --- a/crates/webcodex-runner/src/webcodex_runner/persistent_shell.rs +++ b/crates/webcodex-runner/src/webcodex_runner/persistent_shell.rs @@ -1094,6 +1094,7 @@ mod tests { sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: Some(PersistentShellRequest { action: action.to_string(), shell_id: shell_id.to_string(), diff --git a/crates/webcodex-runner/src/webcodex_runner/shutdown.rs b/crates/webcodex-runner/src/webcodex_runner/shutdown.rs index 899db51e..44f3a324 100644 --- a/crates/webcodex-runner/src/webcodex_runner/shutdown.rs +++ b/crates/webcodex-runner/src/webcodex_runner/shutdown.rs @@ -340,7 +340,7 @@ impl Drop for ActivityGuard { } } -#[derive(Default)] +#[derive(Debug, Default)] pub(crate) struct BackgroundThreads { handles: Mutex>>, } diff --git a/crates/webcodex-runner/src/webcodex_runner/transport.rs b/crates/webcodex-runner/src/webcodex_runner/transport.rs index ae027bc0..6952ad6c 100644 --- a/crates/webcodex-runner/src/webcodex_runner/transport.rs +++ b/crates/webcodex-runner/src/webcodex_runner/transport.rs @@ -361,16 +361,22 @@ impl AgentRuntimeState { let joined = self.background_threads.join_until(background_deadline); let workers_done = self.jobs.wait_for_workers(background_deadline); let dispatches_done = self.dispatches.wait_until(background_deadline); + let coding_agents = self + .config + .coding_agents() + .map(|manager| manager.drain_workers_until(background_deadline)) + .unwrap_or_default(); let background_timeouts = reload_retry.timed_out + joined.timed_out + usize::from(!workers_done) - + usize::from(!dispatches_done); + + usize::from(!dispatches_done) + + coding_agents.timed_out; phases.push(shutdown_phase( "background_threads_join", started, - background_resources, + background_resources + coding_agents.resources, background_timeouts, - reload_retry.panicked + joined.panicked, + reload_retry.panicked + joined.panicked + coding_agents.panicked, "background_thread_panicked", )); @@ -963,6 +969,7 @@ impl AgentSink { }, command_execution_state: None, mcp_gateway: None, + coding_agent: None, }) } @@ -986,6 +993,31 @@ impl AgentSink { }, command_execution_state: None, mcp_gateway: Some(response), + coding_agent: None, + }) + } + + /// Submit one closed CodingAgentRun response. ACP JSON-RPC remains local to + /// the Runner and never crosses this typed transport result boundary. + pub(crate) fn submit_coding_agent_result( + &self, + request_id: String, + response: webcodex_core::coding_agent::CodingAgentResponse, + ) -> Result { + self.submit_result_payload(ShellAgentResultPayload { + result: ShellAgentResultRequest { + client_id: self.client_id().to_string(), + agent_instance_id: self.agent_instance_id().to_string(), + request_id, + exit_code: None, + stdout: None, + stderr: None, + duration_ms: None, + error: None, + }, + command_execution_state: None, + mcp_gateway: None, + coding_agent: Some(response), }) } @@ -1031,6 +1063,7 @@ impl AgentSink { }, command_execution_state: Some(execution_state), mcp_gateway: None, + coding_agent: None, }; let submitted = self.submit_result_payload(body); if matches!(&submitted, Ok(ResultSubmission::Accepted)) { diff --git a/crates/webcodex-runner/src/webcodex_runner/transport_tests.rs b/crates/webcodex-runner/src/webcodex_runner/transport_tests.rs index 2dd02bb2..ef20abde 100644 --- a/crates/webcodex-runner/src/webcodex_runner/transport_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/transport_tests.rs @@ -47,6 +47,7 @@ fn test_agent_config(server_url: String) -> AgentConfig { ssh: Default::default(), tool_providers: Default::default(), mcp_gateway: Default::default(), + acp: Default::default(), } } @@ -704,6 +705,7 @@ fn sync_file_request(request_id: &str) -> ShellAgentShellRequest { sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, } } @@ -736,6 +738,7 @@ fn polling_shell_request(request_id: &str, cwd: &Path, command: String) -> Shell sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, } } @@ -3040,6 +3043,7 @@ fn start_job_request(cwd: &Path, command: &str) -> ShellAgentShellRequest { sandbox: None, job_context: Some(crate::test_job_context(cwd, Vec::new())), mcp_gateway: None, + coding_agent: None, persistent_shell: None, } } diff --git a/docs/agent/acp-coding-agent-run.md b/docs/agent/acp-coding-agent-run.md new file mode 100644 index 00000000..c73a6a8c --- /dev/null +++ b/docs/agent/acp-coding-agent-run.md @@ -0,0 +1,1026 @@ +# ACP Coding Agent Run Contract + +This note defines the P0 architecture baseline for running an Agent Client +Protocol (ACP) coding agent from WebCodex. It is deliberately narrower than a +public implementation: the goal is to fix the execution, identity, lifecycle, +configuration, permission, observation, and recovery semantics that P1 must +preserve. + +The product model is a **protocol-aware detached Job**, but the product object is +not a WebCodex Job. `CodingAgentRun` is a separate execution primitive whose +payload is ACP protocol state and structured coding-agent activity rather than a +shell command plus stdout/stderr. + +This contract is based on current WebCodex V1 architecture plus real dogfood of +the official `agentclientprotocol/codex-acp` adapter on 2026-08-23. The dogfood +used `npx -y @agentclientprotocol/codex-acp`, package version **1.6.2**, without a +global install or credential changes. Protocol details below describe that +observed implementation and the current ACP surface; P1 must continue to +negotiate and validate rather than hard-code incidental adapter behavior. + +## P1 implementation realization + +P1 implements this contract as a Codex-first vertical slice without changing the +product identity model. `CodingAgentRun` remains separate from Jobs and Workflow +Sessions, and the Server/Runner boundary is a closed typed protocol rather than +an ACP JSON-RPC tunnel. + +The Runner owns an `[acp]` / `[[acp.agents]]` startup configuration. Each agent +entry supplies a logical id/name, executable, argv, explicit `env_from_env` +mappings, and an operator ceiling for run-level ACP config option ids. There is +no production `codex-acp` default. The provider child is always spawned after +`env_clear()` and receives only configured mappings. Missing source variables +fail before provider process start. + +P1 exposes exactly three model tools: `coding_agent_start`, +`coding_agent_observe`, and `coding_agent_cancel`. They require the independent +`coding_agent:run` OAuth scope. Direct shared-key, open-anonymous, project +credentials, existing OAuth clients, `project:write`, `job:run`, and `mcp:local` +do not imply it. The hosted shared-key OAuth bridge can add this scope only via +an explicit client-provisioning opt-in; existing clients are never widened on +Server upgrade. + +`recording_session_id` is carried by the existing generic stateless recorder +wrapper rather than duplicated as CodingAgentRun business input. It can attach +bounded `coding_agent_started`, `coding_agent_waiting_permission`, and +`coding_agent_terminal` lifecycle evidence to an exact Project-matching +Workflow Session. Recorder provenance never crosses the Server/Runner execution +request, never grants Run authority, and never stores prompt/reasoning/tool +bodies or the private ACP session id. + +The Runner durable record intentionally retains only recovery identity and +certainty metadata. Immediately before writing `session/prompt`, it durably +crosses `prompt_dispatch_may_have_occurred`; any nonterminal restart from that +phase becomes `lost/outcome_unknown` and is never redispatched. A correlated +terminal result is durably recorded before active execution state is reclaimed. + +P1 uses stable ACP v1 schema types and a narrow stdio client. It advertises only +implemented client capabilities and supports initialize, session/new, validated +session/set_config_option, session/prompt, session/update, +session/request_permission, and session/cancel. Unsupported agent-to-client +requests fail closed. Permission requests are normalized for observation, never +auto-allowed, receive ACP `Cancelled` after a bounded deadline (or before prompt +cancel), and the same prompt is then observed to terminal correlation. + +The opt-in P1 real-provider smoke on 2026-08-23 re-queried npm and observed +`@agentclientprotocol/codex-acp` version 1.6.2. It used a test/operator-owned +`npx -y @agentclientprotocol/codex-acp` provider declaration, existing local +Codex authentication, explicit environment mappings, and a read-only prompt on +the registered ACP worktree. The Run produced normalized agent/activity events +and correlated `stopReason=end_turn`; no global package install or credential +mutation was performed. + +## 1. Product model + +The intended user flow is: + +```text +ChatGPT / API client + -> coding_agent_start + -> exact WebCodex Project + Runner + configured ACP provider + -> CodingAgentRun R + -> Runner-owned ACP child + -> initialize + -> session/new + -> optional validated session/set_config_option calls + -> session/prompt + -> structured session/update observations + -> correlated terminal prompt response +``` + +WebCodex owns: + +- exact Runner and registered Project routing; +- provider identity and stale-provider fencing; +- run admission and idempotent initiation; +- bounded lifecycle, timeout, cancellation, and process-tree cleanup; +- sanitized structured observation; +- provenance, authorization, audit, and telemetry; +- recovery classification when transport or process state is uncertain. + +The ACP agent owns: + +- coding reasoning and planning; +- its own shell/edit/tool decisions; +- its own sandbox and approval behavior; +- account, organization, model, and provider policy; +- provider-specific coding behavior. + +WebCodex must not turn ACP into a second implementation of WebCodex file, shell, +or patch tools. The ACP child is a delegated local coding agent, not an MCP tool +provider and not a raw JSON-RPC endpoint exposed to remote callers. + +## 2. Four identities that must remain separate + +```text +Workflow Session W + | + | optional provenance / evidence relationship only + v +CodingAgentRun R model-visible: wc_agent_run_... + | + | Runner-private protocol execution + v +ACP session S never model authority + | + v +Codex / future ACP agent + +WebCodex Job J independent process-execution primitive +``` + +### Workflow Session + +A Workflow Session remains the bounded coding-task evidence and collaboration +ledger described by `session-model.md`. A Run may record the `wc_sess_*` that +initiated it, but that reference is provenance only. + +Knowing a Workflow Session id does not authorize a Run. Recorder metadata must +never be accepted as Run authority, and a Run must continue to enforce its own +caller/project/provider ownership on observe and cancel. + +### CodingAgentRun + +`CodingAgentRun` is the WebCodex business identity for one admitted autonomous +coding turn. The future public id should be opaque and WebCodex-owned, for +example `wc_agent_run_*`. It is the only agent-execution identity a model needs +to retain after successful admission. + +A Run owns the bounded normalized event history, current state, exact Project, +exact Runner instance, exact ACP provider instance, a bounded initiation-intent +fingerprint, optional Workflow Session provenance, and the Runner-private ACP +session id. Raw prompt/config input is execution data, not durable authority +identity, and should be discarded once it is no longer needed for active dispatch. + +### ACP session + +The raw ACP `sessionId` is a provider/protocol identity. It must stay Runner +private and must never be accepted as Project, Workflow Session, or Run +authority. + +The current Codex adapter advertises `loadSession`; P0 dogfood verified that a +session created in one `codex-acp` process could be loaded in a fresh adapter +process and then accept another prompt. That is a useful future recovery +building block, but it is not an idempotency guarantee for an in-flight prompt. +P1 must not expose the ACP session id or rely on it as the product Run id. + +### Job + +A WebCodex Job is stdout/stderr/process/exit oriented. A CodingAgentRun is +agent-message/reasoning/tool/file/terminal/usage/permission oriented. Reusing the +Job record would either discard ACP structure or overload Job semantics with a +second event model. + +P1 should reuse proven Job machinery where the semantics really match: + +- `ManagedChild` process-tree ownership and cleanup; +- bounded timeout and cancellation patterns; +- detached initiation/idempotency concepts; +- opaque observation-token patterns; +- Runner inventory and same-instance reconciliation concepts; +- existing `execution_state`, `failure_kind`, and `recovery_kind` vocabulary. + +It should not serialize ACP updates into stdout/stderr or make `job_id` an alias +for `run_id`. + +## 3. P0 real Codex ACP dogfood + +### Environment + +The probe ran from the registered ACP worktree using the existing local Codex +authentication. It did not read, print, replace, or migrate credential files. +No global npm package was installed. + +Observed adapter identity: + +```text +@agentclientprotocol/codex-acp 1.6.2 +ACP initialize protocolVersion = 1 +agentInfo.name = @agentclientprotocol/codex-acp +agentInfo.title = Codex +agentInfo.version = 1.6.2 +``` + +The adapter advertised auth, load-session, MCP, prompt, provider, and session +capabilities. `session/new` returned a private `sessionId` plus modes, models, +and config options. + +The current official adapter source maps its advertised `agent` mode to Codex +`approvalPolicy=on-request` with a workspace-write sandbox and network access +disabled. This is adapter/provider behavior: WebCodex did not set those values in +the P0 no-override probes. It is the concrete reason that "no WebCodex override" +must not be documented as "identical to bare Codex CLI defaults". + +### Successful no-tool turn + +A prompt explicitly forbidding tool use completed with `stopReason=end_turn`. +While the `session/prompt` request was outstanding, the client observed +`session/update` notifications including: + +- `available_commands_update`; +- `session_info_update`; +- `agent_message_chunk`; +- `usage_update`. + +No permission request occurred in that turn. + +### Read-oriented coding turn and permission behavior + +A safe prompt asked Codex to inspect a small amount of repository source and +answer an architecture question without modifying files. The adapter emitted +structured updates including: + +- agent thought chunks; +- agent message chunks; +- tool calls and tool-call updates; +- usage updates. + +In that run the adapter also issued **two** `session/request_permission` client +requests. Each advertised options including `allow_once`, `allow_always`, and +`reject_once`. The disposable headless probe deliberately returned a cancelled +permission outcome rather than auto-allowing; the prompt subsequently terminated +with `stopReason=cancelled`. + +Other safe probes did not always produce a permission request. Permission +callbacks are therefore real but contextual. They are not a sound basis for +assuming that every read/tool action asks, nor for assuming that the path never +asks. + +### Cancel + +P0 sent `session/cancel` during an active no-tool prompt. The correlated +`session/prompt` response completed with `stopReason=cancelled`. The same ACP +session subsequently accepted another prompt that completed with `end_turn`. + +For WebCodex, cancellation therefore means cancellation of the active Run/turn. +It must not be modeled as authority to destroy or expose the underlying ACP +session object. + +### Config options + +The current adapter advertised these config ids in the dogfood environment: + +```text +mode +collaboration_mode +model +reasoning_effort +fast-mode +``` + +The observed current/default mode was `agent`, with advertised mode values: + +```text +read-only +agent +agent-full-access +``` + +An invalid `session/set_config_option` id returned JSON-RPC error `-32602` +(`Invalid params`). Setting the advertised `mode` option to the advertised +`read-only` value succeeded and returned refreshed `configOptions`. + +These names and values are observations, not WebCodex enums. P1 must validate +against the exact options advertised by the selected live provider session. + +### Adapter process environment + +With the ordinary current process environment, real prompts completed. A +separate probe launched the adapter with only `HOME` and `PATH`; `initialize` +and `session/new` succeeded, but `session/prompt` did not complete within the +probe deadline. + +P0 did not isolate which environment dependency was missing, so the contract +must not claim one. The architectural conclusion is narrower: an ACP provider +cannot assume that a universal two-variable environment is sufficient. The +Runner must let the operator explicitly map required host environment through a +bounded `env_from_env` configuration while continuing to prohibit remote +caller-supplied environment and secret values. + +## 4. Protocol facts P1 depends on + +### Transport and correlation + +The official Codex adapter accepted newline-delimited JSON-RPC 2.0 over stdio. +Each request/response pair carries a JSON-RPC id. Notifications such as +`session/update` and `session/cancel` are not terminal acknowledgements. +Agent-to-client requests such as `session/request_permission` have their own +request ids and require a correlated client response. + +Identity lifetime is intentionally asymmetric: + +| Identifier | P0 lifetime conclusion | +|---|---| +| JSON-RPC request id | Connection/process-local correlation only; never recovery or authority identity. | +| ACP `sessionId` | Provider-private. Current Codex ACP can persist/load it across adapter processes, but that is a provider capability, not a universal ACP/WebCodex guarantee. | +| ACP provider instance id | WebCodex Runner process/provider-instance fence; replacement makes old requests stale. | +| `wc_agent_run_*` | Future WebCodex business identity, retained independently of one HTTP/MCP request and reconciled only from authoritative Runner Run state. | +| Workflow `wc_sess_*` | Independent evidence/collaboration identity; optional Run provenance only. | + +The Runner must own the JSON-RPC id space/correlation machinery. Remote callers +must never provide a JSON-RPC method or id. + +### Initialize and version negotiation + +The client sends `initialize` with its supported ACP protocol version and client +capabilities, and validates the returned negotiated version and advertised +capabilities before creating a session. Unsupported/malformed negotiation is a +pre-prompt failure and must fail closed. + +P1 must advertise only ACP client capabilities that it actually implements. +`session/request_permission` is a baseline client method, while client-side +filesystem, terminal, and elicitation methods are optional capability-gated +surfaces. P1 must not advertise `fs.readTextFile`, `fs.writeTextFile`, terminal, +or elicitation support merely because the selected agent can use those concepts. +An unexpected unsupported agent-to-client request must receive a bounded +fail-closed protocol response rather than hanging the Run. + +### Session creation + +For P1 the Runner supplies the exact registered Project root as the `session/new` +`cwd`. The caller cannot provide an arbitrary cwd. MCP server inputs, if any are +supported later, are Runner-owned; P1 should send only the closed configuration +it explicitly supports. + +The new-session response supplies the private session id and current advertised +modes/models/config options. These are provider observations, not authority. + +### Prompt lifecycle + +`session/prompt` is one correlated long-running request. `session/update` +notifications may arrive before its response. The correlated prompt response +with a `stopReason` is the terminal protocol result for the turn. + +There is no separate P0-observed durable acknowledgement proving exactly when a +prompt became safe to retry. Once the Runner has successfully written/flushed +the prompt request, loss of the ACP process/stdio or its correlated response can +leave the effect uncertain. + +### Cancel + +`session/cancel` is a notification naming the private ACP session. P1 should +send it only from Runner-owned state, then continue bounded observation for the +prompt's correlated terminal response. A successful write of the cancel +notification alone is not proof that cancellation took effect. + +### Permission requests + +`session/request_permission` is an ACP agent-to-client request, not a WebCodex +runtime-tool permission evaluation. The client must implement it because the +current Codex adapter can actually send it. It must never default to allow. + +Current `codex-acp` bridges Codex approval activity through this callback and +fails closed when the ACP client's approval interaction fails or is cancelled. +This reinforces the product boundary: WebCodex performs one admission decision +for the Run, then the delegated agent owns its normal internal coding policy; +WebCodex does not rerun `PermissionEvaluator` for every ACP tool action. + +### Error and process-exit semantics + +Request-level protocol failures are JSON-RPC errors; terminal prompt outcomes +are represented by the correlated prompt result/stop reason. The ACP child +process has a separate OS lifetime. Process exit is diagnostic/lifecycle input, +not a substitute for a terminal prompt result. + +If the child exits after prompt dispatch without a correlated prompt response, +WebCodex cannot infer success or failure from its exit code alone. The Run must +be treated as uncertain unless exact protocol recovery proves otherwise. + +## 5. Configuration semantics + +### `config` omitted or `{}` means no WebCodex override + +P0 corrects an important earlier assumption. For WebCodex: + +```text +config omitted +or +config = {} +``` + +means **send no `session/set_config_option` calls**. + +It does not mean "the ACP adapter behaves exactly like a bare local Codex CLI". +The adapter itself may have defaults. In real dogfood, `codex-acp 1.6.2` +advertised current `mode=agent`; that mode is adapter/provider policy, not a +WebCodex override. + +Therefore the precise inheritance contract is: + +> WebCodex inherits the selected Runner-owned ACP provider's effective defaults +> by abstaining from run-level ACP config overrides. + +The Run should record a bounded sanitized snapshot of the effective advertised +config ids/current values needed for diagnosis. It must not claim that those +values came directly from `~/.codex`, an account, or organization policy. + +### Explicit run-level overrides + +For a non-empty caller `config` object P1 must perform this order: + +1. start/initialize the exact configured provider; +2. create the private ACP session; +3. obtain the session's advertised config options; +4. reject any caller key not currently advertised; +5. reject any value not legal for that advertised option; +6. apply Runner/operator allow/deny policy for remotely overridable options; +7. call `session/set_config_option` only for approved explicit overrides; +8. validate the returned refreshed config options; +9. only then dispatch the prompt. + +An invalid override is `not_started` with `recovery_kind=fix_input`; it must not +partially begin the coding prompt. + +The initial P1 operator policy is an explicit allowlist of option ids rather than +a generic policy language. Permitting one id delegates selection among that +session's currently advertised legal values; unknown newly advertised option ids +remain non-overridable. If a future provider exposes materially different +authority levels as values of one option, add an explicit value ceiling for that +concrete need rather than pretending P1 already has per-value policy. + +### Fields remote callers never control + +`coding_agent_start` must not accept: + +- executable or argv; +- arbitrary environment or secret/API-key material; +- arbitrary cwd; +- transport selection; +- raw ACP JSON-RPC method/params/id; +- raw ACP session id. + +Those belong to Runner configuration or closed Runner protocol. + +## 6. Runner-owned ACP provider configuration + +ACP should get a new narrow Runner section rather than being placed under +`[mcp]` or the existing Claude MCP tool-provider router. ACP is a bidirectional, +long-lived coding-agent protocol with callbacks and structured turn state; the +MCP gateway is a `tools/list`/`tools/call` provider surface. Reusing the latter +would collapse distinct semantics. + +A minimal P1 configuration is: + +```toml +[acp] +max_concurrent_runs = 1 + +[[acp.agents]] +id = "codex" +name = "Codex" +executable = "/runner/owned/path/to/codex-acp" +args = [] + +[acp.agents.env_from_env] +# Explicit operator mappings only. Values never go to the Server. +HTTPS_PROXY = "HTTPS_PROXY" +``` + +The exact executable example is operator-specific; WebCodex must not prescribe +`npx -y` as a production default or download packages at request time. + +P1 should make `[acp]` startup/restart-owned, following the simpler precedent of +the static MCP gateway rather than adding hot replacement immediately. A config +change therefore requires Runner restart. This avoids creating a new dynamic +provider-reload framework for one consumer. + +Each advertised provider needs bounded sanitized identity such as: + +```text +provider_id # logical id selectable within the exact Project Runner +provider_instance_id # opaque internal fence for this startup-owned provider instance +name +configured/routing capability facts only +``` + +P1 configuration is startup/restart-owned and has no hot provider replacement, +so a second `provider_revision` authority token has no demonstrated purpose. +The exact Runner `agent_instance_id` plus opaque `provider_instance_id` are the +replacement fence. A future hot-reload design can add a revision only if it +creates a distinct live replacement boundary. + +Public callers select only the logical provider id after Project resolution. The +Server captures the exact Runner/provider instance internally and revalidates it +immediately before dispatch; ephemeral provider-instance identity is not a +model input. A new Runner/provider instance makes an already-bound request stale +before Run start. The Server must never receive executable path, argv, PID, +environment values, credential material, stderr, local config contents, or raw +ACP auth data. + +`env_from_env` is resolved only on the Runner immediately before spawn. The +caller supplies neither source names nor values. The ACP child must follow the +existing static-MCP provider boundary: clear the inherited process environment +first, then inject only operator-declared `env_from_env` mappings. Missing mapped +sources fail before child start. P1 must preserve existing secret-redaction +rules; it must never silently inherit the Runner's complete environment or log +resolved values. + +Admission capacity is an ACP-run plane, not Job concurrency. P1 should use a +small bounded `max_concurrent_runs` and **reject before start when full** rather +than add queueing/scheduling states. Do not infer ACP capacity from +`max_concurrent_jobs` and do not build a worker pool. + +## 7. Project binding and confinement truth + +Project binding gives WebCodex three real guarantees: + +1. the selected Run is routed to the exact Runner owning the registered Project; +2. the Run records that exact Project identity; +3. `session/new.cwd` starts at the Runner-authoritative Project root. + +That is not a filesystem sandbox. + +`cwd == Project root` does **not** prove that the delegated agent can read or +write only that tree. ACP itself is not a filesystem confinement mechanism. +The selected coding agent may apply its own sandbox, OS policy, account/org +policy, and approval mode; those controls can be stronger or weaker than +WebCodex file-tool path rules and may evolve independently. + +For the current Codex adapter, the effective mode influences Codex sandbox and +approval behavior. WebCodex may report the provider's sanitized advertised +configuration, but it must not translate that into a claim of WebCodex Project +isolation unless WebCodex separately enforces such isolation. + +The P1 product description should therefore say **operator-configured delegated +local coding agent**. It must not promise parity with WebCodex `read_file` / +`apply_text_edits` filesystem isolation. + +## 8. Permission-request exceptional path + +The normal WebCodex authority decision happens once at `coding_agent_start`: + +```text +caller auth + exact Project + provider fence + config override policy + -> WebCodex start admission decision + -> ACP Run starts + -> delegated agent applies its own coding policy +``` + +An ACP `session/request_permission` callback is not fed back through the normal +WebCodex `PermissionEvaluator`, because that would create a second per-action +policy layer over the agent's own approval system. + +P1 nevertheless must implement the callback. The minimum safe behavior is: + +- emit a bounded sanitized `permission_request` Run event; +- enter `waiting_permission` while a bounded response deadline is active; +- never choose an allow option automatically; +- if `coding_agent_cancel` cancels the prompt while a permission request is + outstanding, answer that request with ACP `Cancelled` as required by v1 and + then continue the cancel path; +- if only the permission-response deadline expires, answer ACP `Cancelled`. + Do not synthesize an option selection or mutate the Agent's persistent policy; +- then continue observing the same prompt until it reaches a terminal result or + becomes lost. + +This makes P1 safe but intentionally incomplete for providers/configurations that +frequently require interactive approval. A later operator UI or model-visible +permission-response capability requires separate evidence and authority design; +it is not part of P0/P1 by implication. + +## 9. Minimal CodingAgentRun lifecycle + +Use exactly these product states initially: + +```text +starting +running +waiting_permission +completed +failed +cancelled +lost +``` + +Keep prompt dispatch certainty as structured execution metadata rather than +multiplying states. Reuse the existing concepts `not_started`, `started`, +`completed`, and `outcome_unknown` where applicable. + +| State | Coding execution / prompt fact | Retry rule | Recovery | +|---|---|---|---| +| `starting` | Run admitted; provider/session/config setup may be in progress. Prompt may still be `not_started`. | Never create a second Run for the same initiation key; observe/reconcile the admitted Run. | `wait` or `reobserve`. | +| `running` | Prompt request was dispatched; agent may have accepted it. | No blind prompt retry. | `reobserve`; after transport failure use `reconcile`. | +| `waiting_permission` | Prompt is active and a real ACP permission callback is pending. | No prompt retry and no automatic allow. | `wait`; P1 fail-closes the permission deadline. | +| `completed` | Correlated terminal prompt result proves normal terminal completion. | No retry of the same Run. | `none`. | +| `failed` | Deterministic terminal failure is known, or setup failed before prompt dispatch. | The retained Run itself is terminal and same-key replay only returns it. A new initiation with a new idempotency key is safe only when `execution_state=not_started`; otherwise caller must not infer retry safety. | `none` for the retained terminal Run. Pre-admission tool-call failures may separately use `fix_input` or exact `retry_same`. | +| `cancelled` | Cancellation reached a correlated terminal cancelled result, or the Run was cancelled while prompt was provably not started. Pre-prompt cancellation uses `execution_state=not_started`, no ACP `stop_reason`/`error_code`, and a bounded terminal message stating that no ACP prompt was dispatched. | Do not resend the cancelled prompt as a retry. | `none`. | +| `lost` | No terminal prompt result is available and exact continuation cannot currently be proved. Prompt may have run. | Never blind retry. | `reconcile` / `reobserve`; create a new Run only after authoritative evidence establishes safety or the user intentionally requests new work. | + +ACP v1 terminal `stopReason` mapping is closed for P1: `end_turn` becomes +`completed`; `cancelled` becomes `cancelled`; `max_tokens`, +A `cancelled/not_started` Run is not an ACP terminal response: its `stop_reason` +remains absent because the prompt never crossed the dispatch boundary. Only a +correlated post-dispatch ACP cancellation carries `stopReason=cancelled`. + +`max_turn_requests`, and `refusal` become deterministic `failed` outcomes. Those +non-success stop reasons are correlated terminal responses, so they are not +`lost`, but they also do not prove that the turn had no coding effects and do not +create retry authority. An unknown stop reason is a fail-closed protocol failure, +not normal completion. A correlated JSON-RPC error for the prompt is likewise a +terminal `failed` outcome; loss of correlation/transport before any terminal +response is what produces `lost`. + +`lost` is an uncertainty state, not proof that the coding process had no effect. + +A timeout is not a separate initial state. On a Run deadline, request cancellation +and wait for a bounded terminal result. A correlated cancelled result becomes +`cancelled`; losing the process/transport before correlation becomes `lost`. +Setup deadline failures before prompt dispatch become `failed` with +`execution_state=not_started`. +A retained terminal `failed/not_started` Run therefore never advertises +`retry_same`: deterministic same-key replay is observation/idempotency only and +cannot redispatch that terminal Run. After correcting the underlying setup issue, +a caller may intentionally create a new initiation with a new idempotency key; +that is distinct from replaying the old initiation. + +## 10. Initiation and retry safety + +`coding_agent_start` is consequential autonomous execution. It needs a required +bounded caller-chosen `idempotency_key`, using the same semantic pattern as +`run_detached_process` but a separate CodingAgentRun namespace. + +The idempotency identity is the stable authenticated principal plus the bounded +caller key; the initiation intent is a separate conflict check. Following the +existing detached-Job precedent, P1 should derive `run_id` deterministically +from an ACP-specific domain separator, canonical stable principal identity, and +the key. Do not randomly mint the Run id unless an equally strong durable +admission mapping is committed before dispatch; P1 should not add that extra +persistence concept. + +Before first dispatch, compute a bounded canonical `intent_fingerprint` for the +execution-affecting start intent. Replaying the same derived `run_id` with the +same fingerprint returns/observes that Run and cannot dispatch the prompt twice. +A different Project, logical provider, prompt, config, timeout, or other +execution-affecting intent under the same key is an idempotency conflict before +dispatch. The Runner Run record/inventory carries the `run_id` and fingerprint +needed for post-Server-restart reconciliation, not the caller's raw key or a +retained raw prompt solely for idempotency checking. + +The initiation key is not authority and must not be copied to the Runner, +persisted in ordinary evidence, or logged. Deterministic Run identity is what +lets a retry after Server restart meet an already-running authoritative Run +instead of creating a second execution. + +Deterministic identity alone is insufficient across a Runner restart because the +new Runner must still know whether that logical Run may already have produced +effects. P1 therefore needs a **minimal durable Runner-local Run record** (or an +equally strong existing durable mechanism) for admitted CodingAgentRuns. This is +not a durable transcript and not automatic ACP-session recovery. It stores only +bounded authority/lifecycle facts such as `run_id`, `intent_fingerprint`, exact +Project identity, logical provider identity, conservative dispatch phase, and +terminal metadata. + +The dispatch phase must include a crash-safe conservative barrier persisted +**before** writing `session/prompt`. Once that barrier is durable, a restart may +only conclude that the prompt *may have been dispatched* until a correlated +terminal result is durably recorded. A crash after the barrier but before the +actual write therefore sacrifices retryability and recovers as `lost`; that is +preferable to duplicate coding effects. Only a record proven to have remained +strictly before this barrier may recover as `not_started`. A correlated terminal +result may later replace the barrier with bounded terminal metadata. Raw prompt, +config bodies, event transcript, idempotency key, credentials, and ACP messages +must not be stored in this durable record. + +Consequently, a same-key initiation after either Server or Runner restart must +first reconcile the deterministic `run_id` against the durable Run record and +current Runner inventory. A matching `lost`, active, or retained-terminal record +is observed/returned and never redispatched; a mismatched fingerprint conflicts. +If the required durable record is unavailable or corrupt after a possibly +started Run, fail closed rather than treating absence as proof of `not_started`. + +The most important uncertainty rule is: + +```text +session/prompt successfully dispatched ++ ACP transport/process lost before correlated terminal response += outcome_unknown / Run lost +!= retry session/prompt +``` + +`session/load` support does not change this rule. Loading the conversation can +recover a durable ACP session, but it does not by itself prove whether an +in-flight prompt completed, partially acted, or never ran. + +## 11. Observation delta contract + +The planned model surface is: + +```text +coding_agent_start +coding_agent_observe +coding_agent_cancel +``` + +P0 does not implement these tools. + +`coding_agent_observe` should accept: + +```text +run_id + after_observation_token? # opaque, exact Run-bound + wait_secs? # one bounded wait +``` + +The Runner is authoritative for the Run's bounded event ring and monotonically +ordered observation revision/sequence. The Server returns normalized only-new +events when continuity is provable. It never returns the full transcript on +every call and never exposes raw ACP JSON-RPC. + +Initial normalized event kinds: + +```text +agent_message +reasoning +plan +tool_activity +file_change +terminal_activity +usage +permission_request +terminal +``` + +Not every provider must emit every kind. P1 maps only protocol/provider updates +whose semantics are understood; unknown raw update variants are ignored or +recorded as a bounded diagnostic count, not forwarded verbatim. + +The response must make retention explicit: + +- token is opaque and bound to exactly one `run_id`; +- no token returns a bounded current baseline, not unbounded history; +- token calls return only retained changes after that cursor; +- `history_lost=true` when the requested cursor predates reconstructable retained + events; +- `has_more`/continuation advances only through the last returned event; +- `wait_secs` is one bounded wait, not a stream or subscription; +- serialized model output has a fixed budget; +- terminal state is returned even when there are no new textual events. + +Raw reasoning and tool payloads can contain sensitive or very large data. P1 +must define per-event bounds/redaction. The bounded Runner observation ring may +retain model-facing message/reasoning/tool summaries needed to observe the Run, +but existing durable Action Audit, generic model-ergonomics telemetry, and +Workflow Session lifecycle evidence must not automatically persist prompt text, +agent-message/reasoning bodies, or raw ACP tool inputs/results. Durable surfaces +should record bounded lifecycle/size/kind metadata unless a future explicit +evidence feature defines otherwise. Environment values, auth data, absolute +provider executable paths, raw credentials, and arbitrary stderr are never event +content. + +## 12. Cancel, timeout, restart, and replacement + +### Cancel + +`coding_agent_cancel` requires exact Run authorization and should be idempotent. +For an active prompt the Runner sends `session/cancel` once, then observes the +same prompt toward terminal state. It must not launch a replacement session or +prompt. + +### Control Server restart + +A surviving Runner process can retain a Run and its event ring independently of +the Server request that started/observed it. P1 should extend the established +Runner reconciliation pattern with a bounded active/recent-terminal +CodingAgentRun inventory. The same Runner `agent_instance_id`, exact `run_id`, +Project, provider instance, `intent_fingerprint`, state, and observation revision +are the recovery authority. The deterministic principal+idempotency-key Run id +allows a retried initiation to correlate with that recovered inventory without +sending the raw key to the Runner. + +A Server restart invalidates process-local waits/tokens as needed, but it must +not imply that the Run should be restarted. Re-observation should return a +bounded reset/baseline and fresh token when the Run is still authoritative. + +### Runner restart / provider replacement + +A new Runner process has a new `agent_instance_id` and new ACP provider instance +identity. P1 does not claim transparent active-Run recovery across that boundary. +At startup it first loads the minimal durable Run records described above. A +record strictly before the prompt-dispatch barrier may close as deterministic +`not_started`; a record at/after that barrier without a durable correlated +terminal result recovers as `lost`; a retained terminal record stays terminal +although its in-memory event history may have been lost. The new Runner must not +turn a missing in-memory Run into permission to redispatch a deterministic +`run_id` whose durable record says effects were possible. + +Although current Codex ACP can load a durable session in a new adapter process, +P1 should defer automatic cross-process Run recovery. `session/load` alone does +not provide exact in-flight prompt reconciliation, and guessing would risk +re-executing coding effects. + +A stale Server request internally bound to an old provider instance must fail +closed before spawning or retargeting any agent. + +## 13. Authorization and scope recommendation + +ACP delegated autonomous coding is a new externally reachable authority. It must +have a distinct coarse scope, recommended: + +```text +coding_agent:run +``` + +Do **not** infer it from any of: + +```text +project:write +job:run +job:detach +mcp:local +``` + +Those scopes authorize different primitives. Giving an existing credential the +ability to start an autonomous coding agent merely because it can edit a file, +run a Job, or call a local MCP tool would silently broaden authority. + +Registering the new scope is not permission to add it to existing default scope +ceilings. In particular, P1 must leave direct shared-key model scopes, the OAuth +shared-key bridge defaults, open-anonymous scopes, Project-credential connector +scopes, and already-issued legacy OAuth clients unchanged unless an explicit +operator/consent path grants `coding_agent:run`. The first usable ACP flow needs +such an explicit opt-in issuance path; it must not obtain usability by silently +expanding existing credentials. + +For the P1 public vertical slice: + +- start requires `coding_agent:run` plus normal authorization for the exact + writable Project; + Concretely, delegated start also requires `project:write` and the current + Runner registration must still have `allow_patch=true`; the Runner rechecks + that writable binding immediately before admitting/spawning the ACP run. +- observe/cancel require the same Run visibility/ownership and exact Project + boundary; knowing `run_id` is never sufficient; +- Workflow Session provenance grants no additional authority; +- provider selection is constrained to the sanitized providers advertised by + the exact Runner instance. + +One new scope is enough initially. Do not add separate run/observe/cancel scopes +without a demonstrated consumer that needs that split. + +P0 does not modify OAuth or token issuance. + +## 14. P1 exact vertical slice + +P1 should implement one Codex-capable vertical slice, not a generic plugin +framework or a second provider. + +### Core / Runner protocol + +Add a narrow transport-neutral coding-agent protocol module (for example +`crates/webcodex-core/src/coding_agent.rs`) with bounded typed values for: + +- sanitized ACP provider advertisement plus internal instance identity; +- Run start intent/result plus bounded `intent_fingerprint`; +- Run observe request/result and normalized events; +- Run cancel request/result; +- bounded active/recent-terminal Run inventory for same-Runner reconciliation. + +Extend `ShellClientCapabilities` / Runner registration with a coding-agent-run +capability and sanitized provider inventory. Extend the existing agent transport +with a closed typed coding-agent operation/update path. Do not tunnel arbitrary +ACP JSON-RPC. + +### Runner + +Add a focused ACP module under `crates/webcodex-runner` that: + +- parses `[acp]` / `[[acp.agents]]` startup configuration; +- validates ids, executable/argv bounds, explicit `env_from_env`, and concurrency; +- creates an opaque startup-owned provider-instance identity; +- spawns each admitted Run with `ManagedChild` and owns the whole process tree; +- clears inherited environment and injects only configured `env_from_env` values; +- uses project root as `session/new.cwd`; +- performs `initialize` with only implemented client capabilities, `session/new`, + validated explicit config overrides, then one `session/prompt`; +- handles `session/update`, `session/request_permission`, prompt response, + `session/cancel`, protocol faults, and process exit; +- retains a bounded normalized event ring and active/recent-terminal Run inventory; +- persists the minimal crash-safe Run admission/dispatch/terminal record before + crossing the prompt-dispatch barrier, without persisting prompt/transcript bodies; +- rejects stale provider instance and over-capacity starts before prompt dispatch; +- after terminal Run state, closes ACP stdin/transport and boundedly reaps or + terminates the owned `ManagedChild` process tree; terminal protocol state does + not by itself prove child cleanup; +- drains/discards bounded diagnostic stderr without projecting secrets. + +Do not reuse `McpGatewayManager` itself: its callbacks are intentionally +unsupported and its request model is synchronous `tools/list`/`tools/call`. +Reuse its good patterns for provider fencing, `ManagedChild`, stdio bounding, +secret-free advertisement, and dispatch certainty. + +### Server runtime + +Add a `CodingAgentRun` registry/runtime path separate from `jobs.rs`, with: + +- deterministic `wc_agent_run_*` identity derived from stable principal + + idempotency key before Runner dispatch; +- bounded intent fingerprinting and detached-style idempotency conflict checking; +- exact Project/Runner/provider binding; +- lifecycle and recovery metadata using existing vocabulary; +- same-Runner inventory reconciliation after Server restart; +- reconciliation with the Runner's durable Run record so restart never turns a + possibly-dispatched prompt into a fresh admission; +- bounded observation-token encoding and serialized-output enforcement; +- optional Workflow Session recording/provenance only. + +### Public tool layer + +Only after the internal vertical slice is typed and tested, add exactly: + +```text +coding_agent_start +coding_agent_observe +coding_agent_cancel +``` + +`coding_agent_start` inputs should be limited to Project, logical provider id, +required idempotency key, prompt/instruction, optional explicit validated +`config`, timeout, and optional Workflow Session provenance as appropriate. The +Server resolves and fences the exact Runner/provider instance internally. The +tool must not accept provider-instance tokens, executable/argv/env/cwd/transport, +or raw RPC. + +Add the new `coding_agent:run` scope to the normal scope registry and an +explicit opt-in OAuth/operator issuance path in the same P1 vertical slice. +Keep existing default/shared-key/Project-credential/open-anonymous ceilings +unchanged, with explicit tests proving legacy `project:write`, Job, MCP, and +shared-key credentials do not inherit it. + +### Focused validation + +P1 tests should use a fake bounded ACP stdio process for deterministic protocol +coverage and one opt-in real Codex ACP smoke for compatibility. Cover at least: + +- initialize/new/prompt/update/terminal normalization; +- invalid and valid config override sequencing; +- permission request never auto-allows; +- cancel -> correlated terminal cancellation; +- provider crash before vs after prompt dispatch; +- stale provider instance; +- idempotent start replay, intent conflict, and post-Server-restart replay without + duplicate dispatch; +- minimal client-capability advertisement and fail-closed unsupported callbacks; +- event retention/history loss and token Run binding; +- Server restart with same Runner inventory and intent fingerprint; +- Runner restart at each dispatch boundary: before durable barrier, after barrier + before prompt write, after prompt write before terminal, and terminal-before- + projection, proving no duplicate prompt dispatch; +- missing/corrupt durable record after possible dispatch fails closed; +- Runner/provider replacement -> lost/fail closed; +- project/scope/Workflow-Session authority boundaries; +- environment redaction and bounded serialized output; +- cleared child environment plus explicit `env_from_env` injection; +- terminal child-process cleanup; +- durable audit/telemetry privacy for prompt, message, reasoning, and tool bodies. + +## 15. Explicitly deferred + +P0/P1 do not imply: + +- Claude as a second ACP provider; +- browser-hosted ACP sessions; +- raw ACP JSON-RPC tools; +- model-visible permission-response tooling; +- automatic permission allow; +- hot ACP provider reload/generic provider framework; +- ACP v2 or experimental extensions; +- scheduler, worker pool, automatic worker spawning, or orchestration; +- Room/Discussion/Participant/presence/typing; +- durable Operation DAG; +- unification of Workflow Session, Job, ACP Session, and CodingAgentRun; +- a claim that Project cwd is a filesystem sandbox; +- automatic `session/load` recovery of uncertain in-flight prompts; +- tool slimming or unrelated model-ergonomics work. + +## 16. P0 decisions + +The P0 architecture baseline is therefore: + +1. `CodingAgentRun` is a separate protocol execution primitive, not a Job alias. +2. Workflow Session is optional evidence provenance, never Run authority. +3. Raw ACP session ids stay Runner-private. +4. Default WebCodex config inheritance means **no ACP config override calls**; + effective behavior is whatever the configured provider advertises. +5. Explicit config is validated against live advertised options and Runner policy + before prompt dispatch. +6. ACP permission callbacks are real and exceptional; never auto-allow them and + do not rerun WebCodex `PermissionEvaluator` per agent action. +7. Project root selects initial cwd but is not a filesystem security boundary. +8. Observation is a bounded normalized event delta with opaque Run-bound tokens, + not a transcript replay or raw ACP stream. +9. After prompt dispatch, transport/process loss is outcome-unknown; no blind + retry, including when `session/load` exists. +10. Runner/provider instance fencing follows the established MCP pattern, while + the model selects only a logical provider id; ephemeral provider identity is + an internal dispatch fence. +11. Restart-safe initiation uses deterministic principal+key Run identity plus a + separate intent fingerprint and a minimal durable Runner dispatch record. + After the conservative prompt-dispatch barrier, uncertainty recovers as + `lost`, never as permission to redispatch. +12. ACP execution receives a new `coding_agent:run` scope rather than inheriting + `project:write`, Job, MCP, shared-key, or other existing default authority. +13. P1 advertises only ACP client capabilities it actually implements, clears + the child environment before explicit `env_from_env` injection, and keeps + prompt/reasoning/tool bodies out of ordinary durable telemetry/audit. +14. P1 is one exact Codex vertical slice with typed closed Server<->Runner + protocol and three eventual model tools, not a generic agent framework. diff --git a/src/admin_project_lifecycle.rs b/src/admin_project_lifecycle.rs index 7d8b5bd5..d36ad7a2 100644 --- a/src/admin_project_lifecycle.rs +++ b/src/admin_project_lifecycle.rs @@ -910,6 +910,8 @@ mod tests { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, }) .await .unwrap(); diff --git a/src/agent_quic.rs b/src/agent_quic.rs index c30a630f..b307419b 100644 --- a/src/agent_quic.rs +++ b/src/agent_quic.rs @@ -608,6 +608,8 @@ mod tests { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: instance.to_string(), display_name: Some("quic-test".to_string()), @@ -658,6 +660,7 @@ mod tests { computer_window_activate: false, computer_text_input: false, job_state_reconciliation: false, + coding_agent_runs: false, }), projects: None, agent_protocol_version: Some(protocol.to_string()), diff --git a/src/agent_ws.rs b/src/agent_ws.rs index 32459d8c..f19a43f1 100644 --- a/src/agent_ws.rs +++ b/src/agent_ws.rs @@ -398,6 +398,8 @@ mod tests { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: instance_id.to_string(), display_name: Some("ws-test".to_string()), @@ -448,6 +450,7 @@ mod tests { computer_window_activate: false, computer_text_input: false, job_state_reconciliation: false, + coding_agent_runs: false, }), projects: None, agent_protocol_version: Some( diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 3745ef07..01b37aa2 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -71,10 +71,10 @@ pub(crate) use project_share::{ pub use scopes::{ AGENT_SCOPES, SCOPE_ADMIN, SCOPE_AGENT_JOB_UPDATE, SCOPE_AGENT_POLL, SCOPE_AGENT_REGISTER, - SCOPE_AGENT_RESULT, SCOPE_COMPUTER_CLIPBOARD_READ, SCOPE_COMPUTER_CLIPBOARD_WRITE, - SCOPE_COMPUTER_CONTROL, SCOPE_COMPUTER_DISPLAY_READ, SCOPE_COMPUTER_LAUNCH, - SCOPE_COMPUTER_POINTER_CONTROL, SCOPE_COMPUTER_READ, SCOPE_JOB_RUN, SCOPE_MCP_LOCAL, - SCOPE_PROJECT_READ, SCOPE_PROJECT_WRITE, SCOPE_RUNTIME_READ, + SCOPE_AGENT_RESULT, SCOPE_CODING_AGENT_RUN, SCOPE_COMPUTER_CLIPBOARD_READ, + SCOPE_COMPUTER_CLIPBOARD_WRITE, SCOPE_COMPUTER_CONTROL, SCOPE_COMPUTER_DISPLAY_READ, + SCOPE_COMPUTER_LAUNCH, SCOPE_COMPUTER_POINTER_CONTROL, SCOPE_COMPUTER_READ, SCOPE_JOB_RUN, + SCOPE_MCP_LOCAL, SCOPE_PROJECT_READ, SCOPE_PROJECT_WRITE, SCOPE_RUNTIME_READ, }; #[cfg(test)] pub use scopes::{SCOPE_ACCOUNT_MANAGE, SCOPE_JOB_DETACH}; diff --git a/src/auth/scopes.rs b/src/auth/scopes.rs index 2e38e029..d265077a 100644 --- a/src/auth/scopes.rs +++ b/src/auth/scopes.rs @@ -32,6 +32,9 @@ pub const SCOPE_COMPUTER_CLIPBOARD_WRITE: &str = "computer:clipboard_write"; /// through the built-in `/mcp` gateway. It is intentionally absent from legacy /// and lightweight default scope ceilings. pub const SCOPE_MCP_LOCAL: &str = "mcp:local"; +/// Explicit authority to start/observe/cancel delegated autonomous ACP coding +/// agents. It is intentionally absent from all legacy/default shared-key scopes. +pub const SCOPE_CODING_AGENT_RUN: &str = "coding_agent:run"; pub const SCOPE_AGENT_REGISTER: &str = "agent:register"; pub const SCOPE_ADMIN: &str = "admin"; @@ -67,6 +70,7 @@ pub(crate) const KNOWN_SCOPES: &[&str] = &[ SCOPE_COMPUTER_LAUNCH, SCOPE_COMPUTER_DISPLAY_READ, SCOPE_MCP_LOCAL, + SCOPE_CODING_AGENT_RUN, SCOPE_ACCOUNT_MANAGE, SCOPE_AGENT_REGISTER, SCOPE_AGENT_POLL, @@ -309,6 +313,9 @@ pub(crate) fn oauth_scope_policy_for_runtime_tool(tool_name: &str) -> OAuthToolS if tool_name == "run_detached_process" { return OAuthToolScopePolicy::RequireAll(&[SCOPE_JOB_RUN, SCOPE_JOB_DETACH]); } + if tool_name == "coding_agent_start" { + return OAuthToolScopePolicy::RequireAll(&[SCOPE_CODING_AGENT_RUN, SCOPE_PROJECT_WRITE]); + } if tool_name == "computer_read_clipboard" { return OAuthToolScopePolicy::RequireAll(&[ SCOPE_COMPUTER_READ, @@ -976,6 +983,18 @@ mod tests { ("run_shell", OAuthToolScopePolicy::Require(SCOPE_JOB_RUN)), ("stop_job", OAuthToolScopePolicy::Require(SCOPE_JOB_RUN)), ("cargo_test", OAuthToolScopePolicy::Require(SCOPE_JOB_RUN)), + ( + "coding_agent_start", + OAuthToolScopePolicy::RequireAll(&[SCOPE_CODING_AGENT_RUN, SCOPE_PROJECT_WRITE]), + ), + ( + "coding_agent_observe", + OAuthToolScopePolicy::Require(SCOPE_CODING_AGENT_RUN), + ), + ( + "coding_agent_cancel", + OAuthToolScopePolicy::Require(SCOPE_CODING_AGENT_RUN), + ), ] { assert_eq!(oauth_scope_policy_for_runtime_tool(tool), policy, "{tool}"); } @@ -1033,6 +1052,8 @@ mod tests { let metadata = lookup_tool_metadata(tool).unwrap(); let expected = if tool == "run_detached_process" { OAuthToolScopePolicy::RequireAll(&[SCOPE_JOB_RUN, SCOPE_JOB_DETACH]) + } else if tool == "coding_agent_start" { + OAuthToolScopePolicy::RequireAll(&[SCOPE_CODING_AGENT_RUN, SCOPE_PROJECT_WRITE]) } else if tool == "computer_save_snapshot" { OAuthToolScopePolicy::RequireAll(&[SCOPE_PROJECT_WRITE, SCOPE_COMPUTER_READ]) } else if tool == "computer_read_clipboard" { @@ -1101,6 +1122,31 @@ mod tests { } } + #[test] + fn coding_agent_scope_is_never_implicit_for_legacy_lightweight_authority() { + for (label, auth) in [ + ( + "shared-key", + crate::auth::shared_key_context("coding-agent-scope-check"), + ), + ("open", crate::auth::open_anonymous_context()), + ( + "project-credential", + crate::auth::shared_key::project_credential_context("wc_pgrant_codingagentscope"), + ), + ] { + assert!( + !auth.has_scope(SCOPE_CODING_AGENT_RUN), + "{label} must not implicitly gain delegated coding-agent authority" + ); + } + for base in [SCOPE_PROJECT_WRITE, SCOPE_JOB_RUN, SCOPE_MCP_LOCAL] { + let mut auth = crate::auth::AuthContext::new(crate::auth::AuthKind::OAuth2Token); + auth.scopes = vec![base.to_string()]; + assert!(!auth.has_scope(SCOPE_CODING_AGENT_RUN), "{base}"); + } + } + #[test] fn oauth_scope_policy_unknown_tool_is_unknown() { assert_eq!( diff --git a/src/connector_runtime/connector_runtime_tests.rs b/src/connector_runtime/connector_runtime_tests.rs index 03be0bd5..913ec64c 100644 --- a/src/connector_runtime/connector_runtime_tests.rs +++ b/src/connector_runtime/connector_runtime_tests.rs @@ -57,6 +57,8 @@ async fn register_agent_with_lsp_capabilities( build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "hosted".to_string(), agent_instance_id: "instance".to_string(), display_name: None, @@ -107,6 +109,7 @@ async fn register_agent_with_lsp_capabilities( computer_window_activate: false, computer_text_input: false, job_state_reconciliation: false, + coding_agent_runs: false, }), projects: Some(vec![ShellAgentProjectSummary { id: project_id.to_string(), diff --git a/src/connector_runtime/execution_tests.rs b/src/connector_runtime/execution_tests.rs index 713639f3..311c69d5 100644 --- a/src/connector_runtime/execution_tests.rs +++ b/src/connector_runtime/execution_tests.rs @@ -105,6 +105,8 @@ pub(crate) async fn console_fixture() -> ConsoleFixture { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "laptop".into(), agent_instance_id: "instance-b".into(), display_name: None, @@ -168,6 +170,8 @@ async fn fixture_built( build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "hosted".into(), agent_instance_id: "instance".into(), display_name: None, @@ -680,6 +684,8 @@ async fn connector_readiness_uses_registered_agent_capabilities() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "hosted".into(), agent_instance_id: "instance".into(), display_name: None, @@ -1980,6 +1986,8 @@ async fn go_json_execution_replay_does_not_require_current_runner_capability() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "hosted".into(), agent_instance_id: "instance".into(), display_name: None, @@ -2248,6 +2256,8 @@ async fn old_agent_cannot_receive_a_structured_validation_job() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "hosted".into(), agent_instance_id: "instance".into(), display_name: None, @@ -3482,6 +3492,8 @@ async fn read_only_commands_run_is_denied_even_when_agent_advertises_sandbox() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "hosted".into(), agent_instance_id: "instance".into(), display_name: None, @@ -3541,6 +3553,8 @@ async fn enable_inspect_sandbox(fixture: &Fixture) { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "hosted".into(), agent_instance_id: "instance".into(), display_name: None, @@ -4042,6 +4056,8 @@ async fn manifestless_python_unittest_checks_finish_with_clean_result() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "hosted".into(), agent_instance_id: "instance".into(), display_name: None, diff --git a/src/lib.rs b/src/lib.rs index a97be925..78eef1ef 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -195,11 +195,14 @@ only for local/trusted-network demos." let runtime_info = Arc::new(tool_runtime::RuntimeInfo::from_env_with_quic_config( &quic_cfg, )); + let runtime_state_dir = config.runtime_state_dir(); let mut tool_runtime_builder = tool_runtime::ToolRuntime::new(shell_registry.clone(), runtime_info.clone()) .with_model_surface(model_surface) - .with_checkpoint_state_dir(config.runtime_state_dir()) - .with_session_ledger(config.session_ledger_path()); + .with_checkpoint_state_dir(runtime_state_dir.clone()) + .with_session_ledger(config.session_ledger_path()) + .with_persistent_coding_agent_observation_state(&runtime_state_dir) + .map_err(std::io::Error::other)?; if let Some(activity_store) = db::WorkspaceActivityStore::from_env(db.clone()) { tool_runtime_builder = tool_runtime_builder.with_activity_recorder(Arc::new(activity_store)); diff --git a/src/mcp_tests/artifact_export.rs b/src/mcp_tests/artifact_export.rs index f4bdd1c7..e0ecdf57 100644 --- a/src/mcp_tests/artifact_export.rs +++ b/src/mcp_tests/artifact_export.rs @@ -19,6 +19,8 @@ async fn mcp_export_runtime_with_capabilities( build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "exporter".to_string(), agent_instance_id: "inst-export".to_string(), display_name: None, diff --git a/src/mcp_tests/file_import.rs b/src/mcp_tests/file_import.rs index 0dae120d..2c77e94e 100644 --- a/src/mcp_tests/file_import.rs +++ b/src/mcp_tests/file_import.rs @@ -236,6 +236,8 @@ async fn mcp_import_runtime( build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "importer".to_string(), agent_instance_id: "inst-import".to_string(), display_name: None, diff --git a/src/mcp_tests/http_transport.rs b/src/mcp_tests/http_transport.rs index 3f9397cb..14019fb5 100644 --- a/src/mcp_tests/http_transport.rs +++ b/src/mcp_tests/http_transport.rs @@ -94,6 +94,8 @@ async fn stateless_observation_shell_clients() -> Arc Option Option> { +fn bridge_scope_ceiling_without_optional_class_scopes(scopes: &[String]) -> Option> { if !bridge_scope_list_is_unique(scopes) { return None; } Some( scopes .iter() - .filter(|scope| scope.as_str() != SCOPE_MCP_LOCAL) + .filter(|scope| !matches!(scope.as_str(), SCOPE_MCP_LOCAL | SCOPE_CODING_AGENT_RUN)) .cloned() .collect(), ) } fn bridge_scope_ceiling_is_valid(scopes: &[String]) -> bool { - let Some(base) = bridge_scope_ceiling_without_local_mcp(scopes) else { + let Some(base) = bridge_scope_ceiling_without_optional_class_scopes(scopes) else { return false; }; bridge_baseline_scope_ceiling_is_valid(&base) || bridge_computer_scope_ceiling_is_valid(&base) @@ -184,8 +184,9 @@ fn bridge_scope_ceiling_with_options( scopes: &[String], computer_permissions: bool, local_mcp: bool, + coding_agent: bool, ) -> Option> { - let base = bridge_scope_ceiling_without_local_mcp(scopes)?; + let base = bridge_scope_ceiling_without_optional_class_scopes(scopes)?; let mut desired = if computer_permissions { bridge_computer_enabled_scope_ceiling(&base)? } else if bridge_baseline_scope_ceiling_is_valid(&base) { @@ -196,13 +197,21 @@ fn bridge_scope_ceiling_with_options( if local_mcp { desired.push(SCOPE_MCP_LOCAL.to_string()); } + if coding_agent { + desired.push(SCOPE_CODING_AGENT_RUN.to_string()); + } Some(desired) } -fn bridge_scope_profile_error(computer_permissions: bool, local_mcp: bool) -> &'static str { - match (computer_permissions, local_mcp) { - (true, false) => "persisted OAuth scope ceiling is not valid for Computer opt-in", - (false, true) => "persisted OAuth scope ceiling is not valid for local MCP opt-in", +fn bridge_scope_profile_error( + computer_permissions: bool, + local_mcp: bool, + coding_agent: bool, +) -> &'static str { + match (computer_permissions, local_mcp, coding_agent) { + (true, false, false) => "persisted OAuth scope ceiling is not valid for Computer opt-in", + (false, true, false) => "persisted OAuth scope ceiling is not valid for local MCP opt-in", + (false, false, true) => "persisted OAuth scope ceiling is not valid for coding-agent opt-in", _ => "persisted OAuth scope ceiling is not valid for the requested explicit permission profile", } } @@ -211,7 +220,7 @@ fn bridge_client_is_computer_enabled(client: &crate::models::OAuthClientRecord) if !client.is_shared_key_owned() { return false; } - bridge_scope_ceiling_without_local_mcp(&client.allowed_scopes_vec()) + bridge_scope_ceiling_without_optional_class_scopes(&client.allowed_scopes_vec()) .is_some_and(|scopes| bridge_computer_scope_ceiling_is_valid(&scopes)) } @@ -222,6 +231,13 @@ fn bridge_client_has_local_mcp_scope(client: &crate::models::OAuthClientRecord) .any(|scope| scope == SCOPE_MCP_LOCAL) } +fn bridge_client_has_coding_agent_scope(client: &crate::models::OAuthClientRecord) -> bool { + client + .allowed_scopes_vec() + .iter() + .any(|scope| scope == SCOPE_CODING_AGENT_RUN) +} + fn bridge_client_has_optional_computer_scope(client: &crate::models::OAuthClientRecord) -> bool { client .allowed_scopes_vec() @@ -253,6 +269,7 @@ pub(crate) fn normalize_bridge_oauth_scopes( if normalized.split_whitespace().any(|scope| { scope != OAUTH_OFFLINE_ACCESS_SCOPE && scope != SCOPE_MCP_LOCAL + && scope != SCOPE_CODING_AGENT_RUN && !SHARED_KEY_OAUTH_COMPUTER_ENABLED_SCOPES.contains(&scope) }) { return Err(OAuthAuthorizeError::InvalidScope( @@ -275,7 +292,10 @@ impl BridgeAuthorizeValidated { fn standard_grant_scopes(&self) -> Vec { self.requestable_scopes .split_whitespace() - .filter(|scope| bridge_oauth_scopes().contains(scope) || *scope == SCOPE_MCP_LOCAL) + .filter(|scope| { + bridge_oauth_scopes().contains(scope) + || matches!(*scope, SCOPE_MCP_LOCAL | SCOPE_CODING_AGENT_RUN) + }) .map(str::to_string) .collect() } @@ -510,6 +530,8 @@ pub(super) fn validate_bridge_authorize_request( let computer_permissions_enabled = bridge_client_is_computer_enabled(&client); let local_mcp_enabled = client.is_shared_key_owned() && bridge_client_has_local_mcp_scope(&client); + let coding_agent_enabled = + client.is_shared_key_owned() && bridge_client_has_coding_agent_scope(&client); let client_bridge_ceiling = if computer_permissions_enabled { SHARED_KEY_OAUTH_COMPUTER_ENABLED_SCOPES } else { @@ -518,6 +540,7 @@ pub(super) fn validate_bridge_authorize_request( if requestable_scopes.split_whitespace().any(|scope| { scope != OAUTH_OFFLINE_ACCESS_SCOPE && !(scope == SCOPE_MCP_LOCAL && local_mcp_enabled) + && !(scope == SCOPE_CODING_AGENT_RUN && coding_agent_enabled) && !client_bridge_ceiling.contains(&scope) }) { redirect_with_oauth_error( @@ -644,6 +667,8 @@ struct ProvisionSharedKeyOAuthClientRequest { computer_permissions: bool, #[serde(default)] local_mcp: bool, + #[serde(default)] + coding_agent: bool, } fn bridge_client_scopes_are_current(client: &crate::models::OAuthClientRecord) -> bool { @@ -770,16 +795,28 @@ pub(crate) async fn oauth_shared_key_client_provision( }))); return; } + if !body.coding_agent && bridge_client_has_coding_agent_scope(&client) { + res.status_code(StatusCode::CONFLICT); + res.render(Json(serde_json::json!({ + "error": "OAuth client has coding-agent authority enabled; reconnect with --oauth-coding-agent to reuse this client" + }))); + return; + } let current_scopes = client.allowed_scopes_vec(); let Some(desired_scope_vec) = bridge_scope_ceiling_with_options( ¤t_scopes, body.computer_permissions, body.local_mcp, + body.coding_agent, ) else { res.status_code(StatusCode::CONFLICT); res.render(Json(serde_json::json!({ - "error": bridge_scope_profile_error(body.computer_permissions, body.local_mcp) + "error": bridge_scope_profile_error( + body.computer_permissions, + body.local_mcp, + body.coding_agent, + ) }))); return; }; @@ -862,12 +899,30 @@ pub(crate) async fn oauth_shared_key_client_provision( }))); return; } - let Some(create_scopes) = - bridge_scope_ceiling_with_options(&base_scopes, body.computer_permissions, body.local_mcp) - else { + if !body.coding_agent + && base_scopes + .iter() + .any(|scope| scope == SCOPE_CODING_AGENT_RUN) + { res.status_code(StatusCode::CONFLICT); res.render(Json(serde_json::json!({ - "error": bridge_scope_profile_error(body.computer_permissions, body.local_mcp) + "error": "persisted OAuth profile has coding-agent authority enabled; reconnect with --oauth-coding-agent" + }))); + return; + } + let Some(create_scopes) = bridge_scope_ceiling_with_options( + &base_scopes, + body.computer_permissions, + body.local_mcp, + body.coding_agent, + ) else { + res.status_code(StatusCode::CONFLICT); + res.render(Json(serde_json::json!({ + "error": bridge_scope_profile_error( + body.computer_permissions, + body.local_mcp, + body.coding_agent, + ) }))); return; }; diff --git a/src/oauth_http/tests/shared_key_bridge.rs b/src/oauth_http/tests/shared_key_bridge.rs index e1a96e84..6787d753 100644 --- a/src/oauth_http/tests/shared_key_bridge.rs +++ b/src/oauth_http/tests/shared_key_bridge.rs @@ -151,6 +151,80 @@ async fn bridge_authorize_local_mcp_requires_shared_key_owned_opt_in() { ); } +#[test] +fn bridge_coding_agent_scope_requires_explicit_client_ceiling_opt_in() { + assert!(!bridge_oauth_scopes().contains(&crate::auth::SCOPE_CODING_AGENT_RUN)); + + let baseline = bridge_oauth_scopes() + .iter() + .map(|scope| (*scope).to_string()) + .collect::>(); + let mut opted_in = baseline.clone(); + opted_in.push(crate::auth::SCOPE_CODING_AGENT_RUN.to_string()); + assert!(normalize_bridge_oauth_scopes( + Some(crate::auth::SCOPE_CODING_AGENT_RUN), + &opted_in.join(" "), + ) + .is_ok()); + assert!(normalize_bridge_oauth_scopes( + Some(crate::auth::SCOPE_CODING_AGENT_RUN), + &baseline.join(" "), + ) + .is_err()); +} + +#[tokio::test] +async fn bridge_authorize_coding_agent_requires_shared_key_owned_opt_in() { + let config = test_config(oauth2_enabled_bridge()); + let (_tmp, db) = test_db(); + let shared_key = "coding-agent-owned-shared-key"; + let allowed_scopes = format!( + "{} {}", + bridge_oauth_scopes().join(" "), + crate::auth::SCOPE_CODING_AGENT_RUN + ); + let (owned, _) = seed_shared_key_bridge_client( + &db, + shared_key, + "https://coding-agent.example/callback", + &allowed_scopes, + ); + let service = Service::new(build_router(config.clone(), db.clone())); + let owned_url = valid_bridge_authorize_url( + &owned, + "https://coding-agent.example/callback", + "runtime:read coding_agent:run", + ); + let mut owned_response = TestClient::get(&owned_url).send(&service).await; + assert_eq!(owned_response.status_code, Some(StatusCode::OK)); + let owned_html = owned_response.take_string().await.unwrap_or_default(); + assert!(owned_html.contains("coding_agent:run")); + + let user = seed_user(&db, "coding-agent-legacy-owner"); + let legacy = seed_client_with_redirects_and_scopes( + &db, + &user, + "https://legacy-coding-agent.example/callback", + &allowed_scopes, + ); + let legacy_url = valid_bridge_authorize_url( + &legacy, + "https://legacy-coding-agent.example/callback", + "runtime:read coding_agent:run", + ); + let legacy_response = TestClient::get(&legacy_url).send(&service).await; + assert_eq!(legacy_response.status_code, Some(StatusCode::FOUND)); + let location = url::Url::parse(&location_header(&legacy_response).unwrap()).unwrap(); + assert_eq!( + location + .query_pairs() + .find(|(key, _)| key == "error") + .map(|(_, value)| value.into_owned()) + .as_deref(), + Some("invalid_scope") + ); +} + #[test] fn normalize_bridge_oauth_scopes_accepts_offline_access_as_protocol_scope() { let normalized = normalize_bridge_oauth_scopes( @@ -198,6 +272,8 @@ async fn register_shared_key_runner_with_capabilities( build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, }, Some(&auth), ) diff --git a/src/project_entry_tests.rs b/src/project_entry_tests.rs index eb37dc62..4bfd5d95 100644 --- a/src/project_entry_tests.rs +++ b/src/project_entry_tests.rs @@ -213,6 +213,8 @@ async fn authenticated_project_fixture_for(recipe: &str) -> AuthenticatedProject build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: config.executor_client_id.clone(), agent_instance_id: "project-agent-instance".to_string(), display_name: Some("configured project Agent".to_string()), diff --git a/src/runtime_console_http.rs b/src/runtime_console_http.rs index 2a6aa7cc..401c62e5 100644 --- a/src/runtime_console_http.rs +++ b/src/runtime_console_http.rs @@ -1235,6 +1235,8 @@ mod tests { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: format!("inst-{client_id}"), display_name: Some(format!("Device {client_id}")), diff --git a/src/runtime_http_tests.rs b/src/runtime_http_tests.rs index 9117fee6..8e14bfb4 100644 --- a/src/runtime_http_tests.rs +++ b/src/runtime_http_tests.rs @@ -273,6 +273,8 @@ async fn register_import_agent_with_capabilities( build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "importer".to_string(), agent_instance_id: "inst-import".to_string(), display_name: None, diff --git a/src/shell_client/agents.rs b/src/shell_client/agents.rs index 566421de..95894fac 100644 --- a/src/shell_client/agents.rs +++ b/src/shell_client/agents.rs @@ -26,6 +26,87 @@ use crate::shell_protocol::{ use std::collections::{HashSet, VecDeque}; use std::sync::Arc; use tokio::sync::Notify; +use webcodex_core::coding_agent::{ + validate_coding_agent_run_snapshot, validate_provider_id as validate_coding_agent_provider_id, + validate_provider_instance_id as validate_coding_agent_provider_instance_id, + CodingAgentProvider, CodingAgentRunInventory, CodingAgentRunSnapshot, + CODING_AGENT_MAX_INVENTORY_RUNS, CODING_AGENT_MAX_PROVIDERS, + CODING_AGENT_MAX_PROVIDER_NAME_BYTES, +}; + +fn validate_coding_agent_registration( + client_id: &str, + capability: bool, + providers: Option<&[CodingAgentProvider]>, + inventory: Option<&CodingAgentRunInventory>, +) -> Result<(), String> { + match (capability, providers, inventory) { + (false, None, None) => return Ok(()), + (false, _, _) => { + return Err( + "coding-agent provider/inventory metadata requires coding_agent_runs capability" + .to_string(), + ) + } + (true, Some(providers), Some(inventory)) if !providers.is_empty() => { + if providers.len() > CODING_AGENT_MAX_PROVIDERS { + return Err("coding-agent provider inventory exceeds bounded limit".to_string()); + } + if inventory.runs.len() > CODING_AGENT_MAX_INVENTORY_RUNS { + return Err("coding-agent Run inventory exceeds bounded limit".to_string()); + } + let mut provider_ids = HashSet::new(); + let mut provider_instances = HashSet::new(); + for provider in providers { + validate_coding_agent_provider_id(&provider.provider_id) + .map_err(|error| format!("invalid coding-agent provider id: {error}"))?; + validate_coding_agent_provider_instance_id(&provider.provider_instance_id) + .map_err(|error| format!("invalid coding-agent provider instance: {error}"))?; + if provider.name.trim().is_empty() + || provider.name.len() > CODING_AGENT_MAX_PROVIDER_NAME_BYTES + || provider.name.chars().any(char::is_control) + { + return Err("invalid coding-agent provider name".to_string()); + } + if !provider_ids.insert(provider.provider_id.as_str()) + || !provider_instances.insert(provider.provider_instance_id.as_str()) + { + return Err("duplicate coding-agent provider identity".to_string()); + } + } + let expected_project_prefix = format!("agent:{client_id}:"); + let mut run_ids = HashSet::new(); + for run in &inventory.runs { + validate_coding_agent_run_snapshot(run) + .map_err(|error| format!("invalid coding-agent Run snapshot: {error}"))?; + if !run_ids.insert(run.run_id.as_str()) { + return Err("duplicate coding-agent Run id in inventory".to_string()); + } + if !run.runtime_project_id.starts_with(&expected_project_prefix) { + return Err( + "coding-agent Run inventory references another Runner project namespace" + .to_string(), + ); + } + if !run.state.terminal() + && !providers.iter().any(|provider| { + provider.provider_id == run.provider_id + && provider.provider_instance_id == run.provider_instance_id + }) + { + return Err( + "active coding-agent Run references a stale provider instance".to_string(), + ); + } + } + Ok(()) + } + (true, _, _) => Err( + "coding_agent_runs capability requires non-empty provider inventory and Run inventory" + .to_string(), + ), + } +} impl ShellClientRegistry { #[cfg(test)] @@ -68,6 +149,16 @@ impl ShellClientRegistry { let agent_instance_id = body.agent_instance_id.trim().to_string(); let capabilities = body.capabilities.clone().unwrap_or_default(); let job_inventory = body.job_inventory.clone(); + let coding_agent_providers = body.coding_agent_providers.clone(); + let coding_agent_inventory = body.coding_agent_inventory.clone(); + validate_coding_agent_registration( + &client_id, + capabilities.coding_agent_runs, + coding_agent_providers.as_deref(), + coding_agent_inventory.as_ref(), + )?; + let coding_agent_providers = coding_agent_providers.unwrap_or_default(); + let coding_agent_inventory = coding_agent_inventory.unwrap_or_default(); let agent_protocol_version = body .agent_protocol_version .as_deref() @@ -134,6 +225,8 @@ impl ShellClientRegistry { process_started_at: body.process_started_at, build: body.build, job_concurrency_limit: body.job_concurrency_limit, + coding_agent_providers: coding_agent_providers.clone(), + coding_agent_inventory, projected_structured_terminal_suppressions: VecDeque::new(), }; match ( @@ -232,6 +325,24 @@ impl ShellClientRegistry { .to_string(), ); } + if inner.clients.get(&client_id).is_some_and(|existing| { + existing.agent_instance_id == agent_instance_id + && existing.capabilities.coding_agent_runs + && !capabilities.coding_agent_runs + }) { + return Err( + "same runner instance cannot downgrade coding_agent_runs capability".to_string(), + ); + } + if inner.clients.get(&client_id).is_some_and(|existing| { + existing.agent_instance_id == agent_instance_id + && existing.coding_agent_providers != coding_agent_providers + }) { + return Err( + "same runner instance cannot change ACP coding-agent provider inventory" + .to_string(), + ); + } // `agent_instance_id` is the Runner process identity, so a same-process // reconnect that stops advertising structured_file_delete is a // downgrade of process-lifetime capability: reject it before replacing @@ -1034,6 +1145,67 @@ impl ShellClientRegistry { Self::client_view_locked(&inner, client_id) } + pub(crate) async fn coding_agent_run_for_client_for_auth( + &self, + auth: Option<&crate::auth::AuthContext>, + client_id: &str, + run_id: &str, + ) -> Option<(ShellClientView, CodingAgentRunSnapshot)> { + let mut inner = self.inner.lock().await; + self.prune_expired_shared_key_clients_locked(&mut inner, now_ts()); + let client = inner.clients.get(client_id)?; + if !shell_client_visible_to_auth(auth, client) { + return None; + } + let run = client + .coding_agent_inventory + .runs + .iter() + .find(|run| run.run_id == run_id) + .cloned()?; + let view = Self::client_view_locked(&inner, client_id)?; + Some((view, run)) + } + + pub(crate) async fn coding_agent_run_for_auth( + &self, + auth: Option<&crate::auth::AuthContext>, + run_id: &str, + ) -> Option<(ShellClientView, CodingAgentRunSnapshot)> { + let now = now_ts(); + let mut inner = self.inner.lock().await; + self.prune_expired_shared_key_clients_locked(&mut inner, now); + let mut ids = inner.clients.keys().cloned().collect::>(); + ids.sort(); + let mut matched = None; + for client_id in ids { + let Some(client) = inner.clients.get(&client_id) else { + continue; + }; + if !shell_client_visible_to_auth(auth, client) { + continue; + } + let Some(run) = client + .coding_agent_inventory + .runs + .iter() + .find(|run| run.run_id == run_id) + .cloned() + else { + continue; + }; + if matched.is_some() { + // A Server restart has no process-local binding to disambiguate + // duplicate run ids. Fail closed instead of choosing a Runner by + // registry iteration order and silently retargeting provenance. + return None; + } + let view = Self::client_view_locked(&inner, &client_id)?; + matched = Some((view, run)); + } + matched + } + pub(crate) async fn assert_client_access( &self, auth: Option<&crate::auth::AuthContext>, @@ -1071,6 +1243,8 @@ impl ShellClientRegistry { connected, last_seen: client.last_seen, capabilities: client.capabilities.clone(), + coding_agent_providers: (!client.coding_agent_providers.is_empty()) + .then(|| client.coding_agent_providers.clone()), pending_requests, projects: client.projects.clone(), project_inventory: Some(client.project_inventory.status.clone()), diff --git a/src/shell_client/job_updates.rs b/src/shell_client/job_updates.rs index 9dcf8194..976b17c7 100644 --- a/src/shell_client/job_updates.rs +++ b/src/shell_client/job_updates.rs @@ -839,6 +839,7 @@ impl ShellClientRegistry { sandbox, job_context: Some(job_context), mcp_gateway: None, + coding_agent: None, persistent_shell: None, }; let mut inner = self.inner.lock().await; @@ -1219,6 +1220,7 @@ impl ShellClientRegistry { sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, }; enqueue_pending_request_locked( @@ -1755,6 +1757,7 @@ impl ShellClientRegistry { sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, }; enqueue_pending_request_locked( diff --git a/src/shell_client/mod_tests.rs b/src/shell_client/mod_tests.rs index 4f92f6a1..b7717128 100644 --- a/src/shell_client/mod_tests.rs +++ b/src/shell_client/mod_tests.rs @@ -105,6 +105,8 @@ fn runner_registration( build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: agent_instance_id.to_string(), display_name: None, @@ -225,6 +227,8 @@ async fn register_computer_test_client( build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: "computer-inst".to_string(), display_name: None, @@ -259,6 +263,8 @@ async fn register_quic_v1_client(registry: &ShellClientRegistry, client_id: &str build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: "inst".to_string(), display_name: None, @@ -301,6 +307,8 @@ async fn register_instance_with_capabilities( build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: instance.to_string(), display_name: None, @@ -367,6 +375,8 @@ async fn register_with_instance( build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: instance.to_string(), display_name: None, diff --git a/src/shell_client/mod_tests/client_liveness.rs b/src/shell_client/mod_tests/client_liveness.rs index d09fb459..1409ed33 100644 --- a/src/shell_client/mod_tests/client_liveness.rs +++ b/src/shell_client/mod_tests/client_liveness.rs @@ -9,6 +9,8 @@ async fn touch_client_refreshes_stale_client_back_to_online() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "oe".to_string(), agent_instance_id: "inst".to_string(), display_name: None, diff --git a/src/shell_client/mod_tests/computer_accessibility.rs b/src/shell_client/mod_tests/computer_accessibility.rs index 1f8bc388..32dde826 100644 --- a/src/shell_client/mod_tests/computer_accessibility.rs +++ b/src/shell_client/mod_tests/computer_accessibility.rs @@ -79,6 +79,8 @@ async fn computer_element_state_requires_its_own_additive_capability() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "computer-state-capable".to_string(), agent_instance_id: "computer-inst".to_string(), display_name: None, diff --git a/src/shell_client/mod_tests/computer_control.rs b/src/shell_client/mod_tests/computer_control.rs index 29ff0d33..f81682fe 100644 --- a/src/shell_client/mod_tests/computer_control.rs +++ b/src/shell_client/mod_tests/computer_control.rs @@ -100,6 +100,8 @@ async fn computer_scroll_to_element_requires_independent_capability() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "computer-scroll-capable".to_string(), agent_instance_id: "computer-scroll-inst".to_string(), display_name: None, @@ -178,6 +180,8 @@ async fn computer_key_input_requires_independent_capability() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "computer-key-capable".to_string(), agent_instance_id: "computer-key-inst".to_string(), display_name: None, @@ -234,6 +238,8 @@ async fn computer_pointer_enqueue_requires_independent_capability_and_typed_enve build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "computer-pointer-old".to_string(), agent_instance_id: "pointer-old-inst".to_string(), display_name: None, @@ -275,6 +281,8 @@ async fn computer_pointer_enqueue_requires_independent_capability_and_typed_enve build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "computer-pointer-capable".to_string(), agent_instance_id: "pointer-inst".to_string(), display_name: None, @@ -330,6 +338,8 @@ async fn computer_clipboard_enqueue_requires_independent_capabilities_and_typed_ build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "computer-clipboard-old".to_string(), agent_instance_id: "clipboard-old-inst".to_string(), display_name: None, @@ -379,6 +389,8 @@ async fn computer_clipboard_enqueue_requires_independent_capabilities_and_typed_ build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "computer-clipboard-read".to_string(), agent_instance_id: "clipboard-read-inst".to_string(), display_name: None, @@ -441,6 +453,8 @@ async fn computer_clipboard_enqueue_requires_independent_capabilities_and_typed_ build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "computer-clipboard-write".to_string(), agent_instance_id: "clipboard-write-inst".to_string(), display_name: None, @@ -532,6 +546,8 @@ async fn computer_window_activation_requires_its_own_additive_capability() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "computer-activate".to_string(), agent_instance_id: "computer-inst".to_string(), display_name: None, diff --git a/src/shell_client/mod_tests/computer_observe.rs b/src/shell_client/mod_tests/computer_observe.rs index e99eb884..a0cdebc8 100644 --- a/src/shell_client/mod_tests/computer_observe.rs +++ b/src/shell_client/mod_tests/computer_observe.rs @@ -111,6 +111,8 @@ async fn computer_snapshot_region_requires_additive_capability() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "computer-region-only".to_string(), agent_instance_id: "computer-inst".to_string(), display_name: None, @@ -149,6 +151,8 @@ async fn computer_snapshot_region_requires_additive_capability() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "computer-region-new".to_string(), agent_instance_id: "computer-inst".to_string(), display_name: None, @@ -203,6 +207,8 @@ async fn computer_snapshot_display_preserves_large_native_image_response_stdout( build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "computer-display-large".to_string(), agent_instance_id: "display-large-inst".to_string(), display_name: None, diff --git a/src/shell_client/mod_tests/computer_snapshot_artifact.rs b/src/shell_client/mod_tests/computer_snapshot_artifact.rs index e953fe1e..e6578fa5 100644 --- a/src/shell_client/mod_tests/computer_snapshot_artifact.rs +++ b/src/shell_client/mod_tests/computer_snapshot_artifact.rs @@ -33,6 +33,8 @@ async fn computer_snapshot_artifact_requires_current_target_project_and_file_wri build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: instance_id.to_string(), display_name: None, diff --git a/src/shell_client/mod_tests/connection_lease.rs b/src/shell_client/mod_tests/connection_lease.rs index 1c0346fa..65f7db08 100644 --- a/src/shell_client/mod_tests/connection_lease.rs +++ b/src/shell_client/mod_tests/connection_lease.rs @@ -17,6 +17,8 @@ async fn register_with_connection( build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: instance.to_string(), display_name: None, @@ -227,6 +229,8 @@ async fn stale_connection_runtime_metadata_does_not_overwrite_current() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "oe".to_string(), agent_instance_id: "inst-x".to_string(), display_name: None, diff --git a/src/shell_client/mod_tests/disconnect_reconciliation.rs b/src/shell_client/mod_tests/disconnect_reconciliation.rs index 2e70d37b..1d6a9aad 100644 --- a/src/shell_client/mod_tests/disconnect_reconciliation.rs +++ b/src/shell_client/mod_tests/disconnect_reconciliation.rs @@ -9,6 +9,8 @@ async fn reconcile_disconnect_marks_running_jobs_lost() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "oe".to_string(), agent_instance_id: "inst".to_string(), display_name: None, @@ -66,6 +68,8 @@ async fn reconcile_disconnect_fails_pending_sync_requests_fast() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "oe".to_string(), agent_instance_id: "inst".to_string(), display_name: None, diff --git a/src/shell_client/mod_tests/instance_lease.rs b/src/shell_client/mod_tests/instance_lease.rs index c4346a22..cc32dbf5 100644 --- a/src/shell_client/mod_tests/instance_lease.rs +++ b/src/shell_client/mod_tests/instance_lease.rs @@ -34,6 +34,8 @@ async fn lease_different_online_instance_rejected() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "oe".to_string(), agent_instance_id: "inst-b".to_string(), display_name: None, @@ -515,6 +517,8 @@ async fn lease_register_rejects_empty_instance_id() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "oe".to_string(), agent_instance_id: "".to_string(), display_name: None, @@ -539,6 +543,7 @@ async fn lease_replacement_transfers_exact_detached_inventory_to_new_instance() async_jobs: true, async_shell_jobs: true, job_state_reconciliation: true, + coding_agent_runs: false, structured_process_argv: true, structured_execution_jobs: true, detached_process_jobs: true, @@ -553,6 +558,8 @@ async fn lease_replacement_transfers_exact_detached_inventory_to_new_instance() active_complete: true, jobs: Vec::new(), }), + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "oe".to_string(), agent_instance_id: "inst-a".to_string(), display_name: None, @@ -679,6 +686,8 @@ async fn lease_replacement_transfers_exact_detached_inventory_to_new_instance() active_complete: true, jobs: vec![snapshot.clone()], }), + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "oe".to_string(), agent_instance_id: "inst-b".to_string(), display_name: None, diff --git a/src/shell_client/mod_tests/job_lifecycle.rs b/src/shell_client/mod_tests/job_lifecycle.rs index 29047bf4..7400bf0b 100644 --- a/src/shell_client/mod_tests/job_lifecycle.rs +++ b/src/shell_client/mod_tests/job_lifecycle.rs @@ -9,6 +9,8 @@ async fn terminal_observed_legacy_poll_complete_and_log() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "oe".to_string(), agent_instance_id: "inst".to_string(), display_name: None, @@ -126,6 +128,8 @@ async fn terminal_observed_queued_stop_records_server_time() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "oe".to_string(), agent_instance_id: "inst".to_string(), display_name: None, @@ -189,6 +193,8 @@ async fn registry_shell_job_stop_running_delivers_stop_to_client() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "oe".to_string(), agent_instance_id: "inst".to_string(), display_name: None, @@ -259,6 +265,8 @@ async fn registry_marks_running_job_lost_when_client_stale() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "oe".to_string(), agent_instance_id: "inst".to_string(), display_name: None, diff --git a/src/shell_client/mod_tests/job_log_wait.rs b/src/shell_client/mod_tests/job_log_wait.rs index 067a8189..74d6664b 100644 --- a/src/shell_client/mod_tests/job_log_wait.rs +++ b/src/shell_client/mod_tests/job_log_wait.rs @@ -15,6 +15,7 @@ fn sequenced_job_capabilities() -> ShellClientCapabilities { async_shell_jobs: true, jobs: true, job_state_reconciliation: true, + coding_agent_runs: false, structured_validation_argv: true, ..Default::default() } @@ -59,6 +60,8 @@ async fn register_sequenced(registry: &ShellClientRegistry, instance: &str) { active_complete: true, jobs: Vec::new(), }), + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "oe".to_string(), agent_instance_id: instance.to_string(), display_name: None, @@ -450,6 +453,8 @@ async fn job_log_wait_legacy_update_between_calls_and_noop_replacement() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "legacy".to_string(), agent_instance_id: "legacy-inst".to_string(), display_name: None, diff --git a/src/shell_client/mod_tests/lsp.rs b/src/shell_client/mod_tests/lsp.rs index 457e9d6a..c943d108 100644 --- a/src/shell_client/mod_tests/lsp.rs +++ b/src/shell_client/mod_tests/lsp.rs @@ -28,6 +28,8 @@ async fn register_lsp_test_client_capabilities( build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: "inst".to_string(), display_name: None, diff --git a/src/shell_client/mod_tests/mcp_gateway.rs b/src/shell_client/mod_tests/mcp_gateway.rs index 4790bcfa..3ed3a67f 100644 --- a/src/shell_client/mod_tests/mcp_gateway.rs +++ b/src/shell_client/mod_tests/mcp_gateway.rs @@ -39,6 +39,8 @@ async fn register_bridge_runner(registry: &ShellClientRegistry) { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, }) .await .unwrap(); @@ -74,6 +76,8 @@ fn bridge_registration( build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, } } @@ -410,6 +414,7 @@ async fn typed_bridge_result_is_correlated_once() { mcp_gateway: Some(McpGatewayResponse::success( McpGatewayResponsePayload::Tools { tools: Vec::new() }, )), + coding_agent: None, }; registry.complete(payload.clone()).await.unwrap(); let response = receiver.await.unwrap(); diff --git a/src/shell_client/mod_tests/polling.rs b/src/shell_client/mod_tests/polling.rs index 97a96991..40af40e5 100644 --- a/src/shell_client/mod_tests/polling.rs +++ b/src/shell_client/mod_tests/polling.rs @@ -9,6 +9,8 @@ async fn registry_enqueues_polls_and_completes_shell_request() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "xrh".to_string(), agent_instance_id: "inst".to_string(), display_name: None, @@ -75,6 +77,8 @@ async fn polling_out_of_order_results_resolve_only_their_original_waiters() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "ordered".to_string(), agent_instance_id: "inst".to_string(), display_name: None, diff --git a/src/shell_client/mod_tests/project_projection.rs b/src/shell_client/mod_tests/project_projection.rs index e0798d99..eba51d6f 100644 --- a/src/shell_client/mod_tests/project_projection.rs +++ b/src/shell_client/mod_tests/project_projection.rs @@ -71,6 +71,8 @@ async fn registry_register_saves_projects() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "oe".to_string(), agent_instance_id: "inst".to_string(), display_name: None, @@ -102,6 +104,8 @@ async fn registry_poll_updates_projects() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "oe".to_string(), agent_instance_id: "inst".to_string(), display_name: None, @@ -143,6 +147,8 @@ async fn registry_poll_without_projects_preserves_existing_projection() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "oe".to_string(), agent_instance_id: "inst".to_string(), display_name: None, @@ -182,6 +188,8 @@ async fn registry_project_owner_check_enforces_boundary() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "alice-client".to_string(), agent_instance_id: "inst".to_string(), display_name: None, @@ -201,6 +209,8 @@ async fn registry_project_owner_check_enforces_boundary() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "bob-client".to_string(), agent_instance_id: "inst".to_string(), display_name: None, diff --git a/src/shell_client/mod_tests/protocol.rs b/src/shell_client/mod_tests/protocol.rs index 4fc6121d..62139a33 100644 --- a/src/shell_client/mod_tests/protocol.rs +++ b/src/shell_client/mod_tests/protocol.rs @@ -84,6 +84,8 @@ async fn register_without_protocol_version_defaults_to_unknown() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "oe".to_string(), agent_instance_id: "inst".to_string(), display_name: None, @@ -110,6 +112,8 @@ async fn register_with_protocol_version_is_exposed_in_view() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "xrh".to_string(), agent_instance_id: "inst".to_string(), display_name: None, @@ -140,6 +144,8 @@ async fn register_blank_protocol_version_falls_back_to_unknown() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "oe".to_string(), agent_instance_id: "inst".to_string(), display_name: None, @@ -176,6 +182,8 @@ async fn client_supports_reflects_registered_capabilities() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "oe".to_string(), agent_instance_id: "inst".to_string(), display_name: None, @@ -247,6 +255,159 @@ async fn client_supports_reflects_registered_capabilities() { ); } +#[tokio::test] +async fn coding_agent_run_lookup_is_exact_when_bound_and_ambiguous_when_unbound() { + let registry = ShellClientRegistry::default(); + let run_id = "wc_agent_run_duplicate_123"; + for client_id in ["a", "b"] { + let provider_instance_id = format!("provider_{client_id}"); + registry + .register(ShellClientRegisterRequest { + process_started_at: None, + build: None, + job_concurrency_limit: None, + job_inventory: None, + coding_agent_providers: Some(vec![ + webcodex_core::coding_agent::CodingAgentProvider { + provider_id: "codex".to_string(), + provider_instance_id: provider_instance_id.clone(), + name: "Codex".to_string(), + }, + ]), + coding_agent_inventory: Some( + webcodex_core::coding_agent::CodingAgentRunInventory { + runs: vec![webcodex_core::coding_agent::CodingAgentRunSnapshot { + run_id: run_id.to_string(), + intent_fingerprint: "fingerprint".to_string(), + authority_fingerprint: "auth_test".to_string(), + runtime_project_id: format!("agent:{client_id}:demo"), + provider_id: "codex".to_string(), + provider_instance_id, + state: webcodex_core::coding_agent::CodingAgentRunState::Running, + execution_state: + webcodex_core::coding_agent::CodingAgentExecutionState::Started, + observation_revision: 1, + created_at: 1, + updated_at: 1, + terminal: None, + }], + }, + ), + client_id: client_id.to_string(), + agent_instance_id: format!("inst_{client_id}"), + display_name: None, + owner: None, + hostname: None, + host_context: None, + capabilities: Some(ShellClientCapabilities { + coding_agent_runs: true, + ..Default::default() + }), + projects: None, + agent_protocol_version: None, + policy: None, + }) + .await + .unwrap(); + } + + assert!(registry + .coding_agent_run_for_auth(None, run_id) + .await + .is_none()); + let (client, run) = registry + .coding_agent_run_for_client_for_auth(None, "b", run_id) + .await + .expect("exact bound client lookup"); + assert_eq!(client.client_id, "b"); + assert_eq!(run.runtime_project_id, "agent:b:demo"); +} + +#[tokio::test] +async fn coding_agent_registration_rejects_semantically_contradictory_snapshot() { + let registry = ShellClientRegistry::default(); + let register = + |run: webcodex_core::coding_agent::CodingAgentRunSnapshot| ShellClientRegisterRequest { + process_started_at: None, + build: None, + job_concurrency_limit: None, + job_inventory: None, + coding_agent_providers: Some(vec![webcodex_core::coding_agent::CodingAgentProvider { + provider_id: "codex".to_string(), + provider_instance_id: "provider_test".to_string(), + name: "Codex".to_string(), + }]), + coding_agent_inventory: Some(webcodex_core::coding_agent::CodingAgentRunInventory { + runs: vec![run], + }), + client_id: "test".to_string(), + agent_instance_id: "inst_test".to_string(), + display_name: None, + owner: None, + hostname: None, + host_context: None, + capabilities: Some(ShellClientCapabilities { + coding_agent_runs: true, + ..Default::default() + }), + projects: None, + agent_protocol_version: None, + policy: None, + }; + let base = webcodex_core::coding_agent::CodingAgentRunSnapshot { + run_id: "wc_agent_run_registration_semantic".to_string(), + intent_fingerprint: "fingerprint".to_string(), + authority_fingerprint: "auth_test".to_string(), + runtime_project_id: "agent:test:demo".to_string(), + provider_id: "codex".to_string(), + provider_instance_id: "provider_test".to_string(), + state: webcodex_core::coding_agent::CodingAgentRunState::Running, + execution_state: webcodex_core::coding_agent::CodingAgentExecutionState::Started, + observation_revision: 1, + created_at: 1, + updated_at: 1, + terminal: None, + }; + registry.register(register(base.clone())).await.unwrap(); + + let mut completed_with_refusal = base.clone(); + completed_with_refusal.run_id = "wc_agent_run_registration_bad1".to_string(); + completed_with_refusal.state = webcodex_core::coding_agent::CodingAgentRunState::Completed; + completed_with_refusal.execution_state = + webcodex_core::coding_agent::CodingAgentExecutionState::Completed; + completed_with_refusal.terminal = Some(webcodex_core::coding_agent::CodingAgentTerminal { + stop_reason: Some("refusal".to_string()), + error_code: Some("refusal".to_string()), + message: None, + completed_at: 1, + }); + let error = registry + .register(register(completed_with_refusal)) + .await + .unwrap_err(); + assert!( + error.contains("invalid coding-agent Run snapshot"), + "{error}" + ); + + let mut unknown_stop = base; + unknown_stop.run_id = "wc_agent_run_registration_bad2".to_string(); + unknown_stop.state = webcodex_core::coding_agent::CodingAgentRunState::Failed; + unknown_stop.execution_state = + webcodex_core::coding_agent::CodingAgentExecutionState::Completed; + unknown_stop.terminal = Some(webcodex_core::coding_agent::CodingAgentTerminal { + stop_reason: Some("future_stop_reason".to_string()), + error_code: Some("future_stop_reason".to_string()), + message: None, + completed_at: 1, + }); + let error = registry.register(register(unknown_stop)).await.unwrap_err(); + assert!( + error.contains("invalid coding-agent Run snapshot"), + "{error}" + ); +} + #[tokio::test] async fn client_supports_recognizes_all_protocol_capability_names() { let registry = ShellClientRegistry::default(); @@ -259,6 +420,14 @@ async fn client_supports_recognizes_all_protocol_capability_names() { active_complete: true, jobs: Vec::new(), }), + coding_agent_providers: Some(vec![webcodex_core::coding_agent::CodingAgentProvider { + provider_id: "codex".to_string(), + provider_instance_id: "provider_all".to_string(), + name: "Codex".to_string(), + }]), + coding_agent_inventory: Some( + webcodex_core::coding_agent::CodingAgentRunInventory::default(), + ), client_id: "all".to_string(), agent_instance_id: "inst".to_string(), display_name: None, @@ -309,6 +478,7 @@ async fn client_supports_recognizes_all_protocol_capability_names() { computer_window_activate: true, computer_text_input: true, job_state_reconciliation: true, + coding_agent_runs: true, }), projects: None, agent_protocol_version: None, diff --git a/src/shell_client/mod_tests/queue_admission.rs b/src/shell_client/mod_tests/queue_admission.rs index 4d1c27f5..1a13557c 100644 --- a/src/shell_client/mod_tests/queue_admission.rs +++ b/src/shell_client/mod_tests/queue_admission.rs @@ -9,6 +9,8 @@ async fn registry_rejects_enqueue_when_queue_full() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "full".to_string(), agent_instance_id: "inst".to_string(), display_name: None, @@ -73,6 +75,8 @@ async fn registry_rejects_enqueue_when_client_offline() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "stale".to_string(), agent_instance_id: "inst".to_string(), display_name: None, diff --git a/src/shell_client/mod_tests/quic_queueing.rs b/src/shell_client/mod_tests/quic_queueing.rs index 2970dc39..c58b8c43 100644 --- a/src/shell_client/mod_tests/quic_queueing.rs +++ b/src/shell_client/mod_tests/quic_queueing.rs @@ -139,6 +139,8 @@ async fn registry_allows_quic_v1_stop_job_delivery_queueing() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "quic-stop".to_string(), agent_instance_id: "inst".to_string(), display_name: None, diff --git a/src/shell_client/mod_tests/registration_projection.rs b/src/shell_client/mod_tests/registration_projection.rs index 511ea55c..323986ff 100644 --- a/src/shell_client/mod_tests/registration_projection.rs +++ b/src/shell_client/mod_tests/registration_projection.rs @@ -49,6 +49,8 @@ async fn registry_registers_and_lists_client() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "xrh".to_string(), agent_instance_id: "inst".to_string(), display_name: Some("XRH".to_string()), diff --git a/src/shell_client/mod_tests/run_enqueue.rs b/src/shell_client/mod_tests/run_enqueue.rs index e413f3f5..392839bd 100644 --- a/src/shell_client/mod_tests/run_enqueue.rs +++ b/src/shell_client/mod_tests/run_enqueue.rs @@ -9,6 +9,8 @@ async fn registry_allows_session_scoped_run_without_ssh_resource() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "xrh".to_string(), agent_instance_id: "inst".to_string(), display_name: None, diff --git a/src/shell_client/mod_tests/shared_key_isolation.rs b/src/shell_client/mod_tests/shared_key_isolation.rs index 1d557ad8..419740fb 100644 --- a/src/shell_client/mod_tests/shared_key_isolation.rs +++ b/src/shell_client/mod_tests/shared_key_isolation.rs @@ -42,6 +42,8 @@ async fn registry_filters_lightweight_clients_by_auth_group() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: format!("inst-{}", client_id), display_name: None, @@ -64,6 +66,8 @@ async fn registry_filters_lightweight_clients_by_auth_group() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "managed".to_string(), agent_instance_id: "inst-managed".to_string(), display_name: None, @@ -180,6 +184,8 @@ async fn same_client_id_in_different_project_grants_is_isolated() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "same-project-agent".to_string(), agent_instance_id: "same-instance-id".to_string(), display_name: None, @@ -241,6 +247,8 @@ async fn shared_key_client_id_collision_cannot_cross_group_or_revive_old_connect build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: instance.to_string(), display_name: None, diff --git a/src/shell_client/mod_tests/structured_file_delete.rs b/src/shell_client/mod_tests/structured_file_delete.rs index fdb8637b..492aee90 100644 --- a/src/shell_client/mod_tests/structured_file_delete.rs +++ b/src/shell_client/mod_tests/structured_file_delete.rs @@ -11,6 +11,8 @@ async fn register_structured_delete_client( build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: "inst".to_string(), display_name: None, @@ -129,6 +131,8 @@ async fn enqueue_structured_file_delete_missing_capability_defaults_false() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "structured-delete-missing".to_string(), agent_instance_id: "inst".to_string(), display_name: None, diff --git a/src/shell_client/polling.rs b/src/shell_client/polling.rs index 066dd18a..1b96a5e2 100644 --- a/src/shell_client/polling.rs +++ b/src/shell_client/polling.rs @@ -13,6 +13,10 @@ use crate::shell_protocol::{ ShellAgentPersistentShellResultRequest, ShellAgentPollRequest, ShellAgentResultPayload, ShellAgentResultRequest, ShellAgentShellRequest, ShellCommandExecutionState, ShellRunResponse, }; +use webcodex_core::coding_agent::{ + validate_response_for_request as validate_coding_agent_response, CodingAgentDispatchState, + CodingAgentResponse, +}; impl ShellClientRegistry { /// Polling-transport entry point. Polling registrations do not carry a @@ -189,6 +193,57 @@ impl ShellClientRegistry { inner.persistent_waiters.remove(&request_id); continue; } + let stale_coding_agent_error = + inner.pending_by_id.get(&request_id).and_then(|pending| { + if pending.request.coding_agent.is_none() { + return None; + } + let Some(fence) = inner.coding_agent_fences.get(&request_id) else { + return Some(( + "stale_coding_agent_fence", + "CodingAgentRun exact dispatch fence is missing".to_string(), + )); + }; + let Some(client) = inner.clients.get(&body.client_id) else { + return Some(( + "stale_runner", + "CodingAgentRun target Runner disappeared before dispatch".to_string(), + )); + }; + if client.agent_instance_id != fence.agent_instance_id { + return Some(( + "stale_runner", + "CodingAgentRun target Runner changed before dispatch".to_string(), + )); + } + if !client.coding_agent_providers.iter().any(|provider| { + provider.provider_id == fence.provider_id + && provider.provider_instance_id == fence.provider_instance_id + }) { + return Some(( + "stale_provider", + "CodingAgentRun target ACP provider changed before dispatch" + .to_string(), + )); + } + None + }); + if let Some((code, message)) = stale_coding_agent_error { + inner.pending_by_id.remove(&request_id); + if let Some(waiter) = inner.coding_agent_waiters.remove(&request_id) { + let _ = waiter.send(CodingAgentResponse::error( + CodingAgentDispatchState::NotStarted, + code, + message, + Some("stale_state"), + Some("reobserve"), + )); + } + inner.coding_agent_fences.remove(&request_id); + inner.mcp_gateway_waiters.remove(&request_id); + inner.persistent_waiters.remove(&request_id); + continue; + } let stale_project_error = inner.pending_by_id.get(&request_id).and_then(|pending| { match ( pending.expected_project_id.as_deref(), @@ -283,6 +338,7 @@ impl ShellClientRegistry { payload.result, payload.command_execution_state, payload.mcp_gateway, + payload.coding_agent, None, ) .await @@ -304,6 +360,7 @@ impl ShellClientRegistry { payload.result, payload.command_execution_state, payload.mcp_gateway, + payload.coding_agent, Some(connection_id), ) .await @@ -314,6 +371,7 @@ impl ShellClientRegistry { body: ShellAgentResultRequest, command_execution_state: Option, mcp_gateway: Option, + coding_agent: Option, expected_connection_id: Option<&str>, ) -> Result<(), String> { validate_id(&body.client_id, "client_id")?; @@ -380,6 +438,54 @@ impl ShellClientRegistry { } return Ok(()); } + if pending.request.coding_agent.is_some() { + let response = match coding_agent { + Some(response) + if command_execution_state.is_none() + && mcp_gateway.is_none() + && body.exit_code.is_none() + && body.stdout.is_none() + && body.stderr.is_none() + && body.duration_ms.is_none() + && body.error.is_none() + && validate_coding_agent_response( + pending + .request + .coding_agent + .as_ref() + .expect("checked above"), + &response, + ) + .is_ok() => + { + response + } + _ => CodingAgentResponse::error( + if pending.dispatched { + CodingAgentDispatchState::OutcomeUnknown + } else { + CodingAgentDispatchState::NotStarted + }, + "invalid_runner_response", + if pending.dispatched { + "Runner returned an invalid CodingAgentRun response after dispatch; reconcile the same run_id before any new initiation" + } else { + "Runner returned an invalid CodingAgentRun response before dispatch" + }, + Some("protocol"), + Some("reobserve"), + ), + }; + let waiter = inner.coding_agent_waiters.remove(&body.request_id); + inner.coding_agent_fences.remove(&body.request_id); + if let Some(waiter) = waiter { + let _ = waiter.send(response); + } + return Ok(()); + } + if coding_agent.is_some() { + return Err("unexpected CodingAgentRun result for non-coding request".to_string()); + } if mcp_gateway.is_some() { return Err("unexpected MCP gateway result for non-bridge request".to_string()); } diff --git a/src/shell_client/projects.rs b/src/shell_client/projects.rs index 387cbcad..0f397396 100644 --- a/src/shell_client/projects.rs +++ b/src/shell_client/projects.rs @@ -8,7 +8,7 @@ use crate::shell_protocol::{ ShellAgentProjectSummary, ShellClientCapabilities, SHELL_CLIENT_CAPABILITY_ARTIFACT_EXPORT_CHUNK_READ, SHELL_CLIENT_CAPABILITY_ARTIFACT_EXPORT_STREAMING_METADATA, SHELL_CLIENT_CAPABILITY_ASYNC_JOBS, - SHELL_CLIENT_CAPABILITY_ASYNC_SHELL_JOBS, + SHELL_CLIENT_CAPABILITY_ASYNC_SHELL_JOBS, SHELL_CLIENT_CAPABILITY_CODING_AGENT_RUNS, SHELL_CLIENT_CAPABILITY_COMPUTER_ACCESSIBILITY_OBSERVE, SHELL_CLIENT_CAPABILITY_COMPUTER_APPLICATION_DISCOVERY, SHELL_CLIENT_CAPABILITY_COMPUTER_APPLICATION_LAUNCH, @@ -107,6 +107,7 @@ pub(super) fn capability_enabled(caps: &ShellClientCapabilities, capability: &st SHELL_CLIENT_CAPABILITY_COMPUTER_WINDOW_ACTIVATE => caps.computer_window_activate, SHELL_CLIENT_CAPABILITY_COMPUTER_TEXT_INPUT => caps.computer_text_input, SHELL_CLIENT_CAPABILITY_JOB_STATE_RECONCILIATION => caps.job_state_reconciliation, + SHELL_CLIENT_CAPABILITY_CODING_AGENT_RUNS => caps.coding_agent_runs, _ => false, } } @@ -153,8 +154,8 @@ impl ShellClientRegistry { /// `internal_posix_script`, `structured_execution_jobs`, /// `lsp_read_only_navigation`, `lsp_call_hierarchy`, /// `sandbox_inspect_commands`, `project_lifecycle`, - /// `project_path_registration`, `job_state_reconciliation`, `computer_observe`. Unknown capability - /// names return `false`. + /// `project_path_registration`, `job_state_reconciliation`, `coding_agent_runs`, + /// `computer_observe`. Unknown capability names return `false`. #[cfg(test)] pub(crate) async fn client_supports( &self, diff --git a/src/shell_client/reconciliation_tests.rs b/src/shell_client/reconciliation_tests.rs index fe8fc04c..9947518f 100644 --- a/src/shell_client/reconciliation_tests.rs +++ b/src/shell_client/reconciliation_tests.rs @@ -35,6 +35,7 @@ fn reconciliation_capabilities() -> ShellClientCapabilities { structured_execution_jobs: true, structured_validation_argv: true, job_state_reconciliation: true, + coding_agent_runs: false, ..Default::default() } } @@ -81,6 +82,8 @@ fn register_request(instance: &str, inventory: ShellJobInventory) -> ShellClient build: None, job_concurrency_limit: None, job_inventory: Some(inventory), + coding_agent_providers: None, + coding_agent_inventory: None, } } diff --git a/src/shell_client/requests.rs b/src/shell_client/requests.rs index ff853130..d8685b3e 100644 --- a/src/shell_client/requests.rs +++ b/src/shell_client/requests.rs @@ -4,7 +4,7 @@ use super::jobs::{ request_preview, PendingRequestEnqueueError, }; use super::projects::{capability_enabled, ShellClientLookupError}; -use super::state::{PendingShellRequest, ShellClientRegistryInner}; +use super::state::{CodingAgentDispatchFence, PendingShellRequest, ShellClientRegistryInner}; use super::validation::{ validate_file_request, validate_id, validate_process_request, validate_run_request, validate_script_enqueue_request, @@ -42,6 +42,10 @@ use crate::shell_protocol::{ use std::fmt; use tokio::sync::oneshot; use uuid::Uuid; +use webcodex_core::coding_agent::{ + validate_request as validate_coding_agent_request, CodingAgentDispatchState, + CodingAgentRequest, CodingAgentResponse, +}; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum EnqueueLspError { @@ -249,6 +253,25 @@ pub(super) fn resolve_disconnected_sync_requests_locked( }, )); } + if let Some(waiter) = inner.coding_agent_waiters.remove(&request_id) { + let state = if pending.dispatched { + CodingAgentDispatchState::OutcomeUnknown + } else { + CodingAgentDispatchState::NotStarted + }; + let _ = waiter.send(CodingAgentResponse::error( + state, + "runner_unavailable", + if pending.dispatched { + "Runner transport failed after CodingAgentRun dispatch; reconcile the same run_id before any new initiation" + } else { + "Runner transport failed before CodingAgentRun dispatch; request was not started" + }, + Some("unavailable"), + Some("reobserve"), + )); + } + inner.coding_agent_fences.remove(&request_id); inner.persistent_waiters.remove(&request_id); } } @@ -295,6 +318,7 @@ impl ShellClientRegistry { sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, }; let mut inner = self.inner.lock().await; @@ -365,6 +389,7 @@ impl ShellClientRegistry { sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, }; let mut inner = self.inner.lock().await; @@ -454,6 +479,7 @@ impl ShellClientRegistry { sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, }; let mut inner = self.inner.lock().await; @@ -536,6 +562,7 @@ impl ShellClientRegistry { sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, }; let mut inner = self.inner.lock().await; @@ -620,6 +647,7 @@ impl ShellClientRegistry { sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, }; let mut inner = self.inner.lock().await; @@ -702,6 +730,7 @@ impl ShellClientRegistry { sandbox: sandbox.clone(), job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, }; let mut inner = self.inner.lock().await; @@ -785,6 +814,7 @@ impl ShellClientRegistry { sandbox: sandbox.clone(), job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, }; let mut inner = self.inner.lock().await; @@ -877,6 +907,7 @@ impl ShellClientRegistry { sandbox: sandbox.clone(), job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, }; let mut inner = self.inner.lock().await; @@ -984,6 +1015,7 @@ impl ShellClientRegistry { sandbox: sandbox.clone(), job_context: ssh_context, mcp_gateway: None, + coding_agent: None, persistent_shell: None, }; let mut inner = self.inner.lock().await; @@ -1055,6 +1087,10 @@ impl ShellClientRegistry { || inner .mcp_gateway_waiters .get(&pending.request.request_id) + .is_some_and(tokio::sync::oneshot::Sender::is_closed) + || inner + .coding_agent_waiters + .get(&pending.request.request_id) .is_some_and(tokio::sync::oneshot::Sender::is_closed)) }) .map(|(request_id, _)| request_id.clone()) @@ -1062,6 +1098,8 @@ impl ShellClientRegistry { for request_id in &abandoned { inner.persistent_waiters.remove(request_id); inner.mcp_gateway_waiters.remove(request_id); + inner.coding_agent_waiters.remove(request_id); + inner.coding_agent_fences.remove(request_id); remove_pending_request_locked(&mut inner, request_id); } abandoned.len() @@ -1075,6 +1113,8 @@ impl ShellClientRegistry { let mut inner = self.inner.lock().await; inner.persistent_waiters.remove(request_id); inner.mcp_gateway_waiters.remove(request_id); + inner.coding_agent_waiters.remove(request_id); + inner.coding_agent_fences.remove(request_id); remove_pending_request_locked(&mut inner, request_id).map(|pending| pending.dispatched) } @@ -1123,6 +1163,7 @@ impl ShellClientRegistry { job_context: None, persistent_shell: None, mcp_gateway: Some(operation), + coding_agent: None, }; let mut inner = self.inner.lock().await; let client = inner @@ -1171,6 +1212,108 @@ impl ShellClientRegistry { Ok((request_id, rx)) } + /// Enqueue one closed CodingAgentRun operation for one exact Runner/provider + /// process lease. The caller supplies only WebCodex typed Run semantics; raw + /// ACP method/params never enter this registry. + pub(crate) async fn enqueue_coding_agent( + &self, + client_id: &str, + expected_agent_instance_id: &str, + expected_provider_id: &str, + expected_provider_instance_id: &str, + operation: CodingAgentRequest, + auth: Option<&crate::auth::AuthContext>, + requested_by: String, + ) -> Result<(String, oneshot::Receiver), String> { + validate_coding_agent_request(&operation) + .map_err(|error| format!("invalid CodingAgentRun request: {error}"))?; + if let Some((provider_id, provider_instance_id)) = operation.provider_binding() { + if provider_id != expected_provider_id + || provider_instance_id != expected_provider_instance_id + { + return Err( + "CodingAgentRun provider binding does not match exact dispatch fence" + .to_string(), + ); + } + } + let request_id = next_request_id(); + let (tx, rx) = oneshot::channel(); + let request = ShellAgentShellRequest { + request_id: request_id.clone(), + client_id: client_id.to_string(), + kind: "coding_agent".to_string(), + job_id: None, + cwd: None, + path: None, + content: None, + max_bytes: None, + expected_sha256: None, + expected_prefix: None, + start_line: None, + end_line: None, + create_dirs: false, + command: String::new(), + process: None, + script: None, + stdin: None, + timeout_secs: 120, + requested_by, + created_at: now_ts(), + validation: None, + lsp: None, + sandbox: None, + job_context: None, + persistent_shell: None, + mcp_gateway: None, + coding_agent: Some(operation), + }; + let mut inner = self.inner.lock().await; + let client = inner + .clients + .get(client_id) + .ok_or_else(|| "exact Runner is unavailable".to_string())?; + assert_shell_client_access(auth, client) + .map_err(|_| "exact Runner is unavailable".to_string())?; + if !client.capabilities.coding_agent_runs { + return Err("exact Runner does not support CodingAgentRun".to_string()); + } + if client.agent_instance_id != expected_agent_instance_id { + return Err("stale Runner identity; CodingAgentRun was not dispatched".to_string()); + } + let provider_is_current = client.coding_agent_providers.iter().any(|provider| { + provider.provider_id == expected_provider_id + && provider.provider_instance_id == expected_provider_instance_id + }); + if !provider_is_current { + return Err( + "stale ACP provider identity; CodingAgentRun was not dispatched".to_string(), + ); + } + if now_ts().saturating_sub(client.last_seen) > super::CLIENT_ONLINE_WINDOW_SECS { + return Err("exact Runner is offline; CodingAgentRun was not dispatched".to_string()); + } + enqueue_pending_request_locked( + &mut inner, + client_id, + request_id.clone(), + request, + None, + None, + )?; + inner.coding_agent_fences.insert( + request_id.clone(), + CodingAgentDispatchFence { + agent_instance_id: expected_agent_instance_id.to_string(), + provider_id: expected_provider_id.to_string(), + provider_instance_id: expected_provider_instance_id.to_string(), + }, + ); + inner.coding_agent_waiters.insert(request_id.clone(), tx); + notify_client_locked(&inner, client_id); + Ok((request_id, rx)) + } + /// Enqueue one explicit persistent-shell lifecycle operation. Capability /// absence is a hard failure; there is no fallback to `run_shell`. /// @@ -1245,6 +1388,7 @@ impl ShellClientRegistry { sandbox: None, job_context: job_context.clone(), mcp_gateway: None, + coding_agent: None, persistent_shell: Some(request), }; let mut inner = self.inner.lock().await; @@ -1338,6 +1482,7 @@ impl ShellClientRegistry { sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, }; let mut inner = self.inner.lock().await; @@ -1430,6 +1575,7 @@ impl ShellClientRegistry { sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, }; let mut inner = self.inner.lock().await; @@ -1509,6 +1655,7 @@ impl ShellClientRegistry { sandbox: None, job_context: None, mcp_gateway: None, + coding_agent: None, persistent_shell: None, }; let mut inner = self.inner.lock().await; diff --git a/src/shell_client/state.rs b/src/shell_client/state.rs index 5dd412c9..dea0a8d8 100644 --- a/src/shell_client/state.rs +++ b/src/shell_client/state.rs @@ -11,6 +11,9 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::atomic::AtomicU64; use std::sync::Arc; use tokio::sync::{oneshot, Notify}; +use webcodex_core::coding_agent::{ + CodingAgentProvider, CodingAgentResponse, CodingAgentRunInventory, +}; #[derive(Debug, Clone)] pub(super) struct ProjectInventoryStaging { @@ -88,6 +91,11 @@ pub(super) struct ShellClientRecord { /// Runner-reported effective static Job execution concurrency. This is /// safe operational metadata and remains unknown for older Runners. pub(super) job_concurrency_limit: Option, + /// Sanitized startup-owned ACP providers for this exact Runner process. + pub(super) coding_agent_providers: Vec, + /// Authoritative active/recent-terminal CodingAgentRun inventory from this + /// Runner. Bodies/events are deliberately absent from this durable projection. + pub(super) coding_agent_inventory: CodingAgentRunInventory, /// Same-Server evidence that a hidden structured terminal Job was already /// projected into its initiating tool result and deliberately discarded. /// This stays process-local and is preserved only across registrations by @@ -181,6 +189,13 @@ pub(super) struct PendingShellRequest { pub(super) dispatched: bool, } +#[derive(Debug, Clone)] +pub(super) struct CodingAgentDispatchFence { + pub(super) agent_instance_id: String, + pub(super) provider_id: String, + pub(super) provider_instance_id: String, +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub(crate) enum ShellJobVisibility { #[default] @@ -307,6 +322,10 @@ pub(super) struct ShellClientRegistryInner { /// separate from shell stdout/stderr so bridge calls cannot become a raw /// result tunnel. pub(super) mcp_gateway_waiters: HashMap>, + /// Waiters and exact process/provider dispatch fences for CodingAgentRun + /// operations. They are independent from shell/Job/MCP result channels. + pub(super) coding_agent_waiters: HashMap>, + pub(super) coding_agent_fences: HashMap, pub(super) queues_by_client: HashMap>, pub(super) jobs_by_id: HashMap, pub(super) request_to_job: HashMap, diff --git a/src/tool_runtime/coding_agent.rs b/src/tool_runtime/coding_agent.rs new file mode 100644 index 00000000..b6d85118 --- /dev/null +++ b/src/tool_runtime/coding_agent.rs @@ -0,0 +1,1731 @@ +use super::{RecoveryKind, ToolResult, ToolRuntime}; +use crate::auth::{AuthContext, AuthKind}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::Utc; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, HashMap}; +use std::fs::{self, OpenOptions}; +use std::io::{ErrorKind, Read, Write}; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::Mutex; +use uuid::Uuid; +use webcodex_core::coding_agent::{ + CodingAgentCancelRequest, CodingAgentConfigValue, CodingAgentDispatchState, CodingAgentEvent, + CodingAgentExecutionState, CodingAgentObserveRequest, CodingAgentObserveResult, + CodingAgentRequest, CodingAgentResponse, CodingAgentResponsePayload, CodingAgentRunSnapshot, + CodingAgentRunState, CodingAgentStartRequest, CodingAgentTerminal, + CODING_AGENT_MAX_CONFIG_OPTIONS, CODING_AGENT_MAX_EVENTS_PER_RESPONSE, + CODING_AGENT_MAX_INVENTORY_RUNS, CODING_AGENT_OBSERVE_WAIT_MAX_SECS, + CODING_AGENT_TIMEOUT_MAX_SECS, CODING_AGENT_TIMEOUT_MIN_SECS, +}; + +const IDEMPOTENCY_KEY_MAX_BYTES: usize = 256; +const START_RESPONSE_WAIT_SECS: u64 = 32; +const CONTROL_RESPONSE_WAIT_SECS: u64 = 65; +const DEFAULT_RUN_TIMEOUT_SECS: u64 = 300; +const PUBLIC_TOKEN_PREFIX: &str = "wcar2_"; +const PUBLIC_TOKEN_MAX_BYTES: usize = 192; +const PUBLIC_TOKEN_EPOCH_BYTES: usize = 32; +const PUBLIC_TOKEN_SEQUENCE_BYTES: usize = 8; +const PUBLIC_TOKEN_TAG_BYTES: usize = 16; +const PUBLIC_TOKEN_PAYLOAD_BYTES: usize = + PUBLIC_TOKEN_EPOCH_BYTES + PUBLIC_TOKEN_SEQUENCE_BYTES + PUBLIC_TOKEN_TAG_BYTES; +const SERVER_TERMINAL_RETENTION_SECS: i64 = 15 * 60; +const SERVER_MAX_TERMINAL_RUNS: usize = CODING_AGENT_MAX_INVENTORY_RUNS; +const OBSERVATION_MAC_KEY_DIR: &str = "private"; +const OBSERVATION_MAC_KEY_FILE: &str = "coding-agent-observation-mac-key-v2"; +const OBSERVATION_MAC_KEY_BYTES: usize = 32; + +#[derive(Debug, Clone)] +pub(crate) struct ServerRunBinding { + authority_fingerprint: String, + client_id: String, + agent_instance_id: String, + runtime_project_id: String, + provider_id: String, + provider_instance_id: String, + recording_session_id: Option, + recorded_lifecycle_mask: u8, + snapshot: CodingAgentRunSnapshot, +} + +pub(crate) struct CodingAgentServerState { + epoch: String, + observation_mac_key: [u8; OBSERVATION_MAC_KEY_BYTES], + runs: Mutex>, +} + +fn prune_server_runs_locked(runs: &mut HashMap, now: i64) { + let cutoff = now.saturating_sub(SERVER_TERMINAL_RETENTION_SECS); + runs.retain(|_, binding| { + !binding.snapshot.state.terminal() || binding.snapshot.updated_at >= cutoff + }); + let mut terminals = runs + .iter() + .filter(|(_, binding)| binding.snapshot.state.terminal()) + .map(|(run_id, binding)| (run_id.clone(), binding.snapshot.updated_at)) + .collect::>(); + if terminals.len() <= SERVER_MAX_TERMINAL_RUNS { + return; + } + terminals.sort_by_key(|(_, updated_at)| *updated_at); + let remove_count = terminals.len().saturating_sub(SERVER_MAX_TERMINAL_RUNS); + for (run_id, _) in terminals.into_iter().take(remove_count) { + runs.remove(&run_id); + } +} + +fn run_matches_binding_identity(binding: &ServerRunBinding, run: &CodingAgentRunSnapshot) -> bool { + run.run_id == binding.snapshot.run_id + && run.authority_fingerprint == binding.authority_fingerprint + && run.intent_fingerprint == binding.snapshot.intent_fingerprint + && run.runtime_project_id == binding.runtime_project_id + && run.provider_id == binding.provider_id + && run.provider_instance_id == binding.provider_instance_id +} + +impl Default for CodingAgentServerState { + fn default() -> Self { + Self::with_observation_mac_key(new_observation_mac_key()) + } +} + +impl CodingAgentServerState { + fn with_observation_mac_key(observation_mac_key: [u8; OBSERVATION_MAC_KEY_BYTES]) -> Self { + Self { + epoch: Uuid::new_v4().simple().to_string(), + observation_mac_key, + runs: Mutex::new(HashMap::new()), + } + } + + fn with_persistent_observation_mac_key(state_dir: &Path) -> Result { + Ok(Self::with_observation_mac_key( + load_or_create_observation_mac_key(state_dir)?, + )) + } + + fn observation_token(&self, run_id: &str, sequence: u64) -> String { + observation_token(&self.observation_mac_key, &self.epoch, run_id, sequence) + } + + fn parse_observation_token(&self, run_id: &str, token: &str) -> Result { + parse_observation_token(&self.observation_mac_key, &self.epoch, run_id, token) + } + async fn bind( + &self, + client: &crate::shell_protocol::ShellClientView, + run: CodingAgentRunSnapshot, + recording_session_id: Option, + ) { + let mut runs = self.runs.lock().await; + prune_server_runs_locked(&mut runs, Utc::now().timestamp()); + let existing = runs.get(&run.run_id); + let recording_session_id = existing + .and_then(|binding| binding.recording_session_id.clone()) + .or(recording_session_id); + let recorded_lifecycle_mask = existing + .map(|binding| binding.recorded_lifecycle_mask) + .unwrap_or_default(); + runs.insert( + run.run_id.clone(), + ServerRunBinding { + authority_fingerprint: run.authority_fingerprint.clone(), + client_id: client.client_id.clone(), + agent_instance_id: client.agent_instance_id.clone(), + runtime_project_id: run.runtime_project_id.clone(), + provider_id: run.provider_id.clone(), + provider_instance_id: run.provider_instance_id.clone(), + recording_session_id, + recorded_lifecycle_mask, + snapshot: run, + }, + ); + } + + async fn attach_recorder(&self, run_id: &str, recording_session_id: Option) { + let Some(recording_session_id) = recording_session_id else { + return; + }; + let mut runs = self.runs.lock().await; + prune_server_runs_locked(&mut runs, Utc::now().timestamp()); + if let Some(binding) = runs.get_mut(run_id) { + if binding.recording_session_id.is_none() { + binding.recording_session_id = Some(recording_session_id); + } + } + } + + async fn take_lifecycle_evidence( + &self, + run_id: &str, + ) -> Option<(String, CodingAgentRunSnapshot, &'static str)> { + let mut runs = self.runs.lock().await; + prune_server_runs_locked(&mut runs, Utc::now().timestamp()); + let binding = runs.get_mut(run_id)?; + let session_id = binding.recording_session_id.clone()?; + let (bit, kind) = match binding.snapshot.state { + CodingAgentRunState::Starting | CodingAgentRunState::Running => { + (1, "coding_agent_started") + } + CodingAgentRunState::WaitingPermission => (2, "coding_agent_waiting_permission"), + CodingAgentRunState::Completed + | CodingAgentRunState::Failed + | CodingAgentRunState::Cancelled + | CodingAgentRunState::Lost => (4, "coding_agent_terminal"), + }; + if binding.recorded_lifecycle_mask & bit != 0 { + return None; + } + binding.recorded_lifecycle_mask |= bit; + Some((session_id, binding.snapshot.clone(), kind)) + } + + async fn get(&self, run_id: &str) -> Option { + let mut runs = self.runs.lock().await; + prune_server_runs_locked(&mut runs, Utc::now().timestamp()); + runs.get(run_id).cloned() + } +} + +impl ToolRuntime { + pub(crate) fn with_persistent_coding_agent_observation_state( + mut self, + state_dir: impl AsRef, + ) -> Result { + self.coding_agent_runs = Arc::new( + CodingAgentServerState::with_persistent_observation_mac_key(state_dir.as_ref())?, + ); + Ok(self) + } + + pub(crate) async fn coding_agent_start( + &self, + project: String, + provider_id: String, + idempotency_key: String, + instruction: String, + config: Option>, + timeout_secs: Option, + recording_session_id: Option, + auth: Option<&AuthContext>, + ) -> ToolResult { + if let Err(error) = validate_start_input( + &provider_id, + &idempotency_key, + &instruction, + config.as_ref(), + timeout_secs, + ) { + return coding_agent_error( + "invalid_coding_agent_start", + error, + "not_started", + RecoveryKind::FixInput, + None, + ); + } + let principal = match stable_principal(auth) { + Ok(principal) => principal, + Err(error) => { + return coding_agent_error( + "coding_agent_identity_unavailable", + error, + "not_started", + RecoveryKind::FixInput, + None, + ) + } + }; + let authority_fingerprint = authority_fingerprint(&principal); + let run_id = deterministic_run_id(&principal, &idempotency_key); + let resolved = match self.resolve_project_input_for_auth(&project, auth).await { + Ok(resolved) => resolved, + Err(error) => return error.into_tool_result(), + }; + if !resolved.config.allow_patch { + return coding_agent_project_not_writable_result(&run_id); + } + let client_id = match resolved.config.agent_client_id() { + Ok(value) => value.to_string(), + Err(error) => { + return coding_agent_error( + "invalid_project", + error, + "not_started", + RecoveryKind::FixInput, + Some(&run_id), + ) + } + }; + let client = match self + .shell_clients + .get_client_view_for_auth(&client_id, auth) + .await + { + Some(client) if client.connected => client, + _ => { + return coding_agent_error( + "coding_agent_runner_unavailable", + "exact Project Runner is offline or unauthorized", + "not_started", + RecoveryKind::Wait, + Some(&run_id), + ) + } + }; + if !client.capabilities.coding_agent_runs { + return coding_agent_error( + "coding_agent_unsupported", + "exact Project Runner does not advertise CodingAgentRun", + "not_started", + RecoveryKind::Reobserve, + Some(&run_id), + ); + } + let providers = client.coding_agent_providers.as_deref().unwrap_or(&[]); + let provider = match providers + .iter() + .find(|provider| provider.provider_id == provider_id) + { + Some(provider) => provider, + None => { + return coding_agent_error( + "coding_agent_provider_unavailable", + "logical ACP provider is not advertised by the exact Project Runner", + "not_started", + RecoveryKind::Reobserve, + Some(&run_id), + ) + } + }; + let timeout_secs = timeout_secs.unwrap_or(DEFAULT_RUN_TIMEOUT_SECS); + let config = config.unwrap_or_default(); + let intent_fingerprint = intent_fingerprint( + &resolved.resolved_id, + &provider_id, + &instruction, + &config, + timeout_secs, + ); + + if let Some(existing) = self + .reconcile_run(&run_id, &authority_fingerprint, auth) + .await + { + if existing.snapshot.intent_fingerprint != intent_fingerprint { + return coding_agent_error( + "idempotency_conflict", + "idempotency_key is already bound to a different CodingAgentRun intent", + "not_started", + RecoveryKind::FixInput, + Some(&run_id), + ); + } + if existing.runtime_project_id != resolved.resolved_id { + return coding_agent_error( + "idempotency_conflict", + "idempotency_key is already bound to another Project", + "not_started", + RecoveryKind::FixInput, + Some(&run_id), + ); + } + self.coding_agent_runs + .attach_recorder(&run_id, recording_session_id.clone()) + .await; + self.record_coding_agent_lifecycle_if_needed(&run_id).await; + return ToolResult::ok(start_projection( + &existing.snapshot, + self.coding_agent_runs.observation_token(&run_id, 0), + )); + } + + let operation = CodingAgentRequest::Start(CodingAgentStartRequest { + run_id: run_id.clone(), + intent_fingerprint: intent_fingerprint.clone(), + authority_fingerprint: authority_fingerprint.clone(), + runtime_project_id: resolved.resolved_id.clone(), + project_root: resolved.config.path.clone(), + provider_id: provider_id.clone(), + provider_instance_id: provider.provider_instance_id.clone(), + instruction, + config, + timeout_secs, + }); + let (request_id, receiver) = match self + .shell_clients + .enqueue_coding_agent( + &client.client_id, + &client.agent_instance_id, + &provider_id, + &provider.provider_instance_id, + operation, + auth, + authority_fingerprint.clone(), + ) + .await + { + Ok(value) => value, + Err(error) => { + return coding_agent_error( + "coding_agent_dispatch_rejected", + error, + "not_started", + RecoveryKind::Reobserve, + Some(&run_id), + ) + } + }; + let response = + match tokio::time::timeout(Duration::from_secs(START_RESPONSE_WAIT_SECS), receiver) + .await + { + Ok(Ok(response)) => response, + Ok(Err(_)) | Err(_) => { + return self + .start_waiter_lost( + &request_id, + &run_id, + &authority_fingerprint, + recording_session_id.clone(), + auth, + ) + .await; + } + }; + match response.payload { + Some(CodingAgentResponsePayload::Start { run }) => { + if run.authority_fingerprint != authority_fingerprint + || run.intent_fingerprint != intent_fingerprint + || run.runtime_project_id != resolved.resolved_id + || run.provider_id != provider_id + || run.provider_instance_id != provider.provider_instance_id + { + return coding_agent_error( + "invalid_runner_response", + "Runner returned mismatched CodingAgentRun identity", + "outcome_unknown", + RecoveryKind::Reconcile, + Some(&run_id), + ); + } + self.coding_agent_runs + .bind(&client, run.clone(), recording_session_id) + .await; + self.record_coding_agent_lifecycle_if_needed(&run_id).await; + ToolResult::ok(start_projection( + &run, + self.coding_agent_runs.observation_token(&run_id, 0), + )) + } + _ => response_to_tool_error(response, Some(&run_id)), + } + } + + pub(crate) async fn coding_agent_observe( + &self, + run_id: String, + after_observation_token: Option, + wait_secs: Option, + auth: Option<&AuthContext>, + ) -> ToolResult { + let wait_secs = wait_secs.unwrap_or(0); + if wait_secs > CODING_AGENT_OBSERVE_WAIT_MAX_SECS { + return coding_agent_error( + "invalid_wait_secs", + "wait_secs exceeds CodingAgentRun bounded wait", + "not_started", + RecoveryKind::FixInput, + Some(&run_id), + ); + } + let authority = match stable_principal(auth) { + Ok(principal) => authority_fingerprint(&principal), + Err(error) => { + return coding_agent_error( + "coding_agent_identity_unavailable", + error, + "not_started", + RecoveryKind::FixInput, + Some(&run_id), + ) + } + }; + let Some(binding) = self.reconcile_run(&run_id, &authority, auth).await else { + return coding_agent_error( + "unknown_coding_agent_run", + "CodingAgentRun is not visible to this caller", + "not_started", + RecoveryKind::Reobserve, + Some(&run_id), + ); + }; + if binding.authority_fingerprint != authority { + return coding_agent_error( + "unknown_coding_agent_run", + "CodingAgentRun is not visible to this caller", + "not_started", + RecoveryKind::Reobserve, + Some(&run_id), + ); + } + self.record_coding_agent_lifecycle_if_needed(&run_id).await; + let (after_sequence, token_reset) = match after_observation_token.as_deref() { + None => (None, false), + Some(token) => { + match self + .coding_agent_runs + .parse_observation_token(&run_id, token) + { + Ok(sequence) => (Some(sequence), false), + Err(TokenError::StaleEpoch) => (None, true), + Err(TokenError::Invalid) => { + return coding_agent_error( + "invalid_observation_token", + "observation token is invalid or belongs to another Run", + "not_started", + RecoveryKind::FixInput, + Some(&run_id), + ) + } + } + } + }; + if binding.snapshot.state.terminal() + && binding.agent_instance_id + != self + .current_agent_instance(&binding.client_id, auth) + .await + .unwrap_or_default() + { + return ToolResult::ok(observe_projection( + CodingAgentObserveResult { + run: binding.snapshot.clone(), + events: Vec::new(), + first_retained_sequence: 1, + next_sequence: after_sequence.unwrap_or(0), + has_more: false, + history_lost: true, + }, + self.coding_agent_runs.as_ref(), + token_reset, + )); + } + let operation = CodingAgentRequest::Observe(CodingAgentObserveRequest { + run_id: run_id.clone(), + after_sequence, + limit: CODING_AGENT_MAX_EVENTS_PER_RESPONSE, + wait_secs, + }); + let (request_id, receiver) = match self + .shell_clients + .enqueue_coding_agent( + &binding.client_id, + &binding.agent_instance_id, + &binding.provider_id, + &binding.provider_instance_id, + operation, + auth, + authority.clone(), + ) + .await + { + Ok(value) => value, + Err(error) => { + if binding.snapshot.state.terminal() { + return ToolResult::ok(observe_projection( + CodingAgentObserveResult { + run: binding.snapshot.clone(), + events: Vec::new(), + first_retained_sequence: 1, + next_sequence: after_sequence.unwrap_or(0), + has_more: false, + history_lost: true, + }, + self.coding_agent_runs.as_ref(), + true, + )); + } + return coding_agent_error( + "coding_agent_runner_unavailable", + error, + "outcome_unknown", + RecoveryKind::Reobserve, + Some(&run_id), + ); + } + }; + let response = + match tokio::time::timeout(Duration::from_secs(CONTROL_RESPONSE_WAIT_SECS), receiver) + .await + { + Ok(Ok(response)) => response, + _ => { + let _ = self + .shell_clients + .cancel_request_dispatch_state(&request_id) + .await; + return coding_agent_error( + "coding_agent_observe_timeout", + "timed out waiting for bounded CodingAgentRun observation", + "outcome_unknown", + RecoveryKind::Reobserve, + Some(&run_id), + ); + } + }; + match response.payload { + Some(CodingAgentResponsePayload::Observe { mut observation }) => { + if !run_matches_binding_identity(&binding, &observation.run) { + return coding_agent_error( + "invalid_runner_response", + "Runner returned mismatched CodingAgentRun identity", + "outcome_unknown", + RecoveryKind::Reconcile, + Some(&run_id), + ); + } + if token_reset { + observation.history_lost = true; + } + let client = match self + .shell_clients + .get_client_view_for_auth(&binding.client_id, auth) + .await + { + Some(client) if client.agent_instance_id == binding.agent_instance_id => client, + Some(_) => { + return coding_agent_error( + "invalid_runner_response", + "owning Runner instance changed while observation was in flight", + "outcome_unknown", + RecoveryKind::Reconcile, + Some(&run_id), + ) + } + None => { + return coding_agent_error( + "coding_agent_runner_unavailable", + "exact Runner became unavailable", + "outcome_unknown", + RecoveryKind::Reobserve, + Some(&run_id), + ) + } + }; + self.coding_agent_runs + .bind(&client, observation.run.clone(), None) + .await; + self.record_coding_agent_lifecycle_if_needed(&run_id).await; + ToolResult::ok(observe_projection( + observation, + self.coding_agent_runs.as_ref(), + token_reset, + )) + } + _ => response_to_tool_error(response, Some(&run_id)), + } + } + + pub(crate) async fn coding_agent_cancel( + &self, + run_id: String, + auth: Option<&AuthContext>, + ) -> ToolResult { + let authority = match stable_principal(auth) { + Ok(principal) => authority_fingerprint(&principal), + Err(error) => { + return coding_agent_error( + "coding_agent_identity_unavailable", + error, + "not_started", + RecoveryKind::FixInput, + Some(&run_id), + ) + } + }; + let Some(binding) = self.reconcile_run(&run_id, &authority, auth).await else { + return coding_agent_error( + "unknown_coding_agent_run", + "CodingAgentRun is not visible to this caller", + "not_started", + RecoveryKind::Reobserve, + Some(&run_id), + ); + }; + self.record_coding_agent_lifecycle_if_needed(&run_id).await; + if binding.snapshot.state.terminal() { + return ToolResult::ok(cancel_projection(&binding.snapshot)); + } + let operation = CodingAgentRequest::Cancel(CodingAgentCancelRequest { + run_id: run_id.clone(), + }); + let (request_id, receiver) = match self + .shell_clients + .enqueue_coding_agent( + &binding.client_id, + &binding.agent_instance_id, + &binding.provider_id, + &binding.provider_instance_id, + operation, + auth, + authority.clone(), + ) + .await + { + Ok(value) => value, + Err(error) => { + return coding_agent_error( + "coding_agent_cancel_unavailable", + error, + "outcome_unknown", + RecoveryKind::Reobserve, + Some(&run_id), + ) + } + }; + let response = + match tokio::time::timeout(Duration::from_secs(START_RESPONSE_WAIT_SECS), receiver) + .await + { + Ok(Ok(response)) => response, + _ => { + let _ = self + .shell_clients + .cancel_request_dispatch_state(&request_id) + .await; + return coding_agent_error( + "coding_agent_cancel_timeout", + "cancel outcome is not yet authoritative; observe the same Run", + "outcome_unknown", + RecoveryKind::Reobserve, + Some(&run_id), + ); + } + }; + match response.payload { + Some(CodingAgentResponsePayload::Cancel { run }) => { + if !run_matches_binding_identity(&binding, &run) { + return coding_agent_error( + "invalid_runner_response", + "Runner returned mismatched CodingAgentRun identity", + "outcome_unknown", + RecoveryKind::Reconcile, + Some(&run_id), + ); + } + if let Some(client) = self + .shell_clients + .get_client_view_for_auth(&binding.client_id, auth) + .await + { + if client.agent_instance_id != binding.agent_instance_id { + return coding_agent_error( + "invalid_runner_response", + "owning Runner instance changed while cancellation was in flight", + "outcome_unknown", + RecoveryKind::Reconcile, + Some(&run_id), + ); + } + self.coding_agent_runs + .bind(&client, run.clone(), None) + .await; + } + self.record_coding_agent_lifecycle_if_needed(&run_id).await; + ToolResult::ok(cancel_projection(&run)) + } + _ => response_to_tool_error(response, Some(&run_id)), + } + } + + async fn start_waiter_lost( + &self, + request_id: &str, + run_id: &str, + authority: &str, + recording_session_id: Option, + auth: Option<&AuthContext>, + ) -> ToolResult { + let dispatched = self + .shell_clients + .cancel_request_dispatch_state(request_id) + .await; + if let Some(binding) = self.reconcile_run(run_id, authority, auth).await { + self.coding_agent_runs + .attach_recorder(run_id, recording_session_id) + .await; + self.record_coding_agent_lifecycle_if_needed(run_id).await; + return ToolResult::ok(start_projection( + &binding.snapshot, + self.coding_agent_runs.observation_token(run_id, 0), + )); + } + match dispatched { + Some(false) => coding_agent_error("coding_agent_start_timeout", "Run admission timed out before Runner dispatch", "not_started", RecoveryKind::RetrySame, Some(run_id)), + Some(true) | None => coding_agent_error("coding_agent_start_outcome_unknown", "Run dispatch may have reached the Runner; do not use a new idempotency key, reobserve/retry the same initiation", "outcome_unknown", RecoveryKind::Reconcile, Some(run_id)), + } + } + + async fn record_coding_agent_lifecycle_if_needed(&self, run_id: &str) { + let Some((session_id, snapshot, kind)) = + self.coding_agent_runs.take_lifecycle_evidence(run_id).await + else { + return; + }; + self.sessions.record_coding_agent_lifecycle_evidence( + &session_id, + &snapshot.runtime_project_id, + &snapshot.run_id, + &snapshot.provider_id, + kind, + state_name(&snapshot.state), + execution_name(snapshot.execution_state), + snapshot + .terminal + .as_ref() + .and_then(|terminal| terminal.stop_reason.as_deref()), + snapshot + .terminal + .as_ref() + .and_then(|terminal| terminal.error_code.as_deref()), + ); + } + + async fn reconcile_run( + &self, + run_id: &str, + authority: &str, + auth: Option<&AuthContext>, + ) -> Option { + let existing = self.coding_agent_runs.get(run_id).await; + if let Some(mut binding) = existing { + if binding.authority_fingerprint != authority { + return None; + } + // Once the Server has a binding, only the exact bound client may + // refresh it. Another visible Runner advertising the same run_id is + // not evidence about this Run and must not force a false retarget/lost. + if let Some((client, run)) = self + .shell_clients + .coding_agent_run_for_client_for_auth(auth, &binding.client_id, run_id) + .await + { + if run.authority_fingerprint != authority { + return None; + } + let identity_changed = !run_matches_binding_identity(&binding, &run); + let runner_replaced_while_active = + client.agent_instance_id != binding.agent_instance_id && !run.state.terminal(); + if identity_changed || runner_replaced_while_active { + mark_server_binding_lost( + &mut binding, + if identity_changed { + "coding_agent_identity_changed_uncertain" + } else { + "runner_replaced_uncertain" + }, + ); + self.coding_agent_runs + .runs + .lock() + .await + .insert(run_id.to_string(), binding.clone()); + return Some(binding); + } + self.coding_agent_runs.bind(&client, run, None).await; + return self.coding_agent_runs.get(run_id).await; + } + + if binding.snapshot.state.terminal() { + return Some(binding); + } + + // A temporary disconnect of the same Runner is not proof of loss: keep + // the active projection so callers get wait/reobserve semantics. A live + // replacement instance, however, is a positive fence crossing. If that + // replacement does not advertise the durable Run, the old prompt may have + // executed and P1 must close it `lost` rather than retrying blindly. + if let Some(current) = self + .shell_clients + .get_client_view_for_auth(&binding.client_id, auth) + .await + { + let instance_replaced = current.agent_instance_id != binding.agent_instance_id; + let provider_replaced = !instance_replaced + && current + .coding_agent_providers + .as_deref() + .unwrap_or_default() + .iter() + .all(|provider| { + provider.provider_instance_id != binding.provider_instance_id + }); + if instance_replaced || provider_replaced { + let code = if instance_replaced { + "runner_replaced_uncertain" + } else { + "provider_replaced_uncertain" + }; + mark_server_binding_lost(&mut binding, code); + self.coding_agent_runs + .runs + .lock() + .await + .insert(run_id.to_string(), binding.clone()); + } + } + return Some(binding); + } + + // After a Server restart there is no process-local binding. Recover only + // from a unique visible Runner inventory match; the registry fails closed + // on duplicate run ids instead of choosing by iteration order. + let (client, run) = self + .shell_clients + .coding_agent_run_for_auth(auth, run_id) + .await?; + if run.authority_fingerprint != authority { + return None; + } + self.coding_agent_runs.bind(&client, run, None).await; + self.coding_agent_runs.get(run_id).await + } + + async fn current_agent_instance( + &self, + client_id: &str, + auth: Option<&AuthContext>, + ) -> Option { + self.shell_clients + .get_client_view_for_auth(client_id, auth) + .await + .map(|client| client.agent_instance_id) + } +} + +fn mark_server_binding_lost(binding: &mut ServerRunBinding, code: &str) { + if binding.snapshot.state.terminal() { + return; + } + let completed_at = chrono::Utc::now().timestamp(); + binding.snapshot.state = CodingAgentRunState::Lost; + binding.snapshot.execution_state = CodingAgentExecutionState::OutcomeUnknown; + binding.snapshot.updated_at = completed_at; + binding.snapshot.observation_revision = binding.snapshot.observation_revision.saturating_add(1); + binding.snapshot.terminal = Some(CodingAgentTerminal { + stop_reason: None, + error_code: Some(code.to_string()), + message: Some( + "owning Runner/provider instance was replaced while prompt outcome was uncertain; do not redispatch" + .to_string(), + ), + completed_at, + }); +} + +fn validate_start_input( + provider_id: &str, + idempotency_key: &str, + instruction: &str, + config: Option<&BTreeMap>, + timeout_secs: Option, +) -> Result<(), String> { + webcodex_core::coding_agent::validate_provider_id(provider_id)?; + if idempotency_key.is_empty() + || idempotency_key.len() > IDEMPOTENCY_KEY_MAX_BYTES + || idempotency_key.contains(['\0', '\r', '\n']) + { + return Err(format!( + "idempotency_key must contain 1..={IDEMPOTENCY_KEY_MAX_BYTES} bytes and no NUL/CR/LF" + )); + } + if instruction.is_empty() + || instruction.len() > webcodex_core::coding_agent::CODING_AGENT_MAX_INSTRUCTION_BYTES + || instruction.contains('\0') + { + return Err("instruction is empty, too large, or contains NUL".to_string()); + } + if config.is_some_and(|config| config.len() > CODING_AGENT_MAX_CONFIG_OPTIONS) { + return Err("too many CodingAgentRun config overrides".to_string()); + } + if let Some(timeout) = timeout_secs { + if !(CODING_AGENT_TIMEOUT_MIN_SECS..=CODING_AGENT_TIMEOUT_MAX_SECS).contains(&timeout) { + return Err("timeout_secs is outside the supported range".to_string()); + } + } + Ok(()) +} + +fn stable_principal(auth: Option<&AuthContext>) -> Result { + let Some(auth) = auth else { + return Ok("local-dev:local-dev".to_string()); + }; + if auth.kind == AuthKind::Bootstrap || auth.is_bootstrap { + return Ok("bootstrap:server-bootstrap".to_string()); + } + if auth.is_oauth_shared_key_subject() || auth.is_shared_key() { + let shared_key_hash = auth + .shared_key_hash + .as_deref() + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + "shared-key authority has no stable CodingAgentRun group identity".to_string() + })?; + return Ok(format!("shared-key-group:{shared_key_hash}")); + } + if auth.is_oauth_project_subject() || auth.is_project_credential() || auth.is_agent_token() { + let project_grant_id = auth + .project_grant_id + .as_deref() + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + "project-grant authority has no stable CodingAgentRun grant identity".to_string() + })?; + return Ok(format!("project-grant:{project_grant_id}")); + } + if auth.is_open_anonymous() { + return Ok("open-anonymous:open-anonymous".to_string()); + } + if matches!( + auth.kind, + AuthKind::ApiToken | AuthKind::AccountCredential | AuthKind::OAuth2Token + ) { + let user_id = auth + .user_id + .as_deref() + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + "managed authority has no stable CodingAgentRun user identity".to_string() + })?; + return Ok(format!("managed-user:{user_id}")); + } + Err("authenticated credential has no canonical CodingAgentRun authority identity".to_string()) +} + +fn authority_fingerprint(principal: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"webcodex-coding-agent-authority-v1\0"); + hasher.update(principal.as_bytes()); + format!("auth_{:x}", hasher.finalize()) +} + +fn deterministic_run_id(principal: &str, key: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"webcodex-coding-agent-run-v1\0"); + hasher.update(principal.as_bytes()); + hasher.update(b"\0"); + hasher.update(key.as_bytes()); + format!("wc_agent_run_{:x}", hasher.finalize()) +} + +fn intent_fingerprint( + project: &str, + provider: &str, + instruction: &str, + config: &BTreeMap, + timeout_secs: u64, +) -> String { + let canonical = serde_json::to_vec(&json!({ + "project": project, + "provider": provider, + "instruction": instruction, + "config": config, + "timeout_secs": timeout_secs, + })) + .unwrap_or_default(); + let mut hasher = Sha256::new(); + hasher.update(b"webcodex-coding-agent-intent-v1\0"); + hasher.update(&canonical); + format!("{:x}", hasher.finalize()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TokenError { + Invalid, + StaleEpoch, +} + +fn new_observation_mac_key() -> [u8; OBSERVATION_MAC_KEY_BYTES] { + let mut hasher = Sha256::new(); + hasher.update(b"webcodex.coding-agent.observation.mac-key.v2\0"); + hasher.update(Uuid::new_v4().as_bytes()); + hasher.update(Uuid::new_v4().as_bytes()); + hasher.finalize().into() +} + +fn read_observation_mac_key(path: &Path) -> Result<[u8; OBSERVATION_MAC_KEY_BYTES], String> { + let mut file = OpenOptions::new() + .read(true) + .open(path) + .map_err(|error| format!("cannot read CodingAgent observation MAC key: {error}"))?; + let metadata = file + .metadata() + .map_err(|error| format!("cannot inspect CodingAgent observation MAC key: {error}"))?; + if !metadata.is_file() { + return Err("CodingAgent observation MAC key is not a regular file".to_string()); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o077 != 0 { + return Err("CodingAgent observation MAC key permissions are not private".to_string()); + } + } + let mut bytes = Vec::with_capacity(OBSERVATION_MAC_KEY_BYTES); + file.read_to_end(&mut bytes) + .map_err(|error| format!("cannot read CodingAgent observation MAC key: {error}"))?; + bytes + .try_into() + .map_err(|_| "CodingAgent observation MAC key has an invalid persisted length".to_string()) +} + +fn load_or_create_observation_mac_key( + state_dir: &Path, +) -> Result<[u8; OBSERVATION_MAC_KEY_BYTES], String> { + let private_dir = state_dir.join(OBSERVATION_MAC_KEY_DIR); + fs::create_dir_all(&private_dir) + .map_err(|error| format!("cannot create CodingAgent private state directory: {error}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&private_dir, fs::Permissions::from_mode(0o700)).map_err(|error| { + format!("cannot protect CodingAgent private state directory: {error}") + })?; + } + let path = private_dir.join(OBSERVATION_MAC_KEY_FILE); + match read_observation_mac_key(&path) { + Ok(key) => return Ok(key), + Err(_) if !path.exists() => {} + Err(error) => return Err(error), + } + + let key = new_observation_mac_key(); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + match options.open(&path) { + Ok(mut file) => { + if let Err(error) = file.write_all(&key).and_then(|_| file.sync_all()) { + let _ = fs::remove_file(&path); + return Err(format!( + "cannot persist CodingAgent observation MAC key: {error}" + )); + } + Ok(key) + } + Err(error) if error.kind() == ErrorKind::AlreadyExists => read_observation_mac_key(&path), + Err(error) => Err(format!( + "cannot create CodingAgent observation MAC key: {error}" + )), + } +} + +fn observation_token_hmac( + key: &[u8; OBSERVATION_MAC_KEY_BYTES], + domain: &[u8], + epoch: &str, + run_id: &str, + extra: &[u8], +) -> [u8; 32] { + const BLOCK_BYTES: usize = 64; + let mut ipad = [0x36u8; BLOCK_BYTES]; + let mut opad = [0x5cu8; BLOCK_BYTES]; + for (index, byte) in key.iter().enumerate() { + ipad[index] ^= byte; + opad[index] ^= byte; + } + let mut inner = Sha256::new(); + inner.update(ipad); + inner.update(domain); + inner.update((epoch.len() as u64).to_be_bytes()); + inner.update(epoch.as_bytes()); + inner.update((run_id.len() as u64).to_be_bytes()); + inner.update(run_id.as_bytes()); + inner.update((extra.len() as u64).to_be_bytes()); + inner.update(extra); + let inner_digest = inner.finalize(); + let mut outer = Sha256::new(); + outer.update(opad); + outer.update(inner_digest); + outer.finalize().into() +} + +fn observation_token( + key: &[u8; OBSERVATION_MAC_KEY_BYTES], + epoch: &str, + run_id: &str, + sequence: u64, +) -> String { + debug_assert_eq!(epoch.len(), PUBLIC_TOKEN_EPOCH_BYTES); + let mask = observation_token_hmac( + key, + b"webcodex.coding-agent.observation.sequence-mask.v2\0", + epoch, + run_id, + &[], + ); + let sequence = sequence.to_be_bytes(); + let mut masked_sequence = [0_u8; PUBLIC_TOKEN_SEQUENCE_BYTES]; + for (index, byte) in sequence.iter().enumerate() { + masked_sequence[index] = byte ^ mask[index]; + } + let tag = observation_token_hmac( + key, + b"webcodex.coding-agent.observation.tag.v2\0", + epoch, + run_id, + &masked_sequence, + ); + let mut payload = Vec::with_capacity(PUBLIC_TOKEN_PAYLOAD_BYTES); + payload.extend_from_slice(epoch.as_bytes()); + payload.extend_from_slice(&masked_sequence); + payload.extend_from_slice(&tag[..PUBLIC_TOKEN_TAG_BYTES]); + let token = format!("{PUBLIC_TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode(payload)); + debug_assert!(token.len() <= PUBLIC_TOKEN_MAX_BYTES); + token +} + +fn parse_observation_token( + key: &[u8; OBSERVATION_MAC_KEY_BYTES], + epoch: &str, + run_id: &str, + token: &str, +) -> Result { + if token.len() > PUBLIC_TOKEN_MAX_BYTES { + return Err(TokenError::Invalid); + } + let encoded = token + .strip_prefix(PUBLIC_TOKEN_PREFIX) + .ok_or(TokenError::Invalid)?; + if encoded.is_empty() || !encoded.is_ascii() { + return Err(TokenError::Invalid); + } + let payload = URL_SAFE_NO_PAD + .decode(encoded.as_bytes()) + .map_err(|_| TokenError::Invalid)?; + if payload.len() != PUBLIC_TOKEN_PAYLOAD_BYTES { + return Err(TokenError::Invalid); + } + let token_epoch = std::str::from_utf8(&payload[..PUBLIC_TOKEN_EPOCH_BYTES]) + .map_err(|_| TokenError::Invalid)?; + if token_epoch.len() != PUBLIC_TOKEN_EPOCH_BYTES + || !token_epoch.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err(TokenError::Invalid); + } + let masked_start = PUBLIC_TOKEN_EPOCH_BYTES; + let masked_end = masked_start + PUBLIC_TOKEN_SEQUENCE_BYTES; + let masked_sequence: [u8; PUBLIC_TOKEN_SEQUENCE_BYTES] = payload[masked_start..masked_end] + .try_into() + .map_err(|_| TokenError::Invalid)?; + let expected_tag = observation_token_hmac( + key, + b"webcodex.coding-agent.observation.tag.v2\0", + token_epoch, + run_id, + &masked_sequence, + ); + if !crate::config::constant_time_eq( + &payload[masked_end..], + &expected_tag[..PUBLIC_TOKEN_TAG_BYTES], + ) { + return Err(TokenError::Invalid); + } + if token_epoch != epoch { + return Err(TokenError::StaleEpoch); + } + let mask = observation_token_hmac( + key, + b"webcodex.coding-agent.observation.sequence-mask.v2\0", + token_epoch, + run_id, + &[], + ); + let mut sequence = [0_u8; PUBLIC_TOKEN_SEQUENCE_BYTES]; + for (index, byte) in masked_sequence.iter().enumerate() { + sequence[index] = byte ^ mask[index]; + } + Ok(u64::from_be_bytes(sequence)) +} + +fn start_projection(run: &CodingAgentRunSnapshot, token: String) -> Value { + json!({ + "run_id": run.run_id, + "project": run.runtime_project_id, + "provider_id": run.provider_id, + "state": state_name(&run.state), + "execution_state": execution_name(run.execution_state), + "observation_token": token, + "terminal": terminal_projection(run), + }) +} + +fn cancel_projection(run: &CodingAgentRunSnapshot) -> Value { + json!({ + "run_id": run.run_id, + "project": run.runtime_project_id, + "provider_id": run.provider_id, + "state": state_name(&run.state), + "execution_state": execution_name(run.execution_state), + "cancel_requested": !run.state.terminal(), + "terminal": terminal_projection(run), + }) +} + +fn observe_projection( + observation: CodingAgentObserveResult, + token_state: &CodingAgentServerState, + reset: bool, +) -> Value { + let run = &observation.run; + let token = token_state.observation_token(&run.run_id, observation.next_sequence); + let events = observation + .events + .iter() + .map(event_projection) + .collect::>(); + json!({ + "run_id": run.run_id, + "project": run.runtime_project_id, + "provider_id": run.provider_id, + "state": state_name(&run.state), + "execution_state": execution_name(run.execution_state), + "events": events, + "observation_token": token, + "has_more": observation.has_more, + "history_lost": observation.history_lost || reset, + "first_retained_sequence": observation.first_retained_sequence, + "terminal": terminal_projection(run), + "recovery_kind": run_recovery_kind(run), + }) +} + +fn event_projection(event: &CodingAgentEvent) -> Value { + json!({ + "sequence": event.sequence, + "kind": event.kind.as_str(), + "text": event.text, + "label": event.label, + "status": event.status, + "usage": event.usage, + }) +} + +fn terminal_projection(run: &CodingAgentRunSnapshot) -> Value { + run.terminal + .as_ref() + .map(|terminal| { + json!({ + "stop_reason": terminal.stop_reason, + "error_code": terminal.error_code, + "message": terminal.message, + "completed_at": terminal.completed_at, + }) + }) + .unwrap_or(Value::Null) +} + +fn state_name(state: &CodingAgentRunState) -> &'static str { + match state { + CodingAgentRunState::Starting => "starting", + CodingAgentRunState::Running => "running", + CodingAgentRunState::WaitingPermission => "waiting_permission", + CodingAgentRunState::Completed => "completed", + CodingAgentRunState::Failed => "failed", + CodingAgentRunState::Cancelled => "cancelled", + CodingAgentRunState::Lost => "lost", + } +} + +fn execution_name(state: CodingAgentExecutionState) -> &'static str { + match state { + CodingAgentExecutionState::NotStarted => "not_started", + CodingAgentExecutionState::Started => "started", + CodingAgentExecutionState::OutcomeUnknown => "outcome_unknown", + CodingAgentExecutionState::Completed => "completed", + } +} + +fn run_recovery_kind(run: &CodingAgentRunSnapshot) -> &'static str { + match run.state { + CodingAgentRunState::Starting | CodingAgentRunState::Running => "reobserve", + CodingAgentRunState::WaitingPermission => "wait", + CodingAgentRunState::Lost => "reconcile", + CodingAgentRunState::Completed + | CodingAgentRunState::Failed + | CodingAgentRunState::Cancelled => "none", + } +} + +fn coding_agent_project_not_writable_result(run_id: &str) -> ToolResult { + ToolResult::err_with_output( + "coding_agent_start requires a Project with allow_patch=true", + json!({ + "error_kind": "coding_agent_project_not_writable", + "failure_kind": "policy_rejected", + "run_id": run_id, + "state_changed": false, + "execution_state": "not_started", + }), + ) + .with_recovery(RecoveryKind::UserAction, None) +} + +fn coding_agent_error( + kind: &str, + message: impl Into, + execution_state: &str, + recovery: RecoveryKind, + run_id: Option<&str>, +) -> ToolResult { + ToolResult::err_with_output( + message.into(), + json!({ + "error_kind": kind, + "run_id": run_id, + "execution_state": execution_state, + }), + ) + .with_recovery(recovery, None) +} + +fn response_to_tool_error(response: CodingAgentResponse, run_id: Option<&str>) -> ToolResult { + let dispatch = response.dispatch_state; + let Some(error) = response.error else { + return coding_agent_error( + "invalid_runner_response", + "Runner CodingAgentRun response contained no result", + if dispatch == CodingAgentDispatchState::NotStarted { + "not_started" + } else { + "outcome_unknown" + }, + RecoveryKind::Reobserve, + run_id, + ); + }; + let recovery = match error.recovery_kind.as_deref() { + Some("fix_input") => RecoveryKind::FixInput, + Some("retry_same") => RecoveryKind::RetrySame, + Some("reconcile") => RecoveryKind::Reconcile, + Some("wait") => RecoveryKind::Wait, + Some("user_action") => RecoveryKind::UserAction, + Some("none") => RecoveryKind::NoAction, + _ => RecoveryKind::Reobserve, + }; + coding_agent_error( + &error.code, + error.message, + if dispatch == CodingAgentDispatchState::NotStarted { + "not_started" + } else { + "outcome_unknown" + }, + recovery, + run_id, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_server_binding( + run_id: String, + state: CodingAgentRunState, + updated_at: i64, + ) -> ServerRunBinding { + let terminal = state.terminal().then(|| CodingAgentTerminal { + stop_reason: Some("end_turn".to_string()), + error_code: None, + message: None, + completed_at: updated_at, + }); + ServerRunBinding { + authority_fingerprint: "auth_test".to_string(), + client_id: "client".to_string(), + agent_instance_id: "instance".to_string(), + runtime_project_id: "agent:test:demo".to_string(), + provider_id: "codex".to_string(), + provider_instance_id: "provider".to_string(), + recording_session_id: None, + recorded_lifecycle_mask: 0, + snapshot: CodingAgentRunSnapshot { + run_id, + intent_fingerprint: "fingerprint".to_string(), + authority_fingerprint: "auth_test".to_string(), + runtime_project_id: "agent:test:demo".to_string(), + provider_id: "codex".to_string(), + provider_instance_id: "provider".to_string(), + state, + execution_state: if terminal.is_some() { + CodingAgentExecutionState::Completed + } else { + CodingAgentExecutionState::Started + }, + observation_revision: 0, + created_at: updated_at, + updated_at, + terminal, + }, + } + } + + #[test] + fn server_run_registry_prunes_expired_and_bounds_recent_terminals() { + let now = 10_000; + let mut runs = HashMap::new(); + runs.insert( + "wc_agent_run_active".to_string(), + test_server_binding( + "wc_agent_run_active".to_string(), + CodingAgentRunState::Running, + 1, + ), + ); + runs.insert( + "wc_agent_run_expired".to_string(), + test_server_binding( + "wc_agent_run_expired".to_string(), + CodingAgentRunState::Completed, + now - SERVER_TERMINAL_RETENTION_SECS - 1, + ), + ); + for index in 0..SERVER_MAX_TERMINAL_RUNS + 2 { + let run_id = format!("wc_agent_run_recent_{index:03}"); + runs.insert( + run_id.clone(), + test_server_binding(run_id, CodingAgentRunState::Completed, now - index as i64), + ); + } + prune_server_runs_locked(&mut runs, now); + assert!(runs.contains_key("wc_agent_run_active")); + assert!(!runs.contains_key("wc_agent_run_expired")); + assert_eq!( + runs.values() + .filter(|binding| binding.snapshot.state.terminal()) + .count(), + SERVER_MAX_TERMINAL_RUNS + ); + } + + #[test] + fn non_writable_project_is_a_hard_prestart_denial() { + let result = coding_agent_project_not_writable_result("wc_agent_run_readonly"); + assert!(!result.success); + assert_eq!(result.output["failure_kind"], "policy_rejected"); + assert_eq!(result.output["execution_state"], "not_started"); + assert_eq!(result.output["state_changed"], false); + assert_eq!(result.output["recovery_kind"], "user_action"); + assert!(crate::tool_runtime::permissions::is_hard_denied_output( + &result.output, + result.error.as_deref() + )); + } + + #[test] + fn bound_run_identity_rejects_provider_project_and_intent_retarget() { + let binding = test_server_binding( + "wc_agent_run_identity_fence".to_string(), + CodingAgentRunState::Running, + 1, + ); + assert!(run_matches_binding_identity(&binding, &binding.snapshot)); + + let mut retargeted = binding.snapshot.clone(); + retargeted.provider_id = "other-provider".to_string(); + assert!(!run_matches_binding_identity(&binding, &retargeted)); + + let mut retargeted = binding.snapshot.clone(); + retargeted.provider_instance_id = "other-instance".to_string(); + assert!(!run_matches_binding_identity(&binding, &retargeted)); + + let mut retargeted = binding.snapshot.clone(); + retargeted.runtime_project_id = "agent:test:other".to_string(); + assert!(!run_matches_binding_identity(&binding, &retargeted)); + + let mut retargeted = binding.snapshot.clone(); + retargeted.intent_fingerprint = "other-intent".to_string(); + assert!(!run_matches_binding_identity(&binding, &retargeted)); + } + + #[test] + fn terminal_not_started_run_does_not_advertise_retry_same() { + let mut binding = test_server_binding( + "wc_agent_run_failed_not_started".to_string(), + CodingAgentRunState::Failed, + 1, + ); + binding.snapshot.execution_state = CodingAgentExecutionState::NotStarted; + assert_eq!( + run_recovery_kind(&binding.snapshot), + "none", + "a retained terminal Run cannot be redispatched by replaying the same idempotency key" + ); + } + + #[test] + fn stable_principal_canonicalizes_equivalent_credential_transports() { + let direct_shared = crate::auth::shared_key_context("coding-agent-shared-key"); + let shared_hash = direct_shared.shared_key_hash.clone().unwrap(); + let oauth_shared = AuthContext { + token_kind: Some("oauth2_shared_key".to_string()), + shared_key_hash: Some(shared_hash), + ..AuthContext::new(AuthKind::OAuth2Token) + }; + assert_eq!( + stable_principal(Some(&direct_shared)).unwrap(), + stable_principal(Some(&oauth_shared)).unwrap() + ); + + let pat = AuthContext { + user_id: Some("user-1".to_string()), + api_key_id: Some("pat-1".to_string()), + ..AuthContext::new(AuthKind::ApiToken) + }; + let oauth_user = AuthContext { + user_id: Some("user-1".to_string()), + api_key_id: Some("oauth-access-1".to_string()), + token_kind: Some("oauth2".to_string()), + ..AuthContext::new(AuthKind::OAuth2Token) + }; + assert_eq!( + stable_principal(Some(&pat)).unwrap(), + stable_principal(Some(&oauth_user)).unwrap() + ); + + let project = AuthContext { + project_grant_id: Some("grant-1".to_string()), + ..AuthContext::new(AuthKind::ProjectCredential) + }; + let oauth_project = AuthContext { + token_kind: Some(crate::auth::PROJECT_SHARE_OAUTH_TOKEN_KIND.to_string()), + project_grant_id: Some("grant-1".to_string()), + ..AuthContext::new(AuthKind::OAuth2Token) + }; + assert_eq!( + stable_principal(Some(&project)).unwrap(), + stable_principal(Some(&oauth_project)).unwrap() + ); + } + + #[test] + fn identities_are_domain_separated_and_tokens_are_run_bound_and_tamper_evident() { + let principal = "oauth2:shared-key:abc"; + let run = deterministic_run_id(principal, "same-key"); + let epoch = "11111111111111111111111111111111"; + let stale_epoch = "22222222222222222222222222222222"; + assert!(run.starts_with("wc_agent_run_")); + assert_ne!(authority_fingerprint(principal), run); + + let key = [0x5au8; OBSERVATION_MAC_KEY_BYTES]; + let token = observation_token(&key, epoch, &run, 7); + assert!(token.starts_with(PUBLIC_TOKEN_PREFIX)); + assert!(token.len() <= PUBLIC_TOKEN_MAX_BYTES); + assert!(!token.contains(&run)); + assert_eq!(parse_observation_token(&key, epoch, &run, &token), Ok(7)); + let continuation = observation_token(&key, epoch, &run, 9); + assert_eq!( + parse_observation_token(&key, epoch, &run, &continuation), + Ok(9) + ); + assert_eq!( + parse_observation_token(&key, stale_epoch, &run, &token), + Err(TokenError::StaleEpoch) + ); + assert_eq!( + parse_observation_token(&key, epoch, "wc_agent_run_other", &token), + Err(TokenError::Invalid) + ); + + let encoded = token.strip_prefix(PUBLIC_TOKEN_PREFIX).unwrap(); + let mut payload = URL_SAFE_NO_PAD.decode(encoded).unwrap(); + payload[PUBLIC_TOKEN_EPOCH_BYTES] ^= 1; + let forged_sequence = format!("{PUBLIC_TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode(&payload)); + assert_eq!( + parse_observation_token(&key, epoch, &run, &forged_sequence), + Err(TokenError::Invalid) + ); + + let mut payload = URL_SAFE_NO_PAD.decode(encoded).unwrap(); + payload[0] = b'2'; + let forged_epoch = format!("{PUBLIC_TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode(&payload)); + assert_eq!( + parse_observation_token(&key, epoch, &run, &forged_epoch), + Err(TokenError::Invalid) + ); + + let mut payload = URL_SAFE_NO_PAD.decode(encoded).unwrap(); + *payload.last_mut().unwrap() ^= 1; + let tampered = format!("{PUBLIC_TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode(&payload)); + assert_eq!( + parse_observation_token(&key, epoch, &run, &tampered), + Err(TokenError::Invalid) + ); + } + + #[test] + fn persistent_observation_mac_key_preserves_stale_epoch_across_server_restart() { + let state_dir = tempfile::tempdir().unwrap(); + let run = "wc_agent_run_restart"; + let first = + CodingAgentServerState::with_persistent_observation_mac_key(state_dir.path()).unwrap(); + let first_epoch = first.epoch.clone(); + let token = first.observation_token(run, 7); + drop(first); + + let second = + CodingAgentServerState::with_persistent_observation_mac_key(state_dir.path()).unwrap(); + assert_ne!(second.epoch, first_epoch); + assert_eq!( + second.parse_observation_token(run, &token), + Err(TokenError::StaleEpoch) + ); + + let encoded = token.strip_prefix(PUBLIC_TOKEN_PREFIX).unwrap(); + let mut payload = URL_SAFE_NO_PAD.decode(encoded).unwrap(); + *payload.last_mut().unwrap() ^= 1; + let tampered = format!("{PUBLIC_TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode(payload)); + assert_eq!( + second.parse_observation_token(run, &tampered), + Err(TokenError::Invalid) + ); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let private_dir = state_dir.path().join(OBSERVATION_MAC_KEY_DIR); + let key_path = private_dir.join(OBSERVATION_MAC_KEY_FILE); + assert_eq!( + fs::metadata(private_dir).unwrap().permissions().mode() & 0o777, + 0o700 + ); + assert_eq!( + fs::metadata(key_path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } + + #[test] + fn intent_fingerprint_is_stable_over_sorted_config_and_changes_with_execution_intent() { + let config = BTreeMap::from([( + "mode".to_string(), + CodingAgentConfigValue::String("agent".to_string()), + )]); + let a = intent_fingerprint("agent:x:p", "codex", "inspect", &config, 30); + let b = intent_fingerprint("agent:x:p", "codex", "inspect", &config, 30); + let c = intent_fingerprint("agent:x:p", "codex", "different", &config, 30); + assert_eq!(a, b); + assert_ne!(a, c); + } +} diff --git a/src/tool_runtime/dispatch.rs b/src/tool_runtime/dispatch.rs index ae66b1c4..a17bd87f 100644 --- a/src/tool_runtime/dispatch.rs +++ b/src/tool_runtime/dispatch.rs @@ -660,6 +660,8 @@ impl ToolRuntime { inherited_sandbox: Option<&'static str>, window: Option<&crate::client_window::ClientWindow>, ) -> ToolResult { + call = call + .with_coding_agent_recording_session_id(recorder_metadata.recording_session_id.clone()); let project_resolution = match call.project() { Some(project) => Some(self.resolve_project_input_for_auth(project, auth).await), None => None, @@ -1150,6 +1152,37 @@ impl ToolRuntime { | ToolCall::UnregisterProject { .. } | ToolCall::CreateProject { .. }) => self.dispatch_project_tool(call, auth).await, + ToolCall::CodingAgentStart { + project, + provider_id, + idempotency_key, + instruction, + config, + timeout_secs, + recording_session_id, + } => { + self.coding_agent_start( + project, + provider_id, + idempotency_key, + instruction, + config, + timeout_secs, + recording_session_id, + auth, + ) + .await + } + ToolCall::CodingAgentObserve { + run_id, + after_observation_token, + wait_secs, + } => { + self.coding_agent_observe(run_id, after_observation_token, wait_secs, auth) + .await + } + ToolCall::CodingAgentCancel { run_id } => self.coding_agent_cancel(run_id, auth).await, + call @ (ToolCall::RunProcess { .. } | ToolCall::RunDetachedProcess { .. } | ToolCall::RunScript { .. } diff --git a/src/tool_runtime/kernel.rs b/src/tool_runtime/kernel.rs index 6a6ddd85..96c86607 100644 --- a/src/tool_runtime/kernel.rs +++ b/src/tool_runtime/kernel.rs @@ -858,6 +858,51 @@ mod tests { assert!(!serialized.contains("secret-content")); } + #[test] + fn coding_agent_tools_require_independent_execution_scope() { + for insufficient in [ + oauth(&["project:write"]), + oauth(&["job:run"]), + oauth(&["mcp:local"]), + oauth(&["project:write", "job:run", "mcp:local"]), + ] { + for tool in [ + "coding_agent_start", + "coding_agent_observe", + "coding_agent_cancel", + ] { + assert_eq!( + check_runtime_tool_scope(Some(&insufficient), tool), + Err(ToolCallErrorStatus::InsufficientScope { + required_scope: Some(crate::auth::SCOPE_CODING_AGENT_RUN), + description: "missing required scope: coding_agent:run".to_string(), + }), + "{tool} must not inherit project/job/MCP authority" + ); + } + } + let run_only = oauth(&["coding_agent:run"]); + assert_eq!( + check_runtime_tool_scope(Some(&run_only), "coding_agent_start"), + Err(ToolCallErrorStatus::InsufficientScope { + required_scope: Some(crate::auth::SCOPE_PROJECT_WRITE), + description: "missing required scope: project:write".to_string(), + }) + ); + for tool in ["coding_agent_observe", "coding_agent_cancel"] { + assert_eq!( + check_runtime_tool_scope(Some(&run_only), tool), + Ok(()), + "{tool}" + ); + } + let start_allowed = oauth(&["coding_agent:run", "project:write"]); + assert_eq!( + check_runtime_tool_scope(Some(&start_allowed), "coding_agent_start"), + Ok(()) + ); + } + #[test] fn computer_tools_require_independent_scope() { let denied = oauth(&["runtime:read", "project:read"]); diff --git a/src/tool_runtime/metadata.rs b/src/tool_runtime/metadata.rs index 1fd5e9e7..ec14f2be 100644 --- a/src/tool_runtime/metadata.rs +++ b/src/tool_runtime/metadata.rs @@ -62,6 +62,7 @@ pub(crate) const RUNTIME_READ: &str = crate::auth::SCOPE_RUNTIME_READ; pub(crate) const PROJECT_READ: &str = crate::auth::SCOPE_PROJECT_READ; pub(crate) const PROJECT_WRITE: &str = crate::auth::SCOPE_PROJECT_WRITE; pub(crate) const JOB_RUN: &str = crate::auth::SCOPE_JOB_RUN; +pub(crate) const CODING_AGENT_RUN: &str = crate::auth::SCOPE_CODING_AGENT_RUN; pub(crate) const COMPUTER_READ: &str = crate::auth::SCOPE_COMPUTER_READ; pub(crate) const COMPUTER_CONTROL: &str = crate::auth::SCOPE_COMPUTER_CONTROL; pub(crate) const COMPUTER_LAUNCH: &str = crate::auth::SCOPE_COMPUTER_LAUNCH; @@ -145,10 +146,10 @@ mod tests { use super::*; use crate::auth::scopes::{oauth_scope_policy_for_runtime_tool, OAuthToolScopePolicy}; use crate::auth::scopes::{ - SCOPE_COMPUTER_CLIPBOARD_READ, SCOPE_COMPUTER_CLIPBOARD_WRITE, SCOPE_COMPUTER_CONTROL, - SCOPE_COMPUTER_DISPLAY_READ, SCOPE_COMPUTER_POINTER_CONTROL, SCOPE_COMPUTER_READ, - SCOPE_JOB_DETACH, SCOPE_JOB_RUN, SCOPE_PROJECT_READ, SCOPE_PROJECT_WRITE, - SCOPE_RUNTIME_READ, + SCOPE_CODING_AGENT_RUN, SCOPE_COMPUTER_CLIPBOARD_READ, SCOPE_COMPUTER_CLIPBOARD_WRITE, + SCOPE_COMPUTER_CONTROL, SCOPE_COMPUTER_DISPLAY_READ, SCOPE_COMPUTER_POINTER_CONTROL, + SCOPE_COMPUTER_READ, SCOPE_JOB_DETACH, SCOPE_JOB_RUN, SCOPE_PROJECT_READ, + SCOPE_PROJECT_WRITE, SCOPE_RUNTIME_READ, }; use crate::tool_runtime::{is_known_tool_name, known_tool_names}; @@ -170,6 +171,8 @@ mod tests { }; let expected = if metadata.name == "run_detached_process" { OAuthToolScopePolicy::RequireAll(&[SCOPE_JOB_RUN, SCOPE_JOB_DETACH]) + } else if metadata.name == "coding_agent_start" { + OAuthToolScopePolicy::RequireAll(&[SCOPE_CODING_AGENT_RUN, SCOPE_PROJECT_WRITE]) } else if metadata.name == "computer_save_snapshot" { OAuthToolScopePolicy::RequireAll(&[SCOPE_PROJECT_WRITE, SCOPE_COMPUTER_READ]) } else if metadata.name == "computer_read_clipboard" { diff --git a/src/tool_runtime/mod.rs b/src/tool_runtime/mod.rs index 57029565..71f5c0c0 100644 --- a/src/tool_runtime/mod.rs +++ b/src/tool_runtime/mod.rs @@ -8,6 +8,7 @@ mod agent_authorization; mod cargo; mod cargo_tools; mod checkpoint; +mod coding_agent; mod coding_task; mod coding_task_tools; mod computer_tools; diff --git a/src/tool_runtime/patch.rs b/src/tool_runtime/patch.rs index 6d815ab8..51c5f0dd 100644 --- a/src/tool_runtime/patch.rs +++ b/src/tool_runtime/patch.rs @@ -501,6 +501,8 @@ mod tests { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: "inst".to_string(), display_name: None, diff --git a/src/tool_runtime/registry/annotations.rs b/src/tool_runtime/registry/annotations.rs index 450f4208..e2bbfc0a 100644 --- a/src/tool_runtime/registry/annotations.rs +++ b/src/tool_runtime/registry/annotations.rs @@ -1,17 +1,13 @@ use serde_json::{json, Value}; -use super::super::tool_definition::runtime_tool_metadata; +use super::super::tool_definition::runtime_tool_effect_annotations; pub(crate) fn tool_annotations(name: &str) -> Value { - let metadata = runtime_tool_metadata(name); - let read_only = metadata.read_only; - let destructive = metadata.destructive; - let open_world = metadata.shell_like; - let idempotent = metadata.read_only; + let effects = runtime_tool_effect_annotations(name); json!({ - "readOnlyHint": read_only, - "destructiveHint": destructive, - "idempotentHint": idempotent, - "openWorldHint": open_world, + "readOnlyHint": effects.read_only_hint, + "destructiveHint": effects.destructive_hint, + "idempotentHint": effects.idempotent_hint, + "openWorldHint": effects.open_world_hint, }) } diff --git a/src/tool_runtime/registry/input_schemas.rs b/src/tool_runtime/registry/input_schemas.rs index a29e5340..f7ca6946 100644 --- a/src/tool_runtime/registry/input_schemas.rs +++ b/src/tool_runtime/registry/input_schemas.rs @@ -2,6 +2,7 @@ mod artifacts; mod checkpoints; mod cleanup; mod coding; +mod coding_agents; mod common; mod computer; mod discovery; @@ -36,6 +37,10 @@ pub(super) use cleanup::{ pub(super) use coding::{ finish_coding_task_input_schema, start_coding_task_input_schema, work_on_project_input_schema, }; +pub(crate) use coding_agents::{ + coding_agent_cancel_input_schema, coding_agent_observe_input_schema, + coding_agent_start_input_schema, +}; pub(super) use computer::{ computer_accessibility_status_input_schema, computer_accessibility_tree_input_schema, computer_activate_window_input_schema, computer_control_input_schema, diff --git a/src/tool_runtime/registry/input_schemas/coding_agents.rs b/src/tool_runtime/registry/input_schemas/coding_agents.rs new file mode 100644 index 00000000..acb1a0f3 --- /dev/null +++ b/src/tool_runtime/registry/input_schemas/coding_agents.rs @@ -0,0 +1,101 @@ +use serde_json::{json, Value}; + +use webcodex_core::coding_agent::{ + CODING_AGENT_MAX_CONFIG_OPTIONS, CODING_AGENT_MAX_INSTRUCTION_BYTES, + CODING_AGENT_OBSERVE_WAIT_MAX_SECS, CODING_AGENT_TIMEOUT_MAX_SECS, + CODING_AGENT_TIMEOUT_MIN_SECS, +}; + +pub(crate) fn coding_agent_start_input_schema() -> Value { + json!({ + "type": "object", + "properties": { + "project": { + "type": "string", + "minLength": 1, + "description": "Exact registered Project id. It resolves the Runner and fixes ACP session cwd to that Project root; cwd is not a filesystem sandbox." + }, + "provider_id": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "description": "Logical Runner-advertised ACP provider id, for example codex. Executable, argv, environment, credentials, and provider instance ids are Runner-owned and cannot be supplied here." + }, + "idempotency_key": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Required caller-chosen replay key for this autonomous Run initiation. Reuse the same key for the same intent after an uncertain start; do not mint a replacement key to retry an uncertain prompt." + }, + "instruction": { + "type": "string", + "minLength": 1, + "maxLength": CODING_AGENT_MAX_INSTRUCTION_BYTES, + "description": "Bounded coding-agent instruction/prompt. It is sent to the delegated ACP agent but excluded from durable Run recovery records, Workflow lifecycle evidence, audit summaries, and generic telemetry bodies." + }, + "config": { + "type": "object", + "maxProperties": CODING_AGENT_MAX_CONFIG_OPTIONS, + "additionalProperties": { + "anyOf": [ + {"type": "string", "maxLength": 4096}, + {"type": "boolean"}, + {"type": "integer"} + ] + }, + "description": "Optional explicit run-level ACP config overrides. Omission or {} sends zero set_config_option calls. Every key/value must be live-advertised and operator-allowed before prompt dispatch." + }, + "timeout_secs": { + "type": "integer", + "minimum": CODING_AGENT_TIMEOUT_MIN_SECS, + "maximum": CODING_AGENT_TIMEOUT_MAX_SECS, + "default": 300, + "description": "Total Run budget. Timeout requests cancellation; it is not a retry signal and may become lost/outcome_unknown if terminal correlation is unavailable." + } + }, + "required": ["project", "provider_id", "idempotency_key", "instruction"], + "additionalProperties": false + }) +} + +pub(crate) fn coding_agent_observe_input_schema() -> Value { + json!({ + "type": "object", + "properties": { + "run_id": { + "type": "string", + "pattern": "^wc_agent_run_[A-Za-z0-9_.-]+$", + "description": "Opaque CodingAgentRun id returned by coding_agent_start. Knowing the id alone grants no authority." + }, + "after_observation_token": { + "type": "string", + "maxLength": 192, + "description": "Opaque exact-Run-bound observation token returned by the previous observation. A Server restart may reset it while preserving the Run." + }, + "wait_secs": { + "type": "integer", + "minimum": 0, + "maximum": CODING_AGENT_OBSERVE_WAIT_MAX_SECS, + "default": 0, + "description": "One bounded wait for retained Run changes; not a subscription or stream." + } + }, + "required": ["run_id"], + "additionalProperties": false + }) +} + +pub(crate) fn coding_agent_cancel_input_schema() -> Value { + json!({ + "type": "object", + "properties": { + "run_id": { + "type": "string", + "pattern": "^wc_agent_run_[A-Za-z0-9_.-]+$", + "description": "Opaque CodingAgentRun id to cancel. Cancellation never starts or retries work and must be followed by observation for authoritative terminal state." + } + }, + "required": ["run_id"], + "additionalProperties": false + }) +} diff --git a/src/tool_runtime/registry/output_schemas.rs b/src/tool_runtime/registry/output_schemas.rs index a265e286..8ed659aa 100644 --- a/src/tool_runtime/registry/output_schemas.rs +++ b/src/tool_runtime/registry/output_schemas.rs @@ -2,6 +2,7 @@ use serde_json::Value; mod artifacts; mod checkpoints; +mod coding_agents; mod coding_tasks; mod common; mod computer; @@ -19,6 +20,9 @@ mod testing; use common::default_output_schema; pub(crate) fn output_schema_for_tool(name: &str) -> Value { + if let Some(schema) = coding_agents::output_schema_for_tool(name) { + return schema; + } if let Some(schema) = computer::output_schema_for_tool(name) { return schema; } diff --git a/src/tool_runtime/registry/output_schemas/coding_agents.rs b/src/tool_runtime/registry/output_schemas/coding_agents.rs new file mode 100644 index 00000000..f3b0642e --- /dev/null +++ b/src/tool_runtime/registry/output_schemas/coding_agents.rs @@ -0,0 +1,169 @@ +use serde_json::{json, Value}; + +use super::common::{array_schema, nullable_schema, schema_type, wrapped_output_schema}; + +fn state_schema() -> Value { + json!({"type":"string","enum":["starting","running","waiting_permission","completed","failed","cancelled","lost"]}) +} + +fn execution_state_schema() -> Value { + json!({"type":"string","enum":["not_started","started","outcome_unknown","completed"]}) +} + +fn terminal_schema() -> Value { + json!({ + "anyOf": [ + { + "type":"object", + "additionalProperties":false, + "properties":{ + "stop_reason": nullable_schema("string", "Correlated stable ACP v1 stop reason when available."), + "error_code": nullable_schema("string", "Bounded protocol/provider terminal error code when available."), + "message": nullable_schema("string", "Bounded terminal diagnostic; never reasoning or a transcript."), + "completed_at": schema_type("integer", "Unix terminal timestamp.") + }, + "required":["stop_reason","error_code","message","completed_at"] + }, + {"type":"null"} + ] + }) +} + +fn usage_schema() -> Value { + json!({ + "type":"object", + "additionalProperties":false, + "properties":{ + "used_tokens":{"type":"integer","minimum":0}, + "context_window_tokens":{"type":"integer","minimum":0}, + "cost_amount":{"type":"string"}, + "cost_currency":{"type":"string"} + } + }) +} + +fn event_schema() -> Value { + json!({ + "type":"object", + "additionalProperties":false, + "properties":{ + "sequence":{"type":"integer","minimum":1}, + "kind":{ + "type":"string", + "enum":[ + "agent_message", + "reasoning", + "plan", + "tool_activity", + "file_change", + "terminal_activity", + "usage", + "permission_request", + "terminal" + ] + }, + "text":nullable_schema("string", "Bounded normalized text when this event kind carries text."), + "label":nullable_schema("string", "Bounded normalized activity label when present."), + "status":nullable_schema("string", "Bounded normalized activity status when present."), + "usage":{"anyOf":[usage_schema(),{"type":"null"}]} + }, + "required":["sequence","kind","text","label","status","usage"] + }) +} + +fn common_run_fields() -> Vec<(&'static str, Value)> { + vec![ + ("run_id", schema_type("string", "Opaque CodingAgentRun id.")), + ( + "project", + schema_type("string", "Exact registered runtime Project id."), + ), + ( + "provider_id", + schema_type("string", "Logical operator-configured provider id."), + ), + ("state", state_schema()), + ("execution_state", execution_state_schema()), + ("terminal", terminal_schema()), + ( + "error_kind", + schema_type( + "string", + "Bounded failure classification when unsuccessful.", + ), + ), + ] +} + +pub(super) fn output_schema_for_tool(name: &str) -> Option { + let mut fields = common_run_fields(); + match name { + "coding_agent_start" => { + fields.push(( + "observation_token", + schema_type("string", "Opaque Run-bound observation token."), + )); + Some(wrapped_output_schema(fields)) + } + "coding_agent_observe" => { + fields.extend([ + ("events", array_schema(event_schema(), "Only-new retained normalized CodingAgentRun events; raw ACP JSON is never exposed.")), + ("observation_token", schema_type("string", "Opaque Run-bound token for the next observation.")), + ("has_more", schema_type("boolean", "True when retained newer events remain after this page.")), + ("history_lost", schema_type("boolean", "True when the requested cursor predates retained history or the Server epoch rebaselined.")), + ("first_retained_sequence", schema_type("integer", "First currently retained Runner event sequence.")), + ]); + Some(wrapped_output_schema(fields)) + } + "coding_agent_cancel" => { + fields.push(( + "cancel_requested", + schema_type( + "boolean", + "True when cancellation was requested for a nonterminal Run.", + ), + )); + Some(wrapped_output_schema(fields)) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn coding_agent_event_schema_is_closed_and_terminal_is_nullable() { + let event = event_schema(); + assert_eq!(event["additionalProperties"], false); + assert_eq!( + event["properties"]["kind"]["enum"], + json!([ + "agent_message", + "reasoning", + "plan", + "tool_activity", + "file_change", + "terminal_activity", + "usage", + "permission_request", + "terminal" + ]) + ); + assert_eq!( + event["properties"]["usage"]["anyOf"][0]["additionalProperties"], + false + ); + assert_eq!(event["properties"]["usage"]["anyOf"][1]["type"], "null"); + + let terminal = terminal_schema(); + assert_eq!(terminal["anyOf"][0]["additionalProperties"], false); + assert_eq!(terminal["anyOf"][1]["type"], "null"); + + let observe = output_schema_for_tool("coding_agent_observe").unwrap(); + let serialized = serde_json::to_string(&observe).unwrap(); + assert!(serialized.contains("agent_message")); + assert!(!serialized.contains("agentmessage")); + } +} diff --git a/src/tool_runtime/runtime.rs b/src/tool_runtime/runtime.rs index f9b1c998..05856753 100644 --- a/src/tool_runtime/runtime.rs +++ b/src/tool_runtime/runtime.rs @@ -107,6 +107,7 @@ impl ValidationTerminalReconciliationTestHook { pub struct ToolRuntime { pub shell_clients: Arc, pub(crate) mcp_gateway: Arc, + pub(crate) coding_agent_runs: Arc, pub runtime_info: Arc, model_surface: crate::model_surface::ModelSurface, pub(crate) checkpoint_store: checkpoint::CheckpointStore, @@ -158,6 +159,7 @@ impl ToolRuntime { Self { shell_clients, mcp_gateway: Arc::new(crate::mcp_gateway::McpGatewayRuntime::default()), + coding_agent_runs: Arc::new(super::coding_agent::CodingAgentServerState::default()), runtime_info, model_surface: crate::model_surface::ModelSurface::LocalCoding, checkpoint_store: checkpoint::CheckpointStore::default(), diff --git a/src/tool_runtime/runtime_info.rs b/src/tool_runtime/runtime_info.rs index 724303cf..fbdd9b51 100644 --- a/src/tool_runtime/runtime_info.rs +++ b/src/tool_runtime/runtime_info.rs @@ -1563,6 +1563,7 @@ mod phase_e2_status_tests { connected: true, last_seen: 0, capabilities: Default::default(), + coding_agent_providers: None, pending_requests: 0, projects: Vec::new(), project_inventory: None, diff --git a/src/tool_runtime/sessions/events.rs b/src/tool_runtime/sessions/events.rs index d76c46f7..c47b3e84 100644 --- a/src/tool_runtime/sessions/events.rs +++ b/src/tool_runtime/sessions/events.rs @@ -16,17 +16,26 @@ use super::model::{ PersistentShellEventEvidence, SessionEvent, ToolCallExpectation, ToolCallRecorderMetadata, MAX_OBSERVED_PATHS_PER_EVENT, MAX_VALIDATION_EXCERPT_CHARS, SESSION_ID_PREFIX, TOOL_ASSERTION_NAME_FIELD, TOOL_CALL_ACK_SESSION_MESSAGE_IDS_INTERNAL_FIELD, - TOOL_CALL_EXPECTATION_METADATA_FIELDS, TOOL_EXPECTATION_RESULT_MATCHED, - TOOL_EXPECTATION_RESULT_MISMATCH, TOOL_EXPECTATION_RESULT_NONE, - TOOL_EXPECTATION_RESULT_UNEXPECTED_FAILURE, TOOL_EXPECTATION_RESULT_UNEXPECTED_SUCCESS, - TOOL_EXPECTED_FAILURE_FIELD, TOOL_EXPECTED_FAILURE_KIND_FIELD, + TOOL_CALL_EXPECTATION_METADATA_FIELDS, TOOL_CALL_RECORDING_SESSION_ID_FIELD, + TOOL_EXPECTATION_RESULT_MATCHED, TOOL_EXPECTATION_RESULT_MISMATCH, + TOOL_EXPECTATION_RESULT_NONE, TOOL_EXPECTATION_RESULT_UNEXPECTED_FAILURE, + TOOL_EXPECTATION_RESULT_UNEXPECTED_SUCCESS, TOOL_EXPECTED_FAILURE_FIELD, + TOOL_EXPECTED_FAILURE_KIND_FIELD, }; use super::util::redact_and_bound_value; use super::util::{bound_summary_string, validation_excerpt}; impl ToolCallRecorderMetadata { pub(crate) fn from_arguments(arguments: &Value) -> Self { + let recording_session_id = arguments + .as_object() + .and_then(|object| object.get(TOOL_CALL_RECORDING_SESSION_ID_FIELD)) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| is_valid_session_id(value)) + .map(str::to_string); Self { + recording_session_id, expectation: tool_call_expectation_from_arguments(arguments), ack_session_message_ids: arguments .as_object() diff --git a/src/tool_runtime/sessions/model.rs b/src/tool_runtime/sessions/model.rs index 0cc18140..ec6d8d36 100644 --- a/src/tool_runtime/sessions/model.rs +++ b/src/tool_runtime/sessions/model.rs @@ -767,6 +767,9 @@ pub(crate) struct ToolCallExpectation { #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct ToolCallRecorderMetadata { + /// Explicit generic wrapper recorder provenance. It is internal metadata, + /// never concrete tool business input or execution authority. + pub(crate) recording_session_id: Option, pub(crate) expectation: ToolCallExpectation, pub(crate) ack_session_message_ids: Vec, } diff --git a/src/tool_runtime/sessions/store.rs b/src/tool_runtime/sessions/store.rs index 7f86da8f..b31d717d 100644 --- a/src/tool_runtime/sessions/store.rs +++ b/src/tool_runtime/sessions/store.rs @@ -1759,6 +1759,46 @@ impl SessionStore { true } + /// Append bounded recorder-only CodingAgentRun lifecycle evidence. The + /// explicit Workflow Session is provenance only: this path grants no Run + /// authority and intentionally stores no prompt, ACP session id, event body, + /// reasoning, tool payload, credential, or idempotency key. + pub(crate) fn record_coding_agent_lifecycle_evidence( + &self, + session_id: &str, + project: &str, + run_id: &str, + provider_id: &str, + kind: &str, + state: &str, + execution_state: &str, + terminal_stop_reason: Option<&str>, + terminal_error_code: Option<&str>, + ) -> bool { + let project_matches = self + .with_record_for_query(session_id, |record, _| { + record.project.as_deref() == Some(project) + }) + .unwrap_or(false); + if !project_matches { + return false; + } + let now = now_ts(); + self.push_event(coding_agent_lifecycle_event( + session_id, + project, + run_id, + provider_id, + kind, + state, + execution_state, + terminal_stop_reason, + terminal_error_code, + now, + )); + true + } + /// Sole entry for appending a session ledger event. fn push_event(&self, event: SessionEvent) { let session_id = event.session_id.clone(); @@ -2143,6 +2183,82 @@ fn coding_instruction_event( } } +fn coding_agent_lifecycle_event( + session_id: &str, + project: &str, + run_id: &str, + provider_id: &str, + kind: &str, + state: &str, + execution_state: &str, + terminal_stop_reason: Option<&str>, + terminal_error_code: Option<&str>, + now: i64, +) -> SessionEvent { + let input_summary = serde_json::json!({ + "run_id": bound_summary_string(run_id), + "provider_id": bound_summary_string(provider_id), + "state": bound_summary_string(state), + "execution_state": bound_summary_string(execution_state), + "terminal_stop_reason": terminal_stop_reason.map(bound_summary_string), + "terminal_error_code": terminal_error_code.map(bound_summary_string), + }); + SessionEvent { + event_id: format!("{EVENT_ID_PREFIX}{}", uuid::Uuid::new_v4().simple()), + session_id: session_id.to_string(), + kind: bound_summary_string(kind), + call_id: None, + timestamp: now, + transport: "system".to_string(), + tool_name: "coding_agent_start".to_string(), + project: Some(project.to_string()), + resolved_project: Some(project.to_string()), + risk_class: "job_run".to_string(), + read_like: false, + write_like: false, + shell_like: false, + git_like: false, + change_summary_like: false, + diff_review_like: false, + started_at: Some(now), + finished_at: Some(now), + duration_ms: Some(0), + status: Some(bound_summary_string(state)), + exit_code: None, + failure_kind: None, + error_kind: terminal_error_code.map(bound_summary_string), + expected_failure: None, + expected_failure_kind: None, + assertion_name: None, + actual_failure_kind: None, + failure_expectation_result: None, + warning_kind: None, + session_project: None, + request_project: None, + allow_cross_project_session_required: None, + allow_cross_project_session: None, + error_message_summary: None, + changed_paths: Vec::new(), + observed_paths: Vec::new(), + job_id: None, + persistent_shell: None, + effect_evidence: None, + input_summary: Some(input_summary), + validation_output_summary: None, + permission: None, + instruction: None, + requested_mode: None, + previous_mode: None, + requested_guards: None, + previous_guards: None, + capability_changed: None, + context_refreshed: None, + execution_context: None, + previous_execution_context: None, + execution_context_changed: None, + } +} + fn session_closed_system_event(session_id: &str, now: i64) -> SessionEvent { SessionEvent { event_id: format!("{EVENT_ID_PREFIX}{}", uuid::Uuid::new_v4().simple()), diff --git a/src/tool_runtime/sessions/tests.rs b/src/tool_runtime/sessions/tests.rs index 9cf7c011..4f12945f 100644 --- a/src/tool_runtime/sessions/tests.rs +++ b/src/tool_runtime/sessions/tests.rs @@ -459,6 +459,91 @@ fn flush_and_restore(store: &SessionStore, path: PathBuf) -> SessionStore { SessionStore::with_persistence(path, 10, 10) } +#[test] +fn coding_agent_lifecycle_evidence_is_project_scoped_body_free_and_durable() { + let tmp = tempfile::tempdir().unwrap(); + let ledger = tmp.path().join("sessions.json"); + let store = persistent_store(ledger.clone()); + let project = "agent:special:acp-test"; + let session = store.start_session(Some(project.to_string()), Some("ACP recorder".to_string())); + let private_prompt = "PRIVATE_PROMPT_MUST_NOT_PERSIST"; + let private_reasoning = "PRIVATE_REASONING_MUST_NOT_PERSIST"; + + assert!(store.record_coding_agent_lifecycle_evidence( + &session.session_id, + project, + "wc_agent_run_recorder0001", + "codex", + "coding_agent_started", + "running", + "started", + None, + None, + )); + assert!(store.record_coding_agent_lifecycle_evidence( + &session.session_id, + project, + "wc_agent_run_recorder0001", + "codex", + "coding_agent_waiting_permission", + "waiting_permission", + "started", + None, + None, + )); + assert!(store.record_coding_agent_lifecycle_evidence( + &session.session_id, + project, + "wc_agent_run_recorder0001", + "codex", + "coding_agent_terminal", + "completed", + "completed", + Some("end_turn"), + None, + )); + assert!(!store.record_coding_agent_lifecycle_evidence( + &session.session_id, + "agent:special:other", + "wc_agent_run_wrongproject", + "codex", + "coding_agent_started", + "running", + "started", + None, + None, + )); + + // Sentinels model bodies that are intentionally absent from the lifecycle API. + assert!( + !serde_json::to_string(&store.summary(&session.session_id, Some(20)).unwrap()) + .unwrap() + .contains(private_prompt) + ); + assert!( + !serde_json::to_string(&store.summary(&session.session_id, Some(20)).unwrap()) + .unwrap() + .contains(private_reasoning) + ); + + let restored = flush_and_restore(&store, ledger.clone()); + let summary = restored.summary(&session.session_id, Some(20)).unwrap(); + let kinds = summary + .events + .iter() + .map(|event| event.kind.as_str()) + .collect::>(); + assert!(kinds.contains(&"coding_agent_started")); + assert!(kinds.contains(&"coding_agent_waiting_permission")); + assert!(kinds.contains(&"coding_agent_terminal")); + let serialized = std::fs::read_to_string(&ledger).unwrap(); + assert!(!serialized.contains(private_prompt)); + assert!(!serialized.contains(private_reasoning)); + assert!(!serialized.contains("idempotency_key")); + assert!(!serialized.contains("acp_session")); + assert!(!serialized.contains("wc_agent_run_wrongproject")); +} + #[test] fn session_store_persists_and_restores_basic_session() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/tool_runtime/tests/dispatch.rs b/src/tool_runtime/tests/dispatch.rs index d7fc1e81..12521395 100644 --- a/src/tool_runtime/tests/dispatch.rs +++ b/src/tool_runtime/tests/dispatch.rs @@ -584,6 +584,8 @@ async fn apply_patch_agent_does_not_require_server_local_project_root() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "patcher".to_string(), agent_instance_id: "inst".to_string(), display_name: None, diff --git a/src/tool_runtime/tests/execution_context.rs b/src/tool_runtime/tests/execution_context.rs index 55fa3a8a..c4626f41 100644 --- a/src/tool_runtime/tests/execution_context.rs +++ b/src/tool_runtime/tests/execution_context.rs @@ -613,6 +613,7 @@ async fn session_ssh_transport_failure_marks_remote_delivery_uncertain() { }, command_execution_state: Some(ShellCommandExecutionState::OutcomeUnknown), mcp_gateway: None, + coding_agent: None, }) .await .unwrap(); diff --git a/src/tool_runtime/tests/jobs.rs b/src/tool_runtime/tests/jobs.rs index ada085e4..b801dc5b 100644 --- a/src/tool_runtime/tests/jobs.rs +++ b/src/tool_runtime/tests/jobs.rs @@ -535,6 +535,7 @@ async fn run_shell_via_agent_lifecycle_error( }, command_execution_state: Some(execution_state), mcp_gateway: None, + coding_agent: None, }) .await .unwrap(); @@ -873,6 +874,8 @@ async fn long_run_shell_async_job_capability_does_not_bypass_shell_authority() { build: None, job_concurrency_limit: Some(4), job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: "inst".to_string(), display_name: None, @@ -1347,6 +1350,7 @@ async fn run_shell_runner_timeout_preserves_known_timeout_state() { }, command_execution_state: Some(ShellCommandExecutionState::TimedOut), mcp_gateway: None, + coding_agent: None, }) .await .unwrap(); @@ -2447,6 +2451,8 @@ async fn register_job_agent_for_auth( build: None, job_concurrency_limit: Some(4), job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: "inst".to_string(), display_name: None, diff --git a/src/tool_runtime/tests/lsp.rs b/src/tool_runtime/tests/lsp.rs index d2e6adf1..b0474a12 100644 --- a/src/tool_runtime/tests/lsp.rs +++ b/src/tool_runtime/tests/lsp.rs @@ -341,6 +341,8 @@ async fn register_lsp_agent_capabilities( build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: "inst".to_string(), display_name: None, @@ -1507,6 +1509,8 @@ async fn capability_default_false_on_old_registration() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "legacy".into(), agent_instance_id: "inst".into(), display_name: None, diff --git a/src/tool_runtime/tests/metadata.rs b/src/tool_runtime/tests/metadata.rs index ae953682..1134e3a2 100644 --- a/src/tool_runtime/tests/metadata.rs +++ b/src/tool_runtime/tests/metadata.rs @@ -113,6 +113,8 @@ fn metadata_agent_registration( build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: format!("inst-{client_id}"), display_name: None, @@ -143,6 +145,8 @@ async fn register_computer_target_for_auth( build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: format!("inst-{client_id}"), display_name: Some(display_name.to_string()), @@ -184,6 +188,8 @@ async fn register_application_target_for_auth( build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: format!("inst-{client_id}"), display_name: Some(display_name.to_string()), @@ -222,6 +228,8 @@ async fn register_display_target_for_auth( build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: format!("inst-{client_id}"), display_name: Some(display_name.to_string()), @@ -259,6 +267,8 @@ async fn register_pointer_target_for_auth( build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: format!("inst-{client_id}"), display_name: Some(display_name.to_string()), @@ -297,6 +307,8 @@ async fn register_clipboard_target_for_auth( build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: format!("inst-{client_id}"), display_name: Some(display_name.to_string()), @@ -335,6 +347,8 @@ async fn register_agent_projects_for_auth( build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: format!("inst-{}", client_id), display_name: None, @@ -385,6 +399,7 @@ async fn register_agent_projects_for_auth( computer_window_activate: false, computer_text_input: false, job_state_reconciliation: false, + coding_agent_runs: false, }), projects: Some(vec![registered_project( project_id, @@ -793,6 +808,8 @@ async fn replacement_runner_pending_inventory_has_zero_project_routing_authority build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: new_instance.to_string(), display_name: None, @@ -963,6 +980,8 @@ async fn replacement_runner_removed_project_never_inherits_old_authority() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: new_instance.to_string(), display_name: None, @@ -1174,6 +1193,8 @@ async fn runtime_status_shell_profiles_summary_is_sanitized() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "profile-agent".to_string(), agent_instance_id: "inst".to_string(), display_name: None, diff --git a/src/tool_runtime/tests/observe_jobs.rs b/src/tool_runtime/tests/observe_jobs.rs index 3bb94267..c571509a 100644 --- a/src/tool_runtime/tests/observe_jobs.rs +++ b/src/tool_runtime/tests/observe_jobs.rs @@ -793,6 +793,7 @@ async fn observe_jobs_recovering_lost_and_stop_requested_match_job_log_semantics async_jobs: true, async_shell_jobs: true, job_state_reconciliation: true, + coding_agent_runs: false, ..Default::default() }; runtime @@ -805,6 +806,8 @@ async fn observe_jobs_recovering_lost_and_stop_requested_match_job_log_semantics active_complete: true, jobs: Vec::new(), }), + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "observe-recovering".to_string(), agent_instance_id: "inst".to_string(), display_name: None, diff --git a/src/tool_runtime/tests/process.rs b/src/tool_runtime/tests/process.rs index 96861291..8a33d35a 100644 --- a/src/tool_runtime/tests/process.rs +++ b/src/tool_runtime/tests/process.rs @@ -239,6 +239,7 @@ async fn complete_process_lifecycle( }, command_execution_state: Some(state), mcp_gateway: None, + coding_agent: None, }) .await .unwrap(); @@ -679,6 +680,7 @@ async fn detached_process_lost_initiation_after_server_restart_recovers_same_job structured_execution_jobs: true, detached_process_jobs: true, job_state_reconciliation: true, + coding_agent_runs: false, ..Default::default() }; restarted @@ -707,6 +709,8 @@ async fn detached_process_lost_initiation_after_server_restart_recovers_same_job validation_progress: None, }], }), + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "detached-restart-recovery".to_string(), agent_instance_id: "inst".to_string(), display_name: None, diff --git a/src/tool_runtime/tests/reconnect.rs b/src/tool_runtime/tests/reconnect.rs index d018937d..edc14e3a 100644 --- a/src/tool_runtime/tests/reconnect.rs +++ b/src/tool_runtime/tests/reconnect.rs @@ -94,6 +94,8 @@ fn register_request( build, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, } } diff --git a/src/tool_runtime/tests/schema/annotations.rs b/src/tool_runtime/tests/schema/annotations.rs index 6d6b36c7..3f031e5c 100644 --- a/src/tool_runtime/tests/schema/annotations.rs +++ b/src/tool_runtime/tests/schema/annotations.rs @@ -54,6 +54,17 @@ fn tool_specs_annotations_cover_safety_hints() { assert_eq!(annotations["destructiveHint"], false); assert_eq!(annotations["openWorldHint"], false); } + + let cancel = &spec_named(&specs, "coding_agent_cancel").annotations; + assert_eq!(cancel["readOnlyHint"], false); + assert_eq!(cancel["destructiveHint"], false); + assert_eq!(cancel["idempotentHint"], true); + assert_eq!(cancel["openWorldHint"], false); + assert!( + !crate::tool_runtime::tool_definition::runtime_tool_requires_permission( + "coding_agent_cancel" + ) + ); } #[test] diff --git a/src/tool_runtime/tests/schema/discovery.rs b/src/tool_runtime/tests/schema/discovery.rs index a7f3b375..15a03c6f 100644 --- a/src/tool_runtime/tests/schema/discovery.rs +++ b/src/tool_runtime/tests/schema/discovery.rs @@ -271,6 +271,7 @@ fn allowed_tool_definition_categories_for_discovery_group(group: &str) -> &'stat match group { "checkpoint" => &["checkpoint"], "cleanup" => &["checkpoint", "cleanup"], + "coding_agent" => &["coding_agent"], "edit" => &["artifact", "edit", "patch"], "git" => &["checkpoint", "cleanup", "file", "git"], "inspect" => &[ diff --git a/src/tool_runtime/tests/schema/migration.rs b/src/tool_runtime/tests/schema/migration.rs index 682883d9..eff33429 100644 --- a/src/tool_runtime/tests/schema/migration.rs +++ b/src/tool_runtime/tests/schema/migration.rs @@ -235,6 +235,9 @@ fn tool_definition_runtime_tool_policy_inventory_is_stable() { "current_session_fallback", ), ("apply_text_edits", "edit", "current_session_fallback"), + ("coding_agent_start", "coding_agent", "none"), + ("coding_agent_observe", "coding_agent", "none"), + ("coding_agent_cancel", "coding_agent", "none"), ("computer_list_targets", "computer", "none"), ("computer_list_windows", "computer", "none"), ("computer_list_displays", "computer", "none"), diff --git a/src/tool_runtime/tests/schema/policy.rs b/src/tool_runtime/tests/schema/policy.rs index 72f3de72..9dcac4f0 100644 --- a/src/tool_runtime/tests/schema/policy.rs +++ b/src/tool_runtime/tests/schema/policy.rs @@ -390,6 +390,11 @@ fn required_agent_capability_matches_metadata_risk_table() { ToolRisk::JobRun, AgentCapability::StructuredScript, ), + ( + "coding_agent_start", + ToolRisk::JobRun, + AgentCapability::CodingAgentRuns, + ), ("run_shell", ToolRisk::JobRun, AgentCapability::Shell), ( "open_session_shell", diff --git a/src/tool_runtime/tests/script.rs b/src/tool_runtime/tests/script.rs index 50935e73..cdc72614 100644 --- a/src/tool_runtime/tests/script.rs +++ b/src/tool_runtime/tests/script.rs @@ -149,6 +149,7 @@ async fn complete_script_lifecycle( }, command_execution_state: Some(state), mcp_gateway: None, + coding_agent: None, }) .await .unwrap(); diff --git a/src/tool_runtime/tests/sessions_current.rs b/src/tool_runtime/tests/sessions_current.rs index 2c3970b4..a2f38cb9 100644 --- a/src/tool_runtime/tests/sessions_current.rs +++ b/src/tool_runtime/tests/sessions_current.rs @@ -339,6 +339,8 @@ async fn open_anonymous_can_bind_current_session_and_record_project_read() { build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: "open-current".to_string(), agent_instance_id: "inst-open-current".to_string(), display_name: None, diff --git a/src/tool_runtime/tests/support/agent.rs b/src/tool_runtime/tests/support/agent.rs index 6ec0af7d..6825caa0 100644 --- a/src/tool_runtime/tests/support/agent.rs +++ b/src/tool_runtime/tests/support/agent.rs @@ -27,6 +27,8 @@ pub(in crate::tool_runtime::tests) async fn register_agent_project_at_path( build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: "inst".to_string(), display_name: None, @@ -66,6 +68,8 @@ pub(in crate::tool_runtime::tests) async fn register_agent_project_at_path_with_ build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: "inst".to_string(), display_name: None, @@ -441,6 +445,8 @@ pub(in crate::tool_runtime::tests) async fn register_agent( build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: "inst".to_string(), display_name: None, @@ -477,6 +483,8 @@ pub(in crate::tool_runtime::tests) async fn register_agent_with_instance( build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: agent_instance_id.to_string(), display_name: None, @@ -568,6 +576,8 @@ pub(in crate::tool_runtime::tests) async fn register_agent_projects( build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: format!("inst-{}", client_id), display_name: None, @@ -598,6 +608,8 @@ pub(in crate::tool_runtime::tests) async fn register_agent_projects_for_auth( build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: format!("inst-{}", client_id), display_name: None, @@ -872,6 +884,8 @@ pub(in crate::tool_runtime::tests) async fn register_agent_with_projects( build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: "inst".to_string(), display_name: None, @@ -903,6 +917,8 @@ pub(in crate::tool_runtime::tests) async fn register_agent_with_shell_profiles( build: None, job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: "inst".to_string(), display_name: None, diff --git a/src/tool_runtime/tests/support/runtime.rs b/src/tool_runtime/tests/support/runtime.rs index f90e4479..3b9e6c57 100644 --- a/src/tool_runtime/tests/support/runtime.rs +++ b/src/tool_runtime/tests/support/runtime.rs @@ -105,6 +105,8 @@ pub(in crate::tool_runtime::tests) fn sample_field_value(field: &str) -> Value { "query" => json!("ToolRuntime"), "job_id" => json!("job_123"), "idempotency_key" => json!("sample-detached-key"), + "provider_id" => json!("codex"), + "run_id" => json!("wc_agent_run_sample_1234"), "shell_id" => json!("wc_shell_123"), "session_id" => json!("wc_sess_existing"), "checkpoint_id" => json!("wc_ckpt_1234"), diff --git a/src/tool_runtime/tests/targeted_inventory.rs b/src/tool_runtime/tests/targeted_inventory.rs index 1061feec..20d58280 100644 --- a/src/tool_runtime/tests/targeted_inventory.rs +++ b/src/tool_runtime/tests/targeted_inventory.rs @@ -54,6 +54,8 @@ async fn register_target_agent( build, job_concurrency_limit: Some(4), job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: format!("inst-{client_id}"), display_name: Some(format!("Runner {client_id}")), @@ -87,6 +89,8 @@ async fn register_target_agent_for_auth( build: None, job_concurrency_limit: Some(4), job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: format!("inst-{client_id}"), display_name: None, diff --git a/src/tool_runtime/tests/validation_handoff.rs b/src/tool_runtime/tests/validation_handoff.rs index 31f752f8..9d293485 100644 --- a/src/tool_runtime/tests/validation_handoff.rs +++ b/src/tool_runtime/tests/validation_handoff.rs @@ -97,6 +97,7 @@ async fn complete_sync_shell_lifecycle( }, command_execution_state: Some(execution_state), mcp_gateway: None, + coding_agent: None, }) .await .unwrap(); diff --git a/src/tool_runtime/tests/work_on_project.rs b/src/tool_runtime/tests/work_on_project.rs index ce417127..1f8fb0f5 100644 --- a/src/tool_runtime/tests/work_on_project.rs +++ b/src/tool_runtime/tests/work_on_project.rs @@ -90,6 +90,8 @@ async fn register_legacy_031_runner( }), job_concurrency_limit: None, job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, client_id: client_id.to_string(), agent_instance_id: "inst".to_string(), display_name: Some("legacy v0.3.1 runner".to_string()), diff --git a/src/tool_runtime/tool_audit.rs b/src/tool_runtime/tool_audit.rs index d3e2b9ae..5e321787 100644 --- a/src/tool_runtime/tool_audit.rs +++ b/src/tool_runtime/tool_audit.rs @@ -60,6 +60,45 @@ pub(crate) fn session_log_arguments_for_tool_request(tool_name: &str, arguments: ); copy_keys(obj, &mut out, &["timeout_secs", "cwd", "purpose"]); } + "coding_agent_start" => { + copy_keys(obj, &mut out, &["provider_id", "timeout_secs"]); + out.insert( + "instruction_bytes".to_string(), + Value::from( + obj.get("instruction") + .and_then(Value::as_str) + .map(str::len) + .unwrap_or_default(), + ), + ); + out.insert( + "config_count".to_string(), + Value::from( + obj.get("config") + .and_then(Value::as_object) + .map(serde_json::Map::len) + .unwrap_or_default(), + ), + ); + out.insert( + "idempotency_key_present".to_string(), + Value::Bool(obj.get("idempotency_key").and_then(Value::as_str).is_some()), + ); + } + "coding_agent_observe" => { + copy_keys(obj, &mut out, &["run_id", "wait_secs"]); + out.insert( + "token_present".to_string(), + Value::Bool( + obj.get("after_observation_token") + .and_then(Value::as_str) + .is_some(), + ), + ); + } + "coding_agent_cancel" => { + copy_keys(obj, &mut out, &["run_id"]); + } "run_script" => { if let Some(language) = obj.get("language").cloned() { out.insert("language".to_string(), language); @@ -775,6 +814,58 @@ pub(crate) fn session_log_arguments_for_tool_request(tool_name: &str, arguments: pub(crate) fn session_log_result_for_tool(tool_name: &str, output: &Value) -> Value { match tool_name { + "coding_agent_start" | "coding_agent_cancel" => serde_json::json!({ + "run_id": output.get("run_id").cloned().unwrap_or(Value::Null), + "project": output.get("project").cloned().unwrap_or(Value::Null), + "provider_id": output.get("provider_id").cloned().unwrap_or(Value::Null), + "state": output.get("state").cloned().unwrap_or(Value::Null), + "execution_state": output.get("execution_state").cloned().unwrap_or(Value::Null), + "cancel_requested": output.get("cancel_requested").cloned().unwrap_or(Value::Null), + "terminal_stop_reason": output.pointer("/terminal/stop_reason").cloned().unwrap_or(Value::Null), + "terminal_error_code": output.pointer("/terminal/error_code").cloned().unwrap_or(Value::Null), + "terminal_completed_at": output.pointer("/terminal/completed_at").cloned().unwrap_or(Value::Null), + "error_kind": output.get("error_kind").cloned().unwrap_or(Value::Null), + "recovery_kind": output.get("recovery_kind").cloned().unwrap_or(Value::Null), + }), + "coding_agent_observe" => { + let mut kind_counts = serde_json::Map::new(); + let mut event_count = 0usize; + let mut event_body_bytes = 0usize; + if let Some(events) = output.get("events").and_then(Value::as_array) { + event_count = events.len(); + for event in events { + if let Some(kind) = event.get("kind").and_then(Value::as_str) { + let count = kind_counts.get(kind).and_then(Value::as_u64).unwrap_or(0) + 1; + kind_counts.insert(kind.to_string(), Value::from(count)); + } + event_body_bytes = event_body_bytes.saturating_add( + event + .get("text") + .and_then(Value::as_str) + .map(str::len) + .unwrap_or(0), + ); + } + } + serde_json::json!({ + "run_id": output.get("run_id").cloned().unwrap_or(Value::Null), + "project": output.get("project").cloned().unwrap_or(Value::Null), + "provider_id": output.get("provider_id").cloned().unwrap_or(Value::Null), + "state": output.get("state").cloned().unwrap_or(Value::Null), + "execution_state": output.get("execution_state").cloned().unwrap_or(Value::Null), + "event_count": event_count, + "event_kind_counts": kind_counts, + "event_body_bytes": event_body_bytes, + "has_more": output.get("has_more").cloned().unwrap_or(Value::Null), + "history_lost": output.get("history_lost").cloned().unwrap_or(Value::Null), + "first_retained_sequence": output.get("first_retained_sequence").cloned().unwrap_or(Value::Null), + "terminal_stop_reason": output.pointer("/terminal/stop_reason").cloned().unwrap_or(Value::Null), + "terminal_error_code": output.pointer("/terminal/error_code").cloned().unwrap_or(Value::Null), + "terminal_completed_at": output.pointer("/terminal/completed_at").cloned().unwrap_or(Value::Null), + "recovery_kind": output.get("recovery_kind").cloned().unwrap_or(Value::Null), + "error_kind": output.get("error_kind").cloned().unwrap_or(Value::Null), + }) + } "git_review_summary" => serde_json::json!({ "project": output.get("project").cloned().unwrap_or(Value::Null), "scope": output.get("scope").cloned().unwrap_or(Value::Null), @@ -1934,6 +2025,78 @@ mod computer_privacy_tests { assert!(!result_serialized.contains("Private App")); assert!(!result_serialized.contains("Confidential")); } + #[test] + fn coding_agent_audit_is_body_free_for_requests_and_observations() { + const PROMPT: &str = "PRIVATE_ACP_PROMPT_DO_NOT_PERSIST"; + const IDEMPOTENCY: &str = "PRIVATE_ACP_IDEMPOTENCY_KEY"; + const MESSAGE: &str = "PRIVATE_AGENT_MESSAGE_BODY"; + const REASONING: &str = "PRIVATE_REASONING_BODY"; + const TOOL_LABEL: &str = "PRIVATE_TOOL_LABEL"; + const TOKEN: &str = "PRIVATE_OBSERVATION_TOKEN"; + + let request = json!({ + "project": "agent:special:demo", + "provider_id": "codex", + "idempotency_key": IDEMPOTENCY, + "instruction": PROMPT, + "config": {"mode": "agent"}, + "timeout_secs": 60, + "recording_session_id": "wc_sess_safe" + }); + let request_summary = + session_log_arguments_for_tool_request("coding_agent_start", &request); + let request_serialized = serde_json::to_string(&request_summary).unwrap(); + assert_eq!(request_summary["instruction_bytes"], PROMPT.len()); + assert_eq!(request_summary["config_count"], 1); + assert_eq!(request_summary["idempotency_key_present"], true); + assert!(!request_serialized.contains(PROMPT)); + assert!(!request_serialized.contains(IDEMPOTENCY)); + assert!(!request_serialized.contains("agent\"")); + assert!(request_summary.get("recording_session_id").is_none()); + assert!(!request_serialized.contains("wc_sess_safe")); + + let observe_request = json!({ + "run_id": "wc_agent_run_safe", + "after_observation_token": TOKEN, + "wait_secs": 3 + }); + let observe_request_summary = + session_log_arguments_for_tool_request("coding_agent_observe", &observe_request); + let observe_request_serialized = serde_json::to_string(&observe_request_summary).unwrap(); + assert_eq!(observe_request_summary["token_present"], true); + assert!(!observe_request_serialized.contains(TOKEN)); + + let output = json!({ + "run_id": "wc_agent_run_safe", + "project": "agent:special:demo", + "provider_id": "codex", + "state": "running", + "execution_state": "started", + "events": [ + {"sequence": 1, "kind": "agent_message", "text": MESSAGE, "label": null, "status": null, "usage": null}, + {"sequence": 2, "kind": "reasoning", "text": REASONING, "label": null, "status": null, "usage": null}, + {"sequence": 3, "kind": "tool_activity", "text": null, "label": TOOL_LABEL, "status": "running", "usage": null} + ], + "observation_token": TOKEN, + "has_more": false, + "history_lost": false, + "first_retained_sequence": 1, + "terminal": null, + "recovery_kind": "reobserve" + }); + let result_summary = session_log_result_for_tool("coding_agent_observe", &output); + let result_serialized = serde_json::to_string(&result_summary).unwrap(); + assert_eq!(result_summary["event_count"], 3); + assert_eq!( + result_summary["event_body_bytes"], + MESSAGE.len() + REASONING.len() + ); + for private in [MESSAGE, REASONING, TOOL_LABEL, TOKEN] { + assert!(!result_serialized.contains(private)); + } + assert!(result_summary.get("events").is_none()); + assert!(result_summary.get("observation_token").is_none()); + } } impl ToolCall { @@ -1961,6 +2124,34 @@ impl ToolCall { "cwd": cwd, "purpose": purpose, }), + Self::CodingAgentStart { + project, + provider_id, + idempotency_key, + instruction, + config, + timeout_secs, + recording_session_id: _, + } => serde_json::json!({ + "project": project, + "provider_id": provider_id, + "idempotency_key_present": !idempotency_key.is_empty(), + "instruction_bytes": instruction.len(), + "config_count": config.as_ref().map(std::collections::BTreeMap::len).unwrap_or_default(), + "timeout_secs": timeout_secs, + }), + Self::CodingAgentObserve { + run_id, + after_observation_token, + wait_secs, + } => serde_json::json!({ + "run_id": run_id, + "token_present": after_observation_token.is_some(), + "wait_secs": wait_secs, + }), + Self::CodingAgentCancel { run_id } => serde_json::json!({ + "run_id": run_id, + }), Self::RunScript { project, language, diff --git a/src/tool_runtime/tool_call.rs b/src/tool_runtime/tool_call.rs index ffecf255..213f74cf 100644 --- a/src/tool_runtime/tool_call.rs +++ b/src/tool_runtime/tool_call.rs @@ -19,7 +19,7 @@ use crate::lsp_bridge::{ use crate::shell_protocol::ShellScriptLanguage; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::collections::HashSet; +use std::collections::{BTreeMap, HashSet}; pub(crate) const TOOL_CALL_TOOL_FIELD: &str = "tool"; pub(crate) const TOOL_CALL_PARAMS_FIELD: &str = "params"; @@ -395,7 +395,9 @@ pub enum ToolCall { /// explicit `session_id` (never current-session fallback). Idempotent when /// already closed. Does not archive or evict; clears bindings to the closed /// Session. - CloseSession { session_id: String }, + CloseSession { + session_id: String, + }, /// Read bounded structured validation evidence already present in an /// explicit project-scoped session ledger. Never executes validation, @@ -508,15 +510,22 @@ pub enum ToolCall { /// Explicitly bind an existing project-scoped session as current for the /// client window, caller, transport, and project. - BindCurrentSession { project: String, session_id: String }, + BindCurrentSession { + project: String, + session_id: String, + }, /// Return this window/caller/transport's exact current session binding for /// a project, restoring its process-local cache from the ledger if needed. - CurrentSession { project: String }, + CurrentSession { + project: String, + }, /// Remove this window/caller/transport's exact current session binding from /// both the process-local cache and durable ledger projection. Idempotent. - UnbindCurrentSession { project: String }, + UnbindCurrentSession { + project: String, + }, /// Create a bounded last-known-good workspace checkpoint outside the /// project worktree. @@ -616,6 +625,28 @@ pub enum ToolCall { #[serde(default)] purpose: Option, }, + CodingAgentStart { + project: String, + provider_id: String, + idempotency_key: String, + instruction: String, + #[serde(default)] + config: Option>, + #[serde(default)] + timeout_secs: Option, + #[serde(default)] + recording_session_id: Option, + }, + CodingAgentObserve { + run_id: String, + #[serde(default)] + after_observation_token: Option, + #[serde(default)] + wait_secs: Option, + }, + CodingAgentCancel { + run_id: String, + }, /// Execute bounded script content transported as typed data and written to /// a Runner-owned temporary file. The selected language is explicit and @@ -1414,7 +1445,9 @@ pub enum ToolCall { }, /// Read the exact Runner's macOS Accessibility trust status without prompting. - ComputerAccessibilityStatus { client_id: String }, + ComputerAccessibilityStatus { + client_id: String, + }, /// Inspect one exact previously listed macOS surface as a bounded AX tree. ComputerAccessibilityTree { @@ -1482,10 +1515,15 @@ pub enum ToolCall { }, /// Read bounded native plain Unicode text from the global clipboard. - ComputerReadClipboard { client_id: String }, + ComputerReadClipboard { + client_id: String, + }, /// Replace the global clipboard with bounded native plain Unicode text. - ComputerWriteClipboard { client_id: String, text: String }, + ComputerWriteClipboard { + client_id: String, + text: String, + }, /// Move the native macOS or Windows pointer using one latest unspent full-display snapshot generation. ComputerPointerMove { @@ -2083,6 +2121,9 @@ impl ToolCall { Self::WorkspaceCheckpointDelete { .. } => "workspace_checkpoint_delete", Self::RunProcess { .. } => "run_process", Self::RunDetachedProcess { .. } => "run_detached_process", + Self::CodingAgentStart { .. } => "coding_agent_start", + Self::CodingAgentObserve { .. } => "coding_agent_observe", + Self::CodingAgentCancel { .. } => "coding_agent_cancel", Self::RunScript { .. } => "run_script", Self::RunShell { .. } => "run_shell", Self::OpenSessionShell { .. } => "open_session_shell", @@ -2240,6 +2281,8 @@ impl ToolCall { } pub(crate) fn with_effective_session_id(mut self, effective_session_id: String) -> Self { + // CodingAgentRun recorder provenance is intentionally not a business + // Session id and never participates in current-Session inheritance. match &mut self { Self::RunProcess { session_id, .. } | Self::RunDetachedProcess { session_id, .. } @@ -2308,6 +2351,27 @@ impl ToolCall { self } + /// Attach explicit generic recorder provenance to a CodingAgentStart only. + /// This never makes the recorder a business Session or Run authority. + pub(crate) fn with_coding_agent_recording_session_id( + mut self, + recorder_session_id: Option, + ) -> Self { + if let ( + Self::CodingAgentStart { + recording_session_id, + .. + }, + Some(recorder_session_id), + ) = (&mut self, recorder_session_id) + { + if recording_session_id.is_none() { + *recording_session_id = Some(recorder_session_id); + } + } + self + } + /// Apply project-matched Session defaults without overwriting explicit /// per-call arguments. pub(crate) fn with_session_execution_context( @@ -2341,6 +2405,7 @@ impl ToolCall { match self { Self::RunProcess { project, .. } | Self::RunDetachedProcess { project, .. } + | Self::CodingAgentStart { project, .. } | Self::RunScript { project, .. } | Self::RunShell { project, .. } | Self::OpenSessionShell { project, .. } diff --git a/src/tool_runtime/tool_catalog.rs b/src/tool_runtime/tool_catalog.rs index f58f47df..8ab07647 100644 --- a/src/tool_runtime/tool_catalog.rs +++ b/src/tool_runtime/tool_catalog.rs @@ -4,6 +4,7 @@ use super::tool_definition::{ToolDiscoveryGroup, ToolManifestIntent, ToolRecomme pub(crate) const TOOL_DISCOVERY_GROUP_CHECKPOINT: &str = "checkpoint"; pub(crate) const TOOL_DISCOVERY_GROUP_CLEANUP: &str = "cleanup"; +pub(crate) const TOOL_DISCOVERY_GROUP_CODING_AGENT: &str = "coding_agent"; pub(crate) const TOOL_DISCOVERY_GROUP_EDIT: &str = "edit"; pub(crate) const TOOL_DISCOVERY_GROUP_GIT: &str = "git"; pub(crate) const TOOL_DISCOVERY_GROUP_INSPECT: &str = "inspect"; @@ -210,6 +211,14 @@ pub(crate) const TOOL_DISCOVERY_GROUPS: &[ToolDiscoveryGroup] = &[ "tool_manifest", ], }, + ToolDiscoveryGroup { + name: TOOL_DISCOVERY_GROUP_CODING_AGENT, + tools: &[ + "coding_agent_start", + "coding_agent_observe", + "coding_agent_cancel", + ], + }, ToolDiscoveryGroup { name: TOOL_DISCOVERY_GROUP_CLEANUP, tools: &[ @@ -359,6 +368,10 @@ pub(crate) const LOCAL_CODING_TOOL_NAMES: &[&str] = &[ // entry "work_on_project", "list_projects", + // delegated ACP coding-agent Runs (explicit coding_agent:run authority) + "coding_agent_start", + "coding_agent_observe", + "coding_agent_cancel", // project discovery + read "project_overview", "list_project_tracked_files", diff --git a/src/tool_runtime/tool_definition.rs b/src/tool_runtime/tool_definition.rs index d446c8b8..af39c8c6 100644 --- a/src/tool_runtime/tool_definition.rs +++ b/src/tool_runtime/tool_definition.rs @@ -7,6 +7,7 @@ mod artifacts; mod checkpoints; +mod coding_agents; mod computer; mod current_sessions; mod discovery; @@ -50,7 +51,7 @@ pub(crate) use super::tool_policy::{ is_model_visible_tool_name, lookup_tool_definition, model_visible_tool_definitions, model_visible_tool_names_csv, runtime_tool_agent_capability, runtime_tool_allows_current_session_fallback, runtime_tool_captures_validation_output, - runtime_tool_category, runtime_tool_disabled_message, + runtime_tool_category, runtime_tool_disabled_message, runtime_tool_effect_annotations, runtime_tool_extra_accepted_flattened_args, runtime_tool_is_change_summary_like, runtime_tool_is_git_like, runtime_tool_is_read_like, runtime_tool_is_shell_like, runtime_tool_is_write_like, runtime_tool_metadata, runtime_tool_permission_risk, @@ -59,6 +60,7 @@ pub(crate) use super::tool_policy::{ }; use crate::shell_protocol::{ SHELL_CLIENT_CAPABILITY_ASYNC_JOBS, SHELL_CLIENT_CAPABILITY_ASYNC_SHELL_JOBS, + SHELL_CLIENT_CAPABILITY_CODING_AGENT_RUNS, SHELL_CLIENT_CAPABILITY_COMPUTER_ACCESSIBILITY_OBSERVE, SHELL_CLIENT_CAPABILITY_COMPUTER_APPLICATION_DISCOVERY, SHELL_CLIENT_CAPABILITY_COMPUTER_APPLICATION_LAUNCH, @@ -138,6 +140,9 @@ pub(crate) enum AgentCapability { LspReadOnlyNavigation, /// Bounded typed call-hierarchy traversal; never inferred from navigation. LspCallHierarchy, + /// Runner-owned delegated ACP coding-agent execution. Never inferred from + /// shell, Job, MCP, or file-write capability. + CodingAgentRuns, } impl AgentCapability { @@ -173,6 +178,7 @@ impl AgentCapability { Self::ComputerTextInput => SHELL_CLIENT_CAPABILITY_COMPUTER_TEXT_INPUT, Self::LspReadOnlyNavigation => SHELL_CLIENT_CAPABILITY_LSP_READ_ONLY_NAVIGATION, Self::LspCallHierarchy => SHELL_CLIENT_CAPABILITY_LSP_CALL_HIERARCHY, + Self::CodingAgentRuns => SHELL_CLIENT_CAPABILITY_CODING_AGENT_RUNS, } } @@ -213,6 +219,7 @@ impl AgentCapability { Self::ComputerTextInput => &[SHELL_CLIENT_CAPABILITY_COMPUTER_TEXT_INPUT], Self::LspReadOnlyNavigation => &[SHELL_CLIENT_CAPABILITY_LSP_READ_ONLY_NAVIGATION], Self::LspCallHierarchy => &[SHELL_CLIENT_CAPABILITY_LSP_CALL_HIERARCHY], + Self::CodingAgentRuns => &[SHELL_CLIENT_CAPABILITY_CODING_AGENT_RUNS], } } @@ -267,6 +274,7 @@ pub(crate) struct ToolDefinition { pub(crate) const TOOL_CATEGORY_ARTIFACT: &str = "artifact"; pub(crate) const TOOL_CATEGORY_CHECKPOINT: &str = "checkpoint"; +pub(crate) const TOOL_CATEGORY_CODING_AGENT: &str = "coding_agent"; pub(crate) const TOOL_CATEGORY_COMPUTER: &str = "computer"; pub(crate) const TOOL_CATEGORY_CLEANUP: &str = "cleanup"; pub(crate) const TOOL_CATEGORY_EDIT: &str = "edit"; @@ -288,15 +296,25 @@ pub(crate) const PERMISSION_RISK_SHELL: &str = "shell"; pub(crate) const PERMISSION_RISK_VALIDATION: &str = "validation"; pub(crate) const PERMISSION_RISK_WRITE: &str = "write"; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ToolEffectAnnotations { + pub(crate) read_only_hint: bool, + pub(crate) destructive_hint: bool, + pub(crate) idempotent_hint: bool, + pub(crate) open_world_hint: bool, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct ToolDefinitionPolicy { pub(crate) change_summary_like: bool, pub(crate) captures_validation_output: bool, pub(crate) current_session_control: bool, pub(crate) creates_or_binds_session: bool, + pub(crate) current_session_fallback_disabled: bool, pub(crate) disabled_message: Option<&'static str>, pub(crate) extra_accepted_flattened_args: &'static [&'static str], pub(crate) git_like: bool, + pub(crate) effect_annotations: Option, pub(crate) permission_risk: Option<&'static str>, pub(crate) requires_artifact_upload_path_binding: bool, pub(crate) requires_explicit_business_session: bool, @@ -309,9 +327,11 @@ impl ToolDefinitionPolicy { captures_validation_output: false, current_session_control: false, creates_or_binds_session: false, + current_session_fallback_disabled: false, disabled_message: None, extra_accepted_flattened_args: &[], git_like: false, + effect_annotations: None, permission_risk: None, requires_artifact_upload_path_binding: false, requires_explicit_business_session: false, @@ -389,6 +409,19 @@ const fn model_spec( } } +const fn effect_annotations( + definition: ToolDefinition, + annotations: ToolEffectAnnotations, +) -> ToolDefinition { + ToolDefinition { + policy: ToolDefinitionPolicy { + effect_annotations: Some(annotations), + ..definition.policy + }, + ..definition + } +} + macro_rules! bool_policy_modifier { ($function:ident, $field:ident) => { const fn $function(definition: ToolDefinition) -> ToolDefinition { @@ -413,6 +446,11 @@ bool_policy_modifier!(git_like, git_like); bool_policy_modifier!(creates_or_binds_session, creates_or_binds_session); +bool_policy_modifier!( + disable_current_session_fallback, + current_session_fallback_disabled +); + const fn extra_accepted_flattened_args( definition: ToolDefinition, fields: &'static [&'static str], @@ -467,6 +505,7 @@ const TOOL_DEFINITION_GROUPS: &[&[ToolDefinition]] = &[ hygiene::DEFINITIONS, current_sessions::DEFINITIONS, checkpoints::DEFINITIONS, + coding_agents::DEFINITIONS, computer::DEFINITIONS, discovery::DEFINITIONS, jobs::EXECUTION_DEFINITIONS, diff --git a/src/tool_runtime/tool_definition/coding_agents.rs b/src/tool_runtime/tool_definition/coding_agents.rs new file mode 100644 index 00000000..c0a9c0cf --- /dev/null +++ b/src/tool_runtime/tool_definition/coding_agents.rs @@ -0,0 +1,85 @@ +use super::AgentCapability::CodingAgentRuns; +use super::ToolVisibility::ModelVisible; +use super::{ + def, disable_current_session_fallback, effect_annotations, model_spec, permission_risk, + ToolDefinition, ToolEffectAnnotations, PERMISSION_RISK_JOB, TOOL_CATEGORY_CODING_AGENT, +}; +use crate::tool_runtime::metadata::{ + ToolPathHint::None as NoPath, + ToolRisk::{JobRun, ReadOnly}, + CODING_AGENT_RUN, TOOL_PROVIDER_AGENT, +}; +use crate::tool_runtime::registry::input_schemas::{ + coding_agent_cancel_input_schema, coding_agent_observe_input_schema, + coding_agent_start_input_schema, +}; + +pub(super) const DEFINITIONS: &[ToolDefinition] = &[ + permission_risk( + disable_current_session_fallback(model_spec( + def( + "coding_agent_start", + ModelVisible, + TOOL_CATEGORY_CODING_AGENT, + Some(CodingAgentRuns), + TOOL_PROVIDER_AGENT, + JobRun, + Some(CODING_AGENT_RUN), + // The start is Project-bound; Workflow current-session fallback + // remains independently disabled by the Session policy helpers and + // must never synthesize recording_session_id or Run authority. + true, + NoPath, + true, + false, + ), + "Start one idempotent delegated ACP coding-agent Run on an exact registered Project and logical Runner provider. Autonomous execution may outlive this request; after any uncertain start, reuse the same idempotency key and observe the same Run rather than dispatching a replacement.", + coding_agent_start_input_schema, + )), + PERMISSION_RISK_JOB, + ), + model_spec( + def( + "coding_agent_observe", + ModelVisible, + TOOL_CATEGORY_CODING_AGENT, + None, + TOOL_PROVIDER_AGENT, + ReadOnly, + Some(CODING_AGENT_RUN), + false, + NoPath, + false, + false, + ), + "Observe bounded normalized events and lifecycle for one existing CodingAgentRun. Return the opaque token for only-new follow-ups; history loss/reset is explicit. Observation never starts, retries, or resumes ACP work.", + coding_agent_observe_input_schema, + ), + effect_annotations( + model_spec( + def( + "coding_agent_cancel", + ModelVisible, + TOOL_CATEGORY_CODING_AGENT, + None, + TOOL_PROVIDER_AGENT, + // Cancel is Run lifecycle control but deliberately not a second + // WebCodex PermissionEvaluator decision after start admission. + ReadOnly, + Some(CODING_AGENT_RUN), + false, + NoPath, + false, + false, + ), + "Request cancellation of one existing CodingAgentRun. This does not grant permission, retry a prompt, or create a replacement Run; observe the same run_id for authoritative terminal state.", + coding_agent_cancel_input_schema, + ), + ToolEffectAnnotations { + read_only_hint: false, + destructive_hint: false, + idempotent_hint: true, + open_world_hint: false, + }, + ), +]; diff --git a/src/tool_runtime/tool_policy.rs b/src/tool_runtime/tool_policy.rs index d15f5ec5..3ea2daf8 100644 --- a/src/tool_runtime/tool_policy.rs +++ b/src/tool_runtime/tool_policy.rs @@ -2,9 +2,9 @@ use super::metadata::{tool_metadata, ToolMetadata, ToolPathHint, ToolRisk}; use super::tool_definition::{ - tool_definitions, AgentCapability, ToolDefinition, PERMISSION_RISK_ARTIFACT_WRITE, - PERMISSION_RISK_DESTRUCTIVE, PERMISSION_RISK_PATCH, PERMISSION_RISK_SHELL, - PERMISSION_RISK_VALIDATION, PERMISSION_RISK_WRITE, + tool_definitions, AgentCapability, ToolDefinition, ToolEffectAnnotations, + PERMISSION_RISK_ARTIFACT_WRITE, PERMISSION_RISK_DESTRUCTIVE, PERMISSION_RISK_PATCH, + PERMISSION_RISK_SHELL, PERMISSION_RISK_VALIDATION, PERMISSION_RISK_WRITE, }; impl ToolDefinition { @@ -12,6 +12,17 @@ impl ToolDefinition { self.metadata } + pub(crate) fn effect_annotations(self) -> ToolEffectAnnotations { + self.policy + .effect_annotations + .unwrap_or(ToolEffectAnnotations { + read_only_hint: self.metadata.read_only, + destructive_hint: self.metadata.destructive, + idempotent_hint: self.metadata.read_only, + open_world_hint: self.metadata.shell_like, + }) + } + pub(crate) fn session_risk_class(self) -> &'static str { self.metadata.risk.session_risk_class() } @@ -73,6 +84,7 @@ impl ToolDefinition { pub(crate) fn allows_current_session_fallback(self) -> bool { self.metadata.requires_project + && !self.policy.current_session_fallback_disabled && !self.is_current_session_control() && !self.requires_explicit_business_session() && !self.creates_or_binds_session() @@ -163,6 +175,18 @@ pub(crate) fn runtime_tool_metadata(name: &str) -> ToolMetadata { } } +pub(crate) fn runtime_tool_effect_annotations(name: &str) -> ToolEffectAnnotations { + match definition_or_metadata_facade(name) { + Ok(definition) => definition.effect_annotations(), + Err(metadata) => ToolEffectAnnotations { + read_only_hint: metadata.read_only, + destructive_hint: metadata.destructive, + idempotent_hint: metadata.read_only, + open_world_hint: metadata.shell_like, + }, + } +} + pub(crate) fn runtime_tool_agent_capability(name: &str) -> Option { lookup_tool_definition(name) .unwrap_or_else(|| panic!("missing ToolDefinition for {name}"))