diff --git a/.gitignore b/.gitignore index 5a2e08f42..34fe52207 100644 --- a/.gitignore +++ b/.gitignore @@ -252,3 +252,4 @@ uv.lock # package shadows the maturin overlay on sys.path. /headroom/_core.*.so /headroom/_core.so +.tokensave diff --git a/Cargo.lock b/Cargo.lock index 3f54c9a61..052085cc2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -160,9 +160,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" [[package]] name = "as-slice" @@ -202,9 +202,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "av-scenechange" @@ -242,18 +242,18 @@ dependencies = [ [[package]] name = "avif-serialize" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" dependencies = [ "arrayvec", ] [[package]] name = "aws-config" -version = "1.8.16" +version = "1.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50f156acdd2cf55f5aa53ee416c4ac851cf1222694506c0b1f78c85695e9ca9d" +checksum = "e33f815b73a3899c03b380d543532e5865f230dce9678d108dc10732a8682275" dependencies = [ "aws-credential-types", "aws-runtime", @@ -265,12 +265,13 @@ dependencies = [ "aws-smithy-json", "aws-smithy-runtime", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "aws-types", "bytes", "fastrand", "hex", - "http 1.4.0", + "http 1.4.2", "sha1", "time", "tokio", @@ -293,9 +294,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.16.3" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", "zeroize", @@ -303,9 +304,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.40.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ "cc", "cmake", @@ -315,9 +316,9 @@ dependencies = [ [[package]] name = "aws-runtime" -version = "1.7.3" +version = "1.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dcd93c82209ac7413532388067dce79be5a8780c1786e5fae3df22e4dee2864" +checksum = "6c9b9de216a988dd54b754a82a7660cfe14cee4f6782ae4524470972fa0ccb39" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -330,7 +331,7 @@ dependencies = [ "bytes", "bytes-utils", "fastrand", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "percent-encoding", "pin-project-lite", @@ -340,10 +341,11 @@ dependencies = [ [[package]] name = "aws-sdk-sso" -version = "1.98.0" +version = "1.102.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d69c77aafa20460c68b6b3213c84f6423b6e76dbf89accd3e1789a686ffd9489" +checksum = "8c82b3ac19f1431854f7ace3a7531674633e286bfdde21976893bfee36fd493b" dependencies = [ + "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", @@ -357,17 +359,18 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-ssooidc" -version = "1.100.0" +version = "1.104.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c7e7b09346d5ca22a2a08267555843a6a0127fb20d8964cb6ecfb8fdb190225" +checksum = "321000d2b4c5519ee573f73167f612efd7329322d9b26969ad1979f0427f1913" dependencies = [ + "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", @@ -381,17 +384,18 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-sts" -version = "1.103.0" +version = "1.107.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2249b81a2e73a8027c41c378463a81ec39b8510f184f2caab87de912af0f49b" +checksum = "3d0d328ba962af23ecfa3c9f23b98d3d35e325fa218d7f13d17a6bf522f8a560" dependencies = [ + "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", @@ -406,16 +410,16 @@ dependencies = [ "aws-types", "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "regex-lite", "tracing", ] [[package]] name = "aws-sigv4" -version = "1.4.3" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68dc0b907359b120170613b5c09ccc61304eac3998ff6274b97d93ee6490115a" +checksum = "bae38512beae0ffee7010fc24e7a8a123c53efdfef42a61e80fda4882418dc71" dependencies = [ "aws-credential-types", "aws-smithy-http", @@ -426,7 +430,7 @@ dependencies = [ "hex", "hmac", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "percent-encoding", "sha2 0.11.0", "time", @@ -456,7 +460,7 @@ dependencies = [ "bytes-utils", "futures-core", "futures-util", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "percent-encoding", @@ -467,15 +471,15 @@ dependencies = [ [[package]] name = "aws-smithy-http-client" -version = "1.1.12" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769" +checksum = "5c3ef8931ad1c98aa6a55b4256f847f3116090819844e0dd41ea682cac5dd2d3" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", "aws-smithy-types", "h2", - "http 1.4.0", + "http 1.4.2", "hyper", "hyper-rustls", "hyper-util", @@ -491,10 +495,12 @@ dependencies = [ [[package]] name = "aws-smithy-json" -version = "0.62.5" +version = "0.62.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9648b0bb82a2eedd844052c6ad2a1a822d1f8e3adee5fbf668366717e428856a" +checksum = "701a947f4797e52a911e114a898667c746c39feea467bbd1abd7b3721f702ffa" dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", ] @@ -519,20 +525,21 @@ dependencies = [ [[package]] name = "aws-smithy-runtime" -version = "1.11.1" +version = "1.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0504b1ab12debb5959e5165ee5fe97dd387e7aa7ea6a477bfd7635dfe769a4f5" +checksum = "b8e6f5caf6fea86f8c2206541ab5857cfcda9013426cdbe8fa0098b9e2d32182" dependencies = [ "aws-smithy-async", "aws-smithy-http", "aws-smithy-http-client", "aws-smithy-observability", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "bytes", "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "http-body 0.4.6", "http-body 1.0.1", "http-body-util", @@ -544,16 +551,16 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.12.0" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71a13df6ada0aafbf21a73bdfcdf9324cfa9df77d96b8446045be3cde61b42e" +checksum = "9db177daa6ba8afb9ee1aefcf548c907abcf52065e394ee11a92780057fe0e8c" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api-macros", "aws-smithy-types", "bytes", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "pin-project-lite", "tokio", "tracing", @@ -572,16 +579,27 @@ dependencies = [ ] [[package]] -name = "aws-smithy-types" -version = "1.4.7" +name = "aws-smithy-schema" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d73dbfbaa8e4bc57b9045137680b958d274823509a360abfd8e1d514d40c95c" +checksum = "7442cb268338f0eb8278140a107c046756aa01093d8ef5e99628d34ae09c94f5" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http 1.4.2", +] + +[[package]] +name = "aws-smithy-types" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b42fcf341259d85ca10fac9a2f6448a8ec691c6955a18e45bc3b71a85fab85" dependencies = [ "base64-simd", "bytes", "bytes-utils", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "http-body 0.4.6", "http-body 1.0.1", "http-body-util", @@ -605,13 +623,14 @@ dependencies = [ [[package]] name = "aws-types" -version = "1.3.15" +version = "1.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bbcaa9304ea40902d3d5f42a0428d1bd895a2b0f6999436fb279ffddc58ac" +checksum = "d16bf10b03a3c01e6b3b7d47cd964e873ffe9e7d4e80fad16bd4c077cb068531" dependencies = [ "aws-credential-types", "aws-smithy-async", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "rustc_version", "tracing", @@ -629,7 +648,7 @@ dependencies = [ "base64 0.22.1", "bytes", "futures-util", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "hyper", @@ -664,7 +683,7 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "mime", @@ -732,9 +751,9 @@ checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bitstream-io" @@ -770,9 +789,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] @@ -790,15 +809,15 @@ dependencies = [ [[package]] name = "built" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" @@ -820,9 +839,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" [[package]] name = "bytes-utils" @@ -857,9 +876,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.60" +version = "1.2.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", "jobserver", @@ -881,9 +900,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -971,9 +990,9 @@ dependencies = [ [[package]] name = "cmov" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] name = "color_quant" @@ -999,9 +1018,9 @@ dependencies = [ [[package]] name = "compact_str" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" dependencies = [ "castaway", "cfg-if", @@ -1200,9 +1219,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "hybrid-array", ] @@ -1262,9 +1281,9 @@ dependencies = [ [[package]] name = "dashmap" -version = "6.1.0" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ "cfg-if", "crossbeam-utils", @@ -1303,9 +1322,6 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] [[package]] name = "derive_builder" @@ -1354,9 +1370,9 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "const-oid", - "crypto-common 0.2.1", + "crypto-common 0.2.2", "ctutils", ] @@ -1383,9 +1399,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -1409,9 +1425,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "encode_unicode" @@ -1513,9 +1529,9 @@ dependencies = [ [[package]] name = "fastembed" -version = "5.13.3" +version = "5.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58d74247f8cb93f94459e6f3599391f30c3f434f167f7109bd01a288db1bbe67" +checksum = "545e4fb17fc48768ff36c2a3854aa5b0b809d0ed595ab5530fa8ac94f31bd0ea" dependencies = [ "anyhow", "hf-hub 0.5.0", @@ -1536,23 +1552,9 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fax" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" -dependencies = [ - "fax_derive", -] - -[[package]] -name = "fax_derive" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" [[package]] name = "fdeflate" @@ -1702,20 +1704,21 @@ dependencies = [ [[package]] name = "gcp_auth" -version = "0.12.6" +version = "0.12.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2b3d0b409a042a380111af38136310839af8ac1a0917fb6e84515ed1e4bf3ee" +checksum = "26d27dbcc645b60b8e7f6e2868a9d7102ece97d1bb49c1288b5321fcc67f7260" dependencies = [ "async-trait", "base64 0.22.1", "bytes", "chrono", - "http 1.4.0", + "http 1.4.2", "http-body-util", "hyper", "hyper-rustls", "hyper-util", "ring", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -1765,15 +1768,13 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "wasip2", - "wasip3", ] [[package]] @@ -1788,16 +1789,16 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http 1.4.0", + "http 1.4.2", "indexmap", "slab", "tokio", @@ -1851,9 +1852,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" @@ -1876,7 +1877,7 @@ dependencies = [ "fastembed", "flate2", "hf-hub 0.4.3", - "http 1.4.0", + "http 1.4.2", "magika", "md-5", "proptest", @@ -1926,7 +1927,7 @@ dependencies = [ "futures-util", "gcp_auth", "headroom-core", - "http 1.4.0", + "http 1.4.2", "http-body-util", "humantime", "hyper", @@ -1990,7 +1991,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97" dependencies = [ "dirs", - "http 1.4.0", + "http 1.4.2", "indicatif 0.17.11", "libc", "log", @@ -2010,7 +2011,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aef3982638978efa195ff11b305f51f1f22f4f0a6cabee7af79b383ebee6a213" dependencies = [ "dirs", - "http 1.4.0", + "http 1.4.2", "indicatif 0.18.4", "libc", "log", @@ -2051,9 +2052,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -2077,7 +2078,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.0", + "http 1.4.2", ] [[package]] @@ -2088,7 +2089,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "pin-project-lite", ] @@ -2113,25 +2114,25 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] name = "hybrid-array" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", "h2", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "httparse", "httpdate", @@ -2148,7 +2149,7 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http 1.4.0", + "http 1.4.2", "hyper", "hyper-util", "rustls", @@ -2156,7 +2157,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.7", + "webpki-roots 1.0.8", ] [[package]] @@ -2169,7 +2170,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "hyper", "ipnet", @@ -2288,12 +2289,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -2313,9 +2308,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -2357,9 +2352,9 @@ dependencies = [ [[package]] name = "imgref" -version = "1.12.0" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" [[package]] name = "indexmap" @@ -2368,9 +2363,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", - "serde", - "serde_core", + "hashbrown 0.17.1", ] [[package]] @@ -2425,16 +2418,6 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is-terminal" version = "0.4.17" @@ -2497,13 +2480,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -2513,12 +2495,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "lebe" version = "0.5.3" @@ -2527,15 +2503,15 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" [[package]] name = "libc" -version = "0.2.185" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libfuzzer-sys" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" dependencies = [ "arbitrary", "cc", @@ -2553,9 +2529,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ "libc", ] @@ -2600,9 +2576,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "loop9" @@ -2630,9 +2606,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "lzma-rust2" -version = "0.15.7" +version = "0.15.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1670343e58806300d87950e3401e820b519b9384281bbabfb15e3636689ffd69" +checksum = "e20f57f9918e5bd7bc58c22cdd70a6afc7375d4dd9683af5f2b34bd3d2bba619" [[package]] name = "macro_rules_attribute" @@ -2709,9 +2685,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "memoffset" @@ -2746,9 +2722,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", @@ -2810,9 +2786,9 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "no_std_io2" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b51ed7824b6e07d354605f4abb3d9d300350701299da96642ee084f5ce631550" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" dependencies = [ "memchr", ] @@ -2872,9 +2848,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -2946,9 +2922,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "onig" -version = "6.5.1" +version = "6.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" dependencies = [ "bitflags", "libc", @@ -2958,9 +2934,9 @@ dependencies = [ [[package]] name = "onig_sys" -version = "69.9.1" +version = "69.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" dependencies = [ "cc", "pkg-config", @@ -3058,18 +3034,18 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project" -version = "1.1.11" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.11" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", @@ -3174,16 +3150,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -3195,18 +3161,18 @@ dependencies = [ [[package]] name = "profiling" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" dependencies = [ "profiling-procmacros", ] [[package]] name = "profiling-procmacros" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" dependencies = [ "quote", "syn", @@ -3348,9 +3314,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", @@ -3368,9 +3334,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "bytes", "getrandom 0.3.4", @@ -3403,9 +3369,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -3615,9 +3581,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -3644,9 +3610,9 @@ checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" @@ -3659,7 +3625,7 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "hyper", @@ -3687,7 +3653,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.7", + "webpki-roots 1.0.8", ] [[package]] @@ -3760,9 +3726,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.39" +version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "aws-lc-rs", "log", @@ -3776,9 +3742,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -3788,9 +3754,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", @@ -3834,13 +3800,15 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "safetensors" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "675656c1eabb620b921efea4f9199f97fc86e36dd6ffd1fbbe48d0f59a4987f5" +checksum = "79b079b829cb27a1c3c374341345ed2e8b2c0c839034522cee576c140bd7f846" dependencies = [ "hashbrown 0.16.1", + "libc", "serde", "serde_json", + "tempfile", ] [[package]] @@ -3928,9 +3896,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "indexmap", "itoa", @@ -4016,9 +3984,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -4053,15 +4021,15 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -4116,9 +4084,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -4158,7 +4126,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -4244,12 +4212,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -4259,15 +4226,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" dependencies = [ "num-conv", "time-core", @@ -4344,9 +4311,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.52.1" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -4478,21 +4445,21 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags", "bytes", "futures-util", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", "tracing", + "url", "uuid", ] @@ -4608,7 +4575,7 @@ dependencies = [ "byteorder", "bytes", "data-encoding", - "http 1.4.0", + "http 1.4.2", "httparse", "log", "rand 0.8.6", @@ -4621,9 +4588,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unarray" @@ -4648,9 +4615,9 @@ dependencies = [ [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" @@ -4658,12 +4625,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "unicode_categories" version = "0.1.1" @@ -4735,7 +4696,7 @@ dependencies = [ "socks", "ureq-proto", "utf8-zero", - "webpki-roots 1.0.7", + "webpki-roots 1.0.8", ] [[package]] @@ -4745,7 +4706,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" dependencies = [ "base64 0.22.1", - "http 1.4.0", + "http 1.4.2", "httparse", "log", ] @@ -4794,11 +4755,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "wasm-bindgen", ] @@ -4874,27 +4835,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" dependencies = [ "cfg-if", "once_cell", @@ -4905,9 +4857,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.68" +version = "0.4.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" dependencies = [ "js-sys", "wasm-bindgen", @@ -4915,9 +4867,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4925,9 +4877,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" dependencies = [ "bumpalo", "proc-macro2", @@ -4938,35 +4890,13 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - [[package]] name = "wasm-streams" version = "0.4.2" @@ -4980,23 +4910,11 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" dependencies = [ "js-sys", "wasm-bindgen", @@ -5018,14 +4936,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.7", + "webpki-roots 1.0.8", ] [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" dependencies = [ "rustls-pki-types", ] @@ -5310,7 +5228,7 @@ dependencies = [ "base64 0.22.1", "deadpool", "futures", - "http 1.4.0", + "http 1.4.2", "http-body-util", "hyper", "hyper-util", @@ -5323,100 +5241,12 @@ dependencies = [ "url", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" version = "0.6.3" @@ -5437,9 +5267,9 @@ checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -5460,18 +5290,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", @@ -5480,9 +5310,9 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] @@ -5501,9 +5331,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" diff --git a/docs/content/docs/proxy.mdx b/docs/content/docs/proxy.mdx index 707d3666a..0eb856f24 100644 --- a/docs/content/docs/proxy.mdx +++ b/docs/content/docs/proxy.mdx @@ -59,7 +59,15 @@ Telemetry is **off by default** (opt-in). Opt in with `HEADROOM_TELEMETRY=on` or | `--intercept-tool-results` | `false` | Opt into tool-result interceptors such as ast-grep Read outlining. | | `--no-read-lifecycle` | `false` | Disable stale/superseded Read-output compression. | | `--code-aware` / `--no-code-aware` | disabled | Enable or disable AST-based code compression. Requires `headroom-ai[code]`. | -| `--code-graph` | `false` | Index the current project and watch files via codebase-memory-mcp. | +| `--code-graph` | `false` | Force a tokensave code-graph index of the current project (tokensave is the default coding-task compressor registered by `headroom wrap`). | + +#### tokensave binary trust model + +`headroom wrap` registers **tokensave** (a local code-graph MCP server) as the default coding-task compressor. tokensave ships as a single prebuilt Rust binary, so `wrap` downloads the release asset for your platform from GitHub and runs it locally. Because the binary is executed, every supported asset is **pinned to a SHA-256 digest in Headroom** (`headroom/graph/tokensave_installer.py`); the downloaded bytes are verified against that digest before extraction, and a mismatch aborts the install (Headroom falls back to the Serena backup) rather than running unverified code. + +- Set `HEADROOM_BINARIES_OFFLINE=1` to never reach the network — `wrap` then uses an already-installed tokensave or falls back to Serena. +- `HEADROOM_TOKENSAVE_VERSION` overrides the pinned release tag. Since an overridden version has no pinned digest, the download is **refused** unless you also set `HEADROOM_TOKENSAVE_ALLOW_UNVERIFIED=1`. +- Pass `--no-tokensave` to skip the primary compressor entirely, or `--serena` to force the Serena backup on. By default, the proxy uses the shared **ContentRouter** pipeline. It routes text, logs, JSON, code, images, and tool outputs through the currently enabled compressors and preserves reversible CCR markers where applicable. diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index 216073649..e5c42cb0f 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -866,15 +866,18 @@ def _remove_headroom_installed_serena_mcp(registrar: Any) -> str: return "failed" -def _disable_serena_mcp(registrar: Any, *, verbose: bool = False) -> None: - """Make ``--no-serena`` actively disable Serena, not merely skip adding it. +def _disable_serena_mcp( + registrar: Any, *, verbose: bool = False, reason: str = "--no-serena" +) -> None: + """Actively disable a Headroom-installed Serena entry, not merely skip it. - Serena is registered by default, so a prior ``headroom wrap`` persists a - ``serena`` entry into the agent's MCP config; the agent then keeps - launching Serena on startup. Just *skipping* registration on a later - ``--no-serena`` run leaves that stale entry in place — so the flag has to - remove the entry Headroom installed. A user-managed Serena (absent from - our ledger) is reported but left untouched. + Serena used to be registered by default, so a prior ``headroom wrap`` + persists a ``serena`` entry into the agent's MCP config; the agent then + keeps launching Serena on startup. Just *skipping* registration on a later + run leaves that stale entry in place — so this removes the entry Headroom + installed. A user-managed Serena (absent from our ledger) is reported but + left untouched. ``reason`` is surfaced in the message: ``--no-serena`` when + the user opted out, or a note that tokensave is now the primary compressor. """ if not registrar.detect(): if verbose: @@ -883,12 +886,12 @@ def _disable_serena_mcp(registrar: Any, *, verbose: bool = False) -> None: if registrar.get_server("serena") is None: if verbose: - click.echo(" Skipping Serena MCP (--no-serena)") + click.echo(f" Skipping Serena MCP ({reason})") return status = _remove_headroom_installed_serena_mcp(registrar) if status == "removed": - click.echo(" Removed previously-installed Serena MCP (--no-serena)") + click.echo(f" Removed previously-installed Serena MCP ({reason})") click.echo(f" restart {registrar.display_name} if it was already running") elif status == "not_headroom_owned": click.echo( @@ -901,122 +904,219 @@ def _disable_serena_mcp(registrar: Any, *, verbose: bool = False) -> None: ) +# ============================================================================= +# tokensave — primary coding-task compressor (Serena is the backup) +# ============================================================================= + + +def _ensure_tokensave_binary(verbose: bool = False) -> Path | None: + """Resolve the tokensave binary, fetching the release asset if missing. + + Returns the binary path, or ``None`` when tokensave is unavailable + (offline, unsupported platform, or download failure) — the caller then + falls back to Serena. + """ + from headroom.graph.tokensave_installer import ensure_tokensave, get_tokensave_path + + existing = get_tokensave_path() + if existing: + return existing + + click.echo(" tokensave: fetching code-graph binary...") + path = ensure_tokensave() + if path: + click.echo(f" tokensave: installed at {path}") + else: + click.echo( + " tokensave: no prebuilt binary available for this platform " + "(try 'cargo install tokensave') — falling back to Serena" + ) + return path + + +def _index_tokensave_project(bin_path: Path, *, verbose: bool = False) -> None: + """Index the current project into the tokensave graph (non-fatal). + + Runs ``tokensave init`` the first time (creates ``.tokensave/``), then + ``tokensave sync`` for incremental updates. tokensave also re-checks + staleness on demand, so a failure here is logged but never blocks the + wrap — the MCP server still indexes lazily on first query. + """ + project_dir = Path.cwd() + subcommand = "sync" if (project_dir / ".tokensave").exists() else "init" + try: + result = run( + [str(bin_path), subcommand], + capture_output=True, + text=True, + timeout=60, + ) + if result.returncode == 0: + click.echo(" Code graph: indexed (tokensave)") + elif verbose: + click.echo(f" Code graph: tokensave {subcommand} failed ({result.stderr[:100]})") + except subprocess.TimeoutExpired: + click.echo(" Code graph: tokensave indexing timed out (will complete on demand)") + except Exception as e: + if verbose: + click.echo(f" Code graph: tokensave indexing skipped ({e})") + + +def _setup_tokensave_mcp(registrar: Any, *, verbose: bool = False, force: bool = False) -> bool: + """Register tokensave MCP with the given agent (idempotent). + + Returns ``True`` when tokensave is available and set up, ``False`` when the + binary is unavailable — the caller then falls back to Serena. Mirrors + :func:`_setup_serena_mcp`'s ledger-aware migration: a stale + Headroom-installed ``tokensave`` entry is force-updated to the current + spec, while a user-managed entry is left untouched. + """ + from headroom.mcp_registry import build_tokensave_spec, format_result + from headroom.mcp_registry.base import RegisterStatus + from headroom.mcp_registry.ledger import headroom_installed_matching, record_install + + if not registrar.detect(): + if verbose: + click.echo(f" tokensave MCP: {registrar.display_name} not detected — skipping") + return False + + bin_path = _ensure_tokensave_binary(verbose=verbose) + if bin_path is None: + return False + + # Warm the graph so the first query is instant (non-fatal). + _index_tokensave_project(bin_path, verbose=verbose) + + spec = build_tokensave_spec(str(bin_path)) + result = registrar.register_server(spec, force=force) + + # Migrate a stale Headroom-installed entry (e.g. an older binary path or + # pinned version), mirroring the Serena migration path. Only force-update + # when the ledger proves Headroom installed the entry on disk. + if ( + result.status == RegisterStatus.MISMATCH + and not force + and headroom_installed_matching(registrar.name, registrar.get_server("tokensave")) + ): + result = registrar.register_server(spec, force=True) + if result.status == RegisterStatus.REGISTERED: + click.echo(" tokensave MCP: migrated previously-installed entry to current spec") + + if result.status == RegisterStatus.REGISTERED: + record_install(registrar.name, spec) + + line = format_result( + registrar.name, + result, + label="tokensave MCP", + verbose=verbose, + overwrite_hint="update or remove the existing tokensave MCP entry, then rerun headroom wrap", + restart_hint=f"restart {registrar.display_name} if it was already running", + ) + if line is not None: + click.echo(line) + return True + + +def _remove_headroom_installed_tokensave_mcp(registrar: Any) -> str: + """Remove the tokensave MCP entry only if the ledger proves Headroom installed it.""" + from headroom.mcp_registry.ledger import clear_install, headroom_installed_matching + + current = registrar.get_server("tokensave") + if not headroom_installed_matching(registrar.name, current): + return "not_headroom_owned" + if registrar.unregister_server("tokensave"): + clear_install(registrar.name, "tokensave") + return "removed" + return "failed" + + +def _disable_tokensave_mcp(registrar: Any, *, verbose: bool = False) -> None: + """Make ``--no-tokensave`` actively remove a Headroom-installed tokensave entry.""" + if not registrar.detect(): + if verbose: + click.echo(f" tokensave MCP: {registrar.display_name} not detected — skipping") + return + + if registrar.get_server("tokensave") is None: + if verbose: + click.echo(" Skipping tokensave MCP (--no-tokensave)") + return + + status = _remove_headroom_installed_tokensave_mcp(registrar) + if status == "removed": + click.echo(" Removed previously-installed tokensave MCP (--no-tokensave)") + click.echo(f" restart {registrar.display_name} if it was already running") + elif status == "not_headroom_owned": + click.echo( + " tokensave MCP is present but user-managed — leaving it in place " + "(--no-tokensave only removes entries Headroom installed)" + ) + else: # "failed" + click.echo( + " tokensave MCP: removal failed — remove the 'tokensave' entry " + "from your MCP config manually" + ) + + +def _setup_coding_compressor(registrar: Any, *, serena_context: str, **kwargs: Any) -> None: + """Set up the coding-task compressor: tokensave primary, Serena backup. + + Policy (decided per the integration): + + * ``no_tokensave`` — skip/disable tokensave entirely. + * tokensave is set up by default; on success it becomes the primary + compressor and any Headroom-installed Serena entry is removed. + * Serena is the backup: registered automatically when tokensave is + unavailable (unless ``no_serena``), or forced on with ``serena=True``. + + ``kwargs`` carries the boolean flags ``serena``, ``no_serena``, + ``no_tokensave`` and the per-agent registrar ``force`` semantics. + """ + serena = bool(kwargs.get("serena")) + no_serena = bool(kwargs.get("no_serena")) + no_tokensave = bool(kwargs.get("no_tokensave")) + force = bool(kwargs.get("force")) + verbose = bool(kwargs.get("verbose")) + + tokensave_ok = False + if no_tokensave: + _disable_tokensave_mcp(registrar, verbose=verbose) + else: + tokensave_ok = _setup_tokensave_mcp(registrar, verbose=verbose, force=force) + + if serena or (not tokensave_ok and not no_serena): + _setup_serena_mcp(registrar, context=serena_context, verbose=verbose, force=force) + else: + # tokensave is primary (or Serena was explicitly disabled): drop any + # Serena entry a prior wrap installed; user-managed entries are kept. + reason = ( + "--no-serena" if no_serena else "tokensave is now the primary code-graph compressor" + ) + _disable_serena_mcp(registrar, verbose=verbose, reason=reason) + + _CBM_MCP_SERVER_NAME = "codebase-memory-mcp" -def _register_cbm_mcp_server(cbm_bin: str) -> None: - """Register codebase-memory-mcp as an MCP server in Claude Code. - - Uses ``claude mcp add`` so the tools appear in ``/mcp`` automatically. - Idempotent — skips if already registered. - """ - claude_cli = shutil.which("claude") - if not claude_cli: - return - - # Check if already registered - check = run( - [claude_cli, "mcp", "get", _CBM_MCP_SERVER_NAME], - capture_output=True, - text=True, - ) - if check.returncode == 0: - return # Already registered - - result = run( - [claude_cli, "mcp", "add", _CBM_MCP_SERVER_NAME, "-s", "user", "--", cbm_bin], - capture_output=True, - text=True, - ) - if result.returncode == 0: - click.echo(f" Code graph: registered {_CBM_MCP_SERVER_NAME} MCP server") - else: - pass # Non-critical — tools won't appear in /mcp but graph still works - - def _setup_code_graph(verbose: bool = False) -> bool: - """Ensure codebase-memory-mcp is installed, registered as MCP server, and project is indexed. + """Ensure the tokensave code graph is set up and the project indexed. - codebase-memory-mcp builds a knowledge graph of the codebase using - tree-sitter, enabling the LLM to query code structure (call chains, - function definitions, impact analysis) instead of reading entire files. + tokensave is Headroom's primary code-graph compressor and is normally + installed by default (it builds a semantic knowledge graph the LLM can + query for call chains, definitions, and impact analysis instead of + reading whole files). ``--code-graph`` is kept for backward compatibility + and as an explicit "set up the graph and force an index now" switch, even + when tokensave registration was otherwise skipped. - Steps: - 1. Download the binary if not already present. - 2. Register as an MCP server in Claude Code (``claude mcp add``). - 3. Index the current project (fast, idempotent). - - With Claude Code's MCP Tool Search, the 14 graph tools add ~200 tokens - overhead per request (not the full ~1,915) — they're lazy-loaded. - - Returns True if graph is ready, False if setup failed. + Returns True if the graph is ready, False if tokensave is unavailable. + Earlier releases backed this flag with ``codebase-memory-mcp``; that + server is no longer installed, and ``headroom unwrap`` still cleans up any + legacy ``codebase-memory-mcp`` entry a prior wrap left behind. """ - from headroom.graph.installer import ensure_cbm, get_cbm_path + from headroom.mcp_registry import ClaudeRegistrar - cbm_path = get_cbm_path() - if not cbm_path: - click.echo(" Code graph: downloading codebase-memory-mcp...") - cbm_path = ensure_cbm() - if cbm_path: - click.echo(f" Code graph: installed at {cbm_path}") - else: - click.echo(" Code graph: download failed — skipping") - return False - - cbm_bin = str(cbm_path) - - # Register as MCP server so tools appear in /mcp - _register_cbm_mcp_server(cbm_bin) - - # Index current project (fast — ~1s for most repos, idempotent) - project_dir = str(Path.cwd()) - try: - result = run( - [ - cbm_bin, - "cli", - "index_repository", - json.dumps({"repo_path": project_dir, "mode": "fast"}), - ], - capture_output=True, - text=True, - timeout=30, - ) - if result.returncode == 0: - # Parse node/edge counts from output - for line in result.stdout.splitlines(): - if '"nodes"' in line: - try: - # Parse the JSON response to extract node/edge counts - import re - - m_nodes = re.search(r'"nodes":(\d+)', line) - m_edges = re.search(r'"edges":(\d+)', line) - if m_nodes and m_edges: - nodes = int(m_nodes.group(1)) - edges = int(m_edges.group(1)) - click.echo( - f" Code graph: indexed ({nodes:,} symbols, " - f"{edges:,} relationships)" - ) - else: - click.echo(" Code graph: indexed") - except (ValueError, AttributeError): - click.echo(" Code graph: indexed") - return True - click.echo(" Code graph: indexed") - return True - else: - if verbose: - click.echo(f" Code graph: indexing failed ({result.stderr[:100]})") - return False - except subprocess.TimeoutExpired: - click.echo(" Code graph: indexing timed out (will complete in background)") - return False - except Exception as e: - if verbose: - click.echo(f" Code graph: setup failed ({e})") - return False + return _setup_tokensave_mcp(ClaudeRegistrar(), verbose=verbose, force=True) # rtk instructions for tools without hook support (Codex, Cursor, Aider). @@ -3073,11 +3173,22 @@ def unwrap() -> None: is_flag=True, help="Skip headroom MCP server registration (compression markers will be unactionable)", ) -@click.option("--no-serena", is_flag=True, help="Skip Serena MCP server registration") +@click.option( + "--no-tokensave", + is_flag=True, + help="Skip the tokensave code-graph MCP server (primary coding-task compressor)", +) +@click.option( + "--serena", + is_flag=True, + help="Force the Serena MCP backup compressor on (registered automatically when " + "tokensave is unavailable)", +) +@click.option("--no-serena", is_flag=True, help="Never register the Serena backup compressor") @click.option( "--code-graph", is_flag=True, - help="Enable code graph indexing via codebase-memory-mcp (optional)", + help="Force a tokensave code-graph index now (tokensave is the default compressor)", ) @click.option("--no-proxy", is_flag=True, help="Skip proxy startup (use existing proxy)") @click.option( @@ -3127,6 +3238,8 @@ def claude( port: int, no_rtk: bool, no_mcp: bool, + no_tokensave: bool, + serena: bool, no_serena: bool, code_graph: bool, no_proxy: bool, @@ -3152,10 +3265,12 @@ def claude( headroom wrap claude --memory # With persistent memory headroom wrap claude --resume # Resume a session headroom wrap claude -- -p # Claude in print mode - headroom wrap claude --code-graph # With code graph intelligence + headroom wrap claude # tokensave code graph (primary) + headroom wrap claude --no-tokensave # Skip tokensave; fall back to Serena + headroom wrap claude --serena # Also register the Serena backup headroom wrap claude --no-context-tool # Skip CLI context-tool setup headroom wrap claude --no-mcp # Skip MCP retrieve tool registration - headroom wrap claude --no-serena # Skip Serena MCP registration + headroom wrap claude --no-serena # Never register the Serena backup headroom wrap claude --1m # Preserve the 1M context window """ if prepare_only: @@ -3285,14 +3400,17 @@ def claude( elif verbose: click.echo(" Skipping MCP retrieve tool (--no-mcp)") - if not no_serena: - from headroom.mcp_registry import ClaudeRegistrar + # Coding-task compressor: tokensave primary, Serena backup. + from headroom.mcp_registry import ClaudeRegistrar - _setup_serena_mcp(ClaudeRegistrar(), context="claude-code", verbose=verbose) - else: - from headroom.mcp_registry import ClaudeRegistrar - - _disable_serena_mcp(ClaudeRegistrar(), verbose=verbose) + _setup_coding_compressor( + ClaudeRegistrar(), + serena_context="claude-code", + serena=serena, + no_serena=no_serena, + no_tokensave=no_tokensave, + verbose=verbose, + ) if code_graph: _setup_code_graph(verbose=verbose) @@ -3410,13 +3528,20 @@ def unwrap_claude( if registrar.detect(): removed_headroom = registrar.unregister_server("headroom") removed_code_graph = registrar.unregister_server(_CBM_MCP_SERVER_NAME) + tokensave_status = _remove_headroom_installed_tokensave_mcp(registrar) serena_status = _remove_headroom_installed_serena_mcp(registrar) if removed_headroom: click.echo(" Removed Headroom MCP retrieve tool from Claude.") else: click.echo(" Headroom MCP retrieve tool was not registered in Claude.") if removed_code_graph: - click.echo(" Removed code graph MCP server from Claude.") + click.echo(" Removed legacy codebase-memory-mcp code graph server from Claude.") + if tokensave_status == "removed": + click.echo(" Removed Headroom-installed tokensave MCP server from Claude.") + elif tokensave_status == "failed": + click.echo( + " tokensave MCP server matched Headroom ledger but could not be removed." + ) if serena_status == "removed": click.echo(" Removed Headroom-installed Serena MCP server from Claude.") elif serena_status == "failed": @@ -3770,11 +3895,22 @@ def unwrap_copilot(port: int, no_stop_proxy: bool) -> None: is_flag=True, help="Skip headroom MCP server registration (compression markers will be unactionable)", ) -@click.option("--no-serena", is_flag=True, help="Skip Serena MCP server registration") +@click.option( + "--no-tokensave", + is_flag=True, + help="Skip the tokensave code-graph MCP server (primary coding-task compressor)", +) +@click.option( + "--serena", + is_flag=True, + help="Force the Serena MCP backup compressor on (registered automatically when " + "tokensave is unavailable)", +) +@click.option("--no-serena", is_flag=True, help="Never register the Serena backup compressor") @click.option( "--code-graph", is_flag=True, - help="Enable code graph indexing via codebase-memory-mcp (optional)", + help="Force a tokensave code-graph index now (tokensave is the default compressor)", ) @click.option("--no-proxy", is_flag=True, help="Skip proxy startup (use existing proxy)") @click.option( @@ -3801,6 +3937,8 @@ def codex( port: int, no_rtk: bool, no_mcp: bool, + no_tokensave: bool, + serena: bool, no_serena: bool, code_graph: bool, no_proxy: bool, @@ -3828,7 +3966,9 @@ def codex( headroom wrap codex -- "fix the bug" # Pass prompt to codex headroom wrap codex --no-context-tool # Skip CLI context-tool setup headroom wrap codex --no-mcp # Skip MCP retrieve tool registration - headroom wrap codex --no-serena # Skip Serena MCP registration + headroom wrap codex --no-tokensave # Skip tokensave; fall back to Serena + headroom wrap codex --serena # Also register the Serena backup + headroom wrap codex --no-serena # Never register the Serena backup headroom wrap codex --port 9999 # Custom proxy port headroom wrap codex --backend anyllm --anyllm-provider groq """ @@ -3866,14 +4006,19 @@ def codex( elif verbose: click.echo(" Skipping MCP retrieve tool (--no-mcp)") - if not no_serena: - from headroom.mcp_registry import CodexRegistrar + # Coding-task compressor: tokensave primary, Serena backup. Codex starts + # long-lived MCP subprocesses from config.toml, so force re-registration. + from headroom.mcp_registry import CodexRegistrar - _setup_serena_mcp(CodexRegistrar(), context="codex", verbose=verbose, force=True) - else: - from headroom.mcp_registry import CodexRegistrar - - _disable_serena_mcp(CodexRegistrar(), verbose=verbose) + _setup_coding_compressor( + CodexRegistrar(), + serena_context="codex", + serena=serena, + no_serena=no_serena, + no_tokensave=no_tokensave, + verbose=verbose, + force=True, + ) # Setup memory MCP server for Codex (native tool integration) if memory: @@ -5317,15 +5462,21 @@ def unwrap_codex(port: int, no_stop_proxy: bool) -> None: ) click.echo(f" Nothing to undo: {config_file} has no Headroom wrap markers.") - # Serena is written as its own [mcp_servers.serena] table with Headroom - # markers, separate from the provider block handled above — a "cleaned" - # restore leaves it behind. Remove it explicitly (only if we installed it), - # mirroring unwrap_claude. Runs after the restore so a backup-restore that - # already dropped Serena makes this a safe no-op. + # tokensave and Serena are each written as their own [mcp_servers.] + # table with Headroom markers, separate from the provider block handled + # above — a "cleaned" restore leaves them behind. Remove them explicitly + # (only if we installed them), mirroring unwrap_claude. Runs after the + # restore so a backup-restore that already dropped them is a safe no-op. from headroom.mcp_registry import CodexRegistrar codex_registrar = CodexRegistrar() if codex_registrar.detect(): + tokensave_status = _remove_headroom_installed_tokensave_mcp(codex_registrar) + if tokensave_status == "removed": + click.echo(" Removed Headroom-installed tokensave MCP server from Codex.") + elif tokensave_status == "failed": + click.echo(" tokensave MCP server matched Headroom ledger but could not be removed.") + serena_status = _remove_headroom_installed_serena_mcp(codex_registrar) if serena_status == "removed": click.echo(" Removed Headroom-installed Serena MCP server from Codex.") diff --git a/headroom/graph/tokensave_installer.py b/headroom/graph/tokensave_installer.py new file mode 100644 index 000000000..26ea2c99f --- /dev/null +++ b/headroom/graph/tokensave_installer.py @@ -0,0 +1,256 @@ +"""Download and install the ``tokensave`` binary from GitHub releases. + +tokensave (https://github.com/aovestdipaperino/tokensave) is the primary +coding-task compressor: a local semantic code-graph MCP server. It is a +single self-contained Rust binary, so — like ``codebase-memory-mcp`` and +``rtk`` — Headroom fetches the prebuilt release asset for the current +platform, caches it under ``~/.local/bin``, and registers it as an MCP +server. + +Release-binary only. tokensave is also published to crates.io +(``cargo install tokensave``), but we never shell out to cargo here: a +multi-minute compile is the wrong thing to trigger from ``headroom wrap``. +When no prebuilt asset exists for the platform (e.g. x86_64 macOS, which +tokensave does not currently publish) or the download fails, this module +returns ``None`` and the caller falls back to Serena, the backup compressor. + +Supply-chain integrity: + Because ``headroom wrap`` downloads and then *executes* this binary by + default, every release asset is pinned to a SHA-256 digest in + ``TOKENSAVE_ASSET_DIGESTS`` below. The downloaded bytes are verified + against the pinned digest before the archive is unpacked; a mismatch + aborts the install (→ Serena fallback) rather than running unverified + code. When ``HEADROOM_TOKENSAVE_VERSION`` overrides the pinned tag there + is no pinned digest, so the download is refused unless the operator + explicitly opts out of verification via + ``HEADROOM_TOKENSAVE_ALLOW_UNVERIFIED=1``. + +Env vars: + HEADROOM_BINARIES_OFFLINE if set, never reach the network (returns + the already-installed binary or ``None``). + HEADROOM_TOKENSAVE_VERSION override the pinned release tag. + HEADROOM_TOKENSAVE_ALLOW_UNVERIFIED permit installing an asset that has + no pinned digest (only relevant when the + version is overridden). +""" + +from __future__ import annotations + +import hashlib +import io +import logging +import os +import platform +import stat +import tarfile +import zipfile +from pathlib import Path +from urllib.request import urlopen + +logger = logging.getLogger(__name__) + +#: Pinned release. Override with HEADROOM_TOKENSAVE_VERSION. +TOKENSAVE_VERSION = "v7.0.2" +TOKENSAVE_REPO = "aovestdipaperino/tokensave" +TOKENSAVE_BIN_DIR = Path.home() / ".local" / "bin" +TOKENSAVE_BIN_NAME = "tokensave" + +GITHUB_RELEASE_URL = f"https://github.com/{TOKENSAVE_REPO}/releases/download" + +#: SHA-256 of each pinned release asset, keyed by asset filename. The binary +#: is downloaded and executed by default, so its bytes are verified against +#: this map before extraction. Regenerate when bumping TOKENSAVE_VERSION: +#: for f in ; do curl -sL /$f | shasum -a 256; done +TOKENSAVE_ASSET_DIGESTS: dict[str, str] = { + "tokensave-v7.0.2-aarch64-macos.tar.gz": ( + "6d0e07aba5b63df278409feabea54bdd0da82ec63d633cd975ea353773c4efee" + ), + "tokensave-v7.0.2-aarch64-linux.tar.gz": ( + "69c88d0617036d44f2620f5779cd8578fad77664c2373d64de632b8e346ad334" + ), + "tokensave-v7.0.2-x86_64-linux.tar.gz": ( + "d35519fe698a24d2e2bb5622e94b3bdb4794dc1e36acffc980260b50afb40460" + ), + "tokensave-v7.0.2-x86_64-windows.zip": ( + "85f90d358c5f4713b5ac7274f4fa46e985fabc5b76c843ea8456b0d74e1cdd02" + ), + "tokensave-v7.0.2-aarch64-windows.zip": ( + "8706d0d64f429ba7fe58deec9fef319956306797bded476cab4132e71705e8b0" + ), +} + + +def _pinned_version() -> str: + return os.environ.get("HEADROOM_TOKENSAVE_VERSION", "").strip() or TOKENSAVE_VERSION + + +def _detect_asset(version: str) -> tuple[str, str] | None: + """Return ``(asset_filename, archive_kind)`` for this platform. + + ``archive_kind`` is ``"tar.gz"`` or ``"zip"``. Returns ``None`` when + tokensave publishes no prebuilt asset for the current platform (the + caller then falls back to Serena). Release assets are named + ``tokensave---.``. + """ + system = platform.system().lower() + machine = platform.machine().lower() + + if system == "darwin": + if machine == "arm64": + return f"tokensave-{version}-aarch64-macos.tar.gz", "tar.gz" + # No x86_64-macos release asset is published — fall back to Serena. + return None + if system == "linux": + arch = "aarch64" if machine in ("aarch64", "arm64") else "x86_64" + return f"tokensave-{version}-{arch}-linux.tar.gz", "tar.gz" + if system == "windows": + arch = "aarch64" if machine in ("aarch64", "arm64") else "x86_64" + return f"tokensave-{version}-{arch}-windows.zip", "zip" + + return None + + +def _verify_asset_digest(filename: str, data: bytes) -> None: + """Verify downloaded bytes against the pinned SHA-256 digest. + + Raises ``RuntimeError`` on a digest mismatch, or when the asset has no + pinned digest (i.e. a version override) unless the operator has set + ``HEADROOM_TOKENSAVE_ALLOW_UNVERIFIED``. + """ + expected = TOKENSAVE_ASSET_DIGESTS.get(filename) + if expected is None: + if os.environ.get("HEADROOM_TOKENSAVE_ALLOW_UNVERIFIED"): + logger.warning( + "tokensave asset %s has no pinned digest; installing unverified " + "(HEADROOM_TOKENSAVE_ALLOW_UNVERIFIED is set)", + filename, + ) + return + raise RuntimeError( + f"no pinned SHA-256 digest for tokensave asset {filename!r}; refusing to " + "install unverified. Set HEADROOM_TOKENSAVE_ALLOW_UNVERIFIED=1 to override." + ) + actual = hashlib.sha256(data).hexdigest() + if actual != expected: + raise RuntimeError( + f"tokensave asset {filename!r} failed integrity check: " + f"expected sha256 {expected}, got {actual}" + ) + logger.debug("Verified tokensave asset %s (sha256 %s)", filename, actual) + + +def get_tokensave_path() -> Path | None: + """Find the tokensave binary on PATH or in our install dir; else ``None``.""" + import shutil + + found = shutil.which(TOKENSAVE_BIN_NAME) + if found: + return Path(found) + + for name in (TOKENSAVE_BIN_NAME, f"{TOKENSAVE_BIN_NAME}.exe"): + installed = TOKENSAVE_BIN_DIR / name + if installed.exists() and installed.is_file(): + return installed + + return None + + +def download_tokensave(version: str | None = None) -> Path: + """Download and unpack the tokensave release binary. Returns its path. + + Raises ``RuntimeError`` when no asset exists for this platform, or when + the download / extraction / verification fails. + """ + version = version or _pinned_version() + asset = _detect_asset(version) + if asset is None: + raise RuntimeError( + f"no prebuilt tokensave asset for {platform.system()} {platform.machine()}" + ) + filename, kind = asset + url = f"{GITHUB_RELEASE_URL}/{version}/{filename}" + + TOKENSAVE_BIN_DIR.mkdir(parents=True, exist_ok=True) + bin_name = f"{TOKENSAVE_BIN_NAME}.exe" if kind == "zip" else TOKENSAVE_BIN_NAME + target_path = TOKENSAVE_BIN_DIR / bin_name + + logger.info("Downloading tokensave %s for %s ...", version, filename) + + try: + if not url.startswith(("http://", "https://")): + raise ValueError(f"Invalid URL: {url}") + with urlopen(url, timeout=60) as response: # noqa: S310 + data = response.read() + except Exception as e: + raise RuntimeError(f"Failed to download tokensave from {url}: {e}") from e + + _verify_asset_digest(filename, data) + + try: + if kind == "tar.gz": + with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar: + for member in tar.getmembers(): + if member.name == TOKENSAVE_BIN_NAME or member.name.endswith( + f"/{TOKENSAVE_BIN_NAME}" + ): + member.name = target_path.name + tar.extract(member, TOKENSAVE_BIN_DIR) + break + else: + raise RuntimeError("tokensave binary not found in archive") + else: # zip + with zipfile.ZipFile(io.BytesIO(data)) as zf: + for name in zf.namelist(): + if name.endswith(f"{TOKENSAVE_BIN_NAME}.exe") or name.endswith( + f"/{TOKENSAVE_BIN_NAME}" + ): + with zf.open(name) as src, open(target_path, "wb") as dst: + dst.write(src.read()) + break + else: + raise RuntimeError("tokensave binary not found in archive") + except (tarfile.TarError, zipfile.BadZipFile) as e: + raise RuntimeError(f"Failed to extract tokensave archive: {e}") from e + + if kind != "zip": + target_path.chmod(target_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + + try: + from headroom._subprocess import run as _run + + result = _run( + [str(target_path), "--version"], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0: + logger.info("Installed tokensave: %s", result.stdout.strip()) + else: + logger.warning("tokensave installed but version check failed") + except Exception: + pass + + return target_path + + +def ensure_tokensave(version: str | None = None) -> Path | None: + """Ensure tokensave is available, downloading the release binary if needed. + + Returns the binary path, or ``None`` when the binary is absent and cannot + be fetched (offline, unsupported platform, or download failure). Callers + treat ``None`` as "tokensave unavailable → fall back to Serena". + """ + existing = get_tokensave_path() + if existing: + return existing + + if os.environ.get("HEADROOM_BINARIES_OFFLINE"): + logger.info("tokensave not installed and HEADROOM_BINARIES_OFFLINE set — skipping download") + return None + + try: + return download_tokensave(version) + except RuntimeError as e: + logger.warning("Could not install tokensave: %s", e) + return None diff --git a/headroom/integrations/strands/__init__.py b/headroom/integrations/strands/__init__.py index 46088ef52..4e0bd27e9 100644 --- a/headroom/integrations/strands/__init__.py +++ b/headroom/integrations/strands/__init__.py @@ -90,6 +90,6 @@ __all__ = [ # Provider detection "get_headroom_provider", "get_model_name_from_strands", - # One-helper MCP + hook wiring (Headroom + Serena + RTK-equivalent) + # One-helper MCP + hook wiring (Headroom + tokensave/Serena + RTK-equivalent) "HeadroomBundle", ] diff --git a/headroom/integrations/strands/bundle.py b/headroom/integrations/strands/bundle.py index 1bfa292f6..be478741f 100644 --- a/headroom/integrations/strands/bundle.py +++ b/headroom/integrations/strands/bundle.py @@ -11,8 +11,14 @@ Strands-native primitives: needs the original; Strands' MCP dispatcher resolves it via this server. Works identically in streaming and non-streaming. -* **Serena MCP** — semantic code intelligence (symbol search, - references, etc.). Auto-installed via ``uvx`` on first launch. +* **tokensave MCP** — the primary coding-task compressor: a local + semantic code-graph server (``tokensave serve``) the agent queries + for symbols, call chains, and impact analysis instead of reading + whole files. Requires the ``tokensave`` binary on PATH. + +* **Serena MCP** — the backup coding-task compressor (symbol search, + references, etc.), auto-installed via ``uvx`` on first launch. + Off by default; enable with ``enable_serena_mcp=True``. * **HeadroomHookProvider** — the RTK-equivalent for Strands. Compresses tool outputs in-place via ``AfterToolCallEvent`` so @@ -77,6 +83,7 @@ from headroom.mcp_registry.install import ( DEFAULT_PROXY_URL, build_headroom_spec, build_serena_spec, + build_tokensave_spec, ) from .hooks import HeadroomHookProvider @@ -89,8 +96,7 @@ logger = logging.getLogger(__name__) DEFAULT_SERENA_CONTEXT = "ide-assistant" -def _make_headroom_client(proxy_url: str) -> MCPClient: - spec = build_headroom_spec(proxy_url) +def _client_for(spec: Any) -> MCPClient: params = StdioServerParameters( command=spec.command, args=list(spec.args), @@ -100,14 +106,16 @@ def _make_headroom_client(proxy_url: str) -> MCPClient: return MCPClient(partial(stdio_client, params)) +def _make_headroom_client(proxy_url: str) -> MCPClient: + return _client_for(build_headroom_spec(proxy_url)) + + +def _make_tokensave_client() -> MCPClient: + return _client_for(build_tokensave_spec()) + + def _make_serena_client(context: str) -> MCPClient: - spec = build_serena_spec(context) - params = StdioServerParameters( - command=spec.command, - args=list(spec.args), - env=dict(spec.env) if spec.env else None, - ) - return MCPClient(partial(stdio_client, params)) + return _client_for(build_serena_spec(context)) @dataclass @@ -120,8 +128,12 @@ class HeadroomBundle: (``http://127.0.0.1:8787``). serena_context: Serena context label. Default ``"ide-assistant"``. enable_headroom_mcp: Include the Headroom MCP server. Default True. - enable_serena_mcp: Include the Serena MCP server. Default True. - Disabling skips the ``uvx`` first-launch download entirely. + enable_tokensave_mcp: Include the tokensave MCP server — the primary + coding-task compressor. Default True. Requires the ``tokensave`` + binary on PATH (``tokensave serve``). + enable_serena_mcp: Include the Serena MCP server — the backup + coding-task compressor. Default False (tokensave is primary). + Enabling adds the ``uvx`` first-launch download. enable_hooks: Include :class:`HeadroomHookProvider` for in-place tool-output compression (the RTK-equivalent for Strands). Default True. @@ -138,7 +150,10 @@ class HeadroomBundle: proxy_url: str = DEFAULT_PROXY_URL serena_context: str = DEFAULT_SERENA_CONTEXT enable_headroom_mcp: bool = True - enable_serena_mcp: bool = True + # tokensave is the primary coding-task compressor; Serena is the backup + # and stays off unless explicitly enabled. + enable_tokensave_mcp: bool = True + enable_serena_mcp: bool = False # The proxy is the single source of truth for compression — it sees # the full message list, owns CompressionPolicy, owns PrefixCacheTracker, # and places `cache_control` breakpoints. The in-process hook @@ -151,6 +166,7 @@ class HeadroomBundle: config: HeadroomConfig | None = None _headroom_mcp: MCPClient | None = field(default=None, init=False, repr=False, compare=False) + _tokensave_mcp: MCPClient | None = field(default=None, init=False, repr=False, compare=False) _serena_mcp: MCPClient | None = field(default=None, init=False, repr=False, compare=False) _hook: HeadroomHookProvider | None = field(default=None, init=False, repr=False, compare=False) @@ -161,10 +177,13 @@ class HeadroomBundle: "HeadroomBundle: Headroom MCP client constructed (proxy_url=%s)", self.proxy_url, ) + if self.enable_tokensave_mcp: + self._tokensave_mcp = _make_tokensave_client() + logger.info("HeadroomBundle: tokensave MCP client constructed (primary)") if self.enable_serena_mcp: self._serena_mcp = _make_serena_client(self.serena_context) logger.info( - "HeadroomBundle: Serena MCP client constructed (context=%s)", + "HeadroomBundle: Serena MCP client constructed (backup, context=%s)", self.serena_context, ) if self.enable_hooks: @@ -181,6 +200,8 @@ class HeadroomBundle: out: list[Any] = [] if self._headroom_mcp is not None: out.append(self._headroom_mcp) + if self._tokensave_mcp is not None: + out.append(self._tokensave_mcp) if self._serena_mcp is not None: out.append(self._serena_mcp) return out @@ -195,6 +216,11 @@ class HeadroomBundle: """Direct handle to the Headroom MCPClient (for advanced callers).""" return self._headroom_mcp + @property + def tokensave_mcp(self) -> MCPClient | None: + """Direct handle to the tokensave MCPClient (for advanced callers).""" + return self._tokensave_mcp + @property def serena_mcp(self) -> MCPClient | None: """Direct handle to the Serena MCPClient (for advanced callers).""" diff --git a/headroom/mcp_registry/__init__.py b/headroom/mcp_registry/__init__.py index 50dd9f563..7fbcf7f3d 100644 --- a/headroom/mcp_registry/__init__.py +++ b/headroom/mcp_registry/__init__.py @@ -21,6 +21,7 @@ from .install import ( DEFAULT_PROXY_URL, build_headroom_spec, build_serena_spec, + build_tokensave_spec, get_all_registrars, install_everywhere, ) @@ -38,6 +39,7 @@ __all__ = [ "any_succeeded", "build_headroom_spec", "build_serena_spec", + "build_tokensave_spec", "format_result", "format_results", "get_all_registrars", diff --git a/headroom/mcp_registry/install.py b/headroom/mcp_registry/install.py index 86127c190..89b1b7d03 100644 --- a/headroom/mcp_registry/install.py +++ b/headroom/mcp_registry/install.py @@ -68,6 +68,22 @@ def build_serena_spec(context: str) -> ServerSpec: ) +def build_tokensave_spec(binary: str = "tokensave") -> ServerSpec: + """Construct the canonical tokensave MCP server spec. + + tokensave (https://github.com/aovestdipaperino/tokensave) is the primary + coding-task compressor — a local semantic code-graph server launched as + ``tokensave serve`` over stdio. ``binary`` is the command the agent runs; + pass an absolute path when tokensave was fetched to ``~/.local/bin`` and + is not on the agent's PATH, or leave the default when it is on PATH. + """ + return ServerSpec( + name="tokensave", + command=binary, + args=("serve",), + ) + + def install_everywhere( proxy_url: str = DEFAULT_PROXY_URL, *, diff --git a/tests/test_cli/conftest.py b/tests/test_cli/conftest.py new file mode 100644 index 000000000..7a50da4a0 --- /dev/null +++ b/tests/test_cli/conftest.py @@ -0,0 +1,20 @@ +"""Shared fixtures for the CLI test suite. + +tokensave is now the primary coding-task compressor, so a default +``headroom wrap`` tries to fetch the tokensave release binary. Force offline +across CLI tests so a missing binary resolves to ``None`` (→ Serena fallback) +instead of reaching out to GitHub releases. Tests that exercise the +tokensave-present path patch ``_ensure_tokensave_binary`` / ``ensure_tokensave`` +directly and are unaffected by this guard. This env only gates the new +tokensave installer (``headroom.graph.tokensave_installer``); rtk and +codebase-memory-mcp installers do not read it. +""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture(autouse=True) +def _tokensave_offline(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HEADROOM_BINARIES_OFFLINE", "1") diff --git a/tests/test_cli/test_tokensave_helpers.py b/tests/test_cli/test_tokensave_helpers.py new file mode 100644 index 000000000..87f642438 --- /dev/null +++ b/tests/test_cli/test_tokensave_helpers.py @@ -0,0 +1,112 @@ +"""Coverage for the tokensave binary-resolution and indexing helpers.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from headroom.cli import wrap as wrap_cli +from headroom.graph import tokensave_installer as ts + +_FAKE_BIN = Path("/usr/local/bin/tokensave") + + +# --------------------------------------------------------------------------- +# _ensure_tokensave_binary +# --------------------------------------------------------------------------- + + +def test_ensure_binary_returns_existing_without_fetch(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(ts, "get_tokensave_path", lambda: _FAKE_BIN) + + def _should_not_run(*a, **k): + raise AssertionError("must not download when binary already present") + + monkeypatch.setattr(ts, "ensure_tokensave", _should_not_run) + assert wrap_cli._ensure_tokensave_binary() == _FAKE_BIN + + +def test_ensure_binary_fetches_when_absent( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr(ts, "get_tokensave_path", lambda: None) + monkeypatch.setattr(ts, "ensure_tokensave", lambda: _FAKE_BIN) + assert wrap_cli._ensure_tokensave_binary() == _FAKE_BIN + assert "installed at" in capsys.readouterr().out + + +def test_ensure_binary_none_prints_fallback( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr(ts, "get_tokensave_path", lambda: None) + monkeypatch.setattr(ts, "ensure_tokensave", lambda: None) + assert wrap_cli._ensure_tokensave_binary() is None + assert "falling back to Serena" in capsys.readouterr().out + + +# --------------------------------------------------------------------------- +# _index_tokensave_project +# --------------------------------------------------------------------------- + + +def _patch_run(monkeypatch: pytest.MonkeyPatch, result): + calls: list[list[str]] = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + if isinstance(result, Exception): + raise result + return result + + monkeypatch.setattr(wrap_cli.subprocess, "run", fake_run) + return calls + + +def test_index_runs_init_when_no_db( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.chdir(tmp_path) + calls = _patch_run(monkeypatch, SimpleNamespace(returncode=0, stdout="", stderr="")) + wrap_cli._index_tokensave_project(_FAKE_BIN) + assert calls == [[str(_FAKE_BIN), "init"]] + assert "Code graph: indexed (tokensave)" in capsys.readouterr().out + + +def test_index_runs_sync_when_db_exists(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + (tmp_path / ".tokensave").mkdir() + monkeypatch.chdir(tmp_path) + calls = _patch_run(monkeypatch, SimpleNamespace(returncode=0, stdout="", stderr="")) + wrap_cli._index_tokensave_project(_FAKE_BIN) + assert calls == [[str(_FAKE_BIN), "sync"]] + + +def test_index_nonzero_is_nonfatal( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.chdir(tmp_path) + _patch_run(monkeypatch, SimpleNamespace(returncode=1, stdout="", stderr="boom")) + wrap_cli._index_tokensave_project(_FAKE_BIN, verbose=True) + assert "init failed" in capsys.readouterr().out + + +def test_index_timeout_is_nonfatal( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + import subprocess + + monkeypatch.chdir(tmp_path) + _patch_run(monkeypatch, subprocess.TimeoutExpired(cmd="tokensave", timeout=60)) + wrap_cli._index_tokensave_project(_FAKE_BIN) + assert "timed out" in capsys.readouterr().out + + +def test_index_exception_is_nonfatal( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.chdir(tmp_path) + _patch_run(monkeypatch, FileNotFoundError("no binary")) + # Must not raise even when the binary is missing. + wrap_cli._index_tokensave_project(_FAKE_BIN, verbose=True) + assert "indexing skipped" in capsys.readouterr().out diff --git a/tests/test_cli/test_tokensave_setup.py b/tests/test_cli/test_tokensave_setup.py new file mode 100644 index 000000000..0ed0b3078 --- /dev/null +++ b/tests/test_cli/test_tokensave_setup.py @@ -0,0 +1,227 @@ +"""tokensave is the primary coding-task compressor; Serena is the backup. + +These tests pin the wrap-time policy in :func:`_setup_coding_compressor` and +the tokensave register/disable/migrate helpers, mirroring the Serena tests. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from headroom.cli import wrap as wrap_cli +from headroom.mcp_registry import build_tokensave_spec +from headroom.mcp_registry.base import RegisterResult, RegisterStatus, ServerSpec +from headroom.mcp_registry.ledger import headroom_installed_matching, record_install + +_FAKE_BIN = Path("/usr/local/bin/tokensave") + + +def _equivalent(a: ServerSpec, b: ServerSpec) -> bool: + return (a.command, tuple(a.args), dict(a.env)) == (b.command, tuple(b.args), dict(b.env)) + + +class _FakeRegistrar: + """Registrar mirroring real ``register_server`` overwrite semantics.""" + + def __init__(self, name: str = "claude", *, detected: bool = True, server=None): + self.name = name + self.display_name = name.capitalize() + self._detected = detected + self._server = server + self.force_calls: list[bool] = [] + self.unregistered: list[str] = [] + + def detect(self) -> bool: + return self._detected + + def get_server(self, server_name: str): + return self._server if server_name == "tokensave" else None + + def register_server(self, spec: ServerSpec, *, force: bool = False) -> RegisterResult: + self.force_calls.append(force) + if self._server is not None and not _equivalent(self._server, spec) and not force: + return RegisterResult(RegisterStatus.MISMATCH, "differs") + self._server = spec + return RegisterResult(RegisterStatus.REGISTERED, "ok") + + def unregister_server(self, server_name: str) -> bool: + self.unregistered.append(server_name) + self._server = None + return True + + +@pytest.fixture(autouse=True) +def _workspace(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("HEADROOM_WORKSPACE_DIR", str(tmp_path / ".headroom")) + # Never touch the network or run the real binary during these unit tests. + monkeypatch.setattr(wrap_cli, "_index_tokensave_project", lambda *a, **k: None) + + +# --------------------------------------------------------------------------- +# _setup_tokensave_mcp +# --------------------------------------------------------------------------- + + +def test_setup_registers_and_records_when_binary_available( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(wrap_cli, "_ensure_tokensave_binary", lambda verbose=False: _FAKE_BIN) + registrar = _FakeRegistrar() + + assert wrap_cli._setup_tokensave_mcp(registrar) is True + assert registrar._server is not None + assert registrar._server.name == "tokensave" + assert registrar._server.command == str(_FAKE_BIN) + # Ledger now proves Headroom owns the entry. + assert headroom_installed_matching("claude", registrar.get_server("tokensave")) + + +def test_setup_returns_false_when_binary_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(wrap_cli, "_ensure_tokensave_binary", lambda verbose=False: None) + registrar = _FakeRegistrar() + + assert wrap_cli._setup_tokensave_mcp(registrar) is False + assert registrar._server is None # nothing registered + + +def test_setup_skips_when_agent_not_detected(monkeypatch: pytest.MonkeyPatch) -> None: + sentinel = {"called": False} + + def _should_not_run(verbose=False): + sentinel["called"] = True + return _FAKE_BIN + + monkeypatch.setattr(wrap_cli, "_ensure_tokensave_binary", _should_not_run) + registrar = _FakeRegistrar(detected=False) + + assert wrap_cli._setup_tokensave_mcp(registrar) is False + assert sentinel["called"] is False # never even fetched the binary + + +def test_setup_migrates_stale_headroom_entry(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(wrap_cli, "_ensure_tokensave_binary", lambda verbose=False: _FAKE_BIN) + # A stale Headroom-installed entry (different binary path) is on disk. + stale = build_tokensave_spec("/old/path/tokensave") + record_install("claude", stale) + registrar = _FakeRegistrar(server=stale) + + assert wrap_cli._setup_tokensave_mcp(registrar) is True + # Force-updated to the current spec. + assert registrar.force_calls[-1] is True + assert registrar._server.command == str(_FAKE_BIN) + + +def test_setup_preserves_user_managed_mismatch(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(wrap_cli, "_ensure_tokensave_binary", lambda verbose=False: _FAKE_BIN) + # User-managed entry (NOT in ledger) that differs from our spec. + user = ServerSpec(name="tokensave", command="/custom/tokensave", args=("serve",)) + registrar = _FakeRegistrar(server=user) + + wrap_cli._setup_tokensave_mcp(registrar) + # Never force-overwrote a user-managed entry. + assert True not in registrar.force_calls + assert registrar._server.command == "/custom/tokensave" + + +# --------------------------------------------------------------------------- +# _disable_tokensave_mcp +# --------------------------------------------------------------------------- + + +def test_disable_removes_headroom_installed(capsys: pytest.CaptureFixture[str]) -> None: + spec = build_tokensave_spec(str(_FAKE_BIN)) + record_install("claude", spec) + registrar = _FakeRegistrar(server=spec) + + wrap_cli._disable_tokensave_mcp(registrar, verbose=True) + + assert registrar.unregistered == ["tokensave"] + assert "Removed previously-installed tokensave MCP" in capsys.readouterr().out + + +def test_disable_preserves_user_managed(capsys: pytest.CaptureFixture[str]) -> None: + user = ServerSpec(name="tokensave", command="/custom/tokensave") + registrar = _FakeRegistrar(server=user) + + wrap_cli._disable_tokensave_mcp(registrar, verbose=True) + + assert registrar.unregistered == [] + assert "user-managed" in capsys.readouterr().out + + +def test_disable_noop_when_absent(capsys: pytest.CaptureFixture[str]) -> None: + registrar = _FakeRegistrar(server=None) + wrap_cli._disable_tokensave_mcp(registrar, verbose=True) + assert registrar.unregistered == [] + assert "Skipping tokensave MCP" in capsys.readouterr().out + + +# --------------------------------------------------------------------------- +# _setup_coding_compressor — primary/backup policy +# --------------------------------------------------------------------------- + + +def _spy_compressor(monkeypatch: pytest.MonkeyPatch, *, tokensave_ok: bool) -> dict: + calls: dict[str, object] = {"serena_setup": False, "serena_disabled": None, "tokensave": None} + + def fake_setup_tokensave(reg, *, verbose=False, force=False): + calls["tokensave"] = "setup" + return tokensave_ok + + def fake_disable_tokensave(reg, *, verbose=False): + calls["tokensave"] = "disabled" + + def fake_setup_serena(reg, *, context, verbose=False, force=False): + calls["serena_setup"] = True + + def fake_disable_serena(reg, *, verbose=False, reason="--no-serena"): + calls["serena_disabled"] = reason + + monkeypatch.setattr(wrap_cli, "_setup_tokensave_mcp", fake_setup_tokensave) + monkeypatch.setattr(wrap_cli, "_disable_tokensave_mcp", fake_disable_tokensave) + monkeypatch.setattr(wrap_cli, "_setup_serena_mcp", fake_setup_serena) + monkeypatch.setattr(wrap_cli, "_disable_serena_mcp", fake_disable_serena) + return calls + + +def test_policy_tokensave_primary_disables_serena(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _spy_compressor(monkeypatch, tokensave_ok=True) + wrap_cli._setup_coding_compressor(_FakeRegistrar(), serena_context="claude-code") + assert calls["tokensave"] == "setup" + assert calls["serena_setup"] is False + assert calls["serena_disabled"] == "tokensave is now the primary code-graph compressor" + + +def test_policy_serena_fallback_when_tokensave_unavailable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = _spy_compressor(monkeypatch, tokensave_ok=False) + wrap_cli._setup_coding_compressor(_FakeRegistrar(), serena_context="claude-code") + assert calls["serena_setup"] is True + + +def test_policy_force_serena_even_when_tokensave_ok(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _spy_compressor(monkeypatch, tokensave_ok=True) + wrap_cli._setup_coding_compressor(_FakeRegistrar(), serena_context="claude-code", serena=True) + assert calls["serena_setup"] is True + + +def test_policy_no_serena_suppresses_fallback(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _spy_compressor(monkeypatch, tokensave_ok=False) + wrap_cli._setup_coding_compressor( + _FakeRegistrar(), serena_context="claude-code", no_serena=True + ) + assert calls["serena_setup"] is False + assert calls["serena_disabled"] == "--no-serena" + + +def test_policy_no_tokensave_disables_and_falls_back(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _spy_compressor(monkeypatch, tokensave_ok=True) + wrap_cli._setup_coding_compressor( + _FakeRegistrar(), serena_context="claude-code", no_tokensave=True + ) + assert calls["tokensave"] == "disabled" + # tokensave disabled → treated as unavailable → Serena fallback registers. + assert calls["serena_setup"] is True diff --git a/tests/test_graph_tokensave.py b/tests/test_graph_tokensave.py new file mode 100644 index 000000000..52ef4135c --- /dev/null +++ b/tests/test_graph_tokensave.py @@ -0,0 +1,284 @@ +"""Tests for the tokensave release-binary installer.""" + +from __future__ import annotations + +import io +import tarfile +import zipfile +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from headroom.graph import tokensave_installer as ts + + +def _tar_archive(member_name: str = ts.TOKENSAVE_BIN_NAME) -> bytes: + payload = io.BytesIO() + with tarfile.open(fileobj=payload, mode="w:gz") as tar: + data = b"#!/bin/sh\necho version\n" + info = tarfile.TarInfo(name=member_name) + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + return payload.getvalue() + + +def _zip_archive(member_name: str = "tokensave.exe") -> bytes: + payload = io.BytesIO() + with zipfile.ZipFile(payload, "w") as zf: + zf.writestr(member_name, b"binary") + return payload.getvalue() + + +class FakeResponse: + def __init__(self, data: bytes) -> None: + self._data = data + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def read(self) -> bytes: + return self._data + + +@pytest.mark.parametrize( + ("system", "machine", "expected"), + [ + ("darwin", "arm64", ("tokensave-v9-aarch64-macos.tar.gz", "tar.gz")), + ("linux", "aarch64", ("tokensave-v9-aarch64-linux.tar.gz", "tar.gz")), + ("linux", "arm64", ("tokensave-v9-aarch64-linux.tar.gz", "tar.gz")), + ("linux", "x86_64", ("tokensave-v9-x86_64-linux.tar.gz", "tar.gz")), + ("windows", "amd64", ("tokensave-v9-x86_64-windows.zip", "zip")), + ("windows", "arm64", ("tokensave-v9-aarch64-windows.zip", "zip")), + ], +) +def test_detect_asset_variants(monkeypatch, system, machine, expected) -> None: + monkeypatch.setattr(ts.platform, "system", lambda: system) + monkeypatch.setattr(ts.platform, "machine", lambda: machine) + assert ts._detect_asset("v9") == expected + + +def test_detect_asset_returns_none_for_intel_mac_and_unknown(monkeypatch) -> None: + monkeypatch.setattr(ts.platform, "system", lambda: "darwin") + monkeypatch.setattr(ts.platform, "machine", lambda: "x86_64") + assert ts._detect_asset("v9") is None # no x86_64-macos asset is published + + monkeypatch.setattr(ts.platform, "system", lambda: "solaris") + monkeypatch.setattr(ts.platform, "machine", lambda: "sparc") + assert ts._detect_asset("v9") is None + + +def test_get_tokensave_path_prefers_path_then_install_dir(monkeypatch, tmp_path: Path) -> None: + on_path = tmp_path / "on-path" + installed = tmp_path / ts.TOKENSAVE_BIN_NAME + installed.write_text("bin") + monkeypatch.setattr(ts, "TOKENSAVE_BIN_DIR", tmp_path) + monkeypatch.setattr("shutil.which", lambda name: str(on_path)) + assert ts.get_tokensave_path() == on_path + + monkeypatch.setattr("shutil.which", lambda name: None) + assert ts.get_tokensave_path() == installed + + installed.unlink() + assert ts.get_tokensave_path() is None + + +def test_ensure_offline_returns_none_when_absent(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setattr(ts, "TOKENSAVE_BIN_DIR", tmp_path) + monkeypatch.setattr("shutil.which", lambda name: None) + monkeypatch.setenv("HEADROOM_BINARIES_OFFLINE", "1") + + def _boom(*a, **k): + raise AssertionError("download must not run when offline") + + monkeypatch.setattr(ts, "download_tokensave", _boom) + assert ts.ensure_tokensave() is None + + +def test_ensure_returns_existing_without_download(monkeypatch, tmp_path: Path) -> None: + existing = tmp_path / ts.TOKENSAVE_BIN_NAME + existing.write_text("bin") + monkeypatch.setattr(ts, "get_tokensave_path", lambda: existing) + + def _boom(*a, **k): + raise AssertionError("download must not run when binary present") + + monkeypatch.setattr(ts, "download_tokensave", _boom) + assert ts.ensure_tokensave() == existing + + +def test_ensure_returns_none_on_unsupported_platform(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setattr(ts, "get_tokensave_path", lambda: None) + monkeypatch.delenv("HEADROOM_BINARIES_OFFLINE", raising=False) + monkeypatch.setattr(ts.platform, "system", lambda: "darwin") + monkeypatch.setattr(ts.platform, "machine", lambda: "x86_64") # no asset + assert ts.ensure_tokensave() is None + + +def test_download_tokensave_tarball(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setattr(ts, "TOKENSAVE_BIN_DIR", tmp_path) + monkeypatch.setattr(ts.platform, "system", lambda: "linux") + monkeypatch.setattr(ts.platform, "machine", lambda: "x86_64") + # Synthetic archive bytes won't match the pinned digest; this test covers + # extraction, not integrity, so opt out of verification explicitly. + monkeypatch.setenv("HEADROOM_TOKENSAVE_ALLOW_UNVERIFIED", "1") + monkeypatch.setattr(ts, "urlopen", lambda url, timeout=60: FakeResponse(_tar_archive())) + monkeypatch.setattr( + "subprocess.run", lambda *a, **k: SimpleNamespace(returncode=0, stdout="tokensave 6\n") + ) + path = ts.download_tokensave(version="v0.0.0-test") + assert path == tmp_path / ts.TOKENSAVE_BIN_NAME + assert path.exists() + + +def test_download_tokensave_zip_windows(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setattr(ts, "TOKENSAVE_BIN_DIR", tmp_path) + monkeypatch.setattr(ts.platform, "system", lambda: "windows") + monkeypatch.setattr(ts.platform, "machine", lambda: "amd64") + monkeypatch.setenv("HEADROOM_TOKENSAVE_ALLOW_UNVERIFIED", "1") + monkeypatch.setattr(ts, "urlopen", lambda url, timeout=60: FakeResponse(_zip_archive())) + monkeypatch.setattr( + "subprocess.run", lambda *a, **k: SimpleNamespace(returncode=0, stdout="tokensave 6\n") + ) + path = ts.download_tokensave(version="v0.0.0-test") + assert path == tmp_path / "tokensave.exe" + assert path.exists() + + +def test_download_raises_for_unsupported_platform(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setattr(ts, "TOKENSAVE_BIN_DIR", tmp_path) + monkeypatch.setattr(ts.platform, "system", lambda: "darwin") + monkeypatch.setattr(ts.platform, "machine", lambda: "x86_64") + with pytest.raises(RuntimeError, match="no prebuilt tokensave asset"): + ts.download_tokensave(version="v7.0.0") + + +def test_download_wraps_network_failure(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setattr(ts, "TOKENSAVE_BIN_DIR", tmp_path) + monkeypatch.setattr(ts.platform, "system", lambda: "linux") + monkeypatch.setattr(ts.platform, "machine", lambda: "x86_64") + + def _boom(url, timeout=60): + raise OSError("connection refused") + + monkeypatch.setattr(ts, "urlopen", _boom) + with pytest.raises(RuntimeError, match="Failed to download tokensave"): + ts.download_tokensave(version="v7.0.0") + + +def test_download_raises_when_binary_missing_from_tarball(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setattr(ts, "TOKENSAVE_BIN_DIR", tmp_path) + monkeypatch.setattr(ts.platform, "system", lambda: "linux") + monkeypatch.setattr(ts.platform, "machine", lambda: "x86_64") + # Archive contains an unrelated member, not the tokensave binary. + monkeypatch.setenv("HEADROOM_TOKENSAVE_ALLOW_UNVERIFIED", "1") + monkeypatch.setattr( + ts, "urlopen", lambda url, timeout=60: FakeResponse(_tar_archive("README.md")) + ) + with pytest.raises(RuntimeError, match="binary not found in archive"): + ts.download_tokensave(version="v0.0.0-test") + + +def test_download_raises_when_binary_missing_from_zip(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setattr(ts, "TOKENSAVE_BIN_DIR", tmp_path) + monkeypatch.setattr(ts.platform, "system", lambda: "windows") + monkeypatch.setattr(ts.platform, "machine", lambda: "amd64") + monkeypatch.setenv("HEADROOM_TOKENSAVE_ALLOW_UNVERIFIED", "1") + monkeypatch.setattr( + ts, "urlopen", lambda url, timeout=60: FakeResponse(_zip_archive("notes.txt")) + ) + with pytest.raises(RuntimeError, match="binary not found in archive"): + ts.download_tokensave(version="v0.0.0-test") + + +def test_download_tolerates_failed_version_check(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setattr(ts, "TOKENSAVE_BIN_DIR", tmp_path) + monkeypatch.setattr(ts.platform, "system", lambda: "linux") + monkeypatch.setattr(ts.platform, "machine", lambda: "x86_64") + monkeypatch.setenv("HEADROOM_TOKENSAVE_ALLOW_UNVERIFIED", "1") + monkeypatch.setattr(ts, "urlopen", lambda url, timeout=60: FakeResponse(_tar_archive())) + # Non-zero return code and a raising probe must both be non-fatal. + monkeypatch.setattr( + "subprocess.run", lambda *a, **k: SimpleNamespace(returncode=1, stdout="", stderr="x") + ) + assert ts.download_tokensave(version="v0.0.0-test") == tmp_path / ts.TOKENSAVE_BIN_NAME + + monkeypatch.setattr( + "subprocess.run", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("probe boom")) + ) + assert ts.download_tokensave(version="v0.0.0-test") == tmp_path / ts.TOKENSAVE_BIN_NAME + + +def test_verify_asset_digest_accepts_matching_hash(monkeypatch) -> None: + import hashlib + + data = b"some-release-bytes" + digest = hashlib.sha256(data).hexdigest() + monkeypatch.setattr(ts, "TOKENSAVE_ASSET_DIGESTS", {"asset.tar.gz": digest}) + # No exception => verification passed. + ts._verify_asset_digest("asset.tar.gz", data) + + +def test_verify_asset_digest_rejects_mismatch(monkeypatch) -> None: + monkeypatch.setattr(ts, "TOKENSAVE_ASSET_DIGESTS", {"asset.tar.gz": "00" * 32}) + with pytest.raises(RuntimeError, match="failed integrity check"): + ts._verify_asset_digest("asset.tar.gz", b"tampered") + + +def test_verify_asset_digest_refuses_unpinned_without_optout(monkeypatch) -> None: + monkeypatch.setattr(ts, "TOKENSAVE_ASSET_DIGESTS", {}) + monkeypatch.delenv("HEADROOM_TOKENSAVE_ALLOW_UNVERIFIED", raising=False) + with pytest.raises(RuntimeError, match="no pinned SHA-256 digest"): + ts._verify_asset_digest("unknown.tar.gz", b"bytes") + + +def test_verify_asset_digest_allows_unpinned_with_optout(monkeypatch) -> None: + monkeypatch.setattr(ts, "TOKENSAVE_ASSET_DIGESTS", {}) + monkeypatch.setenv("HEADROOM_TOKENSAVE_ALLOW_UNVERIFIED", "1") + ts._verify_asset_digest("unknown.tar.gz", b"bytes") # no exception + + +def test_download_aborts_on_digest_mismatch(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setattr(ts, "TOKENSAVE_BIN_DIR", tmp_path) + monkeypatch.setattr(ts.platform, "system", lambda: "linux") + monkeypatch.setattr(ts.platform, "machine", lambda: "x86_64") + monkeypatch.delenv("HEADROOM_TOKENSAVE_ALLOW_UNVERIFIED", raising=False) + # Pin a digest that the synthetic archive cannot match. + monkeypatch.setattr( + ts, "TOKENSAVE_ASSET_DIGESTS", {"tokensave-v7.0.0-x86_64-linux.tar.gz": "00" * 32} + ) + monkeypatch.setattr(ts, "urlopen", lambda url, timeout=60: FakeResponse(_tar_archive())) + with pytest.raises(RuntimeError, match="failed integrity check"): + ts.download_tokensave(version="v7.0.0") + # The unverified binary must not have been written. + assert not (tmp_path / ts.TOKENSAVE_BIN_NAME).exists() + + +def test_download_honors_invalid_url_scheme(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setattr(ts, "TOKENSAVE_BIN_DIR", tmp_path) + monkeypatch.setattr(ts.platform, "system", lambda: "linux") + monkeypatch.setattr(ts.platform, "machine", lambda: "x86_64") + monkeypatch.setattr(ts, "GITHUB_RELEASE_URL", "ftp://example.test/releases") + with pytest.raises(RuntimeError, match="Failed to download tokensave"): + ts.download_tokensave(version="v7.0.0") + + +def test_ensure_returns_none_when_download_fails(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setattr(ts, "get_tokensave_path", lambda: None) + monkeypatch.delenv("HEADROOM_BINARIES_OFFLINE", raising=False) + + def _raise(version=None): + raise RuntimeError("download failed") + + monkeypatch.setattr(ts, "download_tokensave", _raise) + assert ts.ensure_tokensave() is None + + +def test_pinned_version_env_override(monkeypatch) -> None: + monkeypatch.setenv("HEADROOM_TOKENSAVE_VERSION", "v9.9.9") + assert ts._pinned_version() == "v9.9.9" + monkeypatch.delenv("HEADROOM_TOKENSAVE_VERSION", raising=False) + assert ts._pinned_version() == ts.TOKENSAVE_VERSION