From 2ca541e0a28c093cb0c11fad5b97c75f8734762d Mon Sep 17 00:00:00 2001 From: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:59:50 +0900 Subject: [PATCH 01/16] security(desktop): bump tauri-plugin-updater to 2.14.1 for zip CVE-2026-115905 zip 4.6.1 (critical Zip Cloaking / arbitrary file overwrite) reached the Windows update-extraction path via tauri-plugin-updater 2.9.0, which requires zip ^4. The fix needs updater >=2.14.1 (zip ^9) and tauri ^2.12, so the exact-pin chain moves together: tauri 2.12.5, tauri-build 2.7.4, tauri-utils 2.10.2. Lockfile regenerated; zip resolves to 9.1.3 and the refresh also picks up rustls 0.23.46 (dependabot alerts #5-#8). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- desktop/src-tauri/Cargo.lock | 1216 +++++++++++++++++++--------------- desktop/src-tauri/Cargo.toml | 8 +- 2 files changed, 673 insertions(+), 551 deletions(-) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index ed14eef8e8a..9c9a7ff065f 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "addr2line" -version = "0.25.1" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +checksum = "e567177890eb1617b1f774005b66b26b2377afd138a2ca37aae7d8f0c81429d4" dependencies = [ "gimli", ] @@ -28,25 +28,19 @@ dependencies = [ [[package]] name = "alloc-no-stdlib" -version = "2.0.4" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" [[package]] name = "alloc-stdlib" -version = "0.2.4" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" dependencies = [ "alloc-no-stdlib", ] -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.6" @@ -62,15 +56,6 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - [[package]] name = "async-broadcast" version = "0.7.2" @@ -158,13 +143,13 @@ dependencies = [ [[package]] name = "async-recursion" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -199,7 +184,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -250,14 +235,14 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "backtrace" -version = "0.3.76" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +checksum = "42f4220217f3288537b83684130f42fad43320b1c3b805f1910d683d52ded24f" dependencies = [ "addr2line", "cfg-if", "libc", - "miniz_oxide 0.8.9", + "miniz_oxide 0.9.1", "object", "rustc-demangle", "windows-link 0.2.1", @@ -304,11 +289,11 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.4" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -344,9 +329,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.4" +version = "9.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +checksum = "627b7cfb0578f0eafce087a90fcd40add1231900b69e4d4de2d456260a4f5d81" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -355,9 +340,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.3" +version = "6.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +checksum = "34c6783f42c21c952ad7112b354f0ba2f10760e9decea5b1b60523d6e270faaf" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -411,7 +396,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "cairo-sys-rs", "glib", "libc", @@ -432,11 +417,11 @@ dependencies = [ [[package]] name = "camino" -version = "1.1.12" +version = "1.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd0b03af37dad7a14518b7691d81acb0f8222604ad3d1b02f6b4bed5188c0cd5" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -459,24 +444,25 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] name = "cargo_toml" -version = "0.22.3" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +checksum = "82f4b26e751e711a5302649417f2da046dce6391b2ea30a4820f37462314f0b9" dependencies = [ + "semver", "serde", - "toml 0.9.5", + "toml 1.1.8+spec-1.1.0", ] [[package]] name = "cc" -version = "1.4.7" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" dependencies = [ "find-msvc-tools", "shlex", @@ -490,13 +476,13 @@ checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" [[package]] name = "cfb" -version = "0.7.3" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3" dependencies = [ - "byteorder", "fnv", "uuid", + "web-time", ] [[package]] @@ -534,22 +520,21 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.41" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ - "android-tzdata", "iana-time-zone", "num-traits", "serde", - "windows-link 0.1.3", + "windows-link 0.2.1", ] [[package]] name = "combine" -version = "4.6.8" +version = "4.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" dependencies = [ "bytes", "memchr", @@ -574,6 +559,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -596,8 +591,8 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.9.4", - "core-foundation", + "bitflags 2.13.2", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -609,8 +604,8 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.9.4", - "core-foundation", + "bitflags 2.13.2", + "core-foundation 0.10.1", "libc", ] @@ -640,9 +635,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" dependencies = [ "cfg-if", ] @@ -674,9 +669,9 @@ dependencies = [ [[package]] name = "cssparser" -version = "0.36.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" dependencies = [ "cssparser-macros", "dtoa-short", @@ -687,29 +682,19 @@ dependencies = [ [[package]] name = "cssparser-macros" -version = "0.6.1" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" dependencies = [ "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] name = "ctor" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" -dependencies = [ - "ctor-proc-macro", - "dtor", -] - -[[package]] -name = "ctor-proc-macro" -version = "0.0.7" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" [[package]] name = "darling" @@ -731,7 +716,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -742,7 +727,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -758,9 +743,9 @@ dependencies = [ [[package]] name = "defmt" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" dependencies = [ "bitflags 1.3.2", "defmt-macros", @@ -768,45 +753,34 @@ dependencies = [ [[package]] name = "defmt-macros" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" dependencies = [ "defmt-parser", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] name = "defmt-parser" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" dependencies = [ - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] name = "deranged" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" dependencies = [ "serde_core", ] -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "derive_more" version = "2.1.1" @@ -850,9 +824,9 @@ dependencies = [ [[package]] name = "dirs" -version = "6.0.0" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" dependencies = [ "dirs-sys 0.5.0", ] @@ -886,7 +860,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "block2", "libc", "objc2", @@ -900,7 +874,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -928,9 +902,9 @@ dependencies = [ [[package]] name = "dom_query" -version = "0.27.0" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc" dependencies = [ "bit-set", "cssparser", @@ -965,21 +939,6 @@ dependencies = [ "dtoa", ] -[[package]] -name = "dtor" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" - [[package]] name = "dunce" version = "1.0.5" @@ -994,16 +953,16 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "embed-resource" -version = "3.0.8" +version = "3.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45" +checksum = "6168487b5e7eb2673e6efe1200b1848016e436a6ca0b8031bf04bd9d493a249f" dependencies = [ "cc", "memchr", "rustc_version", - "toml 0.9.5", + "toml 1.1.8+spec-1.1.0", "vswhom", - "winreg 0.55.0", + "winreg 0.56.0", ] [[package]] @@ -1014,13 +973,12 @@ checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" [[package]] name = "encoding_rs" -version = "0.8.41" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" dependencies = [ "cfg-if", "core_detect", - "multiversion", "multiversion_no_op", "rustversion", "scopeguard", @@ -1056,17 +1014,18 @@ dependencies = [ [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "erased-serde" -version = "0.4.6" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7" +checksum = "1e59b668157f32d3459d4bd634861a06ffa2503a461ca5f0916b9f0252605e66" dependencies = [ "serde", + "serde_core", "typeid", ] @@ -1137,9 +1096,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.13" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "flate2" @@ -1182,7 +1141,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1253,7 +1212,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1434,9 +1393,9 @@ dependencies = [ [[package]] name = "gimli" -version = "0.32.3" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +checksum = "1033caf0b349c518623b5396bfb2cf0bddf44f0306d543a250e5743297aafd10" [[package]] name = "gio" @@ -1476,7 +1435,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "futures-channel", "futures-core", "futures-executor", @@ -1633,9 +1592,9 @@ dependencies = [ [[package]] name = "html5ever" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" dependencies = [ "log", "markup5ever", @@ -1682,13 +1641,12 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hyper" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", - "futures-channel", "futures-core", "http", "http-body", @@ -1702,9 +1660,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http", "hyper", @@ -1718,25 +1676,28 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http", "http-body", + "httparse", "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", "socket2 0.6.5", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1765,19 +1726,19 @@ dependencies = [ [[package]] name = "ico" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +checksum = "d75e94d66081e4d8dcd098bd8dc5254800fea14a26389b298410158d834de167" dependencies = [ "byteorder", - "png 0.17.16", + "png", ] [[package]] name = "icu_collections" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" dependencies = [ "displaydoc", "potential_utf", @@ -1789,9 +1750,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" dependencies = [ "displaydoc", "litemap", @@ -1802,9 +1763,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1816,15 +1777,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" [[package]] name = "icu_properties" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" dependencies = [ "displaydoc", "icu_collections", @@ -1837,15 +1798,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" [[package]] name = "icu_provider" -version = "2.3.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" dependencies = [ "displaydoc", "icu_locale_core", @@ -1893,7 +1854,7 @@ dependencies = [ "byteorder-lite", "moxcms", "num-traits", - "png 0.18.1", + "png", ] [[package]] @@ -1921,9 +1882,9 @@ dependencies = [ [[package]] name = "infer" -version = "0.19.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4" dependencies = [ "cfb", ] @@ -1984,9 +1945,9 @@ dependencies = [ [[package]] name = "jiff" -version = "0.2.37" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" dependencies = [ "defmt", "jiff-core", @@ -2011,9 +1972,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.37" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" dependencies = [ "jiff-core", "proc-macro2", @@ -2023,9 +1984,9 @@ dependencies = [ [[package]] name = "jiff-tzdb" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" [[package]] name = "jiff-tzdb-platform" @@ -2052,6 +2013,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -2082,9 +2073,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -2093,21 +2084,21 @@ dependencies = [ [[package]] name = "json-patch" -version = "3.0.1" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72" dependencies = [ "jsonptr", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.21", ] [[package]] name = "jsonptr" -version = "0.6.3" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" dependencies = [ "serde", "serde_json", @@ -2115,13 +2106,12 @@ dependencies = [ [[package]] name = "keyboard-types" -version = "0.7.0" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "serde", - "unicode-segmentation", ] [[package]] @@ -2150,9 +2140,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.189" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libdbus-sys" @@ -2175,9 +2165,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.24" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ "libc", ] @@ -2217,9 +2207,9 @@ checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] name = "markup5ever" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" dependencies = [ "log", "tendril", @@ -2249,9 +2239,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "minisign-verify" -version = "0.2.5" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" +checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce" [[package]] name = "miniz_oxide" @@ -2275,9 +2265,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.3" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "wasi", @@ -2296,9 +2286,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.19.3" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +checksum = "cca139e57da4383727f189e43e66e84fa372347027fc996f9f1a007ec0a37996" dependencies = [ "crossbeam-channel", "dpi", @@ -2309,33 +2299,12 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "once_cell", - "png 0.18.1", + "png", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", "windows-sys 0.61.2", ] -[[package]] -name = "multiversion" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" -dependencies = [ - "multiversion-macros", -] - -[[package]] -name = "multiversion-macros" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" -dependencies = [ - "proc-macro2", - "quote", - "rustversion", - "syn 3.0.6", -] - [[package]] name = "multiversion_no_op" version = "1.0.0" @@ -2348,7 +2317,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "jni-sys 0.3.1", "log", "ndk-sys", @@ -2357,6 +2326,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -2372,6 +2347,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2403,7 +2390,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.119", @@ -2411,9 +2398,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", "objc2-exception-helper", @@ -2425,11 +2412,12 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "block2", "objc2", "objc2-core-foundation", "objc2-foundation", + "objc2-quartz-core", ] [[package]] @@ -2438,7 +2426,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "objc2", "objc2-foundation", ] @@ -2459,7 +2447,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "dispatch2", "objc2", ] @@ -2470,7 +2458,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "dispatch2", "objc2", "objc2-core-foundation", @@ -2503,7 +2491,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -2530,7 +2518,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "block2", "libc", "objc2", @@ -2543,7 +2531,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "objc2", "objc2-core-foundation", ] @@ -2554,7 +2542,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "objc2", "objc2-app-kit", "objc2-foundation", @@ -2566,7 +2554,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "objc2", "objc2-core-foundation", "objc2-foundation", @@ -2578,7 +2566,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "block2", "objc2", "objc2-cloud-kit", @@ -2609,7 +2597,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "block2", "objc2", "objc2-app-kit", @@ -2619,9 +2607,9 @@ dependencies = [ [[package]] name = "object" -version = "0.37.3" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +checksum = "dd229a0361b9d0d4396176e02d65897f487eebeab7caa6d443855ee152ca0b9c" dependencies = [ "memchr", ] @@ -2634,9 +2622,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "open" -version = "5.4.4" +version = "5.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95" dependencies = [ "dunce", "is-wsl", @@ -2668,6 +2656,12 @@ dependencies = [ "winreg 0.55.0", ] +[[package]] +name = "openssl-probe" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" + [[package]] name = "option-ext" version = "0.2.0" @@ -2705,7 +2699,7 @@ dependencies = [ "objc2-osa-kit", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -2846,37 +2840,24 @@ checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "plist" -version = "1.8.0" +version = "1.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "indexmap 2.14.2", "quick-xml", "serde", "time", ] -[[package]] -name = "png" -version = "0.17.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" -dependencies = [ - "bitflags 1.3.2", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide 0.8.9", -] - [[package]] name = "png" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "crc32fast", "fdeflate", "flate2", @@ -2923,9 +2904,9 @@ dependencies = [ [[package]] name = "powerfmt" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" [[package]] name = "precomputed-hash" @@ -2955,11 +2936,11 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.23.4", + "toml_edit 0.25.17+spec-1.1.0", ] [[package]] @@ -3003,9 +2984,9 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" dependencies = [ "memchr", ] @@ -3024,7 +3005,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2 0.6.5", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", "web-time", @@ -3032,9 +3013,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.18" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ "bytes", "getrandom 0.4.3", @@ -3046,7 +3027,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.20", + "thiserror 2.0.21", "tinyvec", "tracing", "web-time", @@ -3054,9 +3035,9 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" dependencies = [ "cfg_aliases", "libc", @@ -3089,9 +3070,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -3125,7 +3106,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", ] [[package]] @@ -3146,7 +3127,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" dependencies = [ "libredox", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -3166,7 +3147,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -3207,7 +3188,6 @@ dependencies = [ "base64 0.22.1", "bytes", "futures-core", - "futures-util", "http", "http-body", "http-body-util", @@ -3227,14 +3207,12 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", - "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams 0.4.2", "web-sys", "webpki-roots", ] @@ -3253,15 +3231,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3269,7 +3252,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams 0.5.0", + "wasm-streams", "web-sys", ] @@ -3310,11 +3293,11 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.5" +version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", @@ -3323,9 +3306,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "once_cell", "ring", @@ -3335,6 +3318,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -3345,11 +3340,38 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -3377,6 +3399,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3406,9 +3437,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" dependencies = [ "dyn-clone", "ref-cast", @@ -3434,13 +3465,36 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" -version = "0.36.1" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "cssparser", "derive_more", "log", @@ -3455,11 +3509,12 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.26" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" dependencies = [ "serde", + "serde_core", ] [[package]] @@ -3474,12 +3529,13 @@ dependencies = [ [[package]] name = "serde-untagged" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34836a629bcbc6f1afdf0907a744870039b1e14c0561cb26094fa683b158eff3" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" dependencies = [ "erased-serde", "serde", + "serde_core", "typeid", ] @@ -3500,7 +3556,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -3535,7 +3591,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -3549,11 +3605,11 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.0" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -3570,9 +3626,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" dependencies = [ "base64 0.23.1", "bs58", @@ -3582,7 +3638,7 @@ dependencies = [ "indexmap 2.14.2", "jiff", "schemars 0.9.0", - "schemars 1.2.2", + "schemars 1.2.3", "serde_core", "serde_json", "serde_with_macros", @@ -3591,14 +3647,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" dependencies = [ "darling", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -3673,9 +3729,9 @@ dependencies = [ [[package]] name = "signal-hook" -version = "0.4.4" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" +checksum = "b29ffa27d3d59a049bb328258de100f8012848d1b08d444e08bbc02ac468435e" dependencies = [ "libc", "signal-hook-registry", @@ -3697,6 +3753,16 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -3705,9 +3771,9 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] name = "slab" @@ -3717,9 +3783,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.1" +version = "1.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" [[package]] name = "socket2" @@ -3795,6 +3861,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_vcruntime" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7589a1859b09232522bb9edf076ca7fcf14ded96685d3fa629db73023ffbb7" + [[package]] name = "string_cache" version = "0.9.0" @@ -3833,9 +3905,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "swift-rs" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e45c444e496845d3f2a351146bff59aae4975b2280238df1dfaa0c7d1846f38e" +checksum = "cefd87076fd385308ee4aff597256c902f1fbb4d031de1558efc12f8ffefadd9" dependencies = [ "base64 0.21.7", "serde", @@ -3865,9 +3937,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -3891,7 +3963,28 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", ] [[package]] @@ -3909,13 +4002,13 @@ dependencies = [ [[package]] name = "tao" -version = "0.35.3" +version = "0.37.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +checksum = "d5777b06fe6435126ae8d816eba269e050e1e10d302cf5461e9c49eccabc3548" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dbus", @@ -3925,24 +4018,24 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", + "ndk-context", "ndk-sys", "objc2", "objc2-app-kit", "objc2-foundation", "objc2-ui-kit", - "once_cell", "parking_lot", "percent-encoding", "raw-window-handle", "tao-macros", "unicode-segmentation", "url", - "windows", - "windows-core 0.61.2", + "windows 0.62.2", + "windows-core 0.62.2", "windows-version", "x11-dl", ] @@ -3977,14 +4070,14 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.11.6" +version = "2.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fa5bacdb9bbad5954af3d1bd6cf6ae9192cab1b2e270f4a07f904610b9e85f4" +checksum = "5fb0244332cfb9cee7e2a568bf5da29a36ed8fdbd717f6f47a66b6b7b358c072" dependencies = [ "anyhow", "bytes", "cookie", - "dirs 6.0.0", + "dirs 7.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -3993,7 +4086,7 @@ dependencies = [ "heck 0.5.0", "http", "image", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -4017,25 +4110,25 @@ dependencies = [ "tauri-runtime", "tauri-runtime-wry", "tauri-utils", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tray-icon", "url", "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.62.2", ] [[package]] name = "tauri-build" -version = "2.6.3" +version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +checksum = "adddb0e648752aaac5da9c3c3c6a68e1e87cab55e3b757b18a9441ef8c3ac614" dependencies = [ "anyhow", "cargo_toml", - "dirs 6.0.0", + "dirs 7.0.0", "glob", "heck 0.5.0", "json-patch", @@ -4043,6 +4136,8 @@ dependencies = [ "semver", "serde", "serde_json", + "static_vcruntime", + "strsim", "tauri-utils", "tauri-winres", "walkdir", @@ -4050,16 +4145,16 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.6.3" +version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +checksum = "fb37631cf71dab5e8ab55e1bdd701e18a52653d90b78e71d0e5598b1e0a61316" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "brotli", "ico", "json-patch", "plist", - "png 0.17.16", + "png", "proc-macro2", "quote", "semver", @@ -4068,7 +4163,7 @@ dependencies = [ "sha2", "syn 2.0.119", "tauri-utils", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", "url", "uuid", @@ -4077,9 +4172,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.6.3" +version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +checksum = "0cc38db58854c10fb5f91a887ed8bf82d3239bb92b5d8db86eda09e96d9963d1" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -4091,9 +4186,9 @@ dependencies = [ [[package]] name = "tauri-plugin" -version = "2.6.3" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +checksum = "67fb21a77e8f94e8beb3325e583102488659365af0a3948b7ea244c5b779c291" dependencies = [ "anyhow", "glob", @@ -4116,7 +4211,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -4135,9 +4230,9 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.20", + "thiserror 2.0.21", "url", - "windows", + "windows 0.61.3", "zbus", ] @@ -4168,7 +4263,7 @@ dependencies = [ "shared_child", "tauri", "tauri-plugin", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", ] @@ -4181,7 +4276,7 @@ dependencies = [ "serde", "serde_json", "tauri", - "thiserror 2.0.20", + "thiserror 2.0.21", "tracing", "windows-sys 0.60.2", "zbus", @@ -4189,21 +4284,23 @@ dependencies = [ [[package]] name = "tauri-plugin-updater" -version = "2.9.0" +version = "2.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b" +checksum = "d957abbcb3f9f6c76d22a3a0f012f30880640d0bccb5634d2b12a9937e4c872b" dependencies = [ - "base64 0.22.1", - "dirs 6.0.0", + "base64 0.23.1", + "dirs 7.0.0", "flate2", "futures-util", "http", "infer", + "libc", "log", "minisign-verify", "osakit", "percent-encoding", - "reqwest 0.12.24", + "reqwest 0.13.5", + "rustls", "semver", "serde", "serde_json", @@ -4211,25 +4308,25 @@ dependencies = [ "tauri", "tauri-plugin", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", "tokio", "url", - "windows-sys 0.60.2", + "windows-sys 0.61.2", "zip", ] [[package]] name = "tauri-runtime" -version = "2.11.3" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +checksum = "7c55766d99cea69055904d1735b93c2049302b2e09effd462120deb5581d78e9" dependencies = [ "cookie", "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -4237,26 +4334,25 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "thiserror 2.0.20", + "thiserror 2.0.21", "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.62.2", ] [[package]] name = "tauri-runtime-wry" -version = "2.11.4" +version = "2.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +checksum = "e1aa9d8b7a120f879f44fdc1053f7b7dbf416bf0bbc6a72b714dec46b782b52f" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", - "once_cell", "percent-encoding", "raw-window-handle", "softbuffer", @@ -4266,15 +4362,15 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.62.2", "wry", ] [[package]] name = "tauri-utils" -version = "2.9.3" +version = "2.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +checksum = "362ab21cd4cc713f2a02b1a9b5688198b1541cfb6907c4281e9190e0f0ef4aa3" dependencies = [ "anyhow", "brotli", @@ -4300,8 +4396,8 @@ dependencies = [ "serde_json", "serde_with", "swift-rs", - "thiserror 2.0.20", - "toml 0.9.5", + "thiserror 2.0.21", + "toml 1.1.8+spec-1.1.0", "url", "urlpattern", "uuid", @@ -4310,13 +4406,13 @@ dependencies = [ [[package]] name = "tauri-winres" -version = "0.3.5" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" +checksum = "c52bb6904475a6ca662f15ef787f18ade61d5740a1ee12c45d3b748efdb6f796" dependencies = [ "dunce", "embed-resource", - "toml 0.9.5", + "toml 1.1.8+spec-1.1.0", ] [[package]] @@ -4352,11 +4448,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.20", + "thiserror-impl 2.0.21", ] [[package]] @@ -4372,13 +4468,13 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4413,9 +4509,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ "displaydoc", "zerovec", @@ -4444,9 +4540,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.5" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", @@ -4479,17 +4575,17 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.5" +version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" dependencies = [ "indexmap 2.14.2", - "serde", - "serde_spanned 1.0.0", - "toml_datetime 0.7.0", + "serde_core", + "serde_spanned 1.1.2", + "toml_datetime 1.1.2+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 0.7.15", + "winnow 1.0.4", ] [[package]] @@ -4503,11 +4599,11 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -4536,30 +4632,30 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.23.4" +version = "0.25.17+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7211ff1b8f0d3adae1663b7da9ffe396eabe1ca25f0b0bee42b0da29a9ddce93" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" dependencies = [ "indexmap 2.14.2", - "toml_datetime 0.7.0", + "toml_datetime 1.1.2+spec-1.1.0", "toml_parser", - "winnow 0.7.15", + "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow 1.0.4", ] [[package]] name = "toml_writer" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" [[package]] name = "tower" @@ -4582,7 +4678,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.2", "bytes", "futures-util", "http", @@ -4639,12 +4735,12 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.24.2" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" +checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72" dependencies = [ "crossbeam-channel", - "dirs 6.0.0", + "dirs 7.0.0", "libappindicator", "muda", "objc2", @@ -4653,9 +4749,9 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation", "once_cell", - "png 0.18.1", + "png", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", "windows-sys 0.61.2", ] @@ -4665,6 +4761,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" + [[package]] name = "typeid" version = "1.0.3" @@ -4688,52 +4790,11 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "unic-char-property" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" -dependencies = [ - "unic-char-range", -] - -[[package]] -name = "unic-char-range" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" - -[[package]] -name = "unic-common" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" - -[[package]] -name = "unic-ucd-ident" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" -dependencies = [ - "unic-char-property", - "unic-char-range", - "unic-ucd-version", -] - -[[package]] -name = "unic-ucd-version" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" -dependencies = [ - "unic-common", -] - [[package]] name = "unicode-ident" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-segmentation" @@ -4762,13 +4823,13 @@ dependencies = [ [[package]] name = "urlpattern" -version = "0.3.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +checksum = "df16f50ef4cc145211879a3867ba757076b25dfee812040dcb0658bd9ae7904b" dependencies = [ + "icu_properties", "regex", "serde", - "unic-ucd-ident", "url", ] @@ -4834,9 +4895,9 @@ dependencies = [ [[package]] name = "want" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ "try-lock", ] @@ -4858,9 +4919,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -4871,19 +4932,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.78" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4891,39 +4953,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-streams" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "wasm-streams" version = "0.5.0" @@ -4939,9 +4988,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -5013,27 +5062,34 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" dependencies = [ "rustls-pki-types", ] [[package]] name = "webview2-com" -version = "0.38.2" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", - "windows-core 0.61.2", - "windows-implement", - "windows-interface", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] @@ -5049,13 +5105,13 @@ dependencies = [ [[package]] name = "webview2-com-sys" -version = "0.38.2" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +checksum = "b3a07132775117d6065853d9d1178157b8c90e228de47129d6bce2c7edebedfb" dependencies = [ - "thiserror 2.0.20", - "windows", - "windows-core 0.61.2", + "thiserror 2.0.21", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] @@ -5091,16 +5147,17 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "window-vibrancy" -version = "0.6.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +checksum = "111e51caca442cafd9bab396628ac4d814880eaea4e63dfd76a12e9f342b5580" dependencies = [ "objc2", "objc2-app-kit", "objc2-core-foundation", "objc2-foundation", + "objc2-quartz-core", "raw-window-handle", - "windows-sys 0.59.0", + "windows-sys 0.61.2", "windows-version", ] @@ -5110,11 +5167,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -5126,6 +5195,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -5160,7 +5238,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -5207,6 +5296,27 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -5345,6 +5455,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-version" version = "0.1.7" @@ -5501,15 +5620,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - [[package]] name = "winnow" version = "1.0.4" @@ -5538,6 +5648,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "winreg" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + [[package]] name = "wit-bindgen" version = "0.57.1" @@ -5552,15 +5672,15 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wry" -version = "0.55.1" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +checksum = "759c50ddad0ef9913bb5496cb9d509a50433a3b78cb0238d61105526589cf0e9" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "block2", "cookie", "crossbeam-channel", - "dirs 6.0.0", + "dirs 7.0.0", "dom_query", "dpi", "dunce", @@ -5568,7 +5688,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -5577,19 +5697,18 @@ dependencies = [ "objc2-foundation", "objc2-ui-kit", "objc2-web-kit", - "once_cell", "percent-encoding", "raw-window-handle", "sha2", "soup3", "tao-macros", - "thiserror 2.0.20", + "thiserror 2.0.21", "url", "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", - "windows-core 0.61.2", + "windows 0.62.2", + "windows-core 0.62.2", "windows-version", "x11-dl", ] @@ -5617,9 +5736,9 @@ dependencies = [ [[package]] name = "xattr" -version = "1.6.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +checksum = "963d6fab92d5504b971dc8bd90ca743c2575e590bc8071ea5bc571598667ae08" dependencies = [ "libc", "rustix", @@ -5627,9 +5746,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -5638,13 +5757,13 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] @@ -5689,10 +5808,10 @@ version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "zbus_names", "zvariant", "zvariant_utils", @@ -5700,9 +5819,9 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.4" +version = "4.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" dependencies = [ "serde", "winnow 1.0.4", @@ -5735,15 +5854,15 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] [[package]] name = "zeroize" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" [[package]] name = "zerotrie" @@ -5758,9 +5877,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ "yoke", "zerofrom", @@ -5769,25 +5888,28 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "zip" -version = "4.6.1" +version = "9.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb" dependencies = [ - "arbitrary", "crc32fast", + "getrandom 0.4.3", "indexmap 2.14.2", "memchr", + "nix", + "typed-path", + "windows-sys 0.61.2", ] [[package]] @@ -5804,9 +5926,9 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zvariant" -version = "5.15.0" +version = "5.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" dependencies = [ "endi", "enumflags2", @@ -5819,14 +5941,14 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.15.0" +version = "5.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "zvariant_utils", ] @@ -5839,6 +5961,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 3.0.6", + "syn 3.0.9", "winnow 1.0.4", ] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 322c1d8f7a4..ff3c0e2cfac 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -12,7 +12,7 @@ name = "opencodex_desktop_lib" crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] -tauri-build = { version = "=2.6.3", features = [] } +tauri-build = { version = "=2.7.4", features = [] } [dependencies] base64 = "=0.22.1" @@ -22,14 +22,14 @@ serde = { version = "=1.0.229", features = ["derive"] } serde_json = "=1.0.151" sha2 = "=0.10.9" uuid = { version = "=1.18.1", features = ["v4"] } -tauri = { version = "=2.11.6", features = ["tray-icon", "image-png", "macos-private-api"] } -tauri-utils = "=2.9.3" +tauri = { version = "=2.12.5", features = ["tray-icon", "image-png", "macos-private-api"] } +tauri-utils = "=2.10.2" tauri-plugin-autostart = "=2.5.0" tauri-plugin-opener = "=2.5.3" tauri-plugin-process = "=2.3.0" tauri-plugin-shell = "=2.2.1" tauri-plugin-single-instance = "=2.4.0" -tauri-plugin-updater = "=2.9.0" +tauri-plugin-updater = "=2.14.1" tokio = { version = "=1.45.1", features = ["sync", "time"] } # Linux only, and already in this graph: `tao` enables its own `dbus` feature by default, so From bed14bf9eece188cc80175de7811fcc669efa831 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Tue, 3 Nov 2026 09:04:10 +0000 Subject: [PATCH 02/16] fix(cli): attest Codex listener before token injection (#8007) * fix(cli): attest Codex listener before token injection * fix(cli): bind listener proof to the Codex destination Carry the actual challenged origin through credential admission and compare it with the final standalone or direct-client destination before loading a token. Preserve existing launch guards and cover port, address-family and proof snapshot behavior with the real attestation verifier and synthetic responses. * fix(cli): keep direct-client launches on their own listener proof A direct client's ownership proof comes from its machine listener, while Codex dials the hub URL, so comparing the two always withheld the client credential. Bind the proof origin to the destination only for standalone installations; direct clients still require a proven local runtime and keep their connection-state and loopback checks. * fix(cli): require the client listener role for direct-client proofs A direct client's credential could be authorized by any listener of the same home that answered the ownership challenge. Carry the proven listener's client role into the proof and refuse direct-client injection without it. * Revert the client-role requirement for direct-client proofs The role a listener reports is not covered by the ownership proof, so requiring it added no authenticated guarantee. Binding the role would need a change to the attestation format shared with the desktop app. Direct clients keep the home-ownership proof, which dev does not require at all. * docs(cli): state what the direct-client listener proof covers * fix(cli): prove a localhost listener at the address it binds A configured localhost binds 127.0.0.1, which is also what the standalone Codex target dials; probing the raw name produced a different origin and withheld the automatic token. --------- Co-authored-by: JUN --- .../src/content/docs/reference/cli/agents.md | 2 + scripts/test-layout/layout-clients.json | 1 + src/cli/codex-launch-auth.ts | 31 ++- src/cli/codex-launcher.ts | 17 +- structure/cli-management.md | 2 +- .../codex-launcher-admission.test.ts | 2 +- .../codex-launcher-diagnostics.test.ts | 8 +- .../codex-launcher-endpoint.test.ts | 181 ++++++++++++++++++ .../codex-launcher-env.test.ts | 6 +- .../codex-launcher-security.test.ts | 12 +- .../codex-integration/codex-launcher.test.ts | 13 +- tests/fixtures/test-layout-expected.json | 1 + tests/helpers/codex-launch-private-fixture.ts | 6 +- 13 files changed, 258 insertions(+), 24 deletions(-) create mode 100644 tests/codex-integration/codex-launcher-endpoint.test.ts diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 11b7f0269cf..8d84eb48a54 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -990,6 +990,8 @@ Alternatively, supply the key yourself, for example by exporting `OPENCODEX_API_ Use `--` before a single-word prompt so it cannot select an unknown Codex subcommand. +Automatic credentials also require a fresh ownership proof from this installation's running listener. For a standalone proxy, the proof must come from the exact HTTP origin in the managed Codex provider configuration; a running proxy on a different port or loopback address does not authorize that destination. A direct client proves its own machine listener and keeps its saved server URL checks, because the server it dials is a different installation. For a direct client the proof shows that the answering listener belongs to this installation; it does not prove which kind of listener it is. Without a matching proof, the launcher withholds the automatic token and leaves routing unchanged. + The machine configuration namespace must also prevent other users from creating missing system files. An unprotected Windows ProgramData/OpenAI/Codex namespace withholds automatic credentials; the launcher does not create it. macOS accepts deny-only and read-only allow ACLs, but refuses any allow entry granting write, append, delete, attribute/security changes or ownership changes, even after a deny entry. Unknown tags and inspection errors refuse. A fresh service token is loaded only when argv is within the allowlist below, the environment has no nonempty HTTP(S)/ALL proxy or `CODEX_*`/`OPENAI_*`/`AZURE_OPENAI_*` overrides (canonical `CODEX_HOME` is allowed), and the home/config/system namespaces pass ownership checks. The configuration must contain only audited keys and the exact managed loopback provider, with no additional credential consumers or configuration layers. Every unsupported argument, including `-c`/`--config`, `-p`/`--profile`, unknown flags and `resume`/`fork`, withholds the token with a value-free diagnostic; explicit user credentials and original arguments are preserved. diff --git a/scripts/test-layout/layout-clients.json b/scripts/test-layout/layout-clients.json index 01a3abb4020..966fb2799a4 100644 --- a/scripts/test-layout/layout-clients.json +++ b/scripts/test-layout/layout-clients.json @@ -1,5 +1,6 @@ { "omo-responses-loop.test.ts": "clients", + "codex-launcher-endpoint.test.ts": "codex-integration", "claude-translated-tool-restrictions.test.ts": "claude-integration", "windows-config-root-async-writers.test.ts": "windows", "t3code-client-lifecycle.test.ts": "clients", diff --git a/src/cli/codex-launch-auth.ts b/src/cli/codex-launch-auth.ts index 9d825b81454..3d0dde1e568 100644 --- a/src/cli/codex-launch-auth.ts +++ b/src/cli/codex-launch-auth.ts @@ -7,10 +7,34 @@ import { readBoundedCodexConfig } from "../codex/inject/bounded-config-reader"; import { buildProviderTableBlockForTarget } from "../codex/inject/config-toml"; import { standaloneCodexRoutingTarget, validateCodexRoutingTarget } from "../codex/inject/routing-target"; import { readClientConnectionState } from "../client/state"; +import { normalizeBindHostname } from "../server/bind-host"; +import { probeHostname, proveLiveProxyOwnedByHome, type LiveProxy } from "../server/proxy-liveness"; import { codexHasOtherTokenConsumers, codexTokenConfigShapeIsSafe } from "./codex-launch-policy"; import { codexLaunchHomeIsOwned, codexLaunchProjectAliasIsOwned, codexLaunchSystemNamespaceIsSafe } from "./codex-launch-ownership"; import type { CodexTokenRefusal, CodexTokenRefusalReporter } from "./codex-launch-diagnostics"; +export type CodexRuntimeAttestation = Readonly<{ origin: string }>; + +/** Retain the exact dial endpoint that answered the ownership challenge, not just its verdict. */ +export async function attestCodexRuntime( + live: LiveProxy, + prove: typeof proveLiveProxyOwnedByHome = proveLiveProxyOwnedByHome, +): Promise { + // Probe the address the listener actually binds: a configured localhost binds 127.0.0.1, + // which is also what the standalone Codex target dials. + const snapshot = Object.freeze({ ...live, probeHostname: normalizeBindHostname(live.probeHostname ?? live.hostname) }); + try { + const origin = new URL(`http://${probeHostname(snapshot.probeHostname)}:${snapshot.port}`).origin; + return await prove(snapshot) === "proven" ? Object.freeze({ origin }) : null; + } catch { return null; } +} + +/** IPv4 and IPv6 listeners are distinct recipients, even when their ports are equal. */ +export function codexRuntimeAttestationMatchesTarget(baseUrl: string, proof: CodexRuntimeAttestation | null | undefined): boolean { + try { return proof !== null && proof !== undefined && new URL(baseUrl).origin === proof.origin; } + catch { return false; } +} + /** Resolve once; the credential verifier and child must use this exact directory. */ export function resolveCodexLaunchHome(env: Record): string | null { try { @@ -28,7 +52,7 @@ export function resolveCodexLaunchHome(env: Record): } /** Config classification is a startup hint; credentials need an independently bound destination. */ -export function codexCredentialDestinationIsTrusted(home: string, systemNamespaceDirectory?: string, report?: CodexTokenRefusalReporter): boolean { +export function codexCredentialDestinationIsTrusted(home: string, systemNamespaceDirectory?: string, report?: CodexTokenRefusalReporter, runtimeAttestation?: CodexRuntimeAttestation | null): boolean { const refuse = (reason: CodexTokenRefusal): false => { report?.(reason); return false; }; try { if (!codexLaunchHomeIsOwned(home)) return refuse("ownership"); @@ -43,15 +67,20 @@ export function codexCredentialDestinationIsTrusted(home: string, systemNamespac if (diagnostics.error || diagnostics.source === "fallback") return refuse("installationConfig"); const cfg = diagnostics.config; let target = standaloneCodexRoutingTarget(cfg.port, cfg); + // A direct client's proof comes from its own machine listener, never from the hub it + // dials, so only a standalone destination can be compared with the proven origin. + let destinationIsProvenListener = true; if (cfg.runtimeRole === "client") { // Direct clients bind to persisted connection state; link clients use an authless relay. const connection = readClientConnectionState(); if (connection.kind !== "connected" || connection.value.transport === "link") return refuse("connection"); target = validateCodexRoutingTarget({ baseUrl: `${connection.value.serverUrl}/v1`, requiresAdmissionToken: true, tokenEnv: "OPENCODEX_API_AUTH_TOKEN" }); + destinationIsProvenListener = false; } const url = new URL(target.baseUrl); if (!target.requiresAdmissionToken) return refuse("admission"); if (url.protocol !== "http:" || !["127.0.0.1", "[::1]"].includes(url.hostname)) return refuse("loopback"); + if (destinationIsProvenListener && !codexRuntimeAttestationMatchesTarget(target.baseUrl, runtimeAttestation)) return refuse("destination"); const content = readBoundedCodexConfig(join(home, "config.toml")); if (content === null) return refuse("configRead"); const config = Bun.TOML.parse(content) as Record; diff --git a/src/cli/codex-launcher.ts b/src/cli/codex-launcher.ts index d8fce9489db..7b328602308 100644 --- a/src/cli/codex-launcher.ts +++ b/src/cli/codex-launcher.ts @@ -34,7 +34,7 @@ import { selfLaunchArgv } from "../lib/self-launch-argv"; import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets"; import { findExecutableOnPath } from "../remote-control/workspace-executable"; import { findLiveProxy, type LiveProxy } from "../server/proxy-liveness"; -import { codexCredentialDestinationIsTrusted, resolveCodexLaunchHome } from "./codex-launch-auth"; +import { attestCodexRuntime, codexCredentialDestinationIsTrusted, resolveCodexLaunchHome, type CodexRuntimeAttestation } from "./codex-launch-auth"; import { codexCommandCanBypass, CODEX_UNVERIFIED_SHIM } from "./codex-launch-command"; import { codexTokenArgsAreSafe, codexTokenEnvironmentIsSafe } from "./codex-launch-policy"; import { codexTokenWithheldDiagnostic, type CodexTokenRefusal, type CodexTokenRefusalReporter } from "./codex-launch-diagnostics"; @@ -158,7 +158,9 @@ export type CodexLaunchEnvDeps = { args?: readonly string[]; /** Test seam; production reads the hardened service token file. */ serviceToken?: (base: CodexLaunchEnv) => string | null; - credentialDestination?: (home: string, report?: CodexTokenRefusalReporter) => boolean; + credentialDestination?: (home: string, report?: CodexTokenRefusalReporter, runtimeAttestation?: CodexRuntimeAttestation | null) => boolean; + /** Exact origin that proved this home's runtime-record secret. */ + runtimeAttestation?: CodexRuntimeAttestation | null; }; /** Read the client admission credential from this installation's service-token file. */ @@ -186,6 +188,7 @@ export function buildCodexLaunchEnv( }; if (!codexTokenArgsAreSafe(deps.args ?? [])) return withhold("args"); if (!codexTokenEnvironmentIsSafe(env)) return withhold("environment"); + if (!deps.runtimeAttestation) return withhold("destination"); // NO_PROXY parsing and precedence vary across native HTTP/WebSocket transports. // Without proving bypass, preserve the user's transport but never load a secret. const home = resolveCodexLaunchHome(env); @@ -193,8 +196,8 @@ export function buildCodexLaunchEnv( let refusal: CodexTokenRefusal = "destination"; const report: CodexTokenRefusalReporter = reason => { refusal = reason; }; const trusted = deps.credentialDestination - ? deps.credentialDestination(home, report) - : codexCredentialDestinationIsTrusted(home, undefined, report); + ? deps.credentialDestination(home, report, deps.runtimeAttestation) + : codexCredentialDestinationIsTrusted(home, undefined, report, deps.runtimeAttestation); if (!trusted) return withhold(refusal); const token = (deps.serviceToken ?? defaultServiceToken)(env); if (!token) return withhold("credential"); @@ -318,5 +321,9 @@ export async function cmdCodex(args: string[]): Promise { // what the session does next; `ocx doctor` names the autostart failure. console.error(CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC); } - return spawnCodex(args, buildCodexLaunchEnv(process.env, { routingKind, args })); + // A public health marker proves liveness, not ownership of the listener. Never read the + // persisted service token until the exact runtime-record endpoint answers a fresh challenge. + const runtimeAttestation = plan.kind === "routed" && live !== null + ? await attestCodexRuntime(live) : null; + return spawnCodex(args, buildCodexLaunchEnv(process.env, { routingKind, args, runtimeAttestation })); } diff --git a/structure/cli-management.md b/structure/cli-management.md index 03469aac082..05bac0abe70 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -34,7 +34,7 @@ Same-file project aliases additionally require ownership/ACL inspection of the l For fresh credentials, the launcher resolves a native literal `CODEX_HOME` once and passes the canonical absolute directory both to the destination verifier and to the child. Missing, unreadable or non-directory homes and ambiguous Windows home casing withhold credentials. Any nonempty inherited HTTP(S)/ALL proxy variable, matched case-insensitively, suppresses token reads irrespective of NO_PROXY; denied and inherited-token launches preserve their environment. Successful injection adds both allowed loopback IPs to identical child NO_PROXY/no_proxy values while preserving prior bypass entries, covering default OS proxy discovery. Configured `features.respect_system_proxy` and feature-changing argv suppress loading because system-selected routes can ignore bypasses. -`src/cli/codex-launcher.ts` implements `ocx codex` without rewriting routing. Native/foreign routes pass through; local/unknown routes attempt startup and warn on failure. Fast paths skip startup/token loading. A matched wrapper uses its backing directly. Unknown state, including a throwing inspector, requires a separate bypass check via `src/cli/codex-launch-command.ts`; managed Windows CMD wrappers require complete generated-template equality before the selected absolute path executes with bypass. Obsolete, unreadable or ambiguous wrappers and lookup errors refuse launch without exception contents. A missing executable instead reports the Codex install hint, including on help/version fast paths. This content check does not establish Windows backing provenance or prevent concurrent replacement. `src/cli/codex-launch-auth.ts` separately requires a parsed managed transport block matching this installation's loopback listener or persisted local client connection; names/comments alone grant no token. Profiles, distinct project layers scanned through the filesystem root, destination-changing argv and environment overrides prevent automatic loading. An existing ancestor config is excluded only when its realpath matches the home config; a different file or failed identity resolution refuses. Token-file overrides and management-token collisions remain refused; user exports are preserved. No token enters argv or diagnostics. +`src/cli/codex-launcher.ts` implements `ocx codex` without rewriting routing. Native/foreign routes pass through; local/unknown routes attempt startup and warn on failure. Fast paths skip startup/token loading. A matched wrapper uses its backing directly. Unknown state, including a throwing inspector, requires a separate bypass check via `src/cli/codex-launch-command.ts`; managed Windows CMD wrappers require complete generated-template equality before the selected absolute path executes with bypass. Obsolete, unreadable or ambiguous wrappers and lookup errors refuse launch without exception contents. A missing executable instead reports the Codex install hint, including on help/version fast paths. This content check does not establish Windows backing provenance or prevent concurrent replacement. Before loading a fresh service token, the ensured listener must prove possession of this home's runtime-record attestation secret for its exact pid and port; a public health marker or failed ensure never authorizes credential loading. The successful proof retains an immutable origin snapshot using the actual probe address (including recovery probe overrides). For a standalone installation that origin must match the final provider destination; IPv4 and IPv6 recipients remain distinct. A direct client's proof comes from its machine listener, so its persisted hub URL is not compared with the proven origin and keeps the connection-state and loopback checks instead. The proof authenticates home ownership, pid and port only; a listener role is self-reported and is not bound by it. A mismatched or missing proof withholds the token before the key file is read, without rewriting routing. `src/cli/codex-launch-auth.ts` separately requires a parsed managed transport block matching this installation's loopback listener or persisted local client connection; names/comments alone grant no token. Profiles, distinct project layers scanned through the filesystem root, destination-changing argv and environment overrides prevent automatic loading. An existing ancestor config is excluded only when its realpath matches the home config; a different file or failed identity resolution refuses. Token-file overrides and management-token collisions remain refused; user exports are preserved. No token enters argv or diagnostics. The `codex` namespace forwards all help arguments unchanged; `ocx help codex` retains wrapper help. It skips root shim repair so the routing-aware launcher owns the handoff. Every handoff, including help/version and passthrough, awaits the [execution trust contract](clients/chatgpt-desktop.md#codex-app-execution-trust) for the selected backing and spawns the returned invocation. On macOS, CLI handoffs verify any enclosing `.app` at the canonical target location, including renamed bundles and symlink targets; nested apps retain the outer bundle root. Fast paths bypass startup and credential loading, not executable validation. The hidden `internal codex-exec-target` helper returns a single verified path for the generated app-server launcher; its strict `--bundle-root` mode retains the discovered root independent of bundle name and refuses malformed roots without falling back to generic classification. It skips root shim auto-repair even on malformed arguments, and stays outside public capability discovery. Forwarded `-c`/`--config` and `-p`/`--profile` overrides make effective routing unverified for automatic service-token injection. They retain original argv and user-exported credentials but never load a new token from disk. Single-word prompts require `--` so they cannot select an unknown native subcommand. Missing system files also require a protected namespace: the closest existing directory must prevent another user from creating the missing layer, and its ancestors must prevent replacement. A normal Windows ProgramData directory that grants Users directory creation cannot authorize an absent OpenAI/Codex namespace; the launcher withholds credentials without creating or changing that directory. macOS opens each validated entry without following its final link (O_SYMLINK for the link entry itself), keeps dev/ino as bigint and requires descriptor dev/ino/type to match its original lstat, and reads its ACL with acl_get_fd_np. Only NULL with immediately captured ENOENT on this descriptor query means no extended ACL; open/stat/identity/close failures and every other NULL/error refuse. Every opened descriptor closes in finally. For a present ACL it validates and enumerates every entry through the documented -1/EINVAL terminator. Deny-only and read-only allow entries are accepted; any allow granting write/add-file, append/add-subdirectory, delete, delete-child, attribute, extended-attribute, security or ownership mutation refuses regardless of earlier denies. Unknown tags and FFI/enumeration failures refuse. Forced managed-preference checks remain mandatory. Pure policy and Darwin-gated native ACL coverage lives in `tests/codex-integration/codex-launcher-darwin-acl.test.ts`; modeled IO regressions for all three walks live in `tests/codex-integration/codex-launcher-darwin-identity.test.ts`. Tests model a protected empty machine namespace inside their private fixture; they do not modify real system configuration. Windows fixtures probe ancestor ownership before positive assertions; an unavailable protected location requires a proved ownership refusal and an explicit skip reason. Negative mutations and no-token/zero-read assertions execute even with unsafe ancestors; credential-file negatives bypass destination verification independently. Native checks use production defaults. diff --git a/tests/codex-integration/codex-launcher-admission.test.ts b/tests/codex-integration/codex-launcher-admission.test.ts index 364ad3ef864..205fe9ce3f4 100644 --- a/tests/codex-integration/codex-launcher-admission.test.ts +++ b/tests/codex-integration/codex-launcher-admission.test.ts @@ -36,7 +36,7 @@ function launch(home: string, args: string[] = [], extra: Record console.error = (...values: unknown[]) => { notes.push(values.join(" ")); }; try { const env = buildCodexLaunchEnv({ CODEX_HOME: home, ...extra }, { - routingKind: "opencodex-local", args, serviceToken: () => { reads++; return token; }, + routingKind: "opencodex-local", runtimeAttestation: { origin: "http://127.0.0.1:12199" }, args, serviceToken: () => { reads++; return token; }, }); return { env, reads, notes }; } finally { console.error = original; } diff --git a/tests/codex-integration/codex-launcher-diagnostics.test.ts b/tests/codex-integration/codex-launcher-diagnostics.test.ts index d0532e055ba..a369014d122 100644 --- a/tests/codex-integration/codex-launcher-diagnostics.test.ts +++ b/tests/codex-integration/codex-launcher-diagnostics.test.ts @@ -16,7 +16,7 @@ const secret = "ocx_fixture_diagnostic_secret", contents = "fixture-key-file-con function capture(base: Record, deps: CodexLaunchEnvDeps) { const notes: string[] = [], original = console.error; console.error = (...values: unknown[]) => { notes.push(values.join(" ")); }; - try { return { env: buildCodexLaunchEnv(base, deps), notes }; } + try { return { env: buildCodexLaunchEnv(base, { runtimeAttestation: { origin: "http://127.0.0.1:12199" }, ...deps }), notes }; } finally { console.error = original; } } @@ -41,7 +41,7 @@ describe("Codex credential withholding diagnostics", () => { [{ CODEX_HOME: "" }, {}, "Codex home cannot be resolved unambiguously"], ] as const) test(reason, () => { let reads = 0; - const result = capture(base, { routingKind: "opencodex-local", ...deps, serviceToken: () => { reads++; return secret; } }); + const result = capture(base, { routingKind: "opencodex-local", runtimeAttestation: { origin: "http://127.0.0.1:12199" }, ...deps, serviceToken: () => { reads++; return secret; } }); diagnostic(result.notes, reason); expect(reads).toBe(0); expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); @@ -89,7 +89,7 @@ $acl.AddAccessRule($rule) [System.IO.Directory]::SetAccessControl($p,$acl)`; let reads = 0; const launch = () => capture({ CODEX_HOME: home }, { - credentialDestination: (path, report) => codexCredentialDestinationIsTrusted(path, system, report), + credentialDestination: (path, report, proof) => codexCredentialDestinationIsTrusted(path, system, report, proof), serviceToken: () => { reads++; return secret; }, }); try { @@ -185,7 +185,7 @@ $acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new([Sy } let reads = 0; const result = capture({ CODEX_HOME: home }, { - credentialDestination: (path, report) => codexCredentialDestinationIsTrusted(path, system, report), + credentialDestination: (path, report, proof) => codexCredentialDestinationIsTrusted(path, system, report, proof), serviceToken: () => { reads++; return secret; }, }); // Diagnostic reachability needs protected ancestors; the mutation and diff --git a/tests/codex-integration/codex-launcher-endpoint.test.ts b/tests/codex-integration/codex-launcher-endpoint.test.ts new file mode 100644 index 00000000000..94505bc8782 --- /dev/null +++ b/tests/codex-integration/codex-launcher-endpoint.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { attestCodexRuntime, codexRuntimeAttestationMatchesTarget, type CodexRuntimeAttestation } from "../../src/cli/codex-launch-auth"; +import { buildCodexLaunchEnv } from "../../src/cli/codex-launcher"; +import { proveLiveProxyOwnedByHome, type LiveProxy } from "../../src/server/proxy-liveness"; +import { createLocalAttestationProof, LOCAL_ATTESTATION_PROOF_HEADER } from "../../src/lib/local-management-attestation"; +import { buildProviderTableBlockForTarget } from "../../src/codex/inject/config-toml"; +import { standaloneCodexRoutingTarget, validateCodexRoutingTarget } from "../../src/codex/inject/routing-target"; +import { createCodexLaunchFixtureRoot, codexLaunchOwnershipTest, buildCodexPrivateFixtureLaunchEnv } from "../helpers/codex-launch-private-fixture"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const token = "ocx_fixture_endpoint_bound", origin = "http://127.0.0.1:12199"; +const live: LiveProxy = { pid: 4242, port: 12199, hostname: "0.0.0.0", source: "runtime" }; + +/** Exercise the launch boundary independently of the host's filesystem ACL policy. */ +function launch(proof: CodexRuntimeAttestation | null | undefined, extra: Record = {}, args: string[] = []) { + let reads = 0, checks = 0; + const env = buildCodexLaunchEnv({ CODEX_HOME: process.cwd(), ...extra }, { + routingKind: "opencodex-local", runtimeAttestation: proof, args, + credentialDestination: (_home, _report, received) => { + checks++; + expect(received).toBe(proof); + return codexRuntimeAttestationMatchesTarget(`${origin}/v1`, received); + }, + serviceToken: () => { reads++; return token; }, + }); + return { env, reads, checks }; +} + +describe("Codex runtime endpoint binding", () => { + test("a real challenge proof is bound to the address actually dialed", async () => { + const secret = "s".repeat(43), challenge = "c".repeat(43); + for (const port of [12199, 12200]) { + let requested = ""; + const endpoint = { ...live, port, hostname: "::1", probeHostname: "127.0.0.1" }; + const proof = await attestCodexRuntime(endpoint, snapshot => proveLiveProxyOwnedByHome(snapshot, { + readRuntimeFn: () => ({ pid: 4242, port, attestationSecret: secret }), + createChallengeFn: () => challenge, + fetchFn: async input => { + requested = String(input); + return Response.json({ service: "opencodex", pid: 4242 }, { + headers: { [LOCAL_ATTESTATION_PROOF_HEADER]: createLocalAttestationProof(secret, challenge, 4242, port)! }, + }); + }, + })); + expect(requested).toBe(`http://127.0.0.1:${port}/healthz`); + expect(proof).toEqual({ origin: `http://127.0.0.1:${port}` }); + expect(launch(proof).reads).toBe(port === 12199 ? 1 : 0); + } + }); + + test("a configured localhost listener is proven at the 127.0.0.1 origin it binds", async () => { + let probed: LiveProxy | undefined; + const proof = await attestCodexRuntime({ ...live, hostname: "localhost" }, async snapshot => { probed = snapshot; return "proven"; }); + expect(probed?.probeHostname).toBe("127.0.0.1"); + expect(proof).toEqual({ origin }); + expect(codexRuntimeAttestationMatchesTarget(standaloneCodexRoutingTarget(12199, { hostname: "localhost" }).baseUrl, proof)).toBe(true); + }); + + test("direct-client destination matching is independent of the local machine port", () => { + const local = standaloneCodexRoutingTarget(12200, { hostname: "0.0.0.0" }); + const direct = validateCodexRoutingTarget({ baseUrl: `${origin}/v1`, requiresAdmissionToken: true, tokenEnv: "OPENCODEX_API_AUTH_TOKEN" }); + expect(codexRuntimeAttestationMatchesTarget(direct.baseUrl, { origin })).toBe(true); + expect(codexRuntimeAttestationMatchesTarget(local.baseUrl, { origin })).toBe(false); + expect(codexRuntimeAttestationMatchesTarget(direct.baseUrl, { origin: "http://127.0.0.1:12200" })).toBe(false); + }); + + test("a different attested runtime port cannot authorize the configured destination", async () => { + const result = launch(await attestCodexRuntime({ ...live, port: 12200 }, async () => "proven")); + expect(result.checks).toBe(1); + expect(result.reads).toBe(0); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + }); + test("the same attested endpoint reads exactly one fresh token", async () => { + const result = launch(await attestCodexRuntime(live, async () => "proven")); + expect(result.reads).toBe(1); + expect(result.env.OPENCODEX_API_AUTH_TOKEN).toBe(token); + }); + for (const verdict of ["refuted", "indeterminate"] as const) test(`${verdict} ownership never authorizes loading`, async () => { + const proof = await attestCodexRuntime(live, async () => verdict); + expect(proof).toBeNull(); + expect(launch(proof).reads).toBe(0); + }); + test("missing proof also refuses helper calls without a routing classification", () => { + let reads = 0; + const env = buildCodexLaunchEnv({ CODEX_HOME: process.cwd() }, { + credentialDestination: () => true, serviceToken: () => { reads++; return token; }, + }); + expect(reads).toBe(0); + expect(env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + expect(launch(undefined).checks).toBe(0); + }); + test("a failed proof operation degrades without a fresh credential", async () => { + expect(await attestCodexRuntime(live, async () => { throw new Error("unavailable"); })).toBeNull(); + }); + for (const hostname of ["0.0.0.0", "::", "[::]", "0:0:0:0:0:0:0:0", "0.0.0.0.", "*"]) { + test(`wildcard proof uses established loopback normalization: ${hostname}`, async () => { + expect(await attestCodexRuntime({ ...live, hostname }, async () => "proven")).toEqual({ origin }); + }); + } + test("the successful probe address takes precedence and IPv6 does not bless IPv4", async () => { + const proof = await attestCodexRuntime({ ...live, probeHostname: "::1" }, async () => "proven"); + expect(proof).toEqual({ origin: "http://[::1]:12199" }); + expect(launch(proof).reads).toBe(0); + expect(codexRuntimeAttestationMatchesTarget("http://[0:0:0:0:0:0:0:1]:12199/v1", proof)).toBe(true); + expect(codexRuntimeAttestationMatchesTarget("https://[::1]:12199/v1", proof)).toBe(false); + expect(codexRuntimeAttestationMatchesTarget("invalid", proof)).toBe(false); + }); + test("proof snapshots the endpoint before awaiting ownership", async () => { + const mutable = { ...live }; + const proof = await attestCodexRuntime(mutable, async snapshot => { + mutable.port = 12200; mutable.hostname = "::1"; + expect(snapshot.port).toBe(12199); + expect(snapshot.hostname).toBe("0.0.0.0"); + return "proven"; + }); + expect(proof).toEqual({ origin }); + expect(Object.isFrozen(proof)).toBe(true); + }); + test("valid proof retains argv/environment guards and explicit user credentials win", async () => { + const proof = await attestCodexRuntime(live, async () => "proven"); + for (const args of [["-c", "model_provider=foreign"], ["--profile=foreign"], ["resume"]]) { + expect(launch(proof, {}, args).reads).toBe(0); + } + expect(launch(proof, { OPENAI_BASE_URL: "https://foreign.invalid/v1" }).reads).toBe(0); + const inherited = launch({ origin: "http://127.0.0.1:12200" }, { OPENCODEX_API_AUTH_TOKEN: "fixture-explicit" }, ["--profile=foreign"]); + expect(inherited.reads).toBe(0); + expect(inherited.checks).toBe(0); + expect(inherited.env.OPENCODEX_API_AUTH_TOKEN).toBe("fixture-explicit"); + }); +}); + +function configurationFixture(client: boolean, run: (home: string) => void) { + const root = createCodexLaunchFixtureRoot("ocx-bound-endpoint-"), home = join(root, "codex"), ocx = join(root, "ocx"); + mkdirSync(home); mkdirSync(ocx); + const saved = process.env.OPENCODEX_HOME, cwd = process.cwd(); + try { + process.env.OPENCODEX_HOME = ocx; process.chdir(root); + const config = { port: client ? 12200 : 12199, hostname: "0.0.0.0", ...(client ? { + runtimeRole: "client", client: { serverUrl: origin, managementUrl: origin, + managementTransport: "direct", selectedClients: ["codex"], tokenEnv: "OPENCODEX_API_AUTH_TOKEN", + apiKeyId: "fixture", tokenFingerprint: "a".repeat(64), protocolVersion: 1, connectedAt: "2026-01-01T00:00:00.000Z" }, + } : {}) }; + const target = client ? validateCodexRoutingTarget({ baseUrl: `${origin}/v1`, requiresAdmissionToken: true, tokenEnv: "OPENCODEX_API_AUTH_TOKEN" }) + : standaloneCodexRoutingTarget(config.port, config); + writeFileSync(join(ocx, "config.json"), JSON.stringify(config)); + writeFileSync(join(home, "config.toml"), 'model_provider="opencodex"\n' + buildProviderTableBlockForTarget(target)); + run(home); + } finally { + process.chdir(cwd); + if (saved === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = saved; + removeTreeWithRetry(root); + } +} + +// A direct client's proof comes from its machine listener (12200), never from the hub it dials (12199). +for (const client of [false, true]) codexLaunchOwnershipTest(`${client ? "direct client requires its own listener proof" : "standalone binds proof to the final provider destination"}`, () => { + configurationFixture(client, home => { + for (const port of [12199, 12200]) { + let reads = 0; + const result = buildCodexPrivateFixtureLaunchEnv({ CODEX_HOME: home }, { + routingKind: "opencodex-local", runtimeAttestation: { origin: `http://127.0.0.1:${port}` }, + serviceToken: () => { reads++; return token; }, + }); + const injected = client || port === 12199; + expect(reads).toBe(injected ? 1 : 0); + expect(result.OPENCODEX_API_AUTH_TOKEN).toBe(injected ? token : undefined); + } + for (const runtimeAttestation of [null, undefined]) { + let reads = 0; + const result = buildCodexPrivateFixtureLaunchEnv({ CODEX_HOME: home }, { routingKind: "opencodex-local", runtimeAttestation, serviceToken: () => { reads++; return token; } }); + expect(reads).toBe(0); + expect(result.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + } + writeFileSync(join(home, "managed_config.toml"), ""); + let reads = 0; + buildCodexPrivateFixtureLaunchEnv({ CODEX_HOME: home }, { runtimeAttestation: { origin }, serviceToken: () => { reads++; return token; } }); + expect(reads).toBe(0); + }); +}); diff --git a/tests/codex-integration/codex-launcher-env.test.ts b/tests/codex-integration/codex-launcher-env.test.ts index 505ce6ffc3e..4bdae7df7a6 100644 --- a/tests/codex-integration/codex-launcher-env.test.ts +++ b/tests/codex-integration/codex-launcher-env.test.ts @@ -46,8 +46,8 @@ async function fixture(run: (root: string, home: string) => void | Promise function launch(base = { ...process.env }) { let reads = 0, reason: string | undefined; const env = buildCodexLaunchEnv(base, { - routingKind: "opencodex-local", serviceToken: () => { reads++; return token; }, - credentialDestination: (home, report) => codexCredentialDestinationIsTrusted(home, join(home, "system-namespace-fixture"), r => { reason = r; report?.(r); }), + routingKind: "opencodex-local", runtimeAttestation: { origin: "http://127.0.0.1:12199" }, serviceToken: () => { reads++; return token; }, + credentialDestination: (home, report, proof) => codexCredentialDestinationIsTrusted(home, join(home, "system-namespace-fixture"), r => { reason = r; report?.(r); }, proof), }); return { env, reads, reason }; } @@ -234,7 +234,7 @@ describe("Codex launcher native home and proxy environment", () => { test("feature argv cannot enable a bypass-ignoring system proxy", () => fixture(() => { for (const args of [["--enable", "respect_system_proxy"], ["--enable=respect_system_proxy"]]) { let reads = 0; - buildCodexLaunchEnv(process.env, { routingKind: "opencodex-local", args, serviceToken: () => { reads++; return token; } }); + buildCodexLaunchEnv(process.env, { routingKind: "opencodex-local", runtimeAttestation: { origin: "http://127.0.0.1:12199" }, args, serviceToken: () => { reads++; return token; } }); expect(reads).toBe(0); } })); diff --git a/tests/codex-integration/codex-launcher-security.test.ts b/tests/codex-integration/codex-launcher-security.test.ts index 0837aad73dc..a829fdcd8e7 100644 --- a/tests/codex-integration/codex-launcher-security.test.ts +++ b/tests/codex-integration/codex-launcher-security.test.ts @@ -44,7 +44,7 @@ describe("codex launcher credential destination", () => { codexLaunchOwnershipTest("the managed starting configuration reads the service token exactly once", () => fixture((_home, codexHome) => { writeFileSync(join(codexHome, "config.toml"), local); let reads = 0; - const env = buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", serviceToken: () => { reads++; return fixtureToken; } }); + const env = buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", runtimeAttestation: { origin: "http://127.0.0.1:12199" }, serviceToken: () => { reads++; return fixtureToken; } }); expect(reads).toBe(1); expect(env.OPENCODEX_API_AUTH_TOKEN).toBe(fixtureToken); })); @@ -71,14 +71,14 @@ describe("codex launcher credential destination", () => { const originalDirectory = process.cwd(); try { process.chdir(parse(home).root); - expect(buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local" }).OPENCODEX_API_AUTH_TOKEN).toBe(fixtureToken); + expect(buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", runtimeAttestation: { origin: "http://127.0.0.1:12199" } }).OPENCODEX_API_AUTH_TOKEN).toBe(fixtureToken); } finally { process.chdir(originalDirectory); } })); test("directory and remote overrides never read a service token", () => fixture((_home, codexHome) => { writeFileSync(join(codexHome, "config.toml"), local); for (const args of [["-C", "other"], ["-Cother"], ["--cd=other"], ["--cd", "other"], ["--remote", "remote"], ["--remote=remote"]]) { let reads = 0; - expect(buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", args, serviceToken: () => { reads++; return fixtureToken; } })).toEqual({ CODEX_HOME: codexHome }); + expect(buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", runtimeAttestation: { origin: "http://127.0.0.1:12199" }, args, serviceToken: () => { reads++; return fixtureToken; } })).toEqual({ CODEX_HOME: codexHome }); expect(reads).toBe(0); } })); @@ -93,7 +93,7 @@ describe("codex launcher credential destination", () => { try { process.chdir(project); let reads = 0; - expect(buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", serviceToken: () => { reads++; return fixtureToken; } })).toEqual({ CODEX_HOME: codexHome }); + expect(buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { routingKind: "opencodex-local", runtimeAttestation: { origin: "http://127.0.0.1:12199" }, serviceToken: () => { reads++; return fixtureToken; } })).toEqual({ CODEX_HOME: codexHome }); expect(reads).toBe(0); } finally { process.chdir(originalDirectory); } })); @@ -111,8 +111,8 @@ describe("codex launcher credential destination", () => { process.chdir(cwd); let reads = 0, reason: string | undefined; const env = buildCodexLaunchEnv({ CODEX_HOME: codexHome }, { - routingKind: "opencodex-local", serviceToken: () => { reads++; return fixtureToken; }, - credentialDestination: (home, report) => codexCredentialDestinationIsTrusted(home, join(home, "system-namespace-fixture"), r => { reason = r; report?.(r); }), + routingKind: "opencodex-local", runtimeAttestation: { origin: "http://127.0.0.1:12199" }, serviceToken: () => { reads++; return fixtureToken; }, + credentialDestination: (home, report, proof) => codexCredentialDestinationIsTrusted(home, join(home, "system-namespace-fixture"), r => { reason = r; report?.(r); }, proof), }); expect(reads).toBe(0); expect(reason).toBe(expectedReason); diff --git a/tests/codex-integration/codex-launcher.test.ts b/tests/codex-integration/codex-launcher.test.ts index 96f888fef4d..fa5b0bdb806 100644 --- a/tests/codex-integration/codex-launcher.test.ts +++ b/tests/codex-integration/codex-launcher.test.ts @@ -39,7 +39,7 @@ import { repoRoot } from "../helpers/repo-root"; // These env-assembly tests supply destination authority explicitly; real config authority // (including the positive managed block) is covered in codex-launcher-security.test.ts. const buildCodexLaunchEnv: typeof buildEnv = (base, deps) => buildEnv({ CODEX_HOME: repoRoot(), ...base }, { - credentialDestination: () => true, ...deps, + credentialDestination: () => true, runtimeAttestation: { origin: "http://127.0.0.1:12199" }, ...deps, }); function withTokenFiles(run: (home: string) => void): void { @@ -143,6 +143,17 @@ describe("codex launcher plan", () => { }); describe("codex launch env assembly", () => { + test("managed routing withholds the persisted token until the listener is attested", () => { + let consulted = false; + const env = buildCodexLaunchEnv({ PATH: "/usr/bin" }, { + routingKind: "opencodex-local", + runtimeAttestation: null, + serviceToken: () => { consulted = true; return "must-not-be-read"; }, + }); + expect(env.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + expect(consulted).toBe(false); + }); + test("forwarded config and profile overrides never authorize a newly loaded token", () => { for (const args of [ ["-c", 'model_providers.opencodex.base_url="https://foreign.example/v1"', "hello"], diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 52df34bf612..030a8eaf0db 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -27,6 +27,7 @@ "windows-config-root-async-writers.test.ts": "windows", "windows-atomic-write-acl-rearm.test.ts": "windows", "advisor-web-search.test.ts": "advisor", + "codex-launcher-endpoint.test.ts": "codex-integration", "t3code-client.test.ts": "clients", "t3code-client-lifecycle.test.ts": "clients", "fix-jsonc-cr-comments.test.ts": "lib", diff --git a/tests/helpers/codex-launch-private-fixture.ts b/tests/helpers/codex-launch-private-fixture.ts index ad959f83e2d..e9693c1089d 100644 --- a/tests/helpers/codex-launch-private-fixture.ts +++ b/tests/helpers/codex-launch-private-fixture.ts @@ -75,7 +75,8 @@ export function codexLaunchOwnershipTest(name: string, body: () => void | Promis expect(codexLaunchHomeIsOwned(root)).toBe(false); let reason: string | undefined, reads = 0; const env = buildCodexLaunchEnv({ CODEX_HOME: root }, { - credentialDestination: (home, report) => codexCredentialDestinationIsTrusted(home, join(home, "system"), value => { reason = value; report?.(value); }), + runtimeAttestation: { origin: "http://127.0.0.1:12199" }, + credentialDestination: (home, report, proof) => codexCredentialDestinationIsTrusted(home, join(home, "system"), value => { reason = value; report?.(value); }, proof), serviceToken: () => { reads++; return "ocx_fixture_must_not_read"; }, }); expect(reason).toBe("ownership"); @@ -96,7 +97,8 @@ export function codexLaunchProjectRefusalTest(name: string, body: (reason: "proj // Model a protected, empty machine configuration namespace inside this fixture. // Every other authority check uses real config/ownership IO. Native probes use production defaults. export const buildCodexPrivateFixtureLaunchEnv: typeof buildCodexLaunchEnv = (base, deps) => buildCodexLaunchEnv(base, { - credentialDestination: (home, report) => codexCredentialDestinationIsTrusted(home, join(home, "system-namespace-fixture"), report), ...deps, + runtimeAttestation: { origin: "http://127.0.0.1:12199" }, + credentialDestination: (home, report, proof) => codexCredentialDestinationIsTrusted(home, join(home, "system-namespace-fixture"), report, proof), ...deps, }); /** Windows Temp grants Users create rights to descendants; model a private Codex home. */ From 83f76672c6221e388e5fa707cfff1191f6b5c066 Mon Sep 17 00:00:00 2001 From: Dmitry Zarva Date: Tue, 3 Nov 2026 12:04:40 +0300 Subject: [PATCH 03/16] fix(kimi): report monthly Coding Plan quota (#8077) * fix(kimi): report monthly Coding Plan quota * fix(kimi): validate monthly ratios and continue envelope fallback * fix(kimi): keep nested legacy quota windows and validate counter ranges * fix(kimi): treat a quota counter overage as exhausted A used count above the limit, or a negative remaining count, means the window is used up; dropping the row would let an over-quota account look available. Impossible counters still fall back to the ratio. * fix(kimi): let either counter overage exhaust the quota window A negative remaining next to a lower used count still means the window is used up; check both overages before choosing a counter. * fix(kimi): check quota exhaustion before rejecting counters An exhaustion signal now wins even beside an impossible companion counter, and a positive remainder can no longer round up to an exhausted window. --------- Co-authored-by: JUN --- .../src/content/docs/guides/providers.md | 10 + scripts/test-layout/layout.json | 2 +- src/providers/quota/vendor-probes-key.ts | 84 +++-- structure/providers-and-adapters.md | 10 +- tests/fixtures/test-layout-expected.json | 2 +- tests/providers/kimi-monthly-quota.test.ts | 318 ++++++++++++++++++ 6 files changed, 395 insertions(+), 31 deletions(-) create mode 100644 tests/providers/kimi-monthly-quota.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index e98c4098d95..8e0e93feed8 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1837,6 +1837,16 @@ Google Antigravity, OpenCode Go, OpenRouter, DeepSeek, ClinePass, Z.AI, MiniMax, Moonshot, Venice, Synthetic, DeepInfra, Neuralwatt, Command Code, and any a6api-backed or Abliteration custom provider. +**Kimi Coding Plan quota.** Account login and API-key providers on the canonical +`https://api.kimi.com/coding/v1` endpoint read `/usages`. Plans that report +`usages.limit_month_total` (or `monthTotal`) show a monthly utilization bar, with +`limit_month_code` (or `monthCode`) as the fallback when total usage is unavailable. +The `used_ratio` / `usedRatio` fraction must be in `[0, 1]` before conversion to a consumed +percentage; invalid ratios are ignored. Empty or unusable `usages` containers do not hide +usable monthly data in another supported envelope. Any reported reset time is retained. +Existing 5-hour, weekly, and total subscription-credit +windows remain separate, and an absent monthly window is not shown as 0%. + **Abliteration credits.** A provider of any name whose `baseUrl` is `https://api.abliteration.ai` or `https://api.abliteration.ai/v1` reads `GET https://api.abliteration.ai/v1/credits` with its active key as a Bearer token and does not diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 766e134baff..9d82e65ddd3 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,7 +2,7 @@ "version": 1, "root": "tests", "explicitShards": ["layout-file-identity.json", "layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-replay.json", "layout-adapters.json", "layout-jev.json", "layout-workbuddy.json", "layout-combos.json", "layout-credit-renewal.json", "layout-server-inbound.json", "layout-providers.json", "layout-responses.json", "layout-exec-boundary.json", "layout-persistent-dashboard.json", "layout-start-ownership.json", "layout-config.json", "layout-kiro-usage.json", "layout-usage.json"], - "explicit": { "sync-fanout.test.ts": "cli", "sync-command-fanout.test.ts": "cli", "link-pinned-sync-argv.test.ts": "clients", "sync-link-status.test.ts": "cli", "sync-fanout-process.test.ts": "cli", "codex-installation-acl.test.ts": "codex-integration", "codex-installation-id.test.ts": "codex-integration", "codex-installation-wire.test.ts": "codex-integration", "codex-installation-failover.test.ts": "responses", "service-desktop-recovery.test.ts": "service", "catalog-omit-disabled-native.test.ts": "codex-integration", "provider-input-modalities.test.ts": "providers", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "config-workflow-budget.test.ts": "config", "server-workflow-budget-settings.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "cli-provider-list-active.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "coding-agent-windows-dispatch.test.ts": "adapters", "coding-agent-native-empty-argv.test.ts": "windows", + "explicit": { "kimi-monthly-quota.test.ts": "providers", "sync-fanout.test.ts": "cli", "sync-command-fanout.test.ts": "cli", "link-pinned-sync-argv.test.ts": "clients", "sync-link-status.test.ts": "cli", "sync-fanout-process.test.ts": "cli", "codex-installation-acl.test.ts": "codex-integration", "codex-installation-id.test.ts": "codex-integration", "codex-installation-wire.test.ts": "codex-integration", "codex-installation-failover.test.ts": "responses", "service-desktop-recovery.test.ts": "service", "catalog-omit-disabled-native.test.ts": "codex-integration", "provider-input-modalities.test.ts": "providers", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "config-workflow-budget.test.ts": "config", "server-workflow-budget-settings.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "cli-provider-list-active.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "coding-agent-windows-dispatch.test.ts": "adapters", "coding-agent-native-empty-argv.test.ts": "windows", "stop-restore-history-ownership.test.ts": "service", "cli-stop-refusal-transport.test.ts": "cli", "service-proxy-env.test.ts": "service", "openai-chat-standalone-bare-block.test.ts": "adapters/openai", diff --git a/src/providers/quota/vendor-probes-key.ts b/src/providers/quota/vendor-probes-key.ts index 6b2c713f5b9..e34d581d787 100644 --- a/src/providers/quota/vendor-probes-key.ts +++ b/src/providers/quota/vendor-probes-key.ts @@ -1125,18 +1125,17 @@ export function isCanonicalCommandCodeBaseUrl(baseUrl: string): boolean { return normalized === COMMAND_CODE_BASE_URL || normalized === `${COMMAND_CODE_BASE_URL}/provider/v1`; } -/** Prefer the nested `data` shell when the outer object is only an envelope. */ -function unwrapKimiQuotaPayload(value: unknown): Record | null { - const body = asRecord(value); - if (!body) return null; - const nested = asRecord(body.data); - if (!nested) return body; - // A null/non-usable outer field is a placeholder, not data — an envelope like - // { usage: null, data: { usage: {...} } } must still unwrap to the nested payload. +/** + * Pick the envelope carrying the legacy windows (weekly, 5h, total credits). The monthly + * `usages` container is located separately, so its presence never hides a nested window. + */ +function kimiLegacyWindowBody(root: Record): Record { + const nested = asRecord(root.data); + if (!nested) return root; + // A null outer field is a placeholder, not data — { usage: null, data: { usage: {...} } } unwraps. const usable = (field: unknown): boolean => field !== undefined && field !== null; - const outerHasUsage = usable(body.usage) || usable(body.limits) || usable(body.totalQuota); - const nestedHasUsage = usable(nested.usage) || usable(nested.limits) || usable(nested.totalQuota); - return !outerHasUsage && nestedHasUsage ? nested : body; + const hasLegacy = (body: Record): boolean => usable(body.usage) || usable(body.limits) || usable(body.totalQuota); + return !hasLegacy(root) && hasLegacy(nested) ? nested : root; } function kimiLimitLabel(item: Record, detail: Record): string { @@ -1146,21 +1145,43 @@ function kimiLimitLabel(item: Record, detail: Record): number | undefined { + const limit = toFiniteNumber(row.limit); + if (limit === undefined || limit <= 0) return undefined; + const used = toFiniteNumber(row.used); + const remaining = toFiniteNumber(row.remaining); + if ((used !== undefined && used >= limit) || (remaining !== undefined && remaining <= 0)) return 100; + if ((used !== undefined && used < 0) || (remaining !== undefined && remaining > limit)) return undefined; + if (used !== undefined) return (used / limit) * 100; + if (remaining === undefined) return undefined; + return Math.min(((limit - remaining) / limit) * 100, KIMI_BELOW_EXHAUSTED_PERCENT); +} + +/** Parse consumed usage, preferring valid counters, then a valid [0, 1] ratio, then direct percentages. */ function parseKimiQuotaRow(value: unknown, resetFallback?: Record): { percent: number; resetAt?: number } | null { const row = asRecord(value); if (!row) return null; const resetAt = quotaResetAt(row) ?? (resetFallback ? quotaResetAt(resetFallback) : undefined); - const limit = toFiniteNumber(row.limit); - if (limit !== undefined && limit > 0) { - let used = toFiniteNumber(row.used); - if (used === undefined) { - const remaining = toFiniteNumber(row.remaining); - if (remaining !== undefined) used = limit - remaining; - } - if (used !== undefined) { - const percent = normalizePercent((used / limit) * 100); - if (percent !== undefined) return { percent, ...(resetAt !== undefined ? { resetAt } : {}) }; - } + const counterPercent = kimiCounterPercent(row); + if (counterPercent !== undefined) { + const percent = normalizePercent(counterPercent); + if (percent !== undefined) return { percent, ...(resetAt !== undefined ? { resetAt } : {}) }; + } + // Newer Kimi responses expose utilization as a fraction in [0, 1]. + const ratio = toFiniteNumber(row.used_ratio ?? row.usedRatio); + if (ratio !== undefined && ratio >= 0 && ratio <= 1) { + const percent = normalizePercent(ratio * 100); + if (percent !== undefined) return { percent, ...(resetAt !== undefined ? { resetAt } : {}) }; } // Some payloads expose utilisation directly when limit/used arithmetic is absent. const direct = normalizePercent(row.utilization ?? row.percent ?? row.usedPercent ?? row.used_percent); @@ -1181,11 +1202,22 @@ function isKimiWeeklyLimit(item: Record, detail: Record [key, key.toLowerCase()]); +const originalProxyEnv = Object.fromEntries(proxyKeys.map(key => [key, process.env[key]])); +let home: string; + +beforeEach(() => { + for (const key of proxyKeys) delete process.env[key]; + home = mkdtempSync(join(tmpdir(), "ocx-kimi-monthly-")); + process.env.OPENCODEX_HOME = home; + clearProviderQuotaCache(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + clearProviderQuotaCache(); + clearComboTargetCooldowns(); + for (const key of proxyKeys) { + if (originalProxyEnv[key] === undefined) delete process.env[key]; + else process.env[key] = originalProxyEnv[key]; + } + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); +}); + +function config(authMode: "key" | "oauth" = "key"): OcxConfig { + const name = authMode === "oauth" ? "kimi" : "kimi-code"; + return { + defaultProvider: name, + providers: { + [name]: { + adapter: "openai-chat", + authMode, + baseUrl: "https://api.kimi.com/coding/v1", + ...(authMode === "key" ? { apiKey: "kimi-monthly-fixture-key" } : {}), + }, + }, + } as OcxConfig; +} + +function mockUsage(payload: unknown): void { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + expect(String(input)).toBe("https://api.kimi.com/coding/v1/usages"); + expect(init?.redirect).toBe("error"); + return Response.json(payload); + }) as typeof fetch; +} + +const resetAt = "2026-12-01T00:00:00Z"; +const monthlyRow = { used_ratio: 0.375, reset_at: resetAt }; + +describe("Kimi monthly quota", () => { + test.each(["key", "oauth"] as const)("publishes a monthly-only %s report", async authMode => { + if (authMode === "oauth") { + await saveCredential("kimi", { + access: "kimi-monthly-fixture-access", + refresh: "kimi-monthly-fixture-refresh", + expires: Date.now() + 3600_000, + }); + } + mockUsage({ usages: { limit_month_total: monthlyRow } }); + + const { reports } = await fetchProviderQuotaReports(config(authMode), true); + + expect(reports).toHaveLength(1); + expect(reports[0]?.source).toBe("kimi:usages"); + expect(reports[0]?.quota).toEqual({ + monthlyPercent: 37.5, + monthlyResetAt: Date.parse(resetAt), + updatedAt: expect.any(Number), + }); + }); + + test.each([ + { name: "data", payload: { data: { usages: { limit_month_total: monthlyRow } } } }, + { name: "quota", payload: { quota: { usages: { limit_month_total: monthlyRow } } } }, + { name: "data.quota", payload: { data: { quota: { usages: { limit_month_total: monthlyRow } } } } }, + { name: "null outer usage", payload: { usage: null, data: { usages: { limit_month_total: monthlyRow } } } }, + ])("reads monthly usage from $name", async ({ payload }) => { + mockUsage(payload); + const { reports } = await fetchProviderQuotaReports(config(), true); + expect(reports[0]?.quota.monthlyPercent).toBe(37.5); + expect(reports[0]?.quota.monthlyResetAt).toBe(Date.parse(resetAt)); + }); + + test.each([ + { name: "empty root usages", payload: { usages: {}, data: { usages: { limit_month_total: monthlyRow } } } }, + { name: "unparseable root usages", payload: { usages: { limit_month_total: { used_ratio: "invalid" } }, data: { usages: { limit_month_total: monthlyRow } } } }, + { name: "root quota after empty root usages", payload: { usages: {}, quota: { usages: { limit_month_total: monthlyRow } } } }, + { name: "root quota after empty data usages", payload: { data: { usages: {} }, quota: { usages: { limit_month_total: monthlyRow } } } }, + { name: "data quota after empty root quota", payload: { quota: {}, data: { quota: { usages: { limit_month_total: monthlyRow } } } } }, + { name: "data quota after unparseable root quota", payload: { quota: { usages: { limit_month_code: { used_ratio: "invalid" } } }, data: { quota: { usages: { limit_month_total: monthlyRow } } } } }, + { name: "data quota after empty data usages", payload: { data: { usages: {}, quota: { usages: { limit_month_total: monthlyRow } } } } }, + ])("finds monthly usage with $name", async ({ payload }) => { + mockUsage(payload); + const { reports } = await fetchProviderQuotaReports(config(), true); + expect(reports[0]?.quota.monthlyPercent).toBe(37.5); + expect(reports[0]?.quota.monthlyResetAt).toBe(Date.parse(resetAt)); + }); + + test("retains root quota when legacy windows unwrap the data envelope", async () => { + mockUsage({ + data: { usage: { percent: 25 } }, + quota: { usages: { limit_month_total: monthlyRow } }, + }); + const { reports } = await fetchProviderQuotaReports(config(), true); + expect(reports[0]?.quota).toMatchObject({ weeklyPercent: 25, monthlyPercent: 37.5 }); + }); + + test.each([0, 0.25])("keeps the first usable container, including ratio %s", async ratio => { + mockUsage({ + usages: { limit_month_code: { used_ratio: ratio } }, + data: { usages: { limit_month_total: monthlyRow } }, + }); + const { reports } = await fetchProviderQuotaReports(config(), true); + expect(reports[0]?.quota.monthlyPercent).toBe(ratio * 100); + }); + + test.each(["limit_month_total", "monthTotal", "limit_month_code", "monthCode"])( + "recognizes %s and camel-case ratio/reset fields", async field => { + mockUsage({ usages: { [field]: { usedRatio: "0.625", resetAt } } }); + const { reports } = await fetchProviderQuotaReports(config(), true); + expect(reports[0]?.quota.monthlyPercent).toBe(62.5); + expect(reports[0]?.quota.monthlyResetAt).toBe(Date.parse(resetAt)); + }, + ); + + test("prefers the total monthly allowance over the coding-only fallback", async () => { + mockUsage({ usages: { + limit_month_total: { used_ratio: 0.25 }, + limit_month_code: { used_ratio: 0.75 }, + } }); + const { reports } = await fetchProviderQuotaReports(config(), true); + expect(reports[0]?.quota.monthlyPercent).toBe(25); + }); + + test("falls back to coding usage when total monthly usage is unusable", async () => { + mockUsage({ usages: { + limit_month_total: { used_ratio: "invalid" }, + limit_month_code: monthlyRow, + } }); + const { reports } = await fetchProviderQuotaReports(config(), true); + expect(reports[0]?.quota.monthlyPercent).toBe(37.5); + }); + + test.each([-0.1, 1.5, "-0.1", "1.5"])("ignores out-of-range ratios and preserves coding fallback: %s", async ratio => { + mockUsage({ usages: { limit_month_total: { used_ratio: ratio } } }); + expect((await fetchProviderQuotaReports(config(), true)).reports).toEqual([]); + clearProviderQuotaCache(); + mockUsage({ usages: { + limit_month_total: { used_ratio: ratio }, + limit_month_code: { used_ratio: 0.2, reset_at: resetAt }, + } }); + const { reports } = await fetchProviderQuotaReports(config(), true); + expect(reports[0]?.quota.monthlyPercent).toBe(20); + expect(reports[0]?.quota.monthlyResetAt).toBe(Date.parse(resetAt)); + }); + + test.each(["used_ratio", "usedRatio"])("retains direct-percent fallback after invalid %s", async field => { + mockUsage({ usages: { limit_month_total: { [field]: 1.5, percent: 45 } } }); + const { reports } = await fetchProviderQuotaReports(config(), true); + expect(reports[0]?.quota.monthlyPercent).toBe(45); + }); + + test("keeps legacy windows and subscription credits alongside monthly usage", async () => { + mockUsage({ + usage: { limit: "200", used: "50" }, + limits: [{ + window: { duration: 300, timeUnit: "TIME_UNIT_MINUTE" }, + detail: { limit: "100", remaining: "80" }, + }], + totalQuota: { limit: "100", used: "10" }, + data: { usages: { limit_month_total: monthlyRow } }, + }); + const { reports } = await fetchProviderQuotaReports(config(), true); + expect(reports[0]?.quota).toMatchObject({ + fiveHourPercent: 20, + weeklyPercent: 25, + monthlyPercent: 37.5, + customWindows: [{ label: "Total subscription credits", percent: 10 }], + }); + }); + + const exhaustedLegacy = { + usage: { percent: 100 }, + limits: [{ window: { duration: 300, timeUnit: "TIME_UNIT_MINUTE" }, detail: { limit: "100", used: "100" } }], + }; + + function comboConfig(): OcxConfig { + const base = config(); + return { + ...base, + providers: { ...base.providers, fallback: { adapter: "openai-chat", baseUrl: "https://fallback.example/v1", apiKey: "fallback-key" } }, + combos: { "quota-scope": { strategy: "failover", targets: [ + { provider: "kimi-code", model: "primary-model" }, { provider: "fallback", model: "fallback-model" }, + ] } }, + } as OcxConfig; + } + + test.each([ + { name: "usable outer usages", usages: { limit_month_total: { used_ratio: 0.2 } }, monthly: 20 }, + { name: "empty outer usages", usages: {}, monthly: undefined }, + { name: "malformed outer usages", usages: { limit_month_total: { used_ratio: "invalid" } }, monthly: undefined }, + { name: "non-object outer usages", usages: "invalid", monthly: undefined }, + ])("reports nested exhausted legacy windows beside $name", async ({ usages, monthly }) => { + mockUsage({ usages, data: exhaustedLegacy }); + const cfg = comboConfig(); + const { reports } = await fetchProviderQuotaReports(cfg, true); + expect(reports[0]?.quota).toMatchObject({ weeklyPercent: 100, fiveHourPercent: 100 }); + expect(reports[0]?.quota.monthlyPercent).toBe(monthly); + expect(pickComboTarget(cfg, "quota-scope")?.target.provider).toBe("fallback"); + }); + + test("nested monthly container does not hide exhausted outer legacy windows", async () => { + mockUsage({ ...exhaustedLegacy, data: { usages: { limit_month_total: { used_ratio: 0.2 } } } }); + const cfg = comboConfig(); + const { reports } = await fetchProviderQuotaReports(cfg, true); + expect(reports[0]?.quota).toMatchObject({ weeklyPercent: 100, fiveHourPercent: 100, monthlyPercent: 20 }); + expect(pickComboTarget(cfg, "quota-scope")?.target.provider).toBe("fallback"); + }); + + test.each([ + { name: "remaining above limit", row: { limit: 100, remaining: 200, used_ratio: 1 } }, + { name: "negative remaining", row: { limit: 100, remaining: -50, used_ratio: 1 } }, + { name: "negative used", row: { limit: 100, used: -20, used_ratio: 1 } }, + { name: "used above limit", row: { limit: 100, used: 250, used_ratio: 1 } }, + { name: "negative used with valid remaining", row: { limit: 100, used: -20, remaining: 50, used_ratio: 1 } }, + { name: "negative limit", row: { limit: -100, used: 0, used_ratio: 1 } }, + ])("falls back to used_ratio when counters are invalid: $name", async ({ row }) => { + mockUsage({ usages: { limit_month_total: row } }); + const { reports } = await fetchProviderQuotaReports(config(), true); + expect(reports[0]?.quota.monthlyPercent).toBe(100); + }); + + test.each([ + { name: "used above limit", row: { limit: 100, used: 250 } }, + { name: "negative remaining", row: { limit: 100, remaining: -50 } }, + ])("an overage without a ratio reads as exhausted: $name", async ({ row }) => { + mockUsage({ usages: { limit_month_total: row } }); + const { reports } = await fetchProviderQuotaReports(config(), true); + expect(reports[0]?.quota.monthlyPercent).toBe(100); + }); + + test.each([ + { name: "negative remaining beside a lower used", row: { limit: 100, used: 40, remaining: -50 } }, + { name: "used overage beside a valid remaining", row: { limit: 100, used: 150, remaining: 60 } }, + ])("either counter overage exhausts the window and the route: $name", async ({ row }) => { + mockUsage({ usages: { limit_month_total: row } }); + const cfg = comboConfig(); + const { reports } = await fetchProviderQuotaReports(cfg, true); + expect(reports[0]?.quota.monthlyPercent).toBe(100); + expect(pickComboTarget(cfg, "quota-scope")?.target.provider).toBe("fallback"); + }); + + test.each([ + { name: "zero remaining beside an impossible used", row: { limit: 100, used: -1, remaining: 0, used_ratio: 0.2 } }, + { name: "used at the limit beside an impossible remaining", row: { limit: 100, used: 100, remaining: 101, used_ratio: 0.2 } }, + ])("an exhaustion signal wins over an impossible companion counter: $name", async ({ row }) => { + mockUsage({ usages: { limit_month_total: row } }); + const cfg = comboConfig(); + const { reports } = await fetchProviderQuotaReports(cfg, true); + expect(reports[0]?.quota.monthlyPercent).toBe(100); + expect(pickComboTarget(cfg, "quota-scope")?.target.provider).toBe("fallback"); + }); + + test("a tiny positive remainder is not exhaustion", async () => { + mockUsage({ usages: { limit_month_total: { limit: 100, remaining: 0.000000000000001 } } }); + const cfg = comboConfig(); + const { reports } = await fetchProviderQuotaReports(cfg, true); + expect(reports[0]?.quota.monthlyPercent).toBeLessThan(100); + expect(pickComboTarget(cfg, "quota-scope")?.target.provider).not.toBe("fallback"); + }); + + test("drops a row whose only counters are invalid", async () => { + mockUsage({ usages: { limit_month_total: { limit: 100, remaining: 200 }, limit_month_code: { used_ratio: 0.4 } } }); + const { reports } = await fetchProviderQuotaReports(config(), true); + expect(reports[0]?.quota.monthlyPercent).toBe(40); + }); + + test.each([ + { row: { limit: 100, used: 40, used_ratio: 0.8 }, expected: 40 }, + { row: { limit: 100, remaining: 70, used_ratio: 0.8 }, expected: 30 }, + { row: { percent: 55 }, expected: 55 }, + { row: { used_ratio: 0 }, expected: 0 }, + { row: { used_ratio: 1 }, expected: 100 }, + ])("preserves arithmetic, percentage and boundary values: $expected", async ({ row, expected }) => { + mockUsage({ usages: { limit_month_total: row } }); + const { reports } = await fetchProviderQuotaReports(config(), true); + expect(reports[0]?.quota.monthlyPercent).toBe(expected); + expect(reports[0]?.quota.monthlyResetAt).toBeUndefined(); + }); + + test.each([null, "", "invalid", true, {}, []])("ignores invalid used_ratio %j", async used_ratio => { + mockUsage({ usages: { limit_month_total: { used_ratio } } }); + const { reports } = await fetchProviderQuotaReports(config(), true); + expect(reports).toEqual([]); + }); + + test("does not fabricate monthly usage from legacy windows or total subscription credits", async () => { + mockUsage({ usage: { percent: 20 }, totalQuota: { percent: 60 } }); + const { reports } = await fetchProviderQuotaReports(config(), true); + expect(reports[0]?.quota.weeklyPercent).toBe(20); + expect(reports[0]?.quota.monthlyPercent).toBeUndefined(); + expect(reports[0]?.quota.customWindows).toEqual([{ label: "Total subscription credits", percent: 60 }]); + }); +}); From 14692abe3b035e8817f8536da528e08160501982 Mon Sep 17 00:00:00 2001 From: Aditya Wakharkar <130750636+codewith-aditya@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:34:47 +0530 Subject: [PATCH 04/16] fix(codex): keep profile keys OpenCodex does not manage (#8074) * fix(codex): keep profile keys OpenCodex does not manage Reinjection rebuilt ~/.codex/opencodex.config.toml from the template, so an operator's own Codex root keys (stream_idle_timeout_ms, stream_max_retries, ...) vanished on the next start without a message. Carry root keys this build does not own into the rebuilt profile, ahead of the first table. Values that span several lines are copied whole, keys owned by either profile shape (model_provider, model_catalog_json, openai_base_url) are always rewritten, and a profile with nothing to carry keeps the generated bytes byte for byte. * fix(codex): carry unmanaged profile keys as source text and keep generated tables authoritative Carried root assignments keep their original bytes, so an integer outside the JavaScript safe range in an array or inline table is no longer dropped. A carried key named like a table the generated profile writes (features, model_providers) is dropped instead of redefining that table. The injector re-reads opencodex.config.toml under the write lock and refuses retryably when it changed after the plan read it. * docs(codex): describe which profile keys survive regeneration * fix(codex): keep a carried assignment's trailing spaces * test(codex): keep profile preservation tests inside their own Codex home Install an isolated CODEX_HOME before loading the path-dependent modules and refuse to run when the profile path is outside a test home, so a run without the repository preload cannot remove a real profile. * fix(codex): close multiline strings that end with extra quotes TOML lets a multiline string close with up to two content quotes before its delimiter. The structural scanner treated those as a new string and swallowed the following root keys, so profile regeneration dropped them. * fix(codex): roll back the profile only when this injection published it A refusal before the profile publish, including the stale-profile check, restored the captured pre-image over an edit that landed after the capture. Rollback now reverts the profile only when this operation published it and the file still holds those bytes; config and journal rollback are unchanged. * fix(codex): treat a profile removed before the plan reads it as absent The plan checked existsSync and then read the profile, so a removal in between aborted the run with a raw ENOENT. It now reads directly, treats ENOENT as absent and rethrows other errors; the lock-time comparison still refuses retryably if the profile reappears. * fix(codex): read the profile at lock time without an existence check The lock-time comparison now shares the plan's reader, so a profile removed during the read is treated as absent and the change is refused retryably instead of escaping as ENOENT. --------- Co-authored-by: T Co-authored-by: JUN --- .../docs/fr/guides/codex-integration.md | 10 + .../content/docs/guides/codex-integration.md | 10 + .../docs/ja/guides/codex-integration.md | 2 + .../docs/ko/guides/codex-integration.md | 2 + .../docs/ru/guides/codex-integration.md | 10 + .../docs/tr/guides/codex-integration.md | 13 + .../docs/zh-cn/guides/codex-integration.md | 7 + .../docs/zh-tw/guides/codex-integration.md | 7 + .../test-layout/layout-codex-collision.json | 3 +- src/codex/inject-coordination.ts | 11 + src/codex/inject.ts | 16 +- src/codex/inject/config-toml.ts | 88 +++++ src/codex/inject/plan.ts | 41 ++- src/codex/toml-source-lines.ts | 85 ++++- structure/config.md | 2 +- tests/codex-integration/codex-inject.test.ts | 93 ++++++ .../codex-profile-preservation.test.ts | 306 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 2 +- 18 files changed, 679 insertions(+), 29 deletions(-) create mode 100644 tests/codex-integration/codex-profile-preservation.test.ts diff --git a/docs-site/src/content/docs/fr/guides/codex-integration.md b/docs-site/src/content/docs/fr/guides/codex-integration.md index 8bdfd730b12..1ef8e55d72c 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -134,6 +134,16 @@ configuration de référence et de repli. Sur l'interface de bouclage, ce fichie vous pouvez fusionner manuellement si l'injection automatique a été supprimée ; hors bouclage, il contient la forme avec fournisseur dédié. Le mode de fournisseur externe laisse ce profil intact. +Quand ce fichier est régénéré, les clés racine simples qu'OpenCodex ne gère pas sont conservées : un réglage +que vous ajoutez, comme `stream_idle_timeout_ms` ou `stream_max_retries`, survit à l'injection suivante. +Chaque affectation conservée est copiée telle quelle, y compris les valeurs sur plusieurs lignes et les entiers +64 bits, et placée avant la première table. Les clés à l'intérieur des tables et les clés racine pointées comme +`a.b = 1` ne sont pas conservées. En cas de conflit, OpenCodex l'emporte toujours : les clés racine qu'il gère +(`model_provider`, `model_catalog_json`, `openai_base_url`) sont réécrites pour le mode de routage actif, et +une clé racine portant le nom d'une table écrite par le profil, par exemple un `features = { ... }` en ligne +quand le profil écrit `[features]`, est supprimée. Si le fichier change pendant qu'une injection attend le +verrou d'écriture, l'injection s'arrête avec une erreur réessayable et laisse votre modification en place. + :::caution Les clés racine telles que `openai_base_url`, `model_provider` et `model_catalog_json` **doivent** précéder le premier en-tête `[table]`. L'injecteur garantit ce placement, supprime ses propres copies obsolètes ou en double diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 5e656662799..89e1419cf3a 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -412,6 +412,16 @@ reference/fallback config. On loopback it contains the root keys you can merge m injection was removed; on non-loopback it contains the dedicated provider form. External-provider mode leaves this profile untouched. +Rebuilding that file keeps the plain root keys OpenCodex does not manage, so a setting you add such as +`stream_idle_timeout_ms` or `stream_max_retries` survives the next injection. Each kept assignment is +copied exactly as written, including values that span several lines and 64-bit integers, and is placed +above the first table. Keys inside tables and dotted root keys such as `a.b = 1` are not kept. +OpenCodex wins every collision: the root keys it owns (`model_provider`, `model_catalog_json`, +`openai_base_url`) are rewritten for the routing mode in use, and a root key named like a table the +profile writes, such as an inline `features = { ... }` when the profile writes `[features]`, is +dropped. If the file changes while an injection waits for the write lock, the injection stops with a +retryable error and leaves your edit in place. + :::caution Root keys such as `openai_base_url`, `model_provider`, and `model_catalog_json` **must** sit before the first `[table]` header. The injector guarantees that placement, removes its own stale/duplicate diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index 908fdd1bbc6..57d16985e00 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -94,6 +94,8 @@ env_key = "OPENCODEX_API_AUTH_TOKEN" OpenCodex がルーティングを所有している場合、どちらのモードも参照/フォールバック設定として `$CODEX_HOME/opencodex.config.toml` を書き込みます。ループバックでは、自動挿入が削除された場合に手動でマージできるルート キーが含まれています。非ループバックでは、専用のプロバイダー フォームが含まれます。外部プロバイダー モードでは、このプロファイルは変更されません。 +このファイルを再生成しても、OpenCodex が管理しない単純なルート キーは保持されます。そのため、`stream_idle_timeout_ms` や `stream_max_retries` のように追加した設定は次の挿入後も残ります。保持される代入は、複数行にわたる値や 64 ビット整数も含めて書かれたとおりにコピーされ、最初のテーブルより前に置かれます。テーブル内のキーと、`a.b = 1` のようなドット付きルート キーは保持されません。衝突した場合は常に OpenCodex が優先されます。OpenCodex が所有するルート キー(`model_provider`、`model_catalog_json`、`openai_base_url`)は使用中のルーティング モードに合わせて書き直され、プロファイルが書き込むテーブルと同じ名前のルート キー(プロファイルが `[features]` を書き込むときのインライン `features = { ... }` など)は削除されます。挿入が書き込みロックを待っている間にファイルが変更された場合、挿入は再試行可能なエラーで停止し、編集内容はそのまま残ります。 + :::caution `openai_base_url`、`model_provider`、`model_catalog_json` などのルート キーは、最初の `[table]` ヘッダーの前に**なければなりません**。インジェクターはその配置を保証し、それ自身の古い/重複したコピーを削除し、ユーザー所有のルート `openai_base_url` を決して上書きしません。存在する場合、sync はカタログを更新しますが、ルーティングが挿入されなかったことを報告します。 ::: diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index 717a24f1da0..9de4a3d9e7f 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -196,6 +196,8 @@ env_key = "OPENCODEX_API_AUTH_TOKEN" OpenCodex가 라우팅을 소유할 때는 두 모드 모두 `$CODEX_HOME/opencodex.config.toml`을 참고용/폴백 설정으로 작성합니다. loopback에서는 자동 주입이 사라졌을 때 수동으로 합칠 수 있는 root key가 들어가고, non-loopback에서는 전용 provider 형식이 들어갑니다. 외부 provider 모드는 이 프로필을 건드리지 않습니다. +이 파일을 다시 만들 때 OpenCodex가 관리하지 않는 일반 root key는 그대로 남습니다. 그래서 `stream_idle_timeout_ms`나 `stream_max_retries`처럼 직접 추가한 설정은 다음 주입 뒤에도 유지됩니다. 남는 대입문은 여러 줄에 걸친 값과 64비트 정수까지 작성한 그대로 복사되고, 첫 번째 테이블보다 앞에 놓입니다. 테이블 안의 키와 `a.b = 1` 같은 점 표기 root key는 남지 않습니다. 충돌하면 항상 OpenCodex 쪽이 이깁니다. OpenCodex가 소유한 root key(`model_provider`, `model_catalog_json`, `openai_base_url`)는 현재 라우팅 모드에 맞게 다시 쓰이고, 프로필이 쓰는 테이블과 이름이 같은 root key(프로필이 `[features]`를 쓸 때의 인라인 `features = { ... }` 등)는 버려집니다. 주입이 쓰기 잠금을 기다리는 동안 파일이 바뀌면 주입은 재시도 가능한 오류로 멈추고 수정한 내용은 그대로 둡니다. + :::caution `openai_base_url`, `model_provider`, `model_catalog_json` 같은 root key는 첫 번째 `[table]` 헤더보다 **반드시** 앞에 있어야 합니다. 인젝터는 그 위치를 보장하고, 자신이 남긴 오래되었거나 중복된 복사본은 지웁니다. 사용자가 소유한 root `openai_base_url`은 덮어쓰지 않습니다. 그런 값이 있으면 sync는 카탈로그만 갱신하고 라우팅은 주입하지 않았다고 알립니다. ::: diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index 884eec3e4da..035244e70cb 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -137,6 +137,16 @@ reference/fallback-конфиг. На loopback в нём лежат root key, к если автоматическое внедрение убрали; на не-loopback — форма с выделенным провайдером. Режим external-provider этот профиль не трогает. +При пересоздании файла простые root key, которыми OpenCodex не управляет, сохраняются, поэтому +добавленная вами настройка вроде `stream_idle_timeout_ms` или `stream_max_retries` переживает следующее +внедрение. Каждое сохранённое присваивание копируется как есть, включая многострочные значения и 64-битные +целые, и ставится перед первой таблицей. Ключи внутри таблиц и root key с точками вроде `a.b = 1` не +сохраняются. При конфликте всегда побеждает OpenCodex: его root key (`model_provider`, +`model_catalog_json`, `openai_base_url`) переписываются под текущий режим маршрутизации, а root key с +именем таблицы, которую пишет профиль (например, inline `features = { ... }`, когда профиль пишет +`[features]`), отбрасывается. Если файл меняется, пока внедрение ждёт блокировку записи, внедрение +останавливается с ошибкой, которую можно повторить, и ваша правка остаётся на месте. + :::caution Root key вроде `openai_base_url`, `model_provider` и `model_catalog_json` **обязаны** располагаться до первого заголовка `[table]`. Injector гарантирует это размещение, удаляет собственные diff --git a/docs-site/src/content/docs/tr/guides/codex-integration.md b/docs-site/src/content/docs/tr/guides/codex-integration.md index fce593de37c..30862e73896 100644 --- a/docs-site/src/content/docs/tr/guides/codex-integration.md +++ b/docs-site/src/content/docs/tr/guides/codex-integration.md @@ -150,6 +150,19 @@ döngüde otomatik enjeksiyon kaldırılmışsa manuel olarak birleştirebilece kök anahtarları içerir; geri döngü olmayanda ise özel sağlayıcı formunu içerir. Harici sağlayıcı modu bu profile dokunmaz. +Bu dosya yeniden oluşturulurken OpenCodex'in yönetmediği düz kök anahtarlar +korunur; böylece `stream_idle_timeout_ms` veya `stream_max_retries` gibi +eklediğiniz bir ayar sonraki enjeksiyondan sonra da kalır. Korunan her atama, +birden çok satıra yayılan değerler ve 64 bit tamsayılar dahil yazıldığı gibi +kopyalanır ve ilk tablonun önüne yerleştirilir. Tablo içindeki anahtarlar ve +`a.b = 1` gibi noktalı kök anahtarlar korunmaz. Çakışmada her zaman OpenCodex +kazanır: sahip olduğu kök anahtarlar (`model_provider`, `model_catalog_json`, +`openai_base_url`) kullanılan yönlendirme moduna göre yeniden yazılır ve +profilin yazdığı bir tabloyla aynı adı taşıyan kök anahtar (profil `[features]` +yazarken satır içi `features = { ... }` gibi) atılır. Enjeksiyon yazma kilidini +beklerken dosya değişirse enjeksiyon yeniden denenebilir bir hatayla durur ve +düzenlemeniz yerinde kalır. + :::caution `openai_base_url`, `model_provider` ve `model_catalog_json` gibi kök anahtarlar ilk `[tablo]` başlığından önce **bulunmalıdır**. Enjektör bu yerleşimi garanti diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index af2df25fe35..05d5718e975 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -124,6 +124,13 @@ env_key = "OPENCODEX_API_AUTH_TOKEN" 在 loopback 情况下,它包含可在自动注入被移除后手动合并的 root keys;在非 loopback 情况下, 它包含专用 provider 形式。外部 provider 模式会保持这个 profile 不变。 +重新生成这个文件时,会保留 OpenCodex 不管理的普通 root keys,所以你添加的 `stream_idle_timeout_ms` 或 +`stream_max_retries` 这类设置在下一次注入后仍然存在。保留的赋值按原样复制,包括跨多行的值和 64 位整数, +并放在第一个表之前。表内的键以及 `a.b = 1` 这样的点分 root key 不会保留。发生冲突时总是以 OpenCodex +为准:它拥有的 root keys(`model_provider`、`model_catalog_json`、`openai_base_url`)会按当前路由模式 +重写;与 profile 写入的表同名的 root key(例如 profile 写入 `[features]` 时的内联 `features = { ... }`) +会被丢弃。如果在注入等待写锁期间文件发生变化,注入会以可重试的错误停止,并保留你的修改。 + :::caution 像 `openai_base_url`、`model_provider` 和 `model_catalog_json` 这样的 root keys **必须** 位于第一个 `[table]` 头之前。注入器会保证这一点,移除自己留下的陈旧/重复副本,并且绝不会覆盖用户拥有的 root diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md index d3c3a0c0741..e8eded5bcaf 100644 --- a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md @@ -121,6 +121,13 @@ env_key = "OPENCODEX_API_AUTH_TOKEN" 設定。loopback 模式下,其中包含自動注入被移除時可手動合併的根級鍵;non-loopback 模式下,其中包含 專用 provider 形式。外部 provider 模式不會修改此 profile。 +重新產生此檔案時,會保留 OpenCodex 不管理的一般根級鍵,因此你新增的 `stream_idle_timeout_ms` 或 +`stream_max_retries` 等設定在下一次注入後仍會存在。保留的賦值會照原樣複製,包括跨多行的值與 64 位元 +整數,並放在第一個表之前。表內的鍵以及 `a.b = 1` 這類點分根級鍵不會保留。發生衝突時一律以 OpenCodex +為準:它擁有的根級鍵(`model_provider`、`model_catalog_json`、`openai_base_url`)會依目前的路由模式 +重寫;與 profile 寫入的表同名的根級鍵(例如 profile 寫入 `[features]` 時的內嵌 `features = { ... }`) +會被捨棄。若注入等待寫入鎖期間檔案有變動,注入會以可重試的錯誤停止,並保留你的修改。 + :::caution `openai_base_url`、`model_provider`、`model_catalog_json` 等根級鍵**必須**位於第一個 `[table]` 標頭之前。注入器會保證此位置、移除自己留下的舊值或重複項,而且絕不覆寫使用者自有的根級 diff --git a/scripts/test-layout/layout-codex-collision.json b/scripts/test-layout/layout-codex-collision.json index 6ceb8c4b7dd..e66ec93d488 100644 --- a/scripts/test-layout/layout-codex-collision.json +++ b/scripts/test-layout/layout-codex-collision.json @@ -43,5 +43,6 @@ "codex-stored-main-duplicate.test.ts": "codex-integration", "codex-account-slot-identity.test.ts": "codex-integration", "codex-pool-caller-fallback-policy.test.ts": "codex-integration", - "codex-windows-current-sid.test.ts": "codex-integration" + "codex-windows-current-sid.test.ts": "codex-integration", + "codex-profile-preservation.test.ts": "codex-integration" } diff --git a/src/codex/inject-coordination.ts b/src/codex/inject-coordination.ts index 04a959a5633..01e1f85e6f0 100644 --- a/src/codex/inject-coordination.ts +++ b/src/codex/inject-coordination.ts @@ -189,6 +189,9 @@ export function captureCodexPreImages(): CodexPreImages { }; } +/** The profile bytes one injection published, set just before it writes them. */ +export interface CodexProfilePublish { published?: string } + /** * How many transactions of evidence the ledger keeps. * @@ -366,10 +369,16 @@ export function recordCodexNativeTransactionProvenance( * * Every surface is attempted even after one fails: a second failure is worth * knowing about, and stopping early would leave more unrestored than necessary. + * + * `profilePublish`, when given, limits the profile surface to bytes this + * operation wrote: it is reverted only if `published` is set and the file still + * holds exactly those bytes. A refusal before the publish, or a profile another + * writer replaced since, is left as it is. */ export function restoreCodexPreImages( pre: CodexPreImages, heldConfigWriteLock?: LockHandle, + profilePublish?: CodexProfilePublish, ): { complete: boolean; unrestored: readonly string[] } { const locked = withConfigWriteLockHeld(CODEX_CONFIG_PATH, heldConfigWriteLock, held => { const unrestored: string[] = []; @@ -382,6 +391,8 @@ export function restoreCodexPreImages( // The current journal remains recovery authority until both artifacts are restored. if (name === "journal" && unrestored.length > 0) { unrestored.push(name); continue; } try { + if (name === "profile" && profilePublish + && (profilePublish.published === undefined || readOrNull(path) !== profilePublish.published)) continue; publishCodexArtifact(path, held, (destination, hooks) => { if (bytes === null) { hooks.validateBeforeRename?.(destination); diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 634f7314144..fd773b101b6 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -25,6 +25,7 @@ import { captureCodexPreImages, codexInjectLockOutcome, codexWriteCoordinationEligibility, + type CodexProfilePublish, CodexPartialWriteError, CodexWriteConflictError, DEFAULT_INJECT_LOCK_TIMEOUT_MS, @@ -82,6 +83,7 @@ import { deriveCodexInjectionPlan, type CodexInjectionPlanContext, type CodexInjectionPlanOk, + readCodexProfileSource, } from "./inject/plan"; export { effectiveLoopbackListenerPort, isLoopbackHostname, shouldInjectApiAuthHeader } from "./loopback-target"; @@ -378,9 +380,7 @@ async function injectCodexConfigImpl( && rootTomlString(nativeInput, REALTIME_WS_BASE_URL_KEY) === journaledRealtimeWsBaseUrl); return !hasInjectedCodexRouting(nativeInput) && !looksInjectedByValue; }; - const readCurrentProfile = (): string | null => existsSync(CODEX_PROFILE_PATH) - ? readFileSync(CODEX_PROFILE_PATH, "utf-8") - : null; + const readCurrentProfile = readCodexProfileSource; const unverifiedJournalMessage = "Codex configuration was not written: the journal has no verified baseline for the current config/profile. Current files and the journal were preserved."; // When the reconcile will rewrite config.toml under the lock, the baseline it // must be journaled against does not exist yet — this check runs inside the @@ -440,6 +440,8 @@ async function injectCodexConfigImpl( message: "Codex config injection refused: config.toml changed while the write lock was being acquired. Retry to inject on the latest bytes.", }); } + // The profile carries unmanaged root keys from the bytes the plan read; a profile edit since then would be overwritten. + if (readCurrentProfile() !== admittedPlan.profileSource) throw new CodexInjectRefusal({ success: false, retryable: true, message: "Codex config injection refused: opencodex.config.toml changed while the write lock was being acquired. Retry to inject on the latest bytes." }); if (missingConfig) publishCodexArtifact(CODEX_CONFIG_PATH, heldConfigWriteLock, (_path, hooks) => { hooks.validateBeforeRename?.(CODEX_CONFIG_PATH); createEmptyCodexConfigInBoundary(); hooks.afterRename?.(CODEX_CONFIG_PATH); }); let nativeInput = rawContent; let plan = admittedPlan; @@ -490,6 +492,7 @@ async function injectCodexConfigImpl( publishCodexArtifact(CODEX_CONFIG_PATH, heldConfigWriteLock, (path, hooks) => atomicWriteFile(path, plan.content, undefined, hooks)); historyArtifactStageForTests?.("after-config"); assertCodexHomeOwner(getCodexHome()); + profilePublish.published = plan.profileContent; publishCodexArtifact(CODEX_PROFILE_PATH, heldConfigWriteLock, (path, hooks) => atomicWriteFile(path, plan.profileContent, undefined, hooks)); publishCodexArtifact(JOURNAL_PATH, heldConfigWriteLock, (_path, hooks) => markJournalInjectedState(plan.content, plan.profileContent, { // A root override is ours whenever we wrote one and no user-owned value won. That is @@ -529,6 +532,7 @@ async function injectCodexConfigImpl( * legacy-uncoordinated home, which publishes no transition to resolve. */ let transitionReceipt: { nativeGeneration: number; currentTxId: string } | undefined; + const profilePublish: CodexProfilePublish = {}; // rollback reverts only profile bytes this operation published /* * The file-level write lock every config.toml writer shares (see @@ -594,7 +598,7 @@ async function injectCodexConfigImpl( effectivePlan = resolved.plan; } catch (error) { if (error instanceof ConfigWriteDestinationChanged || codexHomeOwnerBlocksCompensation(getCodexHome(), error)) throw error; - const restored = restoreCodexPreImages(preImages, heldConfigWriteLock); + const restored = restoreCodexPreImages(preImages, heldConfigWriteLock, profilePublish); if (!restored.complete) throw new CodexPartialWriteError(restored.unrestored); throw error; } @@ -614,7 +618,7 @@ async function injectCodexConfigImpl( ...(eligibility.kind === "adopt" ? { adoption: { direction: "apply" as const } } : {}), onPostCallbackFailure: () => { if (!coordinatedPreImages) throw new Error("Codex injection preimages were not captured."); - const restored = restoreCodexPreImages(coordinatedPreImages, heldConfigWriteLock); + const restored = restoreCodexPreImages(coordinatedPreImages, heldConfigWriteLock, profilePublish); if (!restored.complete) throw new CodexPartialWriteError(restored.unrestored); }, admitted: { authoritySnapshotId: witness.comparisonId }, @@ -691,7 +695,7 @@ async function injectCodexConfigImpl( if (error instanceof ConfigWriteDestinationChanged || codexHomeOwnerBlocksCompensation(getCodexHome(), error)) throw error; // Compensate, then ALWAYS throw. Returning a partial result would let the // lock commit a row describing an apply that did not finish. - const restored = restoreCodexPreImages(preImages, heldConfigWriteLock); + const restored = restoreCodexPreImages(preImages, heldConfigWriteLock, profilePublish); if (!restored.complete) { throw new CodexPartialWriteError(restored.unrestored); } diff --git a/src/codex/inject/config-toml.ts b/src/codex/inject/config-toml.ts index 7c4a69065a2..293a6ad7347 100644 --- a/src/codex/inject/config-toml.ts +++ b/src/codex/inject/config-toml.ts @@ -672,6 +672,94 @@ export function buildProfileFileForTarget( return lines.join("\n"); } +/** + * Root keys this build owns in either profile shape. They are never carried + * forward: leaving provider-table mode drops `model_provider` from the template, + * and a caller that passes no catalog path drops `model_catalog_json`, so a + * carried copy would point the profile at routing this build no longer writes. + */ +const MANAGED_PROFILE_ROOT_KEYS = new Set([ + "model_provider", + "model_catalog_json", + "openai_base_url", +]); + +/** + * Root keys the previous profile carried that this build does not write. + * + * Operators add Codex settings such as `stream_idle_timeout_ms` and + * `stream_max_retries` to `opencodex.config.toml`. Rebuilding the file from the + * template dropped them without a word (#8066), so any root key this build does + * not own is carried over, ahead of the first table. Values that span several + * lines are copied whole, because one line of a multi-line value would not + * parse, and every carried assignment keeps its original bytes: nothing is + * decoded and reserialized, so a 64-bit integer Bun cannot represent survives. + * + * A carried root key named like a table the generated profile writes (an inline + * `features = { ... }` against a generated `[features]`) is dropped: TOML + * forbids defining the same table twice, and the generated table wins because + * it carries the settings this build manages. + */ +export function preserveUnmanagedProfileKeys(existing: string | null, generated: string): string { + if (!existing) return generated; + const generatedKeys = rootKeysOf(generated); + if (generatedKeys.size === 0) return generated; + const generatedTables = topLevelTableNamesOf(generated); + + const existingSource = rootSourceLines(existing); + const carried: string[] = []; + const carriedKeys = new Set(); + for (let index = 0; index < existingSource.rootEnd; index += 1) { + if (!existingSource.lines[index]?.structural) continue; + const span = sourceAssignmentSpan(existingSource.lines, index, existingSource.rootEnd); + if (!span) continue; + index = span.end - 1; + if (generatedKeys.has(span.key) || MANAGED_PROFILE_ROOT_KEYS.has(span.key)) continue; + if (generatedTables.has(span.key)) continue; + if (carriedKeys.has(span.key)) continue; + carriedKeys.add(span.key); + // Drop only trailing blank lines; the assignment's own bytes, trailing spaces included, stay. + carried.push(span.text.replace(/(?:\r?\n[ \t]*)+$/, "")); + } + if (carried.length === 0) return generated; + + const lines = generated.split("\n"); + const firstTable = lines.findIndex((line) => /^\s*\[/.test(line)); + lines.splice(firstTable === -1 ? lines.length : firstTable, 0, [ + "# Root keys OpenCodex does not manage, kept from the previous profile:", + ...carried, + "", + ].join("\n")); + return lines.join("\n"); +} + +/** Names of the root assignments in a TOML document, before its first table. */ +function rootKeysOf(content: string): Set { + const keys = new Set(); + const source = rootSourceLines(content); + for (let index = 0; index < source.rootEnd; index += 1) { + if (!source.lines[index]?.structural) continue; + const span = sourceAssignmentSpan(source.lines, index, source.rootEnd); + if (!span) continue; + keys.add(span.key); + index = span.end - 1; + } + return keys; +} + +/** First path segment of every table header in a generated document. */ +function topLevelTableNamesOf(content: string): Set { + const names = new Set(); + for (const line of rootSourceLines(content).lines) { + if (!line.structural || !/^\s*\[/.test(line.text)) continue; + try { + const name = Object.keys(Bun.TOML.parse(line.text))[0]; + if (name !== undefined) names.add(name); + } catch { /* the template only writes valid headers */ } + } + return names; +} + export function chooseCatalogPathForInjection( content: string, requested?: string | null, diff --git a/src/codex/inject/plan.ts b/src/codex/inject/plan.ts index 10918796d1e..4b027342229 100644 --- a/src/codex/inject/plan.ts +++ b/src/codex/inject/plan.ts @@ -9,6 +9,7 @@ * post-transition bytes is what keeps the committed file from re-enabling the * flag the reconcile just turned off. */ +import { existsSync, readFileSync } from "node:fs"; import { websocketsEnabled } from "../../config"; import { HISTORY_RELABEL_STANDS_DOWN, @@ -22,7 +23,7 @@ import { journaledReplacedRootWebSearch, } from "../journal"; import { stripJournaledOpenaiBaseUrl } from "../injected-marker"; -import { CODEX_CONFIG_PATH, resolveCodexStateDbPath } from "../paths"; +import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, resolveCodexStateDbPath } from "../paths"; import { transformManagedSubagentDefaults, type WrittenSubagentDefaults } from "../subagent-defaults"; import type { OcxConfig } from "../../types"; import type { CodexWriteCandidate } from "../write-coordination"; @@ -35,6 +36,7 @@ import { ensureFastModeFeature, ensureRootWebSearchDisabled, normalizeServiceTier, + preserveUnmanagedProfileKeys, removeProfileSection, setRootModelCatalogPath, setRootModelProvider, @@ -71,6 +73,11 @@ export interface CodexInjectionPlanOk { content: string; /** The exact string about to replace the profile file. */ profileContent: string; + /** + * The profile bytes `profileContent` preserved keys from (null when absent). + * Re-checked under the write lock so an edit landing meanwhile is not overwritten. + */ + profileSource: string | null; /** The resolved catalog path, never the raw option. */ catalogPath: string | null; providerTableMode: boolean; @@ -323,12 +330,16 @@ export function deriveCodexInjectionPlan( managedDefaultsMessage = ` ⚠️ ${nativeSubagentDefaultsWarning}\n`; } - const profileContent = buildProfileFileForTarget( - routingTarget, - catalogPath, - websocketsForRoutingTarget(config, routingTarget), - config?.fastMode, - config?.codexProviderDisplayName, + const profileSource = readCodexProfileSource(); + const profileContent = preserveUnmanagedProfileKeys( + profileSource, + buildProfileFileForTarget( + routingTarget, + catalogPath, + websocketsForRoutingTarget(config, routingTarget), + config?.fastMode, + config?.codexProviderDisplayName, + ), ); content = applyEol(content, eol); @@ -395,6 +406,7 @@ export function deriveCodexInjectionPlan( baselineContent, content, profileContent, + profileSource, catalogPath, providerTableMode, keepRootOverrideAlongsideTable, @@ -414,3 +426,18 @@ export function deriveCodexInjectionPlan( }, }; } + + +/** + * Current profile bytes, or null when absent. Read directly: a profile removed between an + * existence check and the read is simply absent. If it reappears before the write lock, the + * lock-time comparison refuses retryably; other read errors propagate. + */ +export function readCodexProfileSource(): string | null { + try { + return readFileSync(CODEX_PROFILE_PATH, "utf-8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + return null; + } +} diff --git a/src/codex/toml-source-lines.ts b/src/codex/toml-source-lines.ts index 1085bd1e3be..ba9d1e71963 100644 --- a/src/codex/toml-source-lines.ts +++ b/src/codex/toml-source-lines.ts @@ -84,22 +84,73 @@ export function sourceAssignmentSpan(lines: readonly SourceLine[], start: number const decoded = sourceAssignment(lines, start, limit); if (decoded) return decoded; const span = lexicalAssignmentSpan(lines, start, limit); - // Bun rejects valid TOML integers outside the JS safe-integer range. Only a complete - // scalar integer gets this exception: collections, strings and other parse failures do not. - if (!span || span.end !== start + 1) return null; - const keyToken = assignmentKeyToken.exec(span.text)!; - const scalar = /^([ \t]*)([+-]?(?:0|[1-9](?:_?[0-9])*)|0x[0-9A-Fa-f](?:_?[0-9A-Fa-f])*|0o[0-7](?:_?[0-7])*|0b[01](?:_?[01])*)([ \t]*(?:#[^\r\n]*)?)$/.exec(span.text.slice(keyToken[0].length)); - if (!scalar) return null; - const integer = BigInt(scalar[2]!.replaceAll("_", "")); - if (integer < -(1n << 63n) || integer >= (1n << 63n)) return null; - // Keep the parser responsible for the key, separator and comment grammar. Replacing - // this already validated integer for validation never edits or decodes the source value. + if (!span) return null; + // Bun rejects valid TOML integers outside the JS safe-integer range, as scalars and + // inside arrays or inline tables alike. Validate a copy in which each such integer that + // fits signed 64 bits reads `0`; the parser stays responsible for every other piece of + // grammar, and the returned span keeps the original bytes untouched. + const masked = maskWideIntegers(span.text); + if (masked === null) return null; try { - const parsed = Bun.TOML.parse(`${keyToken[0]}${scalar[1]}0${scalar[3]}`); + const parsed = Bun.TOML.parse(masked); return Object.keys(parsed).length === 1 && Object.hasOwn(parsed, span.key) ? span : null; } catch { return null; } } +const integerToken = /^(?:[+-]?(?:0|[1-9](?:_?[0-9])*)|0x[0-9A-Fa-f](?:_?[0-9A-Fa-f])*|0o[0-7](?:_?[0-7])*|0b[01](?:_?[01])*)$/; +const bareToken = /[A-Za-z0-9_+.:-]/; + +/** + * The assignment with every integer outside the JS safe range replaced by `0`, skipping + * strings and comments. Null when an integer does not fit signed 64 bits (TOML requires a + * reader to reject it) or when nothing needed masking, so the original parse failure stands. + */ +function maskWideIntegers(text: string): string | null { + let out = ""; + let masked = false; + let index = 0; + const copyUntil = (end: number) => { out += text.slice(index, end); index = end; }; + while (index < text.length) { + const char = text[index]!; + if (text.startsWith('"""', index) || text.startsWith("'''", index)) { + const delimiter = text.slice(index, index + 3); + let cursor = index + 3; + while (cursor < text.length && !text.startsWith(delimiter, cursor)) cursor += delimiter === '"""' && text[cursor] === "\\" ? 2 : 1; + cursor = Math.min(text.length, cursor + 3); + // A closing delimiter may carry up to two more quote characters of content. + for (let extra = 0; extra < 2 && text[cursor] === delimiter[0]; extra += 1) cursor += 1; + copyUntil(cursor); + } else if (char === '"' || char === "'") { + let cursor = index + 1; + while (cursor < text.length && text[cursor] !== char && text[cursor] !== "\n") cursor += char === '"' && text[cursor] === "\\" ? 2 : 1; + copyUntil(Math.min(text.length, cursor + 1)); + } else if (char === "#") { + const newline = text.indexOf("\n", index); + copyUntil(newline === -1 ? text.length : newline); + } else if (bareToken.test(char)) { + let cursor = index + 1; + while (cursor < text.length && bareToken.test(text[cursor]!)) cursor += 1; + const token = text.slice(index, cursor); + // A digit-only bare key (`{ 9223372036854775808 = 1 }`) is a name, not a number. + const isKey = /^[ \t]*[=.]/.test(text.slice(cursor)); + if (!isKey && integerToken.test(token)) { + const integer = BigInt(token.replaceAll("_", "")); + if (integer < -(1n << 63n) || integer >= (1n << 63n)) return null; + if (integer > BigInt(Number.MAX_SAFE_INTEGER) || integer < -BigInt(Number.MAX_SAFE_INTEGER)) { + out += "0"; + index = cursor; + masked = true; + continue; + } + } + copyUntil(cursor); + } else { + copyUntil(index + 1); + } + } + return masked ? out : null; +} + /** Cosmetic document whitespace only; opaque value lines retain every byte. */ export function normalizeStructuralWhitespace(content: string): string { const { bom, lines } = rootSourceLines(content); @@ -122,6 +173,13 @@ export function normalizeStructuralWhitespace(content: string): string { type MultilineStringKind = "basic" | "literal" | null; +/** Length of a closing multiline delimiter: TOML allows a run of three to five quotes. */ +function quoteRunLength(text: string, index: number, quote: string): number { + let end = index; + while (end < text.length && text[end] === quote && end - index < 5) end += 1; + return end - index; +} + /** * TOML table-looking text inside a multiline string is data, not syntax. Keep * a deliberately small lexical state machine so the format-preserving editor @@ -139,8 +197,9 @@ function markStructuralLines(lines: SourceLine[]): void { for (let index = 0; index < line.text.length;) { if (multiline === "basic") { if (line.text.startsWith('"""', index)) { + // A closing delimiter may carry up to two content quotes before it. multiline = null; - index += 3; + index += quoteRunLength(line.text, index, '"'); } else if (line.text[index] === "\\") { index += 2; } else { @@ -151,7 +210,7 @@ function markStructuralLines(lines: SourceLine[]): void { if (multiline === "literal") { if (line.text.startsWith("'''", index)) { multiline = null; - index += 3; + index += quoteRunLength(line.text, index, "'"); } else { index += 1; } diff --git a/structure/config.md b/structure/config.md index 526a1f9bfb3..c4208d3565b 100644 --- a/structure/config.md +++ b/structure/config.md @@ -384,7 +384,7 @@ report is additive. When opencodex owns routing, it also writes `$CODEX_HOME/opencodex.config.toml` as an explicit profile target. Codex config uses `service_tier = "fast"` and `[features].fast_mode = true`; -catalog/request tier metadata may use `priority`. Do not collapse these spellings into one value. Provider `responseTierAuthoritative` is an optional strict boolean validated by `src/config/schema/leaf-validators.ts`; it changes response evidence only, as defined in the [response-tier observation contract](transports/responses.md#response-tier-observation-authority). +catalog/request tier metadata may use `priority`. Do not collapse these spellings into one value. Provider `responseTierAuthoritative` is an optional strict boolean validated by `src/config/schema/leaf-validators.ts`; it changes response evidence only, as defined in the [response-tier observation contract](transports/responses.md#response-tier-observation-authority). Regenerating that profile (`preserveUnmanagedProfileKeys` in `src/codex/inject/config-toml.ts`) carries the previous profile's plain root assignments forward as their original source text, above the first table. Nothing is decoded and reserialized, so a 64-bit integer Bun cannot represent survives. The generated profile wins every collision: keys in `MANAGED_PROFILE_ROOT_KEYS` are never carried, even when the active template omits them, and a carried key named like the first segment of a generated table (`features`, `model_providers`) is dropped, because TOML forbids defining a table twice. Table bodies and dotted root keys are not carried. The injector re-reads the profile under the write lock and refuses retryably when it differs from the bytes the plan carried from (`tests/codex-integration/codex-profile-preservation.test.ts`). That refusal leaves the profile untouched. After a later failure, rollback reverts the profile only when this injection published it and the file still holds exactly those bytes, so an edit another writer landed survives (the `profilePublish` argument of `restoreCodexPreImages`); config and journal roll back as before. ## Provider output defaults diff --git a/tests/codex-integration/codex-inject.test.ts b/tests/codex-integration/codex-inject.test.ts index 117d4c19311..d11ee64a7e4 100644 --- a/tests/codex-integration/codex-inject.test.ts +++ b/tests/codex-integration/codex-inject.test.ts @@ -18,6 +18,7 @@ import { DEFAULT_CODEX_PROVIDER_DISPLAY_NAME, buildProfileFileForTarget, buildProviderTableBlockForTarget, + preserveUnmanagedProfileKeys, resolveCodexProviderDisplayName, } from "../../src/codex/inject/config-toml"; import { @@ -935,3 +936,95 @@ test("malformed TOML preserves user routing and does not enable context injectio } } }); + +describe("profile file round trip", () => { + test("keeps the root keys an operator added to the profile", () => { + const generated = [ + "# OpenCodex proxy profile — use with: codex --profile opencodex", + "# Routes all model requests through the opencodex proxy at 127.0.0.1:10100", + 'model_provider = "opencodex"', + "", + "[features]", + "fast_mode = true", + "", + ].join("\n"); + const existing = [ + 'model_provider = "opencodex"', + "stream_idle_timeout_ms = 60000", + "stream_max_retries = 8", + "", + "[features]", + "fast_mode = true", + "", + ].join("\n"); + + const merged = preserveUnmanagedProfileKeys(existing, generated); + + expect(merged).toContain("stream_idle_timeout_ms = 60000"); + expect(merged).toContain("stream_max_retries = 8"); + // Keys this build owns are not duplicated, and carried keys stay above the + // first table so they remain root keys. + expect(merged.match(/model_provider =/g)).toHaveLength(1); + expect(merged.indexOf("stream_idle_timeout_ms")).toBeLessThan(merged.indexOf("[features]")); + }); + + test("copies a value that continues on another line whole", () => { + const generated = 'model_provider = "opencodex"\n'; + const existing = [ + 'model_provider = "opencodex"', + "notify = [", + ' "first",', + ' "second",', + "]", + "stream_max_retries = 8", + "", + ].join("\n"); + + const merged = preserveUnmanagedProfileKeys(existing, generated); + + expect(merged).toContain('notify = [\n "first",\n "second",\n]'); + expect(merged).toContain("stream_max_retries = 8"); + // The result still parses, which a half-copied array would not. + expect(() => Bun.TOML.parse(merged)).not.toThrow(); + }); + + test("never carries a key this build owns, even when the template omits it", () => { + const rootOverride = 'openai_base_url = "http://127.0.0.1:10100/v1"\n'; + const existing = [ + 'model_provider = "opencodex"', + 'model_catalog_json = "/home/u/.codex/opencodex-catalog.json"', + "stream_max_retries = 8", + "", + ].join("\n"); + + const merged = preserveUnmanagedProfileKeys(existing, rootOverride); + + // Provider-table mode wrote model_provider; this build is in root-override + // mode and must not resurrect it. A null catalog path means the same for + // model_catalog_json. + expect(merged).not.toContain("model_provider"); + expect(merged).not.toContain("model_catalog_json"); + expect(merged).toContain("stream_max_retries = 8"); + }); + + test("leaves the generated profile alone when there is nothing to carry", () => { + const generated = 'model_provider = "opencodex"\n'; + expect(preserveUnmanagedProfileKeys(null, generated)).toBe(generated); + expect(preserveUnmanagedProfileKeys('model_provider = "opencodex"\n', generated)).toBe( + generated, + ); + }); + + test("carries keys through a real profile rebuild", () => { + const target = standaloneCodexRoutingTarget(10100, {}); + const generated = buildProfileFileForTarget(target, null); + const firstTable = generated.indexOf("["); + const prefix = generated.endsWith("\n") ? generated : `${generated}\n`; + const existing = + firstTable === -1 + ? `${prefix}stream_max_retries = 8\n` + : `${generated.slice(0, firstTable)}stream_max_retries = 8\n${generated.slice(firstTable)}`; + + expect(preserveUnmanagedProfileKeys(existing, generated)).toContain("stream_max_retries = 8"); + }); +}); diff --git a/tests/codex-integration/codex-profile-preservation.test.ts b/tests/codex-integration/codex-profile-preservation.test.ts new file mode 100644 index 00000000000..f69a46741ad --- /dev/null +++ b/tests/codex-integration/codex-profile-preservation.test.ts @@ -0,0 +1,306 @@ +/** + * Rebuilding `opencodex.config.toml` keeps the operator's unmanaged root keys + * (#8066). These cases pin the parts that can corrupt the file: original bytes + * survive values Bun cannot decode, a carried key never collides with a table the + * generated profile writes, the plan reads the real profile path, and an edit that + * lands before the write lock is not overwritten. + */ +import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir, userInfo } from "node:os"; +import { join, relative, isAbsolute } from "node:path"; +import type { OcxConfig } from "../../src/types"; +import { installIsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { repoRoot } from "../helpers/repo-root"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +// The profile path is fixed when src/codex/paths first loads, so own a Codex home before the +// path-dependent modules are imported. A run without the repository preload (an absolute test +// path from another directory) must never touch the developer's real profile. +const isolatedHome = installIsolatedCodexHome("ocx-profile-preservation-"); +const { buildProfileFileForTarget, preserveUnmanagedProfileKeys } = await import("../../src/codex/inject/config-toml"); +const { deriveCodexInjectionPlan } = await import("../../src/codex/inject/plan"); +const { CODEX_PROFILE_PATH } = await import("../../src/codex/paths"); +const within = (root: string, path: string) => { + const rel = relative(realpathSync(root), realpathSync(join(path, ".."))); + return !rel.startsWith("..") && !isAbsolute(rel); +}; +// Only this test's own home, or a preload sandbox HOME distinct from the account's real home. +const sandboxHome = process.env.HOME && process.env.HOME !== userInfo().homedir ? process.env.HOME : null; +if (!within(isolatedHome.path, CODEX_PROFILE_PATH) && !(sandboxHome && within(sandboxHome, CODEX_PROFILE_PATH))) { + throw new Error("codex-profile-preservation: refusing to run against a Codex profile outside a test home"); +} +afterAll(() => { isolatedHome.restore(); }); + +const loopback = { baseUrl: "http://127.0.0.1:10100/v1", requiresAdmissionToken: false, tokenEnv: "OPENCODEX_API_AUTH_TOKEN" as const }; +const remote = { baseUrl: "http://10.20.30.40:10100/v1", requiresAdmissionToken: true, tokenEnv: "OPENCODEX_API_AUTH_TOKEN" as const }; +const WIDE = "9223372036854775807"; + +afterEach(() => { rmSync(CODEX_PROFILE_PATH, { force: true }); }); + +describe("carried assignments keep their original bytes", () => { + test.each([ + `limits = [${WIDE}]`, + `limits = [\n 1,\n -9_223_372_036_854_775_808, # floor\n]`, + `limits = { ceiling = 0x7fff_ffff_ffff_ffff, label = "${WIDE}" }`, + ])("an integer Bun cannot represent survives regeneration (%s)", assignment => { + const generated = buildProfileFileForTarget(loopback, null, false, true); + const merged = preserveUnmanagedProfileKeys(`${assignment}\nstream_max_retries = 8\n`, generated); + expect(merged).toContain(`${assignment}\nstream_max_retries = 8\n`); + expect(merged.indexOf("limits")).toBeLessThan(merged.indexOf("[features]")); + // These are valid TOML 1.0 integers that Codex reads, but Bun cannot decode them (dev + // already preserves wide scalars this way), so check the rest of the document with + // them swapped for an in-range value. + expect(() => Bun.TOML.parse(merged.replace(/-?(?:9_?223_?372_?036_?854_?775_?80[78]|0x7fff_ffff_ffff_ffff)/g, "0"))).not.toThrow(); + }); + + test("a carried assignment keeps its trailing spaces and comment exactly", () => { + const assignment = "stream_max_retries = 8 # keep "; + const merged = preserveUnmanagedProfileKeys(`${assignment}\n\n\n`, buildProfileFileForTarget(loopback, null)); + expect(merged).toContain(`${assignment}\n`); + expect(Bun.TOML.parse(merged)).toMatchObject({ stream_max_retries: 8 }); + }); + + test("an integer outside signed 64 bits is invalid TOML and is not carried", () => { + const generated = buildProfileFileForTarget(loopback, null); + const merged = preserveUnmanagedProfileKeys("limits = [9223372036854775808]\nstream_max_retries = 8\n", generated); + expect(merged).not.toContain("limits"); + expect(Bun.TOML.parse(merged)).toMatchObject({ stream_max_retries: 8 }); + }); + + test("a digit-only key beside a wide integer is a name and is kept", () => { + const assignment = `limits = { 92233720368547758080 = 1, ceiling = ${WIDE} }`; + const merged = preserveUnmanagedProfileKeys(`${assignment}\n`, buildProfileFileForTarget(loopback, null)); + expect(merged).toContain(assignment); + expect(Bun.TOML.parse(merged.replace(WIDE, "0"))).toMatchObject({ limits: { "92233720368547758080": 1, ceiling: 0 } }); + }); +}); + +describe("multiline strings that close with extra quotes", () => { + test.each(["arr = [\"\"\"hello\"\"\"\"]","arr = [\"\"\"hello\"\"\"\"\"]","arr = ['''hello'''']","arr = ['''hello''''']","tbl = { s = \"\"\"x\"\"\"\" }","arr = [\n \"\"\"a\nb\"\"\"\",\n]"])("the next root key survives (%s)", assignment => { + const generated = buildProfileFileForTarget(loopback, null); + const merged = preserveUnmanagedProfileKeys(`${assignment}\nstream_max_retries = 8\n`, generated); + expect(merged).toContain(`${assignment}\nstream_max_retries = 8`); + expect(Bun.TOML.parse(merged)).toMatchObject({ stream_max_retries: 8 }); + }); +}); + +describe("a carried key never redefines a generated table", () => { + // Every key that can collide with something a template writes, plus ordinary ones. + const previous = [ + 'model_provider = "elsewhere"', + 'openai_base_url = "https://fixture.invalid/v1"', + 'model_catalog_json = "/fixture/catalog.json"', + "features = { fast_mode = false }", + 'model_providers = { other = { name = "x" } }', + `limits = [${WIDE}]`, + 'notify = """first', + 'second"""', + "stream_max_retries = 8", + "stream_max_retries = 9", + "", + "[features]", + "fast_mode = false", + "", + ].join("\n"); + const shapes = [loopback, remote].flatMap(target => [undefined, true, false].flatMap(fastMode => + [null, "/fixture/catalog.json"].map(catalog => ({ target, fastMode, catalog })))); + + test.each(shapes)("every template shape writes parseable TOML and regenerates stably (%o)", ({ target, fastMode, catalog }) => { + const generated = buildProfileFileForTarget(target, catalog, false, fastMode); + const merged = preserveUnmanagedProfileKeys(previous, generated); + const parsed = Bun.TOML.parse(merged.replace(WIDE, "0")) as Record; + expect(parsed).toMatchObject({ stream_max_retries: 8, limits: [0], notify: "first\nsecond" }); + expect(parsed).toEqual({ ...(Bun.TOML.parse(generated) as Record), ...parsed }); + expect(merged).toContain(`limits = [${WIDE}]`); + // The generated table or root key wins every collision, so a second pass changes nothing. + expect(preserveUnmanagedProfileKeys(merged, generated)).toBe(merged); + }); + + test("the generated [features] table wins over an inline features value", () => { + const generated = buildProfileFileForTarget(loopback, null, false, true); + const merged = preserveUnmanagedProfileKeys('features = { fast_mode = false }\n"stream_max_retries" = 8\n', generated); + expect(merged).not.toContain("features = {"); + const parsed = Bun.TOML.parse(merged) as Record; + expect(parsed.features).toEqual({ fast_mode: true }); + expect(parsed.stream_max_retries).toBe(8); + }); + + test("a dotted root key is not carried", () => { + const merged = preserveUnmanagedProfileKeys("features.fast_mode = false\nstream_max_retries = 8\n", buildProfileFileForTarget(loopback, null, false, true)); + expect(merged).not.toContain("features.fast_mode"); + expect(Bun.TOML.parse(merged)).toMatchObject({ features: { fast_mode: true }, stream_max_retries: 8 }); + }); + + test("without a generated [features] table the inline value is kept", () => { + const generated = buildProfileFileForTarget(loopback, null); + const merged = preserveUnmanagedProfileKeys("features = { fast_mode = false }\n", generated); + expect((Bun.TOML.parse(merged) as Record).features).toEqual({ fast_mode: false }); + }); + + test("the generated provider table wins over an inline model_providers value", () => { + const generated = buildProfileFileForTarget(remote, null); + const merged = preserveUnmanagedProfileKeys("'model_providers' = { other = { name = \"x\" } }\nstream_max_retries = 8\n", generated); + expect(merged).not.toContain("'model_providers'"); + const parsed = Bun.TOML.parse(merged) as { model_providers: Record; stream_max_retries: number }; + expect(Object.keys(parsed.model_providers)).toEqual(["opencodex"]); + expect(parsed.stream_max_retries).toBe(8); + }); +}); + +describe("the injection plan", () => { + test("reads the profile on disk and keeps its unmanaged keys", () => { + const existing = 'stream_idle_timeout_ms = 60000\nfeatures = { fast_mode = false }\n\n[features]\nfast_mode = false\n'; + writeFileSync(CODEX_PROFILE_PATH, existing); + const plan = deriveCodexInjectionPlan('model = "fixture-model"\n', { + config: { fastMode: true } as OcxConfig, routingTarget: loopback, catalogPathOption: null, journalReadOnly: true, + }); + if (plan.kind !== "ok") throw new Error(plan.message); + expect(plan.profileSource).toBe(existing); + const parsed = Bun.TOML.parse(plan.profileContent) as Record; + expect(parsed.stream_idle_timeout_ms).toBe(60000); + expect(parsed.features).toEqual({ fast_mode: true }); + }); + + const planWithoutJournal = () => deriveCodexInjectionPlan('model = "fixture-model"\n', { + config: {} as OcxConfig, routingTarget: loopback, catalogPathOption: null, journalReadOnly: true, + }); + + test("treats a profile removed after the existence check as absent", () => { + // The profile reported present and then gone is a removal between the check and the read. + const realExists = fs.existsSync; + const exists = spyOn(fs, "existsSync").mockImplementation(path => path === CODEX_PROFILE_PATH || realExists(path)); + try { + const plan = planWithoutJournal(); + if (plan.kind !== "ok") throw new Error(plan.message); + expect(plan.profileSource).toBeNull(); + } finally { exists.mockRestore(); } + }); + + test("the shared profile reader used at plan and lock time treats only a missing file as absent", async () => { + const { readCodexProfileSource } = await import("../../src/codex/inject/plan"); + rmSync(CODEX_PROFILE_PATH, { force: true }); + expect(readCodexProfileSource()).toBeNull(); + writeFileSync(CODEX_PROFILE_PATH, "stream_max_retries = 8\n"); + expect(readCodexProfileSource()).toBe("stream_max_retries = 8\n"); + rmSync(CODEX_PROFILE_PATH, { force: true }); + mkdirSync(CODEX_PROFILE_PATH); + try { expect(readCodexProfileSource).toThrow(/EISDIR|directory/i); } + finally { rmSync(CODEX_PROFILE_PATH, { recursive: true, force: true }); } + }); + + test("an unreadable profile other than a missing one still fails the plan", () => { + mkdirSync(CODEX_PROFILE_PATH); + try { + expect(planWithoutJournal).toThrow(/EISDIR|directory/i); + } finally { rmSync(CODEX_PROFILE_PATH, { recursive: true, force: true }); } + }); + + test("refuses retryably when the profile changes before the write lock is held", () => { + const home = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-profile-window-"))); + try { + writeFileSync(join(home, "config.toml"), 'model = "fixture-model"\n'); + writeFileSync(join(home, "opencodex.config.toml"), "stream_idle_timeout_ms = 1\n"); + const child = Bun.spawnSync([process.execPath, "-e", ` + const fs=require('node:fs'),{spyOn}=require('bun:test'); + const locks=require('./src/codex/config-write-lock'),paths=require('./src/codex/paths'); + const original=locks.acquireConfigWriteLock;let edited=false; + const spy=spyOn(locks,'acquireConfigWriteLock').mockImplementation(async (...args)=>{ + if(!edited){edited=true;fs.writeFileSync(paths.CODEX_PROFILE_PATH,'stream_max_retries = 9\\n');} + return original(...args); + }); + let result;try{result=await require('./src/codex/inject').injectCodexConfig(10100);}finally{spy.mockRestore();} + console.log(JSON.stringify({result,profile:fs.readFileSync(paths.CODEX_PROFILE_PATH,'utf8')})); + `], { cwd: repoRoot(), env: { ...process.env, CODEX_HOME: home, OPENCODEX_HOME: join(home, ".ocx-fixture") }, + stdout: "pipe", stderr: "pipe", timeout: SPAWN_BUDGET_MS - 5_000 }); + expect(child.exitCode, child.stderr.toString()).toBe(0); + const out = JSON.parse(child.stdout.toString().trim().split("\n").at(-1)!); + expect(out.result).toMatchObject({ success: false, retryable: true }); + expect(out.result.message).toContain("opencodex.config.toml changed"); + expect(out.profile).toBe("stream_max_retries = 9\n"); + } finally { rmSync(home, { recursive: true, force: true }); } + }, SPAWN_BUDGET_MS); +}); + +// One injection scenario in a child process against its own Codex home. `script` runs with fs, +// paths, inject and coord in scope, after `before` holds the profile, and prints one JSON line. +// The coordinated case first injects once so the home has a coordinator row and a generated +// profile; the scenario then injects for another port so the profile it publishes differs. A +// fresh home holding an operator profile stays on the legacy uncoordinated path. +const homes = [ + { path: "coordinated", prime: true }, + { path: "legacy-uncoordinated", prime: false }, +]; +const EDIT = "stream_max_retries = 9\n"; +function runInjectChild(prime: boolean, script: string): Record { + const home = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-profile-rollback-"))); + try { + writeFileSync(join(home, "config.toml"), 'model = "fixture-model"\n'); + if (!prime) writeFileSync(join(home, "opencodex.config.toml"), "stream_idle_timeout_ms = 1\n"); + const child = Bun.spawnSync([process.execPath, "-e", ` + const fs=require('node:fs'),{spyOn}=require('bun:test'); + const paths=require('./src/codex/paths'),coord=require('./src/codex/inject-coordination'),inject=require('./src/codex/inject'); + const read=()=>fs.readFileSync(paths.CODEX_PROFILE_PATH,'utf8'); + const run=async(port=10101)=>{try{return await inject.injectCodexConfig(port);}catch(error){return {threw:String(error&&error.message)};}}; + if(${prime}){const primed=await run(10100);if(!primed.success)throw new Error('priming failed: '+JSON.stringify(primed));} + const before=read();const kinds=[];inject.setBeforeHistoryArtifactCommitForTests(kind=>kinds.push(kind)); + ${script} + `], { cwd: repoRoot(), env: { ...process.env, CODEX_HOME: home, OPENCODEX_HOME: join(home, ".ocx-fixture") }, + stdout: "pipe", stderr: "pipe", timeout: SPAWN_BUDGET_MS - 5_000 }); + expect(child.exitCode, child.stderr.toString()).toBe(0); + return JSON.parse(child.stdout.toString().trim().split("\n").at(-1)!); + } finally { rmSync(home, { recursive: true, force: true }); } +} + +describe("rolling back a failed injection", () => { + test.each(homes)("a refusal before the profile publish leaves an edit made after the preimage capture ($path)", ({ path, prime }) => { + const out = runInjectChild(prime, ` + const original=coord.captureCodexPreImages;let edited=false; + const spy=spyOn(coord,'captureCodexPreImages').mockImplementation(()=>{ + const pre=original(); + if(!edited){edited=true;fs.writeFileSync(paths.CODEX_PROFILE_PATH,${JSON.stringify(EDIT)}+before);} + return pre; + }); + let first;try{first=await run();}finally{spy.mockRestore();} + const afterRefusal=read(); + const retry=await run(); + console.log(JSON.stringify({first,before,afterRefusal,retry,profile:read(),kinds})); + `); + expect(out.first).toMatchObject({ success: false, retryable: true }); + expect(out.first.message).toContain("opencodex.config.toml changed"); + expect(out.afterRefusal).toBe(EDIT + out.before); + expect(out.retry).toMatchObject({ success: true }); + expect(out.kinds).toEqual([path]); + expect((Bun.TOML.parse(out.profile) as Record).stream_max_retries).toBe(9); + }, SPAWN_BUDGET_MS); + + test.each(homes)("a failure after the profile publish restores the profile when nobody else wrote it ($path)", ({ path, prime }) => { + const out = runInjectChild(prime, ` + let published; + inject.setHistoryArtifactStageForTests(stage=>{if(stage==='after-artifacts'){published=read();throw new Error('fixture failure after publish');}}); + const result=await run(); + inject.setHistoryArtifactStageForTests(undefined); + console.log(JSON.stringify({result,before,published,profile:read(),kinds})); + `); + expect(JSON.stringify(out.result)).toContain("fixture failure after publish"); + expect(out.kinds).toEqual([path]); + expect(out.published).not.toBe(out.before); + expect(out.profile).toBe(out.before); + }, SPAWN_BUDGET_MS); + + test.each(homes)("a failure after the profile publish keeps a concurrent foreign write ($path)", ({ path, prime }) => { + const out = runInjectChild(prime, ` + inject.setHistoryArtifactStageForTests(stage=>{if(stage==='after-artifacts'){ + fs.writeFileSync(paths.CODEX_PROFILE_PATH,'stream_max_retries = 4\\n'); + throw new Error('fixture failure after publish'); + }}); + const result=await run(); + inject.setHistoryArtifactStageForTests(undefined); + console.log(JSON.stringify({result,profile:read(),kinds})); + `); + expect(JSON.stringify(out.result)).toContain("fixture failure after publish"); + expect(out.kinds).toEqual([path]); + expect(out.profile).toBe("stream_max_retries = 4\n"); + }, SPAWN_BUDGET_MS); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f3f1d74f91c..bb0a18b02a6 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -269,7 +269,7 @@ "web-search-forced-declaration.test.ts": "web-search", "web-search-loop-iteration-usage.test.ts": "web-search", "web-search-loop-error-usage.test.ts": "web-search", "web-search-rotation-usage-attribution.test.ts": "web-search", "claude-devin-output-order.test.ts": "claude-integration", - "codex-config-preservation.test.ts": "codex-integration", "codex-credits.test.ts": "codex-integration", + "codex-config-preservation.test.ts": "codex-integration", "codex-profile-preservation.test.ts": "codex-integration", "codex-credits.test.ts": "codex-integration", "codex-credits-settings.test.ts": "codex-integration", "codex-credits-probes.test.ts": "codex-integration", "pool-monthly-window-retirement.test.ts": "codex-integration", "codex-credits-after-limit.test.ts": "codex-integration", From 086f0ab9468883073736ee90bb9776e4fb7822a0 Mon Sep 17 00:00:00 2001 From: Mushikingh <164845020+mushikingh@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:04:58 +0100 Subject: [PATCH 05/16] fix(kiro): report content-filter stops as non-retryable refusals (#8072) * fix(kiro): report content-filter stops as non-retryable refusals A tool-less turn that Kiro's safety filter refuses (CONTENT_FILTERED, e.g. the Claude BIO classifier) arrives with no content. Explicit stop reasons were only mapped in required completion mode, so a disabled-mode refusal fell through to the retryable empty-stream error and clients received a 502 overloaded_error for a verdict that repeats deterministically. CONTENT_FILTERED and GUARDRAIL_INTERVENED now map to the non-retryable content_filter incomplete in every mode, which Messages renders as stop_reason refusal and Responses as a terminal failure. Kiro's refusal category and explanation are carried in the message. * fix(kiro): anchor content-filter checks ahead of the completion branches Same behavior: a validated final answer and a real tool call keep their precedence over a content-filter stop, now through an explicit guard. Placing both checks before the completion-answer branches keeps them first in order for every outcome that follows, and stops them sharing an insertion point with other changes to the same terminal sequence. * fix(kiro): keep first-attempt progress when the retry is filtered Review follow-up. A bounded retry that streamed text before CONTENT_FILTERED made the prior-attempt drain treat that text as superseding the first attempt's held progress, so the client lost it although neither attempt produced an accepted answer. The refusal check now runs before that drain and releases the first attempt's progress, then the retry's partial text, then the refusal. Server tests now cover partial text before a refusal on tool-less Messages (buffered and streaming) and Responses turns. * test(kiro): cover the buffered Responses refusal and document it Review follow-up. The tool-less refusal cases now include non-streaming /v1/responses, asserting the shared buffered contract (incomplete body, content_filter, no error) with and without partial text; both variants fail against dev's Kiro code. The providers guide describes the non-retryable refusal and what a filtered bounded retry shows. * docs(kiro): document the functions the content-filter change touches CodeRabbit's docstring-coverage pre-merge check (80% of touched functions with branches) reported 60%. Adds doc comments to the touched stream and event-parsing functions and to the test helpers; no behavior change. --------- Co-authored-by: JUN --- .../src/content/docs/guides/providers.md | 2 + scripts/test-layout/layout.json | 2 +- src/adapters/kiro-events.ts | 32 ++- src/adapters/kiro/stream.ts | 67 +++++- structure/providers/kiro.md | 7 + tests/fixtures/test-layout-expected.json | 2 +- .../kiro/kiro-content-filter.test.ts | 195 ++++++++++++++++++ tests/server/server-kiro-refusal-e2e.test.ts | 111 ++++++++++ 8 files changed, 411 insertions(+), 7 deletions(-) create mode 100644 tests/providers/kiro/kiro-content-filter.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 8e0e93feed8..b2d078d3a0b 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -654,6 +654,8 @@ If Kiro ends with plain text instead of its private final-answer tool, one bound still runs, and only the resulting final answer is displayed. Progress accompanying a real tool call remains visible. A normal private final answer needs no completion retry. +When Kiro's safety filter stops a turn (`CONTENT_FILTERED`, for example the Claude `BIO` classifier), the request ends as a refusal that clients do not retry: Claude clients see `stop_reason: "refusal"` and Codex sees a failed response carrying Kiro's category and explanation. If the filter stops the bounded retry, no final answer is displayed; the progress held from the first attempt and any partial text from the retry are shown as commentary before the refusal. + When Kiro emits credit metering, request logs preserve the reported spend as `usage.providerCredits`, including in the persisted usage ledger. These are Kiro credits; token counts may still be estimated, and the credit value does not replace USD cost estimates. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 9d82e65ddd3..9821b5a09e7 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1082,7 +1082,7 @@ "kiro-adapter.test.ts": "providers/kiro", "kiro-auth-context-continuation.test.ts": "providers/kiro", "kiro-builder-id-profile.test.ts": "providers/kiro", - "kiro-calibration.test.ts": "providers/kiro", + "kiro-calibration.test.ts": "providers/kiro", "kiro-content-filter.test.ts": "providers/kiro", "kiro-device-builder.test.ts": "providers/kiro", "kiro-device-iam.test.ts": "providers/kiro", "kiro-device-social.test.ts": "providers/kiro", "kiro-fallback-error-body.test.ts": "providers/kiro", "kiro-images.test.ts": "providers/kiro", diff --git a/src/adapters/kiro-events.ts b/src/adapters/kiro-events.ts index 6f0db2ece29..330a87a4360 100644 --- a/src/adapters/kiro-events.ts +++ b/src/adapters/kiro-events.ts @@ -9,11 +9,17 @@ export type ParsedKiroEvent = | { type: "metering"; unit: string; usage: number; unitPlural?: string } | { type: "tool"; name?: string; toolUseId?: string; input?: string; stop?: boolean } | { type: "truncation"; data: string } - | { type: "metadata"; usage?: OcxUsage; contextUsagePercentage?: number; stopReason?: string } + | { type: "metadata"; usage?: OcxUsage; contextUsagePercentage?: number; stopReason?: string; refusal?: KiroRefusalDetails } | { type: "message_metadata"; conversationId?: string } | { type: "invalid_state"; message?: string } | { type: "error"; reason?: string; message?: string }; +/** The model-side refusal Kiro attaches to a CONTENT_FILTERED stop (`stopDetails.refusal`). */ +export interface KiroRefusalDetails { + category?: string; + explanation?: string; +} + const KNOWN_EVENT_TYPES = new Set([ "assistantResponseEvent", "reasoningContentEvent", @@ -61,6 +67,28 @@ function optionalBoolean(eventType: string, obj: Record, key: s return value; } +/** + * `stopDetails.refusal` is advisory copy for the user. Read it leniently: an unexpected shape drops + * the details rather than turning the refusal itself into a protocol error. Upstream-controlled + * text, so each field is bounded. + */ +function refusalDetails(obj: Record): KiroRefusalDetails | undefined { + const stopDetails = obj.stopDetails; + if (!stopDetails || typeof stopDetails !== "object" || Array.isArray(stopDetails)) return undefined; + const refusal = (stopDetails as Record).refusal; + if (!refusal || typeof refusal !== "object" || Array.isArray(refusal)) return undefined; + /** A non-empty trimmed string field of the refusal, cut to `max` characters, or undefined. */ + const field = (key: string, max: number): string | undefined => { + const value = (refusal as Record)[key]; + return typeof value === "string" && value.trim() ? value.trim().slice(0, max) : undefined; + }; + const category = field("category", 64); + const explanation = field("explanation", 400); + return category || explanation + ? { ...(category ? { category } : {}), ...(explanation ? { explanation } : {}) } + : undefined; +} + function tokenCount(eventType: string, obj: Record, key: string, required: boolean): number { const value = obj[key]; if (value === undefined && !required) return 0; @@ -217,6 +245,7 @@ export function parseKiroEvent(eventType: string, payload: Uint8Array): ParsedKi return malformed(eventType, "contextUsagePercentage must be a finite number"); } const stopReason = optionalString(eventType, parsed, "stopReason"); + const refusal = refusalDetails(parsed); return { type: "metadata", ...(parseTokenUsage(eventType, parsed.tokenUsage) !== undefined @@ -224,6 +253,7 @@ export function parseKiroEvent(eventType: string, payload: Uint8Array): ParsedKi : {}), ...(typeof contextUsagePercentage === "number" ? { contextUsagePercentage } : {}), ...(stopReason !== undefined ? { stopReason } : {}), + ...(refusal ? { refusal } : {}), }; } case "contextUsageEvent": { diff --git a/src/adapters/kiro/stream.ts b/src/adapters/kiro/stream.ts index 7aaa5f768ee..72501ecaa6b 100644 --- a/src/adapters/kiro/stream.ts +++ b/src/adapters/kiro/stream.ts @@ -15,7 +15,7 @@ import { safeKiroHttpErrorMessage, type KiroErrorClassification, } from "../kiro-errors"; -import { parseKiroEvent } from "../kiro-events"; +import { parseKiroEvent, type KiroRefusalDetails } from "../kiro-events"; import { noteKiroTransientThrottle } from "../kiro-retry"; import { InlineThinkTagParser } from "../inline-think-tags"; import { isCompleteKiroToolInput, kiroTruncationErrorMessage } from "../kiro-truncation"; @@ -209,6 +209,37 @@ function retryableKiroIncomplete( }; } +/** + * Kiro's safety-filter stops. For Claude models CONTENT_FILTERED is the model's own safety + * classifier, a deterministic verdict on this exact context, so every completion mode maps it to + * the non-retryable `content_filter` incomplete: Claude outbound renders `stop_reason: "refusal"` + * and Responses a terminal `response.failed` (`src/bridge/terminal-refusal.ts`). Kiro's refusal + * category and explanation are model-side copy, never request content, so they ride the message. + */ +function isKiroContentFilterStop( + stopReason: string | undefined, +): stopReason is "CONTENT_FILTERED" | "GUARDRAIL_INTERVENED" { + return stopReason === "CONTENT_FILTERED" || stopReason === "GUARDRAIL_INTERVENED"; +} + +/** The non-retryable `content_filter` incomplete for a filter stop, carrying Kiro's refusal category and explanation when present. */ +function kiroContentFilterIncomplete( + stopReason: string, + refusal: KiroRefusalDetails | undefined, + usage: OcxUsage, + providerState: { kiro: { conversationId: string } } | undefined, +): AdapterEvent { + const category = refusal?.category ? `, category ${refusal.category}` : ""; + const explanation = refusal?.explanation ? `: ${refusal.explanation}` : ""; + return retryableKiroIncomplete( + "content_filter", + `Kiro stopped with ${stopReason}${category}${explanation}`, + usage, + providerState, + false, + ); +} + /** * Catch-path retryability for #519: only transport/socket failures with no emitted output * are replay-safe. Malformed event payloads (`invalid Kiro …`) and any post-output failure @@ -297,6 +328,11 @@ async function* parseKiroAttempt( } } +/** + * Parse one Kiro attempt's event stream into adapter events and a terminal decision: text, reasoning, + * real tool calls, the private terminal tool, usage and the stop reason. In `required` mode it may + * return `needsFallback` instead of a terminal, which asks the caller for the one bounded retry. + */ async function* parseKiroAttemptEvents( response: Response, budget: TranslatorBudget, @@ -344,6 +380,7 @@ async function* parseKiroAttemptEvents( let authoritativeUsage: OcxUsage | undefined; let providerCredits: number | undefined; let stopReason: string | undefined; + let refusal: KiroRefusalDetails | undefined; const fallbackEvents: AdapterEvent[] = []; const thinking = new InlineThinkTagParser(budget); @@ -639,6 +676,7 @@ async function* parseKiroAttemptEvents( contextUsagePercentage = ev.contextUsagePercentage; } if (ev.stopReason !== undefined) stopReason = ev.stopReason; + if (ev.refusal) refusal = ev.refusal; break; case "message_metadata": if (isValidKiroConversationId(ev.conversationId)) { @@ -833,6 +871,19 @@ async function* parseKiroAttemptEvents( } if (mode === "text_fallback") { + // A validated final answer and a real tool call keep their precedence; only a turn that would + // otherwise end on its text is read as refused. Neither attempt produced an accepted answer, so + // the first attempt's held progress is released rather than superseded (retry text normally + // replaces it), and the filtered retry text follows as the commentary it was staged as. + if (completionAnswer === undefined && !sawRealTool && isKiroContentFilterStop(normalizedStopReason)) { + if (priorAttempt) yield* priorAttempt.drainDeferred(false); + yield* emitRetained(fallbackEvents); + return { + assistantText, + sawReasoning, + terminal: kiroContentFilterIncomplete(normalizedStopReason, refusal, finalUsage, finalProviderState), + }; + } if (priorAttempt) { yield* priorAttempt.drainDeferred(completionAnswer !== undefined || (sawText && !sawRealTool)); } @@ -885,6 +936,17 @@ async function* parseKiroAttemptEvents( }; } + // Ahead of the empty-stream fallback below: a tool-less (`disabled`) turn Kiro refuses carries + // no content at all, and reading it as a replay-safe empty stream made clients resend a refusal + // that repeats deterministically. Partial text before the filter is not a finished answer either. + // A validated final answer and a real tool call keep their precedence, as on the retry path. + if (completionAnswer === undefined && !sawRealTool && isKiroContentFilterStop(normalizedStopReason)) { + return { + assistantText, + sawReasoning, + terminal: kiroContentFilterIncomplete(normalizedStopReason, refusal, finalUsage, finalProviderState), + }; + } if (completionAnswer !== undefined) { yield { type: "text_delta", text: completionAnswer, phase: "final_answer" }; return { @@ -953,9 +1015,6 @@ async function* parseKiroAttemptEvents( }; } if (normalizedStopReason === "MAX_TOKENS") return incomplete("max_output_tokens", true); - if (normalizedStopReason === "CONTENT_FILTERED" || normalizedStopReason === "GUARDRAIL_INTERVENED") { - return incomplete("content_filter", false); - } if (normalizedStopReason === "MALFORMED_TOOL_USE") return incomplete("kiro_malformed_tool_use", false); if (normalizedStopReason === "MALFORMED_MODEL_OUTPUT") return incomplete("kiro_malformed_model_output", false); // TOOL_USE here means Kiro claimed a tool call it never emitted. diff --git a/structure/providers/kiro.md b/structure/providers/kiro.md index 1b188e8e014..98f7b1c8fe1 100644 --- a/structure/providers/kiro.md +++ b/structure/providers/kiro.md @@ -172,6 +172,13 @@ Coverage: `tests/providers/kiro/kiro-single-final.test.ts`, `tests/providers/kiro/kiro-completion-with-real-tools.test.ts` and `tests/server/server-kiro-completion-e2e.test.ts`. +## Content-filter stops + +A `metadataEvent` `stopReason` of `CONTENT_FILTERED` or `GUARDRAIL_INTERVENED` is mapped in every completion mode (`disabled`, `required`, and the bounded `text_fallback` retry) before any empty-stream or fallback classification. For Claude models it is the model's own safety classifier, deterministic for that context, so the adapter ends the turn with the non-retryable `content_filter` incomplete and never starts a completion retry. Claude outbound renders it as `stop_reason: "refusal"`; Responses delivers a terminal `response.failed` with `invalid_prompt` (`src/bridge/terminal-refusal.ts`); the empty-completion guard passes it through. Text that streamed before the filter is not promoted to a final answer. A validated final answer and a real tool call keep their precedence over the stop. When the bounded retry is the attempt that gets filtered, neither attempt produced an accepted answer, so the first attempt's held progress is released (retry text normally supersedes it) and the retry's partial text follows as commentary. + +Kiro attaches `stopDetails.refusal` (`category`, `explanation`) to the stop. `src/adapters/kiro-events.ts` reads it leniently, so a malformed shape drops only the details, and bounds both fields. The incomplete message carries them: `Kiro stopped with CONTENT_FILTERED, category BIO: `. Before this, a tool-less turn (`disabled` mode carries no content when refused) fell through to the replay-safe `empty_kiro_stream`, which Claude clients saw as a retryable 502 overload. A stream with no content and no stop reason is still `empty_kiro_stream` and stays retryable. +Coverage: `tests/providers/kiro/kiro-content-filter.test.ts` and `tests/server/server-kiro-refusal-e2e.test.ts`. + `src/adapters/kiro-retry.ts` names exhausted-budget destinations only through the [bounded error-label contract](../data-planes/error-detail.md#client-error-bodies-chat); the shared constructor removes configured URL userinfo, path, query and fragment. It uses the configured executor for every generation send and may try the existing `q.{region}.amazonaws.com` host once after a canonical-host HTTP 502/503/504 before output, subject to the same send budget. Reset, 429, alternate, and completion-fallback sends wait for a pacing slot; only the first send is pre-paid. Kiro web-search turns are paced as well. A Kiro-local wrapper maps its header deadline to HTTP 504 without changing shared or Google fetch behavior; caller cancellation remains an abort. Final HTTP 5xx text is fixed for clients, and opt-in provider diagnostics carry only closed-set status and classification codes. When a first Kiro stream needs a completion fallback, the fallback response's non-success diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index bb0a18b02a6..09253ccfa97 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -956,7 +956,7 @@ "kiro-images.test.ts": "providers/kiro", "kiro-leased-responses.test.ts": "providers/kiro", "kiro-model-catalog.test.ts": "providers/kiro", "kiro-model-preference.test.ts": "providers/kiro", "kiro-oauth.test.ts": "providers/kiro", "oauth-kiro-refresh-attention.test.ts": "oauth", "kiro-pool-load-settings.test.ts": "providers/kiro", - "kiro-org-user-identity.test.ts": "providers/kiro", + "kiro-org-user-identity.test.ts": "providers/kiro", "kiro-content-filter.test.ts": "providers/kiro", "kiro-pool-rank.test.ts": "providers/kiro", "kiro-reasoning-roundtrip.test.ts": "providers/kiro", "kiro-refusal-failover.test.ts": "providers/kiro", "kiro-refusal.test.ts": "providers/kiro", "kiro-remote-image.test.ts": "providers/kiro", "kiro-retry.test.ts": "providers/kiro", diff --git a/tests/providers/kiro/kiro-content-filter.test.ts b/tests/providers/kiro/kiro-content-filter.test.ts new file mode 100644 index 00000000000..2535f1e5ace --- /dev/null +++ b/tests/providers/kiro/kiro-content-filter.test.ts @@ -0,0 +1,195 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createKiroAdapter as createKiroAdapterProduction } from "../../../src/adapters/kiro"; +import { parseKiroEvent } from "../../../src/adapters/kiro-events"; +import { resetKiroThrottleStateForTests } from "../../../src/adapters/kiro-retry"; +import { encodeMessage } from "../../../src/lib/eventstream-decoder"; +import { guardEmptyCompletionEventStream } from "../../../src/server/responses/empty-completion-guard"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; +import { withTestTranslatorBudget } from "../../helpers/translator-budget"; +import { removeTreeWithRetry } from "../../helpers/remove-tree"; + +/** + * Kiro's safety filter refusing a turn (frame shape captured live from kiro/claude-sonnet-5.5). + * A tool-less turn runs in `disabled` completion mode and used to map this content-free stream to + * the replay-safe `empty_kiro_stream`, so clients resent a refusal that repeats deterministically. + */ +const enc = new TextEncoder(); +const origHome = process.env.HOME; +const origRegion = process.env.KIRO_REGION; +const realFetch = globalThis.fetch; +let tmp: string; + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "kiro-content-filter-")); + process.env.HOME = tmp; + process.env.KIRO_REGION = "us-east-1"; +}); +afterEach(() => { + globalThis.fetch = realFetch; + resetKiroThrottleStateForTests(); + if (origHome === undefined) delete process.env.HOME; else process.env.HOME = origHome; + if (origRegion === undefined) delete process.env.KIRO_REGION; else process.env.KIRO_REGION = origRegion; + removeTreeWithRetry(tmp); +}); + +const provider = { adapter: "kiro", baseUrl: "https://runtime.us-east-1.kiro.dev", authMode: "oauth", apiKey: "tok-123" } as unknown as OcxProviderConfig; +const bashTool = { name: "bash", description: "Run a shell command", parameters: { type: "object" } }; +const explanation = "The selected model cannot continue this conversation. Please select a different model, " + + "or start a new conversation, or rewind the current conversation to an earlier point and try a different approach."; +const capturedRefusal = { stopDetails: { refusal: { category: "BIO", explanation } }, stopReason: "CONTENT_FILTERED" }; + +const frame = (eventType: string, payload: unknown) => + encodeMessage({ ":message-type": "event", ":event-type": eventType }, enc.encode(JSON.stringify(payload))); +const initial = () => frame("initial-response", { conversationId: "" }); +const contextUsage = () => frame("contextUsageEvent", { contextUsagePercentage: 1.7943000793457031 }); +const text = (content: string) => frame("assistantResponseEvent", { content }); +const metadata = (payload: unknown) => frame("metadataEvent", payload); + +/** A Kiro event-stream response that yields the given frames in order. */ +function streamOf(...frames: Uint8Array[]): Response { + let i = 0; + return new Response(new ReadableStream({ + /** Enqueue the next frame, or close the stream after the last one. */ + pull(c) { + if (i < frames.length) c.enqueue(frames[i++]); + else c.close(); + }, + })); +} + +/** + * Stream one turn whose first attempt answers with `frames`, and count completion retries: any retry + * fetch is recorded and answered with text that a filtered turn must never show. + */ +async function run(tools: unknown[] | undefined, ...frames: Uint8Array[]): Promise<{ events: AdapterEvent[]; fetches: number }> { + let fetches = 0; + // A bounded completion retry must never run for a filtered turn; one that does sees an answer. + globalThis.fetch = (async () => { + fetches++; + return streamOf(text("must not run")); + }) as unknown as typeof fetch; + const adapter = withTestTranslatorBudget(createKiroAdapterProduction(provider)); + await adapter.buildRequest({ + modelId: "claude-sonnet-4.5", stream: true, options: {}, + context: { messages: [{ role: "user", content: "hello" }], tools }, + } as unknown as OcxParsedRequest); + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(streamOf(...frames))) events.push(event); + return { events, fetches }; +} + +describe("Kiro CONTENT_FILTERED stop", () => { + test("metadataEvent carries the refusal category and explanation", () => { + expect(parseKiroEvent("metadataEvent", enc.encode(JSON.stringify(capturedRefusal)))).toEqual({ + type: "metadata", + stopReason: "CONTENT_FILTERED", + refusal: { category: "BIO", explanation }, + }); + }); + + test("a malformed stopDetails drops the details, not the stop reason", () => { + for (const stopDetails of ["BIO", [], { refusal: "BIO" }, { refusal: { category: 7, explanation: " " } }]) { + expect(parseKiroEvent("metadataEvent", enc.encode(JSON.stringify({ stopDetails, stopReason: "CONTENT_FILTERED" })))) + .toEqual({ type: "metadata", stopReason: "CONTENT_FILTERED" }); + } + }); + + test("a tool-less turn with only the refusal is a non-retryable content filter", async () => { + const { events, fetches } = await run(undefined, initial(), metadata(capturedRefusal), contextUsage()); + expect(fetches).toBe(0); + expect(events.filter(event => event.type === "text_delta")).toEqual([]); + const terminal = events.at(-1); + expect(terminal).toMatchObject({ type: "incomplete", reason: "content_filter", retryable: false, endTurn: false }); + expect(terminal?.type === "incomplete" ? terminal.message : "") + .toBe(`Kiro stopped with CONTENT_FILTERED, category BIO: ${explanation}`); + }); + + test("a tool-less turn filtered after partial text does not complete as a final answer", async () => { + const { events } = await run(undefined, text("Partial."), metadata(capturedRefusal)); + expect(events.filter(event => event.type === "text_delta")).toEqual([{ type: "text_delta", text: "Partial." }]); + expect(events.at(-1)).toMatchObject({ type: "incomplete", reason: "content_filter", retryable: false }); + }); + + test("GUARDRAIL_INTERVENED without details is a content filter too", async () => { + const { events } = await run(undefined, metadata({ stopReason: "GUARDRAIL_INTERVENED" })); + expect(events.at(-1)).toMatchObject({ + type: "incomplete", reason: "content_filter", retryable: false, message: "Kiro stopped with GUARDRAIL_INTERVENED", + }); + }); + + test("required completion mode keeps its non-retryable content filter without a completion retry", async () => { + for (const frames of [[text("Partial."), metadata(capturedRefusal)], [initial(), metadata(capturedRefusal), contextUsage()]]) { + const { events, fetches } = await run([bashTool], ...frames); + expect(fetches).toBe(0); + expect(events.at(-1)).toMatchObject({ + type: "incomplete", reason: "content_filter", retryable: false, + message: `Kiro stopped with CONTENT_FILTERED, category BIO: ${explanation}`, + }); + } + }); + + test("a filtered bounded completion retry is a content filter, not an empty fallback", async () => { + let fetches = 0; + globalThis.fetch = (async () => { + fetches++; + return streamOf(initial(), metadata(capturedRefusal), contextUsage()); + }) as unknown as typeof fetch; + const adapter = withTestTranslatorBudget(createKiroAdapterProduction(provider)); + await adapter.buildRequest({ + modelId: "claude-sonnet-4.5", stream: true, options: {}, + context: { messages: [{ role: "user", content: "hello" }], tools: [bashTool] }, + } as unknown as OcxParsedRequest); + const events: AdapterEvent[] = []; + // No stop reason on the first attempt sends it through the one bounded retry. + for await (const event of adapter.parseStream(streamOf(text("Checking.")))) events.push(event); + expect(fetches).toBe(1); + expect(events.at(-1)).toMatchObject({ type: "incomplete", reason: "content_filter", retryable: false }); + }); + + test("a retry filtered after partial text keeps the first attempt's progress", async () => { + // Retry text normally supersedes the first attempt's held progress. A filtered retry produced no + // accepted answer, so both attempts' text reaches the client as commentary before the refusal. + globalThis.fetch = (async () => streamOf(initial(), text("Partial retry"), metadata(capturedRefusal), contextUsage())) as unknown as typeof fetch; + const adapter = withTestTranslatorBudget(createKiroAdapterProduction(provider)); + await adapter.buildRequest({ + modelId: "claude-sonnet-4.5", stream: true, options: {}, + context: { messages: [{ role: "user", content: "hello" }], tools: [bashTool] }, + } as unknown as OcxParsedRequest); + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(streamOf(text("Checking.")))) events.push(event); + const texts = events.filter(event => event.type === "text_delta").map(event => (event as { text: string }).text); + expect(texts).toEqual(["Checking.", "Partial retry"]); + expect(events.filter(event => event.type === "text_delta").every(event => (event as { phase?: string }).phase === "commentary")).toBe(true); + expect(events.at(-1)).toMatchObject({ type: "incomplete", reason: "content_filter", retryable: false }); + }); + + test("the empty-completion guard passes the refusal through instead of retrying it", async () => { + const { events } = await run(undefined, initial(), metadata(capturedRefusal), contextUsage()); + let continuations = 0; + const guarded: AdapterEvent[] = []; + for await (const event of guardEmptyCompletionEventStream({ + firstEvents: (async function* () { yield* events; })(), + continuation: () => { + continuations++; + return (async function* () { yield* events; })(); + }, + })) guarded.push(event); + expect(continuations).toBe(0); + expect(guarded.at(-1)).toMatchObject({ type: "incomplete", reason: "content_filter", retryable: false }); + }); +}); + +describe("Kiro empty stream without a stop reason", () => { + test("stays the replay-safe empty_kiro_stream", async () => { + const { events } = await run(undefined, initial(), contextUsage()); + expect(events.at(-1)).toMatchObject({ + type: "incomplete", + reason: "empty_kiro_stream", + message: "Kiro returned a successful but empty response stream", + retryable: true, + }); + }); +}); diff --git a/tests/server/server-kiro-refusal-e2e.test.ts b/tests/server/server-kiro-refusal-e2e.test.ts index b3db26cfc3c..4a9c2705bec 100644 --- a/tests/server/server-kiro-refusal-e2e.test.ts +++ b/tests/server/server-kiro-refusal-e2e.test.ts @@ -745,3 +745,114 @@ describe("Kiro content-filter stop through Responses streaming", () => { }); } }); + +/** + * A tool-less turn runs in `disabled` completion mode. Kiro's safety filter answers it with no + * content at all, only a CONTENT_FILTERED metadataEvent (frame shape captured live). That used to + * fall through to the replay-safe `empty_kiro_stream`, which Claude clients saw as a retryable + * 502 overload and resent a refusal that repeats deterministically. + */ +describe("Kiro content-filter stop on a tool-less turn", () => { + const explanation = "The selected model cannot continue this conversation."; + const frame = (eventType: string, payload: Record) => encodeMessage( + { ":message-type": "event", ":event-type": eventType }, + new TextEncoder().encode(JSON.stringify(payload))); + // The captured refusal carries no content; the partial variant is the boundary where text already + // streamed before the filter, which must still end as a refusal rather than a normal completion. + const filtered = (partial: string | undefined) => () => new Response(Buffer.concat([ + frame("initial-response", { conversationId: "" }), + ...(partial === undefined ? [] : [frame("assistantResponseEvent", { content: partial })]), + frame("metadataEvent", { stopDetails: { refusal: { category: "BIO", explanation } }, stopReason: "CONTENT_FILTERED" }), + frame("contextUsageEvent", { contextUsagePercentage: 1.79 }), + ]), { headers: { "content-type": "application/vnd.amazon.eventstream" } }); + let upstream = filtered(undefined); + + async function serve(run: (server: ReturnType) => Promise): Promise { + await seed(); saveConfig(config()); + let sends = 0; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url.startsWith("https://runtime.") && url.endsWith(".kiro.dev/")) { + sends++; + return upstream(); + } + return realFetch(input, init); + }) as typeof fetch; + const server = startServer(0); + try { await run(server); } finally { await server.stop(true); } + return sends; + } + const messages = (server: ReturnType, stream: boolean) => realFetch( + new URL("/v1/messages", server.url), { + method: "POST", headers: { "content-type": "application/json", "anthropic-version": "2023-06-01" }, + body: JSON.stringify({ model: "kiro/claude-sonnet-4.5", max_tokens: 64, stream, + messages: [{ role: "user", content: "hello" }] }), + }); + + for (const [label, partial] of [["no content", undefined], ["partial text", "Partial answer"]] as const) { + describe(label, () => { + beforeEach(() => { upstream = filtered(partial); }); + + test("/v1/messages answers with stop_reason refusal, not a retryable overload", async () => { + const sends = await serve(async server => { + const response = await messages(server, false); + expect(response.status).toBe(200); + const body = await response.json() as Record; + expect(body.type).toBe("message"); + expect(body.stop_reason).toBe("refusal"); + }); + // Neither replayed on the same account nor rotated to the other one. + expect(sends).toBe(1); + }); + + test("/v1/messages streaming ends with a refusal message_delta", async () => { + const sends = await serve(async server => { + const response = await messages(server, true); + expect(response.status).toBe(200); + const wire = await response.text(); + expect(wire).not.toContain("overloaded_error"); + expect(wire).toContain('"stop_reason":"refusal"'); + expect(wire).toContain("event: message_stop"); + }); + expect(sends).toBe(1); + }); + + test("/v1/responses streaming fails terminally with Kiro's refusal explanation", async () => { + const sends = await serve(async server => { + const response = await post(server, true); + expect(response.status).toBe(200); + const text = await response.text(); + const terminals = text.split("\n\n").flatMap(block => { + const name = block.split("\n").find(line => line.startsWith("event: "))?.slice(7) ?? ""; + const data = block.split("\n").find(line => line.startsWith("data: "))?.slice(6); + return /^response\.(completed|incomplete|failed)$/.test(name) && data + ? [{ name, response: JSON.parse(data).response as Record }] : []; + }); + expect(terminals.map(terminal => terminal.name)).toEqual(["response.failed"]); + const failed = terminals[0]!.response; + expect(failed.error.code).toBe("invalid_prompt"); + expect(failed.error.message).toContain("CONTENT_FILTERED"); + expect(failed.error.message).toContain("BIO"); + expect(failed.error.message).toContain(explanation); + expect(failed.retryable).toBe(false); + }); + expect(sends).toBe(1); + }); + + test("/v1/responses non-streaming keeps the content-filter incomplete body", async () => { + const sends = await serve(async server => { + const response = await post(server, false); + expect(response.status).toBe(200); + const body = await response.json() as Record; + // The buffered Responses wire keeps its incomplete body for a refusal (the shared contract in + // server-terminal-refusal-wiring.test.ts); partial text must not turn it into a completed + // response. + expect(body.status).toBe("incomplete"); + expect(body.incomplete_details.reason).toBe("content_filter"); + expect(body.error ?? null).toBeNull(); + }); + expect(sends).toBe(1); + }); + }); + } +}); From 491beb6e6eb36c2adeb49e25f010dc611e6c1c8f Mon Sep 17 00:00:00 2001 From: ZeroDi Date: Tue, 3 Nov 2026 18:05:07 +0900 Subject: [PATCH 06/16] fix(command-code): avoid double charging live NDJSON buffers (#8047) * fix(command-code): reserve only live buffer growth * test(command-code): cover shared buffer ownership and drain --------- Co-authored-by: JUN --- scripts/test-layout/layout.json | 2 +- src/adapters/command-code.ts | 20 ++-- structure/transports/byte-accounting.md | 8 ++ tests/fixtures/test-layout-expected.json | 2 +- .../command-code-buffer-budget.test.ts | 110 ++++++++++++++++++ 5 files changed, 131 insertions(+), 11 deletions(-) create mode 100644 tests/providers/command-code-buffer-budget.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 9821b5a09e7..ed25a471a2d 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -753,7 +753,7 @@ "combo-workspace-data.test.ts": "gui", "combo-workspace-jev-decision.test.ts": "gui", "combos.test.ts": "codex-integration", "combos-kiro-profile-hop.test.ts": "codex-integration", - "command-code-error-finish.test.ts": "providers", + "command-code-error-finish.test.ts": "providers", "command-code-buffer-budget.test.ts": "providers", "command-code-efforts.test.ts": "providers", "command-code-project-context.test.ts": "providers", "command-code-provider.test.ts": "providers", diff --git a/src/adapters/command-code.ts b/src/adapters/command-code.ts index 8a26c300832..e116645cc50 100644 --- a/src/adapters/command-code.ts +++ b/src/adapters/command-code.ts @@ -425,27 +425,29 @@ async function*ndjson(response: Response, budget: TranslatorBudget): AsyncGenera const encoder = new TextEncoder(); let buffer = ""; let bufferBytes = 0; + // One live buffer owns one charge; resizing must not reserve old + replacement together. + const resizeBuffer = (nextBytes: number) => { + if (nextBytes > bufferBytes) { + budget.reserveTransient(nextBytes - bufferBytes, { kind: "live_transient" }).commitRetained(); + } else { + budget.releaseRetained(bufferBytes - nextBytes, { kind: "live_transient" }); + } + bufferBytes = nextBytes; + }; try { for (;;) { const { value, done } = await reader.read(); const next = buffer + decoder.decode(value, { stream: !done }); const nextBytes = encoder.encode(next).byteLength; - const reservation = budget.reserveTransient(nextBytes, { kind: "live_transient" }); + resizeBuffer(nextBytes); buffer = next; - reservation.commitRetained(); - budget.releaseRetained(bufferBytes, { kind: "live_transient" }); - bufferBytes = nextBytes; let newline = buffer.indexOf("\n"); while (newline >= 0) { const line = buffer.slice(0, newline).trim(); buffer = buffer.slice(newline + 1); if (line) yield* decodeEventLine(line); newline = buffer.indexOf("\n"); } - const residualBytes = encoder.encode(buffer).byteLength; - const residualReservation = budget.reserveTransient(residualBytes, { kind: "live_transient" }); - residualReservation.commitRetained(); - budget.releaseRetained(bufferBytes, { kind: "live_transient" }); - bufferBytes = residualBytes; + resizeBuffer(encoder.encode(buffer).byteLength); if (done) break; } const final = buffer.trim(); diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 4da2a12db7d..a93cd509622 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -143,6 +143,14 @@ envelopes, and real HTTP streams; `tests/server/inbound-body-budget.test.ts` cov ## Stream-buffer accounting +Command Code NDJSON in `src/adapters/command-code.ts` holds one `live_transient` charge for +its decoded buffer. Growth reserves only the positive byte delta; draining releases only the +removed bytes. Recounting an unchanged residual does not reserve it twice. Failed growth keeps +the previous charge owned until `finally` releases it, without releasing other owners' bytes. +The turn limit and transport-read accounting remain in force. `tests/providers/command-code-buffer-budget.test.ts` +covers exact-limit Unicode records across whole/one-byte reads and newline/EOF termination, +one-byte overflow, another retained owner, consumer return and upstream cancellation. + Native Ollama NDJSON in `src/adapters/ollama-native.ts` measures new decoded fragments without allocating UTF-8 arrays. A fragment without a newline extends the cached residual byte count; only a fragment containing a newline triggers splitting of the accumulated record. Complete diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 09253ccfa97..09f1d0edbb8 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -743,7 +743,7 @@ "combo-vision-enrollment.test.ts": "routing", "combo-stream-preflight.test.ts": "routing", "combo-workspace-data.test.ts": "gui", "combo-workspace-jev-decision.test.ts": "gui", "combos.test.ts": "codex-integration", "combos-kiro-profile-hop.test.ts": "codex-integration", - "command-code-error-finish.test.ts": "providers", "command-code-credit-failover.test.ts": "providers", "command-code-credit-rotation.test.ts": "providers", "command-code-plan-denial.test.ts": "providers", + "command-code-error-finish.test.ts": "providers", "command-code-buffer-budget.test.ts": "providers", "command-code-credit-failover.test.ts": "providers", "command-code-credit-rotation.test.ts": "providers", "command-code-plan-denial.test.ts": "providers", "command-code-efforts.test.ts": "providers", "command-code-project-context.test.ts": "providers", "command-code-provider.test.ts": "providers", "command-code-quota.test.ts": "providers", "command-code-tool-text.test.ts": "providers", diff --git a/tests/providers/command-code-buffer-budget.test.ts b/tests/providers/command-code-buffer-budget.test.ts new file mode 100644 index 00000000000..a7632986f0d --- /dev/null +++ b/tests/providers/command-code-buffer-budget.test.ts @@ -0,0 +1,110 @@ +import { expect, test } from "bun:test"; +import { createCommandCodeAdapter } from "../../src/adapters/command-code"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; +import type { AdapterEvent } from "../../src/types"; + +const adapter = () => createCommandCodeAdapter({ adapter: "command-code", baseUrl: "https://command.example" }); +const content = "é한😀".repeat(32); +const line = JSON.stringify({ type: "text-delta", text: content }); +const encoder = new TextEncoder(); + +function input(bytes: Uint8Array, chunkSize: number, beforeRead?: (offset: number) => void) { + let offset = 0; + let cancelled = false; + const body = new ReadableStream({ + pull(controller) { + beforeRead?.(offset); + if (offset === bytes.length) { controller.close(); return; } + const end = Math.min(bytes.length, offset + chunkSize); + controller.enqueue(bytes.subarray(offset, end)); + offset = end; + }, + cancel() { cancelled = true; }, + }, { highWaterMark: 0 }); + return { response: new Response(body), cancelled: () => cancelled }; +} + +for (const ending of ["\n", ""] as const) { + for (const fragmented of [false, true]) { + test(`Command Code admits an exact-limit ${fragmented ? "fragmented" : "whole"} record with ${JSON.stringify(ending)} termination`, async () => { + const bytes = encoder.encode(line + ending); + const budget = createTestTranslatorBudget({ maxTurnBytes: bytes.length }); + const source = input(bytes, fragmented ? 1 : bytes.length); + const events: AdapterEvent[] = []; + for await (const event of adapter().parseStream(source.response, budget)) events.push(event); + expect(events).toEqual([ + { type: "text_delta", text: content }, + { type: "done", usage: undefined, stopReason: undefined }, + ]); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, highWaterBytes: bytes.length, overflows: 0 }); + }); + } +} + +for (const fragmented of [false, true]) { + test(`Command Code refuses a ${fragmented ? "fragmented" : "whole"} record one byte over budget and cleans up`, async () => { + const bytes = encoder.encode(line); + const budget = createTestTranslatorBudget({ maxTurnBytes: bytes.length - 1 }); + const source = input(bytes, fragmented ? 1 : bytes.length); + const events: AdapterEvent[] = []; + const consume = async () => { + for await (const event of adapter().parseStream(source.response, budget)) events.push(event); + }; + await expect(consume()).rejects.toMatchObject({ code: "translation_buffer_limit", kind: "live_transient" }); + expect(events).toHaveLength(0); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, overflows: 1 }); + expect(source.cancelled()).toBe(true); + }); +} + +for (const deficit of [0, 1]) { + test(`Command Code preserves another live_transient owner's bytes on ${deficit ? "refused" : "successful"} growth`, async () => { + const bytes = encoder.encode(line); + const budget = createTestTranslatorBudget({ maxTurnBytes: bytes.length + 64 - deficit }); + budget.chargeRetained(64, { kind: "live_transient" }); + const source = input(bytes, 1); + const events: AdapterEvent[] = []; + const consume = async () => { + for await (const event of adapter().parseStream(source.response, budget)) events.push(event); + }; + if (deficit) { + await expect(consume()).rejects.toMatchObject({ code: "translation_buffer_limit" }); + expect(events).toHaveLength(0); + expect(source.cancelled()).toBe(true); + } else { + await consume(); + expect(events.at(-1)?.type).toBe("done"); + } + expect(budget.snapshot()).toMatchObject({ currentBytes: 64, highWaterBytes: bytes.length + 64 - deficit, overflows: deficit }); + budget.releaseRetained(64, { kind: "live_transient" }); + expect(budget.snapshot().currentBytes).toBe(0); + }); +} + +test("Command Code releases drained bytes before reading the next fragment", async () => { + const carryBytes = 16; // The second record's ASCII JSON prefix, before any text content. + const firstReadBytes = encoder.encode(line + "\n").length + carryBytes; + const budget = createTestTranslatorBudget({ maxTurnBytes: firstReadBytes }); + const retainedBeforeSecondRead: number[] = []; + const source = input(encoder.encode(line + "\n" + line), firstReadBytes, offset => { + if (offset === firstReadBytes) retainedBeforeSecondRead.push(budget.snapshot().currentBytes); + }); + const events: AdapterEvent[] = []; + for await (const event of adapter().parseStream(source.response, budget)) events.push(event); + expect(retainedBeforeSecondRead).toEqual([carryBytes]); + expect(events.filter(event => event.type === "text_delta").map(event => event.text)).toEqual([content, content]); + expect(events.at(-1)?.type).toBe("done"); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, highWaterBytes: firstReadBytes, overflows: 0 }); +}); + +test("Command Code consumer return releases its retained record and cancels upstream", async () => { + const bytes = encoder.encode(line + "\n"); + const budget = createTestTranslatorBudget({ maxTurnBytes: bytes.length }); + const source = input(bytes, bytes.length); + const events = adapter().parseStream(source.response, budget); + expect((await events.next()).value).toMatchObject({ type: "text_delta", text: content }); + expect(budget.snapshot().currentBytes).toBe(bytes.length); + await events.return(); + expect(budget.snapshot().currentBytes).toBe(0); + expect(source.cancelled()).toBe(true); +}); From 665cc48093329fec3f9a8322e7c4a1b0b6ca2c21 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Tue, 3 Nov 2026 09:05:16 +0000 Subject: [PATCH 07/16] fix(link): restore owned integrations before native signal exit (#8036) * fix(link): restore local routing when child stops * fix(client): restore owned Link routing before listener release * test(cli): align stop contracts with owned routing cleanup * test(client): preserve failure exit in signal cleanup contract * docs(link): document pending disconnect stop refusal Add an incomplete disconnect receipt (client_disconnect_pending) to the existing stop restoration refusal conditions in all eight lifecycle guides. Keep the recovery-evidence guidance and the runtime behavior unchanged. * fix(link): preserve handled-signal exit wiring * fix(link): allow catalog sync before signal restoration * fix(link): fence sync commits during signal shutdown * fix(link): keep runtime publication through its record builder * fix(link): retry transient stop-fence contention explicitly * docs(link): define the configuration mutation lock on first use * docs(link): keep operator recovery steps in the CLI lifecycle guide The structure note now states only the runtime behavior; starting and resyncing a Link Child after a signal stop is documented with the other stop steps in the public lifecycle reference. * test(link): use the canonical Codex home in sync-stop fixtures Sync records the canonical catalog path while the fixture created its native backup under the /var alias on macOS, so restoration fell back to an empty catalog; clean up held children with SIGKILL so a failed assertion is reported instead of hanging. Also correct the offline-sync wording: an offline sync does reapply routing. --------- Co-authored-by: JUN --- .../docs/fr/reference/cli/lifecycle.md | 2 + .../docs/ja/reference/cli/lifecycle.md | 2 + .../docs/ko/reference/cli/lifecycle.md | 2 + .../content/docs/reference/cli/lifecycle.md | 2 + .../docs/ru/reference/cli/lifecycle.md | 2 + .../docs/tr/reference/cli/lifecycle.md | 2 + .../docs/zh-cn/reference/cli/lifecycle.md | 2 + .../docs/zh-tw/reference/cli/lifecycle.md | 2 + scripts/test-layout/layout-clients.json | 1 + src/cli/index.ts | 4 +- src/client/connect.ts | 45 ++- src/client/link-home.ts | 5 +- src/client/machine-listener.ts | 3 + src/client/restore-ownership.ts | 20 +- src/client/runtime-restart.ts | 16 +- src/client/runtime.ts | 48 ++- src/client/stop-routing.ts | 62 ++++ src/client/sync-runtime.ts | 65 ++++ src/config/process-state.ts | 3 + structure/remote-link.md | 6 + structure/runtime.md | 4 +- tests/clients/client-link-connect.test.ts | 13 + tests/clients/client-link-stop.test.ts | 332 ++++++++++++++++++ tests/clients/client-runtime-restart.test.ts | 51 +++ tests/fixtures/client-link-stop-runner.ts | 228 ++++++++++++ tests/fixtures/client-link-sync-runner.ts | 49 +++ tests/fixtures/test-layout-expected.json | 2 +- tests/update/update-teardown-handover.test.ts | 21 ++ 28 files changed, 958 insertions(+), 36 deletions(-) create mode 100644 src/client/stop-routing.ts create mode 100644 src/client/sync-runtime.ts create mode 100644 tests/clients/client-link-stop.test.ts create mode 100644 tests/fixtures/client-link-stop-runner.ts create mode 100644 tests/fixtures/client-link-sync-runner.ts diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index a123bf34797..f014dd0ecd6 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -32,6 +32,8 @@ ocx start --socks5-off ### `ocx stop` +Un Link Child non supervisé restaure Codex natif et Grok avant de libérer son listener lors d’un signal, en revérifiant la connexion à chaque écriture. Service/Desktop, sibling, transfert de redémarrage et reçu stop du parent préservent les intégrations. L’arrêt refuse le redémarrage avec `409 runtime_stopping`. Une terminaison forcée peut éviter le signal ; la restauration par `ocx stop` reste nécessaire. + Arrête le proxy actif à partir de son PID, supprime le fichier de PID et rétablit le fonctionnement natif de Codex. Si un service d’arrière-plan géré est installé, `ocx stop` l’arrête d’abord afin qu’il ne puisse pas relancer le proxy. Le bouton **Stop** du tableau de bord Web exécute la même opération (`POST /api/stop`) sur tous les backends, sauf le Planificateur de tâches Windows : le wrapper peut y relancer le proxy après la fin de la tâche, donc le tableau de bord refuse avec `respawnable_service`, ne modifie rien et vous demande d'exécuter `ocx stop`. Sur un client hub connecté, `ocx stop`, y compris via l’API, conserve le routage Codex, le catalogue et la configuration Grok appartenant à la connexion ; `ocx start` les dessert de nouveau. L’arrêt explicite d’un Remote Link Child rétablit au contraire, si la restauration partagée réussit, le routage natif de Codex, le catalogue et les métadonnées de reprise sauvegardées, et supprime la configuration Grok gérée. La connexion Home enregistrée reste conservée ; `ocx disconnect` la supprime. diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index 73020dc2fc0..06103b47bc8 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -28,6 +28,8 @@ ocx start --socks5-off ### `ocx stop` +監督プロセスのない Link Child は終了シグナル時、各書き込みで接続を再確認し、リスナーを閉じる前に Codex と管理対象 Grok を復元します。サービス・Desktop、sibling、再起動の引き継ぎ、親 stop 記録がある場合は設定を保持します。終了中の再起動は `409 runtime_stopping` です。強制終了はシグナル処理を省略する場合があり、親 `ocx stop` の復元も必要です。 + 実行中のプロキシを (PID によって) 停止し、PID ファイルを削除します。スタンドアロンの所有者ではネイティブ Codex を復元します。マネージド バックグラウンド サービスがインストールされている場合、`ocx stop` はそれを最初に停止するため、プロキシを再起動できません。Web ダッシュボードの **停止** ボタンは同じ処理 (`POST /api/stop`) を実行しますが、Windows タスク スケジューラだけは例外です。タスク終了後もラッパーがプロキシを再起動しうるため、ダッシュボードは `respawnable_service` で拒否し、何も変更せずに `ocx stop` の実行を促します。 接続済みの hub クライアントでは、`ocx stop`(成功した API 停止を含む)は接続が所有する Codex のルーティング、モデルカタログ、Grok 設定を保持します。`ocx start` はルーティングを再注入せず、その設定で再び処理します。Codex を自身のアカウントへ戻すには `ocx disconnect`、既にネイティブルーティングへ戻した後の再適用には `ocx sync --restart-codex` を使います。 diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 90512b785e1..40ecde6889f 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -44,6 +44,8 @@ ocx start --socks5-off ### `ocx stop` +감독 프로세스 없이 실행되는 Link Child는 종료 신호 처리 시 각 쓰기 직전에 연결을 재확인하고 리스너를 닫기 전에 Codex 기본 설정과 관리 Grok 설정을 복원합니다. 서비스·Desktop 감독, sibling, 재시작·재활용 인계, 부모 stop 영수증이 있으면 설정을 보존합니다. 종료 중 재시작 요청은 `409 runtime_stopping`입니다. 강제 종료는 신호 처리를 건너뛸 수 있어 부모 `ocx stop`의 복원도 필요합니다. + 실행 중인 프록시를 PID 기준으로 중지하고 PID 파일을 삭제합니다. 독립 실행 소유자라면 기본 Codex를 복원합니다. 관리형 백그라운드 서비스가 설치되어 있으면 `ocx stop`이 먼저 그 서비스를 중지하므로 프록시가 다시 올라올 수 없습니다. 웹 대시보드의 **Stop** 버튼도 같은 동작(`POST /api/stop`)을 하지만, Windows 작업 스케줄러는 예외입니다. 작업이 끝나도 래퍼가 프록시를 다시 띄울 수 있어서, 대시보드는 `respawnable_service`로 거절하고 아무것도 바꾸지 않은 채 `ocx stop` 실행을 안내합니다. diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 9dc04b30b64..b557c2dfcb5 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -82,6 +82,8 @@ ocx start --socks5-off ### `ocx stop [--json]` +An unsupervised Link Child handling a shutdown signal restores native Codex and managed Grok before releasing its listener, rechecking the served connection before each write. Service/Desktop supervision, sibling ownership, restart/recycle handoff and pending parent stop receipts preserve integrations. Shutdown rejects bound restart with `409 runtime_stopping`. Forced termination can bypass signal handlers; parent `ocx stop` restoration remains necessary. To route Codex through Home again after such a stop, start the Child (`ocx start`, or `ocx service start` for an installed service) and then run `ocx sync --restart-codex`. Starting alone does not reapply routing. A sync run while the Child is stopped still reapplies routing, which points Codex at a listener that is not running, so start the Child first. + Stop the running proxy (by PID), remove the PID file, and restore native Codex for standalone owners. If a managed background service is installed, `ocx stop` also stops it first so it cannot respawn the proxy. On a connected hub client, `ocx stop` leaves Codex routing, its diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index 2ecf0d288cc..390aae87e90 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -50,6 +50,8 @@ ocx start --socks5-off ### `ocx stop` +Link Child без супервизора при сигнале восстанавливает нативный Codex и управляемый Grok до закрытия listener, проверяя подключение перед каждой записью. Сервис/Desktop, sibling, передача перезапуска и квитанция stop родителя сохраняют настройки. Во время завершения перезапуск возвращает `409 runtime_stopping`. Принудительное завершение может обойти сигнал; восстановление родителем `ocx stop` остаётся необходимым. + Остановить работающий прокси (по PID) и удалить PID-file; для автономного владельца также восстановить native Codex. Если установлена managed background service, `ocx stop` сначала останавливает и её, чтобы она не перезапустила прокси обратно. Кнопка **Stop** в веб-дашборде выполняет то же действие (`POST /api/stop`) на всех бэкендах, кроме планировщика заданий Windows: там обёртка может перезапустить прокси после завершения задачи, поэтому дашборд отказывает с `respawnable_service`, ничего не меняет и просит выполнить `ocx stop`. diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index 4a95d62b8a2..8c7e61c2750 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -50,6 +50,8 @@ ocx start --socks5-off ### `ocx stop` +Gözetimsiz Link Child kapanma sinyalinde her yazmadan önce bağlantıyı doğrular ve dinleyiciyi bırakmadan önce yerel Codex ile yönetilen Grok ayarlarını geri yükler. Servis/Desktop, sibling, yeniden başlatma devri ve üst stop makbuzu ayarları korur. Kapanma sırasında yeniden başlatma `409 runtime_stopping` döndürür. Zorla sonlandırma sinyali atlayabilir; üst `ocx stop` geri yüklemesi yine gereklidir. + Çalışan proxy'yi (PID'ye göre) durdurun, PID dosyasını kaldırın ve yerel Codex'i geri yükleyin. Yönetilen bir arka plan servisi kuruluysa `ocx stop` proxy'yi yeniden oluşturamaması için önce onu da durdurur. Web kontrol panelinin **Durdur** düğmesi aynı eylemi (`POST /api/stop`) Windows Görev Zamanlayıcı dışındaki tüm arka uçlarda çalıştırır: orada görev bittikten sonra sarmalayıcı proxy'yi yeniden başlatabilir, bu yüzden panel `respawnable_service` ile reddeder, hiçbir şeyi değiştirmez ve `ocx stop` çalıştırmanızı ister. diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index d547e59f933..5fafbccc46f 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -28,6 +28,8 @@ ocx start --socks5-off ### `ocx stop` +无人监督的 Link Child 处理退出信号时,会在每次写入前核对连接,并在关闭监听器前恢复 Codex 原生配置及受管理的 Grok。服务/Desktop、sibling、重启交接和父进程 stop 收据会保留配置。退出中的重启请求返回 `409 runtime_stopping`。强制终止可能跳过信号处理,仍需父进程 `ocx stop` 的恢复路径。 + 停止正在运行的代理(按 PID),移除 PID 文件;独立运行的所有者还会恢复原生 Codex。如果安装了受管后台服务,`ocx stop` 还会先停止该服务,这样它就无法重新拉起代理。Web 仪表盘的 **Stop** 按钮在多数后端执行同样的操作(`POST /api/stop`),但 Windows 任务计划程序除外:任务结束后包装器仍可能重新拉起代理,只有运行在代理之外的 stop 才能在恢复客户端配置前确认这个重启窗口,因此仪表盘会以 `respawnable_service` 拒绝、不做任何更改,并提示改用 `ocx stop`。 对于已连接的 hub 客户端,`ocx stop`(包括成功的 API 停止)会保留连接所拥有的 Codex 路由、模型目录和 Grok 配置。`ocx start` 使用这些保留的配置恢复服务,不会重新注入路由。要让 Codex 回到自己的账户,请运行 `ocx disconnect`;若已恢复为原生路由,要重新应用连接路由,请运行 `ocx sync --restart-codex`。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index c76ec26e196..66ba8a3ea3b 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -28,6 +28,8 @@ ocx start --socks5-off ### `ocx stop` +無人監督的 Link Child 處理結束訊號時,會在每次寫入前核對連線,並在關閉接聽器前還原 Codex 原生設定及受管理的 Grok。服務/Desktop、sibling、重新啟動交接和父程序 stop 收據會保留設定。結束中的重新啟動要求回傳 `409 runtime_stopping`。強制終止可能略過訊號處理,仍需父程序 `ocx stop` 的還原路徑。 + 停止執行中的代理(依 PID)、移除 PID 檔案,並還原原生 Codex。若已安裝受管背景服務,`ocx stop` 也會先停止它,使其無法重新生成代理。網頁儀表板的 **Stop** 按鈕在多數後端執行相同動作(`POST /api/stop`),但 Windows 工作排程器除外:工作結束後包裝程序仍可能重新啟動 Proxy,因此儀表板會以 `respawnable_service` 拒絕、不做任何變更,並請你改用 `ocx stop`。 對已連線的 hub 用戶端,`ocx stop`(包括成功的 API 停止)會保留連線擁有的 Codex 路由、目錄和 Grok 設定,`ocx start` 會再次提供服務。明確停止 Remote Link Child 時,若共用設定還原成功,則會還原原生 Codex 路由、目錄與已備份的續接歷程中繼資料,並移除受管理的 Grok 設定。已儲存的 Home 連線會保留,可用 `ocx disconnect` 移除。 diff --git a/scripts/test-layout/layout-clients.json b/scripts/test-layout/layout-clients.json index 966fb2799a4..8934d90bd52 100644 --- a/scripts/test-layout/layout-clients.json +++ b/scripts/test-layout/layout-clients.json @@ -152,6 +152,7 @@ "client-link-connect.test.ts": "clients", "client-link-relay.test.ts": "clients", "client-link-runtime.test.ts": "clients", + "client-link-stop.test.ts": "clients", "link-routes.test.ts": "clients", "client-link-state.test.ts": "clients", "client-link-status.test.ts": "clients", diff --git a/src/cli/index.ts b/src/cli/index.ts index e2f98925666..bcddd09f7ff 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1139,7 +1139,9 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) { .filter(read => isPendingTeardownAbandoned(read, teardownOwnerStillRunning)); let teardownNonce: string | undefined; // #7423: an update that restarts the proxy asks this stop to leave Codex routing for the replacement. - const handoverRequested = updateTeardownHandoverRequested(process.env, () => shouldSyncCodexOnStart(loadConfig())) && !stoppingSibling && !connectedClientSkip; + // Client start never discharges a standalone update-handover receipt or reapplies routing. + const handoverRequested = updateTeardownHandoverRequested(process.env, () => shouldSyncCodexOnStart(loadConfig())) + && !stoppingSibling && readClientConnectionState().kind !== "connected"; let deferralHonoredByProxy = false; const claimTeardown = (endpoint: { hostname: string; port: number }, endpointSource: "exact" | "guessed") => { if (teardownNonce || stoppingSibling) return; diff --git a/src/client/connect.ts b/src/client/connect.ts index 81f7f8ff516..00bb058891c 100644 --- a/src/client/connect.ts +++ b/src/client/connect.ts @@ -18,7 +18,8 @@ import { withClientLifecycle, withClientLifecycleSync, type ClientLifecycleHeld, type ClientLifecycleLockDeps, } from "./lifecycle-lock"; -import { invalidateCodexModelsCache } from "../codex/catalog/sync"; +import { invalidateCodexModelsCache, invalidateCodexModelsCacheWithPermit } from "../codex/catalog/sync"; +import { withCatalogWriteSerialization } from "../codex/catalog-write-serialization"; import { injectCodexConfig, currentExternalCodexModelProvider, @@ -30,7 +31,7 @@ import { journalOwner, restoreJournalState, } from "../codex/journal"; -import { DEFAULT_CATALOG_PATH } from "../codex/paths"; +import { DEFAULT_CATALOG_PATH, getCodexHome } from "../codex/paths"; import { readServiceApiTokenState, readTokenBackupState, @@ -95,6 +96,7 @@ import { import { hubStateCachePath } from "./hub-state"; import { ClientLinkStateError, clearClientLinkState, readClientLinkState } from "./link-state"; import { isLinkPort } from "../link/ports"; +import { clientSyncRuntimeGuard, ClientSyncRuntimeError } from "./sync-runtime"; class RotationRecoveryRequiredError extends Error { constructor(message: string, options?: ErrorOptions) { @@ -901,7 +903,15 @@ async function syncConnectedClientUnlocked( if (state.kind !== "connected" || state.value.pendingOperation) throw new Error("client_sync_unavailable"); const token = readServiceApiTokenState(); if (token.kind !== "present" || token.fingerprint !== state.value.tokenFingerprint) throw new Error("client_token_changed"); - return { connection: state.value, token }; + let assertRuntimeCurrent = () => {}; + try { if (isLinkConnection(state.value)) assertRuntimeCurrent = clientSyncRuntimeGuard(); } + catch (error) { + if (options.expect && error instanceof ClientSyncRuntimeError && error.code === "client_runtime_stopping") { + throw new ClientSyncExpectationError(options.expect, "pending_operation"); + } + throw error; + } + return { connection: state.value, token, assertRuntimeCurrent }; }), deps.lifecycleLockDeps); let downloaded: Awaited> | undefined; let stale = false; @@ -918,6 +928,7 @@ async function syncConnectedClientUnlocked( // they were, which is the known-good state. if (downloaded) assertClientCatalogCompatible(downloaded.body, deps.catalogCompatibility); const next = withClientLifecycleSync(() => withConfigMutationLockSync(() => { + initial.assertRuntimeCurrent(); assertClientConnectionUnchanged(initial.connection); const token = readServiceApiTokenState(); if (token.kind !== "present" || token.fingerprint !== initial.token.fingerprint) throw new Error("client_token_changed"); @@ -933,10 +944,12 @@ async function syncConnectedClientUnlocked( }), deps.lifecycleLockDeps); // Read-only: injection invokes this while N/C are held. Acquiring L here would invert C→L. const beforeClientWrite = () => { + initial.assertRuntimeCurrent(); assertClientConnectionUnchanged(next); const token = readServiceApiTokenState(); if (next.pendingOperation || token.kind !== "present" || token.fingerprint !== next.tokenFingerprint) throw new Error("client_token_changed"); }; + const withClientWrite = (write: () => T): T => withConfigMutationLockSync(() => { beforeClientWrite(); return write(); }); let injected = false; let homeSettings: HomeCodexSettingsSource | undefined; let appliedRecord: HomeCodexSettingsRecord | undefined; @@ -945,12 +958,15 @@ async function syncConnectedClientUnlocked( let config = loadConfig(); // #6957: a link Child applies the Home's OpenCodex-owned Codex settings with the catalog. if (isLinkConnection(next)) { - homeSettings = await resolveHomeCodexSettings(next, initial.token.token, deps.linkHome); + homeSettings = await resolveHomeCodexSettings(next, initial.token.token, { ...deps.linkHome, withWriteGuard: withClientWrite }); if (homeSettings.source !== "local") config = overlayHomeCodexSettings(config, homeSettings.document.settings); // About to write different settings: until their record commits, report no applied digest // rather than the previous one (OCX-6). A failed injection then reads "unknown", never a // digest of values Codex no longer has or never got. - if (homeSettings.source === "home" && next.link) withdrawChangingHomeCodexSettingsRecord(next.link.linkId, homeSettings.document); + if (homeSettings.source === "home" && next.link) { + const { linkId } = next.link, { document } = homeSettings; + withClientWrite(() => withdrawChangingHomeCodexSettingsRecord(linkId, document)); + } } const result = await injectCodexConfig(config.port, { ...config, syncResumeHistory: false }, { routingTarget: routingTarget(next.serverUrl, isLinkConnection(next) ? config.port : undefined, config), catalogPath: DEFAULT_CATALOG_PATH, @@ -962,23 +978,24 @@ async function syncConnectedClientUnlocked( const linkId = next.link.linkId; const settings = homeSettings; try { - appliedRecord = withClientLifecycleSync(() => { - beforeClientWrite(); - return recordHomeCodexSettings(linkId, settings, deps.now) ?? readHomeCodexSettingsRecord(linkId) ?? undefined; - }, deps.lifecycleLockDeps); + appliedRecord = withClientLifecycleSync(() => withClientWrite(() => + recordHomeCodexSettings(linkId, settings, deps.now) ?? readHomeCodexSettingsRecord(linkId) ?? undefined), deps.lifecycleLockDeps); } catch (error) { // An ownership change is not a disk problem: report it as itself. - if (error instanceof Error && (error.message === "client_token_changed" || error.message === "client_connection_changed")) throw error; + if (error instanceof ClientSyncRuntimeError || (error instanceof Error && (error.message === "client_token_changed" || error.message === "client_connection_changed"))) throw error; // The injection committed, so the cache refresh below still runs; the caller reports this. - try { clearHomeCodexSettingsRecord(); } catch { /* status already reads no recorded apply or reports the write */ } + try { withClientWrite(clearHomeCodexSettingsRecord); } catch { /* status already reads no recorded apply or reports the write */ } applyRecordFailed = true; } } } const cacheSynced = withClientLifecycleSync(() => { - beforeClientWrite(); - // Cache invalidation acquires K itself (N -> K -> C); never call it while C is held. - return invalidateCodexModelsCache({ allowWhenDesiredDisabled: true }); + // Acquire K before the guarded C commit; never acquire K from inside C. + const owningHome = getCodexHome(); + const result = withCatalogWriteSerialization(owningHome, permit => withClientWrite(() => + invalidateCodexModelsCacheWithPermit(permit, owningHome, { allowWhenDesiredDisabled: true })), + { intent: "cache", writer: "cache-invalidate" }); + return result.kind === "completed" && result.value; }, deps.lifecycleLockDeps); return { catalogWritten: downloaded !== undefined, cacheSynced, injected, stale, diff --git a/src/client/link-home.ts b/src/client/link-home.ts index a3cf4b49343..3452e602ee1 100644 --- a/src/client/link-home.ts +++ b/src/client/link-home.ts @@ -299,7 +299,7 @@ export type HomeCodexSettingsSource = export async function resolveHomeCodexSettings( connection: LinkHomeConnection, admissionKey: string, - deps: LinkHomeDeps & { now?: () => Date } = {}, + deps: LinkHomeDeps & { now?: () => Date; withWriteGuard?: (write: () => T) => T } = {}, ): Promise { const linkId = connection.link?.linkId; if (!linkId) return { source: "local", reason: "not a Remote Link Child" }; @@ -308,7 +308,8 @@ export async function resolveHomeCodexSettings( return { source: "home", document: fetched.document, fetchedAt: (deps.now ?? (() => new Date()))().toISOString() }; } if (fetched.kind === "unsupported") { - clearHomeCodexSettingsRecord(); + if (deps.withWriteGuard) deps.withWriteGuard(clearHomeCodexSettingsRecord); + else clearHomeCodexSettingsRecord(); return { source: "local", reason: "the Home does not publish Codex settings; upgrade the Home" }; } const cached = readHomeCodexSettingsRecord(linkId); diff --git a/src/client/machine-listener.ts b/src/client/machine-listener.ts index 0f6ca2681b4..a697089b975 100644 --- a/src/client/machine-listener.ts +++ b/src/client/machine-listener.ts @@ -324,6 +324,9 @@ async function handleBoundRestart( ); if (!authorized) return Response.json({ success: false, error: "restart capability required" }, { status: 401, headers: noStore }); const admission = await restart.request(); + if (admission.kind === "stopping") { + return Response.json({ success: false, error: { code: "runtime_stopping" } }, { status: 409, headers: noStore }); + } if (admission.kind === "supervised") { return Response.json({ success: false, diff --git a/src/client/restore-ownership.ts b/src/client/restore-ownership.ts index e1d190cb9f0..4ca5d24741d 100644 --- a/src/client/restore-ownership.ts +++ b/src/client/restore-ownership.ts @@ -1,5 +1,6 @@ import { withConfigMutationLockSync } from "../config"; import { assertNoClientDisconnectPending, pendingClientConnectMayOwnToken, readClientConnectionState } from "./state"; +import type { OcxClientConnectionConfig } from "../types"; export type RestoreBoundaryOwnership = "restore" | "hub-owned" | "unproven"; export type RestoreOwnershipRefusal = Exclude; @@ -21,8 +22,16 @@ export function inspectStopRestoreOwnership(read = readClientConnectionState): R } catch { return "unproven"; } } +function restoreConnectionSnapshot(connection: OcxClientConnectionConfig) { + // Normal sync changes these observations, not restoration ownership. All other + // fields, including priorCatalog and future connection fields, remain guarded. + const { catalogFingerprint: _fingerprint, catalogSyncedAt: _syncedAt, ...snapshot } = connection; + return snapshot; +} + /** C is reentrant inside N/C; this never acquires L or N. No lease crosses an await. */ -export function stopRestoreWriteGuard() { +export function stopRestoreWriteGuard(expectedConnection?: OcxClientConnectionConfig) { + const expected = expectedConnection && restoreConnectionSnapshot(structuredClone(expectedConnection)); let boundary: RestoreBoundaryOwnership = "restore"; return { boundary: () => boundary, @@ -32,6 +41,15 @@ export function stopRestoreWriteGuard() { return withConfigMutationLockSync(() => { entered = true; if (boundary === "restore") boundary = inspectStopRestoreOwnership(); + if (boundary === "restore" && expected) { + try { + if (expected.pendingOperation) throw new Error("client_rotation_pending"); + const current = readClientConnectionState(); + if (current.kind !== "connected" || !Bun.deepEquals(restoreConnectionSnapshot(current.value), expected, true)) { + throw new Error("client_connection_changed"); + } + } catch { boundary = "unproven"; } + } if (boundary !== "restore") throw new StopRestoreOwnershipError(boundary); return write(); }); diff --git a/src/client/runtime-restart.ts b/src/client/runtime-restart.ts index f5ad4417908..899fb165112 100644 --- a/src/client/runtime-restart.ts +++ b/src/client/runtime-restart.ts @@ -30,9 +30,12 @@ export type ClientRestartSupervisor = "service" | "desktop"; export type ClientRestartAdmission = | { kind: "accepted"; alreadyRestarting: boolean } + | { kind: "stopping" } | { kind: "supervised"; supervisor: ClientRestartSupervisor }; export interface ClientRestartIo { + /** A signal shutdown has claimed this runtime; it cannot admit a replacement. */ + stopping?: () => boolean; /** Which supervisor owns this process, or null when it starts its own replacement. */ supervisor(): Promise; /** Stop the tunnel supervisor and the listener, giving in-flight requests up to `drainMs`. */ @@ -41,7 +44,7 @@ export interface ClientRestartIo { spawnReplacement(deadlineAt: number): Promise; /** Serve again on this process: listener, fresh tunnel supervisor, records. */ resume(): Promise; - exitProcess(code: number): void; + exitProcess(code: number): void | Promise; now?: () => number; sleep?: (ms: number) => Promise; warn?: (message: string) => void; @@ -49,7 +52,9 @@ export interface ClientRestartIo { export interface ClientRestartController { request(): Promise; - /** Settles when the restart in flight (if any) has finished or rolled back. Tests only. */ + /** Only an accepted flight owns routing; a supervisor check does not. */ + hasHandoff(): boolean; + /** Join the accepted flight before signal teardown; may reject on a failed exit callback. */ settled(): Promise; } @@ -81,16 +86,19 @@ export function createClientRestartController(io: ClientRestartIo): ClientRestar await io.resume(); } catch (resumeError) { warn(`[client] restart: this runtime could not serve again (${failureCode(resumeError)}); run 'ocx start'`); - io.exitProcess(1); + await io.exitProcess(1); } return; } - io.exitProcess(0); + await io.exitProcess(0); }; return { + hasHandoff: () => flight !== null, async request() { + if (io.stopping?.()) return { kind: "stopping" }; const supervisor = await io.supervisor(); + if (io.stopping?.()) return { kind: "stopping" }; if (supervisor) return { kind: "supervised", supervisor }; if (flight) return { kind: "accepted", alreadyRestarting: true }; flight = run(now()).finally(() => { flight = null; }); diff --git a/src/client/runtime.ts b/src/client/runtime.ts index 7677170f3ae..75e3462f147 100644 --- a/src/client/runtime.ts +++ b/src/client/runtime.ts @@ -32,12 +32,17 @@ import { isLinkConnection, readClientConnectionState } from "./state"; import { withClientLifecycleSync } from "./lifecycle-lock"; import { clientRestartRollbackAction, clientRestartSupervisor, createClientRestartController, type ClientRestartController } from "./runtime-restart"; import { createLinkParityReporter, type LinkParityReporter } from "./link-parity-reporter"; +import { restoreLinkClientRouting } from "./stop-routing"; +import { markClientRuntimeStopping, markClientRuntimeStoppingWithRetry } from "./sync-runtime"; let activeServer: Server | null = null; let activePort: number | null = null; let activeSupervisor: ClientLinkSupervisor | null = null; let activeReporter: LinkParityReporter | null = null; let recycleScheduled = false; +let recycleTimer: ReturnType | undefined; +let recycleFlight: Promise | null = null; +let clientStopping = false; /** * When this runtime's process started (OCX-6, P5): what `/healthz` reports as `startedAt` and the @@ -97,7 +102,7 @@ export function clientRuntimeRecord( ): RuntimePortState { let command: string[] | undefined; try { command = currentServingCommand(); } catch { /* no reliable relaunch identity; update admission fails closed */ } - return { pid, port, hostname: "127.0.0.1", attestationSecret, ...(command ? { command } : {}), ...siblingRuntimeField() }; + return { pid, port, hostname: "127.0.0.1", attestationSecret, ...(command ? { command } : {}), ...siblingRuntimeField(), ...(clientStopping ? { clientStopping: true as const } : {}) }; } export function standaloneRecycleEnv( @@ -126,12 +131,15 @@ export function standaloneRecycleEnv( } export function scheduleStandaloneRecycle(disconnectedTokenFingerprint: string): void { - if (recycleScheduled) return; + if (recycleScheduled || clientStopping) return; recycleScheduled = true; - const timer = setTimeout(() => { - void recycleStandalone(disconnectedTokenFingerprint); + recycleTimer = setTimeout(() => { + recycleTimer = undefined; + if (clientStopping) return; + recycleFlight = recycleStandalone(disconnectedTokenFingerprint).finally(() => { recycleFlight = null; }); + void recycleFlight.catch(error => { if (!clientStopping) throw error; }); }, 50); - if (typeof timer === "object" && "unref" in timer) timer.unref(); + if (typeof recycleTimer === "object" && "unref" in recycleTimer) recycleTimer.unref(); } function configuredTcpPort(): number | undefined { @@ -421,7 +429,9 @@ export async function startClientRuntime( try { await server.stop(true); } catch { /* already closed */ } }; + let shuttingDown = false; const restartController: ClientRestartController = createClientRestartController({ + stopping: () => shuttingDown, supervisor: io.restartSupervisor ?? clientRestartSupervisor, stop: stopServing, spawnReplacement: async deadlineAt => { @@ -445,8 +455,8 @@ export async function startClientRuntime( } }, exitProcess: async code => { - await flushLinkDiagnostics(); - process.exit(code); + try { await flushLinkDiagnostics(); } + finally { process.exit(code); } }, }); @@ -455,11 +465,24 @@ export async function startClientRuntime( await serve(); options.afterPublish?.(); - let shuttingDown = false; const shutdown = (signal?: NodeJS.Signals) => { if (shuttingDown) return; shuttingDown = true; + clientStopping = true; + if (recycleTimer) { clearTimeout(recycleTimer); recycleTimer = undefined; } + const fenceSync = () => { + if (!linkMode) return true; + try { markClientRuntimeStopping({ pid: process.pid, port: servedPort, attestationSecret }); return true; } + catch { return false; } + }; + let syncFenced = fenceSync(); void (async () => { + // An accepted restart owns its replacement and rollback. A successful handoff exits + // inside that flight; a rollback finishes before this signal tears the runtime down. + if (restartController.hasHandoff()) await restartController.settled().catch(() => {}); + if (recycleFlight) await recycleFlight.catch(() => {}); + // A rollback may have republished its runtime record while shutdown joined it. + syncFenced = !linkMode || await markClientRuntimeStoppingWithRetry({ pid: process.pid, port: servedPort, attestationSecret }); credentialObservation?.stop(); credentialObservation = null; activeReporter?.stop(); @@ -470,15 +493,18 @@ export async function startClientRuntime( console.warn(`[client] link supervisor stop failed: ${error instanceof Error ? error.message : String(error)}`); } activeSupervisor = null; + const restored = linkMode ? restoreLinkClientRouting(state.value, { syncFenced }) : null; + if (restored && !restored.success) console.error(`[client] ${restored.message}`); try { activeServer?.stop(true); } catch (error) { console.warn(`[client] listener stop failed: ${error instanceof Error ? error.message : String(error)}`); } finally { - cleanup(); - await flushLinkDiagnostics(); + // Keep stopping admission visible through the last await. Cleanup and exit are adjacent. + try { await flushLinkDiagnostics(); } finally { cleanup(); } // launchd relaunches only unsuccessful exits; an external signal must still count. - process.exit(handledSignalExitCode(signal)); + if (restored?.success === false) process.exit(1); + else process.exit(handledSignalExitCode(signal)); } })(); }; diff --git a/src/client/stop-routing.ts b/src/client/stop-routing.ts new file mode 100644 index 00000000000..f3a5377e9ee --- /dev/null +++ b/src/client/stop-routing.ts @@ -0,0 +1,62 @@ +import { existsSync } from "node:fs"; +import { restoreNativeCodex } from "../codex/inject"; +import { hasInjectedCodexRouting } from "../codex/injected-marker"; +import { readBoundedCodexConfig } from "../codex/inject/bounded-config-reader"; +import { journalOwner, JOURNAL_PATH } from "../codex/journal"; +import { CODEX_CONFIG_PATH } from "../codex/paths"; +import { siblingOfLivePort, siblingSkipMessage } from "../codex/sibling-start"; +import type { OcxClientConnectionConfig } from "../types"; +import { pendingTeardownOutstanding } from "../config/pending-teardown"; +import { stripGrokConfig } from "../grok/inject"; +import { isDesktopSupervised } from "../lib/system-restart-contract"; +import { serviceEnvironmentOwnedHere } from "../service/guards"; +import { stopRestoreWriteGuard } from "./restore-ownership"; +import { isLinkConnection } from "./state"; + +export type LinkRoutingRestoreResult = { success: boolean; restored: boolean; message: string }; + +const resumeGuidance = "After starting the Child again, run `ocx sync --restart-codex` to restore its Home routing."; +const refusal = (reason: string): LinkRoutingRestoreResult => ({ + success: false, restored: false, + message: `Link Child Codex routing restore refused: ${reason}. No further writes; inspect remaining recovery evidence.`, +}); + +/** Unsupervised signal cleanup; explicit parent stop and restart retain their own restoration. */ +export function restoreLinkClientRouting(expected: OcxClientConnectionConfig, options: { syncFenced: boolean }): LinkRoutingRestoreResult { + if (siblingOfLivePort() !== null) return { success: true, restored: false, message: siblingSkipMessage() }; + if (!isLinkConnection(expected)) { + return { success: true, restored: false, message: "Hub connection-owned integrations preserved." }; + } + const preserve = () => process.env.OCX_SERVICE === "1" || isDesktopSupervised() + || pendingTeardownOutstanding(); + if (preserve()) return { success: true, restored: false, message: "Supervised restart or pending teardown owns the client integrations." }; + if (options?.syncFenced !== true) return refusal("the runtime sync fence could not be verified"); + const guard = stopRestoreWriteGuard(expected); + const withWriteGuard = (write: () => T): T => guard.run(() => { + if (preserve()) throw new Error("restart or teardown ownership changed"); + const owner = journalOwner({ readOnly: true }); + if (existsSync(JOURNAL_PATH) && (owner?.kind !== "client" || owner.apiKeyId !== expected.apiKeyId)) { + throw new Error("the Codex journal belongs to another owner or is unverified"); + } + return write(); + }); + try { + withWriteGuard(() => { + const owner = journalOwner({ readOnly: true }); + if (owner === null) { + const config = readBoundedCodexConfig(CODEX_CONFIG_PATH); + if (existsSync(JOURNAL_PATH) || (config !== null && hasInjectedCodexRouting(config))) throw new Error("the client journal is missing or unverified"); + } + }); + const result = restoreNativeCodex({ withWriteGuard }); + if (result.artifacts.config.state === "failed" || result.historyPreflightRefusal) { + return refusal("native Codex restoration was not authorized or could not complete"); + } + const grok = serviceEnvironmentOwnedHere() ? withWriteGuard(() => stripGrokConfig()) : { ok: true, changed: false }; + if (!result.success || !grok.ok) return refusal("native Codex or managed Grok restoration did not complete"); + return { success: true, restored: result.artifacts.config.changed || result.artifacts.catalog.changed || grok.changed, + message: `Link Child native Codex and managed Grok restored; connection credentials preserved. ${resumeGuidance}` }; + } catch (error) { + return refusal(error instanceof Error ? error.message : "restoration failed"); + } +} diff --git a/src/client/sync-runtime.ts b/src/client/sync-runtime.ts new file mode 100644 index 00000000000..883b013b6e9 --- /dev/null +++ b/src/client/sync-runtime.ts @@ -0,0 +1,65 @@ +/** Cross-process admission for Link sync while its local runtime shuts down. */ +import { lstatSync } from "node:fs"; +import { ConfigMutationLockError, withConfigMutationLockSync } from "../config"; +import { getRuntimePortPath, isValidRuntimePortState, writeRuntimePort, type RuntimePortState } from "../config/process-state"; +import { readBoundedRegularFileSync } from "../lib/bounded-file-read"; + +export class ClientSyncRuntimeError extends Error { + constructor(readonly code: "client_runtime_stopping" | "client_runtime_changed" | "client_runtime_unverified") { + super(`${code}: retry sync after the Child has started; ocx start/stop can reconcile a stale runtime record`); + } +} + +function readSyncRuntime(): RuntimePortState | null { + const path = getRuntimePortPath(); + try { lstatSync(path); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw new ClientSyncRuntimeError("client_runtime_unverified"); + } + try { + const value: unknown = JSON.parse(readBoundedRegularFileSync(path, 16 * 1024).toString("utf8")); + if (!isValidRuntimePortState(value)) throw new Error("invalid runtime record"); + return value; + } catch { throw new ClientSyncRuntimeError("client_runtime_unverified"); } +} + +/** Capture and recheck under C. Proven no-runtime sync keeps its existing offline behavior. */ +export function clientSyncRuntimeGuard(): () => void { + const expected = readSyncRuntime(); + if (expected?.clientStopping) throw new ClientSyncRuntimeError("client_runtime_stopping"); + return () => { + const current = readSyncRuntime(); + if (current?.clientStopping) throw new ClientSyncRuntimeError("client_runtime_stopping"); + if (!Bun.deepEquals(current, expected, true)) throw new ClientSyncRuntimeError("client_runtime_changed"); + }; +} + +/** The same C transaction excludes every sync commit; never mark a replacement runtime. */ +export function markClientRuntimeStopping(expected: Required>): void { + withConfigMutationLockSync(() => { + const current = readSyncRuntime(); + if (!current || current.pid !== expected.pid || current.port !== expected.port + || current.attestationSecret !== expected.attestationSecret) { + throw new ClientSyncRuntimeError("client_runtime_changed"); + } + if (!current.clientStopping) writeRuntimePort({ ...current, clientStopping: true }); + }); +} + +/** Yield only for proved SQLite contention; storage/identity failures remain immediate refusals. */ +export async function markClientRuntimeStoppingWithRetry( + expected: Required>, + deps: { mark?: () => void; sleep?: (ms: number) => Promise } = {}, +): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + try { (deps.mark ?? (() => markClientRuntimeStopping(expected)))(); return true; } + catch (error) { + const cause = error instanceof ConfigMutationLockError ? error.cause : undefined; + const code = cause && typeof cause === "object" && "code" in cause ? cause.code : undefined; + if ((code !== "SQLITE_BUSY" && code !== "SQLITE_LOCKED") || attempt === 19) return false; + await (deps.sleep ?? Bun.sleep)(50); + } + } + return false; +} diff --git a/src/config/process-state.ts b/src/config/process-state.ts index b7fa2349112..5b71fd4f430 100644 --- a/src/config/process-state.ts +++ b/src/config/process-state.ts @@ -39,6 +39,8 @@ export type RuntimePortState = { command?: string[]; /** Per-process proof key; protected by the config directory and never served. */ attestationSecret?: string; + /** A Link runtime is draining; new and in-flight sync commits must stand down. */ + clientStopping?: true; /** * The live proxy's port when this runtime is a sibling instance started beside it * (`src/codex/sibling-start.ts`). `ocx stop` reads it to leave shared client routing alone. @@ -61,6 +63,7 @@ export function isValidRuntimePortState(value: unknown): value is RuntimePortSta && Number(state.port) <= 65535 && hostnameOk && attestationOk + && (state.clientStopping === undefined || state.clientStopping === true) && siblingOk; } diff --git a/structure/remote-link.md b/structure/remote-link.md index c0a02ec2c21..75dbd235f5a 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -1,5 +1,11 @@ # Remote Link +Signal fencing uses the configuration mutation lock (`C`), yielding 50 ms between at most 20 attempts only when `ConfigMutationLockError` wraps `SQLITE_BUSY` or `SQLITE_LOCKED`; identity, storage and other failures refuse immediately. Restoration callers must explicitly supply their sync-fence verdict. `tests/clients/client-link-stop.test.ts` covers a real cross-process C transaction, retry exhaustion and non-contention errors. + +`src/client/sync-runtime.ts` fences Link sync against the local runtime record. Signal shutdown marks its own PID/port/attestation generation as `clientStopping` under C before restoration, and retains that mark through its last await. Sync captures the record at admission and rechecks it under C before catalog, injection, settings-record and cache commits. A stopping, replaced, removed or unreadable record refuses the write; a stale operation cannot reinject after the Child exits. Failure to mark the runtime refuses restoration without overriding service, Desktop, sibling or parent-receipt preservation. No-runtime sync remains an explicit offline operation: it reapplies routing even though no Child listener is serving it. A stale stopping record is reconciled by the existing start/stop recovery paths, not by guessing PID ownership. The separate-process catalog/settings-response holds in `tests/clients/client-link-stop.test.ts` cover late commits, fresh sync during shutdown and the completed-sync control. + +Orderly signals in an unsupervised Link Child call `src/client/stop-routing.ts` before releasing the listener. It reuses native Codex config/catalog/history restoration and managed Grok removal; the saved connection and credentials remain. `src/client/restore-ownership.ts` optionally binds each write under C to the immutable served connection, including selection and rotation state. Only `catalogFingerprint` and `catalogSyncedAt`, updated by normal catalog sync, are excluded from this restore-specific comparison; `priorCatalog` remains guarded and the shared full-snapshot CAS for asynchronous connection work is unchanged. A changed owner, pending transition or unverified journal refuses remaining writes. Service/desktop supervision, sibling ownership, accepted restart/recycle handoff and outstanding parent teardown receipts preserve integrations for their existing owners. Signal shutdown waits for an accepted restart to hand off or finish rolling back; a pending admission owns no routing. It refuses new or pending restart admission with `409 runtime_stopping`; normal bound restart remains unchanged. After an ordinary unsupervised stop, start alone does not reapply routing; the operator steps live in the public CLI lifecycle reference. Coverage: `tests/clients/client-link-stop.test.ts`, `tests/clients/client-runtime-restart.test.ts`. + `src/link/ssh-argv.ts` and `src/link/remote-windows.ts` build pinned Child sync commands that select and version-check the same executable before executing `sync --expect-link` without a restart flag. Fan-out platform discovery in `src/link/remote-platform.ts` accepts a Windows marker on any stdout line or one framed uname line, so login-shell output cannot decide the platform. The Home requires one valid selection marker on stderr and one stdout delimiter, and classifies only the text after that delimiter; login-shell output before them is ignored. Oversized version probes are skipped. POSIX version capture and watchdog readiness use a private temporary directory, removed before execution or on refusal. The Home's `src/cli/sync-command.ts` fans out only after its catalog is current; the Child may still restart its own OpenCodex client runtime after applying changed Home settings. `src/link/` owns the building blocks for linking OpenCodex machines over SSH. The pure modules start no process, open no socket and schedule no timer on import. diff --git a/structure/runtime.md b/structure/runtime.md index 50f3d9f4817..ca2ae5820fc 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -117,7 +117,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | `src/config.ts`, `src/config/persisted-mutation.ts` | Persisted `~/.opencodex/config.json` surface: the facade keeps load/save/initialize entry points and re-exports the schema-valid mutation callback, types, and one-shot test seam; the leaf owns its bounded rebase under the shared lock and a read-only authority reader for admitted writers that starts no optional ACL flight. Schema lives in `src/config/schema/` (`config-schema.ts`, `leaf-validators.ts`), defaults in `src/config/proxy-env.ts`, and replace-path persistence in `src/config/persist-unlocked.ts`. | | `src/config/paths.ts` | Resolves `OPENCODEX_HOME`, `config.json`, and owner-only directory hardening. | | `src/config/atomic-write.ts` | Shared synchronous/asynchronous temp-harden-rename writer and residual-temp failure contract. No-follow exclusive publication reuses its private descriptor writer for raw recovery bytes, checks bigint identity and publishes by a no-replace hard link; cleanup never scrubs an already published inode. Synchronous caller validation runs immediately before each rename attempt, including Windows retries. The temp is ACL-hardened before it holds a byte and again before the rename, both `required: true`; the second call is a memo hit rather than a second icacls sequence because the writer re-asserts descriptor/path identity after the content write and re-attributes the harden through `reattributeHardenedSecretPath`. Windows takes no `chmod` on that path — it sets the read-only attribute, not the DACL, and its ChangeTime bump is what used to retire the memo. The existing-secret compliance probe in `src/lib/windows-secret-acl.ts` uses the same SID-based private-entry policy as recovery-copy reuse when the opt-in single-owner ACE fast check cannot establish compliance; recovery-copy reuse always calls the SID policy directly. Retained SYSTEM and Administrators ACEs do not trigger mutation or duplicate recovery publication. The asynchronous probe in `src/lib/windows-owner-acl.ts` yields during the bounded descriptor subprocess and shares the synchronous parser, without admitting a hardening success memo. | -| `src/config/process-state.ts` | Owns `ocx.pid`, `runtime-port.json`, cheap liveness, full command-line identity verification, and snapshot-guarded cleanup. A transient Windows WMIC/PowerShell command-line probe failure gets one bounded retry before a PID reads as unverified. `src/config/tracked-runtime-inspection.ts` re-runs the same record reads once for the guarded stop, shares `readPid()`'s cached start-identity observation rather than re-probing it, and names the failed check as a fixed cause. | +| `src/config/process-state.ts` | Owns `ocx.pid`, `runtime-port.json`, cheap liveness, full command-line identity verification, and snapshot-guarded cleanup. The optional `clientStopping: true` field fences Link sync through signal restoration and the final diagnostic flush; `src/client/sync-runtime.ts` compares the runtime generation before each sync commit. A transient Windows WMIC/PowerShell command-line probe failure gets one bounded retry before a PID reads as unverified. `src/config/tracked-runtime-inspection.ts` re-runs the same record reads once for the guarded stop, shares `readPid()`'s cached start-identity observation rather than re-probing it, and names the failed check as a fixed cause. | | `src/config/admitted-identity.ts` | Which configuration a derived artifact was built from. Detaches the resident configuration as plain data so one pass cannot gather under one state and project under another, and records the complete structure beside the configuration file's bytes. Canonical identity tags arrays and objects separately, so key sorting cannot equate an object with an array of entry pairs. Refuses an accessor, a cycle, a value JSON could not produce, an unreadable file and a file the loader would have had to salvage; a callable `providers[name].fetch` is the one non-data field, held and compared by reference, while a written one is ordinary data on both sides, as the outbound transport also reads it. It does NOT require the resident configuration to equal the file: the proxy routes by what it holds, and live reconciliation retains live changes and the active listener binding on purpose. Evidence stays in a module WeakMap, never on the config and never in a response. | | `src/server/ports.ts` / `src/server/bind-host.ts` | Own bind availability, ephemeral-port selection and startup hostname normalization shared with liveness: blank and case-insensitive `localhost`/`localhost.` bind to IPv4 loopback; other spellings remain unchanged. Temporary probes dispose accepted peers and wait for listener close before reporting success. | | `src/cli/status.ts` / `src/cli/status-probes.ts` | Status snapshot assembly and the shared read-only health/stale-process probes used by status and doctor. Probe evidence keeps recorded-port choice, before/after snapshots and per-call timer cleanup together. | @@ -177,7 +177,7 @@ described in [OpenAI quota ownership](providers/openai-tiers.md#public-provider- `runtime-port.json` through `src/config/process-state.ts`, syncs Codex config/catalog, then serves until shutdown. Standalone shutdown restores native Codex; connected hub clients preserve connection-owned Codex routing, catalog and Grok config across `ocx stop`/`ocx start`. An ordinary runtime rechecks persisted connection ownership in its exit cleanup: a connected Remote Link Child remains subject to the existing native Codex/Grok restoration decisions. Graceful signal shutdown runs that synchronous cleanup after draining and before releasing the listener. Sibling, recycling and service-managed exit exclusions remain intact; receipt-backed API stop may explicitly defer shared restoration to the stopping client. Only an accepted receipt deferral in `src/server/stop-teardown.ts` latches that process's exit deferral; CLI exit cleanup keeps native Codex and Grok restoration with the parent, while still stopping background owners and removing runtime records. Client start does not re-inject routing: use `ocx sync --restart-codex` to reapply it, or `ocx disconnect` to release connection ownership. A sibling instance beside a live proxy ([Codex home](codex-home.md#codex-home)) syncs and restores nothing, and `ocx stop` of a runtime whose record carries `siblingOfPort` skips the shared teardown. `src/cli/index.ts` resolves same-home ownership, then checks shared client hints through the cross-home owner helper, then reconciles the journal only when no sibling is marked. An indeterminate cross-home result refuses a new start before binding or publication, rather than persisting an unverified sibling topology; the next start performs fresh discovery. Service mode sets `OCX_SERVICE=1`, so managed restarts do not repeatedly restore/reinject; scheduler failure diagnostics follow the [service command contract](ops/service-and-sidecars.md#background-service-command-selection). Explicit service stop and -uninstall still restore. `src/service/cli.ts` removes the service token on uninstall only when persisted client state is disconnected and no pending connect marker owns the newly issued key. `src/client/connect.ts` publishes that fingerprint marker before the key, then clears it with the connection commit or rollback under the client lifecycle and config mutation locks. Connected, invalid, or mismatched client state retains an existing token. A valid pending marker retains only its matching fingerprint; an older marker does not own a replacement service key. An absent token is reported as absent; unsafe, malformed, or unreadable markers and lock, state-read, or deletion failures leave cleanup unverified. +uninstall still restore. Native client signal cleanup follows the [served-connection and supervisor boundaries](remote-link.md); client update stops use full parent restoration because client start cannot discharge standalone handover receipts. `src/service/cli.ts` removes the service token on uninstall only when persisted client state is disconnected and no pending connect marker owns the newly issued key. `src/client/connect.ts` publishes that fingerprint marker before the key, then clears it with the connection commit or rollback under the client lifecycle and config mutation locks. Connected, invalid, or mismatched client state retains an existing token. A valid pending marker retains only its matching fingerprint; an older marker does not own a replacement service key. An absent token is reported as absent; unsafe, malformed, or unreadable markers and lock, state-read, or deletion failures leave cleanup unverified. `src/cli/tray-proxy.ts` coordinates `ocx restart` and tray restart. A second start requires proof the prior attempt never launched or its child exited, plus confirmed absence under a fresh bounded discovery window; a missing health response alone is insufficient. After an accepted restart, polling continues through uncertain observations until its deadline and requires a new runtime PID on the same port. If the old proxy then disappears, explicit recovery starts it regardless of the Codex autostart preference; a skipped recovery fails rather than reporting success. The Windows tray startup-health probe in `src/tray/windows-tray.ps1` requests termination at its 30-second deadline without waiting on the UI thread. It retains the process through later ticks until actual exit and both redirected readers settle; replacements cannot start while that reference is held. Launch setup failures follow the same cleanup. Tray shutdown alone waits at most 500 ms before disposal. `ocx restart` also restarts a connected client runtime, which serves the same bound `POST /api/system/restart` from its client listener; the CLI sends that request only while the client's 90-second restart budget still fits before replacement observation ends (`CLIENT_RESTART_REQUEST_MIN_REMAINING_MS`), and reports a supervised client's `409 restart_supervised` as "not performed, runtime kept". See [Remote Link](remote-link.md) for the rollback. diff --git a/tests/clients/client-link-connect.test.ts b/tests/clients/client-link-connect.test.ts index 6b8130a9e33..25e59266a14 100644 --- a/tests/clients/client-link-connect.test.ts +++ b/tests/clients/client-link-connect.test.ts @@ -13,6 +13,8 @@ import { connectClient, routingTarget } from "../../src/client/connect"; import { readServiceApiTokenState } from "../../src/lib/service-secrets"; import { isLinkConnection, readClientConnectionState } from "../../src/client/state"; import { DEFAULT_CATALOG_PATH } from "../../src/codex/paths"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +import { setSyntheticWindowsPrincipalForTests } from "../../src/lib/windows-user-principal"; const linkId = "lnk_0123456789abcdef"; const key = `ocx_data_${"a".repeat(40)}`; @@ -89,12 +91,23 @@ describe("client link connection contracts", () => { const previousCodexHome = process.env.CODEX_HOME; process.env.OPENCODEX_HOME = home; process.env.CODEX_HOME = codexHome; + // These are enrollment/catalog contracts. ACL enforcement has its own native tests; + // real PowerShell/icacls work can consume the five-second semantic test budget. + let aclCalls = 0; + const aclSuccess = () => { aclCalls += 1; return { success: true, exitCode: 0, timedOut: false, stdout: "" }; }; + setSyntheticWindowsPrincipalForTests("*S-1-5-21-1-2-3-1001"); + setIcaclsRunnerForTests(aclSuccess); + setAsyncIcaclsRunnerForTests(async () => aclSuccess()); try { const config = getDefaultConfig(); config.port = 10100; saveConfig(config); await run(home, codexHome); + if (process.platform === "win32") expect(aclCalls).toBeGreaterThan(0); } finally { + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + setSyntheticWindowsPrincipalForTests(null); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; if (previousCodexHome === undefined) delete process.env.CODEX_HOME; diff --git a/tests/clients/client-link-stop.test.ts b/tests/clients/client-link-stop.test.ts new file mode 100644 index 00000000000..0a68e736791 --- /dev/null +++ b/tests/clients/client-link-stop.test.ts @@ -0,0 +1,332 @@ +import { expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fixturePath, repoPath } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +async function runStop(options: Record) { + const root = mkdtempSync(join(tmpdir(), "ocx-link-stop-")); + const home = join(root, "ocx"); + const child = Bun.spawn([process.execPath, fixturePath("client-link-stop-runner.ts")], { + cwd: repoPath(), stdin: new Blob([JSON.stringify(options)]), stdout: "pipe", stderr: "pipe", + env: { ...process.env, HOME: root, USERPROFILE: root, OPENCODEX_HOME: home, CODEX_HOME: join(root, "codex"), OCX_SERVICE: "", OCX_SERVICE_MANAGED: "0" }, + }); + const timeout = setTimeout(() => child.kill(), 15_000); + try { + if (options.osSignal) { + const deadline = Date.now() + 10_000; + while (!existsSync(join(home, "ready")) && child.exitCode === null && Date.now() < deadline) await Bun.sleep(10); + if (!existsSync(join(home, "ready"))) throw new Error("fixture did not publish its signal-ready listener"); + child.kill("SIGTERM"); + } + const [exit, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + if (options.expectedFailure || options.expectedRefusal) { + expect(exit, stdout).toBe(options.expectedFailure ? 1 : 0); + expect(stderr).toContain("Link Child Codex routing restore refused"); + } else if (String(options.preserve).startsWith("restart-rollback")) { + expect(exit, stdout).toBe(0); + expect(stderr.trim()).toBe("[client] restart: the replacement did not start (not_ready); this runtime keeps serving"); + } else expect({ exit, stderr }, stdout).toEqual({ exit: 0, stderr: "" }); + const report = JSON.parse(readFileSync(join(home, "result.json"), "utf8")); + const atExit = JSON.parse(readFileSync(join(home, "exit.json"), "utf8")); + for (const field of ["after", "profileAfter", "journalRetained", "journalHomeBound", "catalogAfter", "grokAfter", "protectedUnchanged"]) { + expect(atExit[field], `no late restoration after listener release: ${field}`).toEqual(report[field]); + } + return report; + } finally { + clearTimeout(timeout); + if (child.exitCode === null) { child.kill(); await child.exited; } + removeTreeWithRetry(root); + } +} + +for (const signal of process.platform === "win32" ? ["SIGINT", "SIGTERM"] : ["SIGINT", "SIGTERM", "SIGHUP"]) { + test(`native Link ${signal} restores its owned Codex routing before listener release`, async () => { + const report = await runStop({ mode: "runtime", signal }); + expect(report.result).toEqual({ atListenerStop: true }); + expect(report.after).toBe('model = "native"\n'); + expect(report.profileAfter).toBeNull(); + expect(report.journalRetained).toBe(false); + expect(report.protectedUnchanged).toBe(true); + expect(report.catalogAfter.models).toEqual([{ slug: "fixture-native", visibility: "list" }]); + expect(report.grokAfter).toBe('theme = "fixture"\n'); + }, 20_000); +} + +test.skipIf(process.platform === "win32")("a real POSIX SIGTERM restores after catalog sync before listener release", async () => { + const report = await runStop({ mode: "runtime", osSignal: true, syncBeforeStop: true }); + expect(report.syncEvidence).toEqual({ changedFields: ["catalogFingerprint", "catalogSyncedAt"], fullCasRefused: true, catalogWritten: true, injected: true, journalHomeBound: true }); + expect(report.after).toBe('model = "native"\n'); + expect(report.profileAfter).toBeNull(); + expect(report.journalHomeBound).toBe(false); + expect(report.catalogAfter.models).toEqual([{ slug: "fixture-native", visibility: "list" }]); + expect(report.grokAfter).toBe('theme = "fixture"\n'); +}, 20_000); + +test("native Link signal restores after real catalog sync without weakening the full snapshot CAS", async () => { + const report = await runStop({ mode: "runtime", syncBeforeStop: true }); + expect(report.syncEvidence).toEqual({ changedFields: ["catalogFingerprint", "catalogSyncedAt"], fullCasRefused: true, catalogWritten: true, injected: true, journalHomeBound: true }); + expect(report.after).toBe('model = "native"\n'); + expect(report.profileAfter).toBeNull(); + // Reinjection preserves the first hash: field-level restore retains its recovery + // snapshot but releases the home binding, rather than deleting recovery evidence. + expect(report.journalRetained).toBe(true); + expect(report.journalHomeBound).toBe(false); + expect(report.protectedUnchanged).toBe(true); + expect(report.catalogAfter.models).toEqual([{ slug: "fixture-native", visibility: "list" }]); + expect(report.grokAfter).toBe('theme = "fixture"\n'); +}, 20_000); + +test("native hub signal exit preserves the connection-owned route", async () => { + const report = await runStop({ mode: "runtime", transport: "hub" }); + expect(report.after).toBe(report.before); + expect(report.profileAfter).toBe(report.profileBefore); + expect(report.journalRetained).toBe(true); + expect(report.protectedUnchanged).toBe(true); +}, 20_000); + +test("native Link signal refuses a connection swapped after runtime publication", async () => { + const report = await runStop({ mode: "runtime", mutation: "connection", expectedFailure: true }); + expect(report.result).toEqual({ atListenerStop: true }); + expect(report.after).toBe(report.before); + expect(report.profileAfter).toBe(report.profileBefore); + expect(report.journalRetained).toBe(true); + expect(report.protectedUnchanged).toBe(true); +}, 20_000); + +test("owned Link full restore is idempotent and retains connection credentials", async () => { + const report = await runStop({ mode: "helper", repeat: true }); + expect(report.result.first).toMatchObject({ success: true, restored: true }); + expect(report.result.repeated).toMatchObject({ success: true, restored: false }); + expect(report.after).toBe('model = "native"\n'); + expect(report.profileAfter).toBeNull(); + expect(report.journalRetained).toBe(false); + expect(report.protectedUnchanged).toBe(true); + expect(report.catalogAfter.models).toEqual([{ slug: "fixture-native", visibility: "list" }]); + expect(report.grokAfter).toBe('theme = "fixture"\n'); +}); + +for (const mutation of ["connection", "selection", "journal", "process-journal", "unverified", "rotation", "hub"]) { + test(`Link restore refuses ${mutation} evidence without touching its files`, async () => { + const report = await runStop({ mode: "helper", mutation }); + expect(report.result).toMatchObject({ success: false, restored: false }); + expect(report.after).toBe(report.before); + expect(report.profileAfter).toBe(report.profileBefore); + expect(report.journalRetained).toBe(true); + expect(report.protectedUnchanged).toBe(true); + expect(report.catalogAfter).toEqual(JSON.parse(report.catalogBefore)); + expect(report.grokAfter).toBe(report.grokBefore); + }); +} + +for (const mutation of ["config", "profile"]) { + test(`Link full restore preserves user edits in ${mutation} while removing owned routing`, async () => { + const report = await runStop({ mode: "helper", mutation }); + expect(report.result.success).toBe(true); + if (mutation === "config") expect(report.after).toContain('approval_policy = "on-request"'); + else expect(report.profileAfter).toBe(report.profileBefore); + expect(report.after).not.toContain("openai_base_url"); + expect(report.protectedUnchanged).toBe(true); + expect(report.catalogAfter.models).toEqual([{ slug: "fixture-native", visibility: "list" }]); + expect(report.grokAfter).toBe('theme = "fixture"\n'); + }); +} + +for (const mutation of ["oversized", "nonregular"]) { + test(`missing Link journal cannot make an ${mutation} config a successful no-op`, async () => { + const report = await runStop({ mode: "helper", mutation }); + expect(report.result).toMatchObject({ success: false, restored: false }); + expect(report.after).toBe(report.before); + expect(report.profileAfter).toBe(report.profileBefore); + expect(report.protectedUnchanged).toBe(true); + }); +} + +test("Link API teardown preserves the merged parent deferral contract", async () => { + const report = await runStop({ mode: "api" }); + expect(report.result).toMatchObject({ success: true, sharedTeardown: "deferred" }); + expect(report.after).toBe(report.before); + expect(report.profileAfter).toBe(report.profileBefore); + expect(report.journalRetained).toBe(true); + expect(report.protectedUnchanged).toBe(true); +}); + +test("parent Link teardown retains the merged full restoration contract", async () => { + const report = await runStop({ mode: "parent" }); + expect(report.result).toEqual({ historyOnly: false, historyDeferred: false, other: false }); + expect(report.after).toBe('model = "native"\n'); + expect(report.journalRetained).toBe(false); + expect(report.protectedUnchanged).toBe(true); + expect(report.catalogAfter.models).toEqual([{ slug: "fixture-native", visibility: "list" }]); + expect(report.grokAfter).toBe('theme = "fixture"\n'); +}); + +for (const preserve of ["service", "desktop", "receipt", "sibling"]) { + test(`native Link signal preserves ${preserve} restoration ownership`, async () => { + const report = await runStop({ mode: "runtime", preserve }); + expect(report.after).toBe(report.before); + expect(report.profileAfter).toBe(report.profileBefore); + expect(report.journalRetained).toBe(true); + expect(report.protectedUnchanged).toBe(true); + expect(report.catalogAfter).toEqual(JSON.parse(report.catalogBefore)); + expect(report.grokAfter).toBe(report.grokBefore); + }, 20_000); +} + +test("a pending restart admission cannot leave routing behind on signal shutdown", async () => { + const report = await runStop({ mode: "runtime", preserve: "restart" }); + expect(report.after).toBe('model = "native"\n'); + expect(report.journalRetained).toBe(false); + expect(report.catalogAfter.models).toEqual([{ slug: "fixture-native", visibility: "list" }]); + expect(report.grokAfter).toBe('theme = "fixture"\n'); +}, 20_000); + +test.each(["restart-rollback", "restart-rollback-recycle"])("signal shutdown joins %s before restoring", async preserve => { + const report = await runStop({ mode: "runtime", preserve }); + expect(report.after).toBe('model = "native"\n'); + expect(report.journalRetained).toBe(false); + expect(report.catalogAfter.models).toEqual([{ slug: "fixture-native", visibility: "list" }]); + expect(report.grokAfter).toBe('theme = "fixture"\n'); +}, 20_000); + +test("a scheduled recycle cannot skip restoration or spawn after signal shutdown", async () => { + const report = await runStop({ mode: "runtime", preserve: "recycle-pending" }); + expect(report.after).toBe('model = "native"\n'); + expect(report.journalRetained).toBe(false); + expect(report.catalogAfter.models).toEqual([{ slug: "fixture-native", visibility: "list" }]); + expect(report.grokAfter).toBe('theme = "fixture"\n'); +}, 20_000); + +for (const mutation of ["connection", "selection", "rotation", "hub", "prior-catalog", "api-key", "token", "management", "link-id"]) { + test(`served-connection guard rechecks ${mutation} at each write and freezes its snapshot`, async () => { + const report = await runStop({ mode: "guard", mutation }); + expect(report.result).toEqual({ writes: ["before-change"], refused: true, boundary: mutation === "hub" ? "hub-owned" : "unproven" }); + expect(report.after).toBe(report.before); + expect(report.journalRetained).toBe(true); + expect(report.catalogAfter).toEqual(JSON.parse(report.catalogBefore)); + expect(report.grokAfter).toBe(report.grokBefore); + }); +} + +test("served-connection guard permits catalog sync metadata at a later write", async () => { + const report = await runStop({ mode: "guard", mutation: "catalog" }); + expect(report.result).toEqual({ writes: ["before-change", "after-change"], refused: false, boundary: "restore" }); +}); + +for (const retryError of ["busy", "locked", "exhausted", "permission", "unwrapped", "identity"]) { + test(`signal sync fencing bounds and classifies ${retryError} retries`, async () => { + const report = await runStop({ mode: "retry", retryError }); + const succeeds = retryError === "busy" || retryError === "locked"; + expect(report.result).toEqual({ success: succeeds, attempts: succeeds ? 3 : retryError === "exhausted" ? 20 : 1, + waits: Array(succeeds ? 2 : retryError === "exhausted" ? 19 : 0).fill(50) }); + expect(report.after).toBe(report.before); + expect(report.catalogAfter).toEqual(JSON.parse(report.catalogBefore)); + }); +} + +for (const runtimeRecord of ["absent", "invalid", "foreign"]) { + test(`signal refuses restoration when its runtime record is ${runtimeRecord}`, async () => { + const report = await runStop({ mode: "runtime", runtimeRecord, expectedFailure: true }); + expect(report.after).toBe(report.before); + expect(report.profileAfter).toBe(report.profileBefore); + expect(report.catalogAfter).toEqual(JSON.parse(report.catalogBefore)); + expect(report.grokAfter).toBe(report.grokBefore); + expect(report.protectedUnchanged).toBe(true); + }, 20_000); +} + +for (const preserve of ["service", "desktop", "receipt", "sibling"]) { + test(`${preserve} signal preservation does not require a restoration sync fence`, async () => { + const report = await runStop({ mode: "runtime", preserve, runtimeRecord: "absent" }); + expect(report.after).toBe(report.before); + expect(report.catalogAfter).toEqual(JSON.parse(report.catalogBefore)); + expect(report.grokAfter).toBe(report.grokBefore); + }, 20_000); +} + +for (const { releaseFirst, hold } of [{ releaseFirst: false, hold: "catalog" }, { releaseFirst: false, hold: "home" }, { releaseFirst: true, hold: "catalog" }, { releaseFirst: false, hold: "lock" }] as const) { + test(`a separate sync ${releaseFirst ? "finishes before" : "cannot commit after"} signal restoration (${hold})`, async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-link-sync-stop-")); + // Sync records the canonical catalog path (/private/var on macOS) and backup lookup hashes the + // spelling, so the fixture must create its native backup under the same spelling. + const home = join(root, "ocx"), codex = join(realpathSync(root), "codex"); + const children: Array<{ child: ReturnType; drained: Promise }> = []; + const spawn = (file: string, options: unknown) => { + const child = Bun.spawn([process.execPath, fixturePath(file)], { + cwd: repoPath(), stdin: new Blob([JSON.stringify(options)]), stdout: "pipe", stderr: "pipe", + env: { ...process.env, HOME: root, USERPROFILE: root, OPENCODEX_HOME: home, CODEX_HOME: codex, OCX_SERVICE: "", OCX_SERVICE_MANAGED: "0" }, + }); + const drained = Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text()]); + children.push({ child, drained }); + return child; + }; + const waitFor = async (name: string) => { + const end = Date.now() + 15_000; + while (!existsSync(join(home, name)) && Date.now() < end) await Bun.sleep(10); + if (!existsSync(join(home, name))) throw new Error(`fixture did not publish ${name}`); + }; + const release = (name: string) => writeFileSync(join(home, name), "ready"); + const read = (name: string) => JSON.parse(readFileSync(join(home, name), "utf8")); + const durable = () => ({ + config: readFileSync(join(codex, "config.toml"), "utf8"), + catalog: JSON.parse(readFileSync(join(codex, "opencodex-catalog.json"), "utf8")), + profile: existsSync(join(codex, "opencodex.config.toml")), + connection: readFileSync(join(home, "config.json"), "utf8"), + token: readFileSync(join(home, "service-api-token"), "utf8"), + }); + const timer = setTimeout(() => { for (const { child } of children) if (child.exitCode === null) child.kill("SIGKILL"); }, 35_000); + try { + const runtime = spawn("client-link-stop-runner.ts", { mode: "runtime", externalSignal: true, holdShutdown: true }); + await waitFor("ready"); + const held = spawn("client-link-sync-runner.ts", { hold, name: "held" }); + await waitFor("held-requested"); + if (releaseFirst) { + release("catalog-release"); + expect(await held.exited).toBe(0); + expect(read("held-sync-result.json")).toMatchObject({ ok: true, catalogWritten: true, injected: true }); + expect(durable().config).toContain("openai_base_url"); + expect(durable().catalog.models).toEqual([{ slug: "fixture/late-sync" }]); + } + release("signal-now"); + if (hold === "lock") { + // Both immediate attempts see a real competing C transaction. Yielding lets it finish. + await Bun.sleep(150); + release("catalog-release"); + } + await waitFor("shutdown-drained"); + const restored = durable(); + expect(restored.config).toBe('model = "native"\n'); + expect(restored.profile).toBe(false); + expect(restored.catalog.models).toEqual([{ slug: "fixture-native", visibility: "list" }]); + if (releaseFirst) { + // No apply lock is held now: a fresh sync must be refused by shutdown admission. + const fresh = spawn("client-link-sync-runner.ts", { hold: false, name: "fresh" }); + expect(await fresh.exited).toBe(0); + expect(read("fresh-sync-result.json")).toMatchObject({ ok: false, fetched: false }); + expect(durable()).toEqual(restored); + } + release("shutdown-release"); + expect(await runtime.exited).toBe(0); + if (!releaseFirst) { + release("catalog-release"); + expect(await held.exited).toBe(0); + expect(read("held-sync-result.json")).toMatchObject(hold === "lock" ? { ok: true, lockHeld: true } : { ok: false, fetched: true }); + } + // Read after BOTH processes exit; the runtime's own exit snapshot cannot prove this. + expect(durable()).toEqual(restored); + if (releaseFirst) { + // A new explicit offline sync after complete shutdown remains supported. + const offline = spawn("client-link-sync-runner.ts", { hold: false, name: "fresh" }); + expect(await offline.exited).toBe(0); + expect(read("fresh-sync-result.json")).toMatchObject({ ok: true, fetched: true, injected: true }); + } + } finally { + clearTimeout(timer); + // A child parked on a held release ignores another SIGTERM; kill it so a failed assertion surfaces. + for (const { child } of children) { if (child.exitCode === null) child.kill("SIGKILL"); await child.exited; } + await Promise.all(children.map(value => value.drained)); + removeTreeWithRetry(root); + } + }, 40_000); +} diff --git a/tests/clients/client-runtime-restart.test.ts b/tests/clients/client-runtime-restart.test.ts index 4f256fe85fd..06cd7feb460 100644 --- a/tests/clients/client-runtime-restart.test.ts +++ b/tests/clients/client-runtime-restart.test.ts @@ -139,6 +139,13 @@ describe("client listener: bound restart", () => { expect(body.restartCapability).toBeUndefined(); expect((await fetch(new URL(SYSTEM_RESTART_PATH, server.url), { method: "POST" })).status).toBe(404); }); + + test("a signal-stopping runtime refuses bound restart without a success receipt", async () => { + const { server, secret, port } = listen(async () => ({ kind: "stopping" })); + const response = await fetch(new URL(SYSTEM_RESTART_PATH, server.url), { method: "POST", headers: restartHeaders(secret, port) }); + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ success: false, error: { code: "runtime_stopping" } }); + }); }); function fakeIo(overrides: Partial = {}) { @@ -158,6 +165,50 @@ function fakeIo(overrides: Partial = {}) { } describe("client restart controller", () => { + test("signal shutdown refuses new and pending restart admissions without spawning", async () => { + let stopping = false; + let admit!: (value: null) => void; + const { io, calls } = fakeIo({ stopping: () => stopping, supervisor: () => new Promise(resolve => { admit = resolve; }) }); + const controller = createClientRestartController(io); + const pending = controller.request(); + expect(controller.hasHandoff()).toBe(false); + stopping = true; + expect(await controller.request()).toEqual({ kind: "stopping" }); + admit(null); + expect(await pending).toEqual({ kind: "stopping" }); + expect(controller.hasHandoff()).toBe(false); + expect(calls).toEqual([]); + }); + + test("pending admission never owns routing and refusal or failure leaves no handoff", async () => { + let settle!: (value: "service" | null) => void; + const { io } = fakeIo({ supervisor: () => new Promise(resolve => { settle = resolve; }) }); + const controller = createClientRestartController(io); + expect(controller.hasHandoff()).toBe(false); + const pending = controller.request(); + expect(controller.hasHandoff()).toBe(false); + settle("service"); + expect(await pending).toEqual({ kind: "supervised", supervisor: "service" }); + expect(controller.hasHandoff()).toBe(false); + const failed = createClientRestartController(fakeIo({ supervisor: async () => { throw new Error("unavailable"); } }).io); + await expect(failed.request()).rejects.toThrow("unavailable"); + expect(failed.hasHandoff()).toBe(false); + }); + + test("accepted handoff remains owned until its asynchronous exit settles", async () => { + let entered!: () => void; + let release!: () => void; + const atExit = new Promise(resolve => { entered = resolve; }); + const exitGate = new Promise(resolve => { release = resolve; }); + const controller = createClientRestartController(fakeIo({ exitProcess: async () => { entered(); await exitGate; } }).io); + await controller.request(); + await atExit; + expect(controller.hasHandoff()).toBe(true); + release(); + await controller.settled(); + expect(controller.hasHandoff()).toBe(false); + }); + test("a replacement that answers ends this process", async () => { const { io, calls } = fakeIo(); const controller = createClientRestartController(io); diff --git a/tests/fixtures/client-link-stop-runner.ts b/tests/fixtures/client-link-stop-runner.ts new file mode 100644 index 00000000000..316a8c88c8b --- /dev/null +++ b/tests/fixtures/client-link-stop-runner.ts @@ -0,0 +1,228 @@ +/** Isolated disk and native-runtime coverage for a connected Link Child's stop. */ +import { mock } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { getDefaultConfig, saveConfig } from "../../src/config"; +import { CODEX_CONFIG_PATH, CODEX_HOME, CODEX_PROFILE_PATH, DEFAULT_CATALOG_PATH } from "../../src/codex/paths"; +import { JOURNAL_PATH, markJournalInjectedState, writeJournal } from "../../src/codex/journal"; +import { serviceApiTokenFingerprint } from "../../src/lib/service-secrets"; +import { ensureCatalogBackup } from "../../src/codex/catalog/parsing"; +import { takeDesktopSupervisedMarker } from "../../src/lib/system-restart-contract"; +import { claimPendingTeardown } from "../../src/config/pending-teardown"; + +const options = JSON.parse(readFileSync(0, "utf8")) as { + mode: "runtime" | "helper" | "api" | "parent" | "guard" | "retry"; + transport?: "link" | "hub"; + signal?: NodeJS.Signals; + osSignal?: boolean; + syncBeforeStop?: boolean; + externalSignal?: boolean; + holdShutdown?: boolean; + runtimeRecord?: "absent" | "invalid" | "foreign"; + retryError?: "busy" | "locked" | "exhausted" | "permission" | "unwrapped" | "identity"; + repeat?: boolean; + preserve?: "service" | "desktop" | "receipt" | "sibling" | "restart" | "restart-rollback" | "recycle-pending" | "restart-rollback-recycle"; + mutation?: "connection" | "journal" | "process-journal" | "config" | "profile" | "unverified" | "selection" | "oversized" | "nonregular" | "rotation" | "hub" | "catalog" | "prior-catalog" | "api-key" | "token" | "management" | "link-id"; +}; +const home = process.env.OPENCODEX_HOME!; +mkdirSync(CODEX_HOME, { recursive: true }); +mkdirSync(home, { recursive: true }); +const lifecycle = await import("../../src/client/lifecycle-lock"); +const lock = lifecycle.withClientLifecycleSync; +mock.module("../../src/client/lifecycle-lock", () => ({ ...lifecycle, + withClientLifecycleSync: (work: Parameters[0]) => lock(work, { lockPath: join(home, "lifecycle.sqlite") }), +})); +const token = `ocx_data_${"c".repeat(40)}`; +const config = getDefaultConfig(); +const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response(null) }); +config.port = probe.port!; +await probe.stop(true); +config.runtimeRole = "client"; +config.client = { + serverUrl: options.transport === "hub" ? "https://hub.example.test" : "http://127.0.0.1:34567", + managementUrl: options.transport === "hub" ? "https://hub.example.test" : "http://127.0.0.1:34567", + managementTransport: "direct", transport: options.transport ?? "link", + ...(options.transport === "hub" ? {} : { link: { tunnelPort: 34567, linkId: "lnk_0123456789abcdef" } }), + selectedClients: ["codex"], tokenEnv: "OPENCODEX_API_AUTH_TOKEN", apiKeyId: "fixture-client", + tokenFingerprint: serviceApiTokenFingerprint(token), protocolVersion: 1, connectedAt: "2026-10-01T00:00:00.000Z", +}; +saveConfig(config); +const expected = structuredClone(config.client); +const original = 'model = "native"\n'; +const injected = `# Auto-injected by opencodex (undo: ocx restore)\nopenai_base_url = "http://127.0.0.1:${config.port}/v1"\n${original}`; +writeFileSync(CODEX_CONFIG_PATH, original); +writeJournal({ currentStateIsNative: true, owner: { kind: "client", apiKeyId: expected.apiKeyId } }); +writeFileSync(CODEX_CONFIG_PATH, injected); +writeFileSync(CODEX_PROFILE_PATH, 'model = "managed"\n'); +markJournalInjectedState(injected, 'model = "managed"\n', { + injectedOpenaiBaseUrl: `http://127.0.0.1:${config.port}/v1`, injectedRealtimeWsBaseUrl: null, injectedCatalogPath: DEFAULT_CATALOG_PATH, +}); +const nativeCatalog = { models: [{ slug: "fixture-native", visibility: "list" }] }; +writeFileSync(DEFAULT_CATALOG_PATH, JSON.stringify(nativeCatalog)); +ensureCatalogBackup(DEFAULT_CATALOG_PATH, nativeCatalog); +writeFileSync(DEFAULT_CATALOG_PATH, '{"models":[{"slug":"fixture/home-model"}]}'); +writeFileSync(join(home, "service-api-token"), token); +const grokHome = join(home, "grok"); +process.env.GROK_HOME = grokHome; +mkdirSync(grokHome, { recursive: true }); +const grokPath = join(grokHome, "config.toml"); +const grokNative = 'theme = "fixture"\n'; +writeFileSync(grokPath, grokNative + '\n# >>> opencodex managed block — do not edit (removed by `ocx stop`) >>>\n[model_providers.opencodex]\nbase_url = "http://127.0.0.1:10100/v1"\n# <<< opencodex managed block <<<\n'); +const mutate = () => { + if (options.mutation === "catalog") { config.client!.catalogFingerprint = "synced"; config.client!.catalogSyncedAt = "2026-10-31T00:00:00.000Z"; saveConfig(config); } + if (options.mutation === "prior-catalog") { config.client!.priorCatalog = ""; saveConfig(config); } + if (options.mutation === "api-key") { config.client!.apiKeyId = "other-client"; saveConfig(config); } + if (options.mutation === "token") { config.client!.tokenFingerprint = serviceApiTokenFingerprint("different-token"); saveConfig(config); } + if (options.mutation === "management") { config.client!.managementUrl = "https://other.example.test"; saveConfig(config); } + if (options.mutation === "link-id") { config.client!.link!.linkId = "lnk_fedcba9876543210"; saveConfig(config); } + if (options.mutation === "connection") { config.client!.connectedAt = "2026-10-02T00:00:00.000Z"; saveConfig(config); } + if (options.mutation === "selection") { config.client!.selectedClients = ["claude"]; saveConfig(config); } + if (options.mutation === "hub") { + delete config.client!.transport; delete config.client!.link; + config.client!.serverUrl = config.client!.managementUrl = "https://hub.example.test"; + saveConfig(config); + } + if (options.mutation === "rotation") { + config.client!.pendingOperation = { kind: "rotate", rotationId: "fixture-rotation", newKeyIssuedAt: "2026-10-02T00:00:00.000Z", oldKeyBackupPath: join(home, "token.prev") }; + saveConfig(config); + } + if (options.mutation === "journal" || options.mutation === "process-journal" || options.mutation === "unverified") { + const journal = JSON.parse(readFileSync(JOURNAL_PATH, "utf8")); + if (options.mutation === "unverified") delete journal.injectedConfigHash; + else journal.owner = options.mutation === "journal" ? { kind: "client", apiKeyId: "another-client" } : { kind: "process", pid: process.pid }; + writeFileSync(JOURNAL_PATH, JSON.stringify(journal)); + } + if (options.mutation === "config") writeFileSync(CODEX_CONFIG_PATH, injected + 'approval_policy = "on-request"\n'); + if (options.mutation === "profile") writeFileSync(CODEX_PROFILE_PATH, 'model = "operator-edit"\n'); + if (options.mutation === "oversized" || options.mutation === "nonregular") { + unlinkSync(JOURNAL_PATH); + if (options.mutation === "oversized") writeFileSync(CODEX_CONFIG_PATH, " ".repeat(1024 * 1024 + 1)); + else { unlinkSync(CODEX_CONFIG_PATH); mkdirSync(CODEX_CONFIG_PATH); } + } +}; +if (options.mode !== "runtime" && options.mode !== "guard") mutate(); +const protectedPaths = [join(home, "config.json"), join(home, "service-api-token")]; +let protectedBefore = protectedPaths.map(path => readFileSync(path, "utf8")); +const catalogBefore = readFileSync(DEFAULT_CATALOG_PATH, "utf8"); +const grokBefore = readFileSync(grokPath, "utf8"); +const configContents = () => statSync(CODEX_CONFIG_PATH).isFile() ? readFileSync(CODEX_CONFIG_PATH, "utf8") : ""; +const before = configContents(); +const profileBefore = readFileSync(CODEX_PROFILE_PATH, "utf8"); +let syncEvidence: { changedFields: string[]; fullCasRefused: boolean; catalogWritten: boolean; injected: boolean; journalHomeBound: boolean } | undefined; +const snapshot = (result: unknown) => ({ + result, before, profileBefore, syncEvidence, after: configContents(), + profileAfter: existsSync(CODEX_PROFILE_PATH) ? readFileSync(CODEX_PROFILE_PATH, "utf8") : null, + journalRetained: existsSync(JOURNAL_PATH), + journalHomeBound: existsSync(JOURNAL_PATH) && typeof JSON.parse(readFileSync(JOURNAL_PATH, "utf8")).opencodexHome === "string", + protectedUnchanged: protectedPaths.every((path, index) => readFileSync(path, "utf8") === protectedBefore[index]), + catalogBefore, catalogAfter: JSON.parse(readFileSync(DEFAULT_CATALOG_PATH, "utf8")), + grokBefore, grokAfter: readFileSync(grokPath, "utf8"), +}); +const report = (result: unknown) => writeFileSync(join(home, "result.json"), JSON.stringify(snapshot(result))); +process.on("exit", () => writeFileSync(join(home, "exit.json"), JSON.stringify(snapshot(null)))); +if (options.preserve === "service") process.env.OCX_SERVICE = "1"; +if (options.preserve === "desktop") takeDesktopSupervisedMarker({ OCX_DESKTOP_SUPERVISED: "1" }); +if (options.preserve === "receipt") claimPendingTeardown({ hostname: "127.0.0.1", port: config.port }, "exact"); +if (options.preserve === "sibling") (await import("../../src/codex/sibling-start")).markSiblingStart(10100); +if (options.mode === "retry") { + const { ConfigMutationLockError } = await import("../../src/config"); + const { ClientSyncRuntimeError, markClientRuntimeStoppingWithRetry } = await import("../../src/client/sync-runtime"); + let attempts = 0; + const waits: number[] = []; + const success = await markClientRuntimeStoppingWithRetry({ pid: process.pid, port: config.port, attestationSecret: "unused-fixture-identity" }, { + mark: () => { + attempts += 1; + if ((options.retryError === "busy" || options.retryError === "locked") && attempts === 3) return; + if (options.retryError === "identity") throw new ClientSyncRuntimeError("client_runtime_changed"); + const cause = Object.assign(new Error("fixture acquisition failure"), { code: options.retryError === "permission" ? "EACCES" : options.retryError === "locked" ? "SQLITE_LOCKED" : "SQLITE_BUSY" }); + if (options.retryError === "unwrapped") throw cause; + throw new ConfigMutationLockError("fixture acquisition failure", { cause }); + }, + sleep: async ms => { waits.push(ms); }, + }); + report({ success, attempts, waits }); +} else if (options.mode === "guard") { + const { stopRestoreWriteGuard } = await import("../../src/client/restore-ownership"); + const guard = stopRestoreWriteGuard(expected); + const writes: string[] = []; + guard.run(() => writes.push("before-change")); + mutate(); + // A caller mutating its own snapshot cannot move an already-created guard's authority. + Object.assign(expected, config.client); + let refused = false; + try { guard.run(() => writes.push("after-change")); } catch { refused = true; } + protectedBefore = protectedPaths.map(path => readFileSync(path, "utf8")); + report({ writes, refused, boundary: guard.boundary() }); +} else if (options.mode === "runtime") { + if (options.holdShutdown) { + const diagnostics = await import("../../src/link/diagnostics"); + mock.module("../../src/link/diagnostics", () => ({ ...diagnostics, flushLinkDiagnostics: async () => { + writeFileSync(join(home, "shutdown-drained"), "ready"); + while (!existsSync(join(home, "shutdown-release"))) await Bun.sleep(10); + } })); + } + if (options.preserve?.startsWith("restart-rollback")) { + const replacement = await import("../../src/server/restart-replacement"); + mock.module("../../src/server/restart-replacement", () => ({ ...replacement, + spawnReplacementStart: async () => { throw Object.assign(new Error("fixture replacement refused"), { code: "not_ready" }); }, + })); + } + const { scheduleStandaloneRecycle, startClientRuntime } = await import("../../src/client/runtime"); + const { startMachineListener } = await import("../../src/client/machine-listener"); + let requestRestart: (() => Promise) | undefined; + await startClientRuntime({ block: false }, { restartSupervisor: () => options.preserve?.startsWith("restart-rollback") ? Promise.resolve(null) : new Promise(() => {}), startListener: (port, deps) => { + requestRestart = deps?.restart?.request; + const server = startMachineListener(port, deps); + const stop = server.stop.bind(server); + server.stop = ((close: boolean) => { report({ atListenerStop: true }); return stop(close); }) as typeof server.stop; + return server; + } }); + if (options.syncBeforeStop) { + const { syncConnectedClient } = await import("../../src/client/connect"); + const { assertClientConnectionUnchanged } = await import("../../src/client/state"); + const synced = await syncConnectedClient({}, { + fetchImpl: (async () => Response.json({ models: [{ slug: "fixture/synced" }] })) as typeof fetch, + now: () => new Date("2026-10-31T00:00:00.000Z"), + catalogCompatibility: { supportedEfforts: () => new Set(["low", "medium", "high"]) }, + linkHome: { fetchImpl: (async () => Response.json({}, { status: 404 })) as typeof fetch }, + }); + const keys = new Set([...Object.keys(expected), ...Object.keys(synced.connection)]); + const changedFields = [...keys].filter(key => !Bun.deepEquals(Reflect.get(expected, key), Reflect.get(synced.connection, key))).sort(); + let fullCasRefused = false; + try { assertClientConnectionUnchanged(expected); } catch { fullCasRefused = true; } + syncEvidence = { changedFields, fullCasRefused, catalogWritten: synced.catalogWritten, injected: synced.injected, + journalHomeBound: typeof JSON.parse(readFileSync(JOURNAL_PATH, "utf8")).opencodexHome === "string" }; + } + if (options.runtimeRecord) { + const path = join(home, "runtime-port.json"); + if (options.runtimeRecord === "absent") unlinkSync(path); + else if (options.runtimeRecord === "invalid") writeFileSync(path, "{"); + else { const record = JSON.parse(readFileSync(path, "utf8")); record.pid += 100_000; writeFileSync(path, JSON.stringify(record)); } + } + mutate(); + protectedBefore = protectedPaths.map(path => readFileSync(path, "utf8")); + if (options.preserve === "restart") { + if (!requestRestart) throw new Error("fixture missing real restart admission"); + void requestRestart(); + } + if (options.preserve?.startsWith("restart-rollback")) { + if (!requestRestart) throw new Error("fixture missing real restart admission"); + await requestRestart(); + } + if (options.preserve === "recycle-pending" || options.preserve === "restart-rollback-recycle") scheduleStandaloneRecycle(expected.tokenFingerprint); + // Exercises the registered production handler; it is not evidence of native OS signal delivery. + if (options.externalSignal) { + writeFileSync(join(home, "ready"), "ready"); + while (!existsSync(join(home, "signal-now"))) await Bun.sleep(10); + process.emit(options.signal ?? "SIGTERM", options.signal ?? "SIGTERM"); + } else if (options.osSignal) writeFileSync(join(home, "ready"), "ready"); + else process.emit(options.signal ?? "SIGTERM", options.signal ?? "SIGTERM"); +} else { + const result = options.mode === "api" + ? await (await import("../../src/server/stop-teardown")).performStopTeardown(new URL("http://127.0.0.1:10100/api/stop?deferSharedTeardown=1&teardownNonce=fixture"), { ownsReceipt: () => true }) + : options.mode === "parent" ? await (await import("../../src/cli/stop-restore")).restoreSharedClientStateAfterStop(() => {}) + // The direct helper fixture has no runtime, async sync or other writer. + : (await import("../../src/client/stop-routing")).restoreLinkClientRouting(expected, { syncFenced: true }); + const repeated = options.repeat ? (await import("../../src/client/stop-routing")).restoreLinkClientRouting(expected, { syncFenced: true }) : undefined; + report(repeated ? { first: result, repeated } : result); +} diff --git a/tests/fixtures/client-link-sync-runner.ts b/tests/fixtures/client-link-sync-runner.ts new file mode 100644 index 00000000000..b2a94d21830 --- /dev/null +++ b/tests/fixtures/client-link-sync-runner.ts @@ -0,0 +1,49 @@ +/** A separate CLI-like sync process; never initializes or overwrites the running fixture. */ +import { mock } from "bun:test"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const options = JSON.parse(readFileSync(0, "utf8")) as { hold: "catalog" | "home" | "lock" | false; name: "held" | "fresh" }; +const home = process.env.OPENCODEX_HOME!; +if (options.hold === "lock") { + const { Database } = await import("bun:sqlite"); + const database = new Database(join(home, "config-mutation.sqlite")); + try { + database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + writeFileSync(join(home, "held-requested"), "ready"); + while (!existsSync(join(home, "catalog-release"))) await Bun.sleep(10); + database.exec("ROLLBACK"); + } finally { database.close(); } + writeFileSync(join(home, "held-sync-result.json"), JSON.stringify({ ok: true, lockHeld: true })); + process.exit(0); +} +const lifecycle = await import("../../src/client/lifecycle-lock"); +const lock = lifecycle.withClientLifecycleSync; +mock.module("../../src/client/lifecycle-lock", () => ({ ...lifecycle, + withClientLifecycleSync: (work: Parameters[0]) => lock(work, { lockPath: join(home, "lifecycle.sqlite") }), +})); +const { syncConnectedClient } = await import("../../src/client/connect"); +let fetched = false; +let result: unknown; +const pause = async (phase: "catalog" | "home") => { + if (options.hold !== phase) return; + writeFileSync(join(home, `${options.name}-requested`), "ready"); + while (!existsSync(join(home, "catalog-release"))) await Bun.sleep(10); +}; +try { + const synced = await syncConnectedClient({}, { + fetchImpl: (async () => { + fetched = true; + // A deterministic transport seam: test ordering, not a production network deadline. + await pause("catalog"); + return Response.json({ models: [{ slug: "fixture/late-sync" }] }); + }) as typeof fetch, + now: () => new Date("2026-10-31T01:00:00.000Z"), + catalogCompatibility: { supportedEfforts: () => new Set(["low", "medium", "high"]) }, + linkHome: { fetchImpl: (async () => { await pause("home"); return Response.json({}, { status: 404 }); }) as typeof fetch }, + }); + result = { ok: true, fetched, catalogWritten: synced.catalogWritten, injected: synced.injected }; +} catch (error) { + result = { ok: false, fetched, error: error instanceof Error ? error.message : "unknown" }; +} +writeFileSync(join(home, `${options.name}-sync-result.json`), JSON.stringify(result)); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 09f1d0edbb8..79fd72e9af0 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1555,7 +1555,7 @@ "command-code-tool-text-prose-split.test.ts": "providers", "cli-effort-slug.test.ts": "cli", "grok-47-build-fast-metadata.test.ts": "providers/xai", "grok-47-fast-model.test.ts": "providers/xai", "grok-47-fast-model-wire.test.ts": "providers/xai", "client-link-connect.test.ts": "clients", - "client-link-relay.test.ts": "clients", "client-link-runtime.test.ts": "clients", + "client-link-relay.test.ts": "clients", "client-link-runtime.test.ts": "clients", "client-link-stop.test.ts": "clients", "link-routes.test.ts": "clients", "client-link-state.test.ts": "clients", "client-link-status.test.ts": "clients", "client-link-teardown.test.ts": "clients", "client-link-tunnel.test.ts": "clients", "link-ports.test.ts": "clients", diff --git a/tests/update/update-teardown-handover.test.ts b/tests/update/update-teardown-handover.test.ts index 688fc588724..a0216e46ac1 100644 --- a/tests/update/update-teardown-handover.test.ts +++ b/tests/update/update-teardown-handover.test.ts @@ -229,6 +229,22 @@ describe("ocx stop under an update handover", () => { expect(receiptNames()).toEqual([]); }); + test("a manager-stopped Link Child cannot leave a standalone update-handover receipt", () => { + const serverUrl = "http://127.0.0.1:10200"; + writeFileSync(join(home, "config.json"), JSON.stringify({ runtimeRole: "client", providers: {}, defaultProvider: "openai", client: { + serverUrl, managementUrl: serverUrl, managementTransport: "direct", transport: "link", + link: { tunnelPort: 10200, linkId: "lnk_0123456789abcdef" }, selectedClients: ["codex"], + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", apiKeyId: "fixture-client", tokenFingerprint: "a".repeat(64), + protocolVersion: 1, connectedAt: "2026-10-01T00:00:00.000Z", + } })); + const run = runStop(true, { managerCascades: true, deadPidReadsNull: true }); + expect(run.exitCode, run.stderr).toBe(0); + expect(run.calls).toMatchObject({ native: 1, grok: 1, service: 1 }); + expect(run.receiptExists).toBe(false); + expect(receiptNames()).toEqual([]); + expect(JSON.parse(run.stdout)).toMatchObject({ sharedTeardown: "restored", exitCode: 0 }); + }); + test("without a runtime record the stop restores instead of handing over a guessed receipt", () => { const run = runStop(true, { noRuntimeRecord: true, nativeSignal: true }); expect(run.exitCode, run.stderr).toBe(0); @@ -376,6 +392,11 @@ describe("dashboard update job", () => { } }), resolvePnpmActiveLauncherFn: () => "/pnpm/owner/global/v11/node_modules/@bitkyc08/opencodex/bin/ocx.mjs", integrityFn: () => ({ ok: true as const, integrity: "sha512-testfixturevalue000000000" }), + serviceHomeAuthorityFn: () => ({ kind: "absent" }), + launcherSupportsUpdateHomeFn: () => true, + // This fixture checks the update child's environment, not a real Windows tray process. + trayHandoffFn: () => ({ installed: false, running: false, stopBeforeReplacement: false, + refreshAfterReplacement: false, restoreOnFailure: false, installArgs: ["tray", "install", "--no-start"] }), // Only the environment handed to the update child matters here; failing the command // ends the job before any restart work, which other suites already cover. runCommandFn: (_job, _bin, _args, _timeout, env) => { updateEnv = env; return { status: 1, signal: null }; }, From a1436049c5e4d8ddeaabd9532875ffce25781e6a Mon Sep 17 00:00:00 2001 From: ZeroDi Date: Tue, 3 Nov 2026 18:29:38 +0900 Subject: [PATCH 08/16] perf(devin): batch consumed stream chunk removal (#8049) * perf(devin): batch consumed stream chunk removal * perf(devin): compact stream chunks without a discarded array --------- Co-authored-by: JUN --- scripts/test-layout/layout.json | 2 +- src/adapters/devin/cloud-direct/chat.ts | 21 ++++-- structure/transports/byte-accounting.md | 7 ++ tests/fixtures/test-layout-expected.json | 2 +- tests/providers/devin-stream-queue.test.ts | 76 ++++++++++++++++++++++ 5 files changed, 100 insertions(+), 8 deletions(-) create mode 100644 tests/providers/devin-stream-queue.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index ed25a471a2d..55bae0134d2 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -898,7 +898,7 @@ "devin-cli-authmode-migration.test.ts": "providers", "devin-effort-ladder.test.ts": "providers", "devin-family-resolution.test.ts": "providers", - "devin-hardening.test.ts": "providers", + "devin-hardening.test.ts": "providers", "devin-stream-queue.test.ts": "providers", "devin-image-passthrough.test.ts": "providers", "devin-live-models.test.ts": "providers", "devin-login.test.ts": "providers", diff --git a/src/adapters/devin/cloud-direct/chat.ts b/src/adapters/devin/cloud-direct/chat.ts index 435bfca98b0..05a02c58bfb 100644 --- a/src/adapters/devin/cloud-direct/chat.ts +++ b/src/adapters/devin/cloud-direct/chat.ts @@ -1392,8 +1392,8 @@ export async function* streamChatEvents(req: CloudChatRequest): AsyncGenerator 0 && chunkQueue.length > 0) { - const head = chunkQueue[0]; + let consumed = 0; + while (remaining > 0 && consumed < chunkQueue.length) { + const head = chunkQueue[consumed]; if (head.length <= remaining) { - chunkQueue.shift(); + consumed++; remaining -= head.length; } else { - chunkQueue[0] = head.slice(remaining); + chunkQueue[consumed] = head.slice(remaining); remaining = 0; } } + if (consumed > 0) { + chunkQueue.copyWithin(0, consumed); + chunkQueue.length -= consumed; + } } // Track the idle timer at outer scope so the finally block can clear it diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index a93cd509622..8a18875c1be 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -151,6 +151,13 @@ The turn limit and transport-read accounting remain in force. `tests/providers/c covers exact-limit Unicode records across whole/one-byte reads and newline/EOF termination, one-byte overflow, another retained owner, consumer return and upstream cancellation. +Devin Connect-RPC in `src/adapters/devin/cloud-direct/chat.ts` compacts surviving chunks in place +and truncates the queue, or clears it when no bytes remain. No deleted-chunk +result array is allocated. Partial head slices and later +frames retain their order without one `shift()` per chunk. `tests/providers/devin-stream-queue.test.ts` +bounds prefix-removal calls and discarded-array slots on fragmented frames and covers split/coalesced headers and payloads, +Unicode, empty frames and gzip. Existing frame/inflation caps, EOS handling and teardown remain in force. + Native Ollama NDJSON in `src/adapters/ollama-native.ts` measures new decoded fragments without allocating UTF-8 arrays. A fragment without a newline extends the cached residual byte count; only a fragment containing a newline triggers splitting of the accumulated record. Complete diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 79fd72e9af0..d6cc69371a2 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -831,7 +831,7 @@ "devin-adapter-reset-wait.test.ts": "providers", "devin-adapter.test.ts": "providers", "devin-chat-wire-fixes.test.ts": "providers", "devin-cli-authmode-migration.test.ts": "providers", "devin-effort-ladder.test.ts": "providers", "devin-family-resolution.test.ts": "providers", - "devin-hardening.test.ts": "providers", "devin-image-passthrough.test.ts": "providers", + "devin-hardening.test.ts": "providers", "devin-stream-queue.test.ts": "providers", "devin-image-passthrough.test.ts": "providers", "devin-live-models.test.ts": "providers", "devin-login.test.ts": "providers", "devin-quota.test.ts": "providers", "devin-output-budget.test.ts": "providers", "devin-prompt-cache.test.ts": "providers", "devin-trajectory.test.ts": "providers", diff --git a/tests/providers/devin-stream-queue.test.ts b/tests/providers/devin-stream-queue.test.ts new file mode 100644 index 00000000000..f318b3a1801 --- /dev/null +++ b/tests/providers/devin-stream-queue.test.ts @@ -0,0 +1,76 @@ +import { expect, spyOn, test } from "bun:test"; +import { streamChatEvents, type CloudChatEvent } from "../../src/adapters/devin/cloud-direct/chat"; +import { encodeString, frameConnectStream } from "../../src/adapters/devin/cloud-direct/wire"; + +function trailer() { + return Buffer.from([2, 0, 0, 0, 2, 123, 125]); +} + +async function collect(wire: Buffer, chunkBytes: number) { + let offset = 0; + const body = new ReadableStream({ + pull(controller) { + if (offset === wire.length) { controller.close(); return; } + const end = Math.min(wire.length, offset + chunkBytes); + controller.enqueue(wire.subarray(offset, end)); + offset = end; + }, + }, { highWaterMark: 0 }); + const before = process.env.OPENCODEX_DEVIN_SEND_USER_JWT; + process.env.OPENCODEX_DEVIN_SEND_USER_JWT = "0"; + try { + const events: CloudChatEvent[] = []; + for await (const event of streamChatEvents({ + apiKey: "devin-session-token$header.payload.signature", + modelUid: "swe-1-7", messages: [], catalog: null, + executor: (async () => new Response(body)) as typeof fetch, + })) events.push(event); + return events; + } finally { + if (before === undefined) delete process.env.OPENCODEX_DEVIN_SEND_USER_JWT; + else process.env.OPENCODEX_DEVIN_SEND_USER_JWT = before; + } +} + +test("Devin fragmented frame drains use bounded queue-prefix removals", async () => { + const text = "x".repeat(64 * 1024); + const wire = Buffer.concat([frameConnectStream(encodeString(3, text), false), trailer()]); + const shift = Array.prototype.shift; + const splice = Array.prototype.splice; + const copyWithin = Array.prototype.copyWithin; + let removals = 0; + let discardedChunkSlots = 0; + const shifting = spyOn(Array.prototype, "shift").mockImplementation(function () { + if (Buffer.isBuffer(this[0])) removals++; + return Reflect.apply(shift, this, []); + }); + const splicing = spyOn(Array.prototype, "splice").mockImplementation(function (...args: Parameters) { + const prefix = args[0] === 0 && args[1] && Buffer.isBuffer(this[0]); + const removed = Reflect.apply(splice, this, args); + if (prefix) { removals++; discardedChunkSlots += removed.length; } + return removed; + }); + const copying = spyOn(Array.prototype, "copyWithin").mockImplementation(function (...args: Parameters) { + if (args[0] === 0 && args[1] > 0 && Buffer.isBuffer(this[0])) removals++; + return Reflect.apply(copyWithin, this, args); + }); + let events: CloudChatEvent[]; + try { events = await collect(wire, 4); } + finally { shifting.mockRestore(); splicing.mockRestore(); copying.mockRestore(); } + expect(events.filter(event => event.kind === "text").map(event => event.text)).toEqual([text]); + expect(removals).toBeLessThanOrEqual(8); + expect(discardedChunkSlots).toBe(0); +}); + +for (const chunkBytes of [1, 3, 5, 7, 17, 65536]) { + test(`Devin queue retains frame order and partial heads across ${chunkBytes}-byte reads`, async () => { + const wire = Buffer.concat([ + frameConnectStream(encodeString(3, "한글 😀"), false), + frameConnectStream(Buffer.alloc(0), false), + frameConnectStream(encodeString(3, "compressed 끝"), true), + trailer(), + ]); + const events = await collect(wire, chunkBytes); + expect(events.filter(event => event.kind === "text").map(event => event.text)).toEqual(["한글 😀", "compressed 끝"]); + }); +} From 8c822dc5da3ff8a5a8980de40d1e88c4095a360f Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Tue, 3 Nov 2026 09:37:47 +0000 Subject: [PATCH 09/16] fix(codex): hold selection ownership through dashboard feature toggles (#8009) * fix(codex): hold selection ownership through dashboard feature toggles * test(codex): cover feature invocation without injected toggle --------- Co-authored-by: JUN --- .../docs/fr/reference/configuration/agents.md | 2 + .../docs/reference/configuration/agents.md | 2 + .../docs/tr/reference/configuration/agents.md | 2 + .../management/agent-settings-routes.ts | 45 +++++---- structure/codex-cli-update.md | 2 +- structure/gui-and-management-api.md | 2 +- .../codex-cli-update-parent-async.test.ts | 96 +++++++++++++++++++ 7 files changed, 128 insertions(+), 23 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/configuration/agents.md b/docs-site/src/content/docs/fr/reference/configuration/agents.md index e73e248598f..6053240fb55 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/agents.md +++ b/docs-site/src/content/docs/fr/reference/configuration/agents.md @@ -34,6 +34,8 @@ L’API de gestion expose `GET`/`PUT /api/v2`, `/api/injection-model`, `/api/eff La page Codex Auth permet également de modifier l’indicateur natif `default_mode_request_user_input` de Codex (`GET`/`PUT /api/codex-auth/features/default-mode-request-user-input`). Son activation ajoute `[features] default_mode_request_user_input = true` au fichier `$CODEX_HOME/config.toml` de Codex au moyen de la commande officielle `codex features enable|disable`. Cette modification préserve le format du fichier et est retirée lorsque l’option est désactivée. Codex peut ainsi suspendre une session en mode Default et vous poser des questions avec l’outil `request_user_input`. Cet indicateur est encore en développement en amont et ne s’applique qu’aux nouvelles sessions ; l’activation échoue explicitement si la version installée de Codex ne le reconnaît pas encore. +Pendant qu’une mise à jour de Codex CLI détient l’installation, cette commande renvoie HTTP 503 avec `codex_cli_selection_busy` et `retryable: true` avant toute exécution. Réessayez après la mise à jour ; les erreurs d’écriture de la configuration restent des réponses 502. + ## Liste des modèles et consignes La liste v2 effective comprend, parmi les modèles configurés, les cinq premiers qui sont visibles dans le sélecteur, classés par priorité, compatibles avec v2 et présents dans le catalogue injecté. Un modèle est admissible en v2 si son choix imposé en amont vaut explicitement `"v2"`, vaut `null` ou est absent. Un véritable choix `"v1"` l’exclut. Les entrées exclues restent dans la configuration afin de pouvoir redevenir admissibles ultérieurement. diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index ea036536740..3ba3ee04605 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -115,6 +115,8 @@ Default-mode session and ask you questions with the `request_user_input` tool. T flag is under development upstream and only applies to new sessions; the toggle fails loudly when the installed Codex build does not know the flag yet. +While a Codex CLI update owns the installation, the toggle returns retryable HTTP 503 (`codex_cli_selection_busy`) before running the feature command. Retry after the update finishes; config-write failures still return 502. + ## Roster and guidance The effective v2 roster is the configured, picker-visible, priority-sorted first five models that diff --git a/docs-site/src/content/docs/tr/reference/configuration/agents.md b/docs-site/src/content/docs/tr/reference/configuration/agents.md index b51ba27d89f..e57200c9cd1 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/agents.md +++ b/docs-site/src/content/docs/tr/reference/configuration/agents.md @@ -64,6 +64,8 @@ kaldırılır), bu da Codex'in bir Varsayılan mod oturumunu duraklatmasına ve akışta geliştirilme aşamasındadır ve yalnızca yeni oturumlara uygulanır; yüklü Codex derlemesi bayrağı henüz tanımadığında anahtar yüksek sesle başarısız olur. +Codex CLI güncellemesi kurulumu kullanırken özellik komutu çalıştırılmadan önce `codex_cli_selection_busy` ve `retryable: true` ile HTTP 503 döner. Güncelleme bitince yeniden deneyin; yapılandırma yazma hataları 502 döndürmeye devam eder. + ## Kadro ve rehberlik Geçerli v2 kadrosu, yapılandırılmış, seçicide görünen, önceliğe göre sıralanmış, diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 85a80da579b..a3f27971255 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -562,26 +562,28 @@ async function handleAgentSettingsRoutesImpl(ctx: ManagementContext): Promise runCodexFeaturesCommand(action, DEFAULT_MODE_REQUEST_USER_INPUT_FEATURE_KEY, env, validate, invocation); - } catch (error) { - return jsonResponse({ error: `default_mode_request_user_input toggle failed: ${error instanceof Error ? error.message : String(error)}` }, 502); + const selectionWrite = await beginCodexCliSelectionParentWriteAsync({ signal: req.signal }); + if (!selectionWrite.entered) return jsonResponse({ error: "codex_cli_selection_busy", retryable: true }, 503); + try { + const before = isDefaultModeRequestUserInputEnabled(); + let toggle = deps.toggleDefaultModeRequestUserInput; + if (!toggle) { + try { + const { codexFeaturesInvocation, runCodexFeaturesCommand } = await import("../../cli/v2"); + const action = body.enabled ? "enable" : "disable"; + const invocation = selectionWrite.commit(() => codexFeaturesInvocation(action, DEFAULT_MODE_REQUEST_USER_INPUT_FEATURE_KEY, process.platform, { requireAvailable: true })); + toggle = (_enabled, env, validate) => runCodexFeaturesCommand(action, DEFAULT_MODE_REQUEST_USER_INPUT_FEATURE_KEY, env, validate, invocation); + } catch (error) { + if (error instanceof CodexCliSelectionMutationBusyError) throw error; + return jsonResponse({ error: `default_mode_request_user_input toggle failed: ${error instanceof Error ? error.message : String(error)}` }, 502); + } } - } - // The `codex features` subprocess rewrites config.toml itself — run it - // under the shared write lock so it cannot interleave with an opencodex - // scalar edit or injection mid-write on either side. - const { acquireConfigWriteLock, releaseConfigWriteLock, configWriteLockFailureMessage, runConfigWriteChild, ConfigWriteDestinationChanged } = await import("../../codex/config-write-lock"); - const configPath = activeCodexConfigPath(); - // Preserve native home creation only after executable resolution succeeds. - prepareCodexHome(dirname(configPath)); - { + // Keep selection ownership across the config-lock await and the whole + // synchronous child execution: an updater must not replace this command. + const { acquireConfigWriteLock, releaseConfigWriteLock, configWriteLockFailureMessage, runConfigWriteChild, ConfigWriteDestinationChanged } = await import("../../codex/config-write-lock"); + const configPath = activeCodexConfigPath(); + // Preserve native home creation only after executable resolution succeeds. + selectionWrite.commit(() => prepareCodexHome(dirname(configPath))); const configLock = await acquireConfigWriteLock(configPath); if (!configLock.ok) { return jsonResponse({ error: configWriteLockFailureMessage(configLock), retryable: configLock.error === "locked" }, 502); @@ -589,8 +591,9 @@ async function handleAgentSettingsRoutesImpl(ctx: ManagementContext): Promise toggle!(body.enabled as boolean, env, validate)); + selectionWrite.commit(() => runConfigWriteChild(configPath, configLock.handle, (env, validate) => toggle!(body.enabled as boolean, env, validate))); } catch (error) { + if (error instanceof CodexCliSelectionMutationBusyError) throw error; destinationChanged = error instanceof ConfigWriteDestinationChanged; const err = error as { stderr?: unknown; message?: string }; const raw = err.stderr; @@ -613,7 +616,7 @@ async function handleAgentSettingsRoutesImpl(ctx: ManagementContext): Promise` projects only that process-local Tauri session; missing or expired state is unknown and never falls back to the package cache. `POST /api/update/desktop-snapshot` is a 1 KiB bounded display-state mutation with no install permission. It accepts the existing admin-token principal or a dedicated single-use, ten-second snapshot capability bound to nonce, method, path, PID, port and the SHA-256 digest of the exact body bytes; that capability cannot authorize another route. | diff --git a/tests/codex-integration/codex-cli-update-parent-async.test.ts b/tests/codex-integration/codex-cli-update-parent-async.test.ts index ed0f23a69b0..2fa9cb41dce 100644 --- a/tests/codex-integration/codex-cli-update-parent-async.test.ts +++ b/tests/codex-integration/codex-cli-update-parent-async.test.ts @@ -13,6 +13,9 @@ import { createCodexCliRecoveryTestHome } from "../helpers/codex-cli-recovery-ho import { trustWindowsTempFixtureAncestry } from "../helpers/windows-temp-ancestry"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; +import * as configWriteLock from "../../src/codex/config-write-lock"; +import { handleAgentSettingsRoutes } from "../../src/server/management/agent-settings-routes"; +import type { ManagementContext } from "../../src/server/management/context"; let home: string; let saved: Record; @@ -244,6 +247,99 @@ for (const outcome of ["allow", "deny", "plan-changed", "cancel"] as const) test } finally { proof.resolve(true); await pending; } }); +function featureToggleFixture(signal?: AbortSignal) { + process.env.CODEX_HOME = join(home, "codex"); + mkdirSync(process.env.CODEX_HOME); + const path = join(process.env.CODEX_HOME, "config.toml"); + const original = "[features]\ndefault_mode_request_user_input = false\n"; + writeFileSync(path, original); + const url = new URL("http://localhost/api/codex-auth/features/default-mode-request-user-input"); + const req = new Request(url, { method: "PUT", signal, + headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled: true }) }); + return { path, original, req, url }; +} +function callFeatureToggle(f: ReturnType, toggle?: NonNullable) { + return handleAgentSettingsRoutes({ req: f.req, url: f.url, config: { providers: [] } as unknown as OcxConfig, + deps: { toggleDefaultModeRequestUserInput: toggle }, version: "test", trustedLoopbackIngress: true, + guiSessionIssuance: null, convergeCodexCatalog: async () => { throw new Error("unexpected catalog convergence"); }, + syncClaudeAgentDefsBestEffort: async () => {} }); +} + +test("single feature toggle refuses a held update gate before any child runs", async () => { + const f = featureToggleFixture(); let children = 0; + const worker = gate.createWorkerCommitWindow({ gate: gate.codexCliUpdateSelectionGate(), fence: gate.createCodexCliUpdateFence() }); + expect(worker.tryEnter()).toBe(true); + try { + const response = await callFeatureToggle(f, () => { children++; writeFileSync(f.path, f.original.replace("false", "true")); }); + expect(response?.status).toBe(503); + expect(await response!.json()).toMatchObject({ error: "codex_cli_selection_busy", retryable: true }); + expect(children).toBe(0); + expect(readFileSync(f.path, "utf8")).toBe(f.original); + expect(gate.codexCliSelectionGateState()).toBe("worker_commit"); + } finally { worker.exit(); } +}); + +test("no-toggle feature request resolves its runtime inside parent admission commit", async () => { + const f = featureToggleFixture(); + spies.push(spyOn(recovery, "codexCliUpdateRecoveryBlocksAdmissionAsync").mockResolvedValue(false)); + spies.push(spyOn(recovery, "codexCliUpdateRecoveryBlocksAdmission").mockReturnValue(false)); + const lock = spyOn(configWriteLock, "acquireConfigWriteLock"); spies.push(lock); + const worker = gate.createWorkerCommitWindow({ gate: gate.codexCliUpdateSelectionGate(), fence: gate.createCodexCliUpdateFence() }); + let resolutions = 0, state = "", workerEntered = false, nestedEntered = false; + spies.push(spyOn(runtime, "resolveAndPersistCodexRuntime").mockImplementation(() => { + resolutions++; state = gate.codexCliSelectionGateState(); + workerEntered = worker.tryEnter(); if (workerEntered) worker.exit(); + const nested = gate.beginCodexCliSelectionParentWrite(); + nestedEntered = nested.entered; if (nestedEntered) nested.exit(); + return { runtime: { command: "codex", source: "fallback", version: null }, failures: [] }; + })); + try { + const response = await callFeatureToggle(f); + expect(resolutions).toBe(1); expect(state).toBe("parent_write"); + expect(workerEntered).toBe(false); expect(nestedEntered).toBe(true); + expect(response?.status).toBe(502); + expect(await response!.json()).toMatchObject({ error: "default_mode_request_user_input toggle failed: spawn codex ENOENT" }); + expect(lock).not.toHaveBeenCalled(); expect(readFileSync(f.path, "utf8")).toBe(f.original); + expect(free()).toBe(true); + } finally { worker.exit(); } +}); + +for (const outcome of ["success", "child-failure", "cancel", "lock-failure"] as const) { + test(`single feature toggle holds selection across config-lock wait and releases after ${outcome}`, async () => { + const controller = new AbortController(); const f = featureToggleFixture(controller.signal); + spies.push(spyOn(recovery, "codexCliUpdateRecoveryBlocksAdmissionAsync").mockResolvedValue(false)); + const reached = deferred(), release = deferred(); + const acquire = configWriteLock.acquireConfigWriteLock; + spies.push(spyOn(configWriteLock, "acquireConfigWriteLock").mockImplementation(async (...args) => { + reached.resolve(); await release.promise; + return outcome === "lock-failure" ? { ok: false as const, error: "locked" as const } : acquire(...args); + })); + let children = 0, childGate = ""; let workerEntered = false; + const worker = gate.createWorkerCommitWindow({ gate: gate.codexCliUpdateSelectionGate(), fence: gate.createCodexCliUpdateFence() }); + const pending = callFeatureToggle(f, () => { + children++; childGate = gate.codexCliSelectionGateState(); + workerEntered = worker.tryEnter(); if (workerEntered) worker.exit(); + if (outcome === "child-failure") throw new Error("fixture child failure"); + writeFileSync(f.path, f.original.replace("false", "true")); + }); + try { + await reached.promise; + expect(gate.codexCliSelectionGateState()).toBe("parent_write"); + const entered = worker.tryEnter(); if (entered) worker.exit(); + expect(entered).toBe(false); + if (outcome === "cancel") controller.abort(); + release.resolve(); + const response = await pending; + expect(response?.status).toBe(outcome === "success" ? 200 : outcome === "cancel" ? 503 : 502); + expect(children).toBe(outcome === "cancel" || outcome === "lock-failure" ? 0 : 1); + if (children) { expect(childGate).toBe("parent_write"); expect(workerEntered).toBe(false); } + expect(readFileSync(f.path, "utf8")).toBe(outcome === "success" ? f.original.replace("false", "true") : f.original); + expect(existsSync(f.path + ".ocx-write.lock")).toBe(false); + expect(free()).toBe(true); + } finally { release.resolve(); await pending; worker.exit(); } + }); +} + const native = process.platform === "win32" && process.arch === "x64" ? test : test.skip; async function nativeTerminalStore() { await hardenSecretDirAsync(home, { required: true }); From fde2e4ceb3b86833937cc9ee8f56dff3c2c34f44 Mon Sep 17 00:00:00 2001 From: fec123 Date: Tue, 3 Nov 2026 17:38:17 +0800 Subject: [PATCH 10/16] fix(responses): keep untyped assistant messages inside their tool-call turn (#8064) ZCode sends assistant text as the Responses message shorthand ({role, content} with no type). isAssistantTurnItem only recognized a typed message, so the shorthand closed the assistant turn and backfillBareToolCallTurnReasoning inserted its placeholder after the text instead of at the head of the turn. DeepSeek's thinking mode rejects that ordering with "The reasoning_text in the thinking mode must be passed back to the API", and every continuation of the session fails the same way. Normalize the item type the way the Responses parser already does, so a shorthand with role=assistant belongs to the surrounding assistant turn. The placeholder then precedes the whole turn, matching the typed-message shape that already passes. Co-authored-by: fec333 <7840187+fec123@users.noreply.github.com> Co-authored-by: JUN --- src/adapters/openai-responses/reasoning.ts | 13 +++++++++---- .../deepseek-bare-tool-call-replay.test.ts | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/adapters/openai-responses/reasoning.ts b/src/adapters/openai-responses/reasoning.ts index 4ba2f26503c..c0b76d05a53 100644 --- a/src/adapters/openai-responses/reasoning.ts +++ b/src/adapters/openai-responses/reasoning.ts @@ -174,10 +174,15 @@ export function sanitizeReasoningInputContent( /** Assistant-authored history items: what DeepSeek merges into one assistant message. */ function isAssistantTurnItem(item: unknown): boolean { - if (!isPlainObject(item) || typeof item.type !== "string") return false; - if (item.type === "reasoning") return true; - if (item.type === "message") return item.role === "assistant"; - return item.type.endsWith("_call"); + if (!isPlainObject(item)) return false; + // Responses input accepts the message shorthand without `type`. ZCode sends its assistant + // text that way, directly ahead of the same turn's function calls; reading only a typed + // `message` would split the turn and place the placeholder backfill after the text, which + // DeepSeek refuses with the same reasoning_text 400. + const type = typeof item.type === "string" ? item.type : "role" in item ? "message" : undefined; + if (type === "reasoning") return true; + if (type === "message") return item.role === "assistant"; + return type?.endsWith("_call") === true; } function isClientToolCallItem(item: unknown): boolean { diff --git a/tests/providers/deepseek-bare-tool-call-replay.test.ts b/tests/providers/deepseek-bare-tool-call-replay.test.ts index ffcc958efa9..17c16e83941 100644 --- a/tests/providers/deepseek-bare-tool-call-replay.test.ts +++ b/tests/providers/deepseek-bare-tool-call-replay.test.ts @@ -35,6 +35,14 @@ describe("backfillBareToolCallTurnReasoning (#7298)", () => { expect(backfillBareToolCallTurnReasoning(input)).toBe(input); }); + test("a shorthand assistant message without `type` stays inside its tool-call turn", () => { + const shorthand: Item = { role: "assistant", content: [{ type: "output_text", text: "checking" }] }; + const input = [user("go"), shorthand, call("c1"), output("c1")]; + expect(backfillBareToolCallTurnReasoning(input)).toEqual([ + user("go"), PLACEHOLDER, shorthand, call("c1"), output("c1"), + ]); + }); + test("custom tool calls count as tool calls", () => { const custom: Item = { type: "custom_tool_call", call_id: "p1", name: "apply_patch", input: "*** Begin" }; const customOutput: Item = { type: "custom_tool_call_output", call_id: "p1", output: "ok" }; @@ -69,5 +77,15 @@ describe("DeepSeek Responses wire receives a replayable reasoning item for a for expect(wire.map(item => item.type)).toEqual(["message", "reasoning", "function_call", "function_call_output"]); expect(wire[1]!.content).toEqual([{ type: "reasoning_text", text: " " }]); }); + + test("ZCode's untyped assistant text keeps the placeholder ahead of the text", () => { + const shorthand: Item = { role: "assistant", content: [{ type: "output_text", text: "let me check" }] }; + const wire = build([user("weather?"), shorthand, call("c1"), output("c1")], "deepseek"); + expect(wire.map(item => item.type)).toEqual([ + "message", "reasoning", undefined, "function_call", "function_call_output", + ]); + expect(wire[2]).toMatchObject({ role: "assistant" }); + expect(wire[1]!.content).toEqual([{ type: "reasoning_text", text: " " }]); + }); }); From 3dc4022cb53cbae55fd5dfc3394f08c5df644a37 Mon Sep 17 00:00:00 2001 From: Nick Davis Date: Tue, 3 Nov 2026 09:54:08 +0000 Subject: [PATCH 11/16] fix(responses): unwrap one extra executable freeform body wrapper (#8048) * fix(responses): unwrap one extra executable freeform body wrapper * fix(responses): stream object-leading executable bodies once they cannot be a nested wrapper * perf(responses): resume progressive freeform decoding across deltas Each delta decoded the whole accumulated canonical value and walked the whole decoded body again for a nested executable wrapper, so one-character deltas did quadratic work. A per-call ProgressiveFreeformInputReader now resumes where the previous delta stopped, and the nested-wrapper check is an incremental state machine that also releases a non-string nested input once no later string duplicate can follow. --------- Co-authored-by: JUN --- .../content/docs/guides/codex-integration.md | 6 + src/bridge/sse.ts | 8 +- src/responses/apply-patch-envelope.ts | 20 +- src/responses/freeform-wrapper-scan.ts | 8 +- src/responses/progressive-freeform-input.ts | 399 ++++++++++++++++-- src/server/responses-custom-tool-repair.ts | 7 +- structure/transports/byte-accounting.md | 11 +- structure/transports/responses.md | 34 +- tests/responses/apply-patch-envelope.test.ts | 49 +++ ...ses-custom-tool-stream-consistency.test.ts | 63 +++ .../responses-freeform-wrapper-keys.test.ts | 132 +++++- 11 files changed, 661 insertions(+), 76 deletions(-) diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 89e1419cf3a..0b75d49d6a4 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -770,6 +770,12 @@ Codex. Native custom calls and converted function calls use the same completion patch previews are held while their executable form is unresolved. JavaScript that merely contains patch text and unrelated native custom payloads stay unchanged. +When a routed model double-wraps an executable freeform body, opencodex removes one extra +JSON object containing only an input string. This repair applies to local exec and apply_patch +bodies, leaves other tool grammars and ambiguous objects unchanged, and does not decode recursively. +A body holds its streamed preview only while it can still turn out to be such a wrapper; +JavaScript that merely starts with an object or block keeps streaming as it arrives. + A routed model can also mistakenly send a shell-argument object such as `{"cmd":"git status --short"}` to code-mode `exec`. For a verified code-mode catalog, opencodex converts an unambiguous shell object into `tools.exec_command(...)` JavaScript diff --git a/src/bridge/sse.ts b/src/bridge/sse.ts index c1390ceebb0..978dd29c3b0 100644 --- a/src/bridge/sse.ts +++ b/src/bridge/sse.ts @@ -24,7 +24,7 @@ import { mayBecomePatchEnvelope, repairFreeformToolInput, } from "../responses/apply-patch-envelope"; -import { progressiveFreeformInput } from "../responses/progressive-freeform-input"; +import { ProgressiveFreeformInputReader } from "../responses/progressive-freeform-input"; import { encodeCompactionSummary, releaseCompactionCiphertextLease } from "../responses/compaction"; import { compileCodeModeHelperInput, resolveCodeModeHelperName } from "../responses/code-mode-helper-compat"; import { mayBecomeCodeModeShellInput } from "../responses/code-mode-shell-input"; @@ -480,7 +480,7 @@ export function bridgeToResponsesSSE( // Full assistant text of a compaction turn (across message boundaries) — becomes the // synthetic compaction item's payload on done. let compaction = emptyChunks(); - let currentToolCall: { itemId: string; outputIndex: number; callId: string; name: string; args: string; argsBytes: number; namespace?: string; freeform?: boolean; toolSearch?: boolean; inputEmitted?: string; codeModeHelperName?: string; providerMetadata?: OcxProviderOpaqueToolCallMetadata } | null = null; + let currentToolCall: { itemId: string; outputIndex: number; callId: string; name: string; args: string; argsBytes: number; namespace?: string; freeform?: boolean; toolSearch?: boolean; inputEmitted?: string; freeformReader?: ProgressiveFreeformInputReader; codeModeHelperName?: string; providerMetadata?: OcxProviderOpaqueToolCallMetadata } | null = null; // Open native web-search cell (between begin and end). Holds the output index allocated on // begin so the matching done reuses it; closed as `failed` if the stream terminates early. let currentWebSearch: { itemId: string; eventId: string; outputIndex: number } | null = null; @@ -1157,7 +1157,9 @@ export function bridgeToResponsesSSE( // is the same disagreement in the other direction. const ownsFreeformGrammar = currentToolCall.namespace === undefined || currentToolCall.namespace === "functions"; - const full = progressiveFreeformInput( + // One reader per call resumes where the previous delta stopped. + currentToolCall.freeformReader ??= new ProgressiveFreeformInputReader(); + const full = currentToolCall.freeformReader.update( currentToolCall.args, ownsFreeformGrammar ? currentToolCall.name : "", ); diff --git a/src/responses/apply-patch-envelope.ts b/src/responses/apply-patch-envelope.ts index 6f893c8d170..4869fcb833a 100644 --- a/src/responses/apply-patch-envelope.ts +++ b/src/responses/apply-patch-envelope.ts @@ -39,6 +39,22 @@ function stripMarkdownCodeFence(text: string, toolName: string): string { return match ? match[1] : text; } +/** Repair one extra canonical wrapper inside an already unwrapped executable body. */ +function repairUnwrappedBody(text: string, toolName: string): string { + if (toolName !== "exec" && toolName !== "apply_patch") return text; + try { + const nested: unknown = JSON.parse(text); + if (nested && typeof nested === "object" && !Array.isArray(nested)) { + const record = nested as Record; + const keys = Object.keys(record); + if (keys.length === 1 && keys[0] === "input" && typeof record.input === "string") text = record.input; + } + } catch { + // Ordinary JavaScript and patches are not JSON wrappers. + } + return stripMarkdownCodeFence(text, toolName); +} + /** Unwrap the `{input:string}` function-call wrapper used for freeform tools. */ export function unwrapFreeformToolInput(argumentsText: unknown, toolName = ""): string { if (typeof argumentsText !== "string") return ""; @@ -48,13 +64,13 @@ export function unwrapFreeformToolInput(argumentsText: unknown, toolName = ""): const record = parsed as Record; if (Object.prototype.hasOwnProperty.call(record, "input")) { return typeof record.input === "string" - ? stripMarkdownCodeFence(record.input, toolName) + ? repairUnwrappedBody(record.input, toolName) : argumentsText; } const fallbackKeys = freeformFallbackKeys(toolName); const candidates = fallbackKeys.filter(key => typeof record[key] === "string"); if (candidates.length === 1) { - return stripMarkdownCodeFence(record[candidates[0]] as string, toolName); + return repairUnwrappedBody(record[candidates[0]] as string, toolName); } } } catch { diff --git a/src/responses/freeform-wrapper-scan.ts b/src/responses/freeform-wrapper-scan.ts index 581e9a5b1d6..27d7aeca471 100644 --- a/src/responses/freeform-wrapper-scan.ts +++ b/src/responses/freeform-wrapper-scan.ts @@ -56,7 +56,9 @@ const NEVER = -2; export type FreeformWrapperScan = | { kind: "hold" } | { kind: "raw" } - | { kind: "input"; valueStart: number }; + // `canonical` is false for a fallback field, which a closed object resolved and trailing text + // can still withdraw; an own `input` is decided by its prefix and stays decided. + | { kind: "input"; valueStart: number; canonical: boolean }; /** Index of the first non-whitespace character at or after `from`, or HOLD past the end. */ function skipWhitespace(text: string, from: number): number { @@ -249,7 +251,7 @@ export function scanFreeformWrapper(text: string, fallbackKeys: readonly string[ // non-string value is decidable too, in the other direction: completion hands back the // argument text unchanged rather than unwrapping a value it cannot use. return bounded[valueAt] === '"' - ? { kind: "input", valueStart: valueAt + 1 } + ? { kind: "input", valueStart: valueAt + 1, canonical: true } : { kind: "raw" }; } @@ -300,7 +302,7 @@ function afterTopLevelClose( if (!wholeText) return { kind: "hold" }; const candidates = fallbackKeys.filter(key => members.get(key)?.stringValue === true); if (candidates.length === 1) { - return { kind: "input", valueStart: members.get(candidates[0]!)!.valueStart }; + return { kind: "input", valueStart: members.get(candidates[0]!)!.valueStart, canonical: false }; } return { kind: "raw" }; } diff --git a/src/responses/progressive-freeform-input.ts b/src/responses/progressive-freeform-input.ts index e400c780941..5c31c422f6d 100644 --- a/src/responses/progressive-freeform-input.ts +++ b/src/responses/progressive-freeform-input.ts @@ -1,5 +1,10 @@ import { freeformFallbackKeys } from "./apply-patch-envelope"; -import { JSON_ESCAPES, scanFreeformWrapper } from "./freeform-wrapper-scan"; +import { + JSON_ESCAPES, + JSON_WHITESPACE, + MAX_FREEFORM_WRAPPER_SCAN_CHARS, + scanFreeformWrapper, +} from "./freeform-wrapper-scan"; /** * Whether a body could still grow into one complete outer Markdown fence. @@ -18,66 +23,291 @@ function mayBecomeFencedBody(text: string, toolName: string): boolean { } const LOW_SURROGATE_ESCAPE = /^\\u[dD][c-fC-F][0-9a-fA-F]{2}$/; +const HEX_DIGIT = /^[0-9a-fA-F]$/; +const HEX_QUAD = /^[0-9a-fA-F]{4}$/; +const JSON_NUMBER_CHAR = /^[-+0-9.eE]$/; +const JSON_NUMBER = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?$/; +const JSON_LITERALS = ["true", "false", "null"] as const; +const CANONICAL_KEY = "input"; +const STRING_END = Symbol("string end"); + +const ownsBodyRepair = (toolName: string): boolean => toolName === "exec" || toolName === "apply_patch"; + +type NestedState = + | "start" | "keyOpen" | "key" | "colon" | "valueOpen" | "string" + | "scalar" | "container" | "afterValue" | "trailing" | "never"; /** - * The decoded prefix of a JSON string body, stopping at the first byte it cannot resolve. + * Whether a decoded executable body can still grow into the one extra `{"input":string}` + * wrapper that completion removes, answered one character at a time so a streamed body is walked + * once rather than once per delta. + * + * Ordinary object-leading JavaScript such as `{ let x` or `({` fails within a few characters and + * streams from there; only a prefix that is still a possible wrapper holds. A false answer never + * turns true again as the body grows, so the held prefix is flushed once and streaming continues. + * + * Every member name must be `input`, because completion unwraps only a single-key object, and + * duplicates are followed because `JSON.parse` keeps the last. A non-string value is skipped to its + * end so a later duplicate can still replace it, and an object that closes on a non-string last + * value is ruled out. Scalars are checked against the JSON grammar; containers are skipped by + * bracket and string tracking alone. That skip is looser than `JSON.parse` but never unsound: it + * finds the true end of every valid container, and any text it misreads is invalid JSON, which + * completion would not unwrap either way. + */ +class NestedWrapperMatcher { + scanned = 0; + private state: NestedState = "start"; + private key = ""; + /** Pending escape inside the current string: "", a backslash, or `\\u` and the hex read so far. */ + private escape = ""; + private scalar = ""; + private readonly closers: string[] = []; + private inContainerString = false; + private containerEscape = false; + private lastValueString = false; + + get possible(): boolean { + return this.state !== "never"; + } + + feed(text: string): boolean { + for (let i = 0; i < text.length && this.state !== "never"; i++) { + this.scanned++; + this.step(text[i]!); + } + return this.possible; + } + + private step(c: string): void { + switch (this.state) { + case "start": + if (!JSON_WHITESPACE.has(c)) this.state = c === "{" ? "keyOpen" : "never"; + return; + case "keyOpen": + if (JSON_WHITESPACE.has(c)) return; + this.state = c === '"' ? "key" : "never"; + this.key = ""; + this.escape = ""; + return; + case "key": { + const part = this.stringStep(c); + if (part === null) this.state = "never"; + else if (part === STRING_END) this.state = this.key === CANONICAL_KEY ? "colon" : "never"; + else if (!CANONICAL_KEY.startsWith(this.key += part)) this.state = "never"; + return; + } + case "colon": + if (!JSON_WHITESPACE.has(c)) this.state = c === ":" ? "valueOpen" : "never"; + return; + case "valueOpen": + if (JSON_WHITESPACE.has(c)) return; + this.lastValueString = c === '"'; + this.escape = ""; + if (c === '"') this.state = "string"; + else this.startOtherValue(c); + return; + case "string": { + const part = this.stringStep(c); + if (part === null) this.state = "never"; + else if (part === STRING_END) this.state = "afterValue"; + return; + } + case "scalar": + this.stepScalar(c); + return; + case "container": + this.stepContainer(c); + return; + case "afterValue": + if (JSON_WHITESPACE.has(c)) return; + if (c === ",") this.state = "keyOpen"; + else this.state = c === "}" && this.lastValueString ? "trailing" : "never"; + return; + case "trailing": + if (!JSON_WHITESPACE.has(c)) this.state = "never"; + return; + case "never": + return; + } + } + + /** + * One character of a JSON string: the text it decodes to ("" while an escape is pending), + * `STRING_END` at the closing quote, or `null` when no continuation can parse. Only key + * text needs the decoded value, so escapes other than `\\u` keep a marker. + */ + private stringStep(c: string): string | typeof STRING_END | null { + if (this.escape === "") { + if (c === '"') return STRING_END; + if (c.charCodeAt(0) <= 0x1f) return null; + if (c !== "\\") return c; + this.escape = "\\"; + return ""; + } + if (this.escape === "\\") { + if (c === "u") { + this.escape = "\\u"; + return ""; + } + if (!JSON_ESCAPES.has(c)) return null; + this.escape = ""; + return "\\" + c; + } + if (!HEX_DIGIT.test(c)) return null; + this.escape += c; + if (this.escape.length < 6) return ""; + const code = parseInt(this.escape.slice(2), 16); + this.escape = ""; + return String.fromCharCode(code); + } + + private startOtherValue(c: string): void { + if (c === "{" || c === "[") { + this.closers.length = 0; + this.closers.push(c === "{" ? "}" : "]"); + this.inContainerString = false; + this.containerEscape = false; + this.state = "container"; + return; + } + if (JSON_NUMBER_CHAR.test(c) || c === "t" || c === "f" || c === "n") { + this.scalar = c; + this.state = "scalar"; + return; + } + this.state = "never"; + } + + private stepScalar(c: string): void { + if (JSON_WHITESPACE.has(c) || c === "," || c === "}") { + const complete = (JSON_LITERALS as readonly string[]).includes(this.scalar) || JSON_NUMBER.test(this.scalar); + if (!complete) { + this.state = "never"; + return; + } + this.state = "afterValue"; + this.step(c); + return; + } + this.scalar += c; + const literal = JSON_LITERALS.find(word => word[0] === this.scalar[0]); + const viable = literal ? literal.startsWith(this.scalar) : JSON_NUMBER_CHAR.test(c); + if (!viable) this.state = "never"; + } + + private stepContainer(c: string): void { + if (this.inContainerString) { + if (this.containerEscape) this.containerEscape = false; + else if (c === "\\") this.containerEscape = true; + else if (c === '"') this.inContainerString = false; + return; + } + if (c === '"') this.inContainerString = true; + else if (c === "{" || c === "[") this.closers.push(c === "{" ? "}" : "]"); + else if (c === "}" || c === "]") { + if (this.closers.pop() !== c) this.state = "never"; + else if (this.closers.length === 0) this.state = "afterValue"; + } + } +} + +type DecodeStop = "open" | "closed" | "stalled" | "invalid"; + +/** + * Decode the JSON string body in `text` from `from`, stopping at the first byte it cannot + * resolve. `next` is where decoding resumes once more text arrives; `scanned` counts the characters + * read, including a split escape that will be read again. * * Every stop is a hold rather than a guess, because `JSON.parse` decides the completed value * and anything invented here would be retracted. Three of them are not obvious: * * An escape JSON does not define makes the whole wrapper unparseable no matter what arrives - * next, so completion falls back to the raw text. Returning `null` for that case stops the - * preview rather than continuing to decode a value the completed item will never carry. - * `\\b` and `\\f` are defined, and were previously decoded to the letters b and f. + * next, so completion falls back to the raw text. `invalid` stops the preview rather than + * continuing to decode a value the completed item will never carry. `\\b` and `\\f` are defined, + * and were previously decoded to the letters b and f. A literal control character is not legal + * inside a JSON string either, and stops the same way. * * A lone high surrogate is not a character. Emitting one alone puts an unpaired code unit in a - * delta the client has to decode by itself, so the pair is emitted together or not at all. + * delta the client has to decode by itself, so the pair is emitted together or not at all; a + * complete escape that is not its low half leaves the preview `stalled` for the rest of the call. */ -function decodeJsonStringPrefix(body: string): string | null { - let out = ""; - for (let i = 0; i < body.length; i++) { - const c = body[i]; - if (c === '"') break; // unescaped closing quote: value complete +function decodeJsonStringChunk( + text: string, + from: number, +): { chunk: string; next: number; stop: DecodeStop; scanned: number } { + let chunk = ""; + let scanned = 0; + let i = from; + let stop: DecodeStop = "open"; + while (i < text.length) { + const c = text[i]!; + scanned++; + if (c === '"') { + stop = "closed"; + break; + } if (c !== "\\") { - // A literal control character is not legal inside a JSON string, so `JSON.parse` will - // reject this wrapper and completion will return the raw text. Emitting the decoded value - // first is the disagreement this decoder exists to prevent, so stop instead. - if (c!.charCodeAt(0) <= 0x1f) return null; - out += c; + if (c.charCodeAt(0) <= 0x1f) { + stop = "invalid"; + break; + } + chunk += c; + i++; continue; } - const n = body[i + 1]; + const n = text[i + 1]; if (n === undefined) break; // escape split across chunks: wait for more + scanned++; if (n === "u") { - const hex = body.slice(i + 2, i + 6); + const hex = text.slice(i + 2, i + 6); + scanned += hex.length; if (hex.length < 4) break; // split across chunks: wait for more - if (!/^[0-9a-fA-F]{4}$/.test(hex)) return null; // never parses + if (!HEX_QUAD.test(hex)) { + stop = "invalid"; + break; + } const code = parseInt(hex, 16); if (code >= 0xd800 && code <= 0xdbff) { - const low = body.slice(i + 6, i + 12); - if (!LOW_SURROGATE_ESCAPE.test(low)) break; - out += String.fromCharCode(code, parseInt(low.slice(2), 16)); - i += 11; + const low = text.slice(i + 6, i + 12); + scanned += low.length; + if (!LOW_SURROGATE_ESCAPE.test(low)) { + if (low.length === 6) stop = "stalled"; + break; + } + chunk += String.fromCharCode(code, parseInt(low.slice(2), 16)); + i += 12; continue; } - out += String.fromCharCode(code); - i += 5; + chunk += String.fromCharCode(code); + i += 6; continue; } const escaped = JSON_ESCAPES.get(n); - if (escaped === undefined) return null; // never parses - out += escaped; - i += 1; + if (escaped === undefined) { + stop = "invalid"; + break; + } + chunk += escaped; + i += 2; } - return out; + return { chunk, next: i, stop, scanned }; } /** - * The value to stream so far, or `null` to HOLD because nothing can be decided yet. + * The value to stream so far for one call's growing arguments, or `null` to HOLD because nothing + * can be decided yet. + * + * A reader belongs to one call and is fed that call's whole accumulated arguments on each delta. + * They only grow, so it resumes where the previous delta stopped: the canonical value is decoded + * and checked for a nested wrapper once overall, which keeps a long body streamed in small deltas + * linear instead of quadratic. A shorter buffer or a different tool name starts over. + * * * `input` is decidable from its prefix: `unwrapFreeformToolInput` returns it whenever the * key is present, whatever else the object carries, so its value can be unescaped - * progressively and never retracted. + * progressively and never retracted, except for executable bodies that may contain one + * extra canonical wrapper. Such a body holds only while its prefix can still become that + * wrapper (`mayBecomeNestedWrapper`); the first byte that rules it out flushes the held text. * * One case escapes that claim and is accepted rather than fixed: a duplicate `input` key. * `JSON.parse` keeps the last one, so `{"input":"a","input":"b"}` streams a and completes with @@ -106,19 +336,98 @@ function decodeJsonStringPrefix(body: string): string | null { * cost of holding is preview on an object body that turns out not to be a wrapper; the cost of * not holding was publishing bytes the completed item removes. */ -export function progressiveFreeformInput(args: string, toolName: string): string | null { - const scan = scanFreeformWrapper(args, freeformFallbackKeys(toolName)); - if (scan.kind === "input") { - const decoded = decodeJsonStringPrefix(args.slice(scan.valueStart)); - if (decoded === null) return null; - return mayBecomeFencedBody(decoded, toolName) ? null : decoded; +export class ProgressiveFreeformInputReader { + private toolName: string | undefined; + private seen = 0; + private mode: "scan" | "raw" | "hold" | "input" = "scan"; + private next = 0; + private decoded = ""; + private stop: DecodeStop = "open"; + private nested: NestedWrapperMatcher | null = null; + private fenceRuledOut = false; + private decodeScanned = 0; + + /** Characters this reader has walked so far; a test seam for the linear-work bound. */ + get scannedChars(): number { + return this.decodeScanned + (this.nested?.scanned ?? 0); + } + + update(args: string, toolName: string): string | null { + if (toolName !== this.toolName || args.length < this.seen) this.reset(toolName); + this.seen = args.length; + if (this.mode === "scan") { + const scan = scanFreeformWrapper(args, freeformFallbackKeys(toolName)); + if (scan.kind === "raw") { + // Prefix-decided: no extension of this text becomes a wrapper again. + this.mode = "raw"; + } else if (scan.kind === "hold") { + // Past the bound the scan only ever sees the same clamped prefix, so it holds for good. + if (args.length > MAX_FREEFORM_WRAPPER_SCAN_CHARS) this.mode = "hold"; + return null; + } else if (scan.canonical) { + this.mode = "input"; + this.next = scan.valueStart; + this.nested = ownsBodyRepair(toolName) ? new NestedWrapperMatcher() : null; + } else { + // A closed fallback wrapper can still be withdrawn by trailing text, so it is decided + // afresh on each delta. Its object fits inside the scan bound, which bounds this too. + return this.closedFallbackValue(args, scan.valueStart, toolName); + } + } + if (this.mode === "hold") return null; + if (this.mode === "raw") return this.mayBecomeFenced(args) ? null : args; + return this.canonicalValue(args); + } + + private reset(toolName: string): void { + this.toolName = toolName; + this.seen = 0; + this.mode = "scan"; + this.next = 0; + this.decoded = ""; + this.stop = "open"; + this.nested = null; + this.fenceRuledOut = false; } - if (scan.kind === "raw") return mayBecomeFencedBody(args, toolName) ? null : args; - - // Undecided: an incomplete object, or a close whose tail is clamped out of view. Both stay - // held — the first is provably incomplete, and re-walking a budget-exhausted buffer on every - // delta is work for a preview that arrives in the same instant as the authoritative - // completion behind it. A closed object the scan could see whole never reaches here: its - // member table already resolved it to `input` or `raw`. - return null; + + private canonicalValue(args: string): string | null { + if (this.stop === "open") { + const step = decodeJsonStringChunk(args, this.next); + this.decodeScanned += step.scanned; + this.next = step.next; + this.stop = step.stop; + this.decoded += step.chunk; + if (this.nested?.possible) this.nested.feed(step.chunk); + } + if (this.stop === "invalid") return null; + // Completion may remove one extra {input:string} body wrapper, so hold while it can. + if (this.nested?.possible) return null; + return this.mayBecomeFenced(this.decoded) ? null : this.decoded; + } + + private closedFallbackValue(args: string, valueStart: number, toolName: string): string | null { + const step = decodeJsonStringChunk(args, valueStart); + this.decodeScanned += step.scanned; + if (step.stop === "invalid") return null; + if (ownsBodyRepair(toolName)) { + const nested = new NestedWrapperMatcher(); + const possible = nested.feed(step.chunk); + this.decodeScanned += nested.scanned; + if (possible) return null; + } + return mayBecomeFencedBody(step.chunk, toolName) ? null : step.chunk; + } + + /** `mayBecomeFencedBody`, remembering a false answer: a ruled-out fence never returns. */ + private mayBecomeFenced(text: string): boolean { + if (this.fenceRuledOut) return false; + if (mayBecomeFencedBody(text, this.toolName ?? "")) return true; + this.fenceRuledOut = true; + return false; + } +} + +/** One-shot form of `ProgressiveFreeformInputReader` for a buffer read once. */ +export function progressiveFreeformInput(args: string, toolName: string): string | null { + return new ProgressiveFreeformInputReader().update(args, toolName); } diff --git a/src/server/responses-custom-tool-repair.ts b/src/server/responses-custom-tool-repair.ts index b2cf37f561a..7b4acc38bc8 100644 --- a/src/server/responses-custom-tool-repair.ts +++ b/src/server/responses-custom-tool-repair.ts @@ -6,7 +6,7 @@ import { } from "../responses/apply-patch-envelope"; import { compileCodeModeHelperInput, resolveCodeModeHelperName } from "../responses/code-mode-helper-compat"; import { mayBecomeCodeModeShellInput } from "../responses/code-mode-shell-input"; -import { progressiveFreeformInput } from "../responses/progressive-freeform-input"; +import { ProgressiveFreeformInputReader } from "../responses/progressive-freeform-input"; import { declaresCodeModeExec } from "../types/tools"; import { customToolItemId, @@ -45,6 +45,8 @@ type OpenCustomCall = { emittedInput: string; canonicalPatchProgress: boolean; retainedBytes: number; + // Resumes across deltas so the growing arguments are decoded once rather than once per delta. + freeformReader?: ProgressiveFreeformInputReader; }; export class RoutedCustomToolProgressError extends Error { @@ -352,7 +354,8 @@ export function createRoutedCustomToolRestoreBlockRewrite( const itemName = itemNames.get(upstreamItemId); const ownsFreeformGrammar = itemName?.namespace === undefined || itemName?.namespace === "functions"; - const fullInput = progressiveFreeformInput( + open.freeformReader ??= new ProgressiveFreeformInputReader(); + const fullInput = open.freeformReader.update( open.argumentsText, ownsFreeformGrammar ? itemName?.name ?? "" : "", ); diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 8a18875c1be..40d7f722caf 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -239,10 +239,15 @@ inspection consumes it without creating an event. The text rewrite relay keeps t outside rewrite/drop callbacks and settles injected nonfinal CR delimiters as CRLF. `src/server/responses-custom-tool-repair.ts` continues to own retained routed argument bytes and -their charge/release lifecycle while it asks the pure progressive decoder in -`src/responses/progressive-freeform-input.ts` which prefix is safe to publish. The decoder neither -charges nor releases translator budget; extracting it from `src/bridge/sse.ts` does not create a +their charge/release lifecycle while it asks a per-call progressive reader from +`src/responses/progressive-freeform-input.ts` which prefix is safe to publish. The reader holds only +parse position and decoded text, and it neither charges nor releases translator budget; extracting +it from `src/bridge/sse.ts` does not create a second retention owner or change terminal, failure, incomplete, or disposal release behavior. +Both callers keep one reader per open call, in their own call state, and feed it the growing +arguments. It resumes where the previous delta stopped, so the canonical value is decoded and +checked for a nested executable wrapper once per call; rescanning both on every delta made +one-character deltas quadratic in the body length. Canonical routed `apply_patch` progress is also tracked by that owner. If authoritative completion does not extend the emitted stable prefix, the owner releases every retained call and pending block before raising the rewrite failure; the relay emits a failed terminal and never exposes a successful diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 38db663f820..fbf26244e4a 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -128,6 +128,7 @@ Function-call wrappers around freeform bodies are restored by `exec` and `apply_patch`, one tool-specific alternate field or one complete outer Markdown fence is recoverable because the wrapper is otherwise unusable; two alternate fields are ambiguous and therefore remain untouched. Foreign freeform grammars never receive that compatibility rewrite. +An unwrapped executable body may carry one extra `{"input":string}` object, removed once, never recursively. #### Schema-bound flat shell repair @@ -146,25 +147,24 @@ A prefix that can still become a complete outer fence stays held so completion n bytes already published in a delta; a body that is not shaped like a wrapper object remains progressive, and JSON escapes emit only complete decoded units. -Which wrapper applies is decided by scanning the prefix as JSON rather than matching it against -a literal opening. `JSON.parse` decides the completed input, and it cares about neither property +Which wrapper applies is decided by scanning the prefix as JSON rather than matching it against a +literal opening. `JSON.parse` decides the completed input, and it cares about neither property order nor how a name is spelled, so a canonical key arriving after other properties or written with an escape is the same wrapper and has to preview as one (#5151). The buffering policy that -follows is: an own `input` with a string value streams progressively, because completion gives -it precedence over everything else in the object whatever its position; an `input` with a -non-string value, a text that is not an object, and an object `JSON.parse` can no longer accept -all publish their own bytes, because that is what completion returns for them; every other -object HOLDS until it closes, because a key that has not arrived yet can still change the -answer. Fallback fields decide at that close rather than from a parse: they only unwrap as the -single string field, so the scan keeps a last-wins table of the members it already walked and -consults it once — one string fallback field releases its value as `input`, anything else -streams the raw text byte-exact through whatever trailing whitespace follows. Classification is -bounded to `MAX_FREEFORM_WRAPPER_SCAN_CHARS`, which keeps the work per delta from growing with -the arguments. Past the bound nothing is previewed at all: the authoritative parse still -unwraps the wrapper at completion, so the bound costs preview and never agreement. Because the -close resolves from the member table instead of `JSON.parse`, fragmented trailing whitespace -and deltas that happen to end on a brace can never reparse a growing provider-controlled -buffer. +follows is: an own string `input` streams progressively (completion prefers it to any other +member) unless its executable body could still be a nested wrapper (a nested non-string `input` holds only while a later string duplicate can follow). A non-string `input`, a text +that is not an object, and an object `JSON.parse` can no longer accept all publish their own +bytes, because that is what completion returns for them; every other object HOLDS until it closes, +because a key that has not arrived yet can still change the answer. Fallback fields decide at that +close rather than from a parse: they only unwrap as the single string field, so the scan keeps a +last-wins table of the members it already walked and consults it once — one string fallback field +releases its value as `input`, anything else streams the raw text byte-exact through whatever +trailing whitespace follows. Classification is bounded to `MAX_FREEFORM_WRAPPER_SCAN_CHARS`, which +keeps the work per delta from growing with the arguments. Past the bound nothing is previewed at +all: the authoritative parse still unwraps the wrapper at completion, so the bound costs preview +and never agreement. Because the close resolves from the member table instead of `JSON.parse`, +fragmented trailing whitespace and deltas that happen to end on a brace can never reparse a +growing provider-controlled buffer. What that policy costs is worth stating plainly, because it is a real narrowing. A body that IS a parseable JSON object but not a wrapper — `{"code":1}` or `{"code":"a","script":"b"}` — now diff --git a/tests/responses/apply-patch-envelope.test.ts b/tests/responses/apply-patch-envelope.test.ts index db738bc7ea3..0e97aef4f20 100644 --- a/tests/responses/apply-patch-envelope.test.ts +++ b/tests/responses/apply-patch-envelope.test.ts @@ -5,6 +5,7 @@ import { normalizeApplyPatchDelimiters, repairFreeformToolInput, stableCanonicalPatchPrefix, + unwrapFreeformToolInput, } from "../../src/responses/apply-patch-envelope"; import { compileCodeModeHelperInput, resolveCodeModeHelperName } from "../../src/responses/code-mode-helper-compat"; @@ -23,6 +24,48 @@ const CANONICAL_PATCH = `*** Begin Patch *** End Patch`; describe("apply_patch envelope repair", () => { + test.each([ + ["exec", 'text("nested wrapper");'], + ["apply_patch", CANONICAL_PATCH], + ])("unwraps one extra canonical string body for %s", (tool, body) => { + const nested = JSON.stringify({ input: body }); + const args = JSON.stringify({ input: nested }); + expect(repairFreeformToolInput(args, tool)).toBe(body); + expect(repairFreeformToolInput(args, tool, "functions")).toBe(body); + expect(repairFreeformToolInput(body, tool)).toBe(body); + expect(repairFreeformToolInput(nested, tool)).toBe(body); + // A third layer is deliberately left in place. + expect(repairFreeformToolInput(JSON.stringify({ input: args }), tool)).toBe(nested); + expect(repairFreeformToolInput(args, tool, "mcp")).toBe(nested); + expect(repairFreeformToolInput(args, "render_diagram")).toBe(nested); + }); + + test("the reported exec body parses only after the extra wrapper is removed", () => { + const body = 'text("nested wrapper");'; + const nested = JSON.stringify({ input: body }); + expect(() => new Function("text", nested)).toThrow(SyntaxError); + const values: string[] = []; + new Function("text", repairFreeformToolInput(JSON.stringify({ input: nested }), "exec"))( + (value: string) => values.push(value), + ); + expect(values).toEqual(["nested wrapper"]); + }); + + test.each(["exec", "apply_patch"])("preserves noncanonical nested %s bodies", tool => { + for (const nested of [ + '{"input":"text(1)","extra":true}', + '{"input":42}', '{"input":null}', '{"input":{}}', + '{"code":"text(1)"}', '["text(1)"]', '{"input":"incomplete', + ]) { + expect(repairFreeformToolInput(JSON.stringify({ input: nested }), tool)).toBe(nested); + } + for (const input of [null, 42, true, {}, []]) { + const args = JSON.stringify({ input }); + expect(repairFreeformToolInput(args, tool)).toBe(args); + } + expect(unwrapFreeformToolInput(undefined, tool)).toBe(""); + }); + test("repairs only the outer lines of a complete top-level apply_patch payload", () => { expect(repairFreeformToolInput(DECORATED_PATCH, "apply_patch")).toBe(CANONICAL_PATCH); expect(normalizeApplyPatchDelimiters(DECORATED_PATCH)).toBe(CANONICAL_PATCH); @@ -176,6 +219,12 @@ describe("raw exec patch envelope recognition", () => { expect(source).not.toContain("*** Begin Patch ***"); }); + test("recognizes a double-wrapped patch submitted as exec", () => { + const args = JSON.stringify({ input: JSON.stringify({ input: DECORATED_PATCH }) }); + expect(resolveCodeModeHelperName(undefined, "exec", args, undefined, CODE_MODE)).toBe("apply_patch"); + expect(compileCodeModeHelperInput(args, "apply_patch", "exec")).toContain(JSON.stringify(CANONICAL_PATCH)); + }); + test("refuses JavaScript that merely mentions an envelope", () => { const cases = [ "const sample = \"tools.apply_patch({ input: patchText })\";", diff --git a/tests/responses/responses-custom-tool-stream-consistency.test.ts b/tests/responses/responses-custom-tool-stream-consistency.test.ts index 199de7af25f..38133d18a49 100644 --- a/tests/responses/responses-custom-tool-stream-consistency.test.ts +++ b/tests/responses/responses-custom-tool-stream-consistency.test.ts @@ -1,5 +1,7 @@ import { describe, expect, spyOn, test } from "bun:test"; +import { bridgeToResponsesSSE } from "../../src/bridge"; import * as patchEnvelope from "../../src/responses/apply-patch-envelope"; +import type { AdapterEvent } from "../../src/types"; import { createRoutedCustomToolRestoreBlockRewrite, RoutedCustomToolProgressError, @@ -109,6 +111,67 @@ function restoreExecStream(argumentsText: string, fragments: readonly string[], } describe("routed Responses custom-tool stream consistency", () => { + test.each(["exec", "apply_patch"])("object-leading %s body streams progressively on both transports", async tool => { + const input = '{ const o = { a: 1, b: "two" };\ntext(o.b); }'; + const args = JSON.stringify({ input }); + const fragments = Array.from(args); + const routed = restoreExecStream(args, fragments, tool); + expect(routed.preview).toBe(input); + expect(routed.previews.filter(preview => preview.length > 0 && preview.length < input.length).length) + .toBeGreaterThan(input.length / 2); + expect([routed.doneInput, routed.itemInput, routed.terminalInput]).toEqual([input, input, input]); + + async function* events(): AsyncGenerator { + yield { type: "tool_call_start", id: "call_object", name: tool }; + for (const argumentsPart of fragments) yield { type: "tool_call_delta", arguments: argumentsPart }; + yield { type: "tool_call_end", id: "call_object" }; + yield { type: "done" }; + } + const wire = await new Response(bridgeToResponsesSSE(events(), "routed/model", undefined, new Set([tool]))).text(); + const frames = wire.split("\n\n") + .filter(block => block.includes("data:") && !block.includes("data: [DONE]")) + .map(dataPayload); + const deltas = frames + .filter(frame => frame.type === "response.custom_tool_call_input.delta") + .map(frame => String(frame.delta)); + expect(deltas.length).toBeGreaterThan(input.length / 2); + expect(deltas.join("")).toBe(input); + expect(frames.find(frame => frame.type === "response.custom_tool_call_input.done")?.input).toBe(input); + }); + + test.each([ + ["exec", 'text("nested");'], + ["apply_patch", "*** Begin Patch\n*** Add File: a.txt\n+x\n*** End Patch"], + ])("double-wrapped %s agrees across direct and routed completion", async (tool, input) => { + const args = JSON.stringify({ input: JSON.stringify({ input }) }); + for (let split = 0; split <= args.length; split++) { + const fragments = [args.slice(0, split), args.slice(split)]; + const routed = restoreExecStream(args, fragments, tool); + expect(routed.preview, "routed split " + split).toBe(""); + expect([routed.doneInput, routed.itemInput, routed.terminalInput]).toEqual([input, input, input]); + + async function* events(): AsyncGenerator { + yield { type: "tool_call_start", id: "call_nested", name: tool }; + for (const argumentsPart of fragments) yield { type: "tool_call_delta", arguments: argumentsPart }; + yield { type: "tool_call_end", id: "call_nested" }; + yield { type: "done" }; + } + const wire = await new Response(bridgeToResponsesSSE( + events(), "routed/model", undefined, new Set([tool]), + )).text(); + const frames = wire.split("\n\n") + .filter(block => block.includes("data:") && !block.includes("data: [DONE]")) + .map(dataPayload); + const deltas = frames.filter(frame => frame.type === "response.custom_tool_call_input.delta"); + // Both transports hold the ambiguous preview; completed input is authoritative. + expect(deltas, "direct split " + split).toEqual([]); + const done = frames.find(frame => frame.type === "response.custom_tool_call_input.done"); + const item = frames.find(frame => frame.type === "response.output_item.done")?.item as { input: string }; + const terminal = frames.find(frame => frame.type === "response.completed")?.response as { output: Array<{ input: string }> }; + expect([done?.input, item.input, terminal.output[0]?.input]).toEqual([input, input, input]); + } + }); + test.each([ ["late-invalid wrapper", ['{"input":"*** Begin Patch\\n*** Add File: a\\n+x\\n*** End Patch', '\\q"}']], ["duplicate input key", ['{"input":"*** Begin Patch\\n*** Add File: a\\n+x\\n*** End Patch",', '"input":"replacement"}']], diff --git a/tests/responses/responses-freeform-wrapper-keys.test.ts b/tests/responses/responses-freeform-wrapper-keys.test.ts index 1214817dae7..bc5550f1ec8 100644 --- a/tests/responses/responses-freeform-wrapper-keys.test.ts +++ b/tests/responses/responses-freeform-wrapper-keys.test.ts @@ -1,7 +1,7 @@ import { describe, expect, spyOn, test } from "bun:test"; import { unwrapFreeformToolInput } from "../../src/responses/apply-patch-envelope"; import { MAX_FREEFORM_WRAPPER_SCAN_CHARS } from "../../src/responses/freeform-wrapper-scan"; -import { progressiveFreeformInput } from "../../src/responses/progressive-freeform-input"; +import { ProgressiveFreeformInputReader, progressiveFreeformInput } from "../../src/responses/progressive-freeform-input"; /** * #5151. The progressive decoder matched the buffer against the literal wrapper opening @@ -47,6 +47,77 @@ const emissions = (body: string, toolName: string) => stream(body, toolName).del const published = (body: string, toolName: string) => emissions(body, toolName).join(""); describe("freeform wrapper keys the literal matcher could not see", () => { + test.each(["exec", "apply_patch"])("holds a possible extra %s wrapper at every prefix", tool => { + const source = tool === "exec" ? 'text("nested");' : "*** Begin Patch\n*** Add File: a.txt\n+x\n*** End Patch"; + for (const body of [ + JSON.stringify({ input: JSON.stringify({ input: source }) }), + JSON.stringify({ code: JSON.stringify({ input: source }) }), + JSON.stringify({ input: ' \n { "\\u0069nput": ' + JSON.stringify(source) + " }" }), + ]) { + // The alternate field is recognized only by exec; apply_patch uses patch. + const args = tool === "apply_patch" ? body.replace('{"code":', '{"patch":') : body; + expect(unwrapFreeformToolInput(args, tool)).toBe(source); + expect(stream(args, tool)).toEqual({ deltas: [], retractions: [] }); + expect(progressiveFreeformInput(args, tool)).toBeNull(); + } + const foreign = JSON.stringify({ input: JSON.stringify({ input: source }) }); + expect(published(foreign, "")).toBe(JSON.stringify({ input: source })); + // A valid JS block can begin like an object too; it streams once it cannot be a wrapper. + const block = "{ let x = 1; return x; }"; + const wrappedBlock = JSON.stringify({ input: block }); + expect(unwrapFreeformToolInput(wrappedBlock, tool)).toBe(block); + expect(published(wrappedBlock, tool)).toBe(block); + }); + + // The extra-wrapper hold covers only prefixes that can still become {"input":}. + test.each(["exec", "apply_patch"])("object-leading %s bodies keep streaming progressively", tool => { + for (const body of [ + "{ let x = 1; text(x); }", + '({ a: 1, b: "two" }).b.length;\ntext("done");', + '{"inputs": 1}; text("not a wrapper");', + '{}; text("empty object first");', + ]) { + const args = JSON.stringify({ input: body }); + const { deltas, retractions } = stream(args, tool); + expect(retractions).toEqual([]); + expect(deltas.length, body).toBeGreaterThan(body.length / 2); + expect(deltas.join("")).toBe(body); + expect(unwrapFreeformToolInput(args, tool)).toBe(body); + } + }); + + test.each(["exec", "apply_patch"])("a real double-wrapped %s body is unwrapped once and never previewed", tool => { + const source = tool === "exec" ? "text(" + JSON.stringify("x".repeat(400)) + ");" : "*** Begin Patch\n*** Add File: a.txt\n+x\n*** End Patch"; + for (const nested of [ + JSON.stringify({ input: source }), + ' \t{ "in\\u0070ut" :\n' + JSON.stringify(source) + " } \n", + '{"input":"first","input":' + JSON.stringify(source) + "}", + ]) { + const args = JSON.stringify({ input: nested }); + expect(stream(args, tool)).toEqual({ deltas: [], retractions: [] }); + expect(unwrapFreeformToolInput(args, tool)).toBe(source); + } + }); + + test.each(["exec", "apply_patch"])("late divergence from the wrapper shape flushes then streams (%s)", tool => { + const long = "y".repeat(600); + for (const [nested, divergeAt] of [ + ['{"input":"' + long + '","extra":true}', 14 + long.length], + ['{"input":"' + long + '"} + text(2);', 14 + long.length], + ['{"input":"' + long + "\\q" + long + '"}', 12 + long.length], + ['{"inputs":"' + long + '"}', 8], + ] as const) { + const args = JSON.stringify({ input: nested }); + const { deltas, retractions } = stream(args, tool); + expect(retractions).toEqual([]); + // Everything held until the divergence arrives in one flush, then streaming resumes. + expect(deltas[0], nested.slice(0, 20)).toBe(nested.slice(0, divergeAt)); + expect(deltas.length).toBeGreaterThan(1); + expect(deltas.join("")).toBe(nested); + expect(unwrapFreeformToolInput(args, tool)).toBe(nested); + } + }); + // `progressive` records whether the value can be previewed before the object closes. It is // the liveness half of the contract and it is asserted, because holding everything until // completion would satisfy the agreement half on its own while quietly ending progressive @@ -214,4 +285,63 @@ describe("freeform wrapper keys the literal matcher could not see", () => { expect(published(lateInvalid, "exec")).toBe("safe"); expect(unwrapFreeformToolInput(lateInvalid, "exec")).toBe(lateInvalid); }); + + test("streaming a long nested wrapper one character at a time scans linearly", () => { + // Each delta used to decode the whole accumulated value and walk the whole decoded body + // again for a possible nested wrapper, so one-character deltas cost quadratic work. The + // reader resumes where the previous delta stopped; count what it scans instead of timing it. + const scanned = (length: number): { chars: number; args: number } => { + const source = Array.from({ length }, (_, i) => + i % 17 === 0 ? "\n" : i % 13 === 0 ? '"' : i % 11 === 0 ? "\\" : i % 7 === 0 ? "\u00e9" : "a").join(""); + const args = JSON.stringify({ input: JSON.stringify({ input: source }) }); + expect(unwrapFreeformToolInput(args, "exec")).toBe(source); + const reader = new ProgressiveFreeformInputReader(); + for (let end = 1; end <= args.length; end++) { + // The wrapper stays hidden at every prefix, including the complete one. + expect(reader.update(args.slice(0, end), "exec")).toBeNull(); + } + return { chars: reader.scannedChars, args: args.length }; + }; + for (const length of [4_000, 16_000]) { + const { chars, args } = scanned(length); + expect({ length, linear: chars <= 3 * args }).toEqual({ length, linear: true }); + } + }); + + test("a non-string nested input holds only while a later duplicate can still unwrap it", () => { + const wrap = (body: string) => JSON.stringify({ input: body }); + // Once text after the object rules a wrapper out, the held prefix flushes and streams on. + for (const body of ['{"input":1}.input', '{"input":[1,"]"]} + 1', '{"input":"a","input":2}']) { + expect({ body, completed: unwrapFreeformToolInput(wrap(body), "exec") }).toEqual({ body, completed: body }); + const { deltas, retractions } = stream(wrap(body), "exec"); + expect({ body, streamed: deltas.join(""), retractions }).toEqual({ body, streamed: body, retractions: [] }); + } + expect(emissions(wrap('{"input":1}.input + more()'), "exec").length).toBeGreaterThan(1); + // A later duplicate replaces the non-string value, so this one stays hidden throughout. + const replaced = wrap('{"input":1,"input":{"a":"}"},"input":"x"}'); + expect(unwrapFreeformToolInput(replaced, "exec")).toBe("x"); + expect(stream(replaced, "exec")).toEqual({ deltas: [], retractions: [] }); + }); + + test("a reader resumed across deltas answers like a fresh read of each prefix", () => { + const bodies = [ + JSON.stringify({ input: JSON.stringify({ input: 'text("\\u00e9\\n")' }) }), + JSON.stringify({ input: '{"input":1}.input' }), + JSON.stringify({ input: "{ let x = 1; }\uD83D\uDE00" }).replace("\u{1F600}", "\\ud83d\\ude00"), + '{"input":"a\\ud83dX"}', + '{"metadata":1,"input":"cmd\\q"}', + '{"code":"cmd"} ', + "plain body", + ]; + for (const tool of ["exec", "apply_patch", ""]) { + for (const body of bodies) { + const reader = new ProgressiveFreeformInputReader(); + for (let end = 1; end <= body.length; end++) { + const prefix = body.slice(0, end); + expect({ prefix, tool, resumed: reader.update(prefix, tool) }) + .toEqual({ prefix, tool, resumed: progressiveFreeformInput(prefix, tool) }); + } + } + } + }); }); From c0221feb91ae454ebcb495360ec9e26c57106e01 Mon Sep 17 00:00:00 2001 From: Mushikingh <164845020+mushikingh@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:31:10 +0100 Subject: [PATCH 12/16] fix(kiro): resubmit a finished answer on the completion retry (#8070) * fix(kiro): resubmit a finished answer on the completion retry On a trivial request claude-sonnet-5 answers in plain text with END_TURN instead of calling the private completion tool, so the bounded retry fires. Its instruction began "Continue the existing task", which the model read as the user's next message: it called the completion tool with "there is no active task to continue", and that replaced the correct answer. The retry instruction now states that ordinary text has not reached the user and asks for an already complete reply to be resubmitted unchanged. The empty-completion classification is unchanged. * test(kiro): register the retry-wording test on an uncontended layout line Other open Kiro changes register their tests on the kiro-single-final line; moving this entry to the kiro-images line keeps the two registrations from conflicting when both land. * fix(kiro): keep a direct completion route in the retry instruction Review follow-up. The reworded retry instruction offered only a resubmitted reply, a blocking question, or another tool call. On a reasoning-only retry there is no previous reply, so a request the model can answer directly had no completion route in the message itself. It now says: if the task is complete, call the completion tool with the final answer, submitting the previous reply unchanged when that already was the answer. Documented in the adapter reference. * test(kiro): document the retry-wording test helpers CodeRabbit's docstring-coverage pre-merge check counts functions this PR touches; the new test file's helpers had no doc comments. --------- Co-authored-by: JUN --- .../src/content/docs/reference/adapters.md | 2 + scripts/test-layout/layout.json | 2 +- src/adapters/kiro-constants.ts | 16 +- structure/providers/kiro.md | 7 + tests/fixtures/test-layout-expected.json | 2 +- .../kiro-completion-retry-wording.test.ts | 148 ++++++++++++++++++ 6 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 tests/providers/kiro/kiro-completion-retry-wording.test.ts diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 58e544a041b..f95272a73cf 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -604,6 +604,8 @@ real client tool call keeps the turn open. A completion-tool answer is always em `final_answer`, even when it exactly repeats prior commentary, because phase correctness is more important than cosmetic de-duplication. Tool-free requests retain normal text completion behavior. +The continuation's instruction tells the model that its ordinary text has not reached the user, and asks it to call `codex_kiro_final_answer` with the complete final answer if the task is complete, submitting its previous reply unchanged when that reply already was the answer. A model that answered a trivial request in plain text therefore keeps its answer, instead of being told to continue a task it had already finished. + ### Reasoning effort The GPT-5.6 family uses `additionalModelRequestFields.reasoning.effort`; `claude-opus-5` diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 55bae0134d2..85994e144e7 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1085,7 +1085,7 @@ "kiro-calibration.test.ts": "providers/kiro", "kiro-content-filter.test.ts": "providers/kiro", "kiro-device-builder.test.ts": "providers/kiro", "kiro-device-iam.test.ts": "providers/kiro", "kiro-device-social.test.ts": "providers/kiro", "kiro-fallback-error-body.test.ts": "providers/kiro", - "kiro-images.test.ts": "providers/kiro", + "kiro-images.test.ts": "providers/kiro", "kiro-completion-retry-wording.test.ts": "providers/kiro", "kiro-leased-responses.test.ts": "providers/kiro", "kiro-model-catalog.test.ts": "providers/kiro", "kiro-model-preference.test.ts": "providers/kiro", "kiro-oauth.test.ts": "providers/kiro", "oauth-kiro-refresh-attention.test.ts": "oauth", "kiro-org-user-identity.test.ts": "providers/kiro", diff --git a/src/adapters/kiro-constants.ts b/src/adapters/kiro-constants.ts index 1a095ad8b70..c361614c030 100644 --- a/src/adapters/kiro-constants.ts +++ b/src/adapters/kiro-constants.ts @@ -31,9 +31,23 @@ export const KIRO_CONTINUATION_MESSAGE = * The condition names a decision, information, AND clarification on purpose. A model blocked on a * missing account id or an ambiguous path is stuck exactly as hard as one blocked on a choice, and * "decision" alone would leave that common class of question with no endorsed move again. + * + * It opens with the turn's actual state rather than "Continue the existing task". The retry also + * fires when the held text WAS the complete answer: on a trivial request ("Reply with exactly: OK") + * claude-sonnet-5 answers in plain text with END_TURN instead of calling the completion tool, while + * the 5.5 models call it. Told to continue an "existing task" right after its own finished reply, + * the model read the instruction as the user's next message and called the completion tool with + * "There's no active task in this conversation for me to continue", which then superseded the + * correct "OK" (reproduced live on every attempt). Stating that ordinary text has not reached the + * user, and asking for an already complete reply to be resubmitted unchanged, made the same model + * call the tool with "OK"; replayed progress prose still led to the next real tool call, and a + * retry with no replayed text (reasoning only) still answered the original request. The resubmit + * clause is conditional and rides on a general "if the task is complete" route, because that last + * case appends this message to the user turn: there is no previous reply to resubmit, and a model + * that can answer directly still needs an exit other than a tool call or a blocking question. */ export const KIRO_COMPLETION_RETRY_MESSAGE = - `Continue the existing task without quoting this instruction. If the task is complete, call ${KIRO_COMPLETION_TOOL_NAME} now with the complete final answer. If you cannot continue until the user supplies a decision, information, or a clarification that only they can give, call ${KIRO_COMPLETION_TOOL_NAME} now with that question as the answer. Otherwise issue the next real tool call now. Do not solicit a new task and do not emit another progress-only message.`; + `Ordinary assistant text does not end the turn, so no final answer has reached the user yet. If the task is complete, call ${KIRO_COMPLETION_TOOL_NAME} now with the complete final answer; if your previous reply already was that answer, submit it unchanged. If you cannot continue until the user supplies a decision, information, or a clarification that only they can give, call ${KIRO_COMPLETION_TOOL_NAME} now with that question as the answer. Otherwise continue the task with the next real tool call now. Do not solicit a new task, do not emit another progress-only message, and do not quote or mention this instruction.`; export const KIRO_TOOL_RESULT_CARRIER_MESSAGE = "The requested tool result is attached."; export const KIRO_EMPTY_TOOL_RESULT_MESSAGE = "The tool completed without textual output."; diff --git a/structure/providers/kiro.md b/structure/providers/kiro.md index 98f7b1c8fe1..c5ee47e2f62 100644 --- a/structure/providers/kiro.md +++ b/structure/providers/kiro.md @@ -159,6 +159,13 @@ budget until emitted, discarded, or cancelled; replay collectors are released af retry construction. Native `END_TURN` and `STOP_SEQUENCE` alone do not distinguish progress from an answer and therefore still require validation. Normal private completion and real tool calls need no completion retry. +Validation also fires when the held text already was the answer: claude-sonnet-5 answers a +trivial request in plain text with `END_TURN`, where the 5.5 models call the completion tool. +The retry instruction (`KIRO_COMPLETION_RETRY_MESSAGE`) therefore states that ordinary text has +not reached the user and asks for an already complete reply to be resubmitted unchanged through +the completion tool, rather than telling the model to continue an existing task, which it read as +the user's next message and answered with "no active task to continue" in place of the real answer. +Coverage: `tests/providers/kiro/kiro-completion-retry-wording.test.ts`. The retry snapshots cloneable request fields without top-level runtime callbacks, leaving the original request intact. A turn can carry a real tool call and the private completion call together, in either order. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d6cc69371a2..82e277f9127 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -953,7 +953,7 @@ "kiro-auth-context-continuation.test.ts": "providers/kiro", "kiro-builder-id-profile.test.ts": "providers/kiro", "kiro-calibration.test.ts": "providers/kiro", "kiro-device-builder.test.ts": "providers/kiro", "kiro-device-iam.test.ts": "providers/kiro", "kiro-device-social.test.ts": "providers/kiro", "kiro-fallback-error-body.test.ts": "providers/kiro", - "kiro-images.test.ts": "providers/kiro", "kiro-leased-responses.test.ts": "providers/kiro", + "kiro-images.test.ts": "providers/kiro", "kiro-leased-responses.test.ts": "providers/kiro", "kiro-completion-retry-wording.test.ts": "providers/kiro", "kiro-model-catalog.test.ts": "providers/kiro", "kiro-model-preference.test.ts": "providers/kiro", "kiro-oauth.test.ts": "providers/kiro", "oauth-kiro-refresh-attention.test.ts": "oauth", "kiro-pool-load-settings.test.ts": "providers/kiro", "kiro-org-user-identity.test.ts": "providers/kiro", "kiro-content-filter.test.ts": "providers/kiro", diff --git a/tests/providers/kiro/kiro-completion-retry-wording.test.ts b/tests/providers/kiro/kiro-completion-retry-wording.test.ts new file mode 100644 index 00000000000..276f4f50fb8 --- /dev/null +++ b/tests/providers/kiro/kiro-completion-retry-wording.test.ts @@ -0,0 +1,148 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createKiroAdapter } from "../../../src/adapters/kiro"; +import { KIRO_COMPLETION_RETRY_MESSAGE, KIRO_COMPLETION_TOOL_NAME } from "../../../src/adapters/kiro-constants"; +import { resetKiroThrottleStateForTests } from "../../../src/adapters/kiro-retry"; +import { encodeMessage } from "../../../src/lib/eventstream-decoder"; +import { createTranslatorBudget } from "../../../src/lib/translator-budget"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; + +// claude-sonnet-5 answers a trivial request in plain text with END_TURN instead of calling the +// private completion tool, so the bounded completion retry fires even though the held text WAS the +// answer. The retry used to open with "Continue the existing task"; the model read that as the +// user's next message and called the completion tool with "There's no active task in this +// conversation for me to continue", superseding the correct "OK". The frames below are the shape +// captured from the live upstream for that turn. + +const provider: OcxProviderConfig = { + adapter: "kiro", baseUrl: "https://runtime.us-east-1.kiro.dev", apiKey: "ksk_test", +}; +// A Claude Code Messages turn reduced to what matters here: tools (so completion is `required`), +// an effort without a native Kiro field for this model, and a one-word answer. +const parsed: OcxParsedRequest = { + modelId: "claude-sonnet-5", stream: true, options: { reasoning: "high" }, + context: { + messages: [{ role: "user", content: "Reply with exactly: OK" }], + tools: [ + { name: "Read", description: "Read a file", parameters: { type: "object", properties: { file_path: { type: "string" } } } }, + { name: "Bash", description: "Run a command", parameters: { type: "object", properties: { command: { type: "string" } } } }, + ], + }, +}; +const frame = (type: string, payload: object) => encodeMessage( + { ":message-type": "event", ":event-type": type }, + new TextEncoder().encode(JSON.stringify(payload)), +); +const tail = [ + frame("contextUsageEvent", { contextUsagePercentage: 0.33 }), + frame("meteringEvent", { unit: "credit", unitPlural: "credits", usage: 0.0077 }), +]; +// First attempt: the answer as ordinary text, then a native END_TURN. +const plainTextAnswer = [ + frame("initial-response", { conversationId: "" }), + frame("assistantResponseEvent", { content: "O" }), + frame("assistantResponseEvent", { content: "K" }), + frame("metadataEvent", { stopReason: "END_TURN" }), + ...tail, +]; +// Retry: the same answer resubmitted through the completion tool, streamed in pieces. +const completionAnswer = [ + frame("initial-response", { conversationId: "" }), + frame("toolUseEvent", { name: KIRO_COMPLETION_TOOL_NAME, toolUseId: "toolu-1" }), + frame("toolUseEvent", { name: KIRO_COMPLETION_TOOL_NAME, toolUseId: "toolu-1", input: "{\"answer\":" }), + frame("toolUseEvent", { name: KIRO_COMPLETION_TOOL_NAME, toolUseId: "toolu-1", input: " \"OK\"}" }), + frame("toolUseEvent", { name: KIRO_COMPLETION_TOOL_NAME, toolUseId: "toolu-1", stop: true }), + frame("metadataEvent", { stopReason: "TOOL_USE" }), + ...tail, +]; +/** A Kiro event-stream response that replays the given frames in order. */ +function response(frames: Uint8Array[]): Response { + return new Response(new ReadableStream({ + /** Enqueue every frame, then close the stream. */ + start(controller) { + for (const value of frames) controller.enqueue(value); + controller.close(); + }, + })); +} +afterEach(resetKiroThrottleStateForTests); + +/** + * Run one Claude Code-shaped turn through the adapter: the first send answers with `first`, the + * bounded retry with `retry`. Returns the client events, the physical send ordinals, every request + * body, and the retry's parsed `conversationState` when a retry was sent. + */ +async function run(first: Uint8Array[], retry: Uint8Array[]) { + const adapter = createKiroAdapter(provider); + const budget = createTranslatorBudget(); + const sends: number[] = []; + const events: AdapterEvent[] = []; + const bodies: string[] = []; + try { + const request = await adapter.buildRequest(structuredClone(parsed)); + const upstream = await adapter.fetchResponse!(request, { + /** Record each request body and answer the first send and the retry with their frames. */ + executor: (async (_url, init) => { + bodies.push(String(init?.body)); + return response(bodies.length === 1 ? first : retry); + }) as typeof fetch, + onPhysicalSend: send => { sends.push(send.ordinal); }, + }); + for await (const event of adapter.parseStream(upstream, budget)) events.push(event); + expect(budget.snapshot().currentBytes).toBe(0); + const retryState = bodies[1] ? JSON.parse(bodies[1]).conversationState : undefined; + return { events, sends, bodies, retryState }; + } finally { + budget.dispose(); + } +} + +describe("Kiro completion retry after a plain-text answer (claude-sonnet-5)", () => { + test("the retry states the turn's state instead of presuming an open task", () => { + // The phrase the model answered with "no active task ... to continue". + expect(KIRO_COMPLETION_RETRY_MESSAGE).not.toContain("Continue the existing task"); + expect(KIRO_COMPLETION_RETRY_MESSAGE).toContain("no final answer has reached the user yet"); + // A direct completion route for a retry with no previous reply (reasoning only), and the + // resubmit clause for a held plain-text answer. + expect(KIRO_COMPLETION_RETRY_MESSAGE) + .toContain(`If the task is complete, call ${KIRO_COMPLETION_TOOL_NAME} now with the complete final answer;`); + expect(KIRO_COMPLETION_RETRY_MESSAGE).toContain("if your previous reply already was that answer, submit it unchanged."); + // The progress-prose path the retry exists for (#531) keeps its instruction. + expect(KIRO_COMPLETION_RETRY_MESSAGE).toContain("next real tool call now"); + }); + + test("a plain-text END_TURN answer is replayed and resubmitted through the completion tool", async () => { + const { events, sends, bodies, retryState } = await run(plainTextAnswer, completionAnswer); + expect(bodies).toHaveLength(2); + expect(sends).toEqual([1, 2]); + // The held answer is replayed as the model's own previous reply, and the retry instruction is + // the whole following user turn, so "your previous reply" points at exactly that text. + expect(retryState.history.at(-1)).toEqual({ assistantResponseMessage: { content: "OK" } }); + expect(retryState.currentMessage.userInputMessage.content).toBe(KIRO_COMPLETION_RETRY_MESSAGE); + expect(retryState.currentMessage.userInputMessage.userInputMessageContext.tools + .some((tool: { toolSpecification: { name: string } }) => tool.toolSpecification.name === KIRO_COMPLETION_TOOL_NAME)) + .toBe(true); + // One final answer: the resubmitted completion supersedes the held plain-text copy. + expect(events.filter(event => event.type === "text_delta")).toEqual([ + { type: "text_delta", text: "OK", phase: "final_answer" }, + ]); + expect(events.at(-1)).toMatchObject({ type: "done", endTurn: true }); + }); + + test("a retry without replayable text appends the same instruction to the original request", async () => { + // Reasoning only and no stop reason: nothing to replay, so there is no previous reply and the + // conditional sentence is vacuous rather than false. + const reasoningOnly = [ + frame("initial-response", { conversationId: "" }), + frame("reasoningContentEvent", { text: "The user wants the word OK." }), + ...tail, + ]; + const { retryState, events } = await run(reasoningOnly, completionAnswer); + expect(retryState.history ?? []).toEqual([]); + const content: string = retryState.currentMessage.userInputMessage.content; + expect(content).toContain("Reply with exactly: OK"); + expect(content.endsWith(KIRO_COMPLETION_RETRY_MESSAGE)).toBe(true); + expect(events.filter(event => event.type === "text_delta")).toEqual([ + { type: "text_delta", text: "OK", phase: "final_answer" }, + ]); + }); +}); From 7b3bb7a25768ecce7507fdb77ee0a5084f53b74c Mon Sep 17 00:00:00 2001 From: visualbridge7188 Date: Tue, 3 Nov 2026 19:57:20 +0900 Subject: [PATCH 13/16] Fix non-ASCII Codex turn metadata headers (#8040) * Fix non-ASCII Codex turn metadata headers * docs: document ASCII-safe JSON contract for rewritten turn metadata * fix(codex): escape DEL in turn metadata header * docs(structure): record turn metadata header escaping * fix(codex): bound turn metadata by canonical size so re-isolation is idempotent * fix(codex): canonicalize matching turn metadata --------- Co-authored-by: JUN --- src/codex/forward-transport-headers.ts | 41 +++++++++++-- .../providers/codex-installation-identity.md | 14 ++++- .../codex-metadata-integrity.test.ts | 60 +++++++++++++++++++ .../codex-installation-turn-limit.test.ts | 32 +++++++++- 4 files changed, 139 insertions(+), 8 deletions(-) diff --git a/src/codex/forward-transport-headers.ts b/src/codex/forward-transport-headers.ts index e87d575f085..ff532293eeb 100644 --- a/src/codex/forward-transport-headers.ts +++ b/src/codex/forward-transport-headers.ts @@ -20,24 +20,53 @@ export const FORWARD_HEADERS = [ const MAX_ROUTING_MODEL_BYTES = 256; const MAX_ROUTING_TIER_BYTES = 64; const MAX_TURN_METADATA_BYTES = 32 * 1024; +// Each escaped code unit (U+007F+) becomes 6 ASCII bytes: at most 3x the UTF-8 size of +// non-ASCII text (DEL alone grows 6x), so 96 KiB holds any DEL-free canonical value. +const MAX_ESCAPED_TURN_METADATA_BYTES = 3 * MAX_TURN_METADATA_BYTES; /** Compatibility export: caller values never contribute to the selected account identity. */ export function deriveCodexAccountInstallationId(chatgptAccountId: string, _callerInstallationId: string): string { return codexInstallationIdForAccount(chatgptAccountId); } +/** + * Rewrites the `installation_id` inside Codex turn metadata while preserving all other fields. + * + * Structure contract: JSON with a nonblank string installation_id is returned as printable ASCII — every code unit outside + * U+0020–U+007E is escaped (JSON.stringify already escapes U+0000–U+001F, with short forms + * such as `\\n`; U+007F and above become `\\uXXXX` here). Both consumers of rewritten turn metadata (the + * `x-codex-turn-metadata` HTTP header and the embedded `client_metadata` value) + * receive this ASCII-safe form, so header setters never see literal Unicode. + * + * The 32 KiB bound applies to the canonical (unescaped UTF-8) rewritten JSON, and the escaped + * result is capped at 96 KiB. Feeding an accepted result back in is therefore a no-op: it + * parses to the same canonical JSON, so repeated isolation passes cannot refuse it. + * Invalid JSON, non-objects, and objects without a nonblank string installation_id pass + * through unchanged up to 32 KiB; their original bytes are not guaranteed header-safe. + */ export function rewriteTurnMetadataInstallationId(rawMetadata: string, chatgptAccountId: string): string { if (!chatgptAccountId.trim()) throw new CodexInstallationIdUnavailableError(); // A size bound must refuse the Pool send, never bypass identity projection. - if (Buffer.byteLength(rawMetadata, "utf8") > MAX_TURN_METADATA_BYTES) throw new CodexInstallationIdUnavailableError(); + const rawBytes = Buffer.byteLength(rawMetadata, "utf8"); + if (rawBytes > MAX_ESCAPED_TURN_METADATA_BYTES) throw new CodexInstallationIdUnavailableError(); + // Values returned unchanged keep the raw bound; only rewritten JSON is measured canonically. + const passThrough = () => { + if (rawBytes > MAX_TURN_METADATA_BYTES) throw new CodexInstallationIdUnavailableError(); + return rawMetadata; + }; let parsed: unknown; - try { parsed = JSON.parse(rawMetadata); } catch { return rawMetadata; } - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return rawMetadata; + try { parsed = JSON.parse(rawMetadata); } catch { return passThrough(); } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return passThrough(); const record = parsed as Record; - if (typeof record.installation_id !== "string" || !record.installation_id.trim()) return rawMetadata; + if (typeof record.installation_id !== "string" || !record.installation_id.trim()) return passThrough(); const nextId = deriveCodexAccountInstallationId(chatgptAccountId, record.installation_id); - if (nextId === record.installation_id) return rawMetadata; - return JSON.stringify({ ...record, installation_id: nextId }); + const canonical = JSON.stringify({ ...record, installation_id: nextId }); + if (Buffer.byteLength(canonical, "utf8") > MAX_TURN_METADATA_BYTES) throw new CodexInstallationIdUnavailableError(); + // Turn metadata is also sent as an HTTP header; DEL and non-ASCII are invalid field-value bytes. + const escaped = canonical + .replace(/[\u007f-\uffff]/g, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`); + if (escaped.length > MAX_ESCAPED_TURN_METADATA_BYTES) throw new CodexInstallationIdUnavailableError(); + return escaped; } export function isolateCodexPoolAccountHeaders(headers: Headers, chatgptAccountId: string): void { diff --git a/structure/providers/codex-installation-identity.md b/structure/providers/codex-installation-identity.md index d16f82cf067..03a91abf9ab 100644 --- a/structure/providers/codex-installation-identity.md +++ b/structure/providers/codex-installation-identity.md @@ -24,7 +24,19 @@ while empty installation and turn-metadata headers retain their existence gates. `src/codex/forward-transport-headers.ts` also isolates a present installation field inside native turn metadata and strips caller attestations from substituted credentials. Its selected-header merge prevents removed identity fields from reappearing through provider defaults. -Pool turn metadata is bounded to 32 KiB of UTF-8. Larger values refuse the send with the same +For an object with a nonblank string `installation_id`, including one already matching the selected +identity, turn-metadata JSON is canonicalized and escapes every code unit outside +printable ASCII: `JSON.stringify` escapes U+0000–U+001F (with short forms such as `\n` and +`\t` where JSON defines them, `\uXXXX` otherwise), and U+007F and above become `\uXXXX`. The +header stays a valid HTTP field value and JSON-decodes to the same metadata; the client-metadata +copy uses the same form. Pool turn metadata is bounded to 32 KiB of canonical UTF-8, measured on +the unescaped rewritten JSON, and the escaped output is capped at 96 KiB (escaping grows +non-ASCII text at most threefold; DEL-heavy values past the cap are refused). Raw input above +96 KiB is refused before parsing. Invalid JSON, non-objects, and objects without a nonblank string +`installation_id` pass through unchanged up to 32 KiB raw and are not guaranteed header-safe. +Because the bound is on canonical JSON, an accepted escaped value re-enters unchanged, so the +Responses path can isolate it during auth materialization and again in the policy recheck. +Larger values refuse the send with the same sanitized identity error instead of passing unprojected bytes through; main and main-pool retain their original values. Routed compact handoffs preserve present empty installation and turn-metadata headers, so the final selected account applies the same rule as a direct send. diff --git a/tests/codex-integration/codex-metadata-integrity.test.ts b/tests/codex-integration/codex-metadata-integrity.test.ts index 0b88f1a5372..c52b8d1e86f 100644 --- a/tests/codex-integration/codex-metadata-integrity.test.ts +++ b/tests/codex-integration/codex-metadata-integrity.test.ts @@ -5,7 +5,9 @@ import { deriveCodexAccountInstallationId, isolateCodexPoolAccountHeaders, isolateCodexPoolClientMetadata, + rewriteTurnMetadataInstallationId, } from "../../src/codex/forward-transport-headers"; +import { CodexInstallationIdUnavailableError } from "../../src/codex/installation-id"; import type { OcxParsedRequest, OcxProviderConfig } from "../../src/types"; import { withTestTranslatorBudget } from "../helpers/translator-budget"; @@ -226,6 +228,64 @@ describe("Codex metadata integrity", () => { isolateCodexPoolAccountHeaders(copyHeaders, accountA.chatgptAccountId); expect(copyHeaders.get("x-codex-installation-id")).toBe(idA); }); + test("pooled turn metadata preserves non-ASCII values in header-safe JSON", () => { + const raw = String.raw`{"installation_id":"caller-installation","workspaces":["/tmp/\ubcd1\uc6d0/\ud83d\ude80"],"extra":{"note":"\ud55c\uae00 \ud83d\ude00"}}`; + const incoming = new Headers({ "x-codex-turn-metadata": raw }); + const headers = headersForCodexAuthContext(incoming, poolAuthContext); + const rewritten = headers.get("x-codex-turn-metadata")!; + expect(rewritten).toMatch(/^[\x00-\x7f]*$/); + expect(JSON.parse(rewritten)).toEqual({ + ...JSON.parse(raw), + installation_id: deriveCodexAccountInstallationId(accountA.chatgptAccountId, "caller-installation"), + }); + const body = { client_metadata: { "x-codex-turn-metadata": raw } }; + expect((isolateCodexPoolClientMetadata(body, accountA.chatgptAccountId) as typeof body) + .client_metadata["x-codex-turn-metadata"]).toBe(rewritten); + isolateCodexPoolAccountHeaders(headers, accountA.chatgptAccountId); + expect(headers.get("x-codex-turn-metadata")).toBe(rewritten); + }); + test("pooled turn metadata escapes DEL so the header stays a valid field value", () => { + const raw = String.raw`{"installation_id":"caller-installation","workspaces":["/tmp/a\u007fb/\ubcd1\u0001"]}`; + const headers = headersForCodexAuthContext(new Headers({ "x-codex-turn-metadata": raw }), poolAuthContext); + const rewritten = headers.get("x-codex-turn-metadata")!; + expect(rewritten).toMatch(/^[\x20-\x7e\t]*$/); + expect(JSON.parse(rewritten).workspaces).toEqual(["/tmp/a\u007fb/\ubcd1\u0001"]); + expect(JSON.parse(rewritten).installation_id) + .toBe(deriveCodexAccountInstallationId(accountA.chatgptAccountId, "caller-installation")); + }); + test("escaped turn metadata is accepted unchanged on re-entry and capped at 96 KiB", () => { + const account = accountA.chatgptAccountId; + const raw = JSON.stringify({ installation_id: "caller-installation", pad: "\u00e9".repeat(6000) }); + const once = rewriteTurnMetadataInstallationId(raw, account); + expect(once.length).toBeGreaterThan(32 * 1024); + expect(rewriteTurnMetadataInstallationId(once, account)).toBe(once); + const headers = headersForCodexAuthContext(new Headers({ "x-codex-turn-metadata": raw }), poolAuthContext); + expect(headersForCodexAuthContext(headers, poolAuthContext).get("x-codex-turn-metadata")).toBe(once); + // DEL expands 6x: 20,000 of them fit the 32 KiB canonical bound but exceed the escaped cap. + const del = JSON.stringify({ installation_id: "caller-installation", pad: "\u007f".repeat(20_000) }); + expect(() => rewriteTurnMetadataInstallationId(del, account)).toThrow(CodexInstallationIdUnavailableError); + // Non-JSON values pass through unchanged, so they keep the raw 32 KiB bound. + expect(() => rewriteTurnMetadataInstallationId("x".repeat(32 * 1024 + 1), account)).toThrow(CodexInstallationIdUnavailableError); + }); + for (const value of ["漢字", "a\u007fb"]) test(`equal installation IDs escape ${JSON.stringify(value)}`, () => { + const account = accountA.chatgptAccountId; + const installation_id = deriveCodexAccountInstallationId(account, "caller"); + const raw = JSON.stringify({ installation_id, value }); + const once = rewriteTurnMetadataInstallationId(raw, account); + expect(once).toMatch(/^[\x20-\x7e]*$/); + expect(JSON.parse(once)).toEqual({ installation_id, value }); + expect(rewriteTurnMetadataInstallationId(once, account)).toBe(once); + }); + test("equal installation IDs use canonical and escaped size bounds", () => { + const account = accountA.chatgptAccountId; + const installation_id = deriveCodexAccountInstallationId(account, "caller"); + const padded = ` ${" ".repeat(90_000)}${JSON.stringify({ installation_id })}`; + expect(rewriteTurnMetadataInstallationId(padded, account)).toBe(JSON.stringify({ installation_id })); + const oversized = JSON.stringify({ installation_id, pad: "a".repeat(32 * 1024) }); + expect(() => rewriteTurnMetadataInstallationId(oversized, account)).toThrow(CodexInstallationIdUnavailableError); + const del = JSON.stringify({ installation_id, pad: "\u007f".repeat(20_000) }); + expect(() => rewriteTurnMetadataInstallationId(del, account)).toThrow(CodexInstallationIdUnavailableError); + }); test("Responses preserves caller User-Agent as a fallback in key and forward modes", async () => { for (const provider of [ { diff --git a/tests/responses/codex-installation-turn-limit.test.ts b/tests/responses/codex-installation-turn-limit.test.ts index 14566a917eb..e6059a3df99 100644 --- a/tests/responses/codex-installation-turn-limit.test.ts +++ b/tests/responses/codex-installation-turn-limit.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { handleResponses, handleResponsesCompact } from "../../src/server/responses"; import { handleSearch } from "../../src/server/search"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; -import { install, installationLiveFixture, liveConfig, turn, turnMetadataBytes } from "../helpers/codex-installation-live"; +import { callerId, install, installationLiveFixture, liveConfig, turn, turnMetadataBytes } from "../helpers/codex-installation-live"; const fixture = installationLiveFixture(); const originalFetch = globalThis.fetch; @@ -16,6 +16,36 @@ beforeEach(() => { }) as typeof fetch; }); afterEach(() => { globalThis.fetch = originalFetch; release(); }); +// Non-ASCII pad of exactly `bytes` canonical UTF-8; escaping it expands the wire form ~3x. +function unicodeTurnMetadataBytes(bytes: number) { + const room = bytes - Buffer.byteLength(JSON.stringify({ installation_id: callerId, pad: "" })); + return JSON.stringify({ installation_id: callerId, pad: "a".repeat(room % 2) + "\u00e9".repeat(Math.floor(room / 2)) }); +} +for (const surface of ["Responses", "compact", "search"] as const) +for (const header of [false, true]) for (const bytes of [12038, 32768, 32769]) { + test(`${surface} ${header ? "header" : "body-only"} non-ASCII turn metadata ${bytes} canonical bytes`, async () => { + const raw = unicodeTurnMetadataBytes(bytes); expect(Buffer.byteLength(raw)).toBe(bytes); + const config = liveConfig(); config.providers.openai!.upstreamWebsocket = false; + const headers = new Headers({ "content-type": "application/json" }); if (header) headers.set(turn, raw); + const req = new Request(`http://localhost/v1/${surface === "compact" ? "responses/compact" : surface === "search" ? "alpha/search" : "responses"}`, { + method: "POST", headers, body: JSON.stringify({ model: "gpt-5.5", input: [], stream: false, + ...(header ? {} : { client_metadata: { [turn]: raw } }) }), + }); + const handler = surface === "compact" ? handleResponsesCompact : surface === "search" ? handleSearch : handleResponses; + const response = await handler(req, config, { model: "", provider: "" }); + expect(response.status).toBe(bytes > 32768 ? 503 : 200); + await response.text(); + expect(sent).toHaveLength(bytes > 32768 ? 0 : 1); + if (bytes <= 32768) { + const wire = sent[0]!; + const metadata: string = header ? wire.headers.get(turn)! : (await wire.json()).client_metadata[turn]; + // The escaped form exceeds 32 KiB; repeated isolation passes must still accept it. + expect(metadata).toMatch(/^[\x20-\x7e]*$/); + expect(metadata.length).toBeGreaterThan(Buffer.byteLength(raw)); + expect(JSON.parse(metadata)).toEqual({ installation_id: fixture.expected(), pad: JSON.parse(raw).pad }); + } + }); +} for (const surface of ["Responses", "compact", "search"] as const) for (const header of [false, true]) for (const bytes of [32768, 32769]) { test(`${surface} ${header ? "header" : "body-only"} turn metadata ${bytes} bytes`, async () => { From 345c4281dd78e2c899aca30f0778b69596df2e63 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 20:02:08 +0900 Subject: [PATCH 14/16] chore(release): open dev at 2.98.0 before releasing 2.97.0 (#8135) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 0cc2f6deea2..5ecab663866 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2645,7 +2645,7 @@ dependencies = [ [[package]] name = "opencodex-desktop" -version = "2.97.0" +version = "2.98.0" dependencies = [ "base64 0.22.1", "dbus", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index a0ecc4f7a0a..0b8099d7e94 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencodex-desktop" -version = "2.97.0" +version = "2.98.0" description = "OpenCodex desktop shell" authors = ["OpenCodex contributors"] license = "MIT" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 7a63a3e2b67..544a26fe31b 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCodex", - "version": "2.97.0", + "version": "2.98.0", "identifier": "com.opencodex.desktop", "build": { "frontendDist": "../ui", diff --git a/package.json b/package.json index d763390a680..74afddd11e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.97.0", + "version": "2.98.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From a98a6bfcae47af3405250e3cc8ace3367b4f0ced Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 3 Nov 2026 20:56:00 +0900 Subject: [PATCH 15/16] test(diagnostics): use executable catalog fixture on macOS (#8136) --- .../tests/async_contracts.test.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/scripts/diagnostics/windows-version-control/tests/async_contracts.test.rs b/scripts/diagnostics/windows-version-control/tests/async_contracts.test.rs index dd498b89ff2..618e0aa56f0 100644 --- a/scripts/diagnostics/windows-version-control/tests/async_contracts.test.rs +++ b/scripts/diagnostics/windows-version-control/tests/async_contracts.test.rs @@ -345,7 +345,8 @@ fn invalidation_and_stopped_source_discard_late_catalogs() { async function loadCase(change,phase) { let current = phase!=='before-call', release, entered, executions=0; const enteredPromise=new Promise(resolve=>{entered=resolve}); - const deps={commandCandidates:()=>['C:\\fixture\\codex.exe'], + // Use the real fixture executable so macOS can validate the candidate. + const deps={commandCandidates:()=>[process.env.CODEX_CLI_PATH], execFile:()=>new Promise(resolve=>{executions++;release=resolve;entered()})}; const pending=bundled.loadBundledCodexCatalogAsync(deps,()=>current); const mutate=()=>{if(change==='invalidate')bundled.invalidateBundledCatalogCache();else current=false}; @@ -475,16 +476,19 @@ fn refresh_deadline_settles_an_uncooperative_executor_and_allows_the_next_load() const realTimeout = globalThis.setTimeout; globalThis.setTimeout = (callback,delay,...args)=>realTimeout(callback,delay===45000?20:delay,...args); const keepAlive = setInterval(()=>{},100); -const expired = await bundled.loadBundledCodexCatalogAsync({commandCandidates:()=>['C:\\fixture\\codex.exe'], - execFile:()=>new Promise(()=>{})}); +let entered = false, aborted = false; +const expired = await bundled.loadBundledCodexCatalogAsync({commandCandidates:()=>[process.env.CODEX_CLI_PATH], + execFile:(_file,_args,options)=>{entered=true; options.signal.addEventListener('abort',()=>{aborted=true}); return new Promise(()=>{})}}); clearInterval(keepAlive); globalThis.setTimeout = realTimeout; -const fresh = await bundled.loadBundledCodexCatalogAsync({commandCandidates:()=>['C:\\fixture\\codex.exe'], +const fresh = await bundled.loadBundledCodexCatalogAsync({commandCandidates:()=>[process.env.CODEX_CLI_PATH], execFile:async()=>JSON.stringify(catalog)}); -return {expired:expired===null,fresh:fresh?.models.map(row=>row.slug)}; +return {expired:expired===null,entered,aborted,fresh:fresh?.models.map(row=>row.slug)}; "#, ); assert_eq!(result["expired"], true); + assert_eq!(result["entered"], true); + assert_eq!(result["aborted"], true); assert_eq!( result["fresh"], serde_json::json!(["gpt-5.5", "gpt-5.6-sol"]) From acea1f4ec9231e049c2465b4d308394f0ba803de Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 3 Nov 2026 22:01:09 +0900 Subject: [PATCH 16/16] ci: run the full Ubuntu shards on this head