From c92ea461bb42afc9ab2796f36f83bc2cd3d88291 Mon Sep 17 00:00:00 2001 From: Chris Liu <11517021+chris7iu@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:24:55 +0000 Subject: [PATCH 01/30] feat(antigravity): add experimental opt-in native Messages provider Add explicit OAuth/profile storage, cancellation-safe token refresh, bounded native transport and lossless fixed-session replay/schema adaptation. Keep disabled discovery baseline-compatible, expose read-only capabilities, and provide local count estimates with documented limitations. Validated offline under umask 022 with formatting, Clippy, build, 1206 Rust tests and 83 Python fixture/launcher tests. Native Google acceptance remains unverified; no live provider or service activation is included. Co-Authored-By: Claude Code --- Cargo.lock | 367 +++++- Cargo.toml | 1 + README.md | 10 + .../docs/reference/command-reference.md | 21 +- .../compatibility-and-limitations.md | 227 ++++ .../content/docs/reference/configuration.md | 63 +- docs/src/content/docs/reference/http-api.md | 51 + meta/antigravity-reference-LICENSE | 21 + src/main.rs | 44 +- src/provider.rs | 11 + src/providers/antigravity/auth.rs | 549 +++++++++ src/providers/antigravity/config.rs | 140 +++ src/providers/antigravity/mod.rs | 358 ++++++ src/providers/antigravity/refresh.rs | 373 ++++++ src/providers/antigravity/replay.rs | 692 +++++++++++ src/providers/antigravity/request.rs | 439 +++++++ src/providers/antigravity/schema.rs | 524 +++++++++ src/providers/antigravity/transport.rs | 306 +++++ src/providers/antigravity_auth_tests.rs | 808 +++++++++++++ src/providers/antigravity_http_tests.rs | 578 ++++++++++ src/providers/antigravity_tests.rs | 1020 +++++++++++++++++ src/providers/mod.rs | 8 + src/registry.rs | 109 ++ src/server.rs | 245 +++- src/traffic.rs | 51 +- tests/antigravity_compatibility/codec.py | 465 ++++++++ tests/antigravity_compatibility/harness.py | 820 +++++++++++++ tests/antigravity_compatibility/read.txt | 3 + .../run_container.py | 444 +++++++ tests/antigravity_compatibility/test_codec.py | 348 ++++++ .../antigravity_compatibility/test_harness.py | 357 ++++++ .../test_run_container.py | 517 +++++++++ tests/fixtures/antigravity-golden-v1.json | 527 +++++++++ tests/fixtures/antigravity-negative-v1.json | 554 +++++++++ .../antigravity-parameterless-v1.json | 1 + tests/server.rs | 570 +++++++++ 36 files changed, 11570 insertions(+), 52 deletions(-) create mode 100644 meta/antigravity-reference-LICENSE create mode 100644 src/providers/antigravity/auth.rs create mode 100644 src/providers/antigravity/config.rs create mode 100644 src/providers/antigravity/mod.rs create mode 100644 src/providers/antigravity/refresh.rs create mode 100644 src/providers/antigravity/replay.rs create mode 100644 src/providers/antigravity/request.rs create mode 100644 src/providers/antigravity/schema.rs create mode 100644 src/providers/antigravity/transport.rs create mode 100644 src/providers/antigravity_auth_tests.rs create mode 100644 src/providers/antigravity_http_tests.rs create mode 100644 src/providers/antigravity_tests.rs create mode 100644 tests/antigravity_compatibility/codec.py create mode 100644 tests/antigravity_compatibility/harness.py create mode 100644 tests/antigravity_compatibility/read.txt create mode 100644 tests/antigravity_compatibility/run_container.py create mode 100644 tests/antigravity_compatibility/test_codec.py create mode 100644 tests/antigravity_compatibility/test_harness.py create mode 100644 tests/antigravity_compatibility/test_run_container.py create mode 100644 tests/fixtures/antigravity-golden-v1.json create mode 100644 tests/fixtures/antigravity-negative-v1.json create mode 100644 tests/fixtures/antigravity-parameterless-v1.json diff --git a/Cargo.lock b/Cargo.lock index 5967b61f..9596455b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,20 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -102,7 +116,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -179,7 +193,7 @@ checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -224,6 +238,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "bstr" version = "1.12.3" @@ -241,6 +261,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "byteorder" version = "1.5.0" @@ -321,7 +347,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -351,6 +377,7 @@ dependencies = [ "http-body-util", "hyper-util", "jiff", + "jsonschema", "once_cell", "predicates", "prost", @@ -487,7 +514,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.118", ] [[package]] @@ -498,7 +525,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -526,7 +553,7 @@ dependencies = [ "defmt-parser", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -568,7 +595,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -577,6 +604,15 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -613,6 +649,17 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "fancy-regex" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -644,6 +691,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "fnv" version = "1.0.7" @@ -656,6 +714,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -665,6 +729,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500" +dependencies = [ + "num", + "num-bigint", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -695,7 +769,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -801,7 +875,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -809,6 +883,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "heck" @@ -1078,7 +1157,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1140,7 +1219,7 @@ checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1154,6 +1233,60 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonschema" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b320ad34c177093d21622edc9215bb608294c2333ff296672c0c8265a5ee60c" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex 0.19.2", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "jsonschema-regex", + "jsonschema-value", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "strum 0.28.0", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1472f7be79026f79e01a9b172263311e829d7c210d57b39cb57037abc0ce0500" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonschema-value" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7550a7a84787e715ead0b47e01fa8c6bb7c3413630f91a8f2094c06c2078a" +dependencies = [ + "ahash", + "bytecount", + "fraction", + "getrandom 0.3.4", + "num-cmp", + "num-traits", + "serde_json", + "zmij", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1235,6 +1368,12 @@ version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[package]] name = "mime" version = "0.3.17" @@ -1305,12 +1444,81 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1338,6 +1546,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1477,7 +1691,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1630,7 +1844,7 @@ dependencies = [ "itertools 0.13.0", "lru", "paste", - "strum", + "strum 0.26.3", "unicode-segmentation", "unicode-truncate", "unicode-width 0.2.0", @@ -1645,6 +1859,43 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "referencing" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473f4dcca28ead7ee0f8e5b5d0bbae2549f4fc4c958fb20af328961b6c174be3" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.17.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.12.4" @@ -1659,9 +1910,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1895,7 +2146,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2060,7 +2311,16 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros", + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -2073,7 +2333,19 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.118", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.118", ] [[package]] @@ -2093,6 +2365,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -2110,7 +2393,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2158,7 +2441,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2169,7 +2452,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2190,7 +2473,7 @@ dependencies = [ "anyhow", "base64", "bstr", - "fancy-regex", + "fancy-regex 0.17.0", "lazy_static", "regex", "rustc-hash", @@ -2275,7 +2558,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2383,7 +2666,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2476,6 +2759,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2559,6 +2848,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "valuable" version = "0.1.1" @@ -2571,6 +2870,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -2646,7 +2951,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.118", "wasm-bindgen-shared", ] @@ -2916,7 +3221,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "synstructure", ] @@ -2937,7 +3242,7 @@ checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2957,7 +3262,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "synstructure", ] @@ -2997,11 +3302,11 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index a1ca0f52..03342ceb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ ratatui = "0.29" crossterm = "0.28" jiff = { version = "0.2", default-features = false, features = ["std", "tz-system", "tzdb-zoneinfo"] } tiktoken-rs = { version = "0.12", default-features = false } +jsonschema = { version = "=0.59.0", default-features = false, features = ["idna"] } [dev-dependencies] tokio = { version = "1", features = ["test-util"] } diff --git a/README.md b/README.md index 30ab8260..2494948f 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,16 @@ The opt-in Images API returns base64 image data and consumes the signed-in accou Run `claude-code-proxy models` for the current catalog or `claude-code-proxy models --full` for every dynamic Cursor alias. +An additional **experimental, default-off native Antigravity Argon adapter** supports +only `antigravity/gemini-4-argon-low`, `antigravity/gemini-4-argon-medium`, and +`antigravity/gemini-4-argon-high`. It does not run or import credentials from `agy`. +Use a fresh session for every model/tier/profile change. Real Google OAuth, +entitlement, native tool round trips and all three tiers remain live-validation +gates; offline tests are not evidence of account access. See the +[Antigravity limitations](docs/src/content/docs/reference/compatibility-and-limitations.md#experimental-native-antigravity-argon) +and [configuration](docs/src/content/docs/reference/configuration.md#experimental-antigravity) +before explicitly enabling it. Existing provider defaults and aliases are unchanged. + > [!WARNING] > The proxy accepts local requests without client authentication. It binds to > `127.0.0.1` by default. Protect any non-loopback listener with a firewall or diff --git a/docs/src/content/docs/reference/command-reference.md b/docs/src/content/docs/reference/command-reference.md index eb1c7fd4..dd11f85d 100644 --- a/docs/src/content/docs/reference/command-reference.md +++ b/docs/src/content/docs/reference/command-reference.md @@ -70,6 +70,7 @@ claude-code-proxy auth | `kimi` | Device code | Unsupported | User, expiry, scope, storage | Delete proxy credential | | `grok` | Browser PKCE | Device code | Expiry and storage | Delete proxy credential | | `cursor` | Browser polling flow | Unsupported | Source, claims, expiry | Delete proxy credential | +| `antigravity` (experimental) | Explicit browser PKCE | Unsupported | Safe local readiness, optional `--json` | Delete only Antigravity credential | Examples: @@ -80,7 +81,25 @@ claude-code-proxy kimi auth status claude-code-proxy cursor auth logout ``` -A missing credential makes `auth status` exit with status 1. Other provider command failures exit with status 2. Successful commands exit with status 0. +For existing providers, a missing credential makes `auth status` exit with status 1. Other provider command failures exit with status 2. Successful commands exit with status 0. + +Antigravity auth commands work even while inference is disabled: + +```sh +claude-code-proxy antigravity auth status --json +claude-code-proxy antigravity auth login +claude-code-proxy antigravity auth logout +``` + +Configure your installed-app client ID before explicit login; see +[Antigravity configuration](/reference/configuration/#experimental-antigravity). +`status --json` is Antigravity-only and emits schema version 1, `provider: +antigravity`, plus the safe provider descriptor documented under +[model discovery](/reference/http-api/#get-v1models). A successfully emitted +report exits 0 even for missing/invalid/unready credentials. Inspection performs +no network, refresh, directory creation or writes. CLI readiness describes this +CLI's configuration, not a different running proxy, and never proves live access. +Auth invocation failures exit nonzero. No existing provider gains a `--json` flag. Logout removes the local proxy-owned credential. It does not call the provider to revoke a refresh token. diff --git a/docs/src/content/docs/reference/compatibility-and-limitations.md b/docs/src/content/docs/reference/compatibility-and-limitations.md index 2dcd30b2..3098b8af 100644 --- a/docs/src/content/docs/reference/compatibility-and-limitations.md +++ b/docs/src/content/docs/reference/compatibility-and-limitations.md @@ -82,6 +82,233 @@ claude-code-proxy targets Claude Code's practical Anthropic API usage rather tha - Conversation and pending tool state are in memory. Restarts clear them. - Cursor count-tokens uses a rough rendered-prompt estimate. +## Experimental native Antigravity Argon + +**Default off and experimental.** Only the exact IDs +`antigravity/gemini-4-argon-low`, `antigravity/gemini-4-argon-medium`, and +`antigravity/gemini-4-argon-high` are registered after explicit opt-in. Aliases, +Codex/Kimi affinity, and other providers' normalization remain unchanged. +Unknown Antigravity names never fall back. Start a **fresh session** whenever +changing model, tier, profile/account or project. The conservative 200,000-token +client window is unmeasured, not a claim of a verified backend limit. Raw `[1m]` +selectors and the `context-1m-2025-08-07` beta are rejected before normalization +on both Messages and count-tokens. + +### Supported client contract + +- Text and ordered system instructions, external function definitions/calls/results, + signed assistant replay, nonstreaming JSON and bounded buffered-turn SSE. + The adapter never executes tools or spawns `agy` for inference. +- Absent thinking or `thinking: {"type":"adaptive"}` is accepted, including + `display: "omitted"`. The exact model tier selects LOW/MEDIUM/HIGH. Omitted + display produces an empty thinking carrier, not invented visible reasoning. +- Recognized `output_config.effort` values (`low`, `medium`, `high`, `max`) are + advisory. They never change the pinned model/tier; the effective tier appears + in the response header and monitor. Numeric thinking budgets, explicit + disabled thinking, output-format promises, temperature/sampling options, + unsupported media, hosted tools, forced tool choice and parallel-disable + semantics are rejected rather than silently weakened. +- `tool_choice: {"type":"auto"}` is supported. `none` removes native declarations + and rejects returned function calls. Function names are collision-checked and + may be deterministically mapped to <=64-character native names; caller names + remain exact. +- Absent context management, an empty edit list, or exact + `clear_thinking_20251015` with `keep: "all"` is accepted. Other context edits + are rejected. Recognized cache hints are frontend hints, not a native cache-TTL + promise, and frontend Anthropic authorization/beta headers never reach Google. +- String/text-array `role: system` messages are extracted in message order after + top-level system parts, including ordinary turns. Logical assistant-fragment + boundaries are determined from the **original role sequence** before extraction: + a system message never manufactures adjacency, and user text resembling a + system reminder never gains system privilege. Adjacent fragments with one + authoritative leading carrier are normalized and digest-checked; distinct + complete turns keep distinct carriers. Empty array fragments carry no turn. + Missing/duplicate/conflicting carriers still fail closed. System-only requests + and non-text system blocks are rejected. +- After a tool-call turn, a complete unambiguous prefix of tool results must + precede ordinary user text. Results are associated in original native call + order; missing, duplicate, interleaved or text-before-results batches fail. + Native result contents use `role: model`, without synthetic `[Continue]` text. + Normal results wrap the original string or ordered text array as `output`; + error results use `error` and `is_error: true`. Strings are never JSON-parsed + into different result types. +- Generated local tool IDs are unique per turn/call, independent of native IDs. + Native ID presence/absence and raw calls are preserved. Frozen v1 rejects + repeated-name parallel calls with absent native IDs as ambiguous. Reused + present native IDs remain distinct local calls, but their live native acceptance + is not established by synthetic tests. A completed native call may omit `args`: + its caller projection is `{}` and is checked against the original tool schema, + while native replay retains the omission. Explicit null/non-object arguments + and incremental-call fields remain errors. Argon acceptance of omitted args is + reference-documented, not live-verified. + +### Schemas and replay + +Original source schemas are validated using pinned `jsonschema` 0.59.0 with +network/file resolution disabled. Accepted dialects are Draft 2020-12 +(`https://json-schema.org/draft/2020-12/schema`, the default when omitted) and +Draft 7 (`http://json-schema.org/draft-07/schema`, optional trailing `#`). References, +definitions/dynamic references, unknown dialects/vocabularies, `prefixItems` and +`unevaluatedProperties` are rejected before compilation. + +Gemini receives a separate projection containing supported fields and uppercase +types. Integer exclusive bounds are projected exactly; non-native constraints +remain enforced against the **original** schema and are identified in bounded +hints, including all non-string enum values. Unsupported/freeform leaves use an +explicit JSON-string codec, decoded before validation, with a complete deterministic +rendering of the original subschema (names, types, required fields and constraints) +in its guidance. If that guidance cannot fit 16 KiB, the request is rejected before +inference rather than truncated. A freeform root object uses one `json` string +wrapper with no extra keys; a closed no-argument object omits native parameters. +Required names without native property declarations use this faithful codec when +additional properties are allowed. When a closed object forbids a required +undeclared property, it is rejected as an unsatisfiable tool contract, not falsely +classified as invalid JSON Schema syntax. Ordinary structured tools stay structured. +Invalid JSON, unsafe numbers or arguments +violating the original schema produce a redacted upstream-output error before +any tool-use emission, never tool execution or hidden repair/retry. + +The single leading `ccp:antigravity:v1:` thinking signature contains original +native contents/signatures plus exact profile/project/model/session binding, +recorded display mode and tool/name/codec mappings. Replay is restart-safe with +no process-local correctness cache. Local validation detects missing/malformed +carriers, changed client projections and scope/session/mapping inconsistencies. +The SHA-256 digest is **consistency checking, not authentication**: it does not +bind the authenticity of opaque native contents/signatures. Corruption that +leaves the client projection unchanged is validated by the native upstream in +one attempt; its rejection is surfaced without signature repair, generation +retry, fallback or tool emission. Raw native signatures/carriers are redacted +from traffic and error diagnostics. + +### Transport, errors and bounds + +Native HTTP headers are awaited before downstream 200. Initial HTTP errors and +`Retry-After` are preserved without exposing upstream error bodies. After native +2xx the response body owns collection; no successful text/tool turn is published +until terminal state, arguments and replay metadata are validated. Late errors +emit an SSE error without `message_stop` and record one failed monitor outcome. +Dropping the body cancels owned inference work. Loopback socket tests cover +pre-header, nonstream and streaming cancellation; this is not a guarantee of +upstream quota refunds. Separately owned credential-refresh maintenance may finish +and persist a rotation after the inference waiter disappears, but cannot continue +generation. See [credential ownership and recovery](/reference/configuration/#experimental-antigravity). +There are no automatic generation retries or endpoint/account fallback. + +| Limit | v1 bound | +| --- | --- | +| Collected native turn / decoded carrier | 8 MiB each | +| Encoded carrier payload | 12 MiB | +| Native SSE line / frame | 1 MiB each | +| JSON depth | 64 | +| Native response contents / parts / calls | 512 / 4096 / 128 | +| Logical assistant projection / blocks | 8 MiB semantic bytes / 4097; cumulative bounds checked before growth | +| Original-schema codec guidance | 16 KiB per complete description; no essential-constraint truncation | +| Incoming messages / prepared native history contents | 512 / 1024 | +| Caller tools / source schema bytes / schema nodes / schema depth | 128 / 256 KiB per schema / 8192 / 32 | +| Raw codec JSON / decoded argument bytes | 1 MiB each | +| Codec paths per call / path bytes / root-key bytes | 64 / 1024 / 256 | +| Requested output tokens | 1..65536 (8192 when omitted) | +| Native header / body idle / whole collection deadline | 30s / 30s / 180s | +| Auth lock acquisition / token-project request / browser callback | 10s / 30s / 180s | +| Callback headers / each preconnect read | 8 KiB / 2s | +| Stored credential / OAuth-project response | 64 KiB each | + +All limits apply together. Exceeding a request bound returns an invalid-request +error before inference where detectable; upstream output bounds return a redacted +upstream error, never a partial successful turn. Usage fields are cumulative, +not summed across frames. Cache reads are capped by prompt usage. Candidate plus +thought output is used only with consistent native totals; ambiguous totals, +decreasing counters, refusals, unknown termination and truncated streams are +explicit errors rather than invented billing numbers. Token counting reconstructs +validated native history and estimates its normalized full semantic projection, +including retained hidden thoughts and recorded caller-name/argument codecs. Full +versus omitted display, supported assistant fragmentation and opaque signature +size cannot change that estimate. Signatures/carrier duplication are excluded; +this remains a local estimate, not verified Google tokenizer/context accounting. +Adjacent text coalescing appends in place without repeated accumulated-prefix +copies. Overrides into Antigravity receive the same strict original-byte checks, +including duplicate/per-message fields, raw context hints and every beta header. +Cross-provider overrides out of explicit Antigravity routes remain blocked. + +### Evidence and remaining gates + +Synthetic Rust HTTP/OAuth tests and an isolated real-client replay fixture do not +prove Google acceptance, entitlement, billing or native model limits. Browser +OAuth with the configured client, project readiness, truthful proxy identity, +native text/reasoning/tool round trips and **all three tiers** still require +real-account smoke validation before activation. Low has public request-shape +evidence; Medium/High encodings are catalog-inferred. Catalog evidence indicates +Google AI Ultra, but actual access responses remain authoritative. Automatic +long-context compaction and native compaction acceptance are unverified. A +manual real-client compact/resume fixture does not establish general compaction +support. + +### Optional offline client fixture + +`tests/antigravity_compatibility/run_container.py` is developer test tooling, not +an inference backend or installer. Supply **both** an already-local image and a +single installed native Claude executable; there are no machine-specific defaults. +For example, replace these paths and image tag with your own trusted local inputs: + +```sh +python3 -B tests/antigravity_compatibility/run_container.py \ + --image your-fixture-image:local \ + --claude-binary /absolute/path/to/native-claude \ + --out /absolute/existing/private-parent/antigravity-compatibility-new minimal +``` + +The supported setup is a nonroot Linux caller with equal positive real/effective +UIDs and GIDs, trusted **local rootful Docker Engine** without user-namespace +remapping, and a compatible Linux image containing `/usr/bin/env`, `python3` on +`/usr/bin:/bin`, and the CLI's runtime libraries. The CLI must be a readable, +executable 64-bit little-endian ELF (amd64 or arm64), matching the inspected image +architecture. Symlinked native installations resolve to one file; npm/JavaScript +wrappers and parent-directory mounts are unsupported. ARM compatibility is +validation-tested, not an actual-client runtime claim. Rootless Docker, Podman, +Docker Desktop, remote daemons, macOS and Windows are not supported by this launcher. + +Pure input validation precedes read-only Docker context/engine/image checks; +output is created only afterward. Docker's context-versus-host precedence is +honored, then the selected Unix endpoint and immutable image ID are pinned. +A Unix endpoint is not proof against a proxy: trusting the daemon, client config, +image and local filesystem remains a prerequisite. No pull, build, download, +credential import, privilege repair or permission change is performed. + +The new output root and its `control/` and `work/` children are private (0700). +Only `work/` is container-writable; host-only `control/` retains command, image, +binary identity, inspection and log evidence. Inputs are read-only, the rootfs is +read-only, network is disabled, and HOME is an identity-owned private tmpfs. +Image VOLUMEs are rejected, inherited environment keys and client-config proxy +variables are explicitly blanked, healthchecks are disabled, and the entrypoint +remains `env -i` running the Python fake-endpoint harness, not the CLI directly. +The launcher never executes the CLI on the host or mounts host auth/config or a +Docker socket. Paths containing commas, double quotes or ASCII controls are +rejected. The output needs an existing nonsymlink parent and cannot overlap inputs. + +Binary device/inode/size/hash are checked again immediately before launch. This +is not race-proof against a malicious host swapping files after validation. A +single 600-second Docker lifecycle budget reserves 30 seconds for cleanup; +cleanup targets only an inspected invocation-owned full container ID, never a +name/filter guess. Interrupted or ambiguous ownership reports a possible orphan +for manual investigation rather than expanding cleanup authority. Keep generated +artifacts private; they are not contribution files. + +Historical actual-client evidence used Claude Code **2.1.270** with synthetic +loopback responses. The portability correction was checked with mocked processes +and the existing codec/HTTP unit suite in an offline nonroot container, with +**zero actual Claude executions**. It does not establish a new client-version, +image/architecture or live-provider compatibility claim. Case validation uses the +existing harness catalog; `catalog-default` must run separately. + +Protocol references were independently translated from MIT-licensed +`cortexkit/antigravity-auth`, revision +`f5a836df058a99cee6099ad308b9675e4ef11026`: +https://github.com/cortexkit/antigravity-auth/tree/f5a836df058a99cee6099ad308b9675e4ef11026. +Copyright (c) 2025 Jens; the reference license is retained in +`meta/antigravity-reference-LICENSE`. No full captured CLI system prompts, +proprietary descriptions, binaries, fingerprint impersonation or signature-bypass +code are included. Test vectors contain synthetic/minimized data only. + ## Diagnostics and privacy - Structured logs redact known credential keys, but user-provided strings can still contain secrets. diff --git a/docs/src/content/docs/reference/configuration.md b/docs/src/content/docs/reference/configuration.md index 84a3c644..01def66c 100644 --- a/docs/src/content/docs/reference/configuration.md +++ b/docs/src/content/docs/reference/configuration.md @@ -56,7 +56,7 @@ These settings configure the proxy process. Claude Code client settings such as } ``` -All keys are optional. An unreadable file, malformed JSON, or incompatible field type causes the file configuration to be ignored and built-in or environment values to apply. +All keys are optional. For existing providers, an unreadable file, malformed JSON, or incompatible field type causes the file configuration to be ignored and built-in or environment values to apply. The experimental Antigravity adapter instead fails closed on invalid configuration; discovery reports bounded status enums without raw parse errors. ## Core and diagnostics @@ -74,7 +74,7 @@ All keys are optional. An unreadable file, malformed JSON, or incompatible field `CCP_CONFIG_DIR` affects `config.json` and file-backed provider auth. It does not relocate the state directory. -Codex auto-review classifier requests use `gpt-6-luna` by default. Requests routed through other providers retain their requested model. `CCP_AUTO_REVIEW_MODEL` or `autoReviewModel` selects an explicit registered model for all detected classifier requests without changing the session's provider affinity. Normal messages, streaming requests, tool-using requests, and token counting retain their requested model. +Codex auto-review classifier requests use `gpt-6-luna` by default. Requests routed through other providers retain their requested model. `CCP_AUTO_REVIEW_MODEL` or `autoReviewModel` selects an explicit registered model for detected classifier requests without changing the session's provider affinity. **Exception:** explicit Antigravity requests ignore cross-provider auto-review overrides. Same-provider overrides still undergo exact replay/model validation; changing tiers with signed history is rejected. Normal messages, streaming requests, tool-using requests, and token counting retain their requested model. ## Outbound proxies @@ -155,6 +155,65 @@ Proxy URLs may use `http`, `https`, `socks4`, `socks4a`, `socks5`, or `socks5h`. | `CCP_CURSOR_AGENT_BUNDLE` | `cursor.agentBundle` | Auto-detected | Points to Cursor Agent's bundled `index.js` protobuf schemas. | | `CCP_CURSOR_AUTH_TOKEN` | none | unset | Uses a bearer token instead of proxy-owned Cursor auth storage. | +## Experimental Antigravity + +Disabled by default. This native Argon adapter is unofficial, not generic Gemini API +support and not a promise of Google account entitlement. Authentication commands +remain available while disabled. Do not enable it in a production session before +separate real-account OAuth/native smoke validation. + +| Environment | Config key | Default | Purpose | +| --- | --- | --- | --- | +| `CCP_ANTIGRAVITY_ENABLED` | `antigravity.enabled` | `false` | Explicit opt-in. Environment accepts only `1`/`true` or `0`/`false`; file value is boolean. | +| `CCP_ANTIGRAVITY_CLIENT_ID` | `antigravity.clientId` | unset | Your public installed-app OAuth client ID; required for login and refresh. | +| `CCP_ANTIGRAVITY_CLIENT_SECRET` | `antigravity.clientSecret` | unset | Optional public-client application secret, when required by that registered application. Not a user bearer token. | +| `CCP_ANTIGRAVITY_PROJECT_ID` | `antigravity.projectId` | stored discovery result | Your authorized project; no borrowed project or automatic onboarding. | +| `CCP_ANTIGRAVITY_BASE_URL` | `antigravity.baseUrl` | `https://cloudcode-pa.googleapis.com` | Explicit trusted HTTPS origin override. No userinfo, query, fragment, or path. Never automatically falls back to another endpoint. | + +Remote endpoints require HTTPS, redirects are never followed, and OAuth endpoints +are fixed to `https://accounts.google.com/o/oauth2/v2/auth` and +`https://oauth2.googleapis.com/token`. Plain loopback HTTP endpoints exist only as +injected test fixtures, not environment/config options. The HTTP User-Agent +identifies `claude-code-proxy` and its real platform as an Antigravity-compatible +client. Product selectors `userAgent: antigravity` and `requestType: agent` do not +claim an official CLI build or device identity. + +Login requests explicit browser consent, offline access, PKCE S256, independent +one-time state, and an ephemeral `127.0.0.1` callback. The reference-protocol scopes +are `https://www.googleapis.com/auth/` followed by `cloud-platform`, `userinfo.email`, +`userinfo.profile`, `cclog`, and `experimentsandconfigs`. These are reference +compatibility scopes, not independently proven minimal scopes. No `agy`, Gemini, +Codex, keychain or other application's credentials are discovered or imported. + +Only `CCP_CONFIG_DIR/antigravity/auth.json` is used. Atomic private writes use mode +0600 on Unix, under a mode-0700 provider directory. A stable sibling `auth.lock` +coordinates login/logout/refresh across processes; logout never removes that +lock. Missing, invalid and unreadable storage remain distinct. Local logout resolves +this exact store path independently of transport settings, so malformed JSON or +an invalid API URL does not prevent deleting this provider's credential. It does +not remove another provider's state. + +Refresh preserves the profile ID and retains the old refresh token when rotation +is omitted. A bounded manager-owned, single-flight transaction acquires the stable +lock **before** contacting OAuth. Cancelling an inference waiter cannot discard a +completed rotation: credential maintenance may finish after disconnection, but +never starts or continues model generation. The 30-second network phase ends +before validated replacement persistence. Filesystem writes are synchronous and +are not claimed cancellable by that timer. + +If persistence fails, the manager retains the replacement in memory, scoped to +the profile, lock epoch and superseded refresh token, and reports a typed pending- +persistence error. Subsequent requests retry the local write, not the obsolete +token. Logout/new login or a different committed refresh token invalidates that +pending scope. An ambiguous network timeout/error requires explicit login rather +than automatically reusing a potentially consumed token. Graceful server shutdown +joins owned maintenance and attempts only pending local persistence; it performs +no new refresh or inference. Forced process termination/crashes between remote +rotation and durable storage are not covered by this in-memory recovery. Google's +immediate refresh-token invalidation behavior remains unverified. + +A new login changes the profile and invalidates prior session replay. + ## Shared compatibility fallbacks `CCP_USER_AGENT` is the fallback when a Codex or Kimi provider-specific user-agent is absent. Prefer `CCP_CODEX_USER_AGENT` or `CCP_KIMI_USER_AGENT` in durable configuration. diff --git a/docs/src/content/docs/reference/http-api.md b/docs/src/content/docs/reference/http-api.md index 65004fdd..7afcbd88 100644 --- a/docs/src/content/docs/reference/http-api.md +++ b/docs/src/content/docs/reference/http-api.md @@ -68,6 +68,57 @@ Returns Anthropic-shaped model discovery: An optional `limit` query truncates `data` and sets `has_more`. The route does not expose a pagination cursor. +Optional `include_capabilities=true` adds the following safe descriptor while +retaining the normal fields. Without this query, the default-off catalog JSON +and all existing provider entries are unchanged. + +```json +{ + "capabilities": { + "schema_version": 1, + "providers": { + "antigravity": { + "supported": true, + "enabled": false, + "mode": "native", + "experimental": true, + "supported_apis": ["messages", "count_tokens"], + "session_policy": "fixed-model", + "context_window": 200000, + "backend_window_verified": false, + "status": { + "configuration": "valid", + "auth": "missing", + "project": "missing", + "configuration_ready": false, + "live_verified": false + } + } + } + } +} +``` + +`configuration` is `valid|invalid`; `auth` is +`missing|invalid|unreadable|ready|refresh_required|login_required`; `project` is +`missing|configured`. `configuration_ready` requires valid local configuration, +a usable access token or refresh-capable credentials, and a local project. It +is independent of `enabled`, and **never an entitlement or live-access check**. +`live_verified` remains false. No actual project/account IDs, private paths, +expiry details, tokens or replay state are exposed. The query performs no network, +refresh, writes or directory creation. An absent or malformed descriptor must not +be interpreted as confirmed disabled/ready state. + +Antigravity uses native Messages/count-tokens only; optional OpenAI generation is +explicitly HTTP 501. Its streaming is buffered-turn SSE, not incremental native +text delivery. The selected tier wins over advisory `output_config.effort`, and +`x-antigravity-reasoning-tier` plus monitor metadata expose the effective tier. +Counts use a local semantic-byte estimate (roughly bytes/3) of validated native +history, including hidden retained thoughts and recorded argument/name codecs. +The estimate is invariant to full/omitted display, supported assistant fragments +and opaque signature size; it is not a native tokenizer/billing value. See the +[full experimental contract](/reference/compatibility-and-limitations/#experimental-native-antigravity-argon). + Claude Code gateway discovery filters IDs according to its own model rules. See [Models and routing](/using/models-and-routing/). ## `POST /v1/chat/completions` diff --git a/meta/antigravity-reference-LICENSE b/meta/antigravity-reference-LICENSE new file mode 100644 index 00000000..76ffda24 --- /dev/null +++ b/meta/antigravity-reference-LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Jens + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/main.rs b/src/main.rs index 1899dbba..2c33a7eb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -51,6 +51,11 @@ enum Commands { #[arg(long)] full: bool, }, + /// Manage experimental native Antigravity authentication (available while disabled) + Antigravity { + #[command(subcommand)] + command: AntigravityGroup, + }, /// Manage Codex authentication Codex { #[command(subcommand)] @@ -73,6 +78,25 @@ enum Commands { }, } +#[derive(Debug, Subcommand)] +enum AntigravityGroup { + Auth { + #[command(subcommand)] + command: AntigravityAuth, + }, +} + +#[derive(Debug, Subcommand)] +enum AntigravityAuth { + Login, + Device, + Status { + #[arg(long)] + json: bool, + }, + Logout, +} + #[derive(Debug, Subcommand)] enum ProviderGroup { Auth { @@ -186,6 +210,24 @@ fn main() -> Result<()> { print_models(&Registry::with_default_alias(), full); Ok(()) } + Commands::Antigravity { + command: AntigravityGroup::Auth { command }, + } => { + let handlers = claude_code_proxy::providers::antigravity::cli_handlers(); + match command { + AntigravityAuth::Login => handlers.login(), + AntigravityAuth::Device => handlers.device(), + AntigravityAuth::Logout => handlers.logout(), + AntigravityAuth::Status { json: true } => { + println!( + "{}", + claude_code_proxy::providers::antigravity::status_json() + ); + Ok(()) + } + AntigravityAuth::Status { json: false } => handlers.status(), + } + } Commands::Codex { command } => run_provider_cli("codex", command), Commands::Kimi { command } => run_provider_cli("kimi", command), Commands::Cursor { command } => run_provider_cli("cursor", command), @@ -328,7 +370,7 @@ fn run_provider_cli(name: &str, command: ProviderGroup) -> Result<()> { fn print_models(registry: &Registry, full: bool) { let grouped = registry.grouped_models(); - for provider in ["codex", "kimi", "grok", "opencode", "cursor"] { + for provider in ["codex", "kimi", "grok", "opencode", "cursor", "antigravity"] { let Some(models) = grouped.get(provider) else { continue; }; diff --git a/src/provider.rs b/src/provider.rs index 2631ce93..8a99bc23 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -25,6 +25,11 @@ pub enum AuthCommand { pub trait Provider: Send + Sync { fn name(&self) -> &'static str; fn supported_models(&self) -> Vec; + /// Optional safe local metadata for this serving instance. Implementations + /// must not perform network requests, refresh credentials, or write state. + fn local_capability(&self) -> Option { + None + } fn cli(&self) -> &'static dyn CliHandlers; async fn handle_messages(&self, body: MessagesRequest, ctx: RequestContext) -> Response; @@ -40,6 +45,12 @@ pub trait Provider: Send + Sync { async fn handle_count_tokens(&self, body: MessagesRequest, ctx: RequestContext) -> Response; + /// After ingress is quiescent, await owned credential maintenance. This + /// hook must never start or continue model generation. + async fn finish_maintenance(&self) -> Result<()> { + Ok(()) + } + async fn generate_anthropic_stream( &self, _body: MessagesRequest, diff --git a/src/providers/antigravity/auth.rs b/src/providers/antigravity/auth.rs new file mode 100644 index 00000000..26f92801 --- /dev/null +++ b/src/providers/antigravity/auth.rs @@ -0,0 +1,549 @@ +//! Proxy-owned OAuth state. No CLI/keychain import, account rotation, or onboarding. +pub(crate) use super::refresh::AccessError; +use super::refresh::RefreshManager; +use super::{ + config::Config, + replay::{self, Result}, +}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::sync::Arc; +use std::{ + fs::{self, File, OpenOptions}, + io::{Read, Seek, SeekFrom, Write}, + path::PathBuf, + time::Duration, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct Credential { + pub profile_id: String, + pub project_id: Option, + pub access_token: String, + pub refresh_token: Option, + pub expires_at: u64, +} +impl Credential { + fn validate(&self) -> Result<()> { + if self.profile_id.is_empty() + || self.profile_id.len() > 128 + || self.access_token.is_empty() + || self.access_token.len() > 16384 + || http::HeaderValue::from_str(&format!("Bearer {}", self.access_token)).is_err() + || self + .refresh_token + .as_ref() + .is_some_and(|v| v.is_empty() || v.len() > 16384) + || self + .project_id + .as_ref() + .is_some_and(|v| v.is_empty() || v.len() > 4096) + { + return Err("invalid"); + } + Ok(()) + } +} + +#[derive(Clone)] +pub(crate) struct Store { + path: PathBuf, + #[cfg(test)] + before_save: Option>, +} +pub(crate) struct Lock { + file: File, + path: PathBuf, +} +impl Lock { + pub fn epoch(&mut self) -> Result { + self.file + .seek(SeekFrom::Start(0)) + .map_err(|_| "auth lock read failed")?; + let mut epoch = String::new(); + std::io::Read::by_ref(&mut self.file) + .take(129) + .read_to_string(&mut epoch) + .map_err(|_| "auth lock read failed")?; + if epoch.len() > 128 { + return Err("invalid auth lock revision"); + } + Ok(epoch) + } + pub fn advance(&mut self) -> Result<()> { + self.file + .set_len(0) + .map_err(|_| "auth revision write failed")?; + self.file + .seek(SeekFrom::Start(0)) + .map_err(|_| "auth revision write failed")?; + self.file + .write_all(uuid::Uuid::new_v4().to_string().as_bytes()) + .map_err(|_| "auth revision write failed")?; + self.file + .sync_all() + .map_err(|_| "auth revision sync failed") + } +} +// Closing the dedicated file releases the OS advisory lock. It is NEVER renamed +// or removed, including logout; atomic auth.json replacement cannot split it. +impl Store { + pub fn new(path: PathBuf) -> Self { + Self { + path, + #[cfg(test)] + before_save: None, + } + } + #[cfg(test)] + pub fn with_before_save(mut self, hook: Arc) -> Self { + self.before_save = Some(hook); + self + } + pub fn configured() -> Self { + Self::new(crate::paths::provider_auth_file("antigravity")) + } + pub fn load(&self) -> Result> { + match fs::symlink_metadata(&self.path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err("unreadable"), + Ok(meta) if !meta.is_file() || meta.file_type().is_symlink() || meta.len() > 65536 => { + return Err("invalid"); + } + Ok(meta) => { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if meta.permissions().mode() & 0o077 != 0 { + return Err("unreadable"); + } + } + } + } + let mut raw = Vec::new(); + File::open(&self.path) + .map_err(|_| "unreadable")? + .take(65537) + .read_to_end(&mut raw) + .map_err(|_| "unreadable")?; + let value = replay::parse_json(&raw, 65536).map_err(|_| "invalid")?; + let credential: Credential = serde_json::from_value(value).map_err(|_| "invalid")?; + credential.validate()?; + Ok(Some(credential)) + } + pub async fn lock(&self, timeout: Duration) -> Result { + let parent = self.path.parent().ok_or("invalid auth directory")?; + fs::create_dir_all(parent).map_err(|_| "auth directory creation failed")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(parent, fs::Permissions::from_mode(0o700)) + .map_err(|_| "auth directory permissions failed")?; + } + let path = parent.join("auth.lock"); + if fs::symlink_metadata(&path).is_ok_and(|m| !m.is_file() || m.file_type().is_symlink()) { + return Err("invalid auth lock"); + } + let mut options = OpenOptions::new(); + options.create(true).read(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options.open(&path).map_err(|_| "auth lock open failed")?; + let deadline = tokio::time::Instant::now() + timeout; + loop { + match file.try_lock() { + Ok(()) => { + return Ok(Lock { + file, + path: self.path.clone(), + }); + } + Err(std::fs::TryLockError::WouldBlock) + if tokio::time::Instant::now() < deadline => + { + tokio::time::sleep(Duration::from_millis(10)).await + } + Err(std::fs::TryLockError::WouldBlock) => { + return Err("Antigravity auth lock timed out"); + } + Err(_) => return Err("Antigravity auth lock failed"), + } + } + } + pub fn save(&self, lock: &Lock, credential: &Credential) -> Result<()> { + if lock.path != self.path { + return Err("wrong auth lock"); + } + credential.validate()?; + #[cfg(test)] + if let Some(hook) = &self.before_save { + hook(); + } + crate::auth::write_atomically(self.path.to_str().ok_or("invalid auth path")?, credential) + .map_err(|_| "auth write failed") + } + pub async fn logout(&self) -> Result<()> { + let mut lock = self.lock(Duration::from_secs(10)).await?; + // The store resolver is independent of transport configuration. Advance + // the stable epoch even when the credential file is already absent. + lock.advance()?; + self.clear(&lock) + } + + pub fn clear(&self, lock: &Lock) -> Result<()> { + if lock.path != self.path { + return Err("wrong auth lock"); + } + match fs::remove_file(&self.path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err("auth deletion failed"), + } + } +} + +pub(crate) fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +pub(crate) struct Auth { + pub store: Store, + pub config: Config, + pub client: reqwest::Client, + refresh: Arc, +} +impl Auth { + pub fn new(config: Config, store: Store) -> Result { + let client = config.client()?; + let refresh = RefreshManager::new(store.clone(), client.clone(), &config); + Ok(Self { + config, + store, + client, + refresh, + }) + } + pub async fn access(&self) -> std::result::Result { + self.refresh.access().await + } + pub async fn finish_maintenance(&self) -> std::result::Result<(), AccessError> { + self.refresh.shutdown().await + } + #[cfg(test)] + pub fn with_refresh_timeouts(mut self, lock: Duration, network: Duration) -> Self { + self.refresh = self.refresh.with_timeouts(lock, network); + self + } + pub async fn logout(&self) -> Result<()> { + self.store.logout().await + } + pub async fn login(&self) -> Result<()> { + self.login_with_opener(|authorization| { + println!("Explicit browser consent for claude-code-proxy's experimental Antigravity adapter. No official CLI credentials are imported.\n{authorization}"); + open_browser(authorization.as_str()); + Ok(()) + }).await + } + pub(crate) async fn login_with_opener( + &self, + opener: impl FnOnce(&url::Url) -> Result<()>, + ) -> Result<()> { + let client_id = + self.config.client_id.as_deref().ok_or( + "set CCP_ANTIGRAVITY_CLIENT_ID to your public installed-app OAuth client ID", + )?; + let mut lock = self.store.lock(Duration::from_secs(10)).await?; + self.store.load()?; + let epoch = lock.epoch()?; + drop(lock); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .map_err(|_| "OAuth callback bind failed")?; + let redirect = format!( + "http://127.0.0.1:{}/oauth/callback", + listener + .local_addr() + .map_err(|_| "callback address unavailable")? + .port() + ); + let verifier = URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>()); + let state = URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>()); + let authorization = + authorization_url(&self.config, client_id, &redirect, &state, &verifier); + opener(&authorization)?; + let code = tokio::time::timeout(Duration::from_secs(180), callback(listener, &state)) + .await + .map_err(|_| "OAuth callback timed out")??; + let mut lock = self.store.lock(Duration::from_secs(10)).await?; + if lock.epoch()? != epoch { + return Err("Antigravity login changed during consent; start login again"); + } + self.store.load()?; + let mut form = vec![ + ("grant_type", "authorization_code"), + ("client_id", client_id), + ("redirect_uri", &redirect), + ("code", &code), + ("code_verifier", &verifier), + ]; + if let Some(secret) = self.config.client_secret.as_deref() { + form.push(("client_secret", secret)); + } + let response = self + .client + .post(self.config.token_url.clone()) + .timeout(Duration::from_secs(30)) + .form(&form) + .send() + .await + .map_err(|_| "OAuth token exchange transport failed")?; + let token = bounded_json(response).await?; + let mut credential = Credential { + profile_id: uuid::Uuid::new_v4().to_string(), + project_id: self.config.project.clone(), + access_token: String::new(), + refresh_token: None, + expires_at: 0, + }; + update_tokens(&mut credential, &token)?; + if credential.project_id.is_none() { + let response = self + .client + .post( + self.config + .base_url + .join("/v1internal:loadCodeAssist") + .map_err(|_| "invalid project discovery endpoint")?, + ) + .timeout(Duration::from_secs(30)) + .bearer_auth(&credential.access_token) + .json(&json!({"metadata":{"ideType":"ANTIGRAVITY","pluginType":"GEMINI"}})) + .send() + .await + .map_err(|_| "Antigravity project discovery transport failed")?; + credential.project_id = Some(project_id(&bounded_json(response).await?)?); + } + lock.advance()?; + self.store.save(&lock, &credential)?; + println!( + "Antigravity credentials saved. Experimental native access and model entitlement are NOT verified; start a fresh fixed-model session." + ); + Ok(()) + } +} + +pub(crate) fn authorization_url( + config: &Config, + client_id: &str, + redirect: &str, + state: &str, + verifier: &str, +) -> url::Url { + let scopes = [ + "cloud-platform", + "userinfo.email", + "userinfo.profile", + "cclog", + "experimentsandconfigs", + ] + .map(|s| format!("https://www.googleapis.com/auth/{s}")) + .join(" "); + let mut url = config.authorization_url.clone(); + url.query_pairs_mut().extend_pairs([ + ("client_id", client_id), + ("redirect_uri", redirect), + ("response_type", "code"), + ("access_type", "offline"), + ("prompt", "consent"), + ("scope", &scopes), + ("state", state), + ("code_challenge_method", "S256"), + ( + "code_challenge", + &URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())), + ), + ]); + url +} + +fn open_browser(url: &str) { + #[cfg(target_os = "linux")] + let command = std::process::Command::new("xdg-open") + .arg(url) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn(); + #[cfg(target_os = "macos")] + let command = std::process::Command::new("open").arg(url).spawn(); + #[cfg(target_os = "windows")] + let command = std::process::Command::new("rundll32") + .args(["url.dll,FileProtocolHandler", url]) + .spawn(); + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] + if let Ok(mut child) = command { + std::thread::spawn(move || { + let _ = child.wait(); + }); + } +} + +pub(crate) async fn callback(listener: tokio::net::TcpListener, state: &str) -> Result { + loop { + let (mut stream, peer) = listener + .accept() + .await + .map_err(|_| "OAuth callback accept failed")?; + if !peer.ip().is_loopback() { + continue; + } + let mut request = Vec::new(); + let read = tokio::time::timeout(Duration::from_secs(2), async { + let mut buffer = [0_u8; 1024]; + while !request.windows(4).any(|w| w == b"\r\n\r\n") { + let n = stream + .read(&mut buffer) + .await + .map_err(|_| "callback read failed")?; + if n == 0 || request.len() + n > 8192 { + return Err("invalid callback"); + } + request.extend_from_slice(&buffer[..n]); + } + Ok(()) + }) + .await; + if !matches!(read, Ok(Ok(()))) { + continue; + } + let line = std::str::from_utf8(&request) + .ok() + .and_then(|r| r.lines().next()) + .unwrap_or(""); + let mut fields = line.split_whitespace(); + if fields.next() != Some("GET") { + continue; + } + let target = fields.next().unwrap_or(""); + let url = match url::Url::parse(&format!("http://127.0.0.1{target}")) { + Ok(url) => url, + Err(_) => continue, + }; + if url.path() != "/oauth/callback" { + let _ = stream + .write_all( + b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await; + continue; + } + let pairs = url.query_pairs().collect::>(); + if pairs.iter().filter(|(k, _)| k == "state").count() != 1 + || pairs + .iter() + .find(|(k, _)| k == "state") + .is_none_or(|(_, v)| v != state) + { + let _ = stream + .write_all( + b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await; + continue; + } + let denied = pairs.iter().any(|(k, _)| k == "error"); + let code = pairs + .iter() + .filter(|(k, _)| k == "code") + .collect::>(); + let result = if denied { + Err("OAuth consent denied") + } else if code.len() != 1 || code[0].1.is_empty() { + Err("invalid OAuth callback") + } else { + Ok(code[0].1.to_string()) + }; + let _=stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 29\r\nConnection: close\r\n\r\nReturn to claude-code-proxy.\n").await; + return result; + } +} + +pub(crate) async fn bounded_json(mut response: reqwest::Response) -> Result { + if !response.status().is_success() { + return Err( + "Antigravity OAuth/project request denied; no fallback or onboarding attempted", + ); + } + let mut raw = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| "OAuth/project response read failed")? + { + if raw.len() + chunk.len() > 65536 { + return Err("OAuth/project response byte limit"); + } + raw.extend_from_slice(&chunk); + } + replay::parse_json(&raw, 65536) +} + +pub(super) fn update_tokens(credential: &mut Credential, value: &Value) -> Result<()> { + credential.access_token = replay::nonempty(&value["access_token"])?.to_string(); + if value + .get("token_type") + .is_some_and(|v| !v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("bearer"))) + { + return Err("unsupported OAuth token type"); + } + let expires = value["expires_in"] + .as_u64() + .filter(|v| *v > 0 && *v <= 86400) + .ok_or("invalid OAuth expiry")?; + credential.expires_at = now().checked_add(expires).ok_or("invalid OAuth expiry")?; + if let Some(refresh) = value.get("refresh_token") { + credential.refresh_token = Some(replay::nonempty(refresh)?.to_string()); + } + credential.validate() +} + +pub(crate) fn project_id(value: &Value) -> Result { + let project = &value["cloudaicompanionProject"]; + let id=project.as_str().or_else(||project["id"].as_str()).filter(|s|!s.is_empty() && s.len()<=4096).ok_or("Antigravity project missing; configure your authorized project, no onboarding was attempted")?; + Ok(id.to_string()) +} + +pub(crate) fn descriptor(config: &Result, store: &Store) -> Value { + let valid = config.is_ok(); + let credential = store.load(); + let project = config + .as_ref() + .ok() + .and_then(|c| c.project.as_ref()) + .is_some() + || credential + .as_ref() + .ok() + .and_then(|c| c.as_ref()) + .is_some_and(|c| c.project_id.is_some()); + let auth = match &credential { + Err(error) => *error, + Ok(None) => "missing", + Ok(Some(c)) if c.expires_at > now().saturating_add(60) => "ready", + Ok(Some(c)) if c.refresh_token.is_some() => "refresh_required", + Ok(Some(_)) => "login_required", + }; + json!({"supported":true,"enabled":config.as_ref().is_ok_and(|c|c.enabled),"mode":"native","experimental":true, + "supported_apis":["messages","count_tokens"],"session_policy":"fixed-model","context_window":200000,"backend_window_verified":false, + "status":{"configuration":if valid {"valid"} else {"invalid"},"auth":auth,"project":if project {"configured"} else {"missing"}, + "configuration_ready":valid && project && (auth=="ready" || (auth=="refresh_required" && config.as_ref().is_ok_and(|c|c.client_id.is_some()))),"live_verified":false}}) +} diff --git a/src/providers/antigravity/config.rs b/src/providers/antigravity/config.rs new file mode 100644 index 00000000..b3d97cca --- /dev/null +++ b/src/providers/antigravity/config.rs @@ -0,0 +1,140 @@ +use super::replay::{Result, keys, parse_json}; +use serde_json::{Value, json}; +use std::path::Path; +use url::Url; + +#[derive(Clone)] +pub(crate) struct Config { + pub enabled: bool, + pub base_url: Url, + pub token_url: Url, + pub authorization_url: Url, + pub client_id: Option, + pub client_secret: Option, + pub project: Option, +} + +impl Default for Config { + fn default() -> Self { + Self { + enabled: false, + base_url: Url::parse("https://cloudcode-pa.googleapis.com").unwrap(), + token_url: Url::parse("https://oauth2.googleapis.com/token").unwrap(), + authorization_url: Url::parse("https://accounts.google.com/o/oauth2/v2/auth").unwrap(), + client_id: None, + client_secret: None, + project: None, + } + } +} + +impl Config { + pub fn validate_endpoint(raw: &str, test_loopback: bool) -> Result { + let url = Url::parse(raw).map_err(|_| "invalid Antigravity endpoint")?; + let loopback = url.host_str().is_some_and(|host| { + host.parse::() + .is_ok_and(|ip| ip.is_loopback()) + }); + if !(url.scheme() == "https" || (test_loopback && loopback && url.scheme() == "http")) + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || url.host_str().is_none() + { + return Err("invalid Antigravity endpoint"); + } + Ok(url) + } + + pub fn load() -> Result { + Self::read(&crate::paths::config_dir().join("config.json"), |key| { + std::env::var(key).ok() + }) + } + + pub fn read(path: &Path, env: impl Fn(&str) -> Option) -> Result { + let file = match std::fs::read(path) { + Ok(raw) => { + parse_json(&raw, 1024 * 1024).map_err(|_| "invalid Antigravity configuration")? + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => json!({}), + Err(_) => return Err("unreadable Antigravity configuration"), + }; + if !file.is_object() { + return Err("invalid Antigravity configuration root"); + } + let section = file + .get("antigravity") + .cloned() + .unwrap_or_else(|| json!({})); + keys( + §ion, + &[ + "enabled", + "baseUrl", + "clientId", + "clientSecret", + "projectId", + ], + &[], + )?; + let mut result = Self::default(); + if let Some(enabled) = section.get("enabled") { + result.enabled = enabled.as_bool().ok_or("invalid enabled flag")?; + } + if let Some(enabled) = env("CCP_ANTIGRAVITY_ENABLED") { + result.enabled = match enabled.as_str() { + "1" | "true" => true, + "0" | "false" => false, + _ => return Err("invalid enabled flag"), + }; + } + fn setting(section: &Value, key: &str, supplied: Option) -> Result> { + let value = supplied.or(section + .get(key) + .map(|v| v.as_str().unwrap_or("").to_owned())); + if value + .as_ref() + .is_some_and(|v| v.is_empty() || v.len() > 4096 || v.chars().any(char::is_control)) + { + return Err("invalid Antigravity setting"); + } + Ok(value) + } + if let Some(base) = setting(§ion, "baseUrl", env("CCP_ANTIGRAVITY_BASE_URL"))? { + result.base_url = Self::validate_endpoint(&base, false)?; + if result.base_url.path() != "/" { + return Err("Antigravity base URL must be an origin"); + } + } + result.client_id = setting(§ion, "clientId", env("CCP_ANTIGRAVITY_CLIENT_ID"))?; + result.client_secret = setting( + §ion, + "clientSecret", + env("CCP_ANTIGRAVITY_CLIENT_SECRET"), + )?; + result.project = setting(§ion, "projectId", env("CCP_ANTIGRAVITY_PROJECT_ID"))?; + Ok(result) + } + + pub fn client_builder(&self) -> reqwest::ClientBuilder { + reqwest::Client::builder() + .retry(reqwest::retry::never()) + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(std::time::Duration::from_secs(15)) + .timeout(std::time::Duration::from_secs(180)) + .user_agent(format!( + "claude-code-proxy/{} (Antigravity-compatible; {}; {})", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + std::env::consts::ARCH + )) + } + + pub fn client(&self) -> Result { + self.client_builder() + .build() + .map_err(|_| "Antigravity HTTP client initialization failed") + } +} diff --git a/src/providers/antigravity/mod.rs b/src/providers/antigravity/mod.rs new file mode 100644 index 00000000..6127565f --- /dev/null +++ b/src/providers/antigravity/mod.rs @@ -0,0 +1,358 @@ +//! Experimental, opt-in native Argon subscription transport. +pub(crate) mod auth; +pub(crate) mod config; +mod refresh; +pub(crate) mod replay; +pub(crate) mod request; +pub(crate) mod schema; +pub(crate) mod transport; + +use crate::{ + anthropic::{json_error, schema::MessagesRequest}, + provider::{CliHandlers, Provider, RequestContext}, + providers::codex::native::NativeResponseOutcome, +}; +use async_trait::async_trait; +use auth::{Auth, Store}; +use axum::{ + Json, + body::Body, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use config::Config; +use replay::{Binding, Result}; +use serde_json::{Value, json}; +use std::{sync::Arc, time::Duration}; + +pub struct AntigravityProvider { + auth: Result>, +} +impl Default for AntigravityProvider { + fn default() -> Self { + Self::new() + } +} +impl AntigravityProvider { + pub fn new() -> Self { + Self { + auth: Config::load() + .and_then(|c| Auth::new(c, Store::configured())) + .map(Arc::new), + } + } + #[cfg(test)] + pub(crate) fn with_auth(auth: Auth) -> Self { + Self { + auth: Ok(Arc::new(auth)), + } + } + + async fn generate(&self, body: MessagesRequest, ctx: RequestContext) -> Response { + let auth = match &self.auth { + Ok(auth) => auth, + Err(_) => return invalid("Antigravity configuration is invalid"), + }; + let stored = match auth.store.load() { + Ok(value) => value, + Err(_) => return authentication("Antigravity credentials are invalid or unreadable"), + }; + let binding = binding(auth, &body, stored.as_ref()); + let prepared = match request::prepare(&body, &binding) { + Ok(p) => p, + Err(e) => return invalid(e), + }; + let credential = match auth.access().await { + Ok(c) => c, + Err(error) => { + return match error { + auth::AccessError::PendingPersistence + | auth::AccessError::Lock(_) + | auth::AccessError::Closed => json_error( + StatusCode::SERVICE_UNAVAILABLE, + "api_error", + error.message(), + ), + _ => authentication(error.message()), + }; + } + }; + let current = binding_for(auth, &credential, body.model.as_deref().unwrap_or_default()); + if current.profile_id != binding.profile_id || current.project_id != binding.project_id { + return invalid( + "Antigravity profile/project changed during request; start a fresh session", + ); + } + if current.project_id == "unconfigured" { + return invalid( + "Antigravity project missing; run auth login or configure your authorized project", + ); + } + if let Some(monitor) = ctx.monitor.as_ref() { + monitor.model_resolved(&ctx.req_id, body.model.as_deref().unwrap_or_default()); + } + let endpoint = match auth + .config + .base_url + .join("/v1internal:streamGenerateContent?alt=sse") + { + Ok(v) => v, + Err(_) => return invalid("invalid native endpoint"), + }; + let sent = tokio::time::timeout( + Duration::from_secs(30), + auth.client + .post(endpoint) + .bearer_auth(&credential.access_token) + .header("accept", "text/event-stream") + .json(&prepared.wire) + .send(), + ) + .await; + let upstream = match sent { + Ok(Ok(response)) => response, + _ => return upstream_error("Antigravity native headers timed out or transport failed"), + }; + if !upstream.status().is_success() { + let status = upstream.status(); + let mut response = json_error( + status, + match status { + StatusCode::UNAUTHORIZED => "authentication_error", + StatusCode::FORBIDDEN => "permission_error", + StatusCode::TOO_MANY_REQUESTS => "rate_limit_error", + _ => "api_error", + }, + format!( + "Antigravity upstream returned HTTP {}; no retry or fallback attempted", + status.as_u16() + ), + ); + if let Some(value) = upstream + .headers() + .get("retry-after") + .filter(|v| v.as_bytes().len() <= 128) + { + response.headers_mut().insert("retry-after", value.clone()); + } + return response; + } + if !upstream + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| { + v.split(';') + .next() + .is_some_and(|v| v.trim() == "text/event-stream") + }) + { + return upstream_error("Antigravity returned an unexpected content type"); + } + let model = body.model.unwrap_or_default(); + let work = async move { + let turn = tokio::time::timeout(Duration::from_secs(180), transport::collect(upstream)) + .await + .map_err(|_| "native turn deadline exceeded")??; + if let Some(monitor) = ctx.monitor.as_ref() { + monitor.usage_updated( + &ctx.req_id, + turn.usage["input_tokens"].as_u64(), + turn.usage["output_tokens"].as_u64(), + ); + } + let mapping = prepared.tools.mapping(&turn.contents)?; + let blocks = replay::encode( + &turn.contents, + &binding, + &prepared.session, + &prepared.display, + &mapping, + )?; + Ok::<_, &'static str>(transport::message(blocks, &model, &turn)) + }; + if !body.stream { + return match work.await { + Ok(message) => Json(message).into_response(), + Err(e) => upstream_error(e), + }; + } + // The returned Body owns this future, including reqwest's response. No + // worker survives a downstream drop. Initial HTTP errors occurred above. + let outcome = NativeResponseOutcome::default(); + let shared = outcome.clone(); + let stream = futures_util::stream::once(async move { + let result = work.await.and_then(|message| transport::sse(&message)); + let bytes = match result { + Ok(bytes) => bytes, + Err(error) => { + shared.fail(format!("Antigravity: {error}")); + format!("event: error\ndata: {}\n\n",json!({"type":"error","error":{"type":"api_error","message":format!("Antigravity: {error}")}})).into_bytes() + } + }; + Ok::<_, std::convert::Infallible>(bytes::Bytes::from(bytes)) + }); + let mut response = Response::new(Body::from_stream(stream)); + response.headers_mut().insert( + "content-type", + http::HeaderValue::from_static("text/event-stream"), + ); + response + .headers_mut() + .insert("cache-control", http::HeaderValue::from_static("no-cache")); + response.extensions_mut().insert(outcome); + response + } +} + +fn binding_for(auth: &Auth, credential: &auth::Credential, model: &str) -> Binding { + Binding { + profile_id: credential.profile_id.clone(), + project_id: auth + .config + .project + .clone() + .or_else(|| credential.project_id.clone()) + .unwrap_or_else(|| "unconfigured".into()), + native_model: model + .strip_prefix("antigravity/") + .unwrap_or(model) + .to_string(), + } +} +fn binding(auth: &Auth, body: &MessagesRequest, credential: Option<&auth::Credential>) -> Binding { + let model = body.model.as_deref().unwrap_or_default(); + credential + .map(|c| binding_for(auth, c, model)) + .unwrap_or_else(|| Binding { + profile_id: "unconfigured".into(), + project_id: auth + .config + .project + .clone() + .unwrap_or_else(|| "unconfigured".into()), + native_model: model.strip_prefix("antigravity/").unwrap_or(model).into(), + }) +} +fn invalid(message: &str) -> Response { + json_error(StatusCode::BAD_REQUEST, "invalid_request_error", message) +} +fn authentication(message: &str) -> Response { + json_error(StatusCode::UNAUTHORIZED, "authentication_error", message) +} +fn upstream_error(message: &str) -> Response { + json_error( + StatusCode::BAD_GATEWAY, + "api_error", + format!("Antigravity: {message}"), + ) +} + +#[async_trait] +impl Provider for AntigravityProvider { + fn name(&self) -> &'static str { + "antigravity" + } + fn supported_models(&self) -> Vec { + request::MODELS.iter().map(|m| (*m).into()).collect() + } + fn local_capability(&self) -> Option { + Some(match &self.auth { + Ok(auth) => auth::descriptor(&Ok(auth.config.clone()), &auth.store), + Err(error) => auth::descriptor(&Err(*error), &Store::configured()), + }) + } + fn cli(&self) -> &'static dyn CliHandlers { + &CLI + } + async fn handle_messages(&self, body: MessagesRequest, ctx: RequestContext) -> Response { + let tier = request::effective_tier(body.model.as_deref().unwrap_or_default()); + let mut response = self.generate(body, ctx).await; + if let Some(tier) = tier { + response.headers_mut().insert( + "x-antigravity-reasoning-tier", + http::HeaderValue::from_static(tier), + ); + } + response + } + async fn handle_count_tokens(&self, body: MessagesRequest, _ctx: RequestContext) -> Response { + let auth = match &self.auth { + Ok(auth) => auth, + Err(_) => return invalid("Antigravity configuration is invalid"), + }; + let credential = match auth.store.load() { + Ok(c) => c, + Err(_) => return authentication("Antigravity credentials invalid or unreadable"), + }; + match request::prepare(&body, &binding(auth, &body, credential.as_ref())) { + Ok(p) => Json(json!({"input_tokens":p.estimate})).into_response(), + Err(e) => invalid(e), + } + } + async fn finish_maintenance(&self) -> anyhow::Result<()> { + if let Ok(auth) = &self.auth { + auth.finish_maintenance().await?; + } + Ok(()) + } + // Optional OpenAI generation deliberately retains the trait's 501 default. +} + +pub fn enabled() -> bool { + Config::load().is_ok_and(|c| c.enabled) +} +pub fn known_model(model: &str) -> bool { + request::MODELS.contains(&model) +} +pub fn capability() -> Value { + auth::descriptor(&Config::load(), &Store::configured()) +} +pub fn status_json() -> Value { + let mut value = capability(); + value["schema_version"] = json!(1); + value["provider"] = json!("antigravity"); + value +} + +struct AntigravityCli; +static CLI: AntigravityCli = AntigravityCli; +fn run_auth(action: &str) -> anyhow::Result<()> { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + if action == "logout" { + return runtime + .block_on(Store::configured().logout()) + .map_err(anyhow::Error::msg); + } + let config = Config::load().map_err(anyhow::Error::msg)?; + let auth = Auth::new(config, Store::configured()).map_err(anyhow::Error::msg)?; + runtime + .block_on(async { + match action { + "login" => auth.login().await, + "logout" => auth.logout().await, + _ => Err("unsupported auth operation"), + } + }) + .map_err(anyhow::Error::msg) +} +impl CliHandlers for AntigravityCli { + fn login(&self) -> anyhow::Result<()> { + run_auth("login") + } + fn logout(&self) -> anyhow::Result<()> { + run_auth("logout") + } + fn device(&self) -> anyhow::Result<()> { + anyhow::bail!("Antigravity device flow is unsupported; use explicit browser auth login") + } + fn status(&self) -> anyhow::Result<()> { + println!("{}", status_json()); + Ok(()) + } +} +pub fn cli_handlers() -> &'static dyn CliHandlers { + &CLI +} diff --git a/src/providers/antigravity/refresh.rs b/src/providers/antigravity/refresh.rs new file mode 100644 index 00000000..20777b9b --- /dev/null +++ b/src/providers/antigravity/refresh.rs @@ -0,0 +1,373 @@ +//! Manager-owned credential maintenance, never model generation. Request +//! cancellation releases only its waiter. The stable lock is acquired BEFORE +//! the bounded network phase; a complete validated rotation is then persisted +//! outside that timer, with no cancellation point in the filesystem phase. +use super::{ + auth::{Credential, Lock, Store, bounded_json, now, update_tokens}, + config::Config, +}; +use std::{ + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; +use tokio::{sync::watch, task::JoinHandle}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum AccessError { + Storage(&'static str), + Lock(&'static str), + Missing, + LoginRequired, + ClientConfiguration, + Network, + NetworkTimeout, + InvalidResponse, + PendingPersistence, + ProfileChanged, + UncertainRotation, + Cancelled, + Closed, +} +impl AccessError { + pub fn message(self) -> &'static str { + match self { + Self::Storage(_) => "Antigravity credential storage is invalid or unreadable", + Self::Lock(_) => "Antigravity credential lock is unavailable or timed out", + Self::Missing => "Antigravity authentication missing; run antigravity auth login", + Self::LoginRequired => "Antigravity login required", + Self::ClientConfiguration => "configure CCP_ANTIGRAVITY_CLIENT_ID before refreshing", + Self::Network => { + "Antigravity refresh failed; its outcome may be unknown, no obsolete-token retry attempted" + } + Self::NetworkTimeout => { + "Antigravity refresh network deadline exceeded; no obsolete-token retry attempted" + } + Self::InvalidResponse => { + "Antigravity refresh response invalid; no obsolete-token retry attempted" + } + Self::PendingPersistence => { + "Rotated Antigravity credentials await persistence; repair local storage and retry without starting a new login" + } + Self::ProfileChanged => { + "Antigravity profile or epoch changed; obsolete credentials were not saved" + } + Self::UncertainRotation => { + "Antigravity refresh outcome is unknown; explicit login required instead of retrying the old refresh token" + } + Self::Cancelled => "Antigravity credential waiter cancelled before refresh began", + Self::Closed => "Antigravity credential manager is shutting down", + } + } +} +impl std::fmt::Display for AccessError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.message()) + } +} +impl std::error::Error for AccessError {} +type AccessResult = std::result::Result; + +#[derive(Clone)] +struct Scope { + epoch: String, + profile_id: String, + refresh_token: Option, +} +impl Scope { + fn matches(&self, epoch: &str, credential: &Credential) -> bool { + // Incidental expiry/project/access metadata is not a new login or a + // replacement for the known-obsolete refresh token. A changed epoch, + // profile, or committed refresh token invalidates this recovery scope. + self.epoch == epoch + && self.profile_id == credential.profile_id + && self.refresh_token == credential.refresh_token + } +} +#[derive(Clone)] +struct Pending { + scope: Scope, + credential: Credential, +} +struct Flight { + reply: watch::Receiver>, + waiters: Arc, + task: JoinHandle<()>, +} +#[derive(Default)] +struct State { + flight: Option, + pending: Option, + uncertain: Option, + closed: bool, +} +struct Waiter(Arc); +impl Drop for Waiter { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::AcqRel); + } +} + +pub(super) struct RefreshManager { + store: Store, + client: reqwest::Client, + token_url: url::Url, + client_id: Option, + client_secret: Option, + lock_timeout: Duration, + network_timeout: Duration, + state: Mutex, +} +impl RefreshManager { + pub fn new(store: Store, client: reqwest::Client, config: &Config) -> Arc { + Arc::new(Self { + store, + client, + token_url: config.token_url.clone(), + client_id: config.client_id.clone(), + client_secret: config.client_secret.clone(), + lock_timeout: Duration::from_secs(10), + network_timeout: Duration::from_secs(30), + state: Mutex::new(State::default()), + }) + } + #[cfg(test)] + pub fn with_timeouts(mut self: Arc, lock: Duration, network: Duration) -> Arc { + let manager = Arc::get_mut(&mut self).expect("unused test manager"); + manager.lock_timeout = lock; + manager.network_timeout = network; + self + } + + pub async fn access(self: &Arc) -> AccessResult { + let (mut reply, _waiter) = { + let mut state = self.state.lock().map_err(|_| AccessError::Closed)?; + if state.closed { + return Err(AccessError::Closed); + } + // A reply is published only AFTER all filesystem work is complete. + // Reap completed work; never reuse its credential across a new login. + if state + .flight + .as_ref() + .is_some_and(|flight| flight.reply.borrow().is_some()) + { + state.flight.take(); + } + if let Some(flight) = state.flight.as_ref() { + flight.waiters.fetch_add(1, Ordering::AcqRel); + (flight.reply.clone(), Waiter(flight.waiters.clone())) + } else { + let waiters = Arc::new(AtomicUsize::new(1)); + let (sender, reply) = watch::channel(None); + let manager = self.clone(); + let subscribers = waiters.clone(); + let task = tokio::spawn(async move { + let result = manager.transaction(&subscribers).await; + let _ = sender.send(Some(result)); + }); + state.flight = Some(Flight { + reply: reply.clone(), + waiters: waiters.clone(), + task, + }); + (reply, Waiter(waiters)) + } + }; + loop { + let completed = reply.borrow().clone(); + if let Some(result) = completed { + return result; + } + reply.changed().await.map_err(|_| AccessError::Closed)?; + } + } + + fn clear_recovery(&self) -> std::result::Result<(), AccessError> { + let mut state = self.state.lock().map_err(|_| AccessError::Closed)?; + state.pending = None; + state.uncertain = None; + Ok(()) + } + + /// Called with the stable OS lock held. A pending replacement is never + /// overwritten with, or retried against, the obsolete refresh credential. + fn recover(&self, lock: &mut Lock) -> std::result::Result, AccessError> { + let (pending, uncertain) = { + let state = self.state.lock().map_err(|_| AccessError::Closed)?; + (state.pending.clone(), state.uncertain.clone()) + }; + let epoch = lock.epoch().map_err(|error| { + if pending.is_some() { + AccessError::PendingPersistence + } else { + AccessError::Storage(error) + } + })?; + let mut stored = self.store.load().map_err(|error| { + if pending.is_some() { + AccessError::PendingPersistence + } else { + AccessError::Storage(error) + } + })?; + if let Some(pending) = pending { + if stored + .as_ref() + .is_some_and(|credential| pending.scope.matches(&epoch, credential)) + { + stored = Some(self.persist(lock, pending)?); + } else { + // Logout/new login/another completed refresh owns the new state. + // Missing storage is not permission to resurrect the old profile. + self.clear_recovery()?; + } + } else if let Some(scope) = uncertain { + if stored + .as_ref() + .is_some_and(|credential| scope.matches(&epoch, credential)) + { + return Err(AccessError::UncertainRotation); + } + self.clear_recovery()?; + } + Ok(stored) + } + + /// No await/timer occurs after a validated replacement is retained. The + /// filesystem calls are synchronous and are NOT claimed to be cancellable. + fn persist(&self, lock: &mut Lock, pending: Pending) -> AccessResult { + { + let mut state = self.state.lock().map_err(|_| AccessError::Closed)?; + state.pending = Some(pending.clone()); + } + let epoch = lock.epoch().map_err(|_| AccessError::PendingPersistence)?; + let stored = self + .store + .load() + .map_err(|_| AccessError::PendingPersistence)?; + if !stored + .as_ref() + .is_some_and(|credential| pending.scope.matches(&epoch, credential)) + { + self.clear_recovery()?; + return Err(AccessError::ProfileChanged); + } + self.store + .save(lock, &pending.credential) + .map_err(|_| AccessError::PendingPersistence)?; + self.clear_recovery()?; + Ok(pending.credential) + } + + async fn transaction(&self, waiters: &AtomicUsize) -> AccessResult { + // Lock acquisition has its own bound and precedes the network side effect. + let mut lock = self + .store + .lock(self.lock_timeout) + .await + .map_err(AccessError::Lock)?; + let credential = self.recover(&mut lock)?.ok_or(AccessError::Missing)?; + if credential.expires_at > now().saturating_add(60) { + return Ok(credential); + } + if waiters.load(Ordering::Acquire) == 0 { + return Err(AccessError::Cancelled); + } + let refresh = credential + .refresh_token + .as_deref() + .ok_or(AccessError::LoginRequired)?; + let client_id = self + .client_id + .as_deref() + .ok_or(AccessError::ClientConfiguration)?; + let scope = Scope { + epoch: lock.epoch().map_err(AccessError::Storage)?, + profile_id: credential.profile_id.clone(), + refresh_token: credential.refresh_token.clone(), + }; + { + let mut state = self.state.lock().map_err(|_| AccessError::Closed)?; + state.uncertain = Some(scope.clone()); + } + let mut form = vec![ + ("grant_type", "refresh_token"), + ("refresh_token", refresh), + ("client_id", client_id), + ]; + if let Some(secret) = self.client_secret.as_deref() { + form.push(("client_secret", secret)); + } + let replacement = tokio::time::timeout(self.network_timeout, async { + let response = self + .client + .post(self.token_url.clone()) + .timeout(self.network_timeout) + .form(&form) + .send() + .await + .map_err(|_| AccessError::Network)?; + let token = bounded_json(response) + .await + .map_err(|_| AccessError::InvalidResponse)?; + let mut replacement = credential.clone(); + update_tokens(&mut replacement, &token).map_err(|_| AccessError::InvalidResponse)?; + Ok::<_, AccessError>(replacement) + }) + .await + .map_err(|_| AccessError::NetworkTimeout)??; + // The manager's network deadline ended above. A disconnected waiter or + // elapsed network timer cannot discard the now validated replacement. + self.persist( + &mut lock, + Pending { + scope, + credential: replacement, + }, + ) + } + + /// Quiesce new waiters, join owned maintenance, and retry ONLY a pending local + /// write. No refresh or inference is initiated during graceful shutdown. + pub async fn shutdown(&self) -> std::result::Result<(), AccessError> { + let reply = { + let mut state = self.state.lock().map_err(|_| AccessError::Closed)?; + state.closed = true; + state.flight.as_ref().map(|flight| flight.reply.clone()) + }; + // Keep the task handle owned by the manager if a shutdown waiter itself + // is cancelled. Only reap after the transaction publishes completion. + if let Some(mut reply) = reply { + while reply.borrow().is_none() { + if reply.changed().await.is_err() { + break; + } + } + } + let flight = self + .state + .lock() + .map_err(|_| AccessError::Closed)? + .flight + .take(); + if let Some(flight) = flight { + flight.task.await.map_err(|_| AccessError::Closed)?; + } + let recovery = { + let state = self.state.lock().map_err(|_| AccessError::Closed)?; + state.pending.is_some() || state.uncertain.is_some() + }; + if recovery { + let mut lock = self + .store + .lock(self.lock_timeout) + .await + .map_err(AccessError::Lock)?; + self.recover(&mut lock)?; + } + Ok(()) + } +} diff --git a/src/providers/antigravity/replay.rs b/src/providers/antigravity/replay.rs new file mode 100644 index 00000000..db6882f6 --- /dev/null +++ b/src/providers/antigravity/replay.rs @@ -0,0 +1,692 @@ +//! Exact frozen v1 transcript transport. The digest detects inconsistency, not forgery. +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use serde::{ + Deserialize, + de::{self, MapAccess, SeqAccess, Visitor}, +}; +use serde_json::{Map, Value, json}; +use sha2::{Digest, Sha256}; +use std::collections::HashSet; + +pub(crate) type Result = std::result::Result; +pub const PREFIX: &str = "ccp:antigravity:v1:"; +pub const MAX_DECODED: usize = 8 * 1024 * 1024; +pub const MAX_ENCODED: usize = 12 * 1024 * 1024; +pub const MAX_ARGUMENT: usize = 1024 * 1024; +pub const MAX_DEPTH: usize = 64; + +#[derive(Clone, Deserialize, serde::Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Binding { + pub profile_id: String, + pub project_id: String, + pub native_model: String, +} + +pub(crate) fn keys(value: &Value, allowed: &[&str], required: &[&str]) -> Result<()> { + let object = value.as_object().ok_or("expected object")?; + if object.keys().any(|key| !allowed.contains(&key.as_str())) + || required.iter().any(|key| !object.contains_key(*key)) + { + return Err("unsupported or missing fields"); + } + Ok(()) +} + +pub(crate) fn nonempty(value: &Value) -> Result<&str> { + value + .as_str() + .filter(|s| !s.is_empty()) + .ok_or("expected nonempty string") +} + +// Check depth before deserializing; the visitor also rejects duplicate keys at +// every level (serde_json::Value's default parser otherwise keeps the last one). +pub(crate) fn parse_json(raw: &[u8], max: usize) -> Result { + if raw.len() > max { + return Err("JSON byte limit"); + } + let mut depth = 0_usize; + let mut quoted = false; + let mut escape = false; + for byte in raw { + if quoted { + if escape { + escape = false; + } else if *byte == b'\\' { + escape = true; + } else if *byte == b'"' { + quoted = false; + } + } else if *byte == b'"' { + quoted = true; + } else if matches!(byte, b'[' | b'{') { + depth += 1; + if depth > MAX_DEPTH { + return Err("JSON depth limit"); + } + } else if matches!(byte, b']' | b'}') { + depth = depth.saturating_sub(1); + } + } + let mut decoder = serde_json::Deserializer::from_slice(raw); + let value = StrictValue::deserialize(&mut decoder).map_err(|_| "invalid JSON")?; + decoder.end().map_err(|_| "invalid JSON")?; + Ok(value.0) +} + +struct StrictValue(Value); +impl<'de> Deserialize<'de> for StrictValue { + fn deserialize>(decoder: D) -> std::result::Result { + struct StrictVisitor; + impl<'de> Visitor<'de> for StrictVisitor { + type Value = StrictValue; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("unique-key JSON") + } + fn visit_unit(self) -> std::result::Result { + Ok(StrictValue(Value::Null)) + } + fn visit_bool(self, value: bool) -> std::result::Result { + Ok(StrictValue(json!(value))) + } + fn visit_i64(self, value: i64) -> std::result::Result { + Ok(StrictValue(json!(value))) + } + fn visit_u64(self, value: u64) -> std::result::Result { + Ok(StrictValue(json!(value))) + } + fn visit_f64(self, value: f64) -> std::result::Result { + serde_json::Number::from_f64(value) + .map(|v| StrictValue(Value::Number(v))) + .ok_or_else(|| E::custom("nonfinite number")) + } + fn visit_str(self, value: &str) -> std::result::Result { + Ok(StrictValue(json!(value))) + } + fn visit_seq>( + self, + mut seq: A, + ) -> std::result::Result { + let mut values = Vec::new(); + while let Some(StrictValue(value)) = seq.next_element()? { + values.push(value); + } + Ok(StrictValue(Value::Array(values))) + } + fn visit_map>( + self, + mut map: A, + ) -> std::result::Result { + let mut values = Map::new(); + while let Some((key, StrictValue(value))) = + map.next_entry::()? + { + if values.insert(key, value).is_some() { + return Err(de::Error::custom("duplicate key")); + } + } + Ok(StrictValue(Value::Object(values))) + } + } + decoder.deserialize_any(StrictVisitor) + } +} + +pub(crate) fn canonical(value: &Value) -> Result> { + fn tagged(value: &Value, depth: usize) -> Result { + if depth > MAX_DEPTH { + return Err("JSON depth limit"); + } + Ok(match value { + Value::Null => json!(["null"]), + Value::Bool(v) => json!(["bool", v]), + Value::String(v) => json!(["string", v]), + Value::Number(v) => { + let n = v.as_f64().ok_or("invalid number")?; + if !n.is_finite() || (n.fract() == 0.0 && n.abs() > 9007199254740991.0) { + return Err("unsafe number"); + } + let bits = if n == 0.0 { 0 } else { n.to_bits() }; + json!(["number", format!("{bits:016x}")]) + } + Value::Array(v) => json!([ + "array", + v.iter() + .map(|v| tagged(v, depth + 1)) + .collect::>>()? + ]), + Value::Object(v) => { + let mut pairs = v.iter().collect::>(); + pairs.sort_by(|a, b| a.0.cmp(b.0)); + json!([ + "object", + pairs + .into_iter() + .map(|(k, v)| tagged(v, depth + 1).map(|v| json!([k, v]))) + .collect::>>()? + ]) + } + }) + } + serde_json::to_vec(&tagged(value, 0)?).map_err(|_| "JSON serialization failed") +} + +pub(crate) fn session_id(value: &str) -> Result<&str> { + if value.len() > 20 { + return Err("invalid session ID"); + } + let n = value.parse::().map_err(|_| "invalid session ID")?; + if n == 0 || n.to_string() != value { + return Err("invalid session ID"); + } + Ok(value) +} + +pub(crate) fn mint_session() -> String { + ((rand::random::() >> 1).max(1)).to_string() +} + +fn display(value: &str) -> Result<()> { + if matches!(value, "full" | "omitted") { + Ok(()) + } else { + Err("invalid thought display") + } +} + +type Call<'a> = (usize, usize, &'a Value); + +/// Omission means an empty completed call in the caller projection only. Never +/// insert this default into the saved native content/signature structure. +pub(crate) fn call_arguments(call: &Value) -> Result<&Value> { + static EMPTY: once_cell::sync::Lazy = once_cell::sync::Lazy::new(|| json!({})); + if call.get("partialArgs").is_some() || call.get("willContinue").is_some() { + return Err("incremental native function calls are unsupported"); + } + match call.get("args") { + None => Ok(&EMPTY), + Some(value) if value.is_object() => Ok(value), + Some(_) => Err("invalid native call arguments"), + } +} +pub(crate) fn calls(contents: &Value) -> Result>> { + let contents = contents + .as_array() + .filter(|v| v.len() <= 512) + .ok_or("native content limit/type")?; + let mut calls = Vec::new(); + let mut idless = HashSet::new(); + let mut part_count = 0; + for (ci, content) in contents.iter().enumerate() { + if content["role"] != "model" { + return Err("invalid native content role"); + } + let parts = content["parts"].as_array().ok_or("invalid native parts")?; + part_count += parts.len(); + if part_count > 4096 { + return Err("native part limit"); + } + for (pi, part) in parts.iter().enumerate() { + if !part.is_object() || part.get("text").is_some_and(|v| !v.is_string()) { + return Err("invalid native part"); + } + let Some(call) = part.get("functionCall") else { + continue; + }; + let name = nonempty(&call["name"])?; + call_arguments(call)?; + if let Some(id) = call.get("id") { + nonempty(id)?; + } else if !idless.insert(name) { + return Err("ambiguous same-name ID-less parallel calls"); + } + calls.push((ci, pi, call)); + if calls.len() > 128 { + return Err("tool count limit"); + } + } + } + Ok(calls) +} + +fn pointer(path: &Value) -> Result> { + let path = path + .as_str() + .filter(|s| s.starts_with('/') && s.len() <= 1024) + .ok_or("invalid codec path")?; + let mut tokens = Vec::new(); + for token in path[1..].split('/') { + let mut chars = token.chars(); + let mut decoded = String::new(); + while let Some(c) = chars.next() { + decoded.push(if c != '~' { + c + } else { + match chars.next() { + Some('0') => '~', + Some('1') => '/', + _ => return Err("invalid codec escape"), + } + }); + } + tokens.push(decoded); + } + if tokens.len() > MAX_DEPTH { + return Err("codec path depth limit"); + } + Ok(tokens) +} + +fn decode_leaf(value: &Value) -> Result { + let raw = value.as_str().ok_or("codec leaf must be a JSON string")?; + let value = parse_json(raw.as_bytes(), MAX_ARGUMENT)?; + canonical(&value)?; + Ok(value) +} + +pub(crate) fn decode_arguments(args: &Value, paths: &Value, root: &Value) -> Result { + let paths = paths + .as_array() + .filter(|v| v.len() <= 64) + .ok_or("invalid codec paths")?; + if !args.is_object() { + return Err("arguments must be object"); + } + let result = if !root.is_null() { + let key = nonempty(root)?; + if key.len() > 256 + || !paths.is_empty() + || args.as_object().unwrap().len() != 1 + || args.get(key).is_none() + { + return Err("invalid root wrapper"); + } + let result = decode_leaf(&args[key])?; + if !result.is_object() { + return Err("root codec must decode object"); + } + result + } else { + let paths = paths.iter().map(pointer).collect::>>()?; + for (i, left) in paths.iter().enumerate() { + if paths[i + 1..] + .iter() + .any(|right| left.starts_with(right) || right.starts_with(left)) + { + return Err("overlapping codec paths"); + } + } + let mut result = args.clone(); + for tokens in paths { + let mut current = &mut result; + for token in &tokens { + current = match current { + Value::Object(object) => object.get_mut(token).ok_or("missing codec key")?, + Value::Array(array) => { + let index = token + .parse::() + .map_err(|_| "invalid codec array index")?; + if token.len() > 10 || index.to_string() != *token { + return Err("invalid codec array index"); + } + array + .get_mut(index) + .ok_or("codec array index out of range")? + } + _ => return Err("codec path traverses scalar"), + }; + } + *current = decode_leaf(current)?; + } + result + }; + canonical(&result)?; + if serde_json::to_vec(&result) + .map_err(|_| "invalid arguments")? + .len() + > MAX_ARGUMENT + { + return Err("argument byte limit"); + } + Ok(result) +} + +pub(crate) fn mapping(contents: &Value, recipes: Option<&Value>) -> Result { + let calls = calls(contents)?; + let recipes = recipes + .map(|v| { + v.as_array() + .filter(|v| v.len() == calls.len()) + .ok_or("recipe count mismatch") + }) + .transpose()?; + let turn = uuid::Uuid::new_v4().simple().to_string(); + let mut mappings = Vec::new(); + for (i, (ci, pi, call)) in calls.iter().enumerate() { + let empty = json!({}); + let recipe = recipes.map(|v| &v[i]).unwrap_or(&empty); + keys( + recipe, + &["local_id", "caller_name", "codec_paths", "root_wrapper"], + &[], + )?; + let mut entry = json!({ + "local_id": recipe.get("local_id").cloned().unwrap_or_else(|| json!(format!("toolu_{turn}_{i:04}"))), + "caller_name": recipe.get("caller_name").unwrap_or(&call["name"]), + "content_index": ci, "part_index": pi, "native_id_present": call.get("id").is_some(), + "codec_paths": recipe.get("codec_paths").cloned().unwrap_or_else(|| json!([])), + "root_wrapper": recipe.get("root_wrapper").cloned().unwrap_or(Value::Null) + }); + if let Some(id) = call.get("id") { + entry["native_id"] = id.clone(); + } + mappings.push(entry); + } + Ok(json!(mappings)) +} + +pub(crate) fn projection( + contents: &Value, + thought_display: &str, + mapping: &Value, +) -> Result { + display(thought_display)?; + let calls = calls(contents)?; + let entries = mapping + .as_array() + .filter(|v| v.len() == calls.len()) + .ok_or("mapping count mismatch")?; + let mut ids = HashSet::new(); + let mut projected_calls = std::collections::BTreeMap::new(); + for ((ci, pi, call), entry) in calls.iter().zip(entries) { + let mut required = vec![ + "local_id", + "caller_name", + "content_index", + "part_index", + "native_id_present", + "codec_paths", + "root_wrapper", + ]; + if call.get("id").is_some() { + required.push("native_id"); + } + keys(entry, &required, &required)?; + if entry["content_index"].as_u64() != Some(*ci as u64) + || entry["part_index"].as_u64() != Some(*pi as u64) + || entry["native_id_present"].as_bool() != Some(call.get("id").is_some()) + || entry.get("native_id") != call.get("id") + { + return Err("mapping native position/ID mismatch"); + } + let id = nonempty(&entry["local_id"])?; + if id.len() > 128 + || !id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-')) + || !ids.insert(id) + { + return Err("invalid/duplicate local ID"); + } + if nonempty(&entry["caller_name"])?.len() > 1024 { + return Err("caller name limit"); + } + let args = decode_arguments( + call_arguments(call)?, + &entry["codec_paths"], + &entry["root_wrapper"], + )?; + projected_calls.insert( + (*ci, *pi), + json!({"type":"tool_use", "id":id, "name":entry["caller_name"], "input":args}), + ); + } + let mut thoughts = String::new(); + let mut blocks = Vec::new(); + for (ci, content) in contents.as_array().unwrap().iter().enumerate() { + for (pi, part) in content["parts"].as_array().unwrap().iter().enumerate() { + if let Some(text) = part.get("text").and_then(Value::as_str) { + if part["thought"] == true { + thoughts.push_str(text); + } else if !text.is_empty() { + blocks.push(json!({"type":"text", "text":text})); + } + } + if let Some(block) = projected_calls.remove(&(ci, pi)) { + blocks.push(block); + } + } + } + blocks.insert(0, json!({"type":"thinking", "thinking":if thought_display == "omitted" { "" } else { &thoughts }})); + Ok(json!(blocks)) +} + +/// Count bounded projected semantic bytes without serializing a large argument +/// into a second allocation. Signatures and cache hints are transport metadata. +pub(crate) fn projected_block_bytes(block: &Value) -> Result { + match block["type"].as_str() { + Some("text") => block["text"] + .as_str() + .map(str::len) + .ok_or("invalid display text"), + Some("thinking") => block["thinking"] + .as_str() + .map(str::len) + .ok_or("invalid display thinking"), + Some("tool_use") => { + struct Count(usize); + impl std::io::Write for Count { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + if self.0.saturating_add(bytes.len()) > MAX_ARGUMENT { + return Err(std::io::Error::other("argument byte limit")); + } + self.0 += bytes.len(); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + if !block["input"].is_object() { + return Err("invalid tool input"); + } + let mut count = Count(0); + serde_json::to_writer(&mut count, &block["input"]) + .map_err(|_| "argument byte limit")?; + Ok(count.0 + nonempty(&block["name"])?.len() + nonempty(&block["id"])?.len()) + } + _ => Err("unsupported projection block"), + } +} + +pub(crate) fn projection_values(blocks: &Value) -> Result { + let blocks = blocks + .as_array() + .filter(|b| b.len() <= 4097) + .ok_or("invalid projection blocks")?; + let mut result: Vec = Vec::new(); + let mut semantic_bytes = 0_usize; + for block in blocks { + semantic_bytes = semantic_bytes + .checked_add(projected_block_bytes(block)?) + .ok_or("projection byte limit")?; + if semantic_bytes > MAX_DECODED { + return Err("projection byte limit"); + } + let kind = block["type"].as_str().ok_or("invalid block type")?; + let mut allowed = vec!["type", "signature", "cache_control"]; + let item = match kind { + "thinking" | "text" => { + allowed.push(kind); + let text = block[kind].as_str().ok_or("invalid display text")?; + json!({"type":kind, kind:text}) + } + "tool_use" => { + allowed.extend(["id", "name", "input"]); + nonempty(&block["id"])?; + nonempty(&block["name"])?; + if !block["input"].is_object() { + return Err("invalid tool input"); + } + json!({"type":kind, "id":block["id"], "name":block["name"], "input":block["input"]}) + } + _ => return Err("unsupported projection block"), + }; + keys(block, &allowed, &["type"])?; + if kind == "text" && result.last().is_some_and(|b| b["type"] == "text") { + let last = result.last_mut().unwrap(); + let Value::String(text) = &mut last["text"] else { + unreachable!("validated text block") + }; + text.push_str(item["text"].as_str().unwrap()); + } else { + result.push(item); + } + } + Ok(json!(result)) +} + +pub(crate) fn digest_input(blocks: &Value, envelope: &Value) -> Result> { + let mut binding = Map::new(); + for key in [ + "profile_id", + "project_id", + "native_model", + "session_id", + "thought_display", + "tool_mapping", + ] { + binding.insert( + key.to_string(), + envelope.get(key).ok_or("missing binding")?.clone(), + ); + } + canonical(&json!({"binding":binding, "blocks":projection_values(blocks)?})) +} + +fn digest(blocks: &Value, envelope: &Value) -> Result { + Ok(format!( + "{:x}", + Sha256::digest(digest_input(blocks, envelope)?) + )) +} + +pub(crate) fn encode( + contents: &Value, + binding: &Binding, + session: &str, + thought_display: &str, + mapping: &Value, +) -> Result { + session_id(session)?; + let mut blocks = projection(contents, thought_display, mapping)?; + let mut envelope = serde_json::to_value(binding).map_err(|_| "invalid binding")?; + if [ + &binding.profile_id, + &binding.project_id, + &binding.native_model, + ] + .iter() + .any(|v| v.is_empty()) + { + return Err("empty binding"); + } + let fields = json!({"version":1, "session_id":session, "thought_display":thought_display, "contents":contents, "tool_mapping":mapping}); + envelope + .as_object_mut() + .unwrap() + .extend(fields.as_object().unwrap().clone()); + envelope["projection_digest"] = json!(digest(&blocks, &envelope)?); + let raw = serde_json::to_vec(&envelope).map_err(|_| "invalid envelope")?; + // Same bounded parser as replay; don't emit a carrier we cannot decode. + parse_json(&raw, MAX_DECODED)?; + let encoded = URL_SAFE_NO_PAD.encode(raw); + if encoded.len() > MAX_ENCODED { + return Err("encoded byte limit"); + } + blocks[0]["signature"] = json!(format!("{PREFIX}{encoded}")); + Ok(blocks) +} + +pub(crate) fn decode(blocks: &Value, binding: &Binding) -> Result { + let list = blocks + .as_array() + .filter(|b| !b.is_empty()) + .ok_or("missing carrier")?; + if list[0]["type"] != "thinking" || list.iter().filter(|b| b["type"] == "thinking").count() != 1 + { + return Err("missing/duplicate/nonleading carrier"); + } + let encoded = list[0]["signature"] + .as_str() + .and_then(|s| s.strip_prefix(PREFIX)) + .ok_or("missing/foreign carrier")?; + if encoded.is_empty() || encoded.len() > MAX_ENCODED || encoded.len() * 3 / 4 > MAX_DECODED { + return Err("carrier byte limit"); + } + let raw = URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| "malformed carrier")?; + let envelope = parse_json(&raw, MAX_DECODED)?; + let fields = [ + "version", + "profile_id", + "project_id", + "native_model", + "session_id", + "thought_display", + "contents", + "tool_mapping", + "projection_digest", + ]; + keys(&envelope, &fields, &fields)?; + if envelope["version"].as_u64() != Some(1) { + return Err("unsupported carrier version"); + } + for (key, value) in [ + ("profile_id", &binding.profile_id), + ("project_id", &binding.project_id), + ("native_model", &binding.native_model), + ] { + if value.is_empty() || envelope[key].as_str() != Some(value) { + return Err("model/profile/project binding mismatch; start a fresh session"); + } + } + session_id(nonempty(&envelope["session_id"])?)?; + let expected = projection( + &envelope["contents"], + nonempty(&envelope["thought_display"])?, + &envelope["tool_mapping"], + )?; + let hash = digest(&expected, &envelope)?; + if envelope["projection_digest"] != hash || digest(blocks, &envelope)? != hash { + return Err("projection digest mismatch"); + } + Ok(envelope) +} + +pub(crate) fn decode_history(turns: &Value, binding: &Binding) -> Result> { + let turns = turns + .as_array() + .filter(|t| t.len() <= 512) + .ok_or("history limit/type")?; + let mut result = Vec::new(); + let mut session = None; + let mut ids = HashSet::new(); + for turn in turns { + let envelope = decode(turn, binding)?; + let current = envelope["session_id"].as_str().unwrap().to_string(); + if session.as_ref().is_some_and(|s| s != ¤t) { + return Err("conflicting session IDs"); + } + session = Some(current); + for entry in envelope["tool_mapping"].as_array().unwrap() { + if !ids.insert(entry["local_id"].as_str().unwrap().to_string()) { + return Err("duplicate local ID across history"); + } + } + result.push(envelope); + } + Ok(result) +} diff --git a/src/providers/antigravity/request.rs b/src/providers/antigravity/request.rs new file mode 100644 index 00000000..77511762 --- /dev/null +++ b/src/providers/antigravity/request.rs @@ -0,0 +1,439 @@ +use super::{ + replay::{self, Binding, Result, keys, nonempty}, + schema::ToolSet, +}; +use crate::anthropic::schema::{Message, MessagesRequest}; +use serde_json::{Value, json}; +use std::borrow::Cow; +use std::collections::{BTreeMap, HashSet}; + +pub const MODELS: &[&str] = &[ + "antigravity/gemini-4-argon-low", + "antigravity/gemini-4-argon-medium", + "antigravity/gemini-4-argon-high", +]; + +pub(crate) fn effective_tier(model: &str) -> Option<&'static str> { + match model { + "antigravity/gemini-4-argon-low" => Some("low"), + "antigravity/gemini-4-argon-medium" => Some("medium"), + "antigravity/gemini-4-argon-high" => Some("high"), + _ => None, + } +} + +pub(crate) struct Prepared { + pub wire: Value, + pub session: String, + pub display: String, + pub tools: ToolSet, + pub estimate: u64, +} + +pub(crate) fn cache_hint(value: Option<&Value>) -> Result<()> { + let Some(value) = value else { return Ok(()) }; + keys(value, &["type", "ttl"], &["type"])?; + if value["type"] != "ephemeral" + || value + .get("ttl") + .is_some_and(|v| !matches!(v.as_str(), Some("5m" | "1h"))) + { + return Err("unsupported cache hint"); + } + Ok(()) +} + +pub(crate) fn validate_raw(raw: &[u8]) -> Result<()> { + let value = replay::parse_json(raw, crate::anthropic::MAX_ANTHROPIC_REQUEST_BYTES)?; + for message in value["messages"] + .as_array() + .ok_or("messages must be an array")? + { + keys(message, &["role", "content"], &["role", "content"])?; + } + Ok(()) +} + +fn text_parts(value: &Value) -> Result> { + if let Some(text) = value.as_str() { + return Ok(vec![json!({"text":text})]); + } + let mut parts = Vec::new(); + for block in value + .as_array() + .ok_or("content must be text or text blocks")? + { + keys(block, &["type", "text", "cache_control"], &["type", "text"])?; + if block["type"] != "text" { + return Err("non-text system/tool-result content is unsupported"); + } + cache_hint(block.get("cache_control"))?; + parts.push(json!({"text":block["text"].as_str().ok_or("text must be a string")?})); + } + Ok(parts) +} + +fn options(body: &MessagesRequest) -> Result<&'static str> { + let allowed = [ + "system", + "tools", + "thinking", + "output_config", + "context_management", + "metadata", + "tool_choice", + "cache_control", + ]; + if body.extra.keys().any(|k| !allowed.contains(&k.as_str())) { + return Err("unsupported Antigravity semantic request option"); + } + let mut display = "full"; + if let Some(thinking) = body.extra.get("thinking") { + keys(thinking, &["type", "display"], &["type"])?; + if thinking["type"] != "adaptive" { + return Err( + "Antigravity supports adaptive thinking only; model tier controls reasoning", + ); + } + if let Some(value) = thinking.get("display") { + if value != "omitted" { + return Err("unsupported thinking display"); + } + display = "omitted"; + } + } + if let Some(output) = body.extra.get("output_config") { + keys(output, &["effort"], &[])?; + if output + .get("effort") + .is_some_and(|v| !matches!(v.as_str(), Some("low" | "medium" | "high" | "max"))) + { + return Err("unsupported advisory effort"); + } + } + if let Some(context) = body.extra.get("context_management") { + keys(context, &["edits"], &["edits"])?; + for edit in context["edits"] + .as_array() + .ok_or("context edits must be an array")? + { + if edit != &json!({"type":"clear_thinking_20251015","keep":"all"}) { + return Err("only keep-all thinking context edits are supported"); + } + } + } + if let Some(metadata) = body.extra.get("metadata") { + keys(metadata, &["user_id"], &[])?; + if metadata.get("user_id").is_some_and(|v| !v.is_string()) { + return Err("invalid metadata"); + } + } + cache_hint(body.extra.get("cache_control"))?; + Ok(display) +} + +/// Establish logical assistant boundaries in the ORIGINAL role sequence. A +/// real system message is a boundary, even though its text is extracted later. +/// A new leading carrier starts a distinct complete turn; only carrier-less +/// adjacent fragments can extend the preceding turn and must pass its digest. +fn logical_messages(messages: &[Message]) -> Result>> { + let mut output: Vec> = Vec::new(); + let mut current_bytes = 0_usize; + for message in messages { + if message.role != "assistant" { + output.push(Cow::Borrowed(message)); + current_bytes = 0; + continue; + } + let converted; + let blocks = if let Some(text) = message.content.as_str() { + converted = vec![json!({"type":"text", "text":text})]; + &converted + } else { + message + .content + .as_array() + .ok_or("invalid assistant content")? + }; + if blocks.is_empty() { + continue; + } + let bytes = blocks.iter().try_fold(0_usize, |total, block| { + total + .checked_add(replay::projected_block_bytes(block)?) + .filter(|n| *n <= replay::MAX_DECODED) + .ok_or("assistant projection byte limit") + })?; + if blocks.len() > 4097 { + return Err("assistant projection block limit"); + } + let starts_turn = blocks + .first() + .is_some_and(|block| block["type"] == "thinking"); + let append = !starts_turn + && output + .last() + .is_some_and(|previous| previous.role == "assistant"); + if append { + let previous = output.last_mut().unwrap(); + let count = previous.content.as_array().map_or(1, Vec::len); + if count.saturating_add(blocks.len()) > 4097 { + return Err("assistant projection block limit"); + } + current_bytes = current_bytes + .checked_add(bytes) + .filter(|n| *n <= replay::MAX_DECODED) + .ok_or("assistant projection byte limit")?; + previous + .to_mut() + .content + .as_array_mut() + .expect("normalized assistant array") + .extend(blocks.iter().cloned()); + } else { + current_bytes = bytes; + output.push(if message.content.is_array() { + Cow::Borrowed(message) + } else { + Cow::Owned(Message { + role: message.role.clone(), + content: json!(blocks), + }) + }); + } + } + Ok(output) +} + +pub(crate) fn prepare(body: &MessagesRequest, binding: &Binding) -> Result { + let model = body.model.as_deref().ok_or("missing model")?; + if !MODELS.contains(&model) || model.strip_prefix("antigravity/") != Some(&binding.native_model) + { + return Err("unsupported Antigravity model or replay scope"); + } + let tier = match model { + "antigravity/gemini-4-argon-low" => "LOW", + "antigravity/gemini-4-argon-medium" => "MEDIUM", + _ => "HIGH", + }; + let cap = body.max_tokens.unwrap_or(8192); + if cap == 0 || cap > 65536 { + return Err("max_tokens must be between 1 and 65536"); + } + let display = options(body)?.to_owned(); + if body.messages.is_empty() || body.messages.len() > 512 { + return Err("message count limit"); + } + let messages = logical_messages(&body.messages)?; + let mut tools = ToolSet::compile(body.extra.get("tools").unwrap_or(&json!([])))?; + if let Some(choice) = body.extra.get("tool_choice") { + keys(choice, &["type"], &["type"])?; + match choice["type"].as_str() { + Some("auto") => {} + Some("none") => tools = ToolSet::compile(&json!([]))?, + _ => return Err("forced tool choice/parallel-disable semantics are unsupported"), + } + } + let mut system = body + .extra + .get("system") + .map(text_parts) + .transpose()? + .unwrap_or_default(); + for message in &messages { + if message.role == "system" { + system.extend(text_parts(&message.content)?); + } + } + let turns = messages + .iter() + .filter(|m| m.role == "assistant") + .map(|m| m.content.clone()) + .collect::>(); + let envelopes = replay::decode_history(&json!(turns), binding)?; + let session = envelopes + .first() + .map(|e| e["session_id"].as_str().unwrap().to_string()) + .unwrap_or_else(replay::mint_session); + let mut history = envelopes.into_iter(); + let mut pending: Vec<(Value, Value)> = Vec::new(); + let mut contents = Vec::new(); + let mut last_role = ""; + let mut estimate_bytes = serde_json::to_vec(&system) + .map_err(|_| "invalid system")? + .len(); + estimate_bytes += serde_json::to_vec(&tools.declarations) + .map_err(|_| "invalid tools")? + .len(); + for message in &messages { + if message.role == "system" { + continue; + } + if message.role == "assistant" { + if !pending.is_empty() { + return Err("missing tool results before next assistant turn"); + } + let envelope = history.next().ok_or("missing replay envelope")?; + for entry in envelope["tool_mapping"].as_array().unwrap() { + let ci = entry["content_index"].as_u64().unwrap() as usize; + let pi = entry["part_index"].as_u64().unwrap() as usize; + pending.push(( + entry.clone(), + envelope["contents"][ci]["parts"][pi]["functionCall"].clone(), + )); + } + // Count the validated native semantic history in a canonical FULL + // projection, not the client's display or opaque carrier. This also + // applies recorded caller-name and JSON codecs without current schemas. + let semantic = replay::projection_values(&replay::projection( + &envelope["contents"], + "full", + &envelope["tool_mapping"], + )?)?; + for block in semantic.as_array().unwrap() { + for key in ["text", "thinking", "input", "name"] { + if let Some(value) = block.get(key) { + estimate_bytes += serde_json::to_vec(value) + .map_err(|_| "invalid history")? + .len(); + } + } + } + let native = envelope["contents"].as_array().unwrap(); + if contents.len().saturating_add(native.len()) > 1024 { + return Err("native history content limit"); + } + contents.extend(native.iter().cloned()); + } else if message.role == "user" { + let blocks = if let Some(text) = message.content.as_str() { + vec![json!({"type":"text","text":text})] + } else { + message + .content + .as_array() + .ok_or("invalid user content")? + .clone() + }; + let mut results = BTreeMap::new(); + let mut text = Vec::new(); + let mut saw_text = false; + let known: HashSet<&str> = pending + .iter() + .map(|(e, _)| e["local_id"].as_str().unwrap()) + .collect(); + for block in blocks { + match block["type"].as_str() { + Some("text") => { + keys( + &block, + &["type", "text", "cache_control"], + &["type", "text"], + )?; + cache_hint(block.get("cache_control"))?; + if results.len() != pending.len() { + return Err("tool results must form a complete prefix before text"); + } + saw_text = true; + let value = block["text"].as_str().ok_or("invalid user text")?; + estimate_bytes += value.len(); + text.push(json!({"text":value})); + } + Some("tool_result") => { + keys( + &block, + &[ + "type", + "tool_use_id", + "content", + "is_error", + "cache_control", + ], + &["type", "tool_use_id"], + )?; + cache_hint(block.get("cache_control"))?; + let id = nonempty(&block["tool_use_id"])?; + if saw_text || !known.contains(id) || results.contains_key(id) { + return Err("ambiguous/unknown/interleaved tool result"); + } + let value = match block.get("content") { + None => json!(""), + Some(Value::String(s)) => json!(s), + Some(value) => json!( + text_parts(value)? + .iter() + .map(|p| p["text"].clone()) + .collect::>() + ), + }; + estimate_bytes += serde_json::to_vec(&value) + .map_err(|_| "invalid tool result")? + .len(); + let is_error = block + .get("is_error") + .map(|v| v.as_bool().ok_or("invalid is_error flag")) + .transpose()? + .unwrap_or(false); + results.insert( + id.to_string(), + if is_error { + json!({"error":value,"is_error":true}) + } else { + json!({"output":value}) + }, + ); + } + _ => return Err("unsupported user content/media"), + } + } + if results.len() != pending.len() { + return Err("incomplete tool result batch"); + } + if !pending.is_empty() { + if contents.len() >= 1024 { + return Err("native history content limit"); + } + let mut parts = Vec::new(); + for (entry, call) in pending.drain(..) { + let mut response = json!({"name":call["name"],"response":results.remove(entry["local_id"].as_str().unwrap()).unwrap()}); + if let Some(id) = call.get("id") { + response["id"] = id.clone(); + } + parts.push(json!({"functionResponse":response})); + } + contents.push(json!({"role":"model","parts":parts})); + } + if !text.is_empty() { + if contents.len() >= 1024 { + return Err("native history content limit"); + } + contents.push(json!({"role":"user","parts":text})); + } + } else { + return Err("unsupported message role"); + } + last_role = &message.role; + } + if contents.is_empty() || last_role != "user" || !pending.is_empty() { + return Err("request needs a final user turn with complete tool results"); + } + if contents.len() > 1024 { + return Err("native history content limit"); + } + let mut request = json!({"contents":contents,"sessionId":session, + "generationConfig":{"maxOutputTokens":cap,"thinkingConfig":{"includeThoughts":true,"thinkingLevel":tier}}}); + if !system.is_empty() { + request["systemInstruction"] = json!({"parts":system}); + } + if !tools.declarations.as_array().unwrap().is_empty() { + request["tools"] = json!([{"functionDeclarations":tools.declarations}]); + } + let wire = json!({"project":binding.project_id,"model":binding.native_model,"request":request, + "requestId":format!("ccp-{}",uuid::Uuid::new_v4()),"userAgent":"antigravity","requestType":"agent"}); + Ok(Prepared { + wire, + session, + display, + tools, + estimate: ((estimate_bytes as u64).div_ceil(3)).max(1), + }) +} diff --git a/src/providers/antigravity/schema.rs b/src/providers/antigravity/schema.rs new file mode 100644 index 00000000..8b969eee --- /dev/null +++ b/src/providers/antigravity/schema.rs @@ -0,0 +1,524 @@ +//! Source validation and honest Gemini generation projection are separate layers. +use super::replay::{self, Result, keys, nonempty}; +use serde_json::{Map, Value, json}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, HashSet}; + +const MAX_SCHEMA_BYTES: usize = 256 * 1024; +const MAX_SCHEMA_NODES: usize = 8192; + +struct Offline; +impl jsonschema::Retrieve for Offline { + fn retrieve( + &self, + _: &jsonschema::Uri, + ) -> std::result::Result> { + Err("external schema retrieval disabled".into()) + } +} + +enum Codec { + Identity, + Json, + Object(BTreeMap), + Array(Box), +} + +struct Tool { + caller: String, + native: String, + validator: jsonschema::Validator, + codec: Codec, + root: bool, + empty: bool, +} + +pub(crate) struct ToolSet { + pub declarations: Value, + tools: Vec, +} + +impl ToolSet { + pub fn compile(tools: &Value) -> Result { + let tools = tools + .as_array() + .filter(|t| t.len() <= 128) + .ok_or("tools must be an array of at most 128 functions")?; + let mut names = HashSet::new(); + let mut native_names = HashSet::new(); + let mut compiled = Vec::new(); + let mut declarations = Vec::new(); + for tool in tools { + keys( + tool, + &["name", "description", "input_schema", "cache_control"], + &["name", "input_schema"], + )?; + super::request::cache_hint(tool.get("cache_control"))?; + let caller = nonempty(&tool["name"])?; + if caller.len() > 1024 || !names.insert(caller) { + return Err("invalid/duplicate caller tool name"); + } + let native = if native_name(caller) { + caller.to_string() + } else { + format!("ccp_{:x}", Sha256::digest(caller.as_bytes()))[..64].to_string() + }; + if !native_names.insert(native.clone()) { + return Err("native tool name collision"); + } + let schema = &tool["input_schema"]; + if serde_json::to_vec(schema) + .map_err(|_| "invalid schema")? + .len() + > MAX_SCHEMA_BYTES + { + return Err("schema byte limit"); + } + let mut nodes = 0; + validate_source(schema, 0, &mut nodes)?; + let draft = match schema.get("$schema").and_then(Value::as_str) { + None | Some("https://json-schema.org/draft/2020-12/schema") => { + jsonschema::Draft::Draft202012 + } + Some( + "http://json-schema.org/draft-07/schema" + | "http://json-schema.org/draft-07/schema#", + ) => jsonschema::Draft::Draft7, + _ => return Err("unsupported schema dialect"), + }; + let validator = jsonschema::options() + .with_draft(draft) + .should_validate_formats(true) + .with_retriever(Offline) + .build(schema) + .map_err(|_| "invalid original tool schema")?; + let (projected, codec) = project(schema)?; + let empty = schema["type"] == "object" + && schema["additionalProperties"] == false + && schema + .get("properties") + .is_none_or(|v| v.as_object().is_some_and(|v| v.is_empty())); + let root = matches!(codec, Codec::Json); + let mut declaration = json!({"name":native}); + if let Some(description) = tool.get("description") { + let text = description + .as_str() + .filter(|v| v.len() <= 32768) + .ok_or("invalid tool description")?; + declaration["description"] = json!(text); + } + if !empty { + declaration["parameters"] = if root { + json!({"type":"OBJECT","properties":{"json":projected},"required":["json"],"description":"Encode the entire original caller argument object as JSON in the single json string field; no extra wrapper keys."}) + } else { + projected + }; + } + declarations.push(declaration); + compiled.push(Tool { + caller: caller.to_string(), + native, + validator, + codec, + root, + empty, + }); + } + Ok(Self { + declarations: json!(declarations), + tools: compiled, + }) + } + + pub fn mapping(&self, contents: &Value) -> Result { + let mut recipes = Vec::new(); + for (_, _, call) in replay::calls(contents)? { + let tool = self + .tools + .iter() + .find(|t| call["name"] == t.native) + .ok_or("upstream returned unknown tool")?; + let mut paths = Vec::new(); + let root = if tool.root { + json!("json") + } else { + Value::Null + }; + let raw_args = replay::call_arguments(call)?; + if !tool.root { + codec_paths(&tool.codec, raw_args, "", &mut paths)?; + } + let args = replay::decode_arguments(raw_args, &json!(paths), &root)?; + if (tool.empty && args != json!({})) || !tool.validator.is_valid(&args) { + return Err("upstream tool arguments violate original schema"); + } + recipes + .push(json!({"caller_name":tool.caller,"codec_paths":paths,"root_wrapper":root})); + } + replay::mapping(contents, Some(&json!(recipes))) + } +} + +fn native_name(name: &str) -> bool { + name.len() <= 64 + && name + .bytes() + .next() + .is_some_and(|c| c.is_ascii_alphabetic() || c == b'_') + && name + .bytes() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'_' | b'-' | b'.')) +} + +fn required_undeclared(schema: &Value) -> bool { + let properties = schema.get("properties").and_then(Value::as_object); + schema + .get("required") + .and_then(Value::as_array) + .is_some_and(|required| { + required + .iter() + .filter_map(Value::as_str) + .any(|name| properties.is_none_or(|props| !props.contains_key(name))) + }) +} + +fn validate_source(schema: &Value, depth: usize, nodes: &mut usize) -> Result<()> { + *nodes += 1; + if depth > 32 || *nodes > MAX_SCHEMA_NODES { + return Err("schema depth/node limit"); + } + if schema.is_boolean() { + return Ok(()); + } + let object = schema + .as_object() + .ok_or("schema must be object or boolean")?; + if schema["type"] == "object" + && schema["additionalProperties"] == false + && required_undeclared(schema) + { + return Err( + "unsatisfiable tool contract: required undeclared property is forbidden by additionalProperties:false", + ); + } + let allowed = [ + "$schema", + "type", + "properties", + "required", + "items", + "additionalProperties", + "allOf", + "anyOf", + "oneOf", + "default", + "description", + "title", + "enum", + "const", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "minItems", + "maxItems", + "minLength", + "maxLength", + "pattern", + "format", + "multipleOf", + "uniqueItems", + "minProperties", + "maxProperties", + ]; + if object.keys().any(|key| !allowed.contains(&key.as_str())) { + return Err("unsupported schema keyword or reference"); + } + if let Some(dialect) = object.get("$schema") { + if !matches!( + dialect.as_str(), + Some( + "https://json-schema.org/draft/2020-12/schema" + | "http://json-schema.org/draft-07/schema" + | "http://json-schema.org/draft-07/schema#" + ) + ) { + return Err("unsupported schema dialect"); + } + if depth != 0 { + return Err("nested dialect is unsupported"); + } + } + if let Some(format) = object.get("format") + && !matches!( + format.as_str(), + Some( + "date-time" + | "date" + | "time" + | "duration" + | "email" + | "idn-email" + | "hostname" + | "idn-hostname" + | "ipv4" + | "ipv6" + | "uri" + | "uri-reference" + | "uuid" + | "regex" + | "json-pointer" + | "relative-json-pointer" + ) + ) + { + return Err("unsupported schema format"); + } + if let Some(properties) = object.get("properties") { + for child in properties + .as_object() + .ok_or("invalid schema properties")? + .values() + { + validate_source(child, depth + 1, nodes)?; + } + } + for key in ["items", "additionalProperties"] { + if let Some(child) = object.get(key) { + validate_source(child, depth + 1, nodes)?; + } + } + for key in ["allOf", "anyOf", "oneOf"] { + if let Some(branches) = object.get(key) { + for child in branches.as_array().ok_or("invalid schema branches")? { + validate_source(child, depth + 1, nodes)?; + } + } + } + Ok(()) +} + +fn json_codec(schema: &Value) -> Result<(Value, Codec)> { + // Complete deterministic JSON, not a lossy summary: the model must know the + // caller's names/types/requirements even when the native carrier is STRING. + let original = serde_json::to_string(schema).map_err(|_| "invalid original schema guidance")?; + let description = format!( + "JSON-string transport: encode the original JSON value exactly as a JSON string. The proxy decodes and validates this original schema before returning a tool call. Original JSON Schema: {original}" + ); + if description.len() > 16384 { + return Err("complete schema guidance exceeds the 16 KiB limit"); + } + Ok(( + json!({"type":"STRING","description":description}), + Codec::Json, + )) +} + +fn project(schema: &Value) -> Result<(Value, Codec)> { + let Some(kind) = schema.get("type").and_then(Value::as_str) else { + // Only a bare union (apart from dialect metadata) can return directly. + // Otherwise sibling constraints/annotations would bypass the normal + // projection path. Use the complete bounded JSON codec, not just anyOf. + if let Some(branches) = schema.get("anyOf").and_then(Value::as_array) + && schema.as_object().is_some_and(|fields| { + fields + .keys() + .all(|key| matches!(key.as_str(), "anyOf" | "$schema")) + }) + { + let projected = branches.iter().map(project).collect::>>()?; + if projected.iter().all(|(_, c)| identity(c)) { + return Ok(( + json!({"anyOf":projected.into_iter().map(|(s,_)|s).collect::>()}), + Codec::Identity, + )); + } + } + return json_codec(schema); + }; + if schema.get("oneOf").is_some() + || !matches!( + kind, + "object" | "array" | "string" | "integer" | "number" | "boolean" + ) + { + return json_codec(schema); + } + let mut out = Map::new(); + out.insert("type".into(), json!(kind.to_ascii_uppercase())); + let mut codec = Codec::Identity; + if kind == "object" { + let props = schema.get("properties").and_then(Value::as_object); + if required_undeclared(schema) { + if schema["additionalProperties"] == false { + return Err( + "unsatisfiable tool contract: required undeclared property is forbidden by additionalProperties:false", + ); + } + return json_codec(schema); + } + if props.is_none_or(|p| p.is_empty()) && schema["additionalProperties"] != false { + return json_codec(schema); + } + if schema + .get("additionalProperties") + .is_some_and(Value::is_object) + { + return json_codec(schema); + } + let mut projected = Map::new(); + let mut children = BTreeMap::new(); + for (name, child) in props.into_iter().flatten() { + let (schema, child_codec) = project(child)?; + projected.insert(name.clone(), schema); + children.insert(name.clone(), child_codec); + } + out.insert("properties".into(), Value::Object(projected)); + codec = Codec::Object(children); + if let Some(required) = schema.get("required") { + out.insert("required".into(), required.clone()); + } + } else if kind == "array" { + let (item, item_codec) = project(schema.get("items").unwrap_or(&Value::Bool(true)))?; + out.insert("items".into(), item); + codec = Codec::Array(Box::new(item_codec)); + } + for key in [ + "description", + "title", + "minimum", + "maximum", + "minItems", + "maxItems", + "minLength", + "maxLength", + "pattern", + ] { + if let Some(value) = schema.get(key) { + out.insert(key.to_string(), value.clone()); + } + } + if let Some(values) = schema + .get("enum") + .and_then(Value::as_array) + .filter(|v| v.iter().all(Value::is_string)) + { + out.insert("enum".into(), json!(values)); + } + for (exclusive, inclusive, lower) in [ + ("exclusiveMinimum", "minimum", true), + ("exclusiveMaximum", "maximum", false), + ] { + if kind == "integer" + && let Some(value) = schema.get(exclusive).and_then(Value::as_f64) + { + let bound = if lower { + value.floor() + 1.0 + } else { + value.ceil() - 1.0 + }; + if bound.abs() <= 9007199254740991.0 { + let existing = out.get(inclusive).and_then(Value::as_f64); + let bound = existing.map_or(bound, |old| { + if lower { + old.ceil().max(bound) + } else { + old.floor().min(bound) + } + }); + if bound.abs() <= 9007199254740991.0 { + out.insert(inclusive.to_string(), json!(bound as i64)); + } + } + } + } + if let Some(branches) = schema.get("anyOf").and_then(Value::as_array) { + let projected = branches.iter().map(project).collect::>>()?; + if !projected.iter().all(|(_, c)| identity(c)) { + return json_codec(schema); + } + out.insert( + "anyOf".into(), + json!(projected.into_iter().map(|(v, _)| v).collect::>()), + ); + } + let local = [ + "additionalProperties", + "allOf", + "format", + "multipleOf", + "uniqueItems", + "minProperties", + "maxProperties", + "exclusiveMinimum", + "exclusiveMaximum", + "const", + ]; + let mut constraints: Map = local + .iter() + .filter_map(|k| schema.get(*k).map(|v| ((*k).to_string(), v.clone()))) + .collect(); + if let Some(values) = schema + .get("enum") + .and_then(Value::as_array) + .filter(|values| values.iter().any(|v| !v.is_string())) + { + constraints.insert("enum".to_string(), json!(values)); + } + if !constraints.is_empty() { + let hint = format!( + "Original schema constraints are validated locally after JSON decoding; invalid calls are rejected, never repaired. Constraints: {}", + Value::Object(constraints) + ); + let prior = out.get("description").and_then(Value::as_str).unwrap_or(""); + let description = format!("{prior}\n{hint}"); + if description.len() > 16384 { + return Err("complete schema guidance exceeds the 16 KiB limit"); + } + out.insert("description".into(), json!(description)); + } + Ok((Value::Object(out), codec)) +} + +fn identity(codec: &Codec) -> bool { + match codec { + Codec::Identity => true, + Codec::Json => false, + Codec::Object(c) => c.values().all(identity), + Codec::Array(c) => identity(c), + } +} + +fn codec_paths(codec: &Codec, args: &Value, path: &str, paths: &mut Vec) -> Result<()> { + match codec { + Codec::Json => paths.push(path.to_string()), + Codec::Object(children) => { + if let Some(object) = args.as_object() { + for (name, child) in children { + if let Some(value) = object.get(name) { + codec_paths( + child, + value, + &format!("{path}/{}", name.replace('~', "~0").replace('/', "~1")), + paths, + )?; + } + } + } + } + Codec::Array(child) => { + if let Some(array) = args.as_array() { + for (i, value) in array.iter().enumerate() { + codec_paths(child, value, &format!("{path}/{i}"), paths)?; + } + } + } + Codec::Identity => {} + } + if paths.len() > 64 { + return Err("codec path count limit"); + } + Ok(()) +} diff --git a/src/providers/antigravity/transport.rs b/src/providers/antigravity/transport.rs new file mode 100644 index 00000000..0b314747 --- /dev/null +++ b/src/providers/antigravity/transport.rs @@ -0,0 +1,306 @@ +//! Bounded native SSE collection. No generation retry; no success before EOF. +use super::replay::{self, Result}; +use serde_json::{Map, Value, json}; + +const MAX_TURN: usize = 8 * 1024 * 1024; +const MAX_FRAME: usize = 1024 * 1024; + +#[derive(Default)] +pub(crate) struct Reducer { + bytes: usize, + line: Vec, + data: Vec, + contents: Vec, + usage: Map, + terminal: Option, + parts: usize, +} + +pub(crate) struct Turn { + pub contents: Value, + pub usage: Value, + pub stop_reason: String, +} + +impl Reducer { + pub fn push(&mut self, chunk: &[u8]) -> Result<()> { + self.bytes = self + .bytes + .checked_add(chunk.len()) + .ok_or("native turn byte limit")?; + if self.bytes > MAX_TURN { + return Err("native turn byte limit"); + } + for byte in chunk { + if *byte == b'\n' { + let mut line = std::mem::take(&mut self.line); + if line.last() == Some(&b'\r') { + line.pop(); + } + if line.is_empty() { + self.frame()?; + } else if let Some(data) = line.strip_prefix(b"data:") { + let data = data.strip_prefix(b" ").unwrap_or(data); + if self.data.len() + data.len() + 1 > MAX_FRAME { + return Err("native SSE frame byte limit"); + } + self.data.extend_from_slice(data); + self.data.push(b'\n'); + } + } else { + self.line.push(*byte); + if self.line.len() > MAX_FRAME { + return Err("native SSE line byte limit"); + } + } + } + Ok(()) + } + fn frame(&mut self) -> Result<()> { + if self.data.is_empty() { + return Ok(()); + } + let mut data = std::mem::take(&mut self.data); + data.pop(); + if data == b"[DONE]" { + return if self.terminal.is_some() { + Ok(()) + } else { + Err("native stream missing terminal state") + }; + } + let value = replay::parse_json(&data, MAX_FRAME)?; + if value.get("error").is_some() { + return Err("native upstream error"); + } + let response = value + .get("response") + .ok_or("invalid native response envelope")?; + if response.get("error").is_some() + || response + .get("promptFeedback") + .is_some_and(|p| p.get("blockReason").is_some()) + { + return Err("native refusal/error"); + } + if let Some(usage) = response.get("usageMetadata") { + let usage = usage.as_object().ok_or("invalid native usage")?; + for key in [ + "promptTokenCount", + "cachedContentTokenCount", + "candidatesTokenCount", + "thoughtsTokenCount", + "totalTokenCount", + ] { + if let Some(value) = usage.get(key) { + let number = value + .as_u64() + .filter(|n| *n <= 1_000_000_000) + .ok_or("invalid native usage count")?; + if self + .usage + .get(key) + .and_then(Value::as_u64) + .is_some_and(|old| number < old) + { + return Err("native cumulative usage decreased"); + } + self.usage.insert(key.into(), value.clone()); + } + } + } + if let Some(candidates) = response.get("candidates") { + let candidates = candidates + .as_array() + .filter(|c| c.len() <= 1) + .ok_or("multiple/invalid native candidates")?; + for candidate in candidates { + if candidate + .get("index") + .is_some_and(|v| v.as_u64() != Some(0)) + { + return Err("unsupported candidate index"); + } + if let Some(content) = candidate.get("content") { + if content["role"] != "model" { + return Err("invalid native content role"); + } + let parts = content["parts"].as_array().ok_or("invalid native parts")?; + self.parts += parts.len(); + if self.parts > 4096 || self.contents.len() >= 512 { + return Err("native content/part limit"); + } + for part in parts { + if !part.is_object() + || part.get("text").is_some_and(|v| !v.is_string()) + || part.get("thought").is_some_and(|v| !v.is_boolean()) + || part.get("thoughtSignature").is_some_and(|v| !v.is_string()) + { + return Err("invalid native part"); + } + if [ + "inlineData", + "fileData", + "executableCode", + "codeExecutionResult", + "functionResponse", + ] + .iter() + .any(|k| part.get(*k).is_some()) + { + return Err("unsupported native content/media"); + } + if let Some(call) = part.get("functionCall") { + replay::keys(call, &["name", "args", "id"], &["name"])?; + replay::call_arguments(call)?; + } + if !["text", "functionCall", "thoughtSignature"] + .iter() + .any(|k| part.get(*k).is_some()) + { + return Err("unsupported native part"); + } + } + self.contents.push(content.clone()); + } + if let Some(reason) = candidate.get("finishReason") { + let reason = reason + .as_str() + .filter(|r| matches!(*r, "STOP" | "MAX_TOKENS")) + .ok_or("unsupported native termination/refusal")?; + if self.terminal.as_ref().is_some_and(|old| old != reason) { + return Err("conflicting native terminal state"); + } + self.terminal = Some(reason.to_string()); + } + } + } + Ok(()) + } + pub fn finish(self) -> Result { + if !self.line.is_empty() || !self.data.is_empty() { + return Err("truncated native SSE frame"); + } + let reason = self + .terminal + .ok_or("native stream missing terminal state")?; + if self.contents.is_empty() { + return Err("empty native turn"); + } + let prompt = self + .usage + .get("promptTokenCount") + .and_then(Value::as_u64) + .ok_or("native prompt usage missing")?; + let candidates = self + .usage + .get("candidatesTokenCount") + .and_then(Value::as_u64) + .ok_or("native output usage missing")?; + let thoughts = self + .usage + .get("thoughtsTokenCount") + .and_then(Value::as_u64) + .unwrap_or(0); + let output = candidates + .checked_add(thoughts) + .ok_or("invalid native usage total")?; + if self + .usage + .get("totalTokenCount") + .and_then(Value::as_u64) + .is_some_and(|total| total != prompt + output) + { + return Err("ambiguous native usage total"); + } + let cache = self + .usage + .get("cachedContentTokenCount") + .and_then(Value::as_u64) + .unwrap_or(0) + .min(prompt); + let contents = json!(self.contents); + let has_calls = !replay::calls(&contents)?.is_empty(); + Ok(Turn { + contents, + usage: json!({"input_tokens":prompt-cache,"cache_read_input_tokens":cache,"output_tokens":output}), + stop_reason: if reason == "MAX_TOKENS" { + "max_tokens" + } else if has_calls { + "tool_use" + } else { + "end_turn" + } + .to_string(), + }) + } +} + +pub(crate) async fn collect(mut response: reqwest::Response) -> Result { + let mut reducer = Reducer::default(); + while let Some(chunk) = + tokio::time::timeout(std::time::Duration::from_secs(30), response.chunk()) + .await + .map_err(|_| "native stream idle timeout")? + .map_err(|_| "native stream transport failed")? + { + reducer.push(&chunk)?; + } + reducer.finish() +} + +pub(crate) fn message(blocks: Value, model: &str, turn: &Turn) -> Value { + json!({"id":format!("msg_{}",uuid::Uuid::new_v4().simple()),"type":"message","role":"assistant","model":model, + "content":blocks,"stop_reason":turn.stop_reason,"stop_sequence":null,"usage":turn.usage}) +} + +pub(crate) fn sse(message: &Value) -> Result> { + let mut start = message.clone(); + start["content"] = json!([]); + start["stop_reason"] = Value::Null; + start["usage"]["output_tokens"] = json!(0); + let mut events = vec![json!({"type":"message_start","message":start})]; + for (index, block) in message["content"] + .as_array() + .ok_or("invalid projected message")? + .iter() + .enumerate() + { + let (initial, deltas) = match block["type"].as_str() { + Some("thinking") => ( + json!({"type":"thinking","thinking":"","signature":""}), + vec![ + json!({"type":"thinking_delta","thinking":block["thinking"]}), + json!({"type":"signature_delta","signature":block["signature"]}), + ], + ), + Some("text") => ( + json!({"type":"text","text":""}), + vec![json!({"type":"text_delta","text":block["text"]})], + ), + Some("tool_use") => ( + json!({"type":"tool_use","id":block["id"],"name":block["name"],"input":{}}), + vec![ + json!({"type":"input_json_delta","partial_json":serde_json::to_string(&block["input"]).map_err(|_| "invalid tool arguments")?}), + ], + ), + _ => return Err("invalid projected block"), + }; + events.push(json!({"type":"content_block_start","index":index,"content_block":initial})); + for delta in deltas { + events.push(json!({"type":"content_block_delta","index":index,"delta":delta})); + } + events.push(json!({"type":"content_block_stop","index":index})); + } + events.push(json!({"type":"message_delta","delta":{"stop_reason":message["stop_reason"],"stop_sequence":null},"usage":message["usage"]})); + events.push(json!({"type":"message_stop"})); + let mut wire = String::new(); + for event in events { + wire.push_str(&format!( + "event: {}\ndata: {}\n\n", + event["type"].as_str().unwrap(), + event + )); + } + Ok(wire.into_bytes()) +} diff --git a/src/providers/antigravity_auth_tests.rs b/src/providers/antigravity_auth_tests.rs new file mode 100644 index 00000000..efd07684 --- /dev/null +++ b/src/providers/antigravity_auth_tests.rs @@ -0,0 +1,808 @@ +use super::{ + antigravity::{ + auth::{self, Auth, Credential, Store}, + config::Config, + }, + antigravity_http_tests::Server, +}; +use axum::{Json, Router, extract::State, routing::post}; +use serde_json::{Value, json}; +use std::{ + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +fn expired() -> Credential { + Credential { + profile_id: "profile-stable".into(), + project_id: Some("project".into()), + access_token: "expired-access".into(), + refresh_token: Some("original-refresh".into()), + expires_at: 0, + } +} +async fn saved(store: &Store, credential: &Credential) { + let lock = store.lock(Duration::from_secs(1)).await.unwrap(); + store.save(&lock, credential).unwrap(); +} + +struct Tokens { + count: AtomicUsize, + started: tokio::sync::Notify, + rotate: bool, +} +async fn token(State(state): State>) -> Json { + state.count.fetch_add(1, Ordering::SeqCst); + state.started.notify_one(); + tokio::time::sleep(Duration::from_millis(60)).await; + let mut value = json!({"access_token":"new-access","expires_in":3600,"token_type":"Bearer"}); + if state.rotate { + value["refresh_token"] = json!("rotated-refresh"); + } + Json(value) +} +async fn auth_fixture(rotate: bool) -> (tempfile::TempDir, Server, Arc, Arc) { + let directory = tempfile::tempdir().unwrap(); + let store = Store::new(directory.path().join("auth.json")); + saved(&store, &expired()).await; + let state = Arc::new(Tokens { + count: AtomicUsize::new(0), + started: tokio::sync::Notify::new(), + rotate, + }); + let server = Server::start( + Router::new() + .route("/token", post(token)) + .with_state(state.clone()), + ) + .await; + let config = Config { + token_url: server.url.join("/token").unwrap(), + client_id: Some("synthetic-client".into()), + ..Config::default() + }; + let auth = Arc::new(Auth::new(config, store).unwrap()); + (directory, server, auth, state) +} + +struct PausedRotation { + requests: AtomicUsize, + issued: tokio::sync::Notify, + release: tokio::sync::Notify, +} + +async fn rotating_token( + State(state): State>, + axum::extract::Form(form): axum::extract::Form>, +) -> axum::response::Response { + let index = state.requests.fetch_add(1, Ordering::SeqCst); + if index > 0 + && form + .get("refresh_token") + .is_some_and(|t| t == "original-refresh") + { + return axum::response::IntoResponse::into_response(( + http::StatusCode::BAD_REQUEST, + "obsolete refresh token", + )); + } + state.issued.notify_one(); + let body = axum::body::Body::from_stream(futures_util::stream::once(async move { + state.release.notified().await; + Ok::<_, std::convert::Infallible>(bytes::Bytes::from_static(b"{\"access_token\":\"rotated-access\",\"refresh_token\":\"rotated-refresh\",\"expires_in\":3600,\"token_type\":\"Bearer\"}")) + })); + axum::response::Response::builder() + .header("content-type", "application/json") + .body(body) + .unwrap() +} + +async fn paused_rotation() -> ( + tempfile::TempDir, + Server, + Auth, + Arc, + Arc, +) { + let directory = tempfile::tempdir().unwrap(); + let store = Store::new(directory.path().join("auth.json")); + saved(&store, &expired()).await; + let state = Arc::new(PausedRotation { + requests: AtomicUsize::new(0), + issued: tokio::sync::Notify::new(), + release: tokio::sync::Notify::new(), + }); + let generations = Arc::new(AtomicUsize::new(0)); + let counted = generations.clone(); + let server = Server::start(Router::new().route("/token", post(rotating_token)).route("/v1internal:streamGenerateContent", post(move |headers: http::HeaderMap| { + let counted = counted.clone(); async move { + assert_eq!(headers["authorization"], "Bearer rotated-access"); + counted.fetch_add(1, Ordering::SeqCst); + ([ ("content-type", "text/event-stream") ], format!("data: {}\n\n", json!({"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2}}}))) + } + })).with_state(state.clone())).await; + let config = Config { + base_url: server.url.clone(), + token_url: server.url.join("/token").unwrap(), + client_id: Some("synthetic-client".into()), + ..Config::default() + }; + let auth = Auth::new(config, store).unwrap(); + (directory, server, auth, state, generations) +} + +#[tokio::test] +async fn antigravity_refresh_rotation_persists_after_inference_disconnect_without_generation() { + let (_directory, _upstream, auth, state, generations) = paused_rotation().await; + let store = auth.store.clone(); + let monitor = crate::monitor::MonitorHandle::new(10); + let registry = crate::registry::Registry::from_providers( + crate::config::AliasProvider::Codex, + [ + Arc::new(super::antigravity::AntigravityProvider::with_auth(auth)) + as Arc, + ], + ); + let proxy = Server::start(crate::server::app_with_features( + Arc::new(registry), + Some(monitor.clone()), + crate::server::AppFeatures::default(), + )) + .await; + let mut downstream = tokio::net::TcpStream::connect(("127.0.0.1", proxy.url.port().unwrap())) + .await + .unwrap(); + let body = json!({"model":"antigravity/gemini-4-argon-low","max_tokens":32,"messages":[{"role":"user","content":"hello"}]}).to_string(); + downstream.write_all(format!("POST /v1/messages HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap(); + state.issued.notified().await; + drop(downstream); + tokio::time::timeout(Duration::from_secs(2), async { + while !monitor.snapshot().active.is_empty() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + state.release.notify_one(); + let persisted = tokio::time::timeout(Duration::from_secs(2), async { + while store.load().unwrap().unwrap().refresh_token.as_deref() != Some("rotated-refresh") { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await; + assert!( + persisted.is_ok(), + "caller cancellation discarded an issued replacement credential" + ); + assert_eq!( + generations.load(Ordering::SeqCst), + 0, + "credential maintenance must never continue inference" + ); + let response = reqwest::Client::new() + .post(proxy.url.join("/v1/messages").unwrap()) + .header("content-type", "application/json") + .body(body) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + assert_eq!( + state.requests.load(Ordering::SeqCst), + 1, + "obsolete refresh token was reused" + ); + assert_eq!(generations.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn antigravity_pending_rotation_survives_persistence_failure_without_obsolete_retry() { + let (directory, _upstream, auth, state, _) = paused_rotation().await; + let auth = Arc::new(auth); + let worker = { + let auth = auth.clone(); + tokio::spawn(async move { auth.access().await }) + }; + state.issued.notified().await; + let path = directory.path().join("auth.json"); + let backup = directory.path().join("auth.before-fault.json"); + std::fs::rename(&path, &backup).unwrap(); + std::fs::create_dir(&path).unwrap(); + state.release.notify_one(); + assert!( + matches!( + worker.await.unwrap(), + Err(auth::AccessError::PendingPersistence) + ), + "unwritable credential destination must report typed pending persistence" + ); + std::fs::remove_dir(&path).unwrap(); + std::fs::rename(&backup, &path).unwrap(); + // An incidental metadata edit is NOT a new profile/epoch/token. It cannot + // authorize retrying the refresh token whose replacement is pending. + let mut same_scope = auth.store.load().unwrap().unwrap(); + same_scope.expires_at = 1; + saved(&auth.store, &same_scope).await; + let next = auth + .access() + .await + .expect("manager must persist its pending rotated credential without reusing old token"); + assert_eq!(next.refresh_token.as_deref(), Some("rotated-refresh")); + assert_eq!( + auth.store.load().unwrap().unwrap().refresh_token.as_deref(), + Some("rotated-refresh") + ); + assert_eq!(state.requests.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn antigravity_cancelled_lock_waiter_does_not_begin_refresh_network() { + let (_directory, _upstream, auth, state, _) = paused_rotation().await; + let auth = Arc::new(auth); + let lock = auth.store.lock(Duration::from_secs(1)).await.unwrap(); + let worker = { + let auth = auth.clone(); + tokio::spawn(async move { auth.access().await }) + }; + tokio::time::sleep(Duration::from_millis(30)).await; + worker.abort(); + let _ = worker.await; + drop(lock); + tokio::time::sleep(Duration::from_millis(50)).await; + assert_eq!(state.requests.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn antigravity_refresh_lock_deadline_precedes_network_and_timeout_never_retries_old_token() { + let (_directory, _server, auth, state, _) = paused_rotation().await; + let auth = + Arc::new(auth.with_refresh_timeouts(Duration::from_millis(20), Duration::from_secs(2))); + let lock = auth.store.lock(Duration::from_secs(1)).await.unwrap(); + assert!(matches!( + auth.access().await, + Err(auth::AccessError::Lock(_)) + )); + assert_eq!(state.requests.load(Ordering::SeqCst), 0); + drop(lock); + let worker = { + let auth = auth.clone(); + tokio::spawn(async move { auth.access().await }) + }; + state.issued.notified().await; + state.release.notify_one(); + assert!(worker.await.unwrap().is_ok()); + assert_eq!(state.requests.load(Ordering::SeqCst), 1); + + let (_directory, _server, auth, state, _) = paused_rotation().await; + let auth = + Arc::new(auth.with_refresh_timeouts(Duration::from_secs(1), Duration::from_millis(500))); + let worker = { + let auth = auth.clone(); + tokio::spawn(async move { auth.access().await }) + }; + state.issued.notified().await; + assert!(worker.await.unwrap().is_err()); + assert!(matches!( + auth.access().await, + Err(auth::AccessError::UncertainRotation) + )); + assert_eq!(state.requests.load(Ordering::SeqCst), 1); + drop(auth.store.lock(Duration::from_secs(1)).await.unwrap()); +} + +#[tokio::test] +async fn antigravity_graceful_manager_shutdown_joins_maintenance_without_inference() { + let (_directory, _server, auth, state, generations) = paused_rotation().await; + let auth = Arc::new(auth); + let worker = { + let auth = auth.clone(); + tokio::spawn(async move { auth.access().await }) + }; + state.issued.notified().await; + worker.abort(); + let _ = worker.await; + // Cancellation of a shutdown waiter must not discard the owned task handle. + assert!( + tokio::time::timeout(Duration::from_millis(20), auth.finish_maintenance()) + .await + .is_err() + ); + assert!(matches!( + auth.access().await, + Err(auth::AccessError::Closed) + )); + state.release.notify_one(); + auth.finish_maintenance().await.unwrap(); + assert_eq!( + auth.store.load().unwrap().unwrap().refresh_token.as_deref(), + Some("rotated-refresh") + ); + assert_eq!(state.requests.load(Ordering::SeqCst), 1); + assert_eq!(generations.load(Ordering::SeqCst), 0); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn antigravity_validated_rotation_persistence_outlives_waiter_and_network_timer() { + let (_directory, _server, auth, state, generations) = paused_rotation().await; + let saving = Arc::new(tokio::sync::Notify::new()); + let (release_io, io_wait) = std::sync::mpsc::channel(); + let io_wait = std::sync::Mutex::new(io_wait); + let notify = saving.clone(); + let store = auth.store.clone().with_before_save(Arc::new(move || { + notify.notify_one(); + // A controlled synchronous filesystem-phase pause, not a claim that + // kernel IO can be interrupted by async cancellation. + io_wait + .lock() + .unwrap() + .recv_timeout(Duration::from_secs(3)) + .unwrap(); + })); + let auth = Arc::new( + Auth::new(auth.config.clone(), store) + .unwrap() + .with_refresh_timeouts(Duration::from_secs(1), Duration::from_millis(200)), + ); + let worker = { + let auth = auth.clone(); + tokio::spawn(async move { auth.access().await }) + }; + state.issued.notified().await; + state.release.notify_one(); + saving.notified().await; + worker.abort(); + let _ = worker.await; + tokio::time::sleep(Duration::from_millis(250)).await; + release_io.send(()).unwrap(); + auth.finish_maintenance().await.unwrap(); + assert_eq!( + auth.store.load().unwrap().unwrap().refresh_token.as_deref(), + Some("rotated-refresh") + ); + assert_eq!(state.requests.load(Ordering::SeqCst), 1); + assert_eq!(generations.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn antigravity_pending_rotation_cannot_resurrect_logout_or_overwrite_new_login() { + for logout in [true, false] { + let (directory, _server, auth, state, _) = paused_rotation().await; + let auth = Arc::new(auth); + let worker = { + let auth = auth.clone(); + tokio::spawn(async move { auth.access().await }) + }; + state.issued.notified().await; + let path = directory.path().join("auth.json"); + let backup = directory.path().join("auth.before-fault.json"); + std::fs::rename(&path, &backup).unwrap(); + std::fs::create_dir(&path).unwrap(); + state.release.notify_one(); + assert!(matches!( + worker.await.unwrap(), + Err(auth::AccessError::PendingPersistence) + )); + std::fs::remove_dir(&path).unwrap(); + std::fs::rename(&backup, &path).unwrap(); + if logout { + auth.logout().await.unwrap(); + assert!(matches!( + auth.access().await, + Err(auth::AccessError::Missing) + )); + assert!(!path.exists()); + } else { + let mut newer = expired(); + newer.profile_id = "new-profile".into(); + newer.access_token = "new-login-access".into(); + newer.expires_at = auth::now() + 3600; + let mut lock = auth.store.lock(Duration::from_secs(1)).await.unwrap(); + lock.advance().unwrap(); + auth.store.save(&lock, &newer).unwrap(); + drop(lock); + assert_eq!( + auth.access().await.unwrap().access_token, + "new-login-access" + ); + assert_eq!( + auth.store.load().unwrap().unwrap().profile_id, + "new-profile" + ); + } + assert_eq!(state.requests.load(Ordering::SeqCst), 1); + } +} + +#[tokio::test] +async fn antigravity_logout_after_cancelled_refresh_waits_then_clears_without_resurrection() { + let (_directory, _server, auth, state, generations) = paused_rotation().await; + let auth = Arc::new(auth); + let worker = { + let auth = auth.clone(); + tokio::spawn(async move { auth.access().await }) + }; + state.issued.notified().await; + worker.abort(); + let _ = worker.await; + let logout = { + let auth = auth.clone(); + tokio::spawn(async move { auth.logout().await }) + }; + tokio::time::sleep(Duration::from_millis(20)).await; + assert!(!logout.is_finished()); + state.release.notify_one(); + logout.await.unwrap().unwrap(); + assert!(auth.store.load().unwrap().is_none()); + assert!(matches!( + auth.access().await, + Err(auth::AccessError::Missing) + )); + assert_eq!(state.requests.load(Ordering::SeqCst), 1); + assert_eq!(generations.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn antigravity_refresh_is_single_flight_and_preserves_profile_and_rotation() { + for rotate in [false, true] { + let (_directory, _server, auth, state) = auth_fixture(rotate).await; + let results = futures_util::future::join_all((0..8).map(|_| auth.access())).await; + for result in results { + let result = result.unwrap(); + assert_eq!(result.profile_id, "profile-stable"); + assert_eq!(result.access_token, "new-access"); + assert_eq!( + result.refresh_token.as_deref(), + Some(if rotate { + "rotated-refresh" + } else { + "original-refresh" + }) + ); + } + assert_eq!(state.count.load(Ordering::SeqCst), 1); + } +} + +#[tokio::test] +async fn antigravity_logout_and_new_login_cannot_be_overwritten_by_refresh() { + for logout in [true, false] { + let (_directory, _server, auth, state) = auth_fixture(true).await; + let refreshed = { + let auth = auth.clone(); + tokio::spawn(async move { auth.access().await }) + }; + state.started.notified().await; + if logout { + auth.logout().await.unwrap(); + } else { + let mut lock = auth.store.lock(Duration::from_secs(1)).await.unwrap(); + let mut newer = expired(); + newer.profile_id = "new-login-profile".into(); + newer.access_token = "new-login-access".into(); + newer.expires_at = auth::now() + 3600; + lock.advance().unwrap(); + auth.store.save(&lock, &newer).unwrap(); + } + assert!(refreshed.await.unwrap().is_ok()); + if logout { + assert!(auth.store.load().unwrap().is_none()); + assert!(auth.access().await.is_err()); + } else { + assert_eq!( + auth.store.load().unwrap().unwrap().profile_id, + "new-login-profile" + ); + } + assert_eq!(state.count.load(Ordering::SeqCst), 1); + } +} + +#[tokio::test] +async fn antigravity_explicit_login_exchanges_pkce_and_saves_discovered_project() { + for project in [ + json!("discovered-project"), + json!({"id":"discovered-project"}), + ] { + let exchanges = Arc::new(std::sync::Mutex::new(Vec::< + std::collections::HashMap, + >::new())); + let capture = exchanges.clone(); + let project_response = project.clone(); + let server=Server::start(Router::new() + .route("/token",post(move |axum::extract::Form(form):axum::extract::Form>| {let capture=capture.clone();async move {capture.lock().unwrap().push(form);Json(json!({"access_token":"login-access","refresh_token":"login-refresh","expires_in":3600}))}})) + .route("/v1internal:loadCodeAssist",post(move |headers:http::HeaderMap| {let project=project_response.clone();async move {assert_eq!(headers["authorization"],"Bearer login-access");Json(json!({"cloudaicompanionProject":project}))}}))).await; + let directory = tempfile::tempdir().unwrap(); + let store = Store::new(directory.path().join("auth.json")); + let config = Config { + base_url: server.url.clone(), + token_url: server.url.join("/token").unwrap(), + client_id: Some("public-client".into()), + ..Config::default() + }; + let auth = Auth::new(config, store).unwrap(); + let mut callback = None; + let mut challenge = None; + auth.login_with_opener(|url| { + let pairs = url + .query_pairs() + .collect::>(); + challenge = Some(pairs["code_challenge"].to_string()); + let mut redirect = url::Url::parse(&pairs["redirect_uri"]).unwrap(); + redirect.query_pairs_mut().extend_pairs([ + ("state", pairs["state"].as_ref()), + ("code", "synthetic-code"), + ]); + callback = Some(tokio::spawn(async move { + reqwest::Client::new().get(redirect).send().await.unwrap() + })); + Ok(()) + }) + .await + .unwrap(); + assert_eq!(callback.unwrap().await.unwrap().status(), 200); + let credentials = auth.store.load().unwrap().unwrap(); + assert_eq!( + credentials.project_id.as_deref(), + Some("discovered-project") + ); + assert_eq!(credentials.access_token, "login-access"); + let exchange = exchanges.lock().unwrap(); + assert_eq!(exchange.len(), 1); + assert_eq!(exchange[0]["grant_type"], "authorization_code"); + assert_eq!(exchange[0]["code"], "synthetic-code"); + use base64::Engine; + use sha2::Digest; + assert_eq!( + challenge.unwrap(), + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sha2::Sha256::digest( + exchange[0]["code_verifier"].as_bytes() + )) + ); + } +} + +#[tokio::test] +async fn antigravity_logout_during_browser_consent_prevents_exchange_and_resurrection() { + let (_directory, _server, auth, state) = auth_fixture(true).await; + let mut callback = None; + let result = auth + .login_with_opener(|url| { + let pairs = url + .query_pairs() + .collect::>(); + let mut redirect = url::Url::parse(&pairs["redirect_uri"]).unwrap(); + redirect.query_pairs_mut().extend_pairs([ + ("state", pairs["state"].as_ref()), + ("code", "synthetic-code"), + ]); + let auth = auth.clone(); + callback = Some(tokio::spawn(async move { + auth.logout().await.unwrap(); + reqwest::Client::new().get(redirect).send().await.unwrap() + })); + Ok(()) + }) + .await; + callback.unwrap().await.unwrap(); + assert!(result.is_err()); + assert!(auth.store.load().unwrap().is_none()); + assert_eq!(state.count.load(Ordering::SeqCst), 0); +} + +#[test] +fn antigravity_pkce_state_and_project_shapes_are_explicit() { + let url = auth::authorization_url( + &Config::default(), + "public-client", + "http://127.0.0.1:1234/oauth/callback", + "independent-state", + "local-secret-verifier", + ); + let query = url + .query_pairs() + .collect::>(); + assert_eq!(query["state"], "independent-state"); + assert_eq!(query["code_challenge_method"], "S256"); + assert!(!url.as_str().contains("local-secret-verifier")); + assert_eq!(query["access_type"], "offline"); + assert_eq!(query["prompt"], "consent"); + for project in [json!("project"), json!({"id":"project"})] { + assert_eq!( + auth::project_id(&json!({"cloudaicompanionProject":project})).unwrap(), + "project" + ); + } + for value in [json!({}), json!({"cloudaicompanionProject":{"id":""}})] { + assert!(auth::project_id(&value).is_err()); + } +} + +#[tokio::test] +async fn antigravity_oauth_callback_ignores_preconnects_paths_and_wrong_state() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + tokio::time::timeout(Duration::from_secs(5), auth::callback(listener, "state")) + .await + .unwrap() + }); + let idle = tokio::net::TcpStream::connect(address).await.unwrap(); + for path in [ + "/favicon.ico", + "/oauth/callback?state=wrong&code=wrong", + "/oauth/callback?state=state&code=accepted", + ] { + let mut stream = tokio::net::TcpStream::connect(address).await.unwrap(); + stream + .write_all(format!("GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n").as_bytes()) + .await + .unwrap(); + let mut response = Vec::new(); + stream.read_to_end(&mut response).await.unwrap(); + } + drop(idle); + assert_eq!(task.await.unwrap().unwrap(), "accepted"); +} + +#[tokio::test] +async fn antigravity_oauth_denial_and_timeout_are_bounded() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let task = tokio::spawn(auth::callback(listener, "state")); + let mut stream = tokio::net::TcpStream::connect(address).await.unwrap(); + stream.write_all(b"GET /oauth/callback?state=state&error=access_denied HTTP/1.1\r\nHost: localhost\r\n\r\n").await.unwrap(); + assert_eq!(task.await.unwrap().unwrap_err(), "OAuth consent denied"); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + assert!( + tokio::time::timeout(Duration::from_millis(20), auth::callback(listener, "state")) + .await + .is_err() + ); +} + +#[tokio::test] +async fn antigravity_redirects_do_not_leak_bearer_or_refresh_credentials() { + let hits = Arc::new(AtomicUsize::new(0)); + let counter = hits.clone(); + let sink = Server::start(Router::new().fallback(move || { + let counter = counter.clone(); + async move { + counter.fetch_add(1, Ordering::SeqCst); + "unexpected" + } + })) + .await; + let destination = sink.url.to_string(); + let redirect = Server::start(Router::new().fallback(move || { + let location = destination.clone(); + async move { + ( + http::StatusCode::TEMPORARY_REDIRECT, + [("location", location)], + "private error", + ) + } + })) + .await; + let directory = tempfile::tempdir().unwrap(); + let store = Store::new(directory.path().join("auth.json")); + saved(&store, &expired()).await; + let config = Config { + token_url: redirect.url.clone(), + client_id: Some("synthetic-client".into()), + ..Config::default() + }; + let auth = Auth::new(config, store).unwrap(); + assert!(auth.access().await.is_err()); + assert_eq!(hits.load(Ordering::SeqCst), 0); + let response = auth + .client + .post(redirect.url.clone()) + .bearer_auth("synthetic-access") + .send() + .await + .unwrap(); + assert_eq!(response.status(), 307); + assert_eq!(hits.load(Ordering::SeqCst), 0); +} + +#[test] +fn antigravity_cross_process_lock_child() { + let Ok(path) = std::env::var("CCP_ANTIGRAVITY_TEST_LOCK") else { + return; + }; + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + file.lock().unwrap(); + std::fs::write(format!("{path}.ready"), b"ready").unwrap(); + std::thread::sleep(Duration::from_millis(500)); +} + +#[tokio::test] +async fn antigravity_cross_process_lock_serializes_stable_sibling() { + let directory = tempfile::tempdir().unwrap(); + let store = Store::new(directory.path().join("auth.json")); + drop(store.lock(Duration::from_secs(1)).await.unwrap()); + let path = directory.path().join("auth.lock"); + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "providers::antigravity_auth_tests::antigravity_cross_process_lock_child", + "--exact", + "--nocapture", + ]) + .env("CCP_ANTIGRAVITY_TEST_LOCK", &path) + .stdout(std::process::Stdio::null()) + .spawn() + .unwrap(); + tokio::time::timeout(Duration::from_secs(3), async { + while !directory.path().join("auth.lock.ready").exists() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + assert!(store.lock(Duration::from_millis(30)).await.is_err()); + assert!(child.wait().unwrap().success()); + assert!(store.lock(Duration::from_secs(1)).await.is_ok()); +} + +#[test] +fn antigravity_local_status_distinguishes_missing_corrupt_and_expired_without_writes() { + let directory = tempfile::tempdir().unwrap(); + let store = Store::new(directory.path().join("absent/auth.json")); + let result = auth::descriptor(&Ok(Config::default()), &store); + assert_eq!(result["status"]["auth"], "missing"); + assert!(!directory.path().join("absent").exists()); + let config = directory.path().join("config.json"); + std::fs::write(&config,r#"{"antigravity":{"enabled":true,"clientId":"public-client","projectId":"private-project"}}"#).unwrap(); + let parsed = Config::read(&config, |_| None).unwrap(); + assert!(parsed.enabled); + let rendered = auth::descriptor(&Ok(parsed), &store).to_string(); + assert!(!rendered.contains("private-project")); + assert!(!rendered.contains("public-client")); + std::fs::write(&config, b"corrupt").unwrap(); + assert!(Config::read(&config, |_| None).is_err()); + std::fs::write(&config, b"[]").unwrap(); + assert!( + Config::read(&config, |_| None).is_err(), + "non-object configuration must not be silently valid" + ); +} + +#[tokio::test] +async fn antigravity_status_never_refreshes_or_exposes_private_values() { + let (_directory, _server, auth, state) = auth_fixture(false).await; + for (expires, refresh, expected) in [ + (0, Some("private-refresh".to_string()), "refresh_required"), + (0, None, "login_required"), + (auth::now() + 3600, None, "ready"), + ] { + let mut credential = expired(); + credential.expires_at = expires; + credential.refresh_token = refresh; + credential.project_id = Some("private-project".into()); + saved(&auth.store, &credential).await; + let descriptor = auth::descriptor(&Ok(auth.config.clone()), &auth.store); + assert_eq!(descriptor["status"]["auth"], expected); + assert_eq!( + descriptor["status"]["configuration_ready"], + expected != "login_required" + ); + for secret in [ + "private-project", + "private-refresh", + "expired-access", + "profile-stable", + ] { + assert!(!descriptor.to_string().contains(secret)); + } + } + assert_eq!(state.count.load(Ordering::SeqCst), 0); +} diff --git a/src/providers/antigravity_http_tests.rs b/src/providers/antigravity_http_tests.rs new file mode 100644 index 00000000..a5adff1c --- /dev/null +++ b/src/providers/antigravity_http_tests.rs @@ -0,0 +1,578 @@ +//! Synthetic loopback HTTP tests. No real OAuth, Google, CLI import, or inference. +use super::antigravity::{ + AntigravityProvider, + auth::{Auth, Credential, Store}, + config::Config, + replay, +}; +use crate::{ + config::AliasProvider, + monitor::{MonitorHandle, RequestStatus}, + provider::Provider, + registry::Registry, + server::{AppFeatures, app_with_features}, +}; +use axum::{ + Router, + body::Body, + extract::{Request, State}, + response::Response, + routing::post, +}; +use serde_json::{Value, json}; +use std::{ + path::Path, + sync::{Arc, Mutex}, + time::Duration, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +pub(super) struct Server { + pub(super) url: url::Url, + task: tokio::task::JoinHandle<()>, +} +impl Drop for Server { + fn drop(&mut self) { + self.task.abort(); + } +} +impl Server { + pub(super) async fn start(router: Router) -> Self { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = url::Url::parse(&format!("http://{}", listener.local_addr().unwrap())).unwrap(); + let task = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + Self { url, task } + } +} + +type Captured = Arc>>; +struct Backend { + captured: Captured, + replies: Vec<(u16, String)>, +} +async fn backend(State(state): State>, request: Request) -> Response { + let headers = request.headers().clone(); + let bytes = axum::body::to_bytes(request.into_body(), 32 * 1024 * 1024) + .await + .unwrap(); + let value = serde_json::from_slice(&bytes).unwrap(); + let index = { + let mut captured = state.captured.lock().unwrap(); + let index = captured.len(); + captured.push((headers, value)); + index + }; + let (status, body) = state + .replies + .get(index) + .unwrap_or_else(|| state.replies.last().unwrap()); + Response::builder() + .status(*status) + .header("content-type", "text/event-stream") + .header("retry-after", "7") + .body(Body::from(body.clone())) + .unwrap() +} +async fn native(replies: Vec<(u16, String)>) -> (Server, Captured) { + let captured = Arc::new(Mutex::new(Vec::new())); + let state = Arc::new(Backend { + captured: captured.clone(), + replies, + }); + let server = Server::start( + Router::new() + .route("/v1internal:streamGenerateContent", post(backend)) + .with_state(state), + ) + .await; + (server, captured) +} +fn frame(value: Value) -> String { + format!("data: {value}\n\n") +} +fn successful(parts: Value) -> String { + frame( + json!({"response":{"candidates":[{"content":{"role":"model","parts":parts},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":2,"thoughtsTokenCount":1,"totalTokenCount":13}}}), + ) +} +async fn provider(directory: &Path, url: &url::Url) -> AntigravityProvider { + let store = Store::new(directory.join("antigravity/auth.json")); + let lock = store.lock(Duration::from_secs(1)).await.unwrap(); + store + .save( + &lock, + &Credential { + profile_id: "profile".into(), + project_id: Some("project".into()), + access_token: "proxy-owned-access".into(), + refresh_token: Some("proxy-owned-refresh".into()), + expires_at: super::antigravity::auth::now() + 3600, + }, + ) + .unwrap(); + drop(lock); + let config = Config { + base_url: url.clone(), + client_id: Some("synthetic-client".into()), + ..Config::default() + }; + AntigravityProvider::with_auth(Auth::new(config, store).unwrap()) +} +async fn proxy(provider: AntigravityProvider, monitor: Option) -> Server { + let registry = Registry::from_providers( + AliasProvider::Codex, + [Arc::new(provider) as Arc], + ); + Server::start(app_with_features( + Arc::new(registry), + monitor, + AppFeatures { + responses_api: true, + ..AppFeatures::default() + }, + )) + .await +} +fn request(stream: bool) -> Value { + json!({"model":"antigravity/gemini-4-argon-low","max_tokens":64,"stream":stream,"messages":[{"role":"user","content":"synthetic prompt"}],"thinking":{"type":"adaptive","display":"omitted"},"output_config":{"effort":"high"}}) +} +fn client() -> reqwest::Client { + reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(5)) + .build() + .unwrap() +} + +#[tokio::test] +async fn antigravity_http2_refused_stream_is_not_automatically_retried() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let upstream = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut preface = [0_u8; 24]; + socket.read_exact(&mut preface).await.unwrap(); + assert_eq!(&preface, b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"); + socket + .write_all(&[0, 0, 0, 4, 0, 0, 0, 0, 0]) + .await + .unwrap(); + let mut attempts = 0; + loop { + let mut header = [0_u8; 9]; + if !matches!( + tokio::time::timeout(Duration::from_millis(500), socket.read_exact(&mut header)) + .await, + Ok(Ok(_)) + ) { + break; + } + let length = u32::from_be_bytes([0, header[0], header[1], header[2]]) as usize; + assert!(length <= 65536); + let mut payload = vec![0; length]; + socket.read_exact(&mut payload).await.unwrap(); + if header[3] == 4 && header[4] & 1 == 0 { + socket + .write_all(&[0, 0, 0, 4, 1, 0, 0, 0, 0]) + .await + .unwrap(); + } + if header[3] == 1 { + attempts += 1; + let reset = [ + 0, 0, 4, 3, 0, header[5], header[6], header[7], header[8], 0, 0, 0, 7, + ]; + socket.write_all(&reset).await.unwrap(); + } + } + attempts + }); + let client = Config::default() + .client_builder() + .no_proxy() + .http2_prior_knowledge() + .build() + .unwrap(); + assert!( + client + .post(format!("http://{address}/v1internal:streamGenerateContent")) + .json(&json!({"synthetic":"generation"})) + .send() + .await + .is_err() + ); + assert_eq!( + upstream.await.unwrap(), + 1, + "even protocol nacks must not silently buy a second native attempt" + ); +} + +#[tokio::test] +async fn antigravity_serving_capability_uses_its_own_auth_and_configuration() { + let (upstream, captured) = native(vec![(200, successful(json!([{"text":"unused"}])))]).await; + let directory = tempfile::tempdir().unwrap(); + let proxy = proxy(provider(directory.path(), &upstream.url).await, None).await; + let response = client() + .get( + proxy + .url + .join("/v1/models?include_capabilities=true") + .unwrap(), + ) + .send() + .await + .unwrap(); + let value: Value = response.json().await.unwrap(); + let descriptor = &value["capabilities"]["providers"]["antigravity"]; + assert_eq!(descriptor["enabled"], true); + assert_eq!(descriptor["status"]["auth"], "ready"); + assert_eq!(descriptor["status"]["project"], "configured"); + assert_eq!(descriptor["status"]["configuration_ready"], true); + assert_eq!(descriptor["status"]["live_verified"], false); + assert!(captured.lock().unwrap().is_empty()); + assert!(!descriptor.to_string().contains("proxy-owned")); +} + +#[tokio::test] +async fn antigravity_opaque_native_signature_corruption_is_upstream_not_local_authentication() { + use base64::Engine; + let binding = replay::Binding { + profile_id: "profile".into(), + project_id: "project".into(), + native_model: "gemini-4-argon-low".into(), + }; + let native_contents = json!([{"role":"model","parts":[{"text":"same projection","thoughtSignature":"original-native-signature"}]}]); + let mut blocks = replay::encode(&native_contents, &binding, "17", "full", &json!([])).unwrap(); + let original = replay::decode(&blocks, &binding).unwrap(); + let mut mutated = original.clone(); + mutated["contents"][0]["parts"][0]["thoughtSignature"] = json!("corrupt-native-signature"); + blocks[0]["signature"] = json!(format!( + "{}{}", + replay::PREFIX, + base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&mutated).unwrap()) + )); + let decoded = replay::decode(&blocks, &binding).unwrap(); + assert_eq!(decoded["projection_digest"], original["projection_digest"]); + let (upstream, captured) = native(vec![( + 400, + "upstream signature validation rejected corrupt-native-signature".into(), + )]) + .await; + let directory = tempfile::tempdir().unwrap(); + let monitor = MonitorHandle::new(10); + let proxy = proxy( + provider(directory.path(), &upstream.url).await, + Some(monitor.clone()), + ) + .await; + let mut body = request(true); + body["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":blocks}), + json!({"role":"user","content":"next"}), + ]); + let response = client() + .post(proxy.url.join("/v1/messages").unwrap()) + .json(&body) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 400); + let error = response.text().await.unwrap(); + assert!(!error.contains("corrupt-native-signature")); + assert!(!error.contains("tool_use")); + assert!(!error.contains("message_stop")); + let captured = captured.lock().unwrap(); + assert_eq!( + captured.len(), + 1, + "opaque corruption requires exactly one native validation attempt, never repair/retry" + ); + assert_eq!( + captured[0].1["request"]["contents"][1]["parts"][0]["thoughtSignature"], + "corrupt-native-signature" + ); + let diagnostic = format!("{:?}", monitor.snapshot()); + assert!(!diagnostic.contains("corrupt-native-signature")); +} + +#[tokio::test] +async fn antigravity_http_preserves_preheader_errors_without_retry_or_secret_echo() { + for status in [401, 403, 429, 503] { + let (upstream, captured) = native(vec![( + status, + "private OAuth token and native signature".into(), + )]) + .await; + let directory = tempfile::tempdir().unwrap(); + let proxy = proxy(provider(directory.path(), &upstream.url).await, None).await; + let response = client() + .post(proxy.url.join("/v1/messages").unwrap()) + .header("authorization", "Bearer frontend-secret") + .header("anthropic-beta", "unrelated-beta") + .json(&request(true)) + .send() + .await + .unwrap(); + assert_eq!(response.status().as_u16(), status); + assert_eq!(response.headers()["retry-after"], "7"); + assert!(!response.text().await.unwrap().contains("private OAuth")); + let captured = captured.lock().unwrap(); + assert_eq!(captured.len(), 1); + assert_eq!(captured[0].0["authorization"], "Bearer proxy-owned-access"); + assert!(!captured[0].0.contains_key("anthropic-beta")); + let identity = captured[0].0["user-agent"].to_str().unwrap(); + assert!(identity.starts_with("claude-code-proxy/")); + assert!(identity.contains("Antigravity-compatible")); + } +} + +#[tokio::test] +async fn antigravity_http_replay_restores_native_calls_after_fresh_provider() { + let original = json!([{"thought":true,"text":"hidden native thought","thoughtSignature":"raw-thought"}, + {"functionCall":{"name":"Read","args":{"limit":1}},"thoughtSignature":"raw-call"}]); + let (upstream, captured) = native(vec![ + (200, successful(original.clone())), + ( + 200, + successful(json!([{"text":"done","thoughtSignature":"raw-final"}])), + ), + ]) + .await; + let directory = tempfile::tempdir().unwrap(); + let first = proxy(provider(directory.path(), &upstream.url).await, None).await; + let mut body = request(false); + body["tools"] = json!([{"name":"Read","input_schema":{"type":"object","properties":{"limit":{"type":"integer","exclusiveMinimum":0}},"required":["limit"],"additionalProperties":false}}]); + let response = client() + .post(first.url.join("/v1/messages").unwrap()) + .json(&body) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + let message: Value = response.json().await.unwrap(); + assert_eq!(message["content"][0]["thinking"], ""); + let local = message["content"][1]["id"].as_str().unwrap(); + drop(first); + let second = proxy(provider(directory.path(), &upstream.url).await, None).await; + body["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":message["content"]}), + json!({"role":"system","content":"ordered system after resume"}), + json!({"role":"user","content":[{"type":"tool_result","tool_use_id":local,"content":"{\"untouched\":true}"},{"type":"text","text":"continue"}]}) + ]); + let response = client() + .post(second.url.join("/v1/messages").unwrap()) + .json(&body) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200, "{}", response.text().await.unwrap()); + { + let captured = captured.lock().unwrap(); + assert_eq!(captured.len(), 2); + let wire = &captured[1].1["request"]; + assert_eq!( + wire["contents"][1], + json!({"role":"model","parts":original}) + ); + assert_eq!( + wire["contents"][2], + json!({"role":"model","parts":[{"functionResponse":{"name":"Read","response":{"output":"{\"untouched\":true}"}}}]}) + ); + assert_eq!( + wire["contents"][3], + json!({"role":"user","parts":[{"text":"continue"}]}) + ); + assert_eq!(wire["sessionId"], captured[0].1["request"]["sessionId"]); + assert_eq!( + wire["systemInstruction"]["parts"], + json!([{"text":"ordered system after resume"}]) + ); + } + body["model"] = json!("antigravity/gemini-4-argon-high"); + let response = client() + .post(second.url.join("/v1/messages").unwrap()) + .json(&body) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 400); + assert_eq!( + captured.lock().unwrap().len(), + 2, + "model switch must not purchase inference" + ); +} + +#[tokio::test] +async fn antigravity_late_error_sets_one_failed_monitor_outcome_and_no_success_events() { + let (upstream,captured)=native(vec![(200,frame(json!({"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"must not publish"}]}}]}})))]).await; + let directory = tempfile::tempdir().unwrap(); + let monitor = MonitorHandle::new(10); + let proxy = proxy( + provider(directory.path(), &upstream.url).await, + Some(monitor.clone()), + ) + .await; + let response = client() + .post(proxy.url.join("/v1/messages").unwrap()) + .json(&request(true)) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + let text = response.text().await.unwrap(); + assert!(text.contains("event: error")); + for prohibited in [ + "message_stop", + "tool_use", + "must not publish", + "message_start", + ] { + assert!(!text.contains(prohibited)); + } + tokio::time::sleep(Duration::from_millis(20)).await; + let snapshot = monitor.snapshot(); + assert!(snapshot.active.is_empty()); + assert_eq!(snapshot.recent.len(), 1); + assert_eq!(snapshot.recent[0].status, RequestStatus::Failed); + assert_eq!(captured.lock().unwrap().len(), 1); +} + +#[tokio::test] +async fn antigravity_effective_tier_is_observable_and_openai_stays_unsupported() { + let (upstream, captured) = native(vec![(200, successful(json!([{"text":"ok"}])))]).await; + let directory = tempfile::tempdir().unwrap(); + let monitor = MonitorHandle::new(10); + let proxy = proxy( + provider(directory.path(), &upstream.url).await, + Some(monitor.clone()), + ) + .await; + let response = client() + .post(proxy.url.join("/v1/messages").unwrap()) + .json(&request(false)) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + assert_eq!( + response + .headers() + .get("x-antigravity-reasoning-tier") + .and_then(|v| v.to_str().ok()), + Some("low") + ); + let _ = response.text().await.unwrap(); + tokio::time::sleep(Duration::from_millis(20)).await; + assert_eq!(monitor.snapshot().recent[0].effort.as_deref(), Some("low")); + assert_eq!(monitor.snapshot().recent[0].input_tokens, Some(10)); + assert_eq!(monitor.snapshot().recent[0].output_tokens, Some(3)); + let response=client().post(proxy.url.join("/v1/chat/completions").unwrap()).json(&json!({"model":"antigravity/gemini-4-argon-low","messages":[{"role":"user","content":"x"}]})).send().await.unwrap(); + assert_eq!(response.status(), 501); + assert_eq!(captured.lock().unwrap().len(), 1); +} + +async fn read_request(stream: &mut tokio::net::TcpStream) { + let mut raw = Vec::new(); + let mut buffer = [0u8; 1024]; + loop { + let n = stream.read(&mut buffer).await.unwrap(); + assert_ne!(n, 0); + raw.extend_from_slice(&buffer[..n]); + if let Some(end) = raw.windows(4).position(|w| w == b"\r\n\r\n") { + let header = String::from_utf8_lossy(&raw[..end]); + let length = header + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .map(|v| v.trim().parse::().unwrap()) + }) + .unwrap(); + if raw.len() >= end + 4 + length { + return; + } + } + } +} + +#[tokio::test] +async fn antigravity_real_disconnect_cancels_preheader_nonstream_and_stream_collection() { + for (headers_sent, streaming) in [(false, false), (false, true), (true, false), (true, true)] { + let upstream = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = url::Url::parse(&format!("http://{}", upstream.local_addr().unwrap())).unwrap(); + let (accepted_tx, accepted_rx) = tokio::sync::oneshot::channel(); + let (closed_tx, closed_rx) = tokio::sync::oneshot::channel(); + let native_task = tokio::spawn(async move { + let (mut socket, _) = upstream.accept().await.unwrap(); + read_request(&mut socket).await; + if headers_sent { + socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\n\r\n").await.unwrap(); + } + let _ = accepted_tx.send(()); + let mut byte = [0u8; 1]; + let closed = tokio::time::timeout(Duration::from_secs(3), socket.read(&mut byte)).await; + let _ = closed_tx.send(matches!(closed, Ok(Ok(0)) | Ok(Err(_)))); + }); + let directory = tempfile::tempdir().unwrap(); + let monitor = MonitorHandle::new(10); + let proxy = proxy( + provider(directory.path(), &url).await, + Some(monitor.clone()), + ) + .await; + let mut downstream = + tokio::net::TcpStream::connect(("127.0.0.1", proxy.url.port().unwrap())) + .await + .unwrap(); + let body = request(streaming).to_string(); + downstream.write_all(format!("POST /v1/messages HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}",body.len()).as_bytes()).await.unwrap(); + accepted_rx.await.unwrap(); + tokio::time::sleep(Duration::from_millis(30)).await; + drop(downstream); + assert!( + closed_rx.await.unwrap(), + "upstream task survived disconnect: headers={headers_sent} stream={streaming}" + ); + native_task.await.unwrap(); + tokio::time::sleep(Duration::from_millis(20)).await; + assert!(monitor.snapshot().active.is_empty()); + } +} + +#[tokio::test] +async fn antigravity_count_estimate_is_signature_size_invariant_and_network_free() { + let (upstream, captured) = native(vec![(200, successful(json!([{"text":"unused"}])))]).await; + let directory = tempfile::tempdir().unwrap(); + let proxy = proxy(provider(directory.path(), &upstream.url).await, None).await; + let binding = replay::Binding { + profile_id: "profile".into(), + project_id: "project".into(), + native_model: "gemini-4-argon-low".into(), + }; + let mut estimates = Vec::new(); + for signature in ["x".to_string(), "x".repeat(100000)] { + let contents = + json!([{"role":"model","parts":[{"text":"same text","thoughtSignature":signature}]}]); + let blocks = replay::encode(&contents, &binding, "123", "omitted", &json!([])).unwrap(); + let mut body = request(false); + body["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":blocks}), + json!({"role":"user","content":"next"}), + ]); + let response = client() + .post(proxy.url.join("/v1/messages/count_tokens").unwrap()) + .json(&body) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + estimates.push(response.json::().await.unwrap()["input_tokens"].clone()); + } + assert_eq!(estimates[0], estimates[1]); + assert!(captured.lock().unwrap().is_empty()); +} diff --git a/src/providers/antigravity_tests.rs b/src/providers/antigravity_tests.rs new file mode 100644 index 00000000..1edf2b0d --- /dev/null +++ b/src/providers/antigravity_tests.rs @@ -0,0 +1,1020 @@ +//! Tests written against the frozen independent Python vectors before the Rust codec. +use super::antigravity::replay; +use super::antigravity::{ + auth::{Credential, Store}, + config::Config, +}; +use super::antigravity::{request::prepare, schema::ToolSet, transport::Reducer}; +use serde_json::{Value, json}; + +#[test] +fn antigravity_endpoint_validation_disallows_credential_redirect_targets() { + for value in [ + "http://example.com", + "https://user:secret@example.com", + "https://example.com/?token=x", + "https://example.com/#x", + ] { + assert!(Config::validate_endpoint(value, false).is_err()); + } + assert!(Config::validate_endpoint("https://cloudcode-pa.googleapis.com", false).is_ok()); + assert!(Config::validate_endpoint("http://127.0.0.1:1234", false).is_err()); + assert!(Config::validate_endpoint("http://127.0.0.1:1234", true).is_ok()); +} + +#[tokio::test] +async fn antigravity_auth_storage_is_strict_private_and_lock_inode_is_stable() { + let directory = tempfile::tempdir().unwrap(); + let store = Store::new(directory.path().join("antigravity/auth.json")); + assert!(store.load().unwrap().is_none()); + assert!( + !directory.path().join("antigravity").exists(), + "inspection must not create directories" + ); + let lock = store.lock(std::time::Duration::from_secs(1)).await.unwrap(); + let credential = Credential { + profile_id: "profile".into(), + project_id: Some("project".into()), + access_token: "synthetic-access".into(), + refresh_token: Some("synthetic-refresh".into()), + expires_at: u64::MAX, + }; + store.save(&lock, &credential).unwrap(); + assert_eq!(store.load().unwrap().unwrap().profile_id, "profile"); + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let inode = std::fs::metadata(directory.path().join("antigravity/auth.lock")) + .unwrap() + .ino(); + assert_eq!( + std::fs::metadata(directory.path().join("antigravity/auth.json")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + store.clear(&lock).unwrap(); + assert_eq!( + std::fs::metadata(directory.path().join("antigravity/auth.lock")) + .unwrap() + .ino(), + inode + ); + } + drop(lock); + let corrupt_path = directory.path().join("antigravity/auth.json"); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + use std::io::Write; + options + .open(&corrupt_path) + .unwrap() + .write_all(b"{bad private data") + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&corrupt_path) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + let error = store.load().err().unwrap(); + assert_eq!(error, "invalid"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&corrupt_path, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert_eq!(store.load().err().unwrap(), "unreadable"); + } +} + +#[tokio::test] +async fn antigravity_auth_lock_is_bounded_and_logout_invalidates_pending_login() { + let directory = tempfile::tempdir().unwrap(); + let store = Store::new(directory.path().join("auth.json")); + let mut lock = store.lock(std::time::Duration::from_secs(1)).await.unwrap(); + let original = lock.epoch().unwrap(); + assert!( + store + .lock(std::time::Duration::from_millis(20)) + .await + .is_err() + ); + store.clear(&lock).unwrap(); + lock.advance().unwrap(); + assert_ne!(lock.epoch().unwrap(), original); + drop(lock); + assert!(store.lock(std::time::Duration::from_secs(1)).await.is_ok()); +} + +fn binding() -> replay::Binding { + replay::Binding { + profile_id: "profile".into(), + project_id: "project".into(), + native_model: "gemini-4-argon-low".into(), + } +} + +fn request_value() -> Value { + json!({"model":"antigravity/gemini-4-argon-low", "max_tokens":100, + "messages":[{"role":"user", "content":"hello"}]}) +} + +#[test] +fn antigravity_absent_native_args_project_empty_without_changing_replay() { + let contents = json!([{"role":"model","parts":[{"functionCall":{"name":"empty"},"thoughtSignature":"untouched"}]}]); + let set = ToolSet::compile( + &json!([{"name":"empty","input_schema":{"type":"object","additionalProperties":false}}]), + ) + .unwrap(); + let mapping = set.mapping(&contents).unwrap(); + let blocks = replay::encode(&contents, &binding(), "19", "full", &mapping).unwrap(); + assert_eq!(blocks[1]["input"], json!({})); + let decoded = replay::decode(&blocks, &binding()).unwrap(); + assert_eq!(decoded["contents"], contents); + assert!( + decoded["contents"][0]["parts"][0]["functionCall"] + .get("args") + .is_none() + ); + let mut continuation = request_value(); + continuation["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":blocks}), + json!({"role":"user","content":[{"type":"tool_result","tool_use_id":blocks[1]["id"],"content":"completed"}]}), + ]); + let prepared = prepare(&serde_json::from_value(continuation).unwrap(), &binding()).unwrap(); + assert_eq!(prepared.wire["request"]["contents"][1], contents[0]); + assert_eq!( + prepared.wire["request"]["contents"][2], + json!({"role":"model","parts":[{"functionResponse":{"name":"empty","response":{"output":"completed"}}}]}) + ); + let mut reducer = Reducer::default(); + reducer.push(format!("data: {}\n\n", json!({"response":{"candidates":[{"content":contents[0],"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2}}})).as_bytes()).unwrap(); + assert_eq!(reducer.finish().unwrap().contents, contents); + let required = ToolSet::compile(&json!([{"name":"empty","input_schema":{"type":"object","properties":{"x":{"type":"string"}},"required":["x"]}}])).unwrap(); + assert!(required.mapping(&contents).is_err()); + for args in [Value::Null, json!([]), json!(""), json!(1)] { + let mut invalid = contents.clone(); + invalid[0]["parts"][0]["functionCall"]["args"] = args; + assert!(set.mapping(&invalid).is_err()); + } + for key in ["partialArgs", "willContinue"] { + let mut invalid = contents.clone(); + invalid[0]["parts"][0]["functionCall"][key] = json!(true); + assert!(set.mapping(&invalid).is_err()); + } +} + +#[test] +fn antigravity_json_fallback_preserves_complete_bounded_schema_guidance() { + for schema in [ + json!({"type":"object","properties":{"known":{"type":"string"}},"required":["mandatory"],"additionalProperties":{"type":"integer","enum":[17,29]}}), + json!({"type":"object","properties":{"value":{"type":"string","pattern":"^prefix"}},"oneOf":[{"required":["value"]},{"required":["other"]}]}), + ] { + let set = ToolSet::compile(&json!([{"name":"wrapped","input_schema":schema}])).unwrap(); + let description = set.declarations[0]["parameters"]["properties"]["json"]["description"] + .as_str() + .unwrap(); + assert!( + description.contains(&serde_json::to_string(&schema).unwrap()), + "original constraints missing from fallback: {description}" + ); + } + let source = json!({"type":"object","additionalProperties":{"type":"string"},"description":"x".repeat(20000)}); + assert!( + ToolSet::compile(&json!([{"name":"oversized","input_schema":source}])).is_err(), + "oversized faithful guidance must reject before inference" + ); +} + +#[test] +fn antigravity_required_undeclared_properties_use_codec_or_reject_unsatisfiable_contract() { + let schema = + json!({"type":"object","properties":{"known":{"type":"string"}},"required":["mandatory"]}); + let set = ToolSet::compile(&json!([{"name":"caller","input_schema":schema}])).unwrap(); + assert_eq!( + set.declarations[0]["parameters"]["required"], + json!(["json"]) + ); + assert!(set.mapping(&json!([{"role":"model","parts":[{"functionCall":{"name":"caller","args":{"json":"{\"mandatory\":17}"}}}]}])).is_ok()); + let mut closed = schema; + closed["additionalProperties"] = json!(false); + assert!(ToolSet::compile(&json!([{"name":"caller","input_schema":closed}])).is_err()); + closed["oneOf"] = json!([{"required":["mandatory"]}]); + assert!( + ToolSet::compile(&json!([{"name":"caller","input_schema":closed}])).is_err(), + "a codec fallback must not hide the known unsatisfiable required-property contract" + ); +} + +#[test] +fn antigravity_nested_freeform_guidance_keeps_required_names_and_original_validation() { + let leaf = json!({"type":"object","required":["weight"],"additionalProperties":{"type":"integer","enum":[17,29]}}); + let set = ToolSet::compile(&json!([{"name":"nested","input_schema":{"type":"object","properties":{"payload":leaf},"required":["payload"]}}])).unwrap(); + let native = &set.declarations[0]["parameters"]["properties"]["payload"]; + assert_eq!(native["type"], "STRING"); + assert!( + native["description"] + .as_str() + .unwrap() + .contains(&serde_json::to_string(&leaf).unwrap()) + ); + for (raw, valid) in [ + (r#"{"weight":17}"#, true), + (r#"{"weight":18}"#, false), + ("{}", false), + ] { + let contents = json!([{"role":"model","parts":[{"functionCall":{"name":"nested","args":{"payload":raw}}}]}]); + assert_eq!(set.mapping(&contents).is_ok(), valid); + } +} + +#[test] +fn antigravity_typeless_anyof_keeps_every_sibling_in_complete_codec_guidance() { + let cases = [ + ("enum", json!({"enum":[17,29]}), json!(18)), + ("const", json!({"const":17}), json!(18)), + ( + "allOf", + json!({"allOf":[{"minimum":17},{"maximum":17}]}), + json!(18), + ), + ( + "oneOf", + json!({"oneOf":[{"const":17},{"const":29}]}), + json!(18), + ), + ( + "description", + json!({"description":"An integer or fractional number; preserve this guidance."}), + json!("not a number"), + ), + ( + "annotations", + json!({"title":"numeric choice","default":17}), + json!("not a number"), + ), + ("type array", json!({"type":["integer"]}), json!(17.5)), + ]; + let mut missing = Vec::new(); + let mut compiled = Vec::new(); + for (name, siblings, invalid) in cases { + let mut leaf = json!({"anyOf":[{"type":"integer"},{"type":"number"}]}); + leaf.as_object_mut() + .unwrap() + .extend(siblings.as_object().unwrap().clone()); + let set = ToolSet::compile(&json!([{"name":"union","input_schema":{"type":"object","properties":{"value":leaf},"required":["value"]}}])).unwrap(); + let native = &set.declarations[0]["parameters"]["properties"]["value"]; + if native["type"] != "STRING" + || !native["description"] + .as_str() + .is_some_and(|text| text.contains(&serde_json::to_string(&leaf).unwrap())) + { + missing.push(name); + } + compiled.push((set, invalid)); + } + assert!( + missing.is_empty(), + "typeless anyOf discarded complete sibling guidance: {missing:?}" + ); + for (set, invalid) in compiled { + let contents = json!([{"role":"model","parts":[{"functionCall":{"name":"union","args":{"value":"17"}},"thoughtSignature":"unchanged"}]}]); + let mapping = set.mapping(&contents).unwrap(); + assert_eq!(mapping[0]["codec_paths"], json!(["/value"])); + let blocks = replay::encode(&contents, &binding(), "19", "full", &mapping).unwrap(); + assert_eq!(blocks[1]["input"]["value"], 17); + assert_eq!( + replay::decode(&blocks, &binding()).unwrap()["contents"], + contents + ); + let mut invalid_contents = contents; + invalid_contents[0]["parts"][0]["functionCall"]["args"]["value"] = + json!(serde_json::to_string(&invalid).unwrap()); + assert!( + set.mapping(&invalid_contents).is_err(), + "decoded arguments must still satisfy the complete original schema" + ); + } +} + +#[test] +fn antigravity_typeless_anyof_keeps_object_siblings_without_narrowing_or_dropping_them() { + let leaf = json!({ + "anyOf":[{"type":"object","properties":{"x":{"type":"integer"}}},{"type":"object","properties":{"y":{"type":"string"}}}], + "properties":{"x":{"type":"integer"}},"required":["x"],"additionalProperties":false + }); + let set = ToolSet::compile(&json!([{"name":"union","input_schema":{"type":"object","properties":{"value":leaf},"required":["value"]}}])).unwrap(); + let native = &set.declarations[0]["parameters"]["properties"]["value"]; + assert_eq!(native["type"], "STRING"); + assert!( + native["description"] + .as_str() + .unwrap() + .contains(&serde_json::to_string(&leaf).unwrap()) + ); + for (raw, valid) in [ + (r#"{"x":17}"#, true), + (r#"{"y":"other"}"#, false), + (r#"{"x":17,"extra":true}"#, false), + ] { + let contents = json!([{"role":"model","parts":[{"functionCall":{"name":"union","args":{"value":raw}}}]}]); + assert_eq!(set.mapping(&contents).is_ok(), valid); + } +} + +#[test] +fn antigravity_typeless_anyof_sibling_guidance_bound_and_reference_rejection_still_apply() { + let oversized = + json!({"anyOf":[{"type":"integer"},{"type":"number"}],"description":"x".repeat(20000)}); + assert!(ToolSet::compile(&json!([{"name":"union","input_schema":{"type":"object","properties":{"value":oversized}}}])).is_err(), "complete anyOf sibling guidance cannot bypass the 16 KiB bound"); + for rejected in [ + json!({"$ref":"https://example.invalid/schema"}), + json!({"$defs":{}}), + json!({"$dynamicRef":"#x"}), + ] { + let mut leaf = json!({"anyOf":[{"type":"integer"},{"type":"number"}]}); + leaf.as_object_mut() + .unwrap() + .extend(rejected.as_object().unwrap().clone()); + assert!(ToolSet::compile(&json!([{"name":"union","input_schema":{"type":"object","properties":{"value":leaf}}}])).is_err()); + } +} + +#[test] +fn antigravity_typeless_anyof_without_siblings_retains_native_alternatives() { + let set = ToolSet::compile(&json!([{"name":"union","input_schema":{"type":"object","properties":{"value":{"anyOf":[{"type":"integer"},{"type":"number"}]}}}}])).unwrap(); + assert_eq!( + set.declarations[0]["parameters"]["properties"]["value"], + json!({"anyOf":[{"type":"INTEGER"},{"type":"NUMBER"}]}) + ); + let contents = + json!([{"role":"model","parts":[{"functionCall":{"name":"union","args":{"value":17.5}}}]}]); + let mapping = set.mapping(&contents).unwrap(); + assert_eq!(mapping[0]["codec_paths"], json!([])); +} + +#[test] +fn antigravity_nonstring_enum_values_survive_native_guidance() { + let set = ToolSet::compile(&json!([{"name":"numbers","input_schema":{"type":"object","properties":{"value":{"type":"integer","enum":[17,29]}}}}])).unwrap(); + let description = set.declarations[0]["parameters"]["properties"]["value"]["description"] + .as_str() + .unwrap(); + assert!( + description.contains("[17,29]"), + "native enum guidance omitted accepted values: {description}" + ); + assert!(set.mapping(&json!([{"role":"model","parts":[{"functionCall":{"name":"numbers","args":{"value":18}}}]}])).is_err()); +} + +#[test] +fn antigravity_adjacent_assistant_fragments_preserve_original_system_boundaries() { + let contents = + json!([{"role":"model","parts":[{"thought":true,"text":"thought"},{"text":"answer"}]}]); + let blocks = replay::encode(&contents, &binding(), "19", "full", &json!([])).unwrap(); + let mut request = request_value(); + request["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":[blocks[0]]}), + json!({"role":"assistant","content":"answer"}), + json!({"role":"user","content":"next"}), + ]); + let prepared = prepare( + &serde_json::from_value(request.clone()).unwrap(), + &binding(), + ) + .unwrap(); + assert_eq!(prepared.wire["request"]["contents"][1], contents[0]); + let mut with_empty_fragment = request.clone(); + with_empty_fragment["messages"] + .as_array_mut() + .unwrap() + .insert(1, json!({"role":"assistant","content":[]})); + assert!( + prepare( + &serde_json::from_value(with_empty_fragment).unwrap(), + &binding() + ) + .is_ok(), + "empty array fragments must not invent unsigned turns" + ); + request["messages"] + .as_array_mut() + .unwrap() + .insert(2, json!({"role":"system","content":"real system boundary"})); + assert!( + prepare(&serde_json::from_value(request).unwrap(), &binding()).is_err(), + "system extraction must not manufacture adjacency" + ); + + let mut distinct = request_value(); + distinct["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":blocks}), + json!({"role":"system","content":"privileged"}), + json!({"role":"assistant","content":blocks}), + json!({"role":"user","content":"ordinary user text"}), + ]); + let prepared = prepare(&serde_json::from_value(distinct).unwrap(), &binding()).unwrap(); + assert_eq!( + prepared.wire["request"]["contents"] + .as_array() + .unwrap() + .len(), + 4 + ); + assert_eq!( + prepared.wire["request"]["systemInstruction"]["parts"], + json!([{"text":"privileged"}]) + ); + assert_eq!(prepared.wire["request"]["contents"][3]["role"], "user"); +} + +#[test] +fn antigravity_count_estimate_is_independent_of_thought_display_and_fragmentation() { + let contents = json!([{"role":"model","parts":[{"thought":true,"text":"retained hidden thought ".repeat(200)},{"text":"answer","thoughtSignature":"opaque"}]}]); + let mut counts = Vec::new(); + for display in ["full", "omitted"] { + let blocks = replay::encode(&contents, &binding(), "19", display, &json!([])).unwrap(); + let mut request = request_value(); + request["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":blocks}), + json!({"role":"user","content":"next"}), + ]); + counts.push( + prepare(&serde_json::from_value(request).unwrap(), &binding()) + .unwrap() + .estimate, + ); + } + assert!( + counts.iter().all(|n| *n == counts[0]), + "presentation changed estimated native history: {counts:?}" + ); + for display in ["full", "omitted"] { + let blocks = replay::encode(&contents, &binding(), "19", display, &json!([])).unwrap(); + let mut request = request_value(); + request["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":[blocks[0]]}), + json!({"role":"assistant","content":"answer"}), + json!({"role":"user","content":"next"}), + ]); + assert_eq!( + prepare(&serde_json::from_value(request).unwrap(), &binding()) + .unwrap() + .estimate, + counts[0] + ); + } +} + +#[test] +fn antigravity_count_reconstructs_recorded_name_and_json_codecs_without_transport_duplication() { + let vectors = golden(); + let mut estimates = Vec::new(); + for index in [0, 2] { + let vector = &vectors["vectors"][index]; + let scope: replay::Binding = serde_json::from_value(vector["binding"].clone()).unwrap(); + for display in ["full", "omitted"] { + for signature_bytes in [1, 100000] { + let mut native = vector["native_contents"].clone(); + native[0]["parts"][0]["thoughtSignature"] = json!("x".repeat(signature_bytes)); + let blocks = replay::encode( + &native, + &scope, + "19", + display, + &vector["envelope"]["tool_mapping"], + ) + .unwrap(); + for split in [false, true] { + let mut request = request_value(); + if split { + request["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":[blocks[0]]}), + json!({"role":"assistant","content":[blocks[1]]}), + ]); + } else { + request["messages"] + .as_array_mut() + .unwrap() + .push(json!({"role":"assistant","content":blocks})); + } + request["messages"].as_array_mut().unwrap().push(json!({"role":"user","content":[{"type":"tool_result","tool_use_id":blocks[1]["id"],"content":"ok"}]})); + estimates.push( + prepare(&serde_json::from_value(request).unwrap(), &scope) + .unwrap() + .estimate, + ); + } + } + } + } + assert!( + estimates.iter().all(|estimate| *estimate == estimates[0]), + "codec/display/fragment representation changed semantic estimate: {estimates:?}" + ); +} + +#[test] +fn antigravity_large_text_coalescing_preserves_unicode_and_unknown_field_checks() { + let text = "雪a".repeat(256); + let contents = json!([{"role":"model","parts":vec![json!({"text":text});4096]}]); + let mapping = json!([]); + let blocks = replay::encode(&contents, &binding(), "19", "full", &mapping).unwrap(); + let envelope = replay::decode(&blocks, &binding()).unwrap(); + let merged = json!([blocks[0], {"type":"text","text":text.repeat(4096)}]); + let start = std::time::Instant::now(); + let fragmented_digest = replay::digest_input(&blocks, &envelope).unwrap(); + eprintln!( + "antigravity_coalescing_4096_parts_us={}", + start.elapsed().as_micros() + ); + assert_eq!( + fragmented_digest, + replay::digest_input(&merged, &envelope).unwrap() + ); + let invalid = json!([blocks[0], {"type":"text","text":"a"},{"type":"text","text":"b","unsupported":true}]); + assert!(replay::digest_input(&invalid, &envelope).is_err()); +} + +#[test] +fn antigravity_coalescing_rejects_cumulative_oversize_before_growth() { + let vector = golden(); + let envelope = &vector["vectors"][0]["envelope"]; + let blocks = json!([{"type":"thinking","thinking":""},{"type":"text","text":"x".repeat(replay::MAX_DECODED)},{"type":"text","text":"b"}]); + assert!(replay::digest_input(&blocks, envelope).is_err()); +} + +#[test] +fn antigravity_integer_exclusive_projection_keeps_fractional_inclusive_constraints() { + let set = ToolSet::compile( + &json!([{"name":"range","input_schema":{"type":"object","properties":{ + "lower":{"type":"integer","minimum":1.5,"exclusiveMinimum":0}, + "upper":{"type":"integer","maximum":-1.5,"exclusiveMaximum":0} + }}}]), + ) + .unwrap(); + let properties = &set.declarations[0]["parameters"]["properties"]; + assert_eq!(properties["lower"]["minimum"], 2); + assert_eq!(properties["upper"]["maximum"], -2); +} + +#[test] +fn antigravity_schema_preserves_original_dialects_and_constraints() { + for dialect in [ + "https://json-schema.org/draft/2020-12/schema", + "http://json-schema.org/draft-07/schema#", + ] { + let set = ToolSet::compile(&json!([{"name":"Read", "input_schema":{ + "$schema":dialect, "type":"object", "properties":{"limit":{"type":"integer","exclusiveMinimum":0}}, + "required":["limit"], "additionalProperties":false + }}])).unwrap(); + assert_eq!( + set.declarations[0]["parameters"]["properties"]["limit"]["minimum"], + 1 + ); + for args in [json!({"limit":0}), json!({"limit":1,"extra":true})] { + assert!(set.mapping(&json!([{"role":"model","parts":[{"functionCall":{"name":"Read","args":args}}]}])).is_err()); + } + assert!(set.mapping(&json!([{"role":"model","parts":[{"functionCall":{"name":"Read","args":{"limit":1}}}]}])).is_ok()); + } +} + +#[test] +fn antigravity_schema_rejects_external_references_and_unknown_keywords() { + for schema in [ + json!({"$ref":"https://example.invalid/schema"}), + json!({"$defs":{}}), + json!({"definitions":{}}), + json!({"$dynamicRef":"#x"}), + json!({"prefixItems":[]}), + json!({"unevaluatedProperties":false}), + json!({"$schema":"https://example.invalid/draft"}), + json!({"type":"object", "unknownConstraint":true}), + ] { + assert!(ToolSet::compile(&json!([{"name":"tool", "input_schema":schema}])).is_err()); + } +} + +#[test] +fn antigravity_freeform_codec_and_mapped_names_are_lossless() { + let name = "caller/tool with spaces"; + let set = ToolSet::compile(&json!([{"name":name,"input_schema":{"type":"object","properties":{"args":{}},"required":["args"]}}])).unwrap(); + let native_name = &set.declarations[0]["name"]; + assert_ne!(native_name, name); + let contents = json!([{"role":"model","parts":[{"functionCall":{"name":native_name,"args":{"args":"[1, {\"x\":true}]"}},"thoughtSignature":"unchanged"}]}]); + let mapping = set.mapping(&contents).unwrap(); + let blocks = replay::encode(&contents, &binding(), "1", "omitted", &mapping).unwrap(); + assert_eq!(blocks[1]["name"], name); + assert_eq!(blocks[1]["input"]["args"], json!([1,{"x":true}])); + assert_eq!( + replay::decode(&blocks, &binding()).unwrap()["contents"], + contents + ); + let invalid = json!([{"role":"model","parts":[{"functionCall":{"name":native_name,"args":{"args":"{"}}}]}]); + assert!(set.mapping(&invalid).is_err()); +} + +#[test] +fn antigravity_original_allof_and_format_are_validated() { + let set = ToolSet::compile( + &json!([{"name":"tool","input_schema":{"type":"object","properties":{ + "to":{"type":"string","allOf":[{"pattern":"^a"},{"pattern":"z$"}]}, + "address":{"type":"string","format":"email"}},"required":["to","address"]}}]), + ) + .unwrap(); + for args in [ + json!({"to":"apple","address":"a@b.com"}), + json!({"to":"az","address":"not email"}), + ] { + assert!( + set.mapping( + &json!([{"role":"model","parts":[{"functionCall":{"name":"tool","args":args}}]}]) + ) + .is_err() + ); + } +} + +#[test] +fn antigravity_request_accepts_observed_options_without_changing_tier() { + let mut request = request_value(); + request["thinking"] = json!({"type":"adaptive","display":"omitted"}); + request["output_config"] = json!({"effort":"high"}); + request["context_management"] = + json!({"edits":[{"type":"clear_thinking_20251015","keep":"all"}]}); + let prepared = prepare(&serde_json::from_value(request).unwrap(), &binding()).unwrap(); + assert_eq!(prepared.wire["model"], "gemini-4-argon-low"); + assert_eq!( + prepared.wire["request"]["generationConfig"]["thinkingConfig"], + json!({"includeThoughts":true,"thinkingLevel":"LOW"}) + ); + assert_eq!( + prepared.wire["request"]["generationConfig"]["maxOutputTokens"], + 100 + ); + assert_eq!(prepared.display, "omitted"); + assert_eq!(prepared.wire["userAgent"], "antigravity"); + assert_eq!(prepared.wire["requestType"], "agent"); + assert!(prepared.wire["request"].get("toolConfig").is_none()); + replay::session_id(prepared.wire["request"]["sessionId"].as_str().unwrap()).unwrap(); +} + +#[test] +fn antigravity_request_system_parts_keep_privilege_and_order() { + let mut request = request_value(); + request["system"] = json!([{"type":"text","text":"top"}]); + request["messages"] = json!([ + {"role":"system","content":"first"}, {"role":"user","content":"hello"}, + {"role":"system","content":[{"type":"text","text":"second","cache_control":{"type":"ephemeral"}}]} + ]); + let prepared = prepare( + &serde_json::from_value(request.clone()).unwrap(), + &binding(), + ) + .unwrap(); + assert_eq!( + prepared.wire["request"]["systemInstruction"]["parts"], + json!([{"text":"top"},{"text":"first"},{"text":"second"}]) + ); + assert_eq!( + prepared.wire["request"]["contents"], + json!([{"role":"user","parts":[{"text":"hello"}]}]) + ); + request["messages"] = json!([{"role":"system","content":"only system"}]); + assert!(prepare(&serde_json::from_value(request).unwrap(), &binding()).is_err()); +} + +#[test] +fn antigravity_request_rejects_semantic_options_media_and_lost_history() { + for (key, value) in [ + ("thinking", json!({"type":"disabled"})), + ("thinking", json!({"type":"enabled","budget_tokens":100})), + ("output_config", json!({"format":{"type":"json_schema"}})), + ("output_config", json!({"effort":"invented"})), + ("temperature", json!(0.5)), + ("tool_choice", json!({"type":"any"})), + ( + "context_management", + json!({"edits":[{"type":"clear_thinking_20251015","keep":1}]}), + ), + ] { + let mut request = request_value(); + request[key] = value; + assert!( + prepare(&serde_json::from_value(request).unwrap(), &binding()).is_err(), + "{key}" + ); + } + for message in [ + json!({"role":"assistant","content":"lost signed history"}), + json!({"role":"user","content":[{"type":"image","source":{}}]}), + json!({"role":"system","content":[{"type":"image"}]}), + json!({"role":"developer","content":"x"}), + ] { + let mut request = request_value(); + request["messages"].as_array_mut().unwrap().push(message); + assert!(prepare(&serde_json::from_value(request).unwrap(), &binding()).is_err()); + } +} + +#[test] +fn antigravity_native_sse_keeps_late_signatures_and_cumulative_usage() { + let frames = [ + json!({"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"雪"}]}}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":1}}}), + json!({"response":{"candidates":[{"content":{"role":"model","parts":[{"thoughtSignature":"late"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"cachedContentTokenCount":3,"candidatesTokenCount":2,"thoughtsTokenCount":4,"totalTokenCount":16}}}), + ]; + let wire = frames + .iter() + .map(|v| format!("data: {v}\r\n\r\n")) + .collect::(); + let mut reducer = Reducer::default(); + for byte in wire.as_bytes() { + reducer.push(&[*byte]).unwrap(); + } + let turn = reducer.finish().unwrap(); + assert_eq!(turn.contents[1]["parts"][0]["thoughtSignature"], "late"); + assert_eq!( + turn.usage, + json!({"input_tokens":7,"cache_read_input_tokens":3,"output_tokens":6}) + ); + assert_eq!(turn.stop_reason, "end_turn"); +} + +#[test] +fn antigravity_native_sse_errors_never_become_successful_turns() { + for raw in [ + "data: {\n\n", + "data: {\"error\":{\"message\":\"private\"}}\n\n", + "data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"truncated\"}]}}]}}\n\n", + ] { + let mut reducer = Reducer::default(); + assert!( + reducer + .push(raw.as_bytes()) + .and_then(|()| reducer.finish()) + .is_err() + ); + } +} + +#[test] +fn antigravity_boundaries_and_canonical_sessions_fail_closed() { + for session in [ + "0", + "-0", + "+1", + "01", + "-01", + "9223372036854775808", + "-9223372036854775809", + ] { + assert!(replay::session_id(session).is_err()); + } + for session in ["1", "-1", "9223372036854775807", "-9223372036854775808"] { + assert_eq!(replay::session_id(session).unwrap(), session); + } + assert!(replay::calls(&json!(vec![json!({"role":"model","parts":[]}); 513])).is_err()); + assert!( + replay::calls(&json!([{"role":"model","parts":vec![json!({"text":""});4097]}])).is_err() + ); + let raw = json!({"x":"0"}); + assert!(replay::decode_arguments(&raw, &json!(vec!["/x"; 65]), &Value::Null).is_err()); + assert!( + replay::decode_arguments( + &raw, + &json!([format!("/{}", "x".repeat(1024))]), + &Value::Null + ) + .is_err() + ); + assert!( + replay::decode_arguments( + &json!({"json":"{\"x\":1,\"x\":2}"}), + &json!([]), + &json!("json") + ) + .is_err() + ); + let carrier = json!([{"type":"thinking","thinking":"","signature":format!("{}{}",replay::PREFIX,"x".repeat(replay::MAX_ENCODED+1))}]); + assert!(replay::decode(&carrier, &binding()).is_err()); + let mut reducer = Reducer::default(); + assert!(reducer.push(&vec![b'x'; 8 * 1024 * 1024 + 1]).is_err()); +} + +#[test] +fn antigravity_complete_result_prefix_uses_native_order_without_synthetic_continue() { + let contents = json!([{"role":"model","parts":[ + {"functionCall":{"name":"Read","id":"native-one","args":{}},"thoughtSignature":"signed"}, + {"functionCall":{"name":"Other","args":{}}} + ]}]); + let mapping = replay::mapping(&contents, None).unwrap(); + let blocks = replay::encode(&contents, &binding(), "123", "full", &mapping).unwrap(); + let first = + json!({"type":"tool_result","tool_use_id":mapping[0]["local_id"],"content":"first"}); + let second = json!({"type":"tool_result","tool_use_id":mapping[1]["local_id"],"content":[{"type":"text","text":"second"}],"is_error":true}); + let mut request = request_value(); + request["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":blocks}), + json!({"role":"system","content":"privileged"}), + json!({"role":"user","content":[second,first]}), + ]); + let prepared = prepare( + &serde_json::from_value(request.clone()).unwrap(), + &binding(), + ) + .unwrap(); + assert_eq!( + prepared.wire["request"]["contents"][2], + json!({"role":"model","parts":[ + {"functionResponse":{"name":"Read","id":"native-one","response":{"output":"first"}}}, + {"functionResponse":{"name":"Other","response":{"error":["second"],"is_error":true}}} + ]}) + ); + assert_eq!( + prepared.wire["request"]["contents"] + .as_array() + .unwrap() + .len(), + 3 + ); + assert!(!prepared.wire.to_string().contains("[Continue]")); + for results in [ + json!([first]), + json!([first, first]), + json!([{"type":"text","text":"before"},first,second]), + json!([first,{"type":"text","text":"between"},second]), + ] { + request["messages"][3]["content"] = results; + assert!( + prepare( + &serde_json::from_value(request.clone()).unwrap(), + &binding() + ) + .is_err() + ); + } +} + +#[test] +fn antigravity_schema_root_freeform_and_closed_no_argument_tools_are_explicit() { + let set = ToolSet::compile(&json!([ + {"name":"free","input_schema":{"type":"object"}}, + {"name":"empty","input_schema":{"type":"object","additionalProperties":false}} + ])) + .unwrap(); + assert_eq!( + set.declarations[0]["parameters"]["properties"]["json"]["type"], + "STRING" + ); + assert!(set.declarations[1].get("parameters").is_none()); + let contents = json!([{"role":"model","parts":[{"functionCall":{"name":"free","args":{"json":"{\"x\":[1,true]}"}}},{"functionCall":{"name":"empty","args":{}}}]}]); + let map = set.mapping(&contents).unwrap(); + let blocks = replay::encode(&contents, &binding(), "1", "full", &map).unwrap(); + assert_eq!(blocks[1]["input"], json!({"x":[1,true]})); + assert_eq!(blocks[2]["input"], json!({})); + let invalid = json!([{"role":"model","parts":[{"functionCall":{"name":"free","args":{"json":"{}","extra":true}}}]}]); + assert!(set.mapping(&invalid).is_err()); +} + +#[test] +fn antigravity_native_terminal_usage_ambiguity_is_not_hidden() { + let mut reducer = Reducer::default(); + reducer.push(format!("data: {}\n\n",json!({"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"text"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"thoughtsTokenCount":2,"totalTokenCount":15}}})).as_bytes()).unwrap(); + assert!(reducer.finish().is_err()); +} + +fn golden() -> Value { + let mut original: Value = serde_json::from_str(include_str!( + "../../tests/fixtures/antigravity-golden-v1.json" + )) + .unwrap(); + // Keep the four frozen source vectors byte-for-byte unchanged; the reviewed + // omitted-args extension is an independently exported synthetic fifth vector. + original["vectors"].as_array_mut().unwrap().push( + serde_json::from_str(include_str!( + "../../tests/fixtures/antigravity-parameterless-v1.json" + )) + .unwrap(), + ); + original +} + +#[test] +fn antigravity_final_v1_goldens_decode_and_reencode_without_native_mutation() { + for vector in golden()["vectors"].as_array().unwrap() { + let binding: replay::Binding = serde_json::from_value(vector["binding"].clone()).unwrap(); + let envelope = replay::decode(&vector["projected_blocks"], &binding).unwrap(); + assert_eq!(envelope, vector["envelope"], "{}", vector["name"]); + let blocks = replay::encode( + &vector["native_contents"], + &binding, + vector["session_id"].as_str().unwrap(), + vector["thought_display"].as_str().unwrap(), + &envelope["tool_mapping"], + ) + .unwrap(); + assert_eq!(replay::decode(&blocks, &binding).unwrap(), envelope); + assert_eq!( + replay::digest_input(&blocks, &envelope).unwrap(), + vector["canonical_digest_input_utf8"] + .as_str() + .unwrap() + .as_bytes() + ); + } +} + +#[test] +fn antigravity_final_v1_negative_vectors_fail_closed() { + let vectors: Value = serde_json::from_str(include_str!( + "../../tests/fixtures/antigravity-negative-v1.json" + )) + .unwrap(); + for vector in vectors["vectors"].as_array().unwrap() { + let input = &vector["input"]; + let binding: replay::Binding = serde_json::from_value(input["binding"].clone()).unwrap(); + let rejected = match vector["operation"].as_str().unwrap() { + "decode_turn" => replay::decode(&input["blocks"], &binding).is_err(), + "decode_history" => replay::decode_history(&input["turns"], &binding).is_err(), + "project_turn" => replay::mapping(&input["contents"], Some(&input["tool_projections"])) + .and_then(|mapping| { + replay::encode( + &input["contents"], + &binding, + input["session_id"].as_str().unwrap(), + input["thought_display"].as_str().unwrap(), + &mapping, + ) + }) + .is_err(), + other => panic!("unhandled frozen vector {other}"), + }; + assert!(rejected, "{}", vector["name"]); + } +} + +#[test] +fn antigravity_replay_rejects_missing_carriers_and_scope_changes() { + let vectors = golden(); + let vector = &vectors["vectors"][0]; + let binding: replay::Binding = serde_json::from_value(vector["binding"].clone()).unwrap(); + let blocks = vector["projected_blocks"].clone(); + assert!(replay::decode(&json!([blocks[1]]), &binding).is_err()); + assert!(replay::decode(&json!([blocks[0], blocks[0], blocks[1]]), &binding).is_err()); + for key in ["native_model", "project_id", "profile_id"] { + let mut wrong = vector["binding"].clone(); + wrong[key] = json!("different"); + assert!(replay::decode(&blocks, &serde_json::from_value(wrong).unwrap()).is_err()); + } +} + +#[test] +fn antigravity_json_parser_and_argument_codec_enforce_bounds_and_uniqueness() { + for raw in [r#"{"x":1,"x":2}"#, r#"[NaN]"#, r#"[Infinity]"#] { + assert!(replay::parse_json(raw.as_bytes(), 1024).is_err()); + } + let deep = format!("{}0{}", "[".repeat(65), "]".repeat(65)); + assert!(replay::parse_json(deep.as_bytes(), 1024).is_err()); + assert!(replay::parse_json(b"{}", 1).is_err()); + for raw in ["9007199254740992", "1e100"] { + assert!(replay::canonical(&serde_json::from_str::(raw).unwrap()).is_err()); + } + assert_eq!( + replay::canonical(&json!(-0.0)).unwrap(), + replay::canonical(&json!(0)).unwrap() + ); + assert_eq!( + replay::canonical(&json!(1.0)).unwrap(), + replay::canonical(&json!(1)).unwrap() + ); +} + +#[test] +fn antigravity_local_tool_ids_are_unique_despite_reused_native_ids() { + let vectors = golden(); + let contents = &vectors["vectors"][3]["native_contents"]; + let first = replay::mapping(contents, None).unwrap(); + let second = replay::mapping(contents, None).unwrap(); + let mut ids = std::collections::HashSet::new(); + for entry in first + .as_array() + .unwrap() + .iter() + .chain(second.as_array().unwrap()) + { + assert!(ids.insert(entry["local_id"].clone().to_string())); + assert_ne!(entry["local_id"], entry["native_id"]); + } +} diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 8c4f82fe..859206ea 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -1,6 +1,14 @@ +pub mod antigravity; pub mod codex; pub mod cursor; pub mod grok; pub mod kimi; pub mod opencode; pub mod translate_shared; + +#[cfg(test)] +mod antigravity_auth_tests; +#[cfg(test)] +mod antigravity_http_tests; +#[cfg(test)] +mod antigravity_tests; diff --git a/src/registry.rs b/src/registry.rs index 46960816..887a0741 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -86,9 +86,21 @@ impl Registry { crate::providers::opencode::advertised_models(), ); + if crate::providers::antigravity::enabled() { + models.insert( + "antigravity".into(), + crate::providers::antigravity::request::MODELS + .iter() + .map(|m| (*m).to_string()) + .collect(), + ); + } let mut handlers = BTreeMap::new(); for (name, entries) in &models { let handler: Arc = match name.as_str() { + "antigravity" => { + Arc::new(crate::providers::antigravity::AntigravityProvider::new()) + } "codex" => Arc::new(crate::providers::codex::CodexProvider::new()), "kimi" => Arc::new(crate::providers::kimi::KimiProvider::new()), "cursor" => Arc::new(crate::providers::cursor::CursorProvider::new()), @@ -134,6 +146,13 @@ impl Registry { names } + pub async fn finish_maintenance(&self) -> Result<()> { + for provider in self.handlers.values() { + provider.finish_maintenance().await?; + } + Ok(()) + } + pub fn provider(&self, name: &str) -> Option> { self.handlers.get(name).cloned() } @@ -348,6 +367,96 @@ fn build_cursor_models() -> Vec { mod tests { use super::*; + #[test] + fn antigravity_default_off_preserves_existing_provider_catalog() { + for alias_provider in [AliasProvider::Codex, AliasProvider::Kimi] { + let registry = Registry::new(alias_provider); + assert_eq!( + registry.list_provider_names(), + ["codex", "cursor", "grok", "kimi", "opencode"] + ); + assert!(registry.provider("antigravity").is_none()); + assert!(registry.supported_models_for("antigravity").is_empty()); + assert!(!registry.grouped_models().contains_key("antigravity")); + assert!( + registry + .all_supported_models() + .iter() + .all(|(model, owner)| { + owner != "antigravity" && !model.starts_with("antigravity/") + }) + ); + } + } + + #[test] + fn antigravity_does_not_claim_existing_models_or_alias_affinity() { + for alias_provider in [AliasProvider::Codex, AliasProvider::Kimi] { + let registry = Registry::new(alias_provider); + for alias in ANTHROPIC_STYLE_ALIASES { + assert_eq!( + registry.provider_for_model(alias, None).unwrap().name(), + alias_provider.as_str() + ); + for affinity in [AliasProvider::Codex, AliasProvider::Kimi] { + assert_eq!( + registry + .provider_for_model(alias, Some(&affinity)) + .unwrap() + .name(), + affinity.as_str() + ); + } + } + for (model, owner) in [ + ("gpt-6-astra-ultrafast", "codex"), + ("kimi-k2.6", "kimi"), + ("grok-4.7", "grok"), + ("cursor:gpt-5.5", "cursor"), + ("opencode-go/kimi-k2.6", "opencode"), + ] { + for affinity in [ + None, + Some(&AliasProvider::Codex), + Some(&AliasProvider::Kimi), + ] { + assert_eq!( + registry.provider_for_model(model, affinity).unwrap().name(), + owner + ); + } + } + } + } + + #[test] + fn antigravity_disabled_and_unknown_selectors_never_fall_back() { + for alias_provider in [AliasProvider::Codex, AliasProvider::Kimi] { + let registry = Registry::new(alias_provider); + for model in [ + "antigravity/gemini-4-argon-low", + "antigravity/gemini-4-argon-medium", + "antigravity/gemini-4-argon-high", + "antigravity/gemini-4-argon-low[1m]", + "antigravity/gemini-4-argon", + "antigravity/gemini-4-argon-ultra", + "antigravity/claude-opus-5", + "gemini-4-argon-low", + ] { + for affinity in [ + None, + Some(&AliasProvider::Codex), + Some(&AliasProvider::Kimi), + ] { + assert!( + registry.provider_for_model(model, affinity).is_none(), + "{model} must not route to an existing provider" + ); + } + } + } + } + #[test] fn normalize_model_trims_hint() { assert_eq!(normalize_incoming_model("gpt-5.4-fast[1m]"), "gpt-5.4-fast"); diff --git a/src/server.rs b/src/server.rs index da3c842b..b8c0bd47 100644 --- a/src/server.rs +++ b/src/server.rs @@ -99,6 +99,9 @@ fn apply_auto_review_model( let override_model = configured_model .filter(|model| !model.is_empty()) .or((original_provider == "codex").then_some(CODEX_AUTO_REVIEW_MODEL))?; + if original_provider == "antigravity" && !override_model.starts_with("antigravity/") { + return None; + } let route = AutoReviewRoute { requested_model: body.model.clone()?, override_model: override_model.to_string(), @@ -167,14 +170,19 @@ pub async fn serve_listener( ), ])), ); - let app = app_with_monitor(Arc::new(Registry::with_default_alias()), monitor); - axum::serve( + let registry = Arc::new(Registry::with_default_alias()); + let app = app_with_monitor(registry.clone(), monitor); + let served = axum::serve( listener, app.into_make_service_with_connect_info::(), ) .with_graceful_shutdown(shutdown) - .await?; - Ok(()) + .await; + // Request futures/body-owned inference are already quiescent. Only bounded + // credential maintenance may remain, and validated writes must be joined. + let maintained = registry.finish_maintenance().await; + served?; + maintained } pub fn app(registry: Arc) -> Router { @@ -346,6 +354,8 @@ async fn healthz() -> Json { #[derive(serde::Deserialize)] struct ModelsQuery { limit: Option, + #[serde(default)] + include_capabilities: bool, } /// Anthropic-shaped model listing so Claude Code's gateway model discovery @@ -374,13 +384,24 @@ async fn handler_models( if let Some(limit) = query.limit { data.truncate(limit); } - Json(json!({ + let mut response = json!({ "object": "list", "data": data, "has_more": has_more, "first_id": data.first().and_then(|entry| entry.get("id")).cloned(), "last_id": data.last().and_then(|entry| entry.get("id")).cloned(), - })) + }); + if query.include_capabilities { + let serving_provider = state.registry.provider("antigravity"); + let mut capability = serving_provider + .as_ref() + .and_then(|provider| provider.local_capability()) + .unwrap_or_else(crate::providers::antigravity::capability); + capability["enabled"] = json!(serving_provider.is_some()); + response["capabilities"] = + json!({"schema_version":1,"providers":{"antigravity":capability}}); + } + Json(response) } async fn handler_messages(State(state): State>, req: Request) -> Response { @@ -1410,6 +1431,28 @@ fn log_native_request_completed( ); } +fn antigravity_context_error( + model: &str, + original_model: &str, + headers: &http::HeaderMap, +) -> Option<&'static str> { + if !model.starts_with("antigravity/") { + return None; + } + let raw_hint = model.to_ascii_lowercase().contains("[1m]") + || original_model.to_ascii_lowercase().contains("[1m]"); + let beta_hint = headers.get_all("anthropic-beta").iter().any(|value| { + value.to_str().map_or(true, |value| { + value + .split(',') + .any(|token| token.trim() == "context-1m-2025-08-07") + }) + }); + (raw_hint || beta_hint).then_some( + "Antigravity does not support the 1m context hint; use its conservative 200k client window", + ) +} + async fn dispatch_request( state: Arc, req: Request, @@ -1590,6 +1633,15 @@ async fn dispatch_request( } }; + if let Some(message) = antigravity_context_error(model, model, &headers) { + return monitor_response_body( + json_error(StatusCode::BAD_REQUEST, "invalid_request_error", message), + request_guard, + ); + } + // Retain the original selector and the one original byte buffer until the + // effective provider (including auto-review routing) has been selected. + let original_model = model.to_string(); let mut normalized_model = normalize_incoming_model(model); body.model = Some(normalized_model.clone()); let session_state = if let Some(session_id) = session_id.as_deref() { @@ -1613,6 +1665,16 @@ async fn dispatch_request( ) }); if auto_review_route.is_some() { + if let Some(message) = antigravity_context_error( + body.model.as_deref().expect("override model"), + &original_model, + &headers, + ) { + return monitor_response_body( + json_error(StatusCode::BAD_REQUEST, "invalid_request_error", message), + request_guard, + ); + } normalized_model = normalize_incoming_model(body.model.as_deref().expect("override model")); body.model = Some(normalized_model.clone()); } @@ -1636,10 +1698,14 @@ async fn dispatch_request( let response = json_error( StatusCode::BAD_REQUEST, "invalid_request_error", - format!( - "Unknown model \"{normalized_model}\". {}", - state.registry.unknown_model_message() - ), + if crate::providers::antigravity::known_model(&normalized_model) { + "Antigravity is experimental and disabled; explicitly set CCP_ANTIGRAVITY_ENABLED=1 after configuring its own auth, then start a fresh fixed-model session".to_string() + } else { + format!( + "Unknown model \"{normalized_model}\". {}", + state.registry.unknown_model_message() + ) + }, ); log_request_completed( &log, @@ -1677,6 +1743,15 @@ async fn dispatch_request( } }; + if provider.name() == "antigravity" + && let Err(message) = crate::providers::antigravity::request::validate_raw(&body_bytes) + { + return monitor_response_body( + json_error(StatusCode::BAD_REQUEST, "invalid_request_error", message), + request_guard, + ); + } + body.bypass_provider_model_override = auto_review_route.is_some() && provider.name() == "codex"; if let Some(route) = auto_review_route.as_ref() { @@ -1699,10 +1774,15 @@ async fn dispatch_request( crate::providers::codex::clear_session_compaction(session_id); } - let effort = crate::providers::translate_shared::read_effort(&body) - .ok() - .flatten() - .map(str::to_string); + let effort = if provider.name() == "antigravity" { + crate::providers::antigravity::request::effective_tier(&normalized_model) + .map(str::to_string) + } else { + crate::providers::translate_shared::read_effort(&body) + .ok() + .flatten() + .map(str::to_string) + }; let current = session::record_session_request_with_affinity_update( session_id.as_deref(), session_state.as_ref(), @@ -1954,7 +2034,7 @@ async fn record_failed_response( } }; - let response_body = response_body_value(&bytes); + let response_body = redact_error_value(response_body_value(&bytes)); let message = error_message_from_response(&response_body) .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); let document = json!({ @@ -2056,7 +2136,12 @@ fn redact_error_value(value: Value) -> Value { Value::Object(fields) => { let mut out = Map::new(); for (key, value) in fields { - if REDACT_KEYS.contains(&key.to_lowercase().as_str()) { + if REDACT_KEYS.contains(&key.to_lowercase().as_str()) + || matches!( + key.to_lowercase().as_str(), + "thoughtsignature" | "thought_signature" | "client_secret" + ) + { out.insert(key, redact_error_key(value)); } else { out.insert(key, redact_error_value(value)); @@ -2064,6 +2149,9 @@ fn redact_error_value(value: Value) -> Value { } Value::Object(out) } + Value::String(text) if text.contains("ccp:antigravity:") => { + Value::String(format!("[redacted len={}]", text.len())) + } value => value, } } @@ -2364,6 +2452,131 @@ mod auto_review_tests { assert_eq!(classifier.model.as_deref(), Some("grok-4.5")); } + #[test] + fn antigravity_error_capture_redacts_recursive_native_and_carrier_metadata() { + let value = json!({"error":{"details":[{"thoughtSignature":"native-secret"}], + "message":"ccp:antigravity:v1:opaque-secret", + "signature":"ccp:antigravity:v1:carrier-secret", + "code_verifier":"verifier-secret", "client_secret":"oauth-client-secret"}}); + let redacted = super::redact_error_value(value).to_string(); + for secret in [ + "native-secret", + "opaque-secret", + "carrier-secret", + "verifier-secret", + "oauth-client-secret", + ] { + assert!(!redacted.contains(secret), "leaked {secret}"); + } + } + + #[test] + fn antigravity_classifier_ignores_cross_provider_review_overrides() { + for override_model in [ + "gpt-6-luna", + "grok-4.7", + "kimi-k2.6", + "cursor:gpt-5.5", + "opencode-go/kimi-k2.6", + "claude-opus-5", + "sonnet", + ] { + let mut classifier = request( + "You are a security monitor for autonomous AI coding agents.", + false, + json!([]), + ); + classifier.model = Some("antigravity/gemini-4-argon-low".to_string()); + let before = serde_json::to_value(&classifier).unwrap(); + assert!( + apply_auto_review_model( + &mut classifier, + false, + Some(override_model), + "antigravity", + ) + .is_none(), + "an explicit Antigravity request must not become {override_model}" + ); + assert_eq!(serde_json::to_value(&classifier).unwrap(), before); + } + } + + #[test] + fn antigravity_same_provider_review_override_preserves_history_for_validation() { + let mut classifier = request( + "You are a security monitor for autonomous AI coding agents.", + false, + json!([]), + ); + classifier.model = Some("antigravity/gemini-4-argon-low".to_string()); + let before = serde_json::to_value(&classifier).unwrap(); + let route = apply_auto_review_model( + &mut classifier, + false, + Some("antigravity/gemini-4-argon-high"), + "antigravity", + ) + .expect("same-provider routing still reaches strict provider replay validation"); + assert_eq!(route.requested_model, "antigravity/gemini-4-argon-low"); + assert_eq!(route.override_model, "antigravity/gemini-4-argon-high"); + let mut expected = before; + expected["model"] = json!("antigravity/gemini-4-argon-high"); + assert_eq!(serde_json::to_value(&classifier).unwrap(), expected); + // This checks routing only, not permission to replay a signed low-tier + // turn as high. The native provider must reject that model mismatch. + } + + #[test] + fn antigravity_review_defaults_and_count_tokens_keep_the_selected_model() { + for (count_tokens, override_model) in [ + (false, None), + (false, Some("")), + (true, Some("gpt-6-luna")), + (true, Some("antigravity/gemini-4-argon-high")), + ] { + let mut classifier = request( + "You are a security monitor for autonomous AI coding agents.", + false, + json!([]), + ); + classifier.model = Some("antigravity/gemini-4-argon-low".to_string()); + assert!( + apply_auto_review_model( + &mut classifier, + count_tokens, + override_model, + "antigravity", + ) + .is_none() + ); + assert_eq!( + classifier.model.as_deref(), + Some("antigravity/gemini-4-argon-low") + ); + } + } + + #[test] + fn antigravity_review_exception_preserves_existing_provider_overrides() { + for original_provider in ["codex", "kimi", "cursor", "grok", "opencode"] { + let mut classifier = request( + "You are a security monitor for autonomous AI coding agents.", + false, + json!([]), + ); + let route = apply_auto_review_model( + &mut classifier, + false, + Some("grok-4.7"), + original_provider, + ) + .expect("existing provider override behavior is unchanged"); + assert_eq!(route.override_model, "grok-4.7"); + assert_eq!(classifier.model.as_deref(), Some("grok-4.7")); + } + } + #[test] fn traffic_headers_redact_agent_lineage_ids() { let mut headers = HeaderMap::new(); diff --git a/src/traffic.rs b/src/traffic.rs index 4d7936e3..9640cded 100644 --- a/src/traffic.rs +++ b/src/traffic.rs @@ -362,7 +362,12 @@ fn redact_traffic_with_depth(value: &Value, depth: u16) -> Value { let mut out = Map::new(); for (key, value) in map { let normalized = key.to_lowercase(); - if normalized == "image_url" { + if matches!( + normalized.as_str(), + "thoughtsignature" | "thought_signature" + ) { + out.insert(key.clone(), redact_traffic_value(value)); + } else if normalized == "image_url" { // Grok/OpenAI `input_image` parts carry data URLs; never // persist the payload in traffic captures. out.insert(key.clone(), redact_traffic_value(value)); @@ -419,6 +424,9 @@ fn redact_traffic_with_depth(value: &Value, depth: u16) -> Value { .map(|value| redact_traffic_with_depth(value, depth + 1)) .collect(), ), + Value::String(text) if text.contains("ccp:antigravity:") => { + Value::String(format!("[redacted len={}]", text.len())) + } Value::String(text) if is_data_url(text) => { Value::String(format!("[redacted data-url len={}]", text.len())) } @@ -481,6 +489,47 @@ fn _provider_alias(_provider: &str) -> Option { mod tests { use super::*; + #[test] + fn antigravity_traffic_and_sse_redact_native_replay_and_oauth_secrets() { + let value = serde_json::json!({ + "contents": [{"parts": [{"thoughtSignature": "native-secret"}]}], + "signature": "ccp:antigravity:v1:opaque-secret", + "message": "upstream echoed ccp:antigravity:v1:embedded-secret", + "nested": {"code": "code-secret", "code_verifier": "verifier-secret", "refresh_token": "refresh-secret"}, + "safe": "retained", "foreign": {"signature": "anthropic-foreign"} + }); + let rendered = redact_traffic(&value).to_string(); + for secret in [ + "native-secret", + "opaque-secret", + "embedded-secret", + "code-secret", + "verifier-secret", + "refresh-secret", + ] { + assert!(!rendered.contains(secret), "leaked {secret}"); + } + assert!(rendered.contains("retained")); + assert!(rendered.contains("anthropic-foreign")); + let directory = tempfile::tempdir().unwrap(); + let capture = test_capture(directory.path().join("traffic")); + let mut stream = capture.stream_capture(); + stream.upstream_event(None, &value); + stream.downstream_event("content_block_delta", value); + stream.finish(&capture, serde_json::json!({"ok":true})); + let sse = std::fs::read_dir(capture.root()) + .unwrap() + .filter_map(|entry| { + let path = entry.ok()?.path(); + (path.extension().and_then(|s| s.to_str()) == Some("sse")) + .then(|| std::fs::read_to_string(path).unwrap()) + }) + .collect::(); + assert!(!sse.is_empty()); + assert!(!sse.contains("native-secret")); + assert!(!sse.contains("opaque-secret")); + } + #[test] fn redact_traffic_strips_input_image_data_urls() { let value = serde_json::json!({ diff --git a/tests/antigravity_compatibility/codec.py b/tests/antigravity_compatibility/codec.py new file mode 100644 index 00000000..83b490cf --- /dev/null +++ b/tests/antigravity_compatibility/codec.py @@ -0,0 +1,465 @@ +"""Test-only Stage A replay codec. No network/auth/provider implementation. + +Digest specification: sha256(UTF-8 compact JSON of tagged canonical values). +Objects become ["object", sorted [key, value] pairs]; arrays stay ordered; +scalars have explicit tags. Numbers are big-endian IEEE754 binary64 hex, with +negative zero normalized to zero. Unsafe integral numbers/nonfinite values fail. +Only adjacent projected text blocks merge. No Unicode normalization is applied. +Only block-level signature/cache_control fields are excluded, never tool args. +The digest binding contains profile_id/project_id/native_model/session_id, +thought_display and the complete ordered tool_mapping. Each mapping explicitly +records caller_name/local_id, native position/ID presence, codec_paths (strict +RFC6901 pointers into RAW native args) and root_wrapper (null or one exact key). +Root-wrapper and leaf-path modes are mutually exclusive. Native contents are +never rewritten; only the client projection decodes JSON-string transport leaves. +This is consistency checking, NOT authentication of arbitrary API callers. +""" +import base64 +import copy +import hashlib +import json +import math +import re +import secrets +import struct +import uuid + +PREFIX = "ccp:antigravity:v1:" +MAX_ENCODED = 12 * 1024 * 1024 +MAX_DECODED = 8 * 1024 * 1024 +MAX_DEPTH = 64 +MAX_CONTENTS = 512 +MAX_PARTS = 4096 +MAX_TOOLS = 128 +SAFE_INTEGER = 2**53 - 1 +BINDING_KEYS = ("profile_id", "project_id", "native_model") +ENVELOPE_KEYS = {"version", *BINDING_KEYS, "session_id", "thought_display", "contents", "tool_mapping", "projection_digest"} +MAPPING_KEYS = {"local_id", "caller_name", "content_index", "part_index", "native_id_present", "codec_paths", "root_wrapper"} +MAX_CODEC_PATHS = 64 +MAX_CODEC_PATH_BYTES = 1024 +MAX_ARGUMENT_BYTES = 1024 * 1024 + + +class ReplayError(ValueError): + pass + + +def compact(value): + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False) + + +def canonical(value, max_depth=MAX_DEPTH): + def visit(v, depth): + if depth > max_depth: + raise ReplayError("JSON depth limit") + if v is None: + return ["null"] + if isinstance(v, bool): + return ["bool", v] + if isinstance(v, (int, float)): + if isinstance(v, int) and abs(v) > SAFE_INTEGER: + raise ReplayError("unsafe integer") + if not math.isfinite(v) or (v == int(v) and abs(v) > SAFE_INTEGER): + raise ReplayError("unsafe/nonfinite number") + f = float(v) + return ["number", struct.pack(">d", 0.0 if f == 0 else f).hex()] + if isinstance(v, str): + return ["string", v] + if isinstance(v, list): + return ["array", [visit(x, depth + 1) for x in v]] + if isinstance(v, dict) and all(isinstance(k, str) for k in v): + return ["object", [[k, visit(v[k], depth + 1)] for k in sorted(v)]] + raise ReplayError("unsupported JSON type") + return compact(visit(value, 0)).encode("utf-8") + + +def binding_fields(binding): + if set(binding) != set(BINDING_KEYS) or not all(isinstance(v, str) and v for v in binding.values()): + raise ReplayError("invalid binding") + return dict(binding) + + +def projection_values(blocks): + result = [] + for block in blocks: + kind = block.get("type") + allowed = {"type", "signature", "cache_control"} + if kind == "thinking": + allowed.add("thinking") + if not isinstance(block.get("thinking"), str): + raise ReplayError("invalid thinking") + item = {"type": kind, "thinking": block["thinking"]} + elif kind == "text": + allowed.add("text") + if not isinstance(block.get("text"), str): + raise ReplayError("invalid text") + item = {"type": kind, "text": block["text"]} + if result and result[-1]["type"] == "text": + if set(block) - allowed: + raise ReplayError("unknown projection fields") + result[-1]["text"] += item["text"] + continue + elif kind == "tool_use": + allowed.update(("id", "name", "input")) + if not all(isinstance(block.get(k), str) and block[k] for k in ("id", "name")) or not isinstance(block.get("input"), dict): + raise ReplayError("invalid tool") + item = {k: copy.deepcopy(block[k]) for k in ("type", "id", "name", "input")} + else: + raise ReplayError("unsupported projection block") + if set(block) - allowed: + raise ReplayError("unknown projection fields") + result.append(item) + return result + + +def validate_session_id(value): + if not isinstance(value, str) or len(value) > 20 or not re.fullmatch(r"-?[1-9][0-9]*", value): + raise ReplayError("session_id must be canonical nonzero i64 decimal string") + if not -(2**63) <= int(value) <= 2**63 - 1: + raise ReplayError("session_id outside i64 range") + return value + + +def mint_session_id(): + return str(secrets.randbelow(2**63 - 1) + 1) + + +def validate_display(value): + if value not in ("full", "omitted"): + raise ReplayError("invalid thought_display") + return value + + +def digest_input(blocks, binding, session_id, thought_display, tool_mapping): + metadata = {**binding_fields(binding), "session_id": validate_session_id(session_id), + "thought_display": validate_display(thought_display), "tool_mapping": tool_mapping} + return canonical({"binding": metadata, "blocks": projection_values(blocks)}) + + +def digest(blocks, binding, session_id, thought_display, tool_mapping): + return hashlib.sha256(digest_input(blocks, binding, session_id, thought_display, tool_mapping)).hexdigest() + + +def call_arguments(call): + """Project absent completed-call args to {}, without mutating native contents.""" + if "partialArgs" in call or "willContinue" in call: + raise ReplayError("incremental native function calls are unsupported") + args = call.get("args", {}) + if not isinstance(args, dict): + raise ReplayError("invalid native call arguments") + return args + + +def collect_calls(contents): + if not isinstance(contents, list) or len(contents) > MAX_CONTENTS: + raise ReplayError("content limit") + calls, idless_names, part_count = [], set(), 0 + for ci, content in enumerate(contents): + if not isinstance(content, dict) or content.get("role") != "model" or not isinstance(content.get("parts"), list): + raise ReplayError("invalid native content") + part_count += len(content["parts"]) + if part_count > MAX_PARTS: + raise ReplayError("part limit") + for pi, part in enumerate(content["parts"]): + if not isinstance(part, dict): + raise ReplayError("invalid native part") + if "text" in part and not isinstance(part["text"], str): + raise ReplayError("invalid native text") + if "functionCall" not in part: + continue + call = part["functionCall"] + if not isinstance(call, dict) or not isinstance(call.get("name"), str) or not call["name"]: + raise ReplayError("invalid native call") + call_arguments(call) + if len(calls) >= MAX_TOOLS: + raise ReplayError("tool map limit") + if "id" in call: + if not isinstance(call["id"], str) or not call["id"]: + raise ReplayError("invalid native ID") + elif call["name"] in idless_names: + raise ReplayError("ambiguous same-name ID-less parallel calls") + else: + idless_names.add(call["name"]) + calls.append((ci, pi, call)) + return calls + + +def pointer_tokens(path): + if not isinstance(path, str) or not path.startswith("/") or len(path.encode("utf-8")) > MAX_CODEC_PATH_BYTES: + raise ReplayError("invalid codec path") + tokens = [] + for token in path[1:].split("/"): + if re.search(r"~(?![01])", token): + raise ReplayError("invalid codec pointer escape") + tokens.append(token.replace("~1", "/").replace("~0", "~")) + if len(tokens) > MAX_DEPTH: + raise ReplayError("codec path depth limit") + return tokens + + +def decoded_codec_json(value): + if not isinstance(value, str): + raise ReplayError("codec leaf must be a JSON string") + raw = value.encode("utf-8") + if len(raw) > MAX_ARGUMENT_BYTES: + raise ReplayError("codec JSON byte limit") + try: + decoded = parse_bounded(raw) + canonical(decoded) # finite/safe numbers, depth and JSON types + except (ValueError, UnicodeError, RecursionError) as exc: + raise ReplayError("invalid codec JSON") from exc + return decoded + + +def decode_arguments(raw_args, paths, root_wrapper): + if not isinstance(paths, list) or len(paths) > MAX_CODEC_PATHS: + raise ReplayError("codec paths limit/type") + if root_wrapper is not None: + if not isinstance(root_wrapper, str) or not root_wrapper or len(root_wrapper.encode("utf-8")) > 256 or paths: + raise ReplayError("invalid root wrapper metadata") + if set(raw_args) != {root_wrapper}: + raise ReplayError("root wrapper requires exactly one named key") + result = decoded_codec_json(raw_args[root_wrapper]) + if not isinstance(result, dict): + raise ReplayError("root codec must decode an object") + else: + parsed = [pointer_tokens(path) for path in paths] + for i, left in enumerate(parsed): + for right in parsed[i + 1:]: + if left[:len(right)] == right or right[:len(left)] == left: + raise ReplayError("duplicate/overlapping codec paths") + result = copy.deepcopy(raw_args) + for tokens in parsed: + parent = result + for offset, token in enumerate(tokens): + if isinstance(parent, dict): + if token not in parent: + raise ReplayError("codec path missing object key") + key = token + elif isinstance(parent, list): + if not re.fullmatch(r"0|[1-9][0-9]*", token) or len(token) > 10: + raise ReplayError("invalid codec array index") + key = int(token) + if key >= len(parent): + raise ReplayError("codec path array index out of range") + else: + raise ReplayError("codec path traverses a scalar") + if offset == len(tokens) - 1: + parent[key] = decoded_codec_json(parent[key]) + else: + parent = parent[key] + canonical(result) + if len(compact(result).encode("utf-8")) > MAX_ARGUMENT_BYTES: + raise ReplayError("decoded argument byte limit") + return result + + +def make_mapping(contents, tool_projections): + calls = collect_calls(contents) + if tool_projections is not None and (not isinstance(tool_projections, list) or len(tool_projections) != len(calls)): + raise ReplayError("tool projection recipe count mismatch") + turn_id = uuid.uuid4().hex + mapping = [] + for index, (ci, pi, call) in enumerate(calls): + recipe = tool_projections[index] if tool_projections is not None else {} + if not isinstance(recipe, dict) or set(recipe) - {"local_id", "caller_name", "codec_paths", "root_wrapper"}: + raise ReplayError("invalid tool projection recipe") + entry = {"local_id": recipe.get("local_id", "toolu_" + turn_id + "_%04d" % index), + "caller_name": recipe.get("caller_name", call["name"]), "content_index": ci, "part_index": pi, + "native_id_present": "id" in call, "codec_paths": copy.deepcopy(recipe.get("codec_paths", [])), + "root_wrapper": recipe.get("root_wrapper")} + if "id" in call: + entry["native_id"] = call["id"] + mapping.append(entry) + return mapping + + +def native_projection(contents, thought_display, tool_mapping): + validate_display(thought_display) + calls = collect_calls(contents) + if not isinstance(tool_mapping, list) or len(tool_mapping) != len(calls): + raise ReplayError("tool mapping count mismatch") + projected_calls, local_ids = {}, set() + for (ci, pi, call), entry in zip(calls, tool_mapping): + expected_keys = MAPPING_KEYS | ({"native_id"} if "id" in call else set()) + if not isinstance(entry, dict) or set(entry) != expected_keys: + raise ReplayError("invalid tool mapping fields") + if (type(entry["content_index"]) is not int or type(entry["part_index"]) is not int + or entry["content_index"] != ci or entry["part_index"] != pi + or type(entry["native_id_present"]) is not bool or entry["native_id_present"] != ("id" in call) + or ("id" in call and entry["native_id"] != call["id"])): + raise ReplayError("tool mapping native position/ID mismatch") + local_id, caller_name = entry["local_id"], entry["caller_name"] + if not isinstance(local_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", local_id) or local_id in local_ids: + raise ReplayError("invalid/duplicate local ID in tool mapping") + if not isinstance(caller_name, str) or not caller_name or len(caller_name.encode("utf-8")) > 1024: + raise ReplayError("invalid caller name in tool mapping") + local_ids.add(local_id) + args = decode_arguments(call_arguments(call), entry["codec_paths"], entry["root_wrapper"]) + projected_calls[(ci, pi)] = {"type": "tool_use", "id": local_id, "name": caller_name, "input": args} + thoughts, blocks = [], [] + for ci, content in enumerate(contents): + for pi, part in enumerate(content["parts"]): + if "text" in part: + if part.get("thought") is True: + thoughts.append(part["text"]) + elif part["text"]: + blocks.append({"type": "text", "text": part["text"]}) + if (ci, pi) in projected_calls: + blocks.append(projected_calls[(ci, pi)]) + text = "" if thought_display == "omitted" else "".join(thoughts) + return [{"type": "thinking", "thinking": text}] + blocks + + +def project_turn(contents, binding, session_id=None, thought_display="full", tool_projections=None): + session_id = mint_session_id() if session_id is None else validate_session_id(session_id) + mapping = make_mapping(contents, tool_projections) + blocks = native_projection(contents, thought_display, mapping) + envelope = {"version": 1, **binding_fields(binding), "session_id": session_id, "thought_display": thought_display, + "contents": copy.deepcopy(contents), "tool_mapping": mapping, + "projection_digest": digest(blocks, binding, session_id, thought_display, mapping)} + raw = compact(envelope).encode("utf-8") + if len(raw) > MAX_DECODED: + raise ReplayError("decoded size limit") + encoded = base64.urlsafe_b64encode(raw).decode().rstrip("=") + if len(encoded) > MAX_ENCODED: + raise ReplayError("encoded size limit") + blocks[0]["signature"] = PREFIX + encoded + return blocks + + +def parse_bounded(raw): + # Check nesting before json.loads can allocate a deeply nested tree. + depth, quoted, escape = 0, False, False + for ch in raw.decode("utf-8"): + if quoted: + if escape: + escape = False + elif ch == "\\": + escape = True + elif ch == '"': + quoted = False + elif ch == '"': + quoted = True + elif ch in "[{": + depth += 1 + if depth > MAX_DEPTH: + raise ReplayError("JSON depth limit") + elif ch in "]}": + depth -= 1 + def unique(pairs): + obj = {} + for key, value in pairs: + if key in obj: + raise ReplayError("duplicate JSON key") + obj[key] = value + return obj + def bad_constant(value): + raise ReplayError("nonfinite JSON number") + return json.loads(raw, object_pairs_hook=unique, parse_constant=bad_constant) + + +def decode_turn(blocks, binding, max_encoded=MAX_ENCODED, max_decoded=MAX_DECODED, expected_session_id=None): + binding_fields(binding) + thinking = [b for b in blocks if b.get("type") == "thinking"] + if len(thinking) != 1 or not blocks or blocks[0] is not thinking[0]: + raise ReplayError("missing/duplicate/nonleading carrier") + signature = thinking[0].get("signature", "") + if not isinstance(signature, str) or not signature.startswith(PREFIX): + raise ReplayError("missing/foreign carrier") + encoded = signature[len(PREFIX):] + if len(encoded) > max_encoded: + raise ReplayError("encoded size limit") + if not encoded or not re.fullmatch(r"[A-Za-z0-9_-]+", encoded): + raise ReplayError("malformed carrier") + if len(encoded) * 3 // 4 > max_decoded: + raise ReplayError("decoded size limit") + try: + raw = base64.b64decode(encoded + "=" * (-len(encoded) % 4), altchars=b"-_", validate=True) + if len(raw) > max_decoded: + raise ReplayError("decoded size limit") + envelope = parse_bounded(raw) + except (ValueError, UnicodeError, RecursionError) as exc: + raise ReplayError("malformed carrier: " + str(exc)) from exc + if not isinstance(envelope, dict) or type(envelope.get("version")) is not int or envelope["version"] != 1 or set(envelope) != ENVELOPE_KEYS: + raise ReplayError("invalid envelope schema/version") + if any(envelope.get(k) != binding[k] for k in BINDING_KEYS): + raise ReplayError("model/profile/project binding mismatch") + session_id = validate_session_id(envelope["session_id"]) + if expected_session_id is not None and validate_session_id(expected_session_id) != session_id: + raise ReplayError("session binding mismatch") + display = validate_display(envelope["thought_display"]) + mapping = envelope["tool_mapping"] + expected_blocks = native_projection(envelope["contents"], display, mapping) + expected_digest = digest(expected_blocks, binding, session_id, display, mapping) + if envelope["projection_digest"] != expected_digest or digest(blocks, binding, session_id, display, mapping) != expected_digest: + raise ReplayError("projection digest mismatch") + return envelope + + +def decode_history(turns, binding): + if not isinstance(turns, list) or len(turns) > MAX_CONTENTS: + raise ReplayError("history turn limit/type") + envelopes, session_id, local_ids = [], None, set() + for blocks in turns: + envelope = decode_turn(blocks, binding) + if session_id is not None and envelope["session_id"] != session_id: + raise ReplayError("conflicting session IDs in history") + session_id = envelope["session_id"] + for entry in envelope["tool_mapping"]: + if entry["local_id"] in local_ids: + raise ReplayError("duplicate local ID across history turns") + local_ids.add(entry["local_id"]) + envelopes.append(envelope) + return {"session_id": session_id, "turns": envelopes} + + +def response_json(blocks, model): + return {"id": "msg_stagea_" + uuid.uuid4().hex, "type": "message", "role": "assistant", "model": model, + "content": copy.deepcopy(blocks), + "stop_reason": "tool_use" if any(b["type"] == "tool_use" for b in blocks) else "end_turn", + "stop_sequence": None, "usage": {"input_tokens": 32, "output_tokens": 24}} + + +def response_sse(message): + events = [] + start = copy.deepcopy(message) + start.update(content=[], stop_reason=None) + start["usage"]["output_tokens"] = 0 + events.append({"type": "message_start", "message": start}) + for index, block in enumerate(message["content"]): + kind = block["type"] + if kind == "thinking": + initial = {"type": kind, "thinking": "", "signature": ""} + deltas = [{"type": "thinking_delta", "thinking": block["thinking"]}, + {"type": "signature_delta", "signature": block["signature"]}] + elif kind == "text": + initial = {"type": kind, "text": ""} + deltas = [{"type": "text_delta", "text": block["text"]}] + else: + initial = {"type": kind, "id": block["id"], "name": block["name"], "input": {}} + deltas = [{"type": "input_json_delta", "partial_json": compact(block["input"])}] + events.append({"type": "content_block_start", "index": index, "content_block": initial}) + for delta in deltas: + events.append({"type": "content_block_delta", "index": index, "delta": delta}) + events.append({"type": "content_block_stop", "index": index}) + events.append({"type": "message_delta", "delta": {"stop_reason": message["stop_reason"], "stop_sequence": None}, + "usage": {"output_tokens": message["usage"]["output_tokens"]}}) + events.append({"type": "message_stop"}) + return "".join("event: " + e["type"] + "\ndata: " + compact(e) + "\n\n" for e in events).encode("utf-8") + + +if __name__ == "__main__": + # Fresh-decoder subprocess: only the echoed blocks and expected binding enter. + import sys + value = json.load(sys.stdin) + try: + if "turns" in value: + result = decode_history(value["turns"], value["binding"]) + print(compact({"ok": True, "history": result})) + else: + result = decode_turn(value["blocks"], value["binding"], expected_session_id=value.get("expected_session_id")) + print(compact({"ok": True, "envelope": result})) + except ReplayError as exc: + print(compact({"ok": False, "error": str(exc)})) + sys.exit(2) diff --git a/tests/antigravity_compatibility/harness.py b/tests/antigravity_compatibility/harness.py new file mode 100644 index 00000000..f5e01fd1 --- /dev/null +++ b/tests/antigravity_compatibility/harness.py @@ -0,0 +1,820 @@ +"""Offline actual-Claude fixture runner, executed ONLY inside the isolated container. + +All HTTP requests terminate here. It never connects to another server. Captures +are synthetic and intentionally remain in the designated /out artifact mount. +""" +import argparse +import concurrent.futures +import copy +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +import os +from pathlib import Path +import queue +import signal +import socket +import subprocess +import sys +import threading +import time +import uuid + +from codec import (PREFIX, ReplayError, canonical, compact, decode_turn, decode_history, mint_session_id, + project_turn, response_json, response_sse) + +LOW = "antigravity/gemini-4-argon-low" +HIGH = "antigravity/gemini-4-argon-high" +BINDING = {"profile_id": "synthetic-profile-stagea", "project_id": "synthetic-project-stagea", + "native_model": "gemini-4-argon-low"} +CLI_TIMEOUT = 90 +FIXTURE_MARKER = "SYNTHETIC_READ_FIXTURE_STAGE_A_20261018" +COMPLETION_NATIVE = [{"role": "model", "parts": [{"text": "SYNTHETIC_STAGE_A_DONE", "thoughtSignature": "fake-native-completion-signature"}]}] + + +def dump(path, value): + Path(path).write_text(json.dumps(value, ensure_ascii=False, indent=2, allow_nan=False) + "\n") + + +def clean_env(out, port): + return {"PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": str(out / "home"), + "CLAUDE_CONFIG_DIR": str(out / "config"), "TMPDIR": str(out / "tmp"), + "LANG": "C.UTF-8", "TERM": "dumb", "NO_COLOR": "1", + "ANTHROPIC_API_KEY": "stage-a-fake-key-not-a-credential", + "ANTHROPIC_BASE_URL": "http://127.0.0.1:%d" % port, + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1", + "DISABLE_AUTOUPDATER": "1", "PYTHONDONTWRITEBYTECODE": "1"} + + +def cli_command(model, settings, prompt, session_id=None, resume=None, effort=None, bare=True, tools="Read"): + if not bare and settings.get("disableAllHooks") is not True: + raise ValueError("Nonbare fixture requires session-only disableAllHooks=true") + cmd = ["/opt/fixture-cli"] + (["--bare"] if bare else []) + ["--print", "--output-format", "json", + "--model", model, "--tools", tools, "--allowedTools", "Read(//fixture/read.txt)", + "--permission-mode", "dontAsk", "--permission-prompts", "none", + "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', + "--no-chrome", "--setting-sources", "", + "--settings", compact(settings)] + if bare: + cmd.append("--disable-slash-commands") + if session_id: + cmd += ["--session-id", session_id] + if resume: + cmd += ["--resume", resume] + if effort: + cmd += ["--effort", effort] + return cmd + ["--", prompt] + + +def stream_cli_command(model, settings, session_id): + command = cli_command(model, settings, "unused", session_id=session_id)[:-2] + command[command.index("--output-format") + 1] = "stream-json" + return command + ["--input-format", "stream-json", "--verbose"] + + +def wire_fields(body): + result = {"top_level_keys": sorted(body)} + for key in ("model", "stream", "thinking", "output_config", "tool_choice", "system", + "max_tokens", "betas", "context_management", "temperature", "metadata"): + result[key] = {"present": True, "value": body[key]} if key in body else {"present": False} + output_config = body.get("output_config", {}) + result["output_config.effort"] = ({"present": True, "value": output_config["effort"]} + if "effort" in output_config else {"present": False}) + result["output_config.format"] = ({"present": True, "value": output_config["format"]} + if "format" in output_config else {"present": False}) + result["tool_names"] = [tool.get("name", tool.get("type")) for tool in body.get("tools", [])] + return result + + +def assistant_turns(messages): + # Boundaries come from the original sequence: removing a real system message + # must not manufacture adjacency. Ordinary user text is never system input. + if not isinstance(messages, list) or len(messages) > 512: + raise ReplayError("message count/type limit") + turns, current = [], [] + for message in messages: + if message.get("role") != "assistant": + if current: + turns.append(current) + current = [] + continue + content = message.get("content", []) + if isinstance(content, str): + content = [{"type": "text", "text": content}] + if not isinstance(content, list): + raise ReplayError("invalid assistant content") + # Separate complete turns keep separate authoritative carriers; unsigned + # adjacent fragments still have to pass the preceding turn's digest. + if content and isinstance(content[0], dict) and content[0].get("type") == "thinking" and current: + turns.append(current) + current = [] + if len(current) + len(content) > 4097: + raise ReplayError("assistant projection block limit") + current.extend(copy.deepcopy(content)) + if current: + turns.append(current) + return turns + + +def first_assistant_turn(messages): + turns = assistant_turns(messages) + return turns[0] if turns else [] + + +def numeric_json(value): + """Equivalent JSON with explicit exponent spelling, for synthetic input deltas.""" + canonical(value) # Reject unsafe numbers before rendering. + def render(v): + if isinstance(v, bool) or v is None or isinstance(v, str): + return compact(v) + if isinstance(v, (int, float)): + return ("-0e0" if v == 0 else format(float(v), ".17e")) + if isinstance(v, list): + return "[" + ",".join(render(x) for x in v) + "]" + return "{" + ",".join(compact(k) + ":" + render(v[k]) for k in v) + "}" + return render(value) + + +def reserialize_row(row): + """Only benign tool-input representation edits; never change any carrier.""" + result = copy.deepcopy(row) + def reorder(v): + if isinstance(v, dict): + return {key: reorder(v[key]) for key in reversed(list(v))} + if isinstance(v, list): + return [reorder(x) for x in v] + if isinstance(v, int) and not isinstance(v, bool): + return float(v) + return v + message = result.get("message", {}) + if message.get("role") == "assistant" and isinstance(message.get("content"), list): + for block in message["content"]: + if block.get("type") == "tool_use": + block["input"] = reorder(block["input"]) + if canonical(row) != canonical(result): + raise ValueError("Synthetic transcript reserialization changed semantics") + return result + + +def reserialize_transcript(out): + """Runs after the original CLI exits, only on this case's new synthetic files.""" + import hashlib + changes = [] + for path in (out / "config" / "projects").rglob("*.jsonl"): + original = path.read_text() + rows = [json.loads(line) for line in original.splitlines() if line] + normalized = [reserialize_row(row) for row in rows] + # Preserve before/after evidence, without copying in any host transcript. + (out / "transcript-before-reserialization.jsonl").write_text(original) + serialized = "\n".join(compact(row) for row in normalized) + "\n" + (out / "transcript-after-reserialization.jsonl").write_text(serialized) + path.write_text(serialized) + changes.append({"path": str(path.relative_to(out)), "changed": original != serialized, + "canonical_equal": canonical(rows) == canonical(normalized), + "before_sha256": hashlib.sha256(original.encode()).hexdigest(), + "after_sha256": hashlib.sha256(serialized.encode()).hexdigest()}) + dump(out / "reserialization.json", changes) + if not changes: + raise ValueError("No synthetic transcript found for reserialization") + + +def fixture(kind): + call = {"functionCall": {"name": "Read", "args": {"file_path": "/fixture/read.txt"}}, + "thoughtSignature": "fake-native-call-signature"} + if kind in ("codec_leaf", "codec_root"): + args = ({"json": compact({"file_path": "/fixture/read.txt", "offset": 1.0})} if kind == "codec_root" + else {"file_path": compact("/fixture/read.txt"), "offset": 1.0}) + return [{"role": "model", "parts": [ + {"thought": True, "text": "Genuine synthetic native thought 雪 retained even when display omitted.", "thoughtSignature": "fake-v1-native-thought"}, + {"functionCall": {"name": "native_read_alias", "id": "synthetic-reusable-native-id", "args": args}, "thoughtSignature": "fake-v1-native-call"}]}] + if kind == "tool_only": + return [{"role": "model", "parts": [call]}] + if kind == "signed_text": + return [{"role": "model", "parts": [{"text": "Synthetic signed ordinary text 雪.", "thoughtSignature": "fake-native-text-signature"}]}] + if kind == "signature_only": + return [{"role": "model", "parts": [{"thoughtSignature": "fake-native-signature-only"}, call]}] + if kind == "genuine": + return [{"role": "model", "parts": [{"thought": True, "text": "Synthetic visible thought α.", "thoughtSignature": "fake-thought"}, call]}] + if kind == "interleaved": + second = {"functionCall": {"id": "synthetic-native-second", "name": "Read", + "args": {"file_path": "/fixture/read.txt", "offset": 1.0, "limit": 2}}} + return [{"role": "model", "parts": [ + {"thought": True, "text": "Synthetic thought one 雪.", "thoughtSignature": "fake-first-thought"}, + {"text": "Synthetic split "}, {"text": "ordinary text."}, + {"thought": True, "text": " Synthetic thought two é."}, call, second], + "synthetic_unknown": {"unicode": "雪é", "nested": [1, 1.0, {"z": 0, "a": -0.0}]}}, + {"role": "model", "parts": [{"thoughtSignature": "fake-late-native-signature"}, {"text": ""}]}] + if kind == "signed_id": + call["functionCall"]["id"] = "synthetic-native-signed" + return [{"role": "model", "parts": [call]}] + if kind == "numeric_valid": + call["functionCall"]["args"] = {"file_path": "/fixture/read.txt", "limit": 2.0, "offset": 1.0} + return [{"role": "model", "parts": [{"thought": True, "text": "Synthetic numeric valid thought 雪é é."}, call]}] + if kind == "numeric": + call["functionCall"]["args"] = {"file_path": "/fixture/read.txt", "limit": 2.0, "offset": 1.0, + "synthetic_extra": {"z": [1, 1.0, 1e0, -0.0, 1e-3, {"雪": "éé"}], "a": True}} + return [{"role": "model", "parts": [{"thought": True, "text": "Synthetic numeric codec thought."}, call]}] + raise ValueError(kind) + + +def expected_tool_error_retained(histories): + """Schema-stress oracle: an expected error is not a successful Read.""" + return bool(histories) and all( + o.get("decoded") is True and o.get("native_exact") is True + and bool(o.get("tool_results")) + and all(t.get("is_error") is True for t in o["tool_results"]) + for o in histories) + + +def schema_inventory(tools): + """JSON-Schema-aware recursion, not a count of user property/example keys.""" + keywords, types, occurrences, top_fields = {}, set(), [], {} + maps = {"properties", "patternProperties", "$defs", "definitions", "dependentSchemas"} + arrays = {"allOf", "anyOf", "oneOf", "prefixItems"} + singles = {"items", "additionalProperties", "additionalItems", "contains", "not", "if", "then", "else", + "propertyNames", "unevaluatedProperties", "unevaluatedItems"} + boolean_nodes = [] + def visit(schema, path, name): + if isinstance(schema, bool): + boolean_nodes.append({"tool": name, "path": path, "value": schema}) + return + if not isinstance(schema, dict): + return + for key, value in schema.items(): + keywords[key] = keywords.get(key, 0) + 1 + child_path = path + "/" + key + occurrences.append({"tool": name, "path": child_path, "keyword": key, "value_type": type(value).__name__}) + if key == "type": + types.update(value if isinstance(value, list) else [value]) + if key in maps and isinstance(value, dict): + for prop, sub in value.items(): + visit(sub, child_path + "/" + prop.replace("~", "~0").replace("/", "~1"), name) + elif key in arrays and isinstance(value, list): + for index, sub in enumerate(value): + visit(sub, child_path + "/" + str(index), name) + elif key in singles: + if isinstance(value, list): + for index, sub in enumerate(value): + visit(sub, child_path + "/" + str(index), name) + else: + visit(value, child_path, name) + for index, tool in enumerate(tools): + for key in tool: + top_fields[key] = top_fields.get(key, 0) + 1 + visit(tool.get("input_schema"), "/tools/%d/input_schema" % index, tool.get("name")) + return {"tool_names": [t.get("name") for t in tools], + "tool_type_fields": [{"name": t.get("name"), "present": "type" in t, **({"value": t["type"]} if "type" in t else {})} for t in tools], + "tool_top_level_fields": dict(sorted(top_fields.items())), "schema_keywords": dict(sorted(keywords.items())), + "schema_types": sorted(types), "keyword_occurrences": occurrences, "boolean_schemas": boolean_nodes} + + +def compaction_status(boundary_count, post_observations, signed_completions): + if boundary_count and signed_completions and any(o.get("history_present") and not o.get("decoded") for o in post_observations): + return "UNSUPPORTED" + if boundary_count and signed_completions and post_observations and all( + o.get("decoded") and o.get("matches_signed_completion") for o in post_observations): + return "PASS" + return "UNVERIFIED" + + +def case_specs(): + cases = {} + for kind in ("tool_only", "signed_text", "signature_only", "genuine", "interleaved", "numeric", "numeric_valid", "signed_id"): + for thinking in ("default", "on", "off"): + cases[kind + "-" + thinking] = {"kind": kind, "thinking": thinking} + cases["minimal"] = {"kind": "tool_only", "thinking": "default"} + for negative in ("missing", "mutated", "arguments", "order", "profile", "project", "model"): + cases["negative-" + negative] = {"kind": "interleaved", "thinking": "default", "negative": negative} + for mode in ("default", "on", "off"): + cases["signed-text-conversation-" + mode] = {"kind": "signed_text", "thinking": mode, "same_process_followup": True} + cases["numeric-reserialize"] = {"kind": "numeric_valid", "thinking": "default", "reserialize": True} + cases["numeric-nested-reserialize"] = {"kind": "numeric", "thinking": "default", "reserialize": True} + cases["switch-low-high"] = {"kind": "interleaved", "thinking": "default", "switch": HIGH} + cases["switch-low-high-on"] = {"kind": "interleaved", "thinking": "on", "switch": HIGH} + cases["switch-low-high-off"] = {"kind": "interleaved", "thinking": "off", "switch": HIGH} + cases["suffix-provider-1m"] = {"kind": "genuine", "thinking": "default", "model": LOW + "[1m]"} + cases["suffix-gateway-1m"] = {"kind": "genuine", "thinking": "default", "model": "claude-antigravity-gemini-4-argon-low[1m]"} + cases["effort-low"] = {"kind": "genuine", "thinking": "on", "effort": "low"} + cases["effort-high"] = {"kind": "genuine", "thinking": "on", "effort": "high"} + cases["compact"] = {"kind": "interleaved", "thinking": "default", "compact": True} + cases["compact-nonbare"] = {"kind": "interleaved", "thinking": "default", "compact": True, "bare": False, "signed_completions": True} + cases["enforcement-valid"] = {"kind": "interleaved", "thinking": "default", "enforce_replay": True} + cases["enforcement-mismatch"] = {"kind": "interleaved", "thinking": "default", "negative": "model", "enforce_replay": True} + cases["v1-codec-omitted"] = {"kind": "codec_leaf", "thinking": "default", "final_codec": True, "signed_completions": True, "enforce_replay": True} + cases["v1-codec-full"] = {"kind": "codec_leaf", "thinking": "on", "final_codec": True, "signed_completions": True, "enforce_replay": True, "same_process_followup": True} + cases["v1-codec-root"] = {"kind": "codec_root", "thinking": "default", "final_codec": True, "signed_completions": True, "enforce_replay": True} + cases["v1-codec-compact"] = {"kind": "codec_leaf", "thinking": "default", "final_codec": True, "signed_completions": True, "enforce_replay": True, "compact": True, "bare": False} + for spec in cases.values(): + if spec["kind"] == "numeric": + spec["expected_tool_error"] = True + if spec.get("switch"): + spec["enforce_replay"] = True + return cases + + +class FixtureServer(ThreadingHTTPServer): + daemon_threads = True + allow_reuse_address = False + + def __init__(self, out, blocks, numeric_spelling=False, enforce_replay=False, signed_completions=False, catalog_only=False, + initial_native=None, tool_projections=None, native_session_id=None): + super().__init__(("127.0.0.1", 0), Handler) + self.out, self.blocks = out, blocks + self.numeric_spelling = numeric_spelling + self.enforce_replay = enforce_replay + self.signed_completions = signed_completions + self.catalog_only = catalog_only + self.initial_native = copy.deepcopy(initial_native) + self.tool_projections = copy.deepcopy(tool_projections) + self.native_session_id = native_session_id or mint_session_id() + self.native_generation_events = [] + self.replay_rejections = [] + self.phase = "initial" + self.records = [] + self.lock = threading.Lock() + self.sent_initial = False + + def synthetic_native_generation(self, record, initial): + """Instrumented LOCAL callback, never a connection to a native backend.""" + self.native_generation_events.append({"request": record["index"], "phase": record["phase"]}) + if self.catalog_only: + return response_json([{"type": "text", "text": "SYNTHETIC_CATALOG_CAPTURE_DONE"}], record["body"].get("model", LOW)) + blocks = self.blocks if initial else [{"type": "text", "text": "SYNTHETIC_STAGE_A_DONE"}] + session_id = record.get("validated_session_id") or self.native_session_id + display = "omitted" if record["body"].get("thinking", {}).get("display") == "omitted" else "full" + if initial and self.initial_native is not None: + blocks = project_turn(self.initial_native, BINDING, session_id=session_id, thought_display=display, + tool_projections=self.tool_projections) + if not initial and self.signed_completions: + blocks = project_turn(COMPLETION_NATIVE, BINDING, session_id=session_id, thought_display=display) + return response_json(blocks, record["body"].get("model", LOW)) + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, *args): + pass + + def setup(self): + super().setup() + self.connection.settimeout(10) + + def do_GET(self): + self.send_payload(404, b'{"error":{"type":"not_found_error","message":"local fixture only"}}', "application/json") + + def send_payload(self, code, data, content_type): + self.send_response(code) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(data))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(data) + self.wfile.flush() + + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + if not 0 < length <= 16 * 1024 * 1024: + return self.send_payload(413, b"{}", "application/json") + raw = self.rfile.read(length) + try: + body = json.loads(raw) + except ValueError: + return self.send_payload(400, b"{}", "application/json") + with self.server.lock: + index = len(self.server.records) + record = {"index": index, "phase": self.server.phase, "path": self.path, + "headers": dict(self.headers), "body": body, "wire": wire_fields(body)} + self.server.records.append(record) + dump(self.server.out / ("request-%02d.json" % index), record) + (self.server.out / ("request-%02d.raw.json" % index)).write_bytes(raw) + if index >= 16: + return self.send_payload(400, b'{"error":{"type":"invalid_request_error","message":"fixture request bound"}}', "application/json") + if self.path.split("?", 1)[0] == "/v1/messages/count_tokens": + return self.send_payload(200, b'{"input_tokens":64}', "application/json") + if self.path.split("?", 1)[0] != "/v1/messages": + return self.send_payload(404, b"{}", "application/json") + if self.server.enforce_replay: + history = first_assistant_turn(body.get("messages", [])) + if history: + model = body.get("model", "") + binding = dict(BINDING, native_model=model[len("antigravity/"):] if model.startswith("antigravity/") else model) + try: + validated = decode_history(assistant_turns(body.get("messages", [])), binding) + record["validated_session_id"] = validated["session_id"] + except ReplayError as exc: + error = {"type": "error", "error": {"type": "invalid_request_error", + "message": "Stage A replay rejected: " + str(exc) + ". Model/tier changes require a new session."}} + rejection = {"request": index, "phase": record["phase"], "status": 400, + "error": error["error"], "native_generation_callbacks_for_request": 0} + self.server.replay_rejections.append(rejection) + record["response_status"] = 400 + dump(self.server.out / ("request-%02d.json" % index), record) + dump(self.server.out / ("response-%02d.json" % index), error) + return self.send_payload(400, compact(error).encode(), "application/json") + initial = not self.server.sent_initial + self.server.sent_initial = True + message = self.server.synthetic_native_generation(record, initial) + record["response_status"] = 200 + dump(self.server.out / ("request-%02d.json" % index), record) + dump(self.server.out / ("response-%02d.json" % index), message) + if body.get("stream") is True: + data = response_sse(message) + if initial and self.server.numeric_spelling: + # Change only JSON lexemes in tool argument deltas, not semantic input. + frames = [] + for frame in data.decode().split("\n\n"): + if not frame: + continue + event = json.loads(frame.split("data: ", 1)[1]) + if event.get("delta", {}).get("type") == "input_json_delta": + event["delta"]["partial_json"] = numeric_json(json.loads(event["delta"]["partial_json"])) + frames.append("event: " + event["type"] + "\ndata: " + compact(event) + "\n\n") + data = "".join(frames).encode() + (self.server.out / ("response-%02d.sse" % index)).write_bytes(data) + self.send_payload(200, data, "text/event-stream") + else: + self.send_payload(200, compact(message).encode(), "application/json") + + +def run_cli(out, phase, command, env): + dump(out / (phase + "-command.json"), {"argv": command, "env": env, "cwd": "/fixture", "timeout_seconds": CLI_TIMEOUT}) + started = time.monotonic() + with (out / (phase + "-stdout.json")).open("wb") as stdout, (out / (phase + "-stderr.log")).open("wb") as stderr: + process = subprocess.Popen(command, cwd="/fixture", env=env, stdin=subprocess.DEVNULL, + stdout=stdout, stderr=stderr, start_new_session=True) + timed_out = False + try: + process.wait(timeout=CLI_TIMEOUT) + except subprocess.TimeoutExpired: + timed_out = True + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=5) + result = {"returncode": process.returncode, "timeout": timed_out, "pid": process.pid, + "seconds": round(time.monotonic() - started, 3)} + try: + result["cli_result_is_error"] = json.loads((out / (phase + "-stdout.json")).read_text()).get("is_error") + except (ValueError, AttributeError): + result["cli_result_is_error"] = None + dump(out / (phase + "-process.json"), result) + return result + + +def run_cli_conversation(out, command, env): + """Two real user turns in ONE CLI process, then normal fresh-process resume. + +Wait for the first result before submitting the second prompt; submitting both +at once can batch user input and would not establish signed-text history echo. +""" + dump(out / "initial-command.json", {"argv": command, "env": env, "cwd": "/fixture", "timeout_seconds": CLI_TIMEOUT}) + started = time.monotonic() + results = queue.Queue() + with (out / "initial-stdout.jsonl").open("w") as stdout, (out / "initial-stderr.log").open("w") as stderr: + process = subprocess.Popen(command, cwd="/fixture", env=env, stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=stderr, text=True, start_new_session=True) + def collect(): + for line in process.stdout: + stdout.write(line) + stdout.flush() + try: + event = json.loads(line) + if event.get("type") == "result": + results.put(event) + except ValueError: + pass + results.put(None) + reader = threading.Thread(target=collect, daemon=True) + reader.start() + completed = 0 + timed_out = False + try: + for prompt in ("Synthetic signed-text Stage A fixture; respond with the prepared synthetic text.", + "Continue this same-process synthetic signed-text fixture without using any tool."): + process.stdin.write(compact({"type": "user", "message": {"role": "user", "content": prompt}}) + "\n") + process.stdin.flush() + event = results.get(timeout=CLI_TIMEOUT) + if event is None: + raise ValueError("CLI ended before same-process result") + if event.get("is_error"): + raise ValueError("Same-process CLI returned error") + completed += 1 + process.stdin.close() + process.wait(timeout=10) + except (queue.Empty, subprocess.TimeoutExpired): + timed_out = True + finally: + if process.poll() is None: + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=5) + reader.join(timeout=5) + process.stdout.close() + result = {"returncode": process.returncode, "timeout": timed_out, "pid": process.pid, + "completed_same_process_user_turns": completed, "seconds": round(time.monotonic() - started, 3)} + dump(out / "initial-process.json", result) + return result + + +def fresh_decode(out, name, blocks, binding): + inp = {"blocks": blocks, "binding": binding} + dump(out / (name + "-decode-input.json"), inp) + process = subprocess.run([sys.executable, "-B", "/fixture/codec.py"], input=compact(inp), + text=True, capture_output=True, timeout=10, + env={"PATH": "/usr/bin:/bin", "PYTHONDONTWRITEBYTECODE": "1"}) + result = json.loads(process.stdout) + dump(out / (name + "-decoded.json"), result) + return result + + +def fresh_history_decode(out, name, messages, binding): + value = {"turns": assistant_turns(messages), "binding": binding} + dump(out / (name + "-history-input.json"), value) + process = subprocess.run([sys.executable, "-B", "/fixture/codec.py"], input=compact(value), + text=True, capture_output=True, timeout=10, + env={"PATH": "/usr/bin:/bin", "PYTHONDONTWRITEBYTECODE": "1"}) + result = json.loads(process.stdout) + dump(out / (name + "-history-decoded.json"), result) + return result + + +def observe_record(out, record, native, binding, all_turns=False): + blocks = first_assistant_turn(record["body"].get("messages", [])) + if not blocks: + return {"index": record["index"], "phase": record["phase"], "history_present": False} + decoded = fresh_decode(out, "request-%02d" % record["index"], blocks, binding) + results = [b for m in record["body"].get("messages", []) if m.get("role") == "user" + for b in (m.get("content", []) if isinstance(m.get("content"), list) else []) + if b.get("type") == "tool_result"] + observation = {"index": record["index"], "phase": record["phase"], "history_present": True, + "carrier_count": sum(b.get("type") == "thinking" and b.get("signature", "").startswith(PREFIX) for b in blocks), + "thinking_texts": [b.get("thinking") for b in blocks if b.get("type") == "thinking"], + "decoded": decoded["ok"], "error": decoded.get("error"), + "native_exact": decoded["ok"] and compact(decoded["envelope"]["contents"]) == compact(native), + "matches_signed_completion": decoded["ok"] and compact(decoded["envelope"]["contents"]) == compact(COMPLETION_NATIVE), + "tool_results": [{"id": b.get("tool_use_id"), "is_error": b.get("is_error", False), + "fixture_read": FIXTURE_MARKER in compact(b.get("content"))} for b in results]} + if decoded["ok"]: + observation["session_id"] = decoded["envelope"]["session_id"] + observation["thought_display"] = decoded["envelope"]["thought_display"] + if all_turns: + history = fresh_history_decode(out, "request-%02d" % record["index"], record["body"].get("messages", []), binding) + observation["history_decoder_ok"] = history["ok"] + observation["history_session_id"] = history.get("history", {}).get("session_id") + observation["history_turns"] = len(history.get("history", {}).get("turns", [])) + observation["history_error"] = history.get("error") + return observation + + +def run_case(name, spec, root): + out = root / name + out.mkdir(parents=True, exist_ok=False) + for directory in ("home", "config", "tmp"): + (out / directory).mkdir() + native = fixture(spec["kind"]) + native_session_id = mint_session_id() + tool_projections = None + if spec["kind"] in ("codec_leaf", "codec_root"): + tool_projections = [{"caller_name": "Read", "codec_paths": [] if spec["kind"] == "codec_root" else ["/file_path"], + "root_wrapper": "json" if spec["kind"] == "codec_root" else None}] + blocks = project_turn(native, BINDING, session_id=native_session_id, tool_projections=tool_projections) + negative = spec.get("negative") + if negative == "missing": + blocks = blocks[1:] + elif negative == "mutated": + blocks[0]["signature"] = PREFIX + "mutated" + elif negative == "arguments": + next(b for b in blocks if b["type"] == "tool_use")["input"]["offset"] = 2 + elif negative == "order": + indices = [i for i, b in enumerate(blocks) if b["type"] == "tool_use"] + blocks[indices[0]], blocks[indices[1]] = blocks[indices[1]], blocks[indices[0]] + elif negative in ("profile", "project", "model"): + key = {"profile": "profile_id", "project": "project_id", "model": "native_model"}[negative] + blocks = project_turn(native, dict(BINDING, **{key: "synthetic-foreign-" + negative})) + dump(out / "fixture.json", {"spec": spec, "binding": BINDING, "contents": native, "projected": blocks}) + server = FixtureServer(out, blocks, numeric_spelling=spec["kind"].startswith("numeric"), + enforce_replay=spec.get("enforce_replay", False), signed_completions=spec.get("signed_completions", False), + initial_native=native if spec.get("final_codec") else None, + tool_projections=tool_projections, native_session_id=native_session_id) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + env = clean_env(out, server.server_port) + settings = {"env": {"ANTHROPIC_API_KEY": env["ANTHROPIC_API_KEY"], "ANTHROPIC_BASE_URL": env["ANTHROPIC_BASE_URL"]}} + bare = spec.get("bare", True) + if not bare: + settings["disableAllHooks"] = True + if spec["thinking"] != "default": + settings["alwaysThinkingEnabled"] = spec["thinking"] == "on" + session = str(uuid.uuid4()) + model = spec.get("model", LOW) + runs = {} + try: + if spec.get("same_process_followup"): + runs["initial"] = run_cli_conversation(out, stream_cli_command(model, settings, session), env) + else: + runs["initial"] = run_cli(out, "initial", cli_command(model, settings, + "Synthetic offline Stage A fixture. Use only Read on /fixture/read.txt if requested, then finish.", + session_id=session, effort=spec.get("effort"), bare=bare), env) + if spec.get("reserialize"): + reserialize_transcript(out) + server.phase = "resume" + runs["resume"] = run_cli(out, "resume", cli_command(model, settings, + "Continue the synthetic fixture and acknowledge its previous result.", resume=session, effort=spec.get("effort"), bare=bare), env) + if spec.get("switch"): + server.phase = "switch" + runs["switch"] = run_cli(out, "switch", cli_command(spec["switch"], settings, + "Continue the same synthetic fixture after this explicit model switch.", resume=session, bare=bare), env) + if spec.get("compact"): + server.phase = "compact" + runs["compact"] = run_cli(out, "compact", cli_command(model, settings, "/compact", resume=session, bare=bare), env) + server.phase = "post-compact" + runs["post-compact"] = run_cli(out, "post-compact", cli_command(model, settings, + "Continue the synthetic fixture after attempted compaction.", resume=session, bare=bare), env) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + dump(out / "endpoint-enforcement.json", {"enabled": server.enforce_replay, + "native_generation_events": server.native_generation_events, "replay_rejections": server.replay_rejections}) + if spec.get("final_codec"): + actual_initial = json.loads((out / "response-00.json").read_text())["content"] + dump(out / "fixture.json", {"spec": spec, "binding": BINDING, "session_id": native_session_id, + "contents": native, "tool_projections": tool_projections, "projected": actual_initial, + "completion_native": COMPLETION_NATIVE}) + records = [r for r in server.records if r["path"].split("?", 1)[0] == "/v1/messages"] + observations = [] + golden = [] + for record in records: + binding = dict(BINDING) + if record["phase"] == "switch": + binding["native_model"] = "gemini-4-argon-high" + observation = observe_record(out, record, native, binding, all_turns=spec.get("final_codec", False)) + if record["phase"] == "switch" and observation.get("history_present"): + original_decode = fresh_decode(out, "switch-origin-binding", first_assistant_turn(record["body"]["messages"]), BINDING) + observation["origin_binding_decodes"] = original_decode["ok"] + # Separate negative control: do NOT inject these old blocks into the + # actual switch request. This proves strict rejection independently + # of any carrier stripping by the real client. + prior = next(r for r in reversed(records[:records.index(record)]) if r["phase"] == "resume") + control = fresh_decode(out, "switch-strict-binding-control", first_assistant_turn(prior["body"]["messages"]), binding) + observation["strict_binding_control_rejected"] = not control["ok"] and "binding" in control.get("error", "") + observations.append(observation) + golden.append({"index": record["index"], "phase": record["phase"], "wire": record["wire"], + "headers": record["headers"], "messages": record["body"].get("messages", [])}) + dump(out / "golden-request-vectors.json", golden) + histories = [o for o in observations if o.get("history_present") and o["phase"] in ("initial", "resume")] + resume_seen = any(o["phase"] == "resume" for o in histories) + expected_error_phases = {r["phase"] for r in server.replay_rejections} + processes_ok = all(not r["timeout"] and ( + (r["returncode"] != 0 or r.get("cli_result_is_error") is True) if phase in expected_error_phases + else r["returncode"] == 0) for phase, r in runs.items()) + retention_passed = bool(histories) and resume_seen and all(o["decoded"] and o["native_exact"] for o in histories) + if negative: + passed = bool(histories) and resume_seen and all(not o["decoded"] for o in histories) and processes_ok + else: + passed = retention_passed and processes_ok + if spec.get("same_process_followup"): + passed = passed and runs["initial"].get("completed_same_process_user_turns") == 2 and any(o["phase"] == "initial" for o in histories) + if any("functionCall" in p for c in native for p in c["parts"]): + read_observed = any(t["fixture_read"] and not t["is_error"] for o in histories for t in o["tool_results"]) + if spec.get("expected_tool_error"): + passed = passed and expected_tool_error_retained(histories) + else: + passed = passed and read_observed + else: + read_observed = None + rejection_indices = {r["request"] for r in server.replay_rejections} + rejected_callback_count = sum(e["request"] in rejection_indices for e in server.native_generation_events) + if spec.get("enforce_replay") and negative: + passed = passed and bool(server.replay_rejections) and rejected_callback_count == 0 + if spec.get("switch"): + switches = [o for o in observations if o["phase"] == "switch" and o.get("history_present")] + switch_requests = [r for r in records if r["phase"] == "switch"] + passed = (passed and bool(switches) and all(not o["decoded"] and o.get("strict_binding_control_rejected") for o in switches) + and bool(switch_requests) and all(r.get("response_status") == 400 for r in switch_requests) + and not any(e["phase"] == "switch" for e in server.native_generation_events)) + if spec.get("final_codec"): + all_histories = [o for o in observations if o.get("history_present")] + passed = passed and bool(all_histories) and all(o.get("history_decoder_ok") and o.get("history_session_id") == native_session_id for o in all_histories) + expected_rejection = bool(spec.get("expected_tool_error") or negative or spec.get("switch")) + summary = {"case": name, "spec": spec, "status": ("EXPECTED_REJECTION" if expected_rejection else "PASS") if passed else "FAIL", "session_id": session, + "same_model_retention": "PASS" if retention_passed else ("EXPECTED_REJECTION" if negative and passed else "FAIL"), + "processes": runs, "requests": len(records), "stream_values": sorted(set(str(r["body"].get("stream")) for r in records)), + "observations": observations, "fixture_read_observed": read_observed, + "expected_tool_error_with_exact_replay": expected_tool_error_retained(histories) if spec.get("expected_tool_error") else None, + "endpoint_enforcement": {"enabled": server.enforce_replay, "rejections": server.replay_rejections, + "generation_callbacks_on_rejected_requests": rejected_callback_count, + "generation_callbacks_by_phase": {phase: sum(e["phase"] == phase for e in server.native_generation_events) for phase in runs}}, + "compaction": "UNVERIFIED" if spec.get("compact") else "not-attempted"} + if spec.get("compact"): + # A successful process exit or post-command replay does not prove compaction. + compact_output = json.loads((out / "compact-stdout.json").read_text()) + summary["compaction_command_result"] = compact_output.get("result") + boundaries = [] + for transcript in (out / "config" / "projects").rglob("*.jsonl"): + for line in transcript.read_text().splitlines(): + value = json.loads(line) + if value.get("subtype") == "compact_boundary": + boundaries.append(value) + post_observations = [o for o in observations if o["phase"] == "post-compact"] + summary["compaction"] = compaction_status(len(boundaries), post_observations, spec.get("signed_completions", False)) + summary["status"] = summary["compaction"] if passed else "FAIL" + summary["compaction_boundary_count"] = len(boundaries) + dump(out / "compaction-evidence.json", {"boundaries": boundaries, + "signed_completions": server.signed_completions, "post_observations": post_observations, + "note": "Fixture summary is synthetic, not a quality test of compaction or Google native compatibility."}) + dump(out / "summary.json", summary) + return summary + + +def run_catalog(root): + """Separate catalog-only session: advertise tools, NEVER emit tool calls.""" + out = root / "catalog-default" + out.mkdir(parents=True, exist_ok=False) + for directory in ("home", "config", "tmp"): + (out / directory).mkdir() + server = FixtureServer(out, [], catalog_only=True) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + env = clean_env(out, server.server_port) + settings = {"disableAllHooks": True, "env": {"ANTHROPIC_API_KEY": env["ANTHROPIC_API_KEY"], "ANTHROPIC_BASE_URL": env["ANTHROPIC_BASE_URL"]}} + command = cli_command(LOW, settings, "Synthetic catalog-only probe. Respond with the prepared text and do not call any tool.", + session_id=str(uuid.uuid4()), bare=False, tools="default") + try: + process = run_cli(out, "catalog", command, env) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + records = [r for r in server.records if r["path"].split("?", 1)[0] == "/v1/messages"] + catalogs = [] + no_calls = True + for record in records: + tools = record["body"].get("tools", []) + catalogs.append({"request": record["index"], "complete_tool_definitions": tools, "inventory": schema_inventory(tools), "wire": record["wire"]}) + response = json.loads((out / ("response-%02d.json" % record["index"])).read_text()) + no_calls = no_calls and response["stop_reason"] == "end_turn" and all(b["type"] == "text" for b in response["content"]) + dump(out / "tool-catalog.json", catalogs) + inventory = schema_inventory([t for r in records for t in r["body"].get("tools", [])]) + passed = bool(records) and len(inventory["tool_names"]) > 1 and no_calls and process["returncode"] == 0 and not process["timeout"] + summary = {"case": "catalog-default", "status": "PASS_CATALOG_ONLY" if passed else "FAIL", + "separate_from_replay_matrix": True, "process": process, "requests": len(records), + "server_tool_calls_emitted": 0 if no_calls else "unexpected", "tool_names": inventory["tool_names"], + "schema_keywords": inventory["schema_keywords"], "schema_types": inventory["schema_types"], + "tool_type_fields": inventory["tool_type_fields"], "tool_top_level_fields": inventory["tool_top_level_fields"]} + dump(out / "summary.json", summary) + return summary + + +def safe_identity(): + """Admit only a positive, matching real/effective caller identity.""" + if sys.platform != "linux": + return False + values = [getattr(os, name)() for name in ("getuid", "geteuid", "getgid", "getegid")] + return (all(type(value) is int and value > 0 for value in values) + and values[0] == values[1] and values[2] == values[3]) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("cases", nargs="+", choices=list(case_specs()) + ["matrix", "catalog-default"]) + parser.add_argument("--workers", type=int, default=6) + args = parser.parse_args() + if not safe_identity() or not Path("/opt/fixture-cli").is_file() or not Path("/out").is_dir(): + raise SystemExit("Run only via run_container.py in the isolated fixture container") + dump(Path("/out/isolation.json"), {"uid": os.getuid(), "gid": os.getgid(), "pid": os.getpid(), + "network_interfaces": socket.if_nameindex(), + "net_namespace": os.readlink("/proc/self/ns/net"), "pid_namespace": os.readlink("/proc/self/ns/pid"), + "container_env_keys": sorted(os.environ), + "routes": Path("/proc/net/route").read_text()}) + if args.cases == ["catalog-default"]: + result = run_catalog(Path("/out")) + dump(Path("/out/catalog-result.json"), result) + print(compact(result), flush=True) + return 0 if result["status"] == "PASS_CATALOG_ONLY" else 1 + if "catalog-default" in args.cases: + raise SystemExit("Run catalog-default separately from the replay matrix") + cases = case_specs() + names = [n for n in cases if n != "minimal"] if "matrix" in args.cases else args.cases + summaries = [] + with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as executor: + futures = {executor.submit(run_case, name, cases[name], Path("/out")): name for name in names} + for future in concurrent.futures.as_completed(futures): + name = futures[future] + try: + summary = future.result() + except Exception as exc: + summary = {"case": name, "status": "HARNESS_ERROR", "error": repr(exc)} + summaries.append(summary) + print(compact({"case": name, "status": summary["status"], "error": summary.get("error")}), flush=True) + dump(Path("/out/matrix.json"), sorted(summaries, key=lambda s: s["case"])) + return 0 if all(s["status"] in ("PASS", "EXPECTED_REJECTION") for s in summaries) else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/antigravity_compatibility/read.txt b/tests/antigravity_compatibility/read.txt new file mode 100644 index 00000000..32190ed0 --- /dev/null +++ b/tests/antigravity_compatibility/read.txt @@ -0,0 +1,3 @@ +SYNTHETIC_READ_FIXTURE_STAGE_A_20261018 +Only synthetic fixture content: Unicode 雪, é, é; numbers 1 and 1.0. +No credentials, private source, or production configuration. diff --git a/tests/antigravity_compatibility/run_container.py b/tests/antigravity_compatibility/run_container.py new file mode 100644 index 00000000..3ae5cf6c --- /dev/null +++ b/tests/antigravity_compatibility/run_container.py @@ -0,0 +1,444 @@ +"""Optional host-side Docker launcher; never executes Claude on the host. + +Requires explicit --image, --claude-binary and a new absolute --out directory. +Only nonroot Linux callers, trusted local rootful Docker and single native ELF +installations are supported. No image pull/build, binary download or auth import. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import stat +import subprocess +import sys +import time +from urllib.parse import urlsplit +import uuid + +from harness import case_specs, safe_identity + +SOURCE = Path(__file__).resolve().parent +TOTAL_SECONDS = 600 +CLEANUP_SECONDS = 30 +LABEL = "antigravity.compatibility.run" +IMAGE_ID = re.compile(r"sha256:[a-f0-9]{64}") +CONTAINER_ID = re.compile(r"[a-f0-9]{64}") + + +def safe_path(value): + text = str(value) + if any(c in ',"' or ord(c) < 32 or ord(c) == 127 for c in text): + raise ValueError("Paths cannot contain CSV delimiters, quotes or ASCII controls") + return Path(text) + + +def valid_image(value): + if IMAGE_ID.fullmatch(value): + return True + # Intentionally conservative Docker reference subset: explicit tag or sha256. + component = r"[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*" + registry = r"(?:[a-z0-9]+(?:[.-][a-z0-9]+)*(?::[0-9]+)?/)?" + repository = r"(?:" + component + r"/)*" + component + suffix = r"(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}|@sha256:[a-f0-9]{64})" + return re.fullmatch(registry + repository + suffix, value) is not None + + +def binary_identity(path): + before = path.lstat() + if not stat.S_ISREG(before.st_mode) or not os.access(path, os.R_OK | os.X_OK): + raise ValueError("CLI must be one readable/executable regular native ELF file") + digest = hashlib.sha256() + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(fd, "rb") as stream: + opened = os.fstat(stream.fileno()) + if (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino): + raise ValueError("CLI changed while opening") + header = stream.read(64) + if (len(header) != 64 or header[:7] != b"\x7fELF\x02\x01\x01" + or header[7] not in (0, 3) or int.from_bytes(header[16:18], "little") not in (2, 3)): + raise ValueError("CLI must be a Linux 64-bit little-endian native ELF, not a wrapper") + architecture = {62: "amd64", 183: "arm64"}.get(int.from_bytes(header[18:20], "little")) + if architecture is None: + raise ValueError("Unsupported ELF architecture") + digest.update(header) + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + after = os.fstat(stream.fileno()) + fields = ("st_dev", "st_ino", "st_size", "st_mtime_ns", "st_ctime_ns", "st_mode") + if any(getattr(before, key) != getattr(after, key) for key in fields): + raise ValueError("CLI changed while hashing") + return dict(zip(fields, (getattr(after, key) for key in fields)), + sha256=digest.hexdigest(), architecture=architecture) + + +def validate(args): + if sys.platform != "linux" or not safe_identity(): + raise ValueError("Requires Linux and positive equal real/effective UID and GID") + if not valid_image(args.image): + raise ValueError("Image must be an explicitly tagged/digest-qualified name or full sha256 ID") + binary = safe_path(args.claude_binary) + if not binary.is_absolute(): + raise ValueError("CLI path must be absolute") + binary = safe_path(binary.resolve(strict=True)) + identity = binary_identity(binary) + source = safe_path(SOURCE) + source = safe_path(source.resolve(strict=True)) + if not source.is_dir(): + raise ValueError("Fixture source must be a directory") + out = safe_path(args.out) + if (not out.is_absolute() or str(out) != args.out or os.path.normpath(args.out) != args.out + or not out.name.startswith("antigravity-compatibility-") + or out.name == "antigravity-compatibility-"): + raise ValueError("Output must be a normalized absolute new antigravity-compatibility-* directory") + if os.path.lexists(out): + raise ValueError("Output already exists (including symlink)") + if out.parent.resolve(strict=True) != out.parent or not stat.S_ISDIR(out.parent.lstat().st_mode): + raise ValueError("Output parent must be an existing real directory without symlink ancestors") + for mounted in (source, binary): + if out == mounted or out in mounted.parents or mounted in out.parents: + raise ValueError("Output overlaps mounted fixture inputs or CLI") + if (len(set(args.cases)) != len(args.cases) + or ("catalog-default" in args.cases and args.cases != ["catalog-default"])): + raise ValueError("Cases must be unique; catalog-default runs separately") + parent = out.parent.stat() + return binary, source, out, identity, (parent.st_dev, parent.st_ino) + + +class Deadline: + """One budget, including preflight and cleanup; never reset after polling.""" + def __init__(self): + self.end = time.monotonic() + TOTAL_SECONDS + + def remaining(self, cleanup=False, cap=None): + value = self.end - time.monotonic() - (0 if cleanup else CLEANUP_SECONDS) + if value <= 0: + raise subprocess.TimeoutExpired("launcher total deadline", TOTAL_SECONDS) + return min(value, cap) if cap is not None else value + + +class Docker: + def __init__(self, deadline): + self.deadline = deadline + keys = ("PATH", "DOCKER_CONFIG", "DOCKER_HOST", "DOCKER_CONTEXT", + "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH", "DOCKER_API_VERSION") + self.env = {key: os.environ[key] for key in keys if key in os.environ} + self.env.setdefault("PATH", "/usr/bin:/bin") + # Preserve the normal config location without forwarding HOME or auth env. + if "DOCKER_CONFIG" not in self.env: + self.env["DOCKER_CONFIG"] = str(Path.home() / ".docker") + self.prefix = ["docker"] + + def call(self, args, cleanup=False): + return subprocess.run(self.prefix + args, env=self.env, stdin=subprocess.DEVNULL, + capture_output=True, text=True, + timeout=self.deadline.remaining(cleanup=cleanup, cap=10)) + + def text(self, args): + result = self.call(args) + if result.returncode: + raise ValueError("Docker read-only preflight failed: " + " ".join(args[:2])) + return result.stdout + + def preflight(self, image, architecture): + # DOCKER_CONTEXT wins over DOCKER_HOST; otherwise a host selects default + # directly. With neither, context show reads Docker's saved currentContext. + context = self.env.get("DOCKER_CONTEXT") + endpoint = None if context else self.env.get("DOCKER_HOST") + if not endpoint: + context = context or self.text(["context", "show"]).strip() + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", context): + raise ValueError("Unsupported Docker context name") + rows = json.loads(self.text(["context", "inspect", context])) + if not isinstance(rows, list) or len(rows) != 1 or rows[0].get("Name") != context: + raise ValueError("Ambiguous Docker context") + endpoint = rows[0]["Endpoints"]["docker"]["Host"] + parsed = urlsplit(endpoint) + if (parsed.scheme != "unix" or parsed.netloc or parsed.query or parsed.fragment + or not parsed.path.startswith("/") or parsed.path.startswith("//") + or any(ord(c) < 32 or ord(c) == 127 for c in endpoint)): + raise ValueError("Only a trusted local unix Docker endpoint is supported") + # Pin the selected endpoint for ALL later operations. No context mutation. + self.prefix = ["docker", "--host", endpoint] + self.env.pop("DOCKER_CONTEXT", None) + self.env.pop("DOCKER_HOST", None) + server = json.loads(self.text(["version", "--format", "{{json .}}"]))["Server"] + info = json.loads(self.text(["info", "--format", "{{json .}}"])).copy() + security = info.get("SecurityOptions") + if (server.get("Os") != "linux" or info.get("OSType") != "linux" + or not server.get("Platform", {}).get("Name", "").startswith("Docker Engine") + or not any(c.get("Name") == "Engine" for c in server.get("Components", [])) + or not isinstance(security, list) or not all(isinstance(s, str) for s in security) + or any("rootless" in s.lower() or "userns" in s.lower() for s in security)): + raise ValueError("Requires known rootful Docker/Linux engine without user namespace remapping") + rows = json.loads(self.text(["image", "inspect", image])) + if not isinstance(rows, list) or len(rows) != 1: + raise ValueError("Image inspection must resolve exactly one local image") + row = rows[0] + image_id = row.get("Id", "") + if (not IMAGE_ID.fullmatch(image_id) or row.get("Os") != "linux" + or row.get("Architecture") != architecture): + raise ValueError("Image must be immutable Linux and match the ELF architecture") + config = row["Config"] + if not isinstance(config, dict) or config.get("Volumes") not in (None, {}): + raise ValueError("Image-declared VOLUMEs are unsupported") + inherited = config.get("Env") or [] + if not isinstance(inherited, list): + raise ValueError("Invalid image environment") + keys = set() + for item in inherited: + if not isinstance(item, str) or "=" not in item: + raise ValueError("Invalid image environment entry") + key = item.split("=", 1)[0] + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key) or key in keys: + raise ValueError("Invalid or duplicate image environment key") + keys.add(key) + # Never persist image credential VALUES, inherited argv, or engine config. + return {"image_id": image_id, "architecture": architecture, "env_keys": sorted(keys), + "endpoint": endpoint, "context": context, "engine": server["Platform"]["Name"], + "security_options": security, "os": "linux", "volumes": False} + + +class Output: + """Pinned private directory FDs; control/ is never visible to the container.""" + def __init__(self, path, uid, gid, create=True, parent_identity=None): + self.path, self.uid, self.gid = path, uid, gid + self.fds, self.identities = {}, {} + try: + if create: + if path.parent.resolve(strict=True) != path.parent: + raise ValueError("Output parent was replaced by a symlink") + parent_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + parent = os.fstat(parent_fd) + if parent_identity is not None and (parent.st_dev, parent.st_ino) != parent_identity: + raise ValueError("Output parent changed after validation") + os.mkdir(path.name, mode=0o700, dir_fd=parent_fd) + finally: + os.close(parent_fd) + self._pin("root", path) + for name in ("control", "work"): + if create: + os.mkdir(name, mode=0o700, dir_fd=self.fds["root"]) + self._pin(name, path / name) + except BaseException: + self.close() + raise + + def _pin(self, name, path): + before = path.lstat() + fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + self.fds[name] = fd + after = os.fstat(fd) + identity = (after.st_dev, after.st_ino) + if ((before.st_dev, before.st_ino) != identity or after.st_uid != self.uid + or after.st_gid != self.gid or stat.S_IMODE(after.st_mode) != 0o700): + raise ValueError("Output directory identity/ownership/private mode check failed") + self.identities[name] = identity + + def verify(self): + if self.path.resolve(strict=True) != self.path: + raise ValueError("Output path was replaced with a symlink") + for name, identity in self.identities.items(): + path = self.path if name == "root" else self.path / name + value = path.lstat() + if (not stat.S_ISDIR(value.st_mode) or (value.st_dev, value.st_ino) != identity + or value.st_uid != self.uid or value.st_gid != self.gid + or stat.S_IMODE(value.st_mode) != 0o700): + raise ValueError("Output directory changed") + + def open(self, name): + if Path(name).name != name: + raise ValueError("Evidence name must be one path component") + fd = os.open(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, dir_fd=self.fds["control"]) + return os.fdopen(fd, "w") + + def dump(self, name, value): + with self.open(name) as stream: + json.dump(value, stream, indent=2) + stream.write("\n") + + def read_cid(self): + # Docker creates an empty cidfile BEFORE daemon creation. Poll metadata, + # not contents; an existing complete cid is opened/read only once. + value = os.stat("container.cid", dir_fd=self.fds["control"], follow_symlinks=False) + if not stat.S_ISREG(value.st_mode) or value.st_uid != self.uid or value.st_nlink != 1: + raise ValueError("Invalid invocation cidfile") + if value.st_size == 0: + return None + fd = os.open("container.cid", os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, + dir_fd=self.fds["control"]) + with os.fdopen(fd, "r", encoding="ascii") as stream: + value = os.fstat(stream.fileno()) + if (not stat.S_ISREG(value.st_mode) or value.st_uid != self.uid + or value.st_nlink != 1 or value.st_size not in (64, 65)): + raise ValueError("Invalid invocation cidfile") + cid = stream.read(66).rstrip("\n") + if not CONTAINER_ID.fullmatch(cid): + raise ValueError("Invalid invocation container ID") + return cid + + def close(self): + for fd in self.fds.values(): + os.close(fd) + self.fds.clear() + + def __enter__(self): + return self + + def __exit__(self, *args): + self.close() + + +def docker_command(out, name, cases, *, image, binary, uid, gid, env_keys=(), source=SOURCE, docker=("docker",)): + command = list(docker) + ["run", "--rm", "--init", "--pull", "never", "--name", name, + "--label", LABEL + "=" + name, "--cidfile", str(out / "control" / "container.cid"), + "--network", "none", "--user", "%s:%s" % (uid, gid), "--cap-drop", "ALL", + "--security-opt", "no-new-privileges", "--read-only", "--ipc", "private", + "--pids-limit", "512", "--memory", "8g", "--cpus", "12", "--no-healthcheck", + "--tmpfs", "/tmp:rw,nosuid,nodev,mode=1777", + "--tmpfs", "/fixture-home:rw,nosuid,nodev,uid=%s,gid=%s,mode=0700" % (uid, gid), + "--mount", "type=bind,source=%s,target=/fixture,readonly" % source, + "--mount", "type=bind,source=%s,target=/out" % (out / "work"), + "--mount", "type=bind,source=%s,target=/opt/fixture-cli,readonly" % binary, + "--workdir", "/fixture", "--entrypoint", "/usr/bin/env"] + # Docker client config may inject proxy credentials even when host env is + # clean. Explicit blanks override both those defaults and image ENV values. + proxy_keys = {key for upper in ("HTTP_PROXY", "HTTPS_PROXY", "FTP_PROXY", "ALL_PROXY", "NO_PROXY") + for key in (upper, upper.lower())} + for key in sorted(set(env_keys) | proxy_keys): + command += ["--env", key + "="] + return command + [image, "-i", "PATH=/usr/bin:/bin", "PYTHONDONTWRITEBYTECODE=1", + "LANG=C.UTF-8", "HOME=/fixture-home", "python3", "-B", "/fixture/harness.py"] + cases + + +def absent(result, cid): + return result.returncode != 0 and result.stderr.strip() in ( + "Error: No such container: " + cid, "Error response from daemon: No such container: " + cid) + + +def confirm(docker, cid, name, image, cleanup=False): + result = docker.call(["container", "inspect", cid], cleanup=cleanup) + if absent(result, cid): + return None + if result.returncode: + raise ValueError("Container ownership inspection failed") + rows = json.loads(result.stdout) + if not isinstance(rows, list) or len(rows) != 1: + raise ValueError("Ambiguous container ownership") + row = rows[0] + if (row.get("Id") != cid or row.get("Image") != image or row.get("Name") != "/" + name + or row.get("Config", {}).get("Labels", {}).get(LABEL) != name): + raise ValueError("Container ID/name/label/image ownership mismatch") + return {"id": cid, "name": name, "image": image, "label": {LABEL: name}} + + +def failure(error): + if isinstance(error, KeyboardInterrupt): + return 130, "Interrupted" + if isinstance(error, subprocess.TimeoutExpired): + return 124, "Timeout: total launcher deadline or bounded Docker operation" + return 1, str(error) + + +def execute(args, validated, deadline): + binary, source, out, identity, parent_identity = validated + uid, gid = os.getuid(), os.getgid() + docker = Docker(deadline) + preflight = docker.preflight(args.image, identity["architecture"]) + name = "antigravity-compat-" + uuid.uuid4().hex + with Output(out, uid, gid, parent_identity=parent_identity) as output: + output.dump("preflight.json", preflight) + output.dump("binary.json", dict(path=str(binary), **identity)) + command = docker_command(out, name, args.cases, image=preflight["image_id"], binary=binary, + uid=uid, gid=gid, env_keys=preflight["env_keys"], source=source, docker=docker.prefix) + output.dump("docker-command.json", command) + code, error, cleanup_errors = 1, None, [] + process, cid, owned, gone = None, None, False, False + launch_attempted = False + try: + with output.open("container.log") as log: + output.verify() + if binary_identity(binary) != identity: + raise ValueError("CLI changed after validation") + deadline.remaining() + launch_attempted = True + process = subprocess.Popen(command, env=docker.env, stdin=subprocess.DEVNULL, + stdout=log, stderr=subprocess.STDOUT) + while cid is None: + deadline.remaining() + try: + cid = output.read_cid() + except FileNotFoundError: + pass + if cid is None: + if process.poll() is not None: + raise ValueError("Docker exited without a complete invocation cidfile") + time.sleep(min(0.1, deadline.remaining())) + inspection = confirm(docker, cid, name, preflight["image_id"]) + gone = inspection is None + owned = not gone + output.dump("docker-inspect.json", inspection or {"id": cid, "absent": True}) + code = process.wait(timeout=deadline.remaining()) + if code: + error = "Container exited with status %s" % code + except (OSError, ValueError, KeyError, TypeError, subprocess.TimeoutExpired, KeyboardInterrupt) as exc: + code, error = failure(exc) + finally: + # Never recover an unverified name/label through a broad query. If an + # interrupt preceded cid confirmation, leave an explicit orphan warning. + if launch_attempted and not owned and not gone: + error = (error or "Unconfirmed container") + "; possible orphan: " + name + " (" + LABEL + "=" + name + ")" + code = code or 1 + if owned: + for args_cleanup in (["stop", "--time", "5", cid], ["rm", "--force", cid]): + try: + result = docker.call(args_cleanup, cleanup=True) + if absent(result, cid): + break + if result.returncode: + cleanup_errors.append("Owned container " + args_cleanup[0] + " failed") + except (OSError, ValueError, subprocess.TimeoutExpired, KeyboardInterrupt) as exc: + cleanup_errors.append(failure(exc)[1]) + if process is not None and process.poll() is None: + try: + process.terminate() + try: + process.wait(timeout=deadline.remaining(cleanup=True, cap=2)) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=deadline.remaining(cleanup=True, cap=2)) + except (OSError, subprocess.TimeoutExpired, KeyboardInterrupt) as exc: + cleanup_errors.append("Docker client wait: " + failure(exc)[1]) + code = code or (1 if cleanup_errors else 0) + result = {"exit": code, "error": error, "cleanup_errors": cleanup_errors, + "artifacts": str(out), "name": name, "container_id": cid, "owned": owned} + output.dump("result.json", result) + print(json.dumps(result)) + return code + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--image", required=True) + parser.add_argument("--claude-binary", required=True) + parser.add_argument("--out", required=True) + parser.add_argument("cases", nargs="+", choices=list(case_specs()) + ["matrix", "catalog-default"]) + args = parser.parse_args(argv) + try: + validated = validate(args) + except (OSError, ValueError, RuntimeError) as exc: + parser.error(str(exc)) + deadline = Deadline() + try: + return execute(args, validated, deadline) + except (OSError, ValueError, KeyError, TypeError, subprocess.TimeoutExpired, KeyboardInterrupt) as exc: + code, error = failure(exc) + print(json.dumps({"exit": code, "error": error}), file=sys.stderr) + return code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/antigravity_compatibility/test_codec.py b/tests/antigravity_compatibility/test_codec.py new file mode 100644 index 00000000..7b354125 --- /dev/null +++ b/tests/antigravity_compatibility/test_codec.py @@ -0,0 +1,348 @@ +"""Optional, synthetic Stage A tests; not a native provider implementation.""" +import copy +import json +import math +import unittest + +from codec import (PREFIX, ReplayError, canonical, decode_turn, decode_history, digest, project_turn, + response_json, response_sse, validate_session_id, mint_session_id) + +BINDING = {"profile_id": "synthetic-profile", "project_id": "synthetic-project", + "native_model": "gemini-4-argon-low"} + + +def native_fixture(): + return [ + {"role": "model", "parts": [ + {"thought": True, "text": "synthetic thought α", "thoughtSignature": "fake-thought-1"}, + {"text": "visible 雪"}, + {"thought": True, "text": " / synthetic thought β"}, + {"functionCall": {"name": "Read", "args": {"file_path": "/fixture/read.txt", "offset": 1.0}}, + "thoughtSignature": "fake-call-signed"}, + {"functionCall": {"name": "Read", "id": "native-second", "args": {"file_path": "/fixture/read.txt", "limit": 1}}}, + ], "unknown": {"preserve": [True, None, "é"]}}, + {"role": "model", "parts": [{"thoughtSignature": "fake-late-signature"}, {"text": ""}]}, + ] + + +class CodecTests(unittest.TestCase): + def test_single_leading_thought_exact_native_and_mapping(self): + native = native_fixture() + blocks = project_turn(native, BINDING) + self.assertEqual(blocks[0]["thinking"], "synthetic thought α / synthetic thought β") + self.assertEqual(sum(b["type"] == "thinking" for b in blocks), 1) + self.assertTrue(blocks[0]["signature"].startswith(PREFIX)) + decoded = decode_turn(blocks, BINDING) + self.assertEqual(json.dumps(decoded["contents"]), json.dumps(native)) + self.assertFalse(decoded["tool_mapping"][0]["native_id_present"]) + self.assertNotIn("native_id", decoded["tool_mapping"][0]) + self.assertEqual(decoded["tool_mapping"][1]["native_id"], "native-second") + self.assertNotIn("id", decoded["contents"][0]["parts"][3]["functionCall"]) + + def test_empty_carrier_never_fabricates_thought(self): + for parts in ([{"functionCall": {"name": "Read", "args": {"file_path": "/fixture/read.txt"}}, "thoughtSignature": "fake"}], + [{"text": "signed ordinary text", "thoughtSignature": "fake"}], + [{"thoughtSignature": "fake-signature-only"}]): + with self.subTest(parts=parts): + native = [{"role": "model", "parts": parts}] + blocks = project_turn(native, BINDING) + self.assertEqual(blocks[0]["thinking"], "") + self.assertEqual(decode_turn(blocks, BINDING)["contents"], native) + + def test_binary64_number_key_order_unicode_normalization(self): + self.assertEqual(canonical({"雪": [1, -0.0, 1e-3], "a": "é"}), + canonical({"a": "é", "雪": [1.0, 0, 0.001]})) + self.assertNotEqual(canonical("é"), canonical("é")) + self.assertNotEqual(canonical([1, 2]), canonical([2, 1])) + self.assertNotEqual(canonical(True), canonical(1)) + for value in (math.nan, math.inf, -math.inf, 2**53, -(2**53), float(2**53)): + with self.subTest(value=value), self.assertRaises(ReplayError): + canonical(value) + + def test_digest_excludes_only_block_signatures_not_tool_argument_keys(self): + blocks = project_turn(native_fixture(), BINDING) + metadata = decode_turn(blocks, BINDING) + bound_digest = lambda value: digest(value, BINDING, metadata["session_id"], metadata["thought_display"], metadata["tool_mapping"]) + altered = copy.deepcopy(blocks) + altered[0]["signature"] = "irrelevant-to-digest" + self.assertEqual(bound_digest(blocks), bound_digest(altered)) + tool = next(b for b in altered if b["type"] == "tool_use") + tool["input"]["signature"] = "must-be-hashed" + self.assertNotEqual(bound_digest(blocks), bound_digest(altered)) + altered[0]["thinking"] += "changed" + self.assertNotEqual(bound_digest(blocks), bound_digest(altered)) + + def test_adjacent_text_merging_on_both_sides_only(self): + native = [{"role": "model", "parts": [{"text": "a"}, {"text": "b"}]}] + blocks = project_turn(native, BINDING) + merged = [blocks[0], {"type": "text", "text": "ab"}] + self.assertEqual(decode_turn(merged, BINDING)["contents"], native) + + def test_negative_controls(self): + blocks = project_turn(native_fixture(), BINDING) + variants = [] + variants.append(blocks[1:]) + variants.append([blocks[0], blocks[0]] + blocks[1:]) + changed = copy.deepcopy(blocks) + next(b for b in changed if b["type"] == "tool_use")["input"]["offset"] = 2 + variants.append(changed) + changed = copy.deepcopy(blocks) + changed[2], changed[3] = changed[3], changed[2] + variants.append(changed) + changed = copy.deepcopy(blocks) + changed[0]["signature"] = PREFIX + "!not-base64!" + variants.append(changed) + for value in variants: + with self.subTest(value=value), self.assertRaises(ReplayError): + decode_turn(value, BINDING) + for key in BINDING: + with self.subTest(key=key), self.assertRaisesRegex(ReplayError, "binding"): + decode_turn(blocks, dict(BINDING, **{key: "foreign"})) + + def test_low_to_high_never_relaxes_native_model_binding(self): + blocks = project_turn(native_fixture(), BINDING) + with self.assertRaisesRegex(ReplayError, "binding"): + decode_turn(blocks, dict(BINDING, native_model="gemini-4-argon-high")) + + def test_unsafe_tool_numbers_fail_before_emission(self): + native = native_fixture() + native[0]["parts"][3]["functionCall"]["args"]["offset"] = 2**53 + with self.assertRaises(ReplayError): + project_turn(native, BINDING) + + def test_native_id_reuse_never_reuses_local_ids(self): + native = native_fixture() + native[0]["parts"][3]["functionCall"]["id"] = "native-second" + turns = [project_turn(native, BINDING, session_id="11") for _ in range(2)] + ids = [b["id"] for blocks in turns for b in blocks if b["type"] == "tool_use"] + self.assertEqual(len(set(ids)), 4) + self.assertEqual(decode_history(turns, BINDING)["session_id"], "11") + + def test_ambiguous_parallel_idless_calls_rejected(self): + native = native_fixture() + del native[0]["parts"][4]["functionCall"]["id"] + with self.assertRaisesRegex(ReplayError, "ambiguous"): + project_turn(native, BINDING) + + def test_bounds_and_malformed_json(self): + blocks = project_turn(native_fixture(), BINDING) + with self.assertRaisesRegex(ReplayError, "encoded"): + decode_turn(blocks, BINDING, max_encoded=20) + with self.assertRaisesRegex(ReplayError, "decoded"): + decode_turn(blocks, BINDING, max_decoded=20) + with self.assertRaisesRegex(ReplayError, "depth"): + canonical([[[[0]]]], max_depth=2) + + def test_unknown_field_on_merged_text_is_not_silently_dropped(self): + with self.assertRaisesRegex(ReplayError, "unknown"): + digest([{"type": "text", "text": "a"}, {"type": "text", "text": "b", "unknown": 1}], BINDING, "1", "full", []) + + def test_huge_integer_is_rejected_without_float_overflow(self): + with self.assertRaises(ReplayError): + canonical(2**4096) + + def test_exact_size_boundaries_and_tampered_mapping(self): + import base64 + blocks = project_turn(native_fixture(), BINDING) + encoded = blocks[0]["signature"][len(PREFIX):] + raw = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)) + decode_turn(blocks, BINDING, max_encoded=len(encoded), max_decoded=len(raw)) + with self.assertRaisesRegex(ReplayError, "encoded"): + decode_turn(blocks, BINDING, max_encoded=len(encoded) - 1) + with self.assertRaisesRegex(ReplayError, "decoded"): + decode_turn(blocks, BINDING, max_decoded=len(raw) - 1) + envelope = json.loads(raw) + envelope["tool_mapping"][0]["part_index"] += 1 + blocks[0]["signature"] = PREFIX + base64.urlsafe_b64encode(json.dumps(envelope).encode()).decode().rstrip("=") + with self.assertRaisesRegex(ReplayError, "mapping"): + decode_turn(blocks, BINDING) + + def test_separate_responses_have_distinct_message_ids(self): + first = response_json([{"type": "text", "text": "a"}], "synthetic") + second = response_json([{"type": "text", "text": "b"}], "synthetic") + self.assertNotEqual(first["id"], second["id"]) + + def test_json_sse_semantic_equivalence_fixture_only(self): + blocks = project_turn(native_fixture(), BINDING) + message = response_json(blocks, "synthetic-selector") + events = [json.loads(frame.split("data: ", 1)[1]) for frame in response_sse(message).decode().split("\n\n") if frame] + rebuilt = [] + for event in events: + if event["type"] == "content_block_start": + rebuilt.append(copy.deepcopy(event["content_block"])) + elif event["type"] == "content_block_delta": + block, delta = rebuilt[event["index"]], event["delta"] + for kind in ("text", "thinking", "signature"): + if kind in delta: + block[kind] = block.get(kind, "") + delta[kind] + if "partial_json" in delta: + block["input"] = json.loads(delta["partial_json"]) + self.assertEqual(rebuilt, message["content"]) + self.assertEqual(events[-1]["type"], "message_stop") + self.assertEqual(events[-2]["delta"]["stop_reason"], "tool_use") + + +class FinalV1Tests(unittest.TestCase): + def test_completed_parameterless_call_preserves_absent_args(self): + native = [{"role": "model", "parts": [{"functionCall": {"name": "empty"}, "thoughtSignature": "synthetic-signature"}]}] + recipe = [{"caller_name": "empty", "local_id": "toolu_parameterless_golden", "codec_paths": [], "root_wrapper": None}] + blocks = project_turn(native, BINDING, session_id="19", tool_projections=recipe) + self.assertEqual(blocks[1]["input"], {}) + restored = decode_turn(blocks, BINDING) + self.assertEqual(restored["contents"], native) + self.assertNotIn("args", restored["contents"][0]["parts"][0]["functionCall"]) + for value in (None, [], "", 1): + changed = copy.deepcopy(native) + changed[0]["parts"][0]["functionCall"]["args"] = value + with self.subTest(args=value), self.assertRaises(ReplayError): + project_turn(changed, BINDING, session_id="19") + for key in ("partialArgs", "willContinue"): + changed = copy.deepcopy(native) + changed[0]["parts"][0]["functionCall"][key] = True + with self.subTest(key=key), self.assertRaises(ReplayError): + project_turn(changed, BINDING, session_id="19") + # Optional synthetic-vector export from this unit test, never a CLI run. + import os + if os.environ.get("ANTIGRAVITY_UNIT_GOLDEN_STDOUT") == "1": + from codec import compact, digest_input + canonical_input = digest_input(blocks, BINDING, "19", "full", restored["tool_mapping"]) + print(compact({"name": "parameterless-omitted-args", "binding": BINDING, + "native_contents": native, "session_id": "19", "thought_display": "full", + "tool_projections": recipe, "projected_blocks": blocks, "envelope": restored, + "canonical_digest_input_utf8": canonical_input.decode("utf-8"), + "canonical_digest_input_hex": canonical_input.hex(), "sha256": restored["projection_digest"]})) + + @staticmethod + def wrapped_fixture(root=False): + args = {"json": json.dumps({"file_path": "/fixture/read.txt", "offset": 1.0})} if root else { + "file_path": json.dumps("/fixture/read.txt"), "offset": 1.0} + native = [{"role": "model", "parts": [ + {"thought": True, "text": "Genuine synthetic native thought 雪", "thoughtSignature": "raw-thought"}, + {"functionCall": {"name": "native_read_alias", "id": "reused-native-id", "args": args}, "thoughtSignature": "raw-call"}]}] + recipe = [{"caller_name": "Read", "codec_paths": [] if root else ["/file_path"], + "root_wrapper": "json" if root else None}] + return native, recipe + + @staticmethod + def replace_envelope(blocks, mutate): + import base64 + copied = copy.deepcopy(blocks) + encoded = copied[0]["signature"][len(PREFIX):] + envelope = json.loads(base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4))) + mutate(envelope) + copied[0]["signature"] = PREFIX + base64.urlsafe_b64encode(json.dumps(envelope).encode()).decode().rstrip("=") + return copied + + def test_session_id_canonical_nonzero_i64(self): + for value in ("1", "-1", "9223372036854775807", "-9223372036854775808"): + self.assertEqual(validate_session_id(value), value) + for value in ("0", "-0", "01", "-01", "+1", " 1", "1 ", "1.0", "1e0", "9223372036854775808", "-9223372036854775809", 1, None): + with self.subTest(value=value), self.assertRaises(ReplayError): + validate_session_id(value) + minted = [mint_session_id() for _ in range(20)] + self.assertEqual(len(set(minted)), 20) + for value in minted: + validate_session_id(value) + + def test_omitted_display_keeps_genuine_native_thought_and_codec_raw_strings(self): + native, recipe = self.wrapped_fixture() + original = json.dumps(native) + blocks = project_turn(native, BINDING, session_id="-37", thought_display="omitted", tool_projections=recipe) + self.assertEqual(blocks[0]["thinking"], "") + self.assertEqual(blocks[1]["name"], "Read") + self.assertEqual(blocks[1]["input"], {"file_path": "/fixture/read.txt", "offset": 1.0}) + result = decode_turn(blocks, BINDING, expected_session_id="-37") + self.assertEqual(result["thought_display"], "omitted") + self.assertEqual(result["session_id"], "-37") + self.assertEqual(result["tool_mapping"][0]["caller_name"], "Read") + self.assertEqual(result["tool_mapping"][0]["codec_paths"], ["/file_path"]) + self.assertIsNone(result["tool_mapping"][0]["root_wrapper"]) + self.assertEqual(json.dumps(result["contents"]), original) + self.assertEqual(json.dumps(native), original) + + def test_root_json_codec_and_full_display(self): + native, recipe = self.wrapped_fixture(root=True) + blocks = project_turn(native, BINDING, session_id="37", thought_display="full", tool_projections=recipe) + self.assertEqual(blocks[0]["thinking"], native[0]["parts"][0]["text"]) + self.assertEqual(blocks[1]["input"], {"file_path": "/fixture/read.txt", "offset": 1.0}) + result = decode_turn(blocks, BINDING) + self.assertEqual(result["contents"], native) + self.assertEqual(result["tool_mapping"][0]["root_wrapper"], "json") + + def test_metadata_session_display_mapping_bound_into_digest(self): + native, recipe = self.wrapped_fixture() + blocks = project_turn(native, BINDING, session_id="7", thought_display="omitted", tool_projections=recipe) + mutations = [lambda e: e.update(session_id="8"), lambda e: e.update(thought_display="full"), + lambda e: e["tool_mapping"][0].update(caller_name="Other"), + lambda e: e["tool_mapping"][0].update(local_id="toolu_changed"), + lambda e: e["tool_mapping"][0].update(codec_paths=[])] + for mutation in mutations: + with self.subTest(mutation=mutation), self.assertRaises(ReplayError): + decode_turn(self.replace_envelope(blocks, mutation), BINDING) + for key, value in (("name", "Other"), ("id", "toolu_changed"), ("input", {"file_path": "/fixture/changed.txt", "offset": 1})): + altered = copy.deepcopy(blocks) + altered[1][key] = value + with self.subTest(key=key), self.assertRaisesRegex(ReplayError, "digest"): + decode_turn(altered, BINDING) + + def test_conflicting_history_sessions_and_local_ids_fail(self): + first = project_turn(native_fixture(), BINDING, session_id="17") + second = project_turn(native_fixture(), BINDING, session_id="18") + with self.assertRaisesRegex(ReplayError, "session"): + decode_history([first, second], BINDING) + with self.assertRaisesRegex(ReplayError, "session"): + decode_turn(first, BINDING, expected_session_id="18") + with self.assertRaisesRegex(ReplayError, "local ID"): + decode_history([first, first], BINDING) + self.assertEqual(decode_history([first, project_turn(native_fixture(), BINDING, session_id="17")], BINDING)["session_id"], "17") + + def test_malformed_leaf_json_fails_before_projection(self): + for raw in ("bad json", "NaN", "1e309", "{\"x\":1,\"x\":2}", "9007199254740992", 123): + native, recipe = self.wrapped_fixture() + native[0]["parts"][1]["functionCall"]["args"]["file_path"] = raw + with self.subTest(raw=raw), self.assertRaises(ReplayError): + project_turn(native, BINDING, session_id="1", tool_projections=recipe) + + def test_root_wrapper_requires_exact_key_and_decoded_object(self): + bad_args = [{}, {"json": "{}", "extra": 1}, {"json": "[]"}, {"json": "null"}, {"json": "bad"}, {"json": 1}] + for args in bad_args: + native, recipe = self.wrapped_fixture(root=True) + native[0]["parts"][1]["functionCall"]["args"] = args + with self.subTest(args=args), self.assertRaises(ReplayError): + project_turn(native, BINDING, session_id="1", tool_projections=recipe) + native, recipe = self.wrapped_fixture(root=True) + recipe[0]["codec_paths"] = ["/json"] + with self.assertRaises(ReplayError): + project_turn(native, BINDING, session_id="1", tool_projections=recipe) + + def test_codec_paths_are_concrete_nonoverlapping_bounded_argument_pointers(self): + args = {"file_path": '"/fixture/read.txt"', "payload": {"x": "1"}, "array": ["1"], "a/b": {"~key": ['{\"x\":1,\"s\":\"雪\"}']}} + bad_paths = [["/missing"], ["/file_path", "/file_path"], ["/payload", "/payload/x"], + ["/array/9"], ["/array/-"], ["/array/01"], ["/a~2b"], [""], ["file_path"], + ["/functionCall/args"], ["/x" + str(i) for i in range(65)]] + for paths in bad_paths: + native, recipe = self.wrapped_fixture() + native[0]["parts"][1]["functionCall"]["args"] = copy.deepcopy(args) + recipe[0]["codec_paths"] = paths + with self.subTest(paths=paths), self.assertRaises(ReplayError): + project_turn(native, BINDING, session_id="1", tool_projections=recipe) + native, recipe = self.wrapped_fixture() + native[0]["parts"][1]["functionCall"]["args"] = copy.deepcopy(args) + recipe[0]["codec_paths"] = ["/a~1b/~0key/0"] + blocks = project_turn(native, BINDING, session_id="1", tool_projections=recipe) + self.assertEqual(blocks[1]["input"]["a/b"]["~key"][0], {"x": 1, "s": "雪"}) + self.assertEqual(decode_turn(blocks, BINDING)["contents"], native) + + def test_invalid_display_and_mapping_recipes_are_rejected(self): + native, recipe = self.wrapped_fixture() + for display in ("hidden", "", None): + with self.subTest(display=display), self.assertRaises(ReplayError): + project_turn(native, BINDING, session_id="1", thought_display=display, tool_projections=recipe) + for changed in ([], [{"caller_name": "", "codec_paths": [], "root_wrapper": None}], + [{"caller_name": "Read", "codec_paths": [], "root_wrapper": None, "unknown": 1}]): + with self.subTest(recipe=changed), self.assertRaises(ReplayError): + project_turn(native, BINDING, session_id="1", tool_projections=changed) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/antigravity_compatibility/test_harness.py b/tests/antigravity_compatibility/test_harness.py new file mode 100644 index 00000000..8668a8db --- /dev/null +++ b/tests/antigravity_compatibility/test_harness.py @@ -0,0 +1,357 @@ +"""Safety/observation tests written before the executable harness.""" +import unittest +from pathlib import Path + +from harness import (cli_command, clean_env, first_assistant_turn, assistant_turns, wire_fields, + reserialize_row, numeric_json, fixture, stream_cli_command, + expected_tool_error_retained, schema_inventory, compaction_status) +from codec import canonical +from run_container import docker_command + + +class HarnessTests(unittest.TestCase): + def test_assistant_normalization_preserves_original_system_boundaries(self): + from codec import project_turn, decode_history, ReplayError + from test_codec import BINDING + native = [{"role": "model", "parts": [{"text": "answer", "thoughtSignature": "synthetic"}]}] + blocks = project_turn(native, BINDING, session_id="19") + messages = [{"role": "assistant", "content": [blocks[0]]}, + {"role": "assistant", "content": "answer"}] + self.assertEqual(decode_history(assistant_turns(messages), BINDING)["turns"][0]["contents"], native) + self.assertEqual(assistant_turns([{"role": "assistant", "content": []}] + messages), assistant_turns(messages)) + messages.insert(1, {"role": "system", "content": "real boundary"}) + self.assertEqual(len(assistant_turns(messages)), 2) + with self.assertRaises(ReplayError): + decode_history(assistant_turns(messages), BINDING) + + def test_distinct_adjacent_complete_turns_keep_their_carriers(self): + from codec import project_turn, decode_history + from test_codec import BINDING + native = [{"role": "model", "parts": [{"text": "answer"}]}] + blocks = project_turn(native, BINDING, session_id="19") + turns = assistant_turns([{"role": "assistant", "content": blocks}, + {"role": "assistant", "content": blocks}, + {"role": "user", "content": "ordinary user text"}]) + self.assertEqual(len(turns), 2) + self.assertEqual(len(decode_history(turns, BINDING)["turns"]), 2) + + def test_assistant_fragment_block_bound_is_cumulative(self): + from codec import ReplayError + with self.assertRaises(ReplayError): + assistant_turns([{"role": "assistant", "content": [{"type": "text", "text": "x"}] * 4097}, + {"role": "assistant", "content": "one more"}]) + + def test_turn_extraction_never_repairs_missing_carrier(self): + messages = [{"role": "user", "content": "fixture"}, + {"role": "assistant", "content": [{"type": "text", "text": "one"}]}, + {"role": "assistant", "content": [{"type": "tool_use", "id": "local"}]}, + {"role": "user", "content": [{"type": "tool_result"}]}, + {"role": "assistant", "content": [{"type": "thinking", "signature": "later"}]}] + blocks = first_assistant_turn(messages) + self.assertEqual([b["type"] for b in blocks], ["text", "tool_use"]) + + def test_wire_observations_do_not_invent_omitted_fields(self): + fields = wire_fields({"model": "antigravity/gemini-4-argon-low[1m]", "stream": True, + "output_config": {"effort": "high"}}) + self.assertEqual(fields["model"]["value"], "antigravity/gemini-4-argon-low[1m]") + self.assertEqual(fields["output_config.effort"]["value"], "high") + self.assertEqual(fields["thinking"], {"present": False}) + self.assertEqual(fields["tool_choice"], {"present": False}) + self.assertEqual(fields["top_level_keys"], ["model", "output_config", "stream"]) + self.assertEqual(fields["output_config.format"], {"present": False}) + extra = wire_fields({"synthetic_unknown_semantic": True, "output_config": {"format": {"type": "json_schema"}}}) + self.assertIn("synthetic_unknown_semantic", extra["top_level_keys"]) + self.assertEqual(extra["output_config.format"], {"present": True, "value": {"type": "json_schema"}}) + + def test_no_inherited_environment_and_no_permission_bypass(self): + env = clean_env(Path("/out/case"), 12345) + self.assertEqual(env["ANTHROPIC_API_KEY"], "stage-a-fake-key-not-a-credential") + self.assertEqual(env["ANTHROPIC_BASE_URL"], "http://127.0.0.1:12345") + self.assertNotIn("ANTHROPIC_AUTH_TOKEN", env) + self.assertNotIn("HTTP_PROXY", env) + cmd = cli_command("antigravity/gemini-4-argon-low", {}, "synthetic", session_id="test-id") + self.assertIn("--bare", cmd) + self.assertEqual(cmd[cmd.index("--tools") + 1], "Read") + self.assertIn("Read(//fixture/read.txt)", cmd) + self.assertNotIn("--dangerously-skip-permissions", cmd) + self.assertNotIn("bypassPermissions", cmd) + + def test_stream_input_keeps_isolation_and_uses_no_positional_prompt(self): + cmd = stream_cli_command("synthetic", {}, "synthetic-session") + self.assertEqual(cmd[cmd.index("--input-format") + 1], "stream-json") + self.assertEqual(cmd[cmd.index("--output-format") + 1], "stream-json") + self.assertNotIn("--", cmd) + self.assertIn("--bare", cmd) + self.assertIn("Read(//fixture/read.txt)", cmd) + + def test_synthetic_reserialization_changes_only_tool_input_representation(self): + row = {"uuid": "synthetic", "message": {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "", "signature": "unchanged"}, + {"type": "tool_use", "id": "local", "name": "Read", "input": {"z": [1, {"雪": -0.0, "a": True}], "a": 2}} + ]}} + changed = reserialize_row(row) + self.assertEqual(canonical(changed), canonical(row)) + self.assertEqual(changed["message"]["content"][0], row["message"]["content"][0]) + self.assertEqual(list(changed["message"]["content"][1]["input"]), ["a", "z"]) + self.assertIsInstance(changed["message"]["content"][1]["input"]["a"], float) + self.assertIsInstance(row["message"]["content"][1]["input"]["a"], int) + + def test_exponent_wire_lexemes_are_numerically_equivalent(self): + import json + value = {"z": [1.0, 0.0, 0.001, {"雪": "é"}], "a": True} + wire = numeric_json(value) + self.assertIn("e", wire) + self.assertEqual(canonical(json.loads(wire)), canonical(value)) + + def test_valid_numeric_read_uses_only_schema_parameters(self): + native = fixture("numeric_valid") + args = next(p["functionCall"]["args"] for c in native for p in c["parts"] if "functionCall" in p) + self.assertEqual(set(args), {"file_path", "offset", "limit"}) + + @unittest.skipUnless(Path("/opt/fixture-cli").is_file(), "HTTP fixture tests run only inside offline container") + def test_local_http_nonstream_and_sse_fixture_formats(self): + import http.client + import json + import tempfile + import threading + from harness import FixtureServer + from codec import project_turn + from test_codec import BINDING, native_fixture + with tempfile.TemporaryDirectory() as tmp: + expected = project_turn(native_fixture(), BINDING) + for stream in (False, True): + server = FixtureServer(Path(tmp), expected) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + connection = http.client.HTTPConnection("127.0.0.1", server.server_port, timeout=5) + connection.request("POST", "/v1/messages", json.dumps({"model": "synthetic", "stream": stream}), {"Content-Type": "application/json"}) + response = connection.getresponse() + raw = response.read() + self.assertEqual(response.status, 200) + if stream: + self.assertIn(b"event: message_stop", raw) + self.assertEqual(response.getheader("Content-Type"), "text/event-stream") + else: + self.assertEqual(json.loads(raw)["content"], expected) + self.assertEqual(response.getheader("Content-Type"), "application/json") + connection.close() + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + def test_expected_tool_error_requires_error_and_exact_replay(self): + import copy + good = [{"decoded": True, "native_exact": True, "tool_results": [ + {"id": "toolu_stagea_0000", "is_error": True, "fixture_read": False}]}] + self.assertTrue(expected_tool_error_retained(good)) + self.assertFalse(expected_tool_error_retained([])) + for mutation in ("decoded", "native_exact", "is_error", "missing_result"): + bad = copy.deepcopy(good) + if mutation in ("decoded", "native_exact"): + bad[0][mutation] = False + elif mutation == "is_error": + bad[0]["tool_results"][0]["is_error"] = False + else: + bad[0]["tool_results"] = [] + with self.subTest(mutation=mutation): + self.assertFalse(expected_tool_error_retained(bad)) + + def test_nonbare_command_requires_session_hook_disable(self): + import json + cmd = cli_command("synthetic", {"disableAllHooks": True}, "/compact", bare=False) + self.assertNotIn("--bare", cmd) + self.assertNotIn("--disable-slash-commands", cmd) + self.assertTrue(json.loads(cmd[cmd.index("--settings") + 1])["disableAllHooks"]) + self.assertIn("--strict-mcp-config", cmd) + self.assertIn("Read(//fixture/read.txt)", cmd) + self.assertEqual(cmd[cmd.index("--setting-sources") + 1], "") + with self.assertRaises(ValueError): + cli_command("synthetic", {}, "/compact", bare=False) + + @unittest.skipUnless(Path("/opt/fixture-cli").is_file(), "HTTP fixture tests run only inside offline container") + def test_http_replay_rejection_precedes_generation_callback(self): + import http.client + import json + import tempfile + import threading + from harness import FixtureServer, BINDING, LOW + from codec import project_turn, decode_turn + from test_codec import native_fixture + with tempfile.TemporaryDirectory() as tmp: + blocks = project_turn(native_fixture(), BINDING) + wrong_model = project_turn(native_fixture(), dict(BINDING, native_model="gemini-4-argon-high")) + server = FixtureServer(Path(tmp), blocks, enforce_replay=True, signed_completions=True) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + def post(content): + connection = http.client.HTTPConnection("127.0.0.1", server.server_port, timeout=5) + messages = [{"role": "user", "content": "synthetic"}] + if content is not None: + messages += [{"role": "assistant", "content": content}, {"role": "user", "content": "continue"}] + connection.request("POST", "/v1/messages", json.dumps({"model": LOW, "stream": False, "messages": messages}), {"Content-Type": "application/json"}) + response = connection.getresponse() + status, body = response.status, json.loads(response.read()) + connection.close() + return status, body + try: + self.assertEqual(post(None)[0], 200) + self.assertEqual(len(server.native_generation_events), 1) + for invalid in (blocks[1:], wrong_model): + status, body = post(invalid) + self.assertEqual(status, 400) + self.assertEqual(body["error"]["type"], "invalid_request_error") + self.assertIn("replay rejected", body["error"]["message"]) + self.assertEqual(len(server.native_generation_events), 1) + status, completion = post(blocks) + self.assertEqual(status, 200) + restored = decode_turn(completion["content"], BINDING) + self.assertEqual(restored["contents"][0]["parts"][0]["text"], "SYNTHETIC_STAGE_A_DONE") + self.assertIn("thoughtSignature", restored["contents"][0]["parts"][0]) + self.assertEqual(completion["content"][0]["thinking"], "") + self.assertEqual(len(server.native_generation_events), 2) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + def test_compaction_case_forwards_signed_completion_flag(self): + import tempfile + from unittest.mock import patch + from harness import run_case, case_specs + with tempfile.TemporaryDirectory() as tmp: + with patch("harness.FixtureServer", side_effect=RuntimeError("stop before launching")) as server: + with self.assertRaisesRegex(RuntimeError, "stop before launching"): + run_case("compact-nonbare", case_specs()["compact-nonbare"], Path(tmp)) + self.assertIs(server.call_args.kwargs.get("signed_completions"), True) + + def test_compaction_oracle_requires_real_boundary_and_signed_history(self): + lost = [{"history_present": True, "decoded": False, "carrier_count": 0}] + self.assertEqual(compaction_status(0, lost, True), "UNVERIFIED") + self.assertEqual(compaction_status(1, lost, False), "UNVERIFIED") + self.assertEqual(compaction_status(1, lost, True), "UNSUPPORTED") + retained = [{"history_present": True, "decoded": True, "matches_signed_completion": True}] + self.assertEqual(compaction_status(1, retained, True), "PASS") + retained[0]["matches_signed_completion"] = False + self.assertEqual(compaction_status(1, retained, True), "UNVERIFIED") + + def test_catalog_default_advertisement_does_not_allow_extra_execution(self): + cmd = cli_command("synthetic", {"disableAllHooks": True}, "catalog only", bare=False, tools="default") + self.assertEqual(cmd[cmd.index("--tools") + 1], "default") + self.assertEqual(cmd[cmd.index("--allowedTools") + 1], "Read(//fixture/read.txt)") + self.assertEqual(cmd[cmd.index("--permission-mode") + 1], "dontAsk") + self.assertEqual(cmd[cmd.index("--permission-prompts") + 1], "none") + + def test_schema_inventory_distinguishes_keywords_from_property_names(self): + tools = [{"name": "Synthetic", "description": "local", "input_schema": { + "type": "object", "properties": {"not_a_keyword": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "type": {"type": "array", "items": {"$ref": "#/$defs/item"}}}, + "$defs": {"item": {"type": "integer", "minimum": 0}}, + "additionalProperties": False, "required": ["not_a_keyword"], + "default": {"not_a_schema_keyword": 1}}}] + inventory = schema_inventory(tools) + self.assertEqual(inventory["tool_names"], ["Synthetic"]) + self.assertEqual(inventory["tool_type_fields"], [{"name": "Synthetic", "present": False}]) + self.assertNotIn("not_a_keyword", inventory["schema_keywords"]) + self.assertNotIn("not_a_schema_keyword", inventory["schema_keywords"]) + self.assertEqual(inventory["schema_keywords"]["type"], 5) + self.assertEqual(inventory["schema_types"], ["array", "integer", "null", "object", "string"]) + self.assertIn("$ref", inventory["schema_keywords"]) + + @unittest.skipUnless(Path("/opt/fixture-cli").is_file(), "HTTP fixture tests run only inside offline container") + def test_http_catalog_cannot_emit_fixture_tool_calls(self): + import http.client + import json + import tempfile + import threading + from harness import FixtureServer, BINDING, LOW + from codec import project_turn + from test_codec import native_fixture + with tempfile.TemporaryDirectory() as tmp: + server = FixtureServer(Path(tmp), project_turn(native_fixture(), BINDING), catalog_only=True) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + connection = http.client.HTTPConnection("127.0.0.1", server.server_port, timeout=5) + connection.request("POST", "/v1/messages", json.dumps({"model": LOW, "stream": False}), {"Content-Type": "application/json"}) + response = connection.getresponse() + body = json.loads(response.read()) + self.assertEqual(response.status, 200) + self.assertEqual(body["stop_reason"], "end_turn") + self.assertTrue(all(b["type"] == "text" for b in body["content"])) + connection.close() + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + @unittest.skipUnless(Path("/opt/fixture-cli").is_file(), "HTTP fixture tests run only inside offline container") + def test_final_v1_server_uses_wire_display_and_validated_history_session(self): + import http.client + import json + import tempfile + import threading + from harness import FixtureServer, BINDING, LOW + from codec import project_turn, decode_turn + from test_codec import FinalV1Tests + native, recipe = FinalV1Tests.wrapped_fixture() + with tempfile.TemporaryDirectory() as tmp: + server = FixtureServer(Path(tmp), [], enforce_replay=True, signed_completions=True, + initial_native=native, tool_projections=recipe, native_session_id="123") + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + def post(messages): + c = http.client.HTTPConnection("127.0.0.1", server.server_port, timeout=5) + c.request("POST", "/v1/messages", json.dumps({"model": LOW, "stream": False, + "thinking": {"type": "adaptive", "display": "omitted"}, "messages": messages}), {"Content-Type": "application/json"}) + response = c.getresponse() + result = response.status, json.loads(response.read()) + c.close() + return result + try: + messages = [{"role": "user", "content": "synthetic"}] + status, first = post(messages) + self.assertEqual(status, 200) + self.assertEqual(first["content"][0]["thinking"], "") + self.assertEqual(first["content"][1]["name"], "Read") + self.assertEqual(first["content"][1]["input"]["file_path"], "/fixture/read.txt") + restored = decode_turn(first["content"], BINDING) + self.assertEqual(restored["session_id"], "123") + self.assertEqual(restored["contents"], native) + server.native_session_id = "999" # Must NOT override the validated history session. + messages += [{"role": "assistant", "content": first["content"]}, {"role": "user", "content": "continue"}] + status, second = post(messages) + self.assertEqual(status, 200) + self.assertEqual(decode_turn(second["content"], BINDING)["session_id"], "123") + conflicting = project_turn(native, BINDING, session_id="124", tool_projections=recipe) + messages += [{"role": "assistant", "content": conflicting}, {"role": "user", "content": "continue"}] + status, error = post(messages) + self.assertEqual(status, 400) + self.assertIn("session", error["error"]["message"]) + self.assertEqual(len(server.native_generation_events), 2) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + def test_container_boundaries(self): + command = docker_command(Path("/out/new"), "owned-stage-a", ["minimal"], + image="sha256:" + "a" * 64, binary=Path("/synthetic/native"), + uid=2345, gid=3456) + self.assertEqual(command[command.index("--network") + 1], "none") + self.assertEqual(command[command.index("--user") + 1], "2345:3456") + self.assertIn("--read-only", command) + self.assertNotIn("--privileged", command) + self.assertNotIn("--pid", command) # Docker default is a private PID namespace. + self.assertNotIn("--publish", command) + mounts = [command[i + 1] for i, v in enumerate(command) if v == "--mount"] + self.assertEqual(len(mounts), 3) + self.assertTrue(any("target=/opt/fixture-cli,readonly" in m for m in mounts)) + self.assertTrue(any("target=/fixture,readonly" in m for m in mounts)) + self.assertTrue(any("target=/out" in m for m in mounts)) + self.assertFalse(any("docker.sock" in m for m in mounts)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/antigravity_compatibility/test_run_container.py b/tests/antigravity_compatibility/test_run_container.py new file mode 100644 index 00000000..08724a3c --- /dev/null +++ b/tests/antigravity_compatibility/test_run_container.py @@ -0,0 +1,517 @@ +"""Launcher contract tests: every process is a fail-closed stub, never Docker/Claude.""" +import contextlib +import io +import json +import os +from pathlib import Path +import stat +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +import harness +import run_container as launcher + +IMAGE = "sha256:" + "a" * 64 +CID = "b" * 64 +FOREIGN = "c" * 64 + + +class DockerStub: + def __init__(self, test): + self.test = test + self.calls = [] + self.envs = [] + self.command = None + self.code = 0 + self.wait_error = None + self.inspect_error = None + self.stop_error = False + self.mismatch = None + self.cid = CID + self.absent = False + self.before_run = None + self.context_host = "unix:///var/run/docker.sock" + self.image = {"Id": IMAGE, "Os": "linux", "Architecture": "amd64", "Config": { + "Env": ["PATH=/malicious", "ANTHROPIC_API_KEY=image-secret", "LD_PRELOAD=/evil.so"], + "Volumes": None, "Entrypoint": ["/evil"], "WorkingDir": "/evil", + "Healthcheck": {"Test": ["CMD", "/evil"]}}} + self.info = {"OSType": "linux", "SecurityOptions": ["name=seccomp,profile=builtin"]} + self.version = {"Server": {"Os": "linux", "Platform": {"Name": "Docker Engine - Community"}, + "Components": [{"Name": "Engine"}]}} + self.failure = None + self.terminated = False + + def run(self, argv, **kwargs): + self.test.assertIsInstance(argv, list) + self.test.assertGreater(kwargs["timeout"], 0) + self.test.assertLessEqual(kwargs["timeout"], launcher.TOTAL_SECONDS) + self.calls.append(argv) + self.envs.append(kwargs["env"]) + args = argv[1:] + if args[:1] == ["--host"]: + args = args[2:] + if self.failure == args[:2]: + return subprocess.CompletedProcess(argv, 1, "", "synthetic failure") + if args == ["context", "show"]: + value = "selected\n" + elif args[:2] == ["context", "inspect"]: + value = json.dumps([{"Name": args[2], "Endpoints": {"docker": {"Host": self.context_host}}}]) + elif args == ["version", "--format", "{{json .}}"]: + value = json.dumps(self.version) + elif args == ["info", "--format", "{{json .}}"]: + value = json.dumps(self.info) + elif args[:2] == ["image", "inspect"]: + self.test.assertFalse(self.test.out.exists()) + value = json.dumps([self.image]) + if self.before_run: + self.before_run() + elif args[:2] == ["container", "inspect"]: + self.test.assertEqual(args[2], CID) + if self.inspect_error: + raise self.inspect_error + if self.absent: + return subprocess.CompletedProcess(argv, 1, "", "Error: No such container: " + CID) + name = self.command[self.command.index("--name") + 1] + label = self.command[self.command.index("--label") + 1].split("=", 1) + row = {"Id": CID, "Name": "/" + name, "Image": IMAGE, + "Config": {"Labels": {label[0]: label[1]}}, "State": {"Running": False}} + if self.mismatch == "id": + row["Id"] = FOREIGN + if self.mismatch == "image": + row["Image"] = "sha256:" + FOREIGN + if self.mismatch == "label": + row["Config"]["Labels"][label[0]] = "foreign" + if self.mismatch == "name": + row["Name"] = "/foreign" + value = json.dumps([row]) + elif args[:1] in (["stop"], ["rm"]): + self.test.assertEqual(args[-1], CID) + if self.stop_error: + return subprocess.CompletedProcess(argv, 1, "", "synthetic cleanup failure") + value = CID + else: + raise AssertionError("Unexpected subprocess: " + repr(argv)) + return subprocess.CompletedProcess(argv, 0, value, "") + + def popen(self, argv, **kwargs): + self.test.assertIsNone(self.command, "Only one run may be launched") + self.command = argv + self.envs.append(kwargs["env"]) + self.test.assertIn("run", argv) + cidfile = Path(argv[argv.index("--cidfile") + 1]) + self.test.assertEqual(cidfile.parent, self.test.out / "control") + if self.cid == "symlink": + cidfile.symlink_to(self.test.binary) + elif self.cid is not None: + cidfile.write_text(self.cid + "\n") + return self + + def poll(self): + return None if self.wait_error and not self.terminated else self.code + + def wait(self, timeout): + self.test.assertGreater(timeout, 0) + if self.wait_error and not self.terminated: + error, self.wait_error = self.wait_error, None + raise error + return self.code + + def terminate(self): + self.terminated = True + + def kill(self): + self.terminated = True + + +class LauncherTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.out = self.root / "antigravity-compatibility-unit" + self.binary = self.root / "synthetic-native" + # ELF-shaped but deliberately incomplete: never a runnable program. + self.binary.write_bytes(b"\x7fELF\x02\x01\x01\0" + b"\0" * 8 + b"\x02\0\x3e\0" + b"\0" * 44) + self.binary.chmod(0o700) + self.stub = DockerStub(self) + self.stack = contextlib.ExitStack() + self.addCleanup(self.stack.close) + self.stack.enter_context(patch.object(launcher.subprocess, "run", side_effect=self.stub.run)) + self.stack.enter_context(patch.object(launcher.subprocess, "Popen", side_effect=self.stub.popen)) + self.stack.enter_context(patch("os.system", side_effect=AssertionError("No shell"))) + # The test container's /tmp is noexec. Model access from permission bits; + # keep both the mount and deliberately incomplete ELF non-runnable. + self.stack.enter_context(patch.object(launcher.os, "access", side_effect=lambda path, mode: + stat.S_IMODE(Path(path).stat().st_mode) & 0o500 == 0o500)) + self.stack.enter_context(patch.dict(os.environ, { + "PATH": "/usr/bin:/bin", "HOME": str(self.root), + "ANTHROPIC_API_KEY": "host-secret", "HTTP_PROXY": "host-proxy"}, clear=True)) + self.stdout = self.stack.enter_context(contextlib.redirect_stdout(io.StringIO())) + self.stack.enter_context(contextlib.redirect_stderr(io.StringIO())) + + def args(self, **changes): + values = {"image": "example/fixture:local", "claude-binary": str(self.binary), "out": str(self.out)} + values.update(changes) + result = [] + for key, value in values.items(): + if value is not None: + result += ["--" + key, value] + return result + ["minimal"] + + def invoke(self, args=None): + return launcher.main(self.args() if args is None else args) + + def invalid(self, args=None): + with patch.object(launcher.os, "mkdir", side_effect=AssertionError("No mkdir for invalid input")): + with self.assertRaises((SystemExit, ValueError)): + self.invoke(args) + self.assertFalse(self.out.exists()) + self.assertEqual(self.stub.calls, []) + self.assertIsNone(self.stub.command) + + def test_explicit_configuration_required(self): + for key in ("image", "claude-binary"): + with self.subTest(key=key): + self.invalid(self.args(**{key: None})) + + def test_invalid_image_references_have_no_side_effects(self): + for image in ("--privileged", "latest", "repo", "repo:", "repo:tag extra", "repo@sha256:abc", "repo:tag\n"): + with self.subTest(image=image): + self.invalid(self.args(image=image)) + + def test_invalid_cases_have_no_side_effects(self): + for cases in (["../outside"], ["minimal", "minimal"], ["catalog-default", "minimal"], ["--privileged"]): + with self.subTest(cases=cases): + self.invalid(self.args()[:-1] + cases) + + def test_identity_rejects_root_privilege_mismatch_and_nonlinux(self): + for names in ((0, 0, 1, 1), (1, 2, 3, 3), (1, 1, 0, 0), (1, 1, 3, 4)): + with self.subTest(identity=names), contextlib.ExitStack() as stack: + for fn, value in zip(("getuid", "geteuid", "getgid", "getegid"), names): + stack.enter_context(patch.object(os, fn, return_value=value)) + self.invalid() + with patch.object(launcher.sys, "platform", "darwin"): + self.invalid() + + def test_binary_must_be_absolute_readable_executable_regular_native_elf(self): + self.invalid(self.args(**{"claude-binary": "relative"})) + self.invalid(self.args(**{"claude-binary": str(self.root)})) + self.invalid(self.args(**{"claude-binary": str(self.root / "missing")})) + self.binary.chmod(0o600) + self.invalid() + self.binary.chmod(0o700) + self.binary.write_text("#!/usr/bin/env node\n") + self.invalid() + self.binary.unlink() + os.mkfifo(self.binary) + self.invalid() + + def test_output_must_be_new_normalized_absolute_with_real_existing_parent(self): + for out in ("antigravity-compatibility-unit", str(self.root / "wrong"), + str(self.root / "missing" / self.out.name), str(self.root) + "/../" + self.root.name + "/" + self.out.name): + with self.subTest(out=out): + self.invalid(self.args(out=out)) + alias = self.root / "alias" + alias.symlink_to(self.root, target_is_directory=True) + self.invalid(self.args(out=str(alias / self.out.name))) + self.out.symlink_to(self.root / "missing") + with self.assertRaises((SystemExit, ValueError)): + self.invoke() + self.assertTrue(self.out.is_symlink()) + self.assertEqual(self.stub.calls, []) + + def test_existing_output_is_not_modified(self): + self.out.mkdir() + marker = self.out / "marker" + marker.write_text("preserve") + with self.assertRaises((SystemExit, ValueError)): + self.invoke() + self.assertEqual(marker.read_text(), "preserve") + self.assertEqual(self.stub.calls, []) + + def test_mount_delimiters_rejected_but_colon_and_binary_symlink_supported(self): + for char in (",", '"', "\n", "\x7f"): + self.invalid(self.args(out=str(self.root / (self.out.name + char)))) + bad = self.root / ("native" + char) + bad.symlink_to(self.binary) + self.invalid(self.args(**{"claude-binary": str(bad)})) + alias = self.root / "native:alias" + alias.symlink_to(self.binary) + self.assertEqual(self.invoke(self.args(**{"claude-binary": str(alias)})), 0) + mounts = self.mounts() + self.assertIn("source=" + str(self.binary) + ",target=/opt/fixture-cli,readonly", " ".join(mounts)) + + def test_overlap_and_unsafe_fixture_path_fail_before_docker(self): + with patch.object(launcher, "SOURCE", self.root): + self.invalid() + with patch.object(launcher, "SOURCE", self.root / 'bad,"'): + self.invalid() + + def mounts(self): + cmd = self.stub.command + return [cmd[i + 1] for i, arg in enumerate(cmd) if arg == "--mount"] + + def result(self): + return json.loads((self.out / "control" / "result.json").read_text()) + + def test_success_uses_pinned_image_clean_env_and_host_only_control(self): + self.assertEqual(self.invoke(), 0) + cmd = self.stub.command + self.assertIn(IMAGE, cmd) + self.assertNotIn("example/fixture:local", cmd) + for option, value in (("--pull", "never"), ("--user", "%s:%s" % (os.getuid(), os.getgid())), + ("--network", "none"), ("--ipc", "private"), ("--workdir", "/fixture"), + ("--entrypoint", "/usr/bin/env"), ("--cap-drop", "ALL")): + self.assertEqual(cmd[cmd.index(option) + 1], value) + for option in ("--read-only", "--init", "--no-healthcheck"): + self.assertIn(option, cmd) + for option in ("-v", "--privileged", "--pid", "--env-file", "--publish"): + self.assertNotIn(option, cmd) + self.assertIn("-i", cmd) + self.assertEqual(cmd[-4:], ["python3", "-B", "/fixture/harness.py", "minimal"]) + self.assertIn("HOME=/fixture-home", cmd) + self.assertIn("/fixture-home:rw,nosuid,nodev,uid=%s,gid=%s,mode=0700" % (os.getuid(), os.getgid()), cmd) + self.assertEqual(len(self.mounts()), 3) + self.assertIn("type=bind,source=" + str(self.out / "work") + ",target=/out", self.mounts()) + self.assertFalse(any("/control" in mount or "docker.sock" in mount for mount in self.mounts())) + for directory in (self.out, self.out / "control", self.out / "work"): + self.assertEqual(stat.S_IMODE(directory.stat().st_mode), 0o700) + self.assertIn("ANTHROPIC_API_KEY=", cmd) + self.assertIn("LD_PRELOAD=", cmd) + for env in self.stub.envs: + self.assertNotIn("ANTHROPIC_API_KEY", env) + self.assertNotIn("HTTP_PROXY", env) + self.assertLessEqual(set(env), {"PATH", "DOCKER_CONFIG", "DOCKER_HOST", "DOCKER_CONTEXT", + "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH", "DOCKER_API_VERSION"}) + for path in (self.out / "control").iterdir(): + if path.is_file(): + self.assertNotIn("image-secret", path.read_text()) + self.assertNotIn("host-secret", path.read_text()) + self.assertEqual(self.result()["exit"], 0) + + def test_context_overrides_host_and_endpoint_is_pinned(self): + with patch.dict(os.environ, {"DOCKER_CONTEXT": "chosen", "DOCKER_HOST": "tcp://remote:2375"}): + self.assertEqual(self.invoke(), 0) + self.assertIn(["docker", "context", "inspect", "chosen"], self.stub.calls) + self.assertEqual(self.stub.command[:3], ["docker", "--host", "unix:///var/run/docker.sock"]) + self.assertNotIn("DOCKER_CONTEXT", self.stub.envs[-1]) + + def test_remote_effective_host_rejected_before_daemon_and_output(self): + with patch.dict(os.environ, {"DOCKER_HOST": "tcp://remote:2375"}): + self.assertNotEqual(self.invoke(), 0) + self.assertFalse(self.out.exists()) + self.assertEqual(self.stub.calls, []) + + def test_remote_context_missing_image_and_unknown_engine_fail_before_output(self): + for change in ("remote", "image", "rootless", "podman", "volumes", "bad-env", "architecture"): + with self.subTest(change=change): + old = self.stub + self.stub.context_host = "ssh://remote" if change == "remote" else "unix:///var/run/docker.sock" + if change == "image": + self.stub.failure = ["image", "inspect"] + if change == "rootless": + self.stub.info["SecurityOptions"] = ["name=rootless"] + if change == "podman": + self.stub.version["Server"]["Platform"]["Name"] = "Podman" + if change == "volumes": + self.stub.image["Config"]["Volumes"] = {"/leak": {}} + if change == "bad-env": + self.stub.image["Config"]["Env"] = ["invalid-key=x"] + if change == "architecture": + self.stub.image["Architecture"] = "arm64" + self.assertNotEqual(self.invoke(), 0) + self.assertFalse(self.out.exists()) + self.assertIsNone(self.stub.command) + replacement = DockerStub(self) + old.__dict__.update(replacement.__dict__) + + def test_binary_change_between_preflight_and_run_is_rejected(self): + self.stub.before_run = lambda: self.binary.write_bytes(b"changed") + self.assertNotEqual(self.invoke(), 0) + self.assertIsNone(self.stub.command) + + def test_success_nonzero_timeout_interrupt_and_cleanup_failure(self): + self.stub.code = 17 + self.assertEqual(self.invoke(), 17) + self.assertEqual(self.result()["exit"], 17) + + def test_timeout_is_bounded_and_only_owned_id_is_cleaned(self): + self.stub.wait_error = subprocess.TimeoutExpired("synthetic", 1) + self.assertEqual(self.invoke(), 124) + self.assertTrue(any("stop" in argv and argv[-1] == CID for argv in self.stub.calls)) + self.assertIn("timeout", self.result()["error"].lower()) + + def test_interrupt_preserves_primary_and_cleanup_errors(self): + self.stub.wait_error = KeyboardInterrupt() + self.stub.stop_error = True + self.assertEqual(self.invoke(), 130) + result = self.result() + self.assertIn("interrupt", result["error"].lower()) + self.assertTrue(result["cleanup_errors"]) + + def test_already_removed_container_is_not_cleanup_failure(self): + self.stub.absent = True + self.assertEqual(self.invoke(), 0) + self.assertEqual(self.result()["cleanup_errors"], []) + self.assertFalse(any("stop" in argv or "rm" in argv for argv in self.stub.calls)) + + def test_foreign_id_label_image_or_name_never_authorizes_cleanup(self): + for mismatch in ("id", "image", "label", "name"): + with self.subTest(mismatch=mismatch): + self.out = self.root / ("antigravity-compatibility-" + mismatch) + self.stub.command = None + self.stub.calls.clear() + self.stub.mismatch = mismatch + self.assertNotEqual(self.invoke(), 0) + self.assertFalse(any("stop" in argv or "rm" in argv for argv in self.stub.calls)) + self.assertIn("possible orphan", self.result()["error"]) + + def test_missing_malformed_and_symlink_cid_never_authorizes_cleanup(self): + for cid in (None, "foreign-name", "symlink"): + with self.subTest(cid=cid): + self.out = self.root / ("antigravity-compatibility-" + str(cid)) + self.stub.command = None + self.stub.calls.clear() + self.stub.cid = cid + self.assertNotEqual(self.invoke(), 0) + self.assertFalse(any("stop" in argv or "rm" in argv for argv in self.stub.calls)) + self.assertIn("possible orphan", self.result()["error"]) + + def test_preflight_timeout_has_no_output(self): + with patch.object(launcher.subprocess, "run", side_effect=subprocess.TimeoutExpired("docker", 1)): + self.assertEqual(self.invoke(), 124) + self.assertFalse(self.out.exists()) + + def test_deadline_reserves_cleanup_without_resetting_total_budget(self): + with patch.object(launcher.time, "monotonic", return_value=10) as clock: + deadline = launcher.Deadline() + clock.return_value = 10 + launcher.TOTAL_SECONDS - launcher.CLEANUP_SECONDS + with self.assertRaises(subprocess.TimeoutExpired): + deadline.remaining() + self.assertLessEqual(deadline.remaining(cleanup=True), launcher.CLEANUP_SECONDS) + clock.return_value = 10 + launcher.TOTAL_SECONDS + with self.assertRaises(subprocess.TimeoutExpired): + deadline.remaining(cleanup=True) + + def test_arm64_elf_requires_matching_image(self): + header = bytearray(self.binary.read_bytes()) + header[18:20] = (183).to_bytes(2, "little") + self.binary.write_bytes(header) + self.stub.image["Architecture"] = "arm64" + self.assertEqual(self.invoke(), 0) + + def test_resolved_binary_mount_delimiters_are_rejected(self): + unsafe = self.root / 'native,unsafe' + self.binary.rename(unsafe) + self.binary.symlink_to(unsafe) + self.invalid() + + def test_no_output_creation_on_any_readonly_preflight_failure(self): + for operation in (["context", "show"], ["version", "--format"], + ["info", "--format"], ["image", "inspect"]): + with self.subTest(operation=operation): + self.stub.failure = operation + with patch.object(launcher.os, "mkdir", side_effect=AssertionError("No preflight mkdir")): + self.assertNotEqual(self.invoke(), 0) + self.assertIsNone(self.stub.command) + + def test_client_config_proxy_defaults_are_always_blanked(self): + self.stub.image["Config"]["Env"] = [] + self.assertEqual(self.invoke(), 0) + for key in ("HTTP_PROXY", "HTTPS_PROXY", "FTP_PROXY", "ALL_PROXY", "NO_PROXY"): + self.assertIn(key + "=", self.stub.command) + self.assertIn(key.lower() + "=", self.stub.command) + + def test_explicit_docker_config_does_not_need_home_or_passwd_entry(self): + with patch.dict(os.environ, {"DOCKER_CONFIG": str(self.root / "docker-config")}): + with patch.object(launcher.Path, "home", side_effect=AssertionError("Do not resolve unused HOME")): + self.assertEqual(self.invoke(), 0) + + def test_empty_cid_is_pending_and_read_only_after_docker_finishes_writing(self): + self.stub.cid = "" + self.stub.poll = lambda: None if not (self.out / "control" / "container.cid").stat().st_size > 1 else 0 + def complete_cid(seconds): + (self.out / "control" / "container.cid").write_text(CID + "\n") + # Docker creates a genuinely empty file before asking the daemon to create. + original_popen = self.stub.popen + def popen(argv, **kwargs): + result = original_popen(argv, **kwargs) + (self.out / "control" / "container.cid").write_text("") + return result + with patch.object(launcher.subprocess, "Popen", side_effect=popen), patch.object(launcher.time, "sleep", side_effect=complete_cid): + self.assertEqual(self.invoke(), 0) + + def test_interrupt_during_launch_reports_unique_possible_orphan(self): + with patch.object(launcher.subprocess, "Popen", side_effect=KeyboardInterrupt()): + self.assertEqual(self.invoke(), 130) + result = self.result() + self.assertIn("possible orphan", result["error"]) + self.assertIn(result["name"], result["error"]) + self.assertFalse(any("stop" in argv or "rm" in argv for argv in self.stub.calls)) + + def test_parent_symlink_replacement_during_preflight_creates_no_output(self): + parent = self.root / "parent" + parent.mkdir() + self.out = parent / self.out.name + destination = self.root / "destination" + destination.mkdir() + def replace_parent(): + parent.rmdir() + parent.symlink_to(destination, target_is_directory=True) + self.stub.before_run = replace_parent + self.assertNotEqual(self.invoke(), 0) + self.assertFalse((destination / self.out.name).exists()) + self.assertIsNone(self.stub.command) + + def test_observed_output_directory_replacement_is_rejected(self): + self.assertEqual(self.invoke(), 0) + with launcher.Output(self.out, os.getuid(), os.getgid(), create=False) as output: + work = self.out / "work" + work.rename(self.out / "old-work") + work.mkdir(mode=0o700) + with self.assertRaises(ValueError): + output.verify() + + def test_cleanup_error_turns_success_into_failure(self): + self.stub.stop_error = True + self.assertNotEqual(self.invoke(), 0) + self.assertTrue(self.result()["cleanup_errors"]) + + def test_inspection_timeout_never_authorizes_cleanup(self): + self.stub.inspect_error = subprocess.TimeoutExpired("inspect", 1) + self.assertEqual(self.invoke(), 124) + self.assertIn("possible orphan", self.result()["error"]) + self.assertFalse(any("stop" in argv or "rm" in argv for argv in self.stub.calls)) + + def test_evidence_writer_never_follows_or_overwrites_existing_leaf(self): + self.assertEqual(self.invoke(), 0) + with launcher.Output(self.out, os.getuid(), os.getgid(), create=False) as output: + victim = self.root / "victim" + victim.write_text("unchanged") + (self.out / "control" / "attack").symlink_to(victim) + for name in ("attack", "docker-command.json"): + with self.assertRaises(OSError): + with output.open(name): + self.fail("existing leaf opened") + self.assertEqual(victim.read_text(), "unchanged") + + +class HarnessAdmissionTests(unittest.TestCase): + def test_other_positive_equal_nonroot_identity_is_accepted(self): + with contextlib.ExitStack() as stack: + for name, value in (("getuid", 2345), ("geteuid", 2345), ("getgid", 3456), ("getegid", 3456)): + stack.enter_context(patch.object(os, name, return_value=value)) + self.assertTrue(harness.safe_identity()) + + def test_root_and_real_effective_mismatches_are_rejected(self): + for values in ((0, 0, 4, 4), (4, 4, 0, 0), (4, 5, 6, 6), (4, 4, 6, 7), (-1, -1, 2, 2)): + with self.subTest(identity=values), contextlib.ExitStack() as stack: + for name, value in zip(("getuid", "geteuid", "getgid", "getegid"), values): + stack.enter_context(patch.object(os, name, return_value=value)) + self.assertFalse(harness.safe_identity()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/fixtures/antigravity-golden-v1.json b/tests/fixtures/antigravity-golden-v1.json new file mode 100644 index 00000000..52a12131 --- /dev/null +++ b/tests/fixtures/antigravity-golden-v1.json @@ -0,0 +1,527 @@ +{ + "format": "FINAL ccp:antigravity:v1 after reviewed codec contract; supersedes pre-freeze draft-v1 fixtures", + "contains": "Only synthetic native contents/projections; no CLI system prompts or complete tool descriptions", + "vectors": [ + { + "name": "leaf-omitted-i64-max", + "binding": { + "profile_id": "synthetic-profile", + "project_id": "synthetic-project", + "native_model": "gemini-4-argon-low" + }, + "native_contents": [ + { + "role": "model", + "parts": [ + { + "thought": true, + "text": "Genuine synthetic native thought 雪", + "thoughtSignature": "raw-thought" + }, + { + "functionCall": { + "name": "native_read_alias", + "id": "reused-native-id", + "args": { + "file_path": "\"/fixture/read.txt\"", + "offset": 1.0 + } + }, + "thoughtSignature": "raw-call" + } + ] + } + ], + "session_id": "9223372036854775807", + "thought_display": "omitted", + "tool_projections": [ + { + "caller_name": "Read", + "codec_paths": [ + "/file_path" + ], + "root_wrapper": null, + "local_id": "toolu_golden_leaf_omitted_i64_max" + } + ], + "projected_blocks": [ + { + "type": "thinking", + "thinking": "", + "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJzZXNzaW9uX2lkIjoiOTIyMzM3MjAzNjg1NDc3NTgwNyIsInRob3VnaHRfZGlzcGxheSI6Im9taXR0ZWQiLCJjb250ZW50cyI6W3sicm9sZSI6Im1vZGVsIiwicGFydHMiOlt7InRob3VnaHQiOnRydWUsInRleHQiOiJHZW51aW5lIHN5bnRoZXRpYyBuYXRpdmUgdGhvdWdodCDpm6oiLCJ0aG91Z2h0U2lnbmF0dXJlIjoicmF3LXRob3VnaHQifSx7ImZ1bmN0aW9uQ2FsbCI6eyJuYW1lIjoibmF0aXZlX3JlYWRfYWxpYXMiLCJpZCI6InJldXNlZC1uYXRpdmUtaWQiLCJhcmdzIjp7ImZpbGVfcGF0aCI6IlwiL2ZpeHR1cmUvcmVhZC50eHRcIiIsIm9mZnNldCI6MS4wfX0sInRob3VnaHRTaWduYXR1cmUiOiJyYXctY2FsbCJ9XX1dLCJ0b29sX21hcHBpbmciOlt7ImxvY2FsX2lkIjoidG9vbHVfZ29sZGVuX2xlYWZfb21pdHRlZF9pNjRfbWF4IiwiY2FsbGVyX25hbWUiOiJSZWFkIiwiY29udGVudF9pbmRleCI6MCwicGFydF9pbmRleCI6MSwibmF0aXZlX2lkX3ByZXNlbnQiOnRydWUsImNvZGVjX3BhdGhzIjpbIi9maWxlX3BhdGgiXSwicm9vdF93cmFwcGVyIjpudWxsLCJuYXRpdmVfaWQiOiJyZXVzZWQtbmF0aXZlLWlkIn1dLCJwcm9qZWN0aW9uX2RpZ2VzdCI6ImI0ZDA3MTdhNzhmMzRhMjE5MWVkMWNiOWRiZmUwNjNkMGFkOWU0ZTQxMWRiNTUwYWUwYjdiNzkyNDAzMTZiNzYifQ" + }, + { + "type": "tool_use", + "id": "toolu_golden_leaf_omitted_i64_max", + "name": "Read", + "input": { + "file_path": "/fixture/read.txt", + "offset": 1.0 + } + } + ], + "envelope": { + "version": 1, + "profile_id": "synthetic-profile", + "project_id": "synthetic-project", + "native_model": "gemini-4-argon-low", + "session_id": "9223372036854775807", + "thought_display": "omitted", + "contents": [ + { + "role": "model", + "parts": [ + { + "thought": true, + "text": "Genuine synthetic native thought 雪", + "thoughtSignature": "raw-thought" + }, + { + "functionCall": { + "name": "native_read_alias", + "id": "reused-native-id", + "args": { + "file_path": "\"/fixture/read.txt\"", + "offset": 1.0 + } + }, + "thoughtSignature": "raw-call" + } + ] + } + ], + "tool_mapping": [ + { + "local_id": "toolu_golden_leaf_omitted_i64_max", + "caller_name": "Read", + "content_index": 0, + "part_index": 1, + "native_id_present": true, + "codec_paths": [ + "/file_path" + ], + "root_wrapper": null, + "native_id": "reused-native-id" + } + ], + "projection_digest": "b4d0717a78f34a2191ed1cb9dbfe063d0ad9e4e411db550ae0b7b79240316b76" + }, + "canonical_digest_input_utf8": "[\"object\",[[\"binding\",[\"object\",[[\"native_model\",[\"string\",\"gemini-4-argon-low\"]],[\"profile_id\",[\"string\",\"synthetic-profile\"]],[\"project_id\",[\"string\",\"synthetic-project\"]],[\"session_id\",[\"string\",\"9223372036854775807\"]],[\"thought_display\",[\"string\",\"omitted\"]],[\"tool_mapping\",[\"array\",[[\"object\",[[\"caller_name\",[\"string\",\"Read\"]],[\"codec_paths\",[\"array\",[[\"string\",\"/file_path\"]]]],[\"content_index\",[\"number\",\"0000000000000000\"]],[\"local_id\",[\"string\",\"toolu_golden_leaf_omitted_i64_max\"]],[\"native_id\",[\"string\",\"reused-native-id\"]],[\"native_id_present\",[\"bool\",true]],[\"part_index\",[\"number\",\"3ff0000000000000\"]],[\"root_wrapper\",[\"null\"]]]]]]]]]],[\"blocks\",[\"array\",[[\"object\",[[\"thinking\",[\"string\",\"\"]],[\"type\",[\"string\",\"thinking\"]]]],[\"object\",[[\"id\",[\"string\",\"toolu_golden_leaf_omitted_i64_max\"]],[\"input\",[\"object\",[[\"file_path\",[\"string\",\"/fixture/read.txt\"]],[\"offset\",[\"number\",\"3ff0000000000000\"]]]]],[\"name\",[\"string\",\"Read\"]],[\"type\",[\"string\",\"tool_use\"]]]]]]]]]", + "canonical_digest_input_hex": "5b226f626a656374222c5b5b2262696e64696e67222c5b226f626a656374222c5b5b226e61746976655f6d6f64656c222c5b22737472696e67222c2267656d696e692d342d6172676f6e2d6c6f77225d5d2c5b2270726f66696c655f6964222c5b22737472696e67222c2273796e7468657469632d70726f66696c65225d5d2c5b2270726f6a6563745f6964222c5b22737472696e67222c2273796e7468657469632d70726f6a656374225d5d2c5b2273657373696f6e5f6964222c5b22737472696e67222c2239323233333732303336383534373735383037225d5d2c5b2274686f756768745f646973706c6179222c5b22737472696e67222c226f6d6974746564225d5d2c5b22746f6f6c5f6d617070696e67222c5b226172726179222c5b5b226f626a656374222c5b5b2263616c6c65725f6e616d65222c5b22737472696e67222c2252656164225d5d2c5b22636f6465635f7061746873222c5b226172726179222c5b5b22737472696e67222c222f66696c655f70617468225d5d5d5d2c5b22636f6e74656e745f696e646578222c5b226e756d626572222c2230303030303030303030303030303030225d5d2c5b226c6f63616c5f6964222c5b22737472696e67222c22746f6f6c755f676f6c64656e5f6c6561665f6f6d69747465645f6936345f6d6178225d5d2c5b226e61746976655f6964222c5b22737472696e67222c227265757365642d6e61746976652d6964225d5d2c5b226e61746976655f69645f70726573656e74222c5b22626f6f6c222c747275655d5d2c5b22706172745f696e646578222c5b226e756d626572222c2233666630303030303030303030303030225d5d2c5b22726f6f745f77726170706572222c5b226e756c6c225d5d5d5d5d5d5d5d5d5d2c5b22626c6f636b73222c5b226172726179222c5b5b226f626a656374222c5b5b227468696e6b696e67222c5b22737472696e67222c22225d5d2c5b2274797065222c5b22737472696e67222c227468696e6b696e67225d5d5d5d2c5b226f626a656374222c5b5b226964222c5b22737472696e67222c22746f6f6c755f676f6c64656e5f6c6561665f6f6d69747465645f6936345f6d6178225d5d2c5b22696e707574222c5b226f626a656374222c5b5b2266696c655f70617468222c5b22737472696e67222c222f666978747572652f726561642e747874225d5d2c5b226f6666736574222c5b226e756d626572222c2233666630303030303030303030303030225d5d5d5d5d2c5b226e616d65222c5b22737472696e67222c2252656164225d5d2c5b2274797065222c5b22737472696e67222c22746f6f6c5f757365225d5d5d5d5d5d5d5d5d", + "sha256": "b4d0717a78f34a2191ed1cb9dbfe063d0ad9e4e411db550ae0b7b79240316b76" + }, + { + "name": "leaf-full-negative-session", + "binding": { + "profile_id": "synthetic-profile", + "project_id": "synthetic-project", + "native_model": "gemini-4-argon-low" + }, + "native_contents": [ + { + "role": "model", + "parts": [ + { + "thought": true, + "text": "Genuine synthetic native thought 雪", + "thoughtSignature": "raw-thought" + }, + { + "functionCall": { + "name": "native_read_alias", + "id": "reused-native-id", + "args": { + "file_path": "\"/fixture/read.txt\"", + "offset": 1.0 + } + }, + "thoughtSignature": "raw-call" + } + ] + } + ], + "session_id": "-37", + "thought_display": "full", + "tool_projections": [ + { + "caller_name": "Read", + "codec_paths": [ + "/file_path" + ], + "root_wrapper": null, + "local_id": "toolu_golden_leaf_full_negative_session" + } + ], + "projected_blocks": [ + { + "type": "thinking", + "thinking": "Genuine synthetic native thought 雪", + "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJzZXNzaW9uX2lkIjoiLTM3IiwidGhvdWdodF9kaXNwbGF5IjoiZnVsbCIsImNvbnRlbnRzIjpbeyJyb2xlIjoibW9kZWwiLCJwYXJ0cyI6W3sidGhvdWdodCI6dHJ1ZSwidGV4dCI6IkdlbnVpbmUgc3ludGhldGljIG5hdGl2ZSB0aG91Z2h0IOmbqiIsInRob3VnaHRTaWduYXR1cmUiOiJyYXctdGhvdWdodCJ9LHsiZnVuY3Rpb25DYWxsIjp7Im5hbWUiOiJuYXRpdmVfcmVhZF9hbGlhcyIsImlkIjoicmV1c2VkLW5hdGl2ZS1pZCIsImFyZ3MiOnsiZmlsZV9wYXRoIjoiXCIvZml4dHVyZS9yZWFkLnR4dFwiIiwib2Zmc2V0IjoxLjB9fSwidGhvdWdodFNpZ25hdHVyZSI6InJhdy1jYWxsIn1dfV0sInRvb2xfbWFwcGluZyI6W3sibG9jYWxfaWQiOiJ0b29sdV9nb2xkZW5fbGVhZl9mdWxsX25lZ2F0aXZlX3Nlc3Npb24iLCJjYWxsZXJfbmFtZSI6IlJlYWQiLCJjb250ZW50X2luZGV4IjowLCJwYXJ0X2luZGV4IjoxLCJuYXRpdmVfaWRfcHJlc2VudCI6dHJ1ZSwiY29kZWNfcGF0aHMiOlsiL2ZpbGVfcGF0aCJdLCJyb290X3dyYXBwZXIiOm51bGwsIm5hdGl2ZV9pZCI6InJldXNlZC1uYXRpdmUtaWQifV0sInByb2plY3Rpb25fZGlnZXN0IjoiYTg1OTFiYTAwNjk5OWVkMmJhMTZjNjE5YTdiNzZlMzc5OTJlZWM5YmEwMDQzNDMxOWQ4YTliMTJiNmI3N2RlMSJ9" + }, + { + "type": "tool_use", + "id": "toolu_golden_leaf_full_negative_session", + "name": "Read", + "input": { + "file_path": "/fixture/read.txt", + "offset": 1.0 + } + } + ], + "envelope": { + "version": 1, + "profile_id": "synthetic-profile", + "project_id": "synthetic-project", + "native_model": "gemini-4-argon-low", + "session_id": "-37", + "thought_display": "full", + "contents": [ + { + "role": "model", + "parts": [ + { + "thought": true, + "text": "Genuine synthetic native thought 雪", + "thoughtSignature": "raw-thought" + }, + { + "functionCall": { + "name": "native_read_alias", + "id": "reused-native-id", + "args": { + "file_path": "\"/fixture/read.txt\"", + "offset": 1.0 + } + }, + "thoughtSignature": "raw-call" + } + ] + } + ], + "tool_mapping": [ + { + "local_id": "toolu_golden_leaf_full_negative_session", + "caller_name": "Read", + "content_index": 0, + "part_index": 1, + "native_id_present": true, + "codec_paths": [ + "/file_path" + ], + "root_wrapper": null, + "native_id": "reused-native-id" + } + ], + "projection_digest": "a8591ba006999ed2ba16c619a7b76e37992eec9ba00434319d8a9b12b6b77de1" + }, + "canonical_digest_input_utf8": "[\"object\",[[\"binding\",[\"object\",[[\"native_model\",[\"string\",\"gemini-4-argon-low\"]],[\"profile_id\",[\"string\",\"synthetic-profile\"]],[\"project_id\",[\"string\",\"synthetic-project\"]],[\"session_id\",[\"string\",\"-37\"]],[\"thought_display\",[\"string\",\"full\"]],[\"tool_mapping\",[\"array\",[[\"object\",[[\"caller_name\",[\"string\",\"Read\"]],[\"codec_paths\",[\"array\",[[\"string\",\"/file_path\"]]]],[\"content_index\",[\"number\",\"0000000000000000\"]],[\"local_id\",[\"string\",\"toolu_golden_leaf_full_negative_session\"]],[\"native_id\",[\"string\",\"reused-native-id\"]],[\"native_id_present\",[\"bool\",true]],[\"part_index\",[\"number\",\"3ff0000000000000\"]],[\"root_wrapper\",[\"null\"]]]]]]]]]],[\"blocks\",[\"array\",[[\"object\",[[\"thinking\",[\"string\",\"Genuine synthetic native thought 雪\"]],[\"type\",[\"string\",\"thinking\"]]]],[\"object\",[[\"id\",[\"string\",\"toolu_golden_leaf_full_negative_session\"]],[\"input\",[\"object\",[[\"file_path\",[\"string\",\"/fixture/read.txt\"]],[\"offset\",[\"number\",\"3ff0000000000000\"]]]]],[\"name\",[\"string\",\"Read\"]],[\"type\",[\"string\",\"tool_use\"]]]]]]]]]", + "canonical_digest_input_hex": "5b226f626a656374222c5b5b2262696e64696e67222c5b226f626a656374222c5b5b226e61746976655f6d6f64656c222c5b22737472696e67222c2267656d696e692d342d6172676f6e2d6c6f77225d5d2c5b2270726f66696c655f6964222c5b22737472696e67222c2273796e7468657469632d70726f66696c65225d5d2c5b2270726f6a6563745f6964222c5b22737472696e67222c2273796e7468657469632d70726f6a656374225d5d2c5b2273657373696f6e5f6964222c5b22737472696e67222c222d3337225d5d2c5b2274686f756768745f646973706c6179222c5b22737472696e67222c2266756c6c225d5d2c5b22746f6f6c5f6d617070696e67222c5b226172726179222c5b5b226f626a656374222c5b5b2263616c6c65725f6e616d65222c5b22737472696e67222c2252656164225d5d2c5b22636f6465635f7061746873222c5b226172726179222c5b5b22737472696e67222c222f66696c655f70617468225d5d5d5d2c5b22636f6e74656e745f696e646578222c5b226e756d626572222c2230303030303030303030303030303030225d5d2c5b226c6f63616c5f6964222c5b22737472696e67222c22746f6f6c755f676f6c64656e5f6c6561665f66756c6c5f6e656761746976655f73657373696f6e225d5d2c5b226e61746976655f6964222c5b22737472696e67222c227265757365642d6e61746976652d6964225d5d2c5b226e61746976655f69645f70726573656e74222c5b22626f6f6c222c747275655d5d2c5b22706172745f696e646578222c5b226e756d626572222c2233666630303030303030303030303030225d5d2c5b22726f6f745f77726170706572222c5b226e756c6c225d5d5d5d5d5d5d5d5d5d2c5b22626c6f636b73222c5b226172726179222c5b5b226f626a656374222c5b5b227468696e6b696e67222c5b22737472696e67222c2247656e75696e652073796e746865746963206e61746976652074686f7567687420e99baa225d5d2c5b2274797065222c5b22737472696e67222c227468696e6b696e67225d5d5d5d2c5b226f626a656374222c5b5b226964222c5b22737472696e67222c22746f6f6c755f676f6c64656e5f6c6561665f66756c6c5f6e656761746976655f73657373696f6e225d5d2c5b22696e707574222c5b226f626a656374222c5b5b2266696c655f70617468222c5b22737472696e67222c222f666978747572652f726561642e747874225d5d2c5b226f6666736574222c5b226e756d626572222c2233666630303030303030303030303030225d5d5d5d5d2c5b226e616d65222c5b22737472696e67222c2252656164225d5d2c5b2274797065222c5b22737472696e67222c22746f6f6c5f757365225d5d5d5d5d5d5d5d5d", + "sha256": "a8591ba006999ed2ba16c619a7b76e37992eec9ba00434319d8a9b12b6b77de1" + }, + { + "name": "root-omitted-i64-min", + "binding": { + "profile_id": "synthetic-profile", + "project_id": "synthetic-project", + "native_model": "gemini-4-argon-low" + }, + "native_contents": [ + { + "role": "model", + "parts": [ + { + "thought": true, + "text": "Genuine synthetic native thought 雪", + "thoughtSignature": "raw-thought" + }, + { + "functionCall": { + "name": "native_read_alias", + "id": "reused-native-id", + "args": { + "json": "{\"file_path\": \"/fixture/read.txt\", \"offset\": 1.0}" + } + }, + "thoughtSignature": "raw-call" + } + ] + } + ], + "session_id": "-9223372036854775808", + "thought_display": "omitted", + "tool_projections": [ + { + "caller_name": "Read", + "codec_paths": [], + "root_wrapper": "json", + "local_id": "toolu_golden_root_omitted_i64_min" + } + ], + "projected_blocks": [ + { + "type": "thinking", + "thinking": "", + "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJzZXNzaW9uX2lkIjoiLTkyMjMzNzIwMzY4NTQ3NzU4MDgiLCJ0aG91Z2h0X2Rpc3BsYXkiOiJvbWl0dGVkIiwiY29udGVudHMiOlt7InJvbGUiOiJtb2RlbCIsInBhcnRzIjpbeyJ0aG91Z2h0Ijp0cnVlLCJ0ZXh0IjoiR2VudWluZSBzeW50aGV0aWMgbmF0aXZlIHRob3VnaHQg6ZuqIiwidGhvdWdodFNpZ25hdHVyZSI6InJhdy10aG91Z2h0In0seyJmdW5jdGlvbkNhbGwiOnsibmFtZSI6Im5hdGl2ZV9yZWFkX2FsaWFzIiwiaWQiOiJyZXVzZWQtbmF0aXZlLWlkIiwiYXJncyI6eyJqc29uIjoie1wiZmlsZV9wYXRoXCI6IFwiL2ZpeHR1cmUvcmVhZC50eHRcIiwgXCJvZmZzZXRcIjogMS4wfSJ9fSwidGhvdWdodFNpZ25hdHVyZSI6InJhdy1jYWxsIn1dfV0sInRvb2xfbWFwcGluZyI6W3sibG9jYWxfaWQiOiJ0b29sdV9nb2xkZW5fcm9vdF9vbWl0dGVkX2k2NF9taW4iLCJjYWxsZXJfbmFtZSI6IlJlYWQiLCJjb250ZW50X2luZGV4IjowLCJwYXJ0X2luZGV4IjoxLCJuYXRpdmVfaWRfcHJlc2VudCI6dHJ1ZSwiY29kZWNfcGF0aHMiOltdLCJyb290X3dyYXBwZXIiOiJqc29uIiwibmF0aXZlX2lkIjoicmV1c2VkLW5hdGl2ZS1pZCJ9XSwicHJvamVjdGlvbl9kaWdlc3QiOiI5OTQwY2I3YTg1ZWQ5MGQ5MzgxZTYyNTA5ZTY4ZTk0YjczM2VhNTcyNTY5M2JiZmVkOWYzZGI1OWUyODEwNDExIn0" + }, + { + "type": "tool_use", + "id": "toolu_golden_root_omitted_i64_min", + "name": "Read", + "input": { + "file_path": "/fixture/read.txt", + "offset": 1.0 + } + } + ], + "envelope": { + "version": 1, + "profile_id": "synthetic-profile", + "project_id": "synthetic-project", + "native_model": "gemini-4-argon-low", + "session_id": "-9223372036854775808", + "thought_display": "omitted", + "contents": [ + { + "role": "model", + "parts": [ + { + "thought": true, + "text": "Genuine synthetic native thought 雪", + "thoughtSignature": "raw-thought" + }, + { + "functionCall": { + "name": "native_read_alias", + "id": "reused-native-id", + "args": { + "json": "{\"file_path\": \"/fixture/read.txt\", \"offset\": 1.0}" + } + }, + "thoughtSignature": "raw-call" + } + ] + } + ], + "tool_mapping": [ + { + "local_id": "toolu_golden_root_omitted_i64_min", + "caller_name": "Read", + "content_index": 0, + "part_index": 1, + "native_id_present": true, + "codec_paths": [], + "root_wrapper": "json", + "native_id": "reused-native-id" + } + ], + "projection_digest": "9940cb7a85ed90d9381e62509e68e94b733ea5725693bbfed9f3db59e2810411" + }, + "canonical_digest_input_utf8": "[\"object\",[[\"binding\",[\"object\",[[\"native_model\",[\"string\",\"gemini-4-argon-low\"]],[\"profile_id\",[\"string\",\"synthetic-profile\"]],[\"project_id\",[\"string\",\"synthetic-project\"]],[\"session_id\",[\"string\",\"-9223372036854775808\"]],[\"thought_display\",[\"string\",\"omitted\"]],[\"tool_mapping\",[\"array\",[[\"object\",[[\"caller_name\",[\"string\",\"Read\"]],[\"codec_paths\",[\"array\",[]]],[\"content_index\",[\"number\",\"0000000000000000\"]],[\"local_id\",[\"string\",\"toolu_golden_root_omitted_i64_min\"]],[\"native_id\",[\"string\",\"reused-native-id\"]],[\"native_id_present\",[\"bool\",true]],[\"part_index\",[\"number\",\"3ff0000000000000\"]],[\"root_wrapper\",[\"string\",\"json\"]]]]]]]]]],[\"blocks\",[\"array\",[[\"object\",[[\"thinking\",[\"string\",\"\"]],[\"type\",[\"string\",\"thinking\"]]]],[\"object\",[[\"id\",[\"string\",\"toolu_golden_root_omitted_i64_min\"]],[\"input\",[\"object\",[[\"file_path\",[\"string\",\"/fixture/read.txt\"]],[\"offset\",[\"number\",\"3ff0000000000000\"]]]]],[\"name\",[\"string\",\"Read\"]],[\"type\",[\"string\",\"tool_use\"]]]]]]]]]", + "canonical_digest_input_hex": "5b226f626a656374222c5b5b2262696e64696e67222c5b226f626a656374222c5b5b226e61746976655f6d6f64656c222c5b22737472696e67222c2267656d696e692d342d6172676f6e2d6c6f77225d5d2c5b2270726f66696c655f6964222c5b22737472696e67222c2273796e7468657469632d70726f66696c65225d5d2c5b2270726f6a6563745f6964222c5b22737472696e67222c2273796e7468657469632d70726f6a656374225d5d2c5b2273657373696f6e5f6964222c5b22737472696e67222c222d39323233333732303336383534373735383038225d5d2c5b2274686f756768745f646973706c6179222c5b22737472696e67222c226f6d6974746564225d5d2c5b22746f6f6c5f6d617070696e67222c5b226172726179222c5b5b226f626a656374222c5b5b2263616c6c65725f6e616d65222c5b22737472696e67222c2252656164225d5d2c5b22636f6465635f7061746873222c5b226172726179222c5b5d5d5d2c5b22636f6e74656e745f696e646578222c5b226e756d626572222c2230303030303030303030303030303030225d5d2c5b226c6f63616c5f6964222c5b22737472696e67222c22746f6f6c755f676f6c64656e5f726f6f745f6f6d69747465645f6936345f6d696e225d5d2c5b226e61746976655f6964222c5b22737472696e67222c227265757365642d6e61746976652d6964225d5d2c5b226e61746976655f69645f70726573656e74222c5b22626f6f6c222c747275655d5d2c5b22706172745f696e646578222c5b226e756d626572222c2233666630303030303030303030303030225d5d2c5b22726f6f745f77726170706572222c5b22737472696e67222c226a736f6e225d5d5d5d5d5d5d5d5d5d2c5b22626c6f636b73222c5b226172726179222c5b5b226f626a656374222c5b5b227468696e6b696e67222c5b22737472696e67222c22225d5d2c5b2274797065222c5b22737472696e67222c227468696e6b696e67225d5d5d5d2c5b226f626a656374222c5b5b226964222c5b22737472696e67222c22746f6f6c755f676f6c64656e5f726f6f745f6f6d69747465645f6936345f6d696e225d5d2c5b22696e707574222c5b226f626a656374222c5b5b2266696c655f70617468222c5b22737472696e67222c222f666978747572652f726561642e747874225d5d2c5b226f6666736574222c5b226e756d626572222c2233666630303030303030303030303030225d5d5d5d5d2c5b226e616d65222c5b22737472696e67222c2252656164225d5d2c5b2274797065222c5b22737472696e67222c22746f6f6c5f757365225d5d5d5d5d5d5d5d5d", + "sha256": "9940cb7a85ed90d9381e62509e68e94b733ea5725693bbfed9f3db59e2810411" + }, + { + "name": "parallel-reused-native-id", + "binding": { + "profile_id": "synthetic-profile", + "project_id": "synthetic-project", + "native_model": "gemini-4-argon-low" + }, + "native_contents": [ + { + "role": "model", + "parts": [ + { + "thought": true, + "text": "synthetic thought α", + "thoughtSignature": "fake-thought-1" + }, + { + "text": "visible 雪" + }, + { + "thought": true, + "text": " / synthetic thought β" + }, + { + "functionCall": { + "name": "Read", + "args": { + "file_path": "/fixture/read.txt", + "offset": 1.0 + }, + "id": "native-second" + }, + "thoughtSignature": "fake-call-signed" + }, + { + "functionCall": { + "name": "Read", + "id": "native-second", + "args": { + "file_path": "/fixture/read.txt", + "limit": 1 + } + } + } + ], + "unknown": { + "preserve": [ + true, + null, + "é" + ] + } + }, + { + "role": "model", + "parts": [ + { + "thoughtSignature": "fake-late-signature" + }, + { + "text": "" + } + ] + } + ], + "session_id": "17", + "thought_display": "full", + "tool_projections": [ + { + "local_id": "toolu_golden_reused_0", + "caller_name": "Read", + "codec_paths": [], + "root_wrapper": null + }, + { + "local_id": "toolu_golden_reused_1", + "caller_name": "Read", + "codec_paths": [], + "root_wrapper": null + } + ], + "projected_blocks": [ + { + "type": "thinking", + "thinking": "synthetic thought α / synthetic thought β", + "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJzZXNzaW9uX2lkIjoiMTciLCJ0aG91Z2h0X2Rpc3BsYXkiOiJmdWxsIiwiY29udGVudHMiOlt7InJvbGUiOiJtb2RlbCIsInBhcnRzIjpbeyJ0aG91Z2h0Ijp0cnVlLCJ0ZXh0Ijoic3ludGhldGljIHRob3VnaHQgzrEiLCJ0aG91Z2h0U2lnbmF0dXJlIjoiZmFrZS10aG91Z2h0LTEifSx7InRleHQiOiJ2aXNpYmxlIOmbqiJ9LHsidGhvdWdodCI6dHJ1ZSwidGV4dCI6IiAvIHN5bnRoZXRpYyB0aG91Z2h0IM6yIn0seyJmdW5jdGlvbkNhbGwiOnsibmFtZSI6IlJlYWQiLCJhcmdzIjp7ImZpbGVfcGF0aCI6Ii9maXh0dXJlL3JlYWQudHh0Iiwib2Zmc2V0IjoxLjB9LCJpZCI6Im5hdGl2ZS1zZWNvbmQifSwidGhvdWdodFNpZ25hdHVyZSI6ImZha2UtY2FsbC1zaWduZWQifSx7ImZ1bmN0aW9uQ2FsbCI6eyJuYW1lIjoiUmVhZCIsImlkIjoibmF0aXZlLXNlY29uZCIsImFyZ3MiOnsiZmlsZV9wYXRoIjoiL2ZpeHR1cmUvcmVhZC50eHQiLCJsaW1pdCI6MX19fV0sInVua25vd24iOnsicHJlc2VydmUiOlt0cnVlLG51bGwsIsOpIl19fSx7InJvbGUiOiJtb2RlbCIsInBhcnRzIjpbeyJ0aG91Z2h0U2lnbmF0dXJlIjoiZmFrZS1sYXRlLXNpZ25hdHVyZSJ9LHsidGV4dCI6IiJ9XX1dLCJ0b29sX21hcHBpbmciOlt7ImxvY2FsX2lkIjoidG9vbHVfZ29sZGVuX3JldXNlZF8wIiwiY2FsbGVyX25hbWUiOiJSZWFkIiwiY29udGVudF9pbmRleCI6MCwicGFydF9pbmRleCI6MywibmF0aXZlX2lkX3ByZXNlbnQiOnRydWUsImNvZGVjX3BhdGhzIjpbXSwicm9vdF93cmFwcGVyIjpudWxsLCJuYXRpdmVfaWQiOiJuYXRpdmUtc2Vjb25kIn0seyJsb2NhbF9pZCI6InRvb2x1X2dvbGRlbl9yZXVzZWRfMSIsImNhbGxlcl9uYW1lIjoiUmVhZCIsImNvbnRlbnRfaW5kZXgiOjAsInBhcnRfaW5kZXgiOjQsIm5hdGl2ZV9pZF9wcmVzZW50Ijp0cnVlLCJjb2RlY19wYXRocyI6W10sInJvb3Rfd3JhcHBlciI6bnVsbCwibmF0aXZlX2lkIjoibmF0aXZlLXNlY29uZCJ9XSwicHJvamVjdGlvbl9kaWdlc3QiOiJlOWIzMGMxZTM2NjYxY2U2MDVmY2VhZmEwMmU3YjcxZGU3MjRiOTM0ZWFiZWM4ZTZiOGNiMzVlZGMzM2E1MzliIn0" + }, + { + "type": "text", + "text": "visible 雪" + }, + { + "type": "tool_use", + "id": "toolu_golden_reused_0", + "name": "Read", + "input": { + "file_path": "/fixture/read.txt", + "offset": 1.0 + } + }, + { + "type": "tool_use", + "id": "toolu_golden_reused_1", + "name": "Read", + "input": { + "file_path": "/fixture/read.txt", + "limit": 1 + } + } + ], + "envelope": { + "version": 1, + "profile_id": "synthetic-profile", + "project_id": "synthetic-project", + "native_model": "gemini-4-argon-low", + "session_id": "17", + "thought_display": "full", + "contents": [ + { + "role": "model", + "parts": [ + { + "thought": true, + "text": "synthetic thought α", + "thoughtSignature": "fake-thought-1" + }, + { + "text": "visible 雪" + }, + { + "thought": true, + "text": " / synthetic thought β" + }, + { + "functionCall": { + "name": "Read", + "args": { + "file_path": "/fixture/read.txt", + "offset": 1.0 + }, + "id": "native-second" + }, + "thoughtSignature": "fake-call-signed" + }, + { + "functionCall": { + "name": "Read", + "id": "native-second", + "args": { + "file_path": "/fixture/read.txt", + "limit": 1 + } + } + } + ], + "unknown": { + "preserve": [ + true, + null, + "é" + ] + } + }, + { + "role": "model", + "parts": [ + { + "thoughtSignature": "fake-late-signature" + }, + { + "text": "" + } + ] + } + ], + "tool_mapping": [ + { + "local_id": "toolu_golden_reused_0", + "caller_name": "Read", + "content_index": 0, + "part_index": 3, + "native_id_present": true, + "codec_paths": [], + "root_wrapper": null, + "native_id": "native-second" + }, + { + "local_id": "toolu_golden_reused_1", + "caller_name": "Read", + "content_index": 0, + "part_index": 4, + "native_id_present": true, + "codec_paths": [], + "root_wrapper": null, + "native_id": "native-second" + } + ], + "projection_digest": "e9b30c1e36661ce605fceafa02e7b71de724b934eabec8e6b8cb35edc33a539b" + }, + "canonical_digest_input_utf8": "[\"object\",[[\"binding\",[\"object\",[[\"native_model\",[\"string\",\"gemini-4-argon-low\"]],[\"profile_id\",[\"string\",\"synthetic-profile\"]],[\"project_id\",[\"string\",\"synthetic-project\"]],[\"session_id\",[\"string\",\"17\"]],[\"thought_display\",[\"string\",\"full\"]],[\"tool_mapping\",[\"array\",[[\"object\",[[\"caller_name\",[\"string\",\"Read\"]],[\"codec_paths\",[\"array\",[]]],[\"content_index\",[\"number\",\"0000000000000000\"]],[\"local_id\",[\"string\",\"toolu_golden_reused_0\"]],[\"native_id\",[\"string\",\"native-second\"]],[\"native_id_present\",[\"bool\",true]],[\"part_index\",[\"number\",\"4008000000000000\"]],[\"root_wrapper\",[\"null\"]]]],[\"object\",[[\"caller_name\",[\"string\",\"Read\"]],[\"codec_paths\",[\"array\",[]]],[\"content_index\",[\"number\",\"0000000000000000\"]],[\"local_id\",[\"string\",\"toolu_golden_reused_1\"]],[\"native_id\",[\"string\",\"native-second\"]],[\"native_id_present\",[\"bool\",true]],[\"part_index\",[\"number\",\"4010000000000000\"]],[\"root_wrapper\",[\"null\"]]]]]]]]]],[\"blocks\",[\"array\",[[\"object\",[[\"thinking\",[\"string\",\"synthetic thought α / synthetic thought β\"]],[\"type\",[\"string\",\"thinking\"]]]],[\"object\",[[\"text\",[\"string\",\"visible 雪\"]],[\"type\",[\"string\",\"text\"]]]],[\"object\",[[\"id\",[\"string\",\"toolu_golden_reused_0\"]],[\"input\",[\"object\",[[\"file_path\",[\"string\",\"/fixture/read.txt\"]],[\"offset\",[\"number\",\"3ff0000000000000\"]]]]],[\"name\",[\"string\",\"Read\"]],[\"type\",[\"string\",\"tool_use\"]]]],[\"object\",[[\"id\",[\"string\",\"toolu_golden_reused_1\"]],[\"input\",[\"object\",[[\"file_path\",[\"string\",\"/fixture/read.txt\"]],[\"limit\",[\"number\",\"3ff0000000000000\"]]]]],[\"name\",[\"string\",\"Read\"]],[\"type\",[\"string\",\"tool_use\"]]]]]]]]]", + "canonical_digest_input_hex": "5b226f626a656374222c5b5b2262696e64696e67222c5b226f626a656374222c5b5b226e61746976655f6d6f64656c222c5b22737472696e67222c2267656d696e692d342d6172676f6e2d6c6f77225d5d2c5b2270726f66696c655f6964222c5b22737472696e67222c2273796e7468657469632d70726f66696c65225d5d2c5b2270726f6a6563745f6964222c5b22737472696e67222c2273796e7468657469632d70726f6a656374225d5d2c5b2273657373696f6e5f6964222c5b22737472696e67222c223137225d5d2c5b2274686f756768745f646973706c6179222c5b22737472696e67222c2266756c6c225d5d2c5b22746f6f6c5f6d617070696e67222c5b226172726179222c5b5b226f626a656374222c5b5b2263616c6c65725f6e616d65222c5b22737472696e67222c2252656164225d5d2c5b22636f6465635f7061746873222c5b226172726179222c5b5d5d5d2c5b22636f6e74656e745f696e646578222c5b226e756d626572222c2230303030303030303030303030303030225d5d2c5b226c6f63616c5f6964222c5b22737472696e67222c22746f6f6c755f676f6c64656e5f7265757365645f30225d5d2c5b226e61746976655f6964222c5b22737472696e67222c226e61746976652d7365636f6e64225d5d2c5b226e61746976655f69645f70726573656e74222c5b22626f6f6c222c747275655d5d2c5b22706172745f696e646578222c5b226e756d626572222c2234303038303030303030303030303030225d5d2c5b22726f6f745f77726170706572222c5b226e756c6c225d5d5d5d2c5b226f626a656374222c5b5b2263616c6c65725f6e616d65222c5b22737472696e67222c2252656164225d5d2c5b22636f6465635f7061746873222c5b226172726179222c5b5d5d5d2c5b22636f6e74656e745f696e646578222c5b226e756d626572222c2230303030303030303030303030303030225d5d2c5b226c6f63616c5f6964222c5b22737472696e67222c22746f6f6c755f676f6c64656e5f7265757365645f31225d5d2c5b226e61746976655f6964222c5b22737472696e67222c226e61746976652d7365636f6e64225d5d2c5b226e61746976655f69645f70726573656e74222c5b22626f6f6c222c747275655d5d2c5b22706172745f696e646578222c5b226e756d626572222c2234303130303030303030303030303030225d5d2c5b22726f6f745f77726170706572222c5b226e756c6c225d5d5d5d5d5d5d5d5d5d2c5b22626c6f636b73222c5b226172726179222c5b5b226f626a656374222c5b5b227468696e6b696e67222c5b22737472696e67222c2273796e7468657469632074686f7567687420ceb1202f2073796e7468657469632074686f7567687420ceb2225d5d2c5b2274797065222c5b22737472696e67222c227468696e6b696e67225d5d5d5d2c5b226f626a656374222c5b5b2274657874222c5b22737472696e67222c2276697369626c6520e99baa225d5d2c5b2274797065222c5b22737472696e67222c2274657874225d5d5d5d2c5b226f626a656374222c5b5b226964222c5b22737472696e67222c22746f6f6c755f676f6c64656e5f7265757365645f30225d5d2c5b22696e707574222c5b226f626a656374222c5b5b2266696c655f70617468222c5b22737472696e67222c222f666978747572652f726561642e747874225d5d2c5b226f6666736574222c5b226e756d626572222c2233666630303030303030303030303030225d5d5d5d5d2c5b226e616d65222c5b22737472696e67222c2252656164225d5d2c5b2274797065222c5b22737472696e67222c22746f6f6c5f757365225d5d5d5d2c5b226f626a656374222c5b5b226964222c5b22737472696e67222c22746f6f6c755f676f6c64656e5f7265757365645f31225d5d2c5b22696e707574222c5b226f626a656374222c5b5b2266696c655f70617468222c5b22737472696e67222c222f666978747572652f726561642e747874225d5d2c5b226c696d6974222c5b226e756d626572222c2233666630303030303030303030303030225d5d5d5d5d2c5b226e616d65222c5b22737472696e67222c2252656164225d5d2c5b2274797065222c5b22737472696e67222c22746f6f6c5f757365225d5d5d5d5d5d5d5d5d", + "sha256": "e9b30c1e36661ce605fceafa02e7b71de724b934eabec8e6b8cb35edc33a539b" + } + ] +} diff --git a/tests/fixtures/antigravity-negative-v1.json b/tests/fixtures/antigravity-negative-v1.json new file mode 100644 index 00000000..113c60c0 --- /dev/null +++ b/tests/fixtures/antigravity-negative-v1.json @@ -0,0 +1,554 @@ +{ + "vectors": [ + { + "name": "client-name-tamper", + "operation": "decode_turn", + "input": { + "blocks": [ + { + "type": "thinking", + "thinking": "", + "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJzZXNzaW9uX2lkIjoiOTIyMzM3MjAzNjg1NDc3NTgwNyIsInRob3VnaHRfZGlzcGxheSI6Im9taXR0ZWQiLCJjb250ZW50cyI6W3sicm9sZSI6Im1vZGVsIiwicGFydHMiOlt7InRob3VnaHQiOnRydWUsInRleHQiOiJHZW51aW5lIHN5bnRoZXRpYyBuYXRpdmUgdGhvdWdodCDpm6oiLCJ0aG91Z2h0U2lnbmF0dXJlIjoicmF3LXRob3VnaHQifSx7ImZ1bmN0aW9uQ2FsbCI6eyJuYW1lIjoibmF0aXZlX3JlYWRfYWxpYXMiLCJpZCI6InJldXNlZC1uYXRpdmUtaWQiLCJhcmdzIjp7ImZpbGVfcGF0aCI6IlwiL2ZpeHR1cmUvcmVhZC50eHRcIiIsIm9mZnNldCI6MS4wfX0sInRob3VnaHRTaWduYXR1cmUiOiJyYXctY2FsbCJ9XX1dLCJ0b29sX21hcHBpbmciOlt7ImxvY2FsX2lkIjoidG9vbHVfZ29sZGVuX2xlYWZfb21pdHRlZF9pNjRfbWF4IiwiY2FsbGVyX25hbWUiOiJSZWFkIiwiY29udGVudF9pbmRleCI6MCwicGFydF9pbmRleCI6MSwibmF0aXZlX2lkX3ByZXNlbnQiOnRydWUsImNvZGVjX3BhdGhzIjpbIi9maWxlX3BhdGgiXSwicm9vdF93cmFwcGVyIjpudWxsLCJuYXRpdmVfaWQiOiJyZXVzZWQtbmF0aXZlLWlkIn1dLCJwcm9qZWN0aW9uX2RpZ2VzdCI6ImI0ZDA3MTdhNzhmMzRhMjE5MWVkMWNiOWRiZmUwNjNkMGFkOWU0ZTQxMWRiNTUwYWUwYjdiNzkyNDAzMTZiNzYifQ" + }, + { + "type": "tool_use", + "id": "toolu_golden_leaf_omitted_i64_max", + "name": "Other", + "input": { + "file_path": "/fixture/read.txt", + "offset": 1.0 + } + } + ], + "binding": { + "profile_id": "synthetic-profile", + "project_id": "synthetic-project", + "native_model": "gemini-4-argon-low" + } + }, + "expected_error": "projection digest mismatch" + }, + { + "name": "client-id-tamper", + "operation": "decode_turn", + "input": { + "blocks": [ + { + "type": "thinking", + "thinking": "", + "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJzZXNzaW9uX2lkIjoiOTIyMzM3MjAzNjg1NDc3NTgwNyIsInRob3VnaHRfZGlzcGxheSI6Im9taXR0ZWQiLCJjb250ZW50cyI6W3sicm9sZSI6Im1vZGVsIiwicGFydHMiOlt7InRob3VnaHQiOnRydWUsInRleHQiOiJHZW51aW5lIHN5bnRoZXRpYyBuYXRpdmUgdGhvdWdodCDpm6oiLCJ0aG91Z2h0U2lnbmF0dXJlIjoicmF3LXRob3VnaHQifSx7ImZ1bmN0aW9uQ2FsbCI6eyJuYW1lIjoibmF0aXZlX3JlYWRfYWxpYXMiLCJpZCI6InJldXNlZC1uYXRpdmUtaWQiLCJhcmdzIjp7ImZpbGVfcGF0aCI6IlwiL2ZpeHR1cmUvcmVhZC50eHRcIiIsIm9mZnNldCI6MS4wfX0sInRob3VnaHRTaWduYXR1cmUiOiJyYXctY2FsbCJ9XX1dLCJ0b29sX21hcHBpbmciOlt7ImxvY2FsX2lkIjoidG9vbHVfZ29sZGVuX2xlYWZfb21pdHRlZF9pNjRfbWF4IiwiY2FsbGVyX25hbWUiOiJSZWFkIiwiY29udGVudF9pbmRleCI6MCwicGFydF9pbmRleCI6MSwibmF0aXZlX2lkX3ByZXNlbnQiOnRydWUsImNvZGVjX3BhdGhzIjpbIi9maWxlX3BhdGgiXSwicm9vdF93cmFwcGVyIjpudWxsLCJuYXRpdmVfaWQiOiJyZXVzZWQtbmF0aXZlLWlkIn1dLCJwcm9qZWN0aW9uX2RpZ2VzdCI6ImI0ZDA3MTdhNzhmMzRhMjE5MWVkMWNiOWRiZmUwNjNkMGFkOWU0ZTQxMWRiNTUwYWUwYjdiNzkyNDAzMTZiNzYifQ" + }, + { + "type": "tool_use", + "id": "toolu_changed", + "name": "Read", + "input": { + "file_path": "/fixture/read.txt", + "offset": 1.0 + } + } + ], + "binding": { + "profile_id": "synthetic-profile", + "project_id": "synthetic-project", + "native_model": "gemini-4-argon-low" + } + }, + "expected_error": "projection digest mismatch" + }, + { + "name": "client-input-tamper", + "operation": "decode_turn", + "input": { + "blocks": [ + { + "type": "thinking", + "thinking": "", + "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJzZXNzaW9uX2lkIjoiOTIyMzM3MjAzNjg1NDc3NTgwNyIsInRob3VnaHRfZGlzcGxheSI6Im9taXR0ZWQiLCJjb250ZW50cyI6W3sicm9sZSI6Im1vZGVsIiwicGFydHMiOlt7InRob3VnaHQiOnRydWUsInRleHQiOiJHZW51aW5lIHN5bnRoZXRpYyBuYXRpdmUgdGhvdWdodCDpm6oiLCJ0aG91Z2h0U2lnbmF0dXJlIjoicmF3LXRob3VnaHQifSx7ImZ1bmN0aW9uQ2FsbCI6eyJuYW1lIjoibmF0aXZlX3JlYWRfYWxpYXMiLCJpZCI6InJldXNlZC1uYXRpdmUtaWQiLCJhcmdzIjp7ImZpbGVfcGF0aCI6IlwiL2ZpeHR1cmUvcmVhZC50eHRcIiIsIm9mZnNldCI6MS4wfX0sInRob3VnaHRTaWduYXR1cmUiOiJyYXctY2FsbCJ9XX1dLCJ0b29sX21hcHBpbmciOlt7ImxvY2FsX2lkIjoidG9vbHVfZ29sZGVuX2xlYWZfb21pdHRlZF9pNjRfbWF4IiwiY2FsbGVyX25hbWUiOiJSZWFkIiwiY29udGVudF9pbmRleCI6MCwicGFydF9pbmRleCI6MSwibmF0aXZlX2lkX3ByZXNlbnQiOnRydWUsImNvZGVjX3BhdGhzIjpbIi9maWxlX3BhdGgiXSwicm9vdF93cmFwcGVyIjpudWxsLCJuYXRpdmVfaWQiOiJyZXVzZWQtbmF0aXZlLWlkIn1dLCJwcm9qZWN0aW9uX2RpZ2VzdCI6ImI0ZDA3MTdhNzhmMzRhMjE5MWVkMWNiOWRiZmUwNjNkMGFkOWU0ZTQxMWRiNTUwYWUwYjdiNzkyNDAzMTZiNzYifQ" + }, + { + "type": "tool_use", + "id": "toolu_golden_leaf_omitted_i64_max", + "name": "Read", + "input": { + "file_path": "/fixture/changed.txt", + "offset": 1 + } + } + ], + "binding": { + "profile_id": "synthetic-profile", + "project_id": "synthetic-project", + "native_model": "gemini-4-argon-low" + } + }, + "expected_error": "projection digest mismatch" + }, + { + "name": "session-tamper", + "operation": "decode_turn", + "input": { + "blocks": [ + { + "type": "thinking", + "thinking": "", + "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjogMSwgInByb2ZpbGVfaWQiOiAic3ludGhldGljLXByb2ZpbGUiLCAicHJvamVjdF9pZCI6ICJzeW50aGV0aWMtcHJvamVjdCIsICJuYXRpdmVfbW9kZWwiOiAiZ2VtaW5pLTQtYXJnb24tbG93IiwgInNlc3Npb25faWQiOiAiOCIsICJ0aG91Z2h0X2Rpc3BsYXkiOiAib21pdHRlZCIsICJjb250ZW50cyI6IFt7InJvbGUiOiAibW9kZWwiLCAicGFydHMiOiBbeyJ0aG91Z2h0IjogdHJ1ZSwgInRleHQiOiAiR2VudWluZSBzeW50aGV0aWMgbmF0aXZlIHRob3VnaHQgXHU5NmVhIiwgInRob3VnaHRTaWduYXR1cmUiOiAicmF3LXRob3VnaHQifSwgeyJmdW5jdGlvbkNhbGwiOiB7Im5hbWUiOiAibmF0aXZlX3JlYWRfYWxpYXMiLCAiaWQiOiAicmV1c2VkLW5hdGl2ZS1pZCIsICJhcmdzIjogeyJmaWxlX3BhdGgiOiAiXCIvZml4dHVyZS9yZWFkLnR4dFwiIiwgIm9mZnNldCI6IDEuMH19LCAidGhvdWdodFNpZ25hdHVyZSI6ICJyYXctY2FsbCJ9XX1dLCAidG9vbF9tYXBwaW5nIjogW3sibG9jYWxfaWQiOiAidG9vbHVfZ29sZGVuX2xlYWZfb21pdHRlZF9pNjRfbWF4IiwgImNhbGxlcl9uYW1lIjogIlJlYWQiLCAiY29udGVudF9pbmRleCI6IDAsICJwYXJ0X2luZGV4IjogMSwgIm5hdGl2ZV9pZF9wcmVzZW50IjogdHJ1ZSwgImNvZGVjX3BhdGhzIjogWyIvZmlsZV9wYXRoIl0sICJyb290X3dyYXBwZXIiOiBudWxsLCAibmF0aXZlX2lkIjogInJldXNlZC1uYXRpdmUtaWQifV0sICJwcm9qZWN0aW9uX2RpZ2VzdCI6ICJiNGQwNzE3YTc4ZjM0YTIxOTFlZDFjYjlkYmZlMDYzZDBhZDllNGU0MTFkYjU1MGFlMGI3Yjc5MjQwMzE2Yjc2In0" + }, + { + "type": "tool_use", + "id": "toolu_golden_leaf_omitted_i64_max", + "name": "Read", + "input": { + "file_path": "/fixture/read.txt", + "offset": 1.0 + } + } + ], + "binding": { + "profile_id": "synthetic-profile", + "project_id": "synthetic-project", + "native_model": "gemini-4-argon-low" + } + }, + "expected_error": "projection digest mismatch" + }, + { + "name": "display-tamper", + "operation": "decode_turn", + "input": { + "blocks": [ + { + "type": "thinking", + "thinking": "", + "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjogMSwgInByb2ZpbGVfaWQiOiAic3ludGhldGljLXByb2ZpbGUiLCAicHJvamVjdF9pZCI6ICJzeW50aGV0aWMtcHJvamVjdCIsICJuYXRpdmVfbW9kZWwiOiAiZ2VtaW5pLTQtYXJnb24tbG93IiwgInNlc3Npb25faWQiOiAiOTIyMzM3MjAzNjg1NDc3NTgwNyIsICJ0aG91Z2h0X2Rpc3BsYXkiOiAiZnVsbCIsICJjb250ZW50cyI6IFt7InJvbGUiOiAibW9kZWwiLCAicGFydHMiOiBbeyJ0aG91Z2h0IjogdHJ1ZSwgInRleHQiOiAiR2VudWluZSBzeW50aGV0aWMgbmF0aXZlIHRob3VnaHQgXHU5NmVhIiwgInRob3VnaHRTaWduYXR1cmUiOiAicmF3LXRob3VnaHQifSwgeyJmdW5jdGlvbkNhbGwiOiB7Im5hbWUiOiAibmF0aXZlX3JlYWRfYWxpYXMiLCAiaWQiOiAicmV1c2VkLW5hdGl2ZS1pZCIsICJhcmdzIjogeyJmaWxlX3BhdGgiOiAiXCIvZml4dHVyZS9yZWFkLnR4dFwiIiwgIm9mZnNldCI6IDEuMH19LCAidGhvdWdodFNpZ25hdHVyZSI6ICJyYXctY2FsbCJ9XX1dLCAidG9vbF9tYXBwaW5nIjogW3sibG9jYWxfaWQiOiAidG9vbHVfZ29sZGVuX2xlYWZfb21pdHRlZF9pNjRfbWF4IiwgImNhbGxlcl9uYW1lIjogIlJlYWQiLCAiY29udGVudF9pbmRleCI6IDAsICJwYXJ0X2luZGV4IjogMSwgIm5hdGl2ZV9pZF9wcmVzZW50IjogdHJ1ZSwgImNvZGVjX3BhdGhzIjogWyIvZmlsZV9wYXRoIl0sICJyb290X3dyYXBwZXIiOiBudWxsLCAibmF0aXZlX2lkIjogInJldXNlZC1uYXRpdmUtaWQifV0sICJwcm9qZWN0aW9uX2RpZ2VzdCI6ICJiNGQwNzE3YTc4ZjM0YTIxOTFlZDFjYjlkYmZlMDYzZDBhZDllNGU0MTFkYjU1MGFlMGI3Yjc5MjQwMzE2Yjc2In0" + }, + { + "type": "tool_use", + "id": "toolu_golden_leaf_omitted_i64_max", + "name": "Read", + "input": { + "file_path": "/fixture/read.txt", + "offset": 1.0 + } + } + ], + "binding": { + "profile_id": "synthetic-profile", + "project_id": "synthetic-project", + "native_model": "gemini-4-argon-low" + } + }, + "expected_error": "projection digest mismatch" + }, + { + "name": "mapping-caller-tamper", + "operation": "decode_turn", + "input": { + "blocks": [ + { + "type": "thinking", + "thinking": "", + "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjogMSwgInByb2ZpbGVfaWQiOiAic3ludGhldGljLXByb2ZpbGUiLCAicHJvamVjdF9pZCI6ICJzeW50aGV0aWMtcHJvamVjdCIsICJuYXRpdmVfbW9kZWwiOiAiZ2VtaW5pLTQtYXJnb24tbG93IiwgInNlc3Npb25faWQiOiAiOTIyMzM3MjAzNjg1NDc3NTgwNyIsICJ0aG91Z2h0X2Rpc3BsYXkiOiAib21pdHRlZCIsICJjb250ZW50cyI6IFt7InJvbGUiOiAibW9kZWwiLCAicGFydHMiOiBbeyJ0aG91Z2h0IjogdHJ1ZSwgInRleHQiOiAiR2VudWluZSBzeW50aGV0aWMgbmF0aXZlIHRob3VnaHQgXHU5NmVhIiwgInRob3VnaHRTaWduYXR1cmUiOiAicmF3LXRob3VnaHQifSwgeyJmdW5jdGlvbkNhbGwiOiB7Im5hbWUiOiAibmF0aXZlX3JlYWRfYWxpYXMiLCAiaWQiOiAicmV1c2VkLW5hdGl2ZS1pZCIsICJhcmdzIjogeyJmaWxlX3BhdGgiOiAiXCIvZml4dHVyZS9yZWFkLnR4dFwiIiwgIm9mZnNldCI6IDEuMH19LCAidGhvdWdodFNpZ25hdHVyZSI6ICJyYXctY2FsbCJ9XX1dLCAidG9vbF9tYXBwaW5nIjogW3sibG9jYWxfaWQiOiAidG9vbHVfZ29sZGVuX2xlYWZfb21pdHRlZF9pNjRfbWF4IiwgImNhbGxlcl9uYW1lIjogIk90aGVyIiwgImNvbnRlbnRfaW5kZXgiOiAwLCAicGFydF9pbmRleCI6IDEsICJuYXRpdmVfaWRfcHJlc2VudCI6IHRydWUsICJjb2RlY19wYXRocyI6IFsiL2ZpbGVfcGF0aCJdLCAicm9vdF93cmFwcGVyIjogbnVsbCwgIm5hdGl2ZV9pZCI6ICJyZXVzZWQtbmF0aXZlLWlkIn1dLCAicHJvamVjdGlvbl9kaWdlc3QiOiAiYjRkMDcxN2E3OGYzNGEyMTkxZWQxY2I5ZGJmZTA2M2QwYWQ5ZTRlNDExZGI1NTBhZTBiN2I3OTI0MDMxNmI3NiJ9" + }, + { + "type": "tool_use", + "id": "toolu_golden_leaf_omitted_i64_max", + "name": "Read", + "input": { + "file_path": "/fixture/read.txt", + "offset": 1.0 + } + } + ], + "binding": { + "profile_id": "synthetic-profile", + "project_id": "synthetic-project", + "native_model": "gemini-4-argon-low" + } + }, + "expected_error": "projection digest mismatch" + }, + { + "name": "mapping-local-id-tamper", + "operation": "decode_turn", + "input": { + "blocks": [ + { + "type": "thinking", + "thinking": "", + "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjogMSwgInByb2ZpbGVfaWQiOiAic3ludGhldGljLXByb2ZpbGUiLCAicHJvamVjdF9pZCI6ICJzeW50aGV0aWMtcHJvamVjdCIsICJuYXRpdmVfbW9kZWwiOiAiZ2VtaW5pLTQtYXJnb24tbG93IiwgInNlc3Npb25faWQiOiAiOTIyMzM3MjAzNjg1NDc3NTgwNyIsICJ0aG91Z2h0X2Rpc3BsYXkiOiAib21pdHRlZCIsICJjb250ZW50cyI6IFt7InJvbGUiOiAibW9kZWwiLCAicGFydHMiOiBbeyJ0aG91Z2h0IjogdHJ1ZSwgInRleHQiOiAiR2VudWluZSBzeW50aGV0aWMgbmF0aXZlIHRob3VnaHQgXHU5NmVhIiwgInRob3VnaHRTaWduYXR1cmUiOiAicmF3LXRob3VnaHQifSwgeyJmdW5jdGlvbkNhbGwiOiB7Im5hbWUiOiAibmF0aXZlX3JlYWRfYWxpYXMiLCAiaWQiOiAicmV1c2VkLW5hdGl2ZS1pZCIsICJhcmdzIjogeyJmaWxlX3BhdGgiOiAiXCIvZml4dHVyZS9yZWFkLnR4dFwiIiwgIm9mZnNldCI6IDEuMH19LCAidGhvdWdodFNpZ25hdHVyZSI6ICJyYXctY2FsbCJ9XX1dLCAidG9vbF9tYXBwaW5nIjogW3sibG9jYWxfaWQiOiAidG9vbHVfY2hhbmdlZCIsICJjYWxsZXJfbmFtZSI6ICJSZWFkIiwgImNvbnRlbnRfaW5kZXgiOiAwLCAicGFydF9pbmRleCI6IDEsICJuYXRpdmVfaWRfcHJlc2VudCI6IHRydWUsICJjb2RlY19wYXRocyI6IFsiL2ZpbGVfcGF0aCJdLCAicm9vdF93cmFwcGVyIjogbnVsbCwgIm5hdGl2ZV9pZCI6ICJyZXVzZWQtbmF0aXZlLWlkIn1dLCAicHJvamVjdGlvbl9kaWdlc3QiOiAiYjRkMDcxN2E3OGYzNGEyMTkxZWQxY2I5ZGJmZTA2M2QwYWQ5ZTRlNDExZGI1NTBhZTBiN2I3OTI0MDMxNmI3NiJ9" + }, + { + "type": "tool_use", + "id": "toolu_golden_leaf_omitted_i64_max", + "name": "Read", + "input": { + "file_path": "/fixture/read.txt", + "offset": 1.0 + } + } + ], + "binding": { + "profile_id": "synthetic-profile", + "project_id": "synthetic-project", + "native_model": "gemini-4-argon-low" + } + }, + "expected_error": "projection digest mismatch" + }, + { + "name": "mapping-codec-path-tamper", + "operation": "decode_turn", + "input": { + "blocks": [ + { + "type": "thinking", + "thinking": "", + "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjogMSwgInByb2ZpbGVfaWQiOiAic3ludGhldGljLXByb2ZpbGUiLCAicHJvamVjdF9pZCI6ICJzeW50aGV0aWMtcHJvamVjdCIsICJuYXRpdmVfbW9kZWwiOiAiZ2VtaW5pLTQtYXJnb24tbG93IiwgInNlc3Npb25faWQiOiAiOTIyMzM3MjAzNjg1NDc3NTgwNyIsICJ0aG91Z2h0X2Rpc3BsYXkiOiAib21pdHRlZCIsICJjb250ZW50cyI6IFt7InJvbGUiOiAibW9kZWwiLCAicGFydHMiOiBbeyJ0aG91Z2h0IjogdHJ1ZSwgInRleHQiOiAiR2VudWluZSBzeW50aGV0aWMgbmF0aXZlIHRob3VnaHQgXHU5NmVhIiwgInRob3VnaHRTaWduYXR1cmUiOiAicmF3LXRob3VnaHQifSwgeyJmdW5jdGlvbkNhbGwiOiB7Im5hbWUiOiAibmF0aXZlX3JlYWRfYWxpYXMiLCAiaWQiOiAicmV1c2VkLW5hdGl2ZS1pZCIsICJhcmdzIjogeyJmaWxlX3BhdGgiOiAiXCIvZml4dHVyZS9yZWFkLnR4dFwiIiwgIm9mZnNldCI6IDEuMH19LCAidGhvdWdodFNpZ25hdHVyZSI6ICJyYXctY2FsbCJ9XX1dLCAidG9vbF9tYXBwaW5nIjogW3sibG9jYWxfaWQiOiAidG9vbHVfZ29sZGVuX2xlYWZfb21pdHRlZF9pNjRfbWF4IiwgImNhbGxlcl9uYW1lIjogIlJlYWQiLCAiY29udGVudF9pbmRleCI6IDAsICJwYXJ0X2luZGV4IjogMSwgIm5hdGl2ZV9pZF9wcmVzZW50IjogdHJ1ZSwgImNvZGVjX3BhdGhzIjogW10sICJyb290X3dyYXBwZXIiOiBudWxsLCAibmF0aXZlX2lkIjogInJldXNlZC1uYXRpdmUtaWQifV0sICJwcm9qZWN0aW9uX2RpZ2VzdCI6ICJiNGQwNzE3YTc4ZjM0YTIxOTFlZDFjYjlkYmZlMDYzZDBhZDllNGU0MTFkYjU1MGFlMGI3Yjc5MjQwMzE2Yjc2In0" + }, + { + "type": "tool_use", + "id": "toolu_golden_leaf_omitted_i64_max", + "name": "Read", + "input": { + "file_path": "/fixture/read.txt", + "offset": 1.0 + } + } + ], + "binding": { + "profile_id": "synthetic-profile", + "project_id": "synthetic-project", + "native_model": "gemini-4-argon-low" + } + }, + "expected_error": "projection digest mismatch" + }, + { + "name": "conflicting-session-history", + "operation": "decode_history", + "input": { + "turns": [ + [ + { + "type": "thinking", + "thinking": "", + "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJzZXNzaW9uX2lkIjoiOTIyMzM3MjAzNjg1NDc3NTgwNyIsInRob3VnaHRfZGlzcGxheSI6Im9taXR0ZWQiLCJjb250ZW50cyI6W3sicm9sZSI6Im1vZGVsIiwicGFydHMiOlt7InRob3VnaHQiOnRydWUsInRleHQiOiJHZW51aW5lIHN5bnRoZXRpYyBuYXRpdmUgdGhvdWdodCDpm6oiLCJ0aG91Z2h0U2lnbmF0dXJlIjoicmF3LXRob3VnaHQifSx7ImZ1bmN0aW9uQ2FsbCI6eyJuYW1lIjoibmF0aXZlX3JlYWRfYWxpYXMiLCJpZCI6InJldXNlZC1uYXRpdmUtaWQiLCJhcmdzIjp7ImZpbGVfcGF0aCI6IlwiL2ZpeHR1cmUvcmVhZC50eHRcIiIsIm9mZnNldCI6MS4wfX0sInRob3VnaHRTaWduYXR1cmUiOiJyYXctY2FsbCJ9XX1dLCJ0b29sX21hcHBpbmciOlt7ImxvY2FsX2lkIjoidG9vbHVfZ29sZGVuX2xlYWZfb21pdHRlZF9pNjRfbWF4IiwiY2FsbGVyX25hbWUiOiJSZWFkIiwiY29udGVudF9pbmRleCI6MCwicGFydF9pbmRleCI6MSwibmF0aXZlX2lkX3ByZXNlbnQiOnRydWUsImNvZGVjX3BhdGhzIjpbIi9maWxlX3BhdGgiXSwicm9vdF93cmFwcGVyIjpudWxsLCJuYXRpdmVfaWQiOiJyZXVzZWQtbmF0aXZlLWlkIn1dLCJwcm9qZWN0aW9uX2RpZ2VzdCI6ImI0ZDA3MTdhNzhmMzRhMjE5MWVkMWNiOWRiZmUwNjNkMGFkOWU0ZTQxMWRiNTUwYWUwYjdiNzkyNDAzMTZiNzYifQ" + }, + { + "type": "tool_use", + "id": "toolu_golden_leaf_omitted_i64_max", + "name": "Read", + "input": { + "file_path": "/fixture/read.txt", + "offset": 1.0 + } + } + ], + [ + { + "type": "thinking", + "thinking": "Genuine synthetic native thought 雪", + "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJzZXNzaW9uX2lkIjoiLTM3IiwidGhvdWdodF9kaXNwbGF5IjoiZnVsbCIsImNvbnRlbnRzIjpbeyJyb2xlIjoibW9kZWwiLCJwYXJ0cyI6W3sidGhvdWdodCI6dHJ1ZSwidGV4dCI6IkdlbnVpbmUgc3ludGhldGljIG5hdGl2ZSB0aG91Z2h0IOmbqiIsInRob3VnaHRTaWduYXR1cmUiOiJyYXctdGhvdWdodCJ9LHsiZnVuY3Rpb25DYWxsIjp7Im5hbWUiOiJuYXRpdmVfcmVhZF9hbGlhcyIsImlkIjoicmV1c2VkLW5hdGl2ZS1pZCIsImFyZ3MiOnsiZmlsZV9wYXRoIjoiXCIvZml4dHVyZS9yZWFkLnR4dFwiIiwib2Zmc2V0IjoxLjB9fSwidGhvdWdodFNpZ25hdHVyZSI6InJhdy1jYWxsIn1dfV0sInRvb2xfbWFwcGluZyI6W3sibG9jYWxfaWQiOiJ0b29sdV9nb2xkZW5fbGVhZl9mdWxsX25lZ2F0aXZlX3Nlc3Npb24iLCJjYWxsZXJfbmFtZSI6IlJlYWQiLCJjb250ZW50X2luZGV4IjowLCJwYXJ0X2luZGV4IjoxLCJuYXRpdmVfaWRfcHJlc2VudCI6dHJ1ZSwiY29kZWNfcGF0aHMiOlsiL2ZpbGVfcGF0aCJdLCJyb290X3dyYXBwZXIiOm51bGwsIm5hdGl2ZV9pZCI6InJldXNlZC1uYXRpdmUtaWQifV0sInByb2plY3Rpb25fZGlnZXN0IjoiYTg1OTFiYTAwNjk5OWVkMmJhMTZjNjE5YTdiNzZlMzc5OTJlZWM5YmEwMDQzNDMxOWQ4YTliMTJiNmI3N2RlMSJ9" + }, + { + "type": "tool_use", + "id": "toolu_golden_leaf_full_negative_session", + "name": "Read", + "input": { + "file_path": "/fixture/read.txt", + "offset": 1.0 + } + } + ] + ], + "binding": { + "profile_id": "synthetic-profile", + "project_id": "synthetic-project", + "native_model": "gemini-4-argon-low" + } + }, + "expected_error": "conflicting session IDs in history" + }, + { + "name": "malformed-json", + "operation": "project_turn", + "input": { + "contents": [ + { + "role": "model", + "parts": [ + { + "thought": true, + "text": "Genuine synthetic native thought 雪", + "thoughtSignature": "raw-thought" + }, + { + "functionCall": { + "name": "native_read_alias", + "id": "reused-native-id", + "args": { + "file_path": "not-json", + "offset": 1 + } + }, + "thoughtSignature": "raw-call" + } + ] + } + ], + "binding": { + "profile_id": "synthetic-profile", + "project_id": "synthetic-project", + "native_model": "gemini-4-argon-low" + }, + "session_id": "1", + "thought_display": "omitted", + "tool_projections": [ + { + "caller_name": "Read", + "codec_paths": [ + "/file_path" + ], + "root_wrapper": null + } + ] + }, + "expected_error": "invalid codec JSON", + "emitted_tool_use": false + }, + { + "name": "root-not-object", + "operation": "project_turn", + "input": { + "contents": [ + { + "role": "model", + "parts": [ + { + "thought": true, + "text": "Genuine synthetic native thought 雪", + "thoughtSignature": "raw-thought" + }, + { + "functionCall": { + "name": "native_read_alias", + "id": "reused-native-id", + "args": { + "json": "[]" + } + }, + "thoughtSignature": "raw-call" + } + ] + } + ], + "binding": { + "profile_id": "synthetic-profile", + "project_id": "synthetic-project", + "native_model": "gemini-4-argon-low" + }, + "session_id": "1", + "thought_display": "omitted", + "tool_projections": [ + { + "caller_name": "Read", + "codec_paths": [], + "root_wrapper": "json" + } + ] + }, + "expected_error": "root codec must decode an object", + "emitted_tool_use": false + }, + { + "name": "root-extra-key", + "operation": "project_turn", + "input": { + "contents": [ + { + "role": "model", + "parts": [ + { + "thought": true, + "text": "Genuine synthetic native thought 雪", + "thoughtSignature": "raw-thought" + }, + { + "functionCall": { + "name": "native_read_alias", + "id": "reused-native-id", + "args": { + "json": "{}", + "extra": 1 + } + }, + "thoughtSignature": "raw-call" + } + ] + } + ], + "binding": { + "profile_id": "synthetic-profile", + "project_id": "synthetic-project", + "native_model": "gemini-4-argon-low" + }, + "session_id": "1", + "thought_display": "omitted", + "tool_projections": [ + { + "caller_name": "Read", + "codec_paths": [], + "root_wrapper": "json" + } + ] + }, + "expected_error": "root wrapper requires exactly one named key", + "emitted_tool_use": false + }, + { + "name": "duplicate-path", + "operation": "project_turn", + "input": { + "contents": [ + { + "role": "model", + "parts": [ + { + "thought": true, + "text": "Genuine synthetic native thought 雪", + "thoughtSignature": "raw-thought" + }, + { + "functionCall": { + "name": "native_read_alias", + "id": "reused-native-id", + "args": { + "file_path": "\"/fixture/read.txt\"" + } + }, + "thoughtSignature": "raw-call" + } + ] + } + ], + "binding": { + "profile_id": "synthetic-profile", + "project_id": "synthetic-project", + "native_model": "gemini-4-argon-low" + }, + "session_id": "1", + "thought_display": "omitted", + "tool_projections": [ + { + "caller_name": "Read", + "codec_paths": [ + "/file_path", + "/file_path" + ], + "root_wrapper": null + } + ] + }, + "expected_error": "duplicate/overlapping codec paths", + "emitted_tool_use": false + }, + { + "name": "overlapping-path", + "operation": "project_turn", + "input": { + "contents": [ + { + "role": "model", + "parts": [ + { + "thought": true, + "text": "Genuine synthetic native thought 雪", + "thoughtSignature": "raw-thought" + }, + { + "functionCall": { + "name": "native_read_alias", + "id": "reused-native-id", + "args": { + "payload": { + "x": "1" + } + } + }, + "thoughtSignature": "raw-call" + } + ] + } + ], + "binding": { + "profile_id": "synthetic-profile", + "project_id": "synthetic-project", + "native_model": "gemini-4-argon-low" + }, + "session_id": "1", + "thought_display": "omitted", + "tool_projections": [ + { + "caller_name": "Read", + "codec_paths": [ + "/payload", + "/payload/x" + ], + "root_wrapper": null + } + ] + }, + "expected_error": "duplicate/overlapping codec paths", + "emitted_tool_use": false + }, + { + "name": "out-of-range-path", + "operation": "project_turn", + "input": { + "contents": [ + { + "role": "model", + "parts": [ + { + "thought": true, + "text": "Genuine synthetic native thought 雪", + "thoughtSignature": "raw-thought" + }, + { + "functionCall": { + "name": "native_read_alias", + "id": "reused-native-id", + "args": { + "array": [ + "1" + ] + } + }, + "thoughtSignature": "raw-call" + } + ] + } + ], + "binding": { + "profile_id": "synthetic-profile", + "project_id": "synthetic-project", + "native_model": "gemini-4-argon-low" + }, + "session_id": "1", + "thought_display": "omitted", + "tool_projections": [ + { + "caller_name": "Read", + "codec_paths": [ + "/array/2" + ], + "root_wrapper": null + } + ] + }, + "expected_error": "codec path array index out of range", + "emitted_tool_use": false + } + ] +} diff --git a/tests/fixtures/antigravity-parameterless-v1.json b/tests/fixtures/antigravity-parameterless-v1.json new file mode 100644 index 00000000..2580a8a7 --- /dev/null +++ b/tests/fixtures/antigravity-parameterless-v1.json @@ -0,0 +1 @@ +{"name":"parameterless-omitted-args","binding":{"profile_id":"synthetic-profile","project_id":"synthetic-project","native_model":"gemini-4-argon-low"},"native_contents":[{"role":"model","parts":[{"functionCall":{"name":"empty"},"thoughtSignature":"synthetic-signature"}]}],"session_id":"19","thought_display":"full","tool_projections":[{"caller_name":"empty","local_id":"toolu_parameterless_golden","codec_paths":[],"root_wrapper":null}],"projected_blocks":[{"type":"thinking","thinking":"","signature":"ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJzZXNzaW9uX2lkIjoiMTkiLCJ0aG91Z2h0X2Rpc3BsYXkiOiJmdWxsIiwiY29udGVudHMiOlt7InJvbGUiOiJtb2RlbCIsInBhcnRzIjpbeyJmdW5jdGlvbkNhbGwiOnsibmFtZSI6ImVtcHR5In0sInRob3VnaHRTaWduYXR1cmUiOiJzeW50aGV0aWMtc2lnbmF0dXJlIn1dfV0sInRvb2xfbWFwcGluZyI6W3sibG9jYWxfaWQiOiJ0b29sdV9wYXJhbWV0ZXJsZXNzX2dvbGRlbiIsImNhbGxlcl9uYW1lIjoiZW1wdHkiLCJjb250ZW50X2luZGV4IjowLCJwYXJ0X2luZGV4IjowLCJuYXRpdmVfaWRfcHJlc2VudCI6ZmFsc2UsImNvZGVjX3BhdGhzIjpbXSwicm9vdF93cmFwcGVyIjpudWxsfV0sInByb2plY3Rpb25fZGlnZXN0IjoiYTA1ZjJkODg4MWE3ZWRmOTdjYTgzNjE3ZTFhYTQyZjI0OWQxYzNiMGZjMGRlNWQ0MmU0OTVlNDAzZmE0OTI2OCJ9"},{"type":"tool_use","id":"toolu_parameterless_golden","name":"empty","input":{}}],"envelope":{"version":1,"profile_id":"synthetic-profile","project_id":"synthetic-project","native_model":"gemini-4-argon-low","session_id":"19","thought_display":"full","contents":[{"role":"model","parts":[{"functionCall":{"name":"empty"},"thoughtSignature":"synthetic-signature"}]}],"tool_mapping":[{"local_id":"toolu_parameterless_golden","caller_name":"empty","content_index":0,"part_index":0,"native_id_present":false,"codec_paths":[],"root_wrapper":null}],"projection_digest":"a05f2d8881a7edf97ca83617e1aa42f249d1c3b0fc0de5d42e495e403fa49268"},"canonical_digest_input_utf8":"[\"object\",[[\"binding\",[\"object\",[[\"native_model\",[\"string\",\"gemini-4-argon-low\"]],[\"profile_id\",[\"string\",\"synthetic-profile\"]],[\"project_id\",[\"string\",\"synthetic-project\"]],[\"session_id\",[\"string\",\"19\"]],[\"thought_display\",[\"string\",\"full\"]],[\"tool_mapping\",[\"array\",[[\"object\",[[\"caller_name\",[\"string\",\"empty\"]],[\"codec_paths\",[\"array\",[]]],[\"content_index\",[\"number\",\"0000000000000000\"]],[\"local_id\",[\"string\",\"toolu_parameterless_golden\"]],[\"native_id_present\",[\"bool\",false]],[\"part_index\",[\"number\",\"0000000000000000\"]],[\"root_wrapper\",[\"null\"]]]]]]]]]],[\"blocks\",[\"array\",[[\"object\",[[\"thinking\",[\"string\",\"\"]],[\"type\",[\"string\",\"thinking\"]]]],[\"object\",[[\"id\",[\"string\",\"toolu_parameterless_golden\"]],[\"input\",[\"object\",[]]],[\"name\",[\"string\",\"empty\"]],[\"type\",[\"string\",\"tool_use\"]]]]]]]]]","canonical_digest_input_hex":"5b226f626a656374222c5b5b2262696e64696e67222c5b226f626a656374222c5b5b226e61746976655f6d6f64656c222c5b22737472696e67222c2267656d696e692d342d6172676f6e2d6c6f77225d5d2c5b2270726f66696c655f6964222c5b22737472696e67222c2273796e7468657469632d70726f66696c65225d5d2c5b2270726f6a6563745f6964222c5b22737472696e67222c2273796e7468657469632d70726f6a656374225d5d2c5b2273657373696f6e5f6964222c5b22737472696e67222c223139225d5d2c5b2274686f756768745f646973706c6179222c5b22737472696e67222c2266756c6c225d5d2c5b22746f6f6c5f6d617070696e67222c5b226172726179222c5b5b226f626a656374222c5b5b2263616c6c65725f6e616d65222c5b22737472696e67222c22656d707479225d5d2c5b22636f6465635f7061746873222c5b226172726179222c5b5d5d5d2c5b22636f6e74656e745f696e646578222c5b226e756d626572222c2230303030303030303030303030303030225d5d2c5b226c6f63616c5f6964222c5b22737472696e67222c22746f6f6c755f706172616d657465726c6573735f676f6c64656e225d5d2c5b226e61746976655f69645f70726573656e74222c5b22626f6f6c222c66616c73655d5d2c5b22706172745f696e646578222c5b226e756d626572222c2230303030303030303030303030303030225d5d2c5b22726f6f745f77726170706572222c5b226e756c6c225d5d5d5d5d5d5d5d5d5d2c5b22626c6f636b73222c5b226172726179222c5b5b226f626a656374222c5b5b227468696e6b696e67222c5b22737472696e67222c22225d5d2c5b2274797065222c5b22737472696e67222c227468696e6b696e67225d5d5d5d2c5b226f626a656374222c5b5b226964222c5b22737472696e67222c22746f6f6c755f706172616d657465726c6573735f676f6c64656e225d5d2c5b22696e707574222c5b226f626a656374222c5b5d5d5d2c5b226e616d65222c5b22737472696e67222c22656d707479225d5d2c5b2274797065222c5b22737472696e67222c22746f6f6c5f757365225d5d5d5d5d5d5d5d5d","sha256":"a05f2d8881a7edf97ca83617e1aa42f249d1c3b0fc0de5d42e495e403fa49268"} diff --git a/tests/server.rs b/tests/server.rs index ed44d862..6e03ed72 100644 --- a/tests/server.rs +++ b/tests/server.rs @@ -1579,3 +1579,573 @@ async fn models_endpoint_tolerates_unknown_query_params() { let (status, _) = get_models(app, "/v1/models?limit=1000&after_id=x").await; assert_eq!(status, StatusCode::OK); } + +#[cfg(test)] +mod antigravity_entry_tests { + use super::*; + + const MODELS: &[&str] = &[ + "antigravity/gemini-4-argon-low", + "antigravity/gemini-4-argon-medium", + "antigravity/gemini-4-argon-high", + ]; + const ENDPOINTS: &[&str] = &["/v1/messages", "/v1/messages/count_tokens"]; + type Dispatches = Arc>>; + + // This provider deliberately accepts every dispatched request. A rejection + // therefore proves the server gate ran, not provider-local validation or an + // unknown-model lookup caused by the real provider being disabled. + struct EntryCaptureProvider { + name: &'static str, + models: Vec, + dispatches: Dispatches, + } + + impl EntryCaptureProvider { + fn record( + &self, + endpoint: &'static str, + body: MessagesRequest, + ) -> axum::response::Response { + let model = body.model.expect("server must resolve the model"); + self.dispatches + .lock() + .unwrap() + .push((endpoint, model.clone())); + axum::Json(json!({"model": model, "provider": self.name})).into_response() + } + } + + #[async_trait] + impl Provider for EntryCaptureProvider { + fn name(&self) -> &'static str { + self.name + } + + fn supported_models(&self) -> Vec { + self.models.clone() + } + + fn cli(&self) -> &'static dyn CliHandlers { + &FAKE_CLI + } + + async fn handle_messages( + &self, + body: MessagesRequest, + _ctx: RequestContext, + ) -> axum::response::Response { + self.record("/v1/messages", body) + } + + async fn handle_count_tokens( + &self, + body: MessagesRequest, + _ctx: RequestContext, + ) -> axum::response::Response { + self.record("/v1/messages/count_tokens", body) + } + } + + fn capture_app(name: &'static str, models: &[&str]) -> (axum::Router, Dispatches) { + let dispatches = Arc::new(Mutex::new(Vec::new())); + let registry = Registry::from_providers( + AliasProvider::Kimi, + [Arc::new(EntryCaptureProvider { + name, + models: models.iter().map(|model| (*model).to_string()).collect(), + dispatches: dispatches.clone(), + }) as Arc], + ); + ( + app_with_features(Arc::new(registry), None, AppFeatures::default()), + dispatches, + ) + } + + async fn post( + app: &axum::Router, + endpoint: &str, + model: &str, + headers: &[(&str, &str)], + ) -> axum::response::Response { + let mut request = Request::builder() + .method(Method::POST) + .uri(endpoint) + .header("content-type", "application/json"); + for (name, value) in headers { + request = request.header(*name, *value); + } + app.clone() + .oneshot( + request + .body(Body::from( + json!({ + "model": model, + "max_tokens": 32, + "messages": [{"role": "user", "content": "synthetic entry test"}] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap() + } + + async fn assert_context_rejected(response: axum::response::Response, dispatches: &Dispatches) { + assert!( + dispatches.lock().unwrap().is_empty(), + "unsupported context must fail before either provider handler" + ); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert!(!request_id(&response).is_empty()); + let body = error_body(response).await; + assert_eq!(body["type"], "error"); + assert_eq!(body["error"]["type"], "invalid_request_error"); + let message = body["error"]["message"].as_str().unwrap(); + assert!(message.to_ascii_lowercase().contains("antigravity")); + assert!( + message.contains("1m"), + "actionable context error: {message}" + ); + } + + #[tokio::test] + async fn antigravity_raw_1m_is_rejected_before_normalization_on_both_endpoints() { + let (app, dispatches) = capture_app("antigravity", MODELS); + for endpoint in ENDPOINTS { + for model in MODELS { + for suffix in ["[1m]", "[1M]"] { + let response = post(&app, endpoint, &format!("{model}{suffix}"), &[]).await; + assert_context_rejected(response, &dispatches).await; + } + } + } + } + + #[tokio::test] + async fn antigravity_1m_beta_is_rejected_in_every_header_on_both_endpoints() { + let (app, dispatches) = capture_app("antigravity", MODELS); + let header_cases = [ + vec![("anthropic-beta", "context-1m-2025-08-07")], + vec![( + "anthropic-beta", + "prompt-caching-2024-07-31, context-1m-2025-08-07 ,other-beta", + )], + vec![ + ("anthropic-beta", "prompt-caching-2024-07-31"), + ("anthropic-beta", "context-1m-2025-08-07"), + ], + vec![ + ("AnThRoPiC-BeTa", "context-1m-2025-08-07"), + ("anthropic-beta", "prompt-caching-2024-07-31"), + ], + ]; + for endpoint in ENDPOINTS { + for model in MODELS { + for headers in &header_cases { + let response = post(&app, endpoint, model, headers).await; + assert_context_rejected(response, &dispatches).await; + } + } + } + } + + #[tokio::test] + async fn antigravity_exact_models_and_unrelated_betas_reach_the_provider() { + let (app, dispatches) = capture_app("antigravity", MODELS); + for endpoint in ENDPOINTS { + for model in MODELS { + for beta in [ + None, + Some("prompt-caching-2024-07-31"), + Some("context-1m-2025-08-07-extra"), + Some("not-context-1m-2025-08-07"), + ] { + let headers: Vec<_> = beta + .map(|value| ("anthropic-beta", value)) + .into_iter() + .collect(); + let response = post(&app, endpoint, model, &headers).await; + assert_eq!(response.status(), StatusCode::OK); + let body = error_body(response).await; + assert_eq!(body, json!({"model": model, "provider": "antigravity"})); + assert_eq!( + std::mem::take(&mut *dispatches.lock().unwrap()), + vec![(*endpoint, (*model).to_string())] + ); + } + } + } + } + + #[tokio::test] + async fn antigravity_unknown_selector_never_dispatches_to_a_known_tier() { + let (app, dispatches) = capture_app("antigravity", MODELS); + for endpoint in ENDPOINTS { + for model in [ + "antigravity/gemini-4-argon", + "antigravity/gemini-4-argon-ultra", + "gemini-4-argon-low", + "antigravity/claude-opus-5", + ] { + let response = post(&app, endpoint, model, &[]).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert!(dispatches.lock().unwrap().is_empty()); + let body = error_body(response).await; + assert_eq!(body["error"]["type"], "invalid_request_error"); + } + } + } + + #[tokio::test] + async fn antigravity_entry_gate_preserves_existing_provider_context_behavior() { + for (provider, model) in [ + ("codex", "gpt-6-astra-ultrafast"), + ("kimi", "kimi-k2.6"), + ("cursor", "cursor:gpt-5.5"), + ("grok", "grok-4.7"), + ("opencode", "opencode-go/kimi-k2.6"), + ] { + let (app, dispatches) = capture_app(provider, &[model]); + for endpoint in ENDPOINTS { + for suffix in ["", "[1m]", "[1M]"] { + let response = post( + &app, + endpoint, + &format!("{model}{suffix}"), + &[("anthropic-beta", "context-1m-2025-08-07")], + ) + .await; + assert_eq!(response.status(), StatusCode::OK, "{provider} {endpoint}"); + assert_eq!( + error_body(response).await, + json!({"model": model, "provider": provider}) + ); + assert_eq!( + std::mem::take(&mut *dispatches.lock().unwrap()), + vec![(*endpoint, model.to_string())] + ); + } + } + } + } + + #[test] + fn antigravity_inbound_review_override_validates_original_bytes_in_isolated_process() { + for target in ["antigravity/gemini-4-argon-low", "grok-4.7"] { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "antigravity_entry_tests::antigravity_inbound_review_override_child", + "--exact", + "--nocapture", + ]) + .env("CCP_ANTIGRAVITY_TEST_REVIEW_CHILD", "1") + .env("CCP_AUTO_REVIEW_MODEL", target) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + } + + #[tokio::test] + async fn antigravity_inbound_review_override_child() { + if std::env::var("CCP_ANTIGRAVITY_TEST_REVIEW_CHILD").is_err() { + return; + } + let native = std::env::var("CCP_AUTO_REVIEW_MODEL") + .unwrap() + .starts_with("antigravity/"); + let dispatches = Arc::new(Mutex::new(Vec::new())); + let providers = [ + ("codex", "gpt-6-luna"), + ("grok", "grok-4.7"), + ("antigravity", MODELS[0]), + ] + .map(|(name, model)| { + Arc::new(EntryCaptureProvider { + name, + models: vec![model.into()], + dispatches: dispatches.clone(), + }) as Arc + }); + let app = app_with_features( + Arc::new(Registry::from_providers(AliasProvider::Codex, providers)), + None, + AppFeatures::default(), + ); + let raw = |model: &str, message: &str| { + format!( + r#"{{"model":"{model}","max_tokens":32,"system":[{{"type":"text","text":"You are a security monitor for autonomous AI coding agents."}}],"messages":[{message}]}}"# + ) + }; + let cases = [ + ( + raw( + "gpt-6-luna", + r#"{"role":"user","content":"review","unsupported":true}"#, + ), + Vec::new(), + ), + ( + raw( + "gpt-6-luna", + r#"{"role":"user","content":[{"type":"text","text":"first","text":"second"}]}"#, + ), + Vec::new(), + ), + ( + raw("gpt-6-luna[1m]", r#"{"role":"user","content":"review"}"#), + Vec::new(), + ), + ( + raw("gpt-6-luna", r#"{"role":"user","content":"review"}"#), + vec!["unrelated", "other, context-1m-2025-08-07"], + ), + ]; + for (body, betas) in cases { + let mut request = Request::builder() + .method(Method::POST) + .uri("/v1/messages") + .header("content-type", "application/json"); + for beta in betas { + request = request.header("anthropic-beta", beta); + } + let response = app + .clone() + .oneshot(request.body(Body::from(body)).unwrap()) + .await + .unwrap(); + assert_eq!( + response.status(), + if native { + StatusCode::BAD_REQUEST + } else { + StatusCode::OK + } + ); + let calls = std::mem::take(&mut *dispatches.lock().unwrap()); + assert_eq!( + calls.len(), + if native { 0 } else { 1 }, + "invalid native reroute reached provider" + ); + } + let body = raw("gpt-6-luna", r#"{"role":"user","content":"review"}"#); + let response = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/v1/messages") + .header("content-type", "application/json") + .body(Body::from(body.clone())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + std::mem::take(&mut *dispatches.lock().unwrap())[0].1, + if native { MODELS[0] } else { "grok-4.7" } + ); + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/v1/messages/count_tokens") + .header("content-type", "application/json") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(dispatches.lock().unwrap()[0].1, "gpt-6-luna"); + } + + #[test] + fn antigravity_logout_removes_only_its_store_despite_invalid_network_configuration() { + let directory = tempfile::tempdir().unwrap(); + let own = directory.path().join("antigravity/auth.json"); + let other = directory.path().join("codex/auth.json"); + claude_code_proxy::auth::write_atomically( + other.to_str().unwrap(), + &json!({"sentinel":"unrelated-provider"}), + ) + .unwrap(); + for config in [ + r#"{"antigravity":{"baseUrl":"http://not-allowed.invalid"}}"#, + "not JSON", + ] { + claude_code_proxy::auth::write_atomically(own.to_str().unwrap(), &json!({"profile_id":"owned","project_id":"owned-project","access_token":"synthetic","refresh_token":"synthetic-refresh","expires_at":0})).unwrap(); + std::fs::write(directory.path().join("config.json"), config).unwrap(); + let output = + std::process::Command::new(assert_cmd::cargo::cargo_bin!("claude-code-proxy")) + .args(["antigravity", "auth", "logout"]) + .env("CCP_CONFIG_DIR", directory.path()) + .output() + .unwrap(); + assert!( + output.status.success(), + "logout must not validate network config: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(!own.exists()); + assert!(directory.path().join("antigravity/auth.lock").is_file()); + assert_eq!( + serde_json::from_slice::(&std::fs::read(&other).unwrap()).unwrap(), + json!({"sentinel":"unrelated-provider"}) + ); + } + } + + #[test] + fn antigravity_opt_in_catalog_is_exact_and_other_provider_cli_is_unchanged() { + let directory = tempfile::tempdir().unwrap(); + let run = |enabled: &str, args: &[&str]| { + std::process::Command::new(assert_cmd::cargo::cargo_bin!("claude-code-proxy")) + .args(args) + .env("CCP_CONFIG_DIR", directory.path()) + .env("CCP_ANTIGRAVITY_ENABLED", enabled) + .output() + .unwrap() + }; + let plain = run("0", &["models"]); + let enabled = run("1", &["models"]); + assert!(plain.status.success() && enabled.status.success()); + let enabled = String::from_utf8(enabled.stdout).unwrap(); + let retained = enabled + .lines() + .filter(|line| !line.starts_with("antigravity:")) + .collect::>() + .join("\n") + + "\n"; + assert_eq!(retained.as_bytes(), plain.stdout); + let line = enabled + .lines() + .find(|line| line.starts_with("antigravity:")) + .unwrap(); + assert_eq!( + line, + "antigravity: antigravity/gemini-4-argon-high, antigravity/gemini-4-argon-low, antigravity/gemini-4-argon-medium" + ); + assert!( + !run("0", &["codex", "auth", "status", "--json"]) + .status + .success() + ); + assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 0); + } + + #[test] + fn antigravity_auth_status_json_is_available_disabled_and_read_only() { + let directory = tempfile::tempdir().unwrap(); + let output = std::process::Command::new(assert_cmd::cargo::cargo_bin!("claude-code-proxy")) + .args(["antigravity", "auth", "status", "--json"]) + .env("CCP_CONFIG_DIR", directory.path()) + .env("CCP_ANTIGRAVITY_ENABLED", "0") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let value: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(value["schema_version"], 1); + assert_eq!(value["provider"], "antigravity"); + assert_eq!(value["enabled"], false); + assert_eq!(value["status"]["auth"], "missing"); + assert_eq!(value["status"]["live_verified"], false); + assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 0); + } + + #[tokio::test] + async fn antigravity_capability_query_reports_only_local_safe_readiness() { + let registry = Arc::new(Registry::new(AliasProvider::Codex)); + let app = app_with_features(registry, None, AppFeatures::default()); + let (_, plain) = get_models(app.clone(), "/v1/models").await; + assert!(plain.get("capabilities").is_none()); + let (status, mut inspected) = get_models(app, "/v1/models?include_capabilities=true").await; + assert_eq!(status, StatusCode::OK); + let capabilities = inspected.as_object_mut().unwrap().remove("capabilities"); + assert_eq!(inspected, plain); + assert_eq!( + capabilities, + Some(json!({ + "schema_version": 1, + "providers": {"antigravity": { + "supported": true, "enabled": false, "mode": "native", "experimental": true, + "supported_apis": ["messages", "count_tokens"], "session_policy": "fixed-model", + "context_window": 200000, "backend_window_verified": false, + "status": {"configuration": "valid", "auth": "missing", "project": "missing", + "configuration_ready": false, "live_verified": false} + }} + })) + ); + } + + #[tokio::test] + async fn antigravity_disabled_requests_explain_the_opt_in_on_both_endpoints() { + let app = app_with_features( + Arc::new(Registry::new(AliasProvider::Codex)), + None, + AppFeatures::default(), + ); + for endpoint in ENDPOINTS { + for model in MODELS { + let response = post(&app, endpoint, model, &[]).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = error_body(response).await; + assert!( + body["error"]["message"] + .as_str() + .unwrap() + .contains("CCP_ANTIGRAVITY_ENABLED") + ); + } + } + } + + #[tokio::test] + async fn antigravity_default_models_json_remains_unchanged() { + for alias in [AliasProvider::Codex, AliasProvider::Kimi] { + let registry = Arc::new(Registry::new(alias)); + let data: Vec = registry + .all_supported_models() + .into_iter() + .map(|(model, provider)| { + assert_ne!(provider, "antigravity"); + assert!(!model.starts_with("antigravity/")); + json!({ + "type": "model", + "object": "model", + "id": model, + "display_name": format!("{model} ({provider})") + }) + }) + .collect(); + assert!(!data.is_empty()); + let expected = json!({ + "object": "list", + "data": data, + "has_more": false, + "first_id": data.first().unwrap()["id"], + "last_id": data.last().unwrap()["id"] + }); + let app = app_with_features(registry, None, AppFeatures::default()); + for uri in ["/v1/models", "/v1/models?include_capabilities=false"] { + let (status, actual) = get_models(app.clone(), uri).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(actual, expected); + } + } + } +} From 1223fbfa016f021fc313e80b72ad8c92a822f567 Mon Sep 17 00:00:00 2001 From: Chris Liu <11517021+chris7iu@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:24:45 +0000 Subject: [PATCH 02/30] feat(antigravity): allow an explicit native API User-Agent override Default all outbound requests to claude-code-proxy/. Apply a validated provider-specific full User-Agent only to generation and project discovery, preserving proxy identity for OAuth and exact override bytes. Keep the independently validated OS-string override outside legacy UTF-8 environment snapshots, with redacted failures and unchanged provider-scoped invalid configuration behavior. Tests-first regressions, independent review, fmt, Clippy, build and 1222 isolated tests pass under umask 022. No live activation. Co-Authored-By: Claude Code --- README.md | 3 + .../compatibility-and-limitations.md | 19 +- .../content/docs/reference/configuration.md | 52 +++- src/config.rs | 56 ++--- src/environment.rs | 173 +++++++++++++ src/lib.rs | 1 + src/paths.rs | 2 +- src/providers/antigravity/auth.rs | 1 + src/providers/antigravity/config.rs | 238 +++++++++++++++++- src/providers/antigravity/mod.rs | 1 + src/providers/antigravity_auth_tests.rs | 95 ++++++- src/providers/antigravity_http_tests.rs | 101 +++++++- src/traffic.rs | 2 +- tests/cli.rs | 223 ++++++++++++++++ 14 files changed, 913 insertions(+), 54 deletions(-) create mode 100644 src/environment.rs diff --git a/README.md b/README.md index 2494948f..739b7628 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,9 @@ Run `claude-code-proxy models` for the current catalog or An additional **experimental, default-off native Antigravity Argon adapter** supports only `antigravity/gemini-4-argon-low`, `antigravity/gemini-4-argon-medium`, and `antigravity/gemini-4-argon-high`. It does not run or import credentials from `agy`. +Its default HTTP User-Agent is `claude-code-proxy/`. An explicit +provider-specific override affects only native generation/project discovery, +not OAuth; it is compatibility metadata, not verified official-client identity. Use a fresh session for every model/tier/profile change. Real Google OAuth, entitlement, native tool round trips and all three tiers remain live-validation gates; offline tests are not evidence of account access. See the diff --git a/docs/src/content/docs/reference/compatibility-and-limitations.md b/docs/src/content/docs/reference/compatibility-and-limitations.md index 3098b8af..9295c212 100644 --- a/docs/src/content/docs/reference/compatibility-and-limitations.md +++ b/docs/src/content/docs/reference/compatibility-and-limitations.md @@ -234,7 +234,7 @@ Cross-provider overrides out of explicit Antigravity routes remain blocked. Synthetic Rust HTTP/OAuth tests and an isolated real-client replay fixture do not prove Google acceptance, entitlement, billing or native model limits. Browser -OAuth with the configured client, project readiness, truthful proxy identity, +OAuth with the configured client, project readiness, the selected HTTP identity, native text/reasoning/tool round trips and **all three tiers** still require real-account smoke validation before activation. Low has public request-shape evidence; Medium/High encodings are catalog-inferred. Catalog evidence indicates @@ -243,6 +243,21 @@ long-context compaction and native compaction acceptance are unverified. A manual real-client compact/resume fixture does not establish general compaction support. +The default HTTP User-Agent is exactly `claude-code-proxy/` on +all Antigravity operations. An explicit `CCP_ANTIGRAVITY_USER_AGENT` or +`antigravity.userAgent` override is verbatim compatibility metadata applied only to +native generation and `loadCodeAssist`; OAuth exchange and refresh keep proxy +identification. The historical CLI 1.3.5 / changelist 998863838 example comes from +this pinned public generation fixture: +https://github.com/cortexkit/antigravity-auth/blob/f5a836df058a99cee6099ad308b9675e4ef11026/test-fixtures/agy-cli-1.3.5/native-argon-low.json. +Project-discovery use is reference-adapter behavior, not a new official capture. +The override is not a host-platform assertion, account verification or +attestation. Exact CLI 1.4.2 matching and provider policy/terms compatibility are +unverified. There is no runtime CLI detection, automatic profile upgrade, device +identity fabrication or change to OAuth clients/scopes. User-Agent is not +authentication and cannot establish access. See +[override validation and precedence](/reference/configuration/#experimental-antigravity). + ### Optional offline client fixture `tests/antigravity_compatibility/run_container.py` is developer test tooling, not @@ -306,7 +321,7 @@ Protocol references were independently translated from MIT-licensed https://github.com/cortexkit/antigravity-auth/tree/f5a836df058a99cee6099ad308b9675e4ef11026. Copyright (c) 2025 Jens; the reference license is retained in `meta/antigravity-reference-LICENSE`. No full captured CLI system prompts, -proprietary descriptions, binaries, fingerprint impersonation or signature-bypass +proprietary descriptions, binaries, device/session fingerprint fabrication or signature-bypass code are included. Test vectors contain synthetic/minimized data only. ## Diagnostics and privacy diff --git a/docs/src/content/docs/reference/configuration.md b/docs/src/content/docs/reference/configuration.md index 01def66c..563693de 100644 --- a/docs/src/content/docs/reference/configuration.md +++ b/docs/src/content/docs/reference/configuration.md @@ -169,14 +169,56 @@ separate real-account OAuth/native smoke validation. | `CCP_ANTIGRAVITY_CLIENT_SECRET` | `antigravity.clientSecret` | unset | Optional public-client application secret, when required by that registered application. Not a user bearer token. | | `CCP_ANTIGRAVITY_PROJECT_ID` | `antigravity.projectId` | stored discovery result | Your authorized project; no borrowed project or automatic onboarding. | | `CCP_ANTIGRAVITY_BASE_URL` | `antigravity.baseUrl` | `https://cloudcode-pa.googleapis.com` | Explicit trusted HTTPS origin override. No userinfo, query, fragment, or path. Never automatically falls back to another endpoint. | +| `CCP_ANTIGRAVITY_USER_AGENT` | `antigravity.userAgent` | `claude-code-proxy/` | Full HTTP User-Agent override for native generation and `loadCodeAssist` only. OAuth token exchange/refresh always retain the default proxy identity. | Remote endpoints require HTTPS, redirects are never followed, and OAuth endpoints are fixed to `https://accounts.google.com/o/oauth2/v2/auth` and `https://oauth2.googleapis.com/token`. Plain loopback HTTP endpoints exist only as -injected test fixtures, not environment/config options. The HTTP User-Agent -identifies `claude-code-proxy` and its real platform as an Antigravity-compatible -client. Product selectors `userAgent: antigravity` and `requestType: agent` do not -claim an official CLI build or device identity. +injected test fixtures, not environment/config options. By default every HTTP +request sent by this adapter uses exactly `claude-code-proxy/`, with no +platform suffix. A provider-specific User-Agent override replaces that header only +on native generation (both streaming and nonstreaming frontend requests) and +`loadCodeAssist` project discovery, never OAuth code exchange or refresh. Incoming +client User-Agent, authorization and cookies are not forwarded. JSON product +selectors `userAgent: antigravity` and `requestType: agent` are unchanged. + +Override precedence is `CCP_ANTIGRAVITY_USER_AGENT`, then `antigravity.userAgent`, +then the proxy default. Neither `CCP_USER_AGENT` nor Codex settings are inherited. +Presence selects the environment override even when empty; invalid values fail +configuration rather than falling back, including while the provider is disabled. +Invalid native configuration removes Antigravity readiness/advertisement; it does +not disable other providers or prevent the proxy listener from starting. Auth +status still exits successfully with a report of `configuration: invalid` and +`configuration_ready: false`; report success does not establish readiness. +The file field must be a string. The selected value must contain 1–4096 printable +ASCII bytes (`0x20`–`0x7e`), with no leading/trailing space, tabs, control bytes or +non-ASCII characters. Non-UTF8 environment values are rejected by the native +validator even though this dedicated setting is excluded from legacy UTF-8 +snapshots. Unix key matching is exact; Windows covers ASCII case variants. +Unrelated non-Unicode environment entries remain unsupported and fail with a +fixed error, rather than being silently discarded. Valid bytes, +including interior spacing, are preserved exactly: no trimming, suffix appending, +version/changelist rewriting or platform substitution. Raw values are omitted from +status/errors and marked sensitive in HTTP diagnostics. Do not put credentials in +a User-Agent; it is sent to the configured trusted native API origin. + +An explicit compatibility override may use this pinned historical value: + +```text +antigravity/cli/1.3.5 (aidev_client; os_type=darwin; arch=arm64; cl=998863838; auth_method=consumer) +``` + +Set it as the full provider-specific environment or file value only when desired; +it is **not the default** and does not enable the provider. These literal platform +and consumer-profile tokens are caller-selected compatibility metadata, not a +claim about the host or a verified account classification. They are not normalized +to the host. The pinned public reference records CLI 1.3.5 generation on +Darwin/arm64 and uses the same formatter for project discovery. Exact CLI 1.4.2 +identification, current official-client equivalence, live acceptance and provider +policy/terms compatibility remain unverified. No runtime `agy` probing, credential +import, device identity or attestation fabrication is performed. User-Agent is not +authentication or evidence of entitlement; see the +[reference evidence](/reference/compatibility-and-limitations/#evidence-and-remaining-gates). Login requests explicit browser consent, offline access, PKCE S256, independent one-time state, and an ephemeral `127.0.0.1` callback. The reference-protocol scopes @@ -190,7 +232,7 @@ Only `CCP_CONFIG_DIR/antigravity/auth.json` is used. Atomic private writes use m coordinates login/logout/refresh across processes; logout never removes that lock. Missing, invalid and unreadable storage remain distinct. Local logout resolves this exact store path independently of transport settings, so malformed JSON or -an invalid API URL does not prevent deleting this provider's credential. It does +an invalid API URL or User-Agent does not prevent deleting this provider's credential. It does not remove another provider's state. Refresh preserves the profile ID and retains the old refresh token when rotation diff --git a/src/config.rs b/src/config.rs index 23c371bf..16ac5067 100644 --- a/src/config.rs +++ b/src/config.rs @@ -242,7 +242,7 @@ pub fn log_stderr() -> bool { pub fn config_override_summary_lines(cfg: &LoadedConfig) -> Vec { let file = read_file_config(&cfg.config_dir); - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); let mut out = Vec::new(); if env.contains_key("CCP_BIND_ADDRESS") { out.push("bindAddress (env)".to_string()); @@ -378,7 +378,7 @@ pub fn config_override_summary_lines(cfg: &LoadedConfig) -> Vec { } pub fn grok_base_url() -> String { - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); if let Some(raw) = env.get("CCP_GROK_BASE_URL") { return raw.clone(); } @@ -391,7 +391,7 @@ pub fn grok_base_url() -> String { } pub fn grok_client_version() -> String { - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); if let Some(raw) = env.get("CCP_GROK_CLIENT_VERSION") { return raw.clone(); } @@ -562,17 +562,17 @@ fn resolve_opencode_config( } pub fn opencode_api_key() -> Option { - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); resolve_opencode_config(&env, &paths::config_dir()).api_key } pub fn opencode_api_key_source() -> Option<&'static str> { - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); resolve_opencode_config(&env, &paths::config_dir()).api_key_source } pub fn opencode_base_url() -> String { - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); resolve_opencode_config(&env, &paths::config_dir()).base_url } @@ -581,7 +581,7 @@ pub fn is_verbose() -> bool { } pub fn kimi_oauth_host() -> String { - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); if let Some(raw) = env.get("CCP_KIMI_OAUTH_HOST") { return raw.clone(); } @@ -596,7 +596,7 @@ pub fn kimi_oauth_host() -> String { } pub fn kimi_base_url() -> String { - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); if let Some(raw) = env.get("CCP_KIMI_BASE_URL") { return raw.clone(); } @@ -611,7 +611,7 @@ pub fn kimi_base_url() -> String { } pub fn kimi_user_agent(default: &str) -> String { - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); if let Some(raw) = env.get("CCP_KIMI_USER_AGENT") { return raw.clone(); } @@ -633,7 +633,7 @@ pub fn kimi_user_agent(default: &str) -> String { // --------------------------------------------------------------------------- pub fn codex_base_url(default: &str) -> String { - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); if let Some(raw) = env.get("CCP_CODEX_BASE_URL") { return raw.clone(); } @@ -651,7 +651,7 @@ pub fn codex_base_url(default: &str) -> String { } pub fn codex_originator(default: &str) -> String { - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); if let Some(raw) = env.get("CCP_CODEX_ORIGINATOR") { return raw.clone(); } @@ -666,7 +666,7 @@ pub fn codex_originator(default: &str) -> String { } pub fn codex_user_agent(default: &str) -> String { - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); if let Some(raw) = env.get("CCP_CODEX_USER_AGENT") { return raw.clone(); } @@ -684,7 +684,7 @@ pub fn codex_user_agent(default: &str) -> String { } pub fn codex_previous_response_id() -> bool { - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); if let Some(raw) = env.get("CCP_CODEX_PREVIOUS_RESPONSE_ID") { return matches!(raw.to_ascii_lowercase().as_str(), "1" | "true" | "yes"); } @@ -699,7 +699,7 @@ pub fn codex_previous_response_id() -> bool { } pub fn codex_server_compaction() -> bool { - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); if let Some(raw) = env.get("CCP_CODEX_SERVER_COMPACTION") { match raw.trim().to_ascii_lowercase().as_str() { "1" | "true" | "yes" | "on" => return true, @@ -718,7 +718,7 @@ pub fn codex_server_compaction() -> bool { } pub fn codex_responses_api() -> bool { - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); if let Some(raw) = env.get("CCP_CODEX_RESPONSES_API") { return matches!(raw.to_ascii_lowercase().as_str(), "1" | "true" | "yes"); } @@ -733,7 +733,7 @@ pub fn codex_responses_api() -> bool { } pub fn codex_images_api() -> bool { - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); if let Some(raw) = env.get("CCP_CODEX_IMAGES_API") { return matches!(raw.to_ascii_lowercase().as_str(), "1" | "true" | "yes"); } @@ -748,7 +748,7 @@ pub fn codex_images_api() -> bool { } pub fn codex_transcriptions_api() -> bool { - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); if let Some(raw) = env.get("CCP_CODEX_TRANSCRIPTIONS_API") { return matches!(raw.to_ascii_lowercase().as_str(), "1" | "true" | "yes"); } @@ -763,7 +763,7 @@ pub fn codex_transcriptions_api() -> bool { } pub fn codex_images_base_url() -> String { - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); if let Some(raw) = env.get("CCP_CODEX_IMAGES_BASE_URL") { return raw.clone(); } @@ -778,7 +778,7 @@ pub fn codex_images_base_url() -> String { } pub fn codex_service_tier() -> Option { - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); if let Some(raw) = env.get("CCP_CODEX_SERVICE_TIER") { return Some(raw.clone()); } @@ -792,7 +792,7 @@ pub fn codex_service_tier() -> Option { } pub fn codex_effort() -> Option { - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); if let Some(raw) = env.get("CCP_CODEX_EFFORT") { return Some(raw.clone()); } @@ -806,7 +806,7 @@ pub fn codex_effort() -> Option { } pub fn codex_reasoning_summary() -> Option { - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); if let Some(raw) = env .get("CCP_CODEX_REASONING_SUMMARY") .filter(|raw| !raw.is_empty()) @@ -824,7 +824,7 @@ pub fn codex_reasoning_summary() -> Option { } pub fn codex_model() -> Option { - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); if let Some(raw) = env.get("CCP_CODEX_MODEL") { return Some(raw.clone()); } @@ -838,7 +838,7 @@ pub fn codex_model() -> Option { } pub fn auto_review_model() -> Option { - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); if let Some(raw) = env .get("CCP_AUTO_REVIEW_MODEL") .filter(|raw| !raw.is_empty()) @@ -881,7 +881,7 @@ fn parse_codex_transport(raw: &str) -> Option { } pub fn codex_transport() -> CodexTransport { - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); if let Some(raw) = env.get("CCP_CODEX_TRANSPORT") && let Some(transport) = parse_codex_transport(raw) { @@ -911,7 +911,7 @@ pub const CODEX_DEFAULT_HEADER_TIMEOUT_MS: u64 = 300_000; pub const CODEX_MIN_HEADER_TIMEOUT_MS: u64 = 1_000; pub fn codex_header_timeout_ms() -> u64 { - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); if let Some(ms) = env .get("CCP_CODEX_HEADER_TIMEOUT_MS") .and_then(|raw| raw.trim().parse::().ok()) @@ -936,7 +936,7 @@ pub fn codex_header_timeout_ms() -> u64 { // --------------------------------------------------------------------------- pub fn cursor_base_url() -> String { - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); if let Some(raw) = env.get("CCP_CURSOR_BASE_URL") { return raw.clone(); } @@ -951,7 +951,7 @@ pub fn cursor_base_url() -> String { } pub fn cursor_client_version() -> String { - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); if let Some(raw) = env.get("CCP_CURSOR_CLIENT_VERSION") { return raw.clone(); } @@ -986,7 +986,7 @@ fn detect_cursor_agent_version() -> Option { } pub fn cursor_agent_bundle() -> Option { - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); if let Some(raw) = env.get("CCP_CURSOR_AGENT_BUNDLE") { return Some(raw.clone()); } diff --git a/src/environment.rs b/src/environment.rs new file mode 100644 index 00000000..c1e7c16e --- /dev/null +++ b/src/environment.rs @@ -0,0 +1,173 @@ +//! Legacy UTF-8 environment snapshots exclude settings validated independently as OS strings. + +use std::ffi::{OsStr, OsString}; + +pub(crate) const ANTIGRAVITY_USER_AGENT: &str = "CCP_ANTIGRAVITY_USER_AGENT"; + +/// Keep the native header out of legacy snapshots so its own OsString-aware +/// validator can reject invalid input without affecting unrelated providers. +/// This does not mutate the process environment or relax other invalid entries. +pub(crate) fn legacy_vars() -> impl Iterator { + legacy_utf8_entries(std::env::vars_os(), cfg!(windows)) +} + +fn is_native_user_agent(name: &OsStr, windows: bool) -> bool { + name.to_str().is_some_and(|name| { + if windows { + name.eq_ignore_ascii_case(ANTIGRAVITY_USER_AGENT) + } else { + name == ANTIGRAVITY_USER_AGENT + } + }) +} + +fn legacy_utf8_entries( + entries: impl IntoIterator, + windows: bool, +) -> impl Iterator { + entries + .into_iter() + .filter(move |(name, _)| !is_native_user_agent(name, windows)) + .map(|(name, value)| { + // Unwrapping into_string() errors would print the invalid input. + // Preserve failure semantics with a fixed, non-sensitive message. + let name = name + .into_string() + .unwrap_or_else(|_| panic!("non-UTF-8 environment entry")); + let value = value + .into_string() + .unwrap_or_else(|_| panic!("non-UTF-8 environment entry")); + (name, value) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::OsString; + + fn entries(values: &[(&str, &str)]) -> Vec<(OsString, OsString)> { + values + .iter() + .map(|(key, value)| ((*key).into(), (*value).into())) + .collect() + } + + #[test] + fn native_user_agent_environment_iterator_preserves_unrelated_entries_and_order() { + let input = entries(&[ + ("FIRST", ""), + (ANTIGRAVITY_USER_AGENT, "native-UA"), + ("環境", "данные"), + (ANTIGRAVITY_USER_AGENT, ""), + ("FIRST", "second value"), + ("CCP_USER_AGENT", "legacy-UA"), + ]); + let output: Vec<_> = legacy_utf8_entries(input, false).collect(); + assert_eq!( + output, + vec![ + ("FIRST".to_owned(), "".to_owned()), + ("環境".to_owned(), "данные".to_owned()), + ("FIRST".to_owned(), "second value".to_owned()), + ("CCP_USER_AGENT".to_owned(), "legacy-UA".to_owned()), + ] + ); + } + + #[test] + fn native_user_agent_environment_key_matching_is_platform_scoped() { + for windows in [false, true] { + for key in [ + "CCP_ANTIGRAVITY_USER_AGENT", + "ccp_antigravity_user_agent", + "Ccp_Antigravity_User_Agent", + ] { + let output: Vec<_> = + legacy_utf8_entries(entries(&[(key, "value")]), windows).collect(); + assert_eq!(output.is_empty(), windows || key == ANTIGRAVITY_USER_AGENT); + } + for key in [ + "CCP_ANTIGRAVITY_USER_AGENT_EXTRA", + "XCCP_ANTIGRAVITY_USER_AGENT", + "CCP_ANTIGRAVITY_USER_AGENŤ", + "", + "OTHER", + ] { + let output: Vec<_> = + legacy_utf8_entries(entries(&[(key, "value")]), windows).collect(); + assert_eq!(output, vec![(key.to_owned(), "value".to_owned())]); + } + } + // This checks the compiled platform selector; Windows execution remains a separate gate. + let output: Vec<_> = legacy_utf8_entries( + entries(&[("ccp_antigravity_user_agent", "value")]), + cfg!(windows), + ) + .collect(); + assert_eq!(output.is_empty(), cfg!(windows)); + } + + #[cfg(unix)] + fn invalid_os_string() -> OsString { + use std::os::unix::ffi::OsStringExt; + OsString::from_vec(b"synthetic-invalid-\xff".to_vec()) + } + + #[cfg(windows)] + fn invalid_os_string() -> OsString { + use std::os::windows::ffi::OsStringExt; + OsString::from_wide(&[b'x' as u16, 0xd800]) + } + + #[cfg(any(unix, windows))] + #[test] + fn native_user_agent_environment_omission_does_not_hide_native_invalid_presence() { + let input = vec![(OsString::from(ANTIGRAVITY_USER_AGENT), invalid_os_string())]; + let output: Vec<_> = legacy_utf8_entries(input.clone(), cfg!(windows)).collect(); + assert!(output.is_empty()); + let directory = tempfile::tempdir().unwrap(); + let result = crate::providers::antigravity::config::Config::read( + &directory.path().join("config.json"), + |key| { + input + .iter() + .find(|(name, _)| name == key) + .map(|(_, value)| value.clone()) + }, + ); + assert_eq!(result.err(), Some("invalid Antigravity user agent")); + assert!( + input[0].1.to_str().is_none(), + "the original fixture must remain unmodified" + ); + for key in [ + "CCP_ANTIGRAVITY_USER_AGENT", + "ccp_antigravity_user_agent", + "Ccp_Antigravity_User_Agent", + ] { + let output: Vec<_> = + legacy_utf8_entries(vec![(key.into(), invalid_os_string())], true).collect(); + assert!(output.is_empty()); + } + } + + #[cfg(any(unix, windows))] + #[test] + fn native_user_agent_environment_unrelated_invalid_entries_fail_without_raw_values() { + for input in [ + vec![(invalid_os_string(), "value".into())], + vec![("OTHER".into(), invalid_os_string())], + vec![("ccp_antigravity_user_agent".into(), invalid_os_string())], + ] { + let panic = + std::panic::catch_unwind(|| legacy_utf8_entries(input, false).collect::>()) + .unwrap_err(); + let message = panic + .downcast_ref::<&str>() + .copied() + .or_else(|| panic.downcast_ref::().map(String::as_str)); + assert_eq!(message, Some("non-UTF-8 environment entry")); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 4d8f86b7..a741d84a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod anthropic; pub mod auth; pub mod config; +mod environment; pub mod logging; pub mod monitor; pub mod openai_compat; diff --git a/src/paths.rs b/src/paths.rs index ac81392d..90c797ad 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -12,7 +12,7 @@ impl Default for DirResolverEnv { fn default() -> Self { Self { platform: std::env::consts::OS.into(), - env: std::env::vars().collect(), + env: crate::environment::legacy_vars().collect(), home: std::env::var("HOME") .or_else(|_| std::env::var("USERPROFILE")) .unwrap_or_else(|_| "/".to_string()), diff --git a/src/providers/antigravity/auth.rs b/src/providers/antigravity/auth.rs index 26f92801..0cb6bc88 100644 --- a/src/providers/antigravity/auth.rs +++ b/src/providers/antigravity/auth.rs @@ -327,6 +327,7 @@ impl Auth { ) .timeout(Duration::from_secs(30)) .bearer_auth(&credential.access_token) + .headers(self.config.native_headers()) .json(&json!({"metadata":{"ideType":"ANTIGRAVITY","pluginType":"GEMINI"}})) .send() .await diff --git a/src/providers/antigravity/config.rs b/src/providers/antigravity/config.rs index b3d97cca..e9c19ad4 100644 --- a/src/providers/antigravity/config.rs +++ b/src/providers/antigravity/config.rs @@ -1,6 +1,8 @@ use super::replay::{Result, keys, parse_json}; +use crate::environment::ANTIGRAVITY_USER_AGENT; +use http::{HeaderMap, HeaderValue, header::USER_AGENT}; use serde_json::{Value, json}; -use std::path::Path; +use std::{ffi::OsString, path::Path}; use url::Url; #[derive(Clone)] @@ -12,6 +14,25 @@ pub(crate) struct Config { pub client_id: Option, pub client_secret: Option, pub project: Option, + pub native_user_agent: HeaderValue, +} + +fn default_user_agent() -> String { + format!("claude-code-proxy/{}", env!("CARGO_PKG_VERSION")) +} + +fn parse_user_agent(value: &str) -> Result { + if value.is_empty() + || value.len() > 4096 + || value.starts_with(' ') + || value.ends_with(' ') + || !value.bytes().all(|byte| (0x20..=0x7e).contains(&byte)) + { + return Err("invalid Antigravity user agent"); + } + let mut header = HeaderValue::from_str(value).map_err(|_| "invalid Antigravity user agent")?; + header.set_sensitive(true); + Ok(header) } impl Default for Config { @@ -24,6 +45,8 @@ impl Default for Config { client_id: None, client_secret: None, project: None, + native_user_agent: parse_user_agent(&default_user_agent()) + .expect("built-in proxy identity is a valid header"), } } } @@ -49,11 +72,15 @@ impl Config { pub fn load() -> Result { Self::read(&crate::paths::config_dir().join("config.json"), |key| { - std::env::var(key).ok() + std::env::var_os(key) }) } - pub fn read(path: &Path, env: impl Fn(&str) -> Option) -> Result { + pub fn read(path: &Path, env: impl Fn(&str) -> Option) -> Result { + // Preserve absence versus invalid Unicode for this header only. Other + // settings retain their existing non-Unicode-as-absent behavior. + let user_agent = env(ANTIGRAVITY_USER_AGENT); + let env = |key: &str| env(key).and_then(|value| value.into_string().ok()); let file = match std::fs::read(path) { Ok(raw) => { parse_json(&raw, 1024 * 1024).map_err(|_| "invalid Antigravity configuration")? @@ -76,10 +103,21 @@ impl Config { "clientId", "clientSecret", "projectId", + "userAgent", ], &[], )?; let mut result = Self::default(); + let file_user_agent = section + .get("userAgent") + .map(|value| value.as_str().ok_or("invalid Antigravity user agent")) + .transpose()?; + if let Some(value) = user_agent.as_ref() { + result.native_user_agent = + parse_user_agent(value.to_str().ok_or("invalid Antigravity user agent")?)?; + } else if let Some(value) = file_user_agent { + result.native_user_agent = parse_user_agent(value)?; + } if let Some(enabled) = section.get("enabled") { result.enabled = enabled.as_bool().ok_or("invalid enabled flag")?; } @@ -118,18 +156,19 @@ impl Config { Ok(result) } + pub fn native_headers(&self) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(USER_AGENT, self.native_user_agent.clone()); + headers + } + pub fn client_builder(&self) -> reqwest::ClientBuilder { reqwest::Client::builder() .retry(reqwest::retry::never()) .redirect(reqwest::redirect::Policy::none()) .connect_timeout(std::time::Duration::from_secs(15)) .timeout(std::time::Duration::from_secs(180)) - .user_agent(format!( - "claude-code-proxy/{} (Antigravity-compatible; {}; {})", - env!("CARGO_PKG_VERSION"), - std::env::consts::OS, - std::env::consts::ARCH - )) + .user_agent(default_user_agent()) } pub fn client(&self) -> Result { @@ -138,3 +177,184 @@ impl Config { .map_err(|_| "Antigravity HTTP client initialization failed") } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn antigravity_user_agent_file_setting_is_accepted_without_enabling_provider() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.json"); + std::fs::write( + &path, + br#"{"antigravity":{"userAgent":"file-agent/7 (exact bytes)"}}"#, + ) + .unwrap(); + let config = Config::read(&path, |_| None).expect("valid provider-specific override"); + assert!(!config.enabled); + } + + #[test] + fn antigravity_user_agent_precedence_bytes_and_sensitive_request_debug() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.json"); + let default = Config::read(&path, |key| match key { + "CCP_USER_AGENT" | "CCP_CODEX_USER_AGENT" => Some("unrelated-UA".into()), + _ => None, + }) + .unwrap(); + assert!(!default.enabled); + assert_eq!( + default.native_headers()["user-agent"], + super::super::super::antigravity_http_tests::expected_native_user_agent() + ); + std::fs::write(&path, br#"{"antigravity":{"userAgent":"file-UA"}}"#).unwrap(); + assert_eq!( + Config::read(&path, |_| None).unwrap().native_headers()["user-agent"], + "file-UA" + ); + for value in [ + "x".to_owned(), + "x".repeat(4096), + "sentinel-UA/9 (exact interior bytes)".into(), + ] { + let config = Config::read(&path, |key| { + (key == "CCP_ANTIGRAVITY_USER_AGENT").then(|| value.clone().into()) + }) + .unwrap(); + let headers = config.native_headers(); + assert_eq!(headers["user-agent"].as_bytes(), value.as_bytes()); + assert!(headers["user-agent"].is_sensitive()); + let request = config + .client() + .unwrap() + .post("http://127.0.0.1:1/test") + .header("user-agent", "stale-one") + .header("user-agent", "stale-two") + .headers(headers.clone()) + .build() + .unwrap(); + assert_eq!(request.headers().get_all("user-agent").iter().count(), 1); + assert_eq!(request.headers()["user-agent"].as_bytes(), value.as_bytes()); + assert!(request.headers()["user-agent"].is_sensitive()); + assert!(!format!("{headers:?} {request:?}").contains("sentinel-UA")); + } + let result = Config::read(&path, |key| { + (key == "CCP_ANTIGRAVITY_USER_AGENT").then(|| "".into()) + }); + assert_eq!(result.err(), Some("invalid Antigravity user agent")); + } + + #[test] + fn antigravity_user_agent_file_validation_and_static_diagnostics() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.json"); + for value in [ + json!(null), + json!(42), + json!(false), + json!([]), + json!({}), + json!(""), + json!(" "), + json!("sentinel\r\nUA"), + json!("sentinel\tUA"), + json!("sentinel\u{7f}"), + json!("é"), + json!("x".repeat(4097)), + ] { + std::fs::write( + &path, + json!({"antigravity":{"userAgent":value}}).to_string(), + ) + .unwrap(); + let error = Config::read(&path, |_| None) + .err() + .expect("invalid header must fail"); + assert_eq!(error, "invalid Antigravity user agent"); + assert!(!format!("{error} {error:?}").contains("sentinel")); + } + std::fs::write( + &path, + br#"{"antigravity":{"userAgent":"file-UA","unknown":true}}"#, + ) + .unwrap(); + assert!(Config::read(&path, |_| None).is_err()); + std::fs::write(&path, b"{not-json").unwrap(); + assert!( + Config::read(&path, |key| (key == "CCP_ANTIGRAVITY_USER_AGENT") + .then(|| "valid-UA".into())) + .is_err() + ); + } + + #[test] + fn antigravity_user_agent_ascii_boundaries_and_environment_precedence() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.json"); + std::fs::write(&path, br#"{"antigravity":{"userAgent":"file-UA"}}"#).unwrap(); + for byte in 0..=127u8 { + let value = format!("a{}z", char::from(byte)); + let result = Config::read(&path, |key| { + (key == "CCP_ANTIGRAVITY_USER_AGENT").then(|| value.clone().into()) + }); + assert_eq!(result.is_ok(), (0x20..=0x7e).contains(&byte)); + } + let too_long = "x".repeat(4097); + assert!( + Config::read(&path, |key| { + (key == "CCP_ANTIGRAVITY_USER_AGENT").then(|| too_long.clone().into()) + }) + .is_err() + ); + std::fs::write(&path, br#"{"antigravity":{"userAgent":""}}"#).unwrap(); + let config = Config::read(&path, |key| { + (key == "CCP_ANTIGRAVITY_USER_AGENT").then(|| "environment-UA".into()) + }) + .unwrap(); + assert_eq!(config.native_headers()["user-agent"], "environment-UA"); + std::fs::write(&path, br#"{"antigravity":{"userAgent":42}}"#).unwrap(); + assert!( + Config::read(&path, |key| { + (key == "CCP_ANTIGRAVITY_USER_AGENT").then(|| "environment-UA".into()) + }) + .is_err() + ); + } + + #[cfg(unix)] + #[test] + fn antigravity_user_agent_non_utf8_injected_environment_does_not_fall_back() { + use std::os::unix::ffi::OsStringExt; + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.json"); + std::fs::write(&path, br#"{"antigravity":{"userAgent":"file-UA"}}"#).unwrap(); + let result = Config::read(&path, |key| { + (key == "CCP_ANTIGRAVITY_USER_AGENT").then(|| std::ffi::OsString::from_vec(vec![0xff])) + }); + assert_eq!(result.err(), Some("invalid Antigravity user agent")); + } + + #[test] + fn antigravity_user_agent_invalid_environment_is_not_ignored() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("absent.json"); + for value in [ + "", + " ", + " bad", + "bad ", + "bad\tvalue", + "bad\r\ninjected", + "bad\0value", + "bad\u{7f}", + "é", + ] { + let result = Config::read(&path, |key| { + (key == "CCP_ANTIGRAVITY_USER_AGENT").then(|| value.into()) + }); + assert_eq!(result.err(), Some("invalid Antigravity user agent")); + } + } +} diff --git a/src/providers/antigravity/mod.rs b/src/providers/antigravity/mod.rs index 6127565f..fa2ff269 100644 --- a/src/providers/antigravity/mod.rs +++ b/src/providers/antigravity/mod.rs @@ -105,6 +105,7 @@ impl AntigravityProvider { .post(endpoint) .bearer_auth(&credential.access_token) .header("accept", "text/event-stream") + .headers(auth.config.native_headers()) .json(&prepared.wire) .send(), ) diff --git a/src/providers/antigravity_auth_tests.rs b/src/providers/antigravity_auth_tests.rs index efd07684..527e3859 100644 --- a/src/providers/antigravity_auth_tests.rs +++ b/src/providers/antigravity_auth_tests.rs @@ -501,6 +501,95 @@ async fn antigravity_logout_and_new_login_cannot_be_overwritten_by_refresh() { } } +#[tokio::test] +async fn antigravity_user_agent_oauth_and_project_wire_matrix() { + for override_value in [ + None, + Some("sentinel-native/9 (exact project identity)"), + Some( + "antigravity/cli/1.3.5 (aidev_client; os_type=darwin; arch=arm64; cl=998863838; auth_method=consumer)", + ), + ] { + let captured = Arc::new(std::sync::Mutex::new(Vec::new())); + let tokens = captured.clone(); + let projects = captured.clone(); + let server = Server::start(Router::new() + .route("/token", post(move |headers: http::HeaderMap, axum::extract::Form(form): axum::extract::Form>| { + let captured = tokens.clone(); + async move { + captured.lock().unwrap().push((form["grant_type"].clone(), headers)); + Json(json!({"access_token":"synthetic-access","refresh_token":"synthetic-refresh","expires_in":3600})) + } + })) + .route("/v1internal:loadCodeAssist", post(move |headers: http::HeaderMap, Json(body): Json| { + let captured = projects.clone(); + async move { + assert_eq!(headers["authorization"], "Bearer synthetic-access"); + assert!(body.get("metadata").is_some()); + captured.lock().unwrap().push(("project".into(), headers)); + Json(json!({"cloudaicompanionProject":"synthetic-project"})) + } + }))).await; + let directory = tempfile::tempdir().unwrap(); + let config = Config { + base_url: server.url.clone(), + token_url: server.url.join("/token").unwrap(), + client_id: Some("public-client".into()), + ..Config::read(&directory.path().join("config.json"), |key| { + if key == "CCP_ANTIGRAVITY_USER_AGENT" { + override_value.map(Into::into) + } else { + None + } + }) + .unwrap() + }; + let auth = Auth::new(config, Store::new(directory.path().join("auth.json"))).unwrap(); + let mut callback = None; + auth.login_with_opener(|url| { + let pairs = url + .query_pairs() + .collect::>(); + let mut redirect = url::Url::parse(&pairs["redirect_uri"]).unwrap(); + redirect.query_pairs_mut().extend_pairs([ + ("state", pairs["state"].as_ref()), + ("code", "synthetic-code"), + ]); + callback = Some(tokio::spawn(async move { + reqwest::Client::new().get(redirect).send().await.unwrap() + })); + Ok(()) + }) + .await + .unwrap(); + assert_eq!(callback.unwrap().await.unwrap().status(), 200); + let mut credential = auth.store.load().unwrap().unwrap(); + credential.expires_at = 0; + saved(&auth.store, &credential).await; + auth.access().await.unwrap(); + let requests = captured.lock().unwrap(); + assert_eq!(requests.len(), 3); + let proxy_identity = format!("claude-code-proxy/{}", env!("CARGO_PKG_VERSION")); + for ((operation, headers), expected_operation) in + requests + .iter() + .zip(["authorization_code", "project", "refresh_token"]) + { + assert_eq!(operation, expected_operation); + assert_eq!(headers.get_all("user-agent").iter().count(), 1); + let expected = if operation == "project" { + override_value + .map(str::to_owned) + .unwrap_or_else(super::antigravity_http_tests::expected_native_user_agent) + } else { + proxy_identity.clone() + }; + assert_eq!(headers["user-agent"], expected); + assert!(!headers.contains_key("cookie")); + } + } +} + #[tokio::test] async fn antigravity_explicit_login_exchanges_pkce_and_saves_discovered_project() { for project in [ @@ -694,7 +783,10 @@ async fn antigravity_redirects_do_not_leak_bearer_or_refresh_credentials() { let config = Config { token_url: redirect.url.clone(), client_id: Some("synthetic-client".into()), - ..Config::default() + ..Config::read(&directory.path().join("config.json"), |key| { + (key == "CCP_ANTIGRAVITY_USER_AGENT").then(|| "configured-no-redirect-UA".into()) + }) + .unwrap() }; let auth = Auth::new(config, store).unwrap(); assert!(auth.access().await.is_err()); @@ -703,6 +795,7 @@ async fn antigravity_redirects_do_not_leak_bearer_or_refresh_credentials() { .client .post(redirect.url.clone()) .bearer_auth("synthetic-access") + .headers(auth.config.native_headers()) .send() .await .unwrap(); diff --git a/src/providers/antigravity_http_tests.rs b/src/providers/antigravity_http_tests.rs index a5adff1c..58c3b05f 100644 --- a/src/providers/antigravity_http_tests.rs +++ b/src/providers/antigravity_http_tests.rs @@ -97,7 +97,19 @@ fn successful(parts: Value) -> String { json!({"response":{"candidates":[{"content":{"role":"model","parts":parts},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":2,"thoughtsTokenCount":1,"totalTokenCount":13}}}), ) } +pub(super) fn expected_native_user_agent() -> String { + format!("claude-code-proxy/{}", env!("CARGO_PKG_VERSION")) +} + async fn provider(directory: &Path, url: &url::Url) -> AntigravityProvider { + provider_with_user_agent(directory, url, None).await +} + +async fn provider_with_user_agent( + directory: &Path, + url: &url::Url, + user_agent: Option<&str>, +) -> AntigravityProvider { let store = Store::new(directory.join("antigravity/auth.json")); let lock = store.lock(Duration::from_secs(1)).await.unwrap(); store @@ -116,7 +128,14 @@ async fn provider(directory: &Path, url: &url::Url) -> AntigravityProvider { let config = Config { base_url: url.clone(), client_id: Some("synthetic-client".into()), - ..Config::default() + ..Config::read(&directory.join("config.json"), |key| { + if key == "CCP_ANTIGRAVITY_USER_AGENT" { + user_agent.map(Into::into) + } else { + None + } + }) + .unwrap() }; AntigravityProvider::with_auth(Auth::new(config, store).unwrap()) } @@ -146,6 +165,58 @@ fn client() -> reqwest::Client { .unwrap() } +#[tokio::test] +async fn antigravity_user_agent_generation_wire_matrix() { + for override_value in [ + None, + Some("sentinel-native/9 (interior spaces preserved)"), + Some( + "antigravity/cli/1.3.5 (aidev_client; os_type=darwin; arch=arm64; cl=998863838; auth_method=consumer)", + ), + ] { + for stream in [false, true] { + let (upstream, captured) = + native(vec![(200, successful(json!([{"text":"ok"}])))]).await; + let directory = tempfile::tempdir().unwrap(); + let monitor = MonitorHandle::new(10); + let proxy = proxy( + provider_with_user_agent(directory.path(), &upstream.url, override_value).await, + Some(monitor.clone()), + ) + .await; + let response = client() + .post(proxy.url.join("/v1/messages").unwrap()) + .header("user-agent", "hostile-frontend-UA") + .header("authorization", "Bearer frontend-credential") + .header("cookie", "private-frontend-cookie") + .header("x-goog-api-client", "frontend-metadata") + .json(&request(stream)) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + let text = response.text().await.unwrap(); + assert!(!text.contains("sentinel-native")); + let captured = captured.lock().unwrap(); + assert_eq!(captured.len(), 1); + let (headers, body) = &captured[0]; + assert_eq!(headers.get_all("user-agent").iter().count(), 1); + assert_eq!( + headers["user-agent"], + override_value + .map(str::to_owned) + .unwrap_or_else(expected_native_user_agent) + ); + assert_eq!(headers["authorization"], "Bearer proxy-owned-access"); + assert!(!headers.contains_key("cookie")); + assert!(!headers.contains_key("x-goog-api-client")); + assert_eq!(body["userAgent"], "antigravity"); + assert_eq!(body["requestType"], "agent"); + assert!(!format!("{:?}", monitor.snapshot()).contains("sentinel-native")); + } + } +} + #[tokio::test] async fn antigravity_http2_refused_stream_is_not_automatically_retried() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -189,7 +260,12 @@ async fn antigravity_http2_refused_stream_is_not_automatically_retried() { } attempts }); - let client = Config::default() + let directory = tempfile::tempdir().unwrap(); + let config = Config::read(&directory.path().join("config.json"), |key| { + (key == "CCP_ANTIGRAVITY_USER_AGENT").then(|| "configured-no-retry-UA".into()) + }) + .unwrap(); + let client = config .client_builder() .no_proxy() .http2_prior_knowledge() @@ -198,6 +274,7 @@ async fn antigravity_http2_refused_stream_is_not_automatically_retried() { assert!( client .post(format!("http://{address}/v1internal:streamGenerateContent")) + .headers(config.native_headers()) .json(&json!({"synthetic":"generation"})) .send() .await @@ -308,7 +385,16 @@ async fn antigravity_http_preserves_preheader_errors_without_retry_or_secret_ech )]) .await; let directory = tempfile::tempdir().unwrap(); - let proxy = proxy(provider(directory.path(), &upstream.url).await, None).await; + let proxy = proxy( + provider_with_user_agent( + directory.path(), + &upstream.url, + Some("sentinel-no-retry-UA"), + ) + .await, + None, + ) + .await; let response = client() .post(proxy.url.join("/v1/messages").unwrap()) .header("authorization", "Bearer frontend-secret") @@ -319,14 +405,15 @@ async fn antigravity_http_preserves_preheader_errors_without_retry_or_secret_ech .unwrap(); assert_eq!(response.status().as_u16(), status); assert_eq!(response.headers()["retry-after"], "7"); - assert!(!response.text().await.unwrap().contains("private OAuth")); + let text = response.text().await.unwrap(); + assert!(!text.contains("private OAuth")); + assert!(!text.contains("sentinel-no-retry-UA")); let captured = captured.lock().unwrap(); assert_eq!(captured.len(), 1); assert_eq!(captured[0].0["authorization"], "Bearer proxy-owned-access"); assert!(!captured[0].0.contains_key("anthropic-beta")); - let identity = captured[0].0["user-agent"].to_str().unwrap(); - assert!(identity.starts_with("claude-code-proxy/")); - assert!(identity.contains("Antigravity-compatible")); + assert_eq!(captured[0].0.get_all("user-agent").iter().count(), 1); + assert_eq!(captured[0].0["user-agent"], "sentinel-no-retry-UA"); } } diff --git a/src/traffic.rs b/src/traffic.rs index 9640cded..f19233d1 100644 --- a/src/traffic.rs +++ b/src/traffic.rs @@ -31,7 +31,7 @@ pub struct TrafficCaptureOptions { } pub fn traffic_capture_enabled() -> bool { - traffic_capture_enabled_for_env(&std::env::vars().collect()) + traffic_capture_enabled_for_env(&crate::environment::legacy_vars().collect()) } pub fn traffic_capture_enabled_for_env(env: &std::collections::HashMap) -> bool { diff --git a/tests/cli.rs b/tests/cli.rs index 3d3ab176..9498a632 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -97,6 +97,120 @@ fn provider_logout_without_auth_is_success() -> Result<(), Box Result<(), Box> { let mut cmd = Command::cargo_bin("claude-code-proxy")?; @@ -175,6 +289,115 @@ fn wait_for_exit( } } +#[cfg(unix)] +#[tokio::test] +async fn antigravity_user_agent_invalid_environment_is_scoped_to_native_provider() +-> Result<(), Box> { + use std::os::unix::{ffi::OsStringExt, fs::PermissionsExt}; + for enabled in ["0", "1"] { + for invalid in [ + b"sentinel-invalid\tUA".to_vec(), + b"sentinel-invalid-\xff".to_vec(), + ] { + let directory = TempDir::new()?; + let upstream = TcpListener::bind("127.0.0.1:0")?; + upstream.set_nonblocking(true)?; + let config = serde_json::json!({"antigravity": { + "userAgent": "file-native-fallback", + "baseUrl": format!("https://{}", upstream.local_addr()?) + }}); + std::fs::write(directory.path().join("config.json"), config.to_string())?; + let auth_dir = directory.path().join("antigravity"); + std::fs::create_dir(&auth_dir)?; + std::fs::set_permissions(&auth_dir, std::fs::Permissions::from_mode(0o700))?; + let auth_path = auth_dir.join("auth.json"); + std::fs::write(&auth_path, serde_json::json!({ + "profile_id":"synthetic-profile", "project_id":"synthetic-project", + "access_token":"synthetic-access", "refresh_token":null, "expires_at":4102444800u64 + }).to_string())?; + std::fs::set_permissions(&auth_path, std::fs::Permissions::from_mode(0o600))?; + let port = TcpListener::bind("127.0.0.1:0")?.local_addr()?.port(); + let child = std::process::Command::new(env!("CARGO_BIN_EXE_claude-code-proxy")) + .args(["serve", "--no-monitor", "--port", &port.to_string()]) + .env("CCP_CONFIG_DIR", directory.path()) + .env("XDG_STATE_HOME", directory.path().join("state")) + .env("CCP_ANTIGRAVITY_ENABLED", enabled) + .env( + "CCP_ANTIGRAVITY_USER_AGENT", + std::ffi::OsString::from_vec(invalid), + ) + .env("CCP_CURSOR_CLIENT_VERSION", "synthetic-version") + .env("CCP_LOG_STDERR", "1") + .env("CCP_LOG_VERBOSE", "1") + .env("CCP_TRAFFIC_LOG", "1") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn()?; + let mut child = ChildGuard(child); + drop(wait_for_service(&mut child, port)?); + let client = reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(5)) + .build()?; + let response = client + .get(format!( + "http://127.0.0.1:{port}/v1/models?include_capabilities=true" + )) + .send() + .await?; + assert_eq!(response.status(), 200); + let models: serde_json::Value = response.json().await?; + assert!(!models["data"].as_array().unwrap().iter().any(|entry| { + entry["id"] + .as_str() + .is_some_and(|name| name.starts_with("antigravity/")) + })); + let capability = &models["capabilities"]["providers"]["antigravity"]; + assert_eq!(capability["status"]["configuration"], "invalid"); + assert_eq!(capability["status"]["configuration_ready"], false); + assert_eq!(capability["enabled"], false); + // A network-free other-provider operation must reach traffic setup, + // not merely the early rejection of an unavailable native model. + let response = client + .post(format!("http://127.0.0.1:{port}/v1/messages/count_tokens")) + .json(&serde_json::json!({ + "model": "kimi-for-coding", + "messages": [{"role": "user", "content": "synthetic"}] + })) + .send() + .await?; + assert_eq!(response.status(), 200); + let count: serde_json::Value = response.json().await?; + assert!( + count["input_tokens"] + .as_u64() + .is_some_and(|count| count > 0) + ); + assert!( + directory + .path() + .join("state/claude-code-proxy/traffic") + .exists() + ); + let response = client.post(format!("http://127.0.0.1:{port}/v1/messages")) + .json(&serde_json::json!({"model":"antigravity/gemini-4-argon-low", "max_tokens":32, "messages":[{"role":"user","content":"synthetic"}]})) + .send().await?; + assert!(response.status().is_client_error()); + assert!(!response.text().await?.contains("sentinel")); + assert!( + matches!(upstream.accept(), Err(error) if error.kind() == std::io::ErrorKind::WouldBlock) + ); + send_signal(&child, "-TERM")?; + assert!(wait_for_exit(&mut child, Duration::from_secs(5))?.success()); + let mut stderr = String::new(); + child.0.stderr.take().unwrap().read_to_string(&mut stderr)?; + assert!(!stderr.contains("sentinel")); + } + } + Ok(()) +} + #[cfg(unix)] fn plain_service_exits_on_second_signal(signal: &str) -> Result<(), Box> { let upstream = TcpListener::bind("127.0.0.1:0")?; From 5f7b97c8b04df5580b596f15fbc13b1acd2a5978 Mon Sep 17 00:00:00 2001 From: Chris Liu <11517021+chris7iu@users.noreply.github.com> Date: Tue, 20 Oct 2026 06:35:58 +0000 Subject: [PATCH 03/30] feat(antigravity): add bounded manual OAuth completion Add opt-in hosted-code completion with PKCE and strict URL/state validation, private bounded Unix terminal input, and explicit post-commit and bounded-exit semantics. Keep automatic callback login unchanged. Co-Authored-By: Claude Code --- Cargo.lock | 1 + Cargo.toml | 3 + README.md | 10 + .../docs/reference/command-reference.md | 26 +- .../compatibility-and-limitations.md | 15 + .../content/docs/reference/configuration.md | 71 ++- src/main.rs | 34 +- src/providers/antigravity/auth.rs | 93 ++- src/providers/antigravity/manual.rs | 196 ++++++ src/providers/antigravity/manual_terminal.rs | 450 ++++++++++++++ .../antigravity/manual_terminal_tests.rs | 588 ++++++++++++++++++ src/providers/antigravity/mod.rs | 19 + src/providers/antigravity_auth_tests.rs | 243 ++++++++ tests/cli.rs | 359 +++++++++++ 14 files changed, 2080 insertions(+), 28 deletions(-) create mode 100644 src/providers/antigravity/manual.rs create mode 100644 src/providers/antigravity/manual_terminal.rs create mode 100644 src/providers/antigravity/manual_terminal_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 9596455b..e8e13584 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -378,6 +378,7 @@ dependencies = [ "hyper-util", "jiff", "jsonschema", + "libc", "once_cell", "predicates", "prost", diff --git a/Cargo.toml b/Cargo.toml index 03342ceb..e72b4296 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,9 @@ jiff = { version = "0.2", default-features = false, features = ["std", "tz-syste tiktoken-rs = { version = "0.12", default-features = false } jsonschema = { version = "=0.59.0", default-features = false, features = ["idna"] } +[target.'cfg(unix)'.dependencies] +libc = "=0.2.186" + [dev-dependencies] tokio = { version = "1", features = ["test-util"] } tempfile = "3.14" diff --git a/README.md b/README.md index 739b7628..b1c709c4 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,16 @@ gates; offline tests are not evidence of account access. See the and [configuration](docs/src/content/docs/reference/configuration.md#experimental-antigravity) before explicitly enabling it. Existing provider defaults and aliases are unchanged. +Antigravity login defaults to automatic browser/loopback completion. On Unix, +`claude-code-proxy antigravity auth login --manual` instead prints a browser URL +and accepts the **code displayed by the completion page** through hidden input in +your local foreground terminal. It does not open a browser or callback listener. +The explicitly configured OAuth client must permit +`https://antigravity.google/oauth-callback`; a loopback-only client may not work. +Cancel on browser errors or no displayed code; there is no automatic fallback. +Never paste a code/token into chat, shell arguments or logs. See the configuration +reference for PKCE/state distinctions, terminal bounds and unverified live gates. + > [!WARNING] > The proxy accepts local requests without client authentication. It binds to > `127.0.0.1` by default. Protect any non-loopback listener with a firewall or diff --git a/docs/src/content/docs/reference/command-reference.md b/docs/src/content/docs/reference/command-reference.md index dd11f85d..5de9a2c2 100644 --- a/docs/src/content/docs/reference/command-reference.md +++ b/docs/src/content/docs/reference/command-reference.md @@ -70,7 +70,7 @@ claude-code-proxy auth | `kimi` | Device code | Unsupported | User, expiry, scope, storage | Delete proxy credential | | `grok` | Browser PKCE | Device code | Expiry and storage | Delete proxy credential | | `cursor` | Browser polling flow | Unsupported | Source, claims, expiry | Delete proxy credential | -| `antigravity` (experimental) | Explicit browser PKCE | Unsupported | Safe local readiness, optional `--json` | Delete only Antigravity credential | +| `antigravity` (experimental) | Browser PKCE; opt-in Unix `--manual` paste-back | Unsupported | Safe local readiness, optional `--json` | Delete only Antigravity credential | Examples: @@ -88,11 +88,33 @@ Antigravity auth commands work even while inference is disabled: ```sh claude-code-proxy antigravity auth status --json claude-code-proxy antigravity auth login +claude-code-proxy antigravity auth login --manual claude-code-proxy antigravity auth logout ``` -Configure your installed-app client ID before explicit login; see +Configure your explicit OAuth client ID before login; see [Antigravity configuration](/reference/configuration/#experimental-antigravity). +Without `--manual`, browser opening and the ephemeral loopback callback are +unchanged. Experimental `--manual` is Unix-only: it requires terminal stdin and a +foreground controlling terminal, prints the authorization URL there, and opens +neither a browser nor a callback listener. The configured client must permit the +exact hosted redirect `https://antigravity.google/oauth-callback`. + +Open the URL in your browser, select the intended account, then copy the **code +displayed by the completion page** into the hidden local terminal prompt. The +page removes `code` from its address bar; copying that address afterward does not +supply the code. Cancel if the browser reports an error (such as +`redirect_uri_mismatch`) or displays no code. There is no automatic callback, +client or endpoint fallback. Never put a code in command arguments, chat or logs. + +Input is limited to 8192 printable ASCII bytes and 180 seconds, with at most +three empty-entry reprompts under the same deadline. Backspace edits; Enter +submits; Ctrl-C/Ctrl-D cancel. Pipes and non-Unix manual mode are rejected before +URL emission. Bare codes rely on server-enforced fresh S256 PKCE, **not returned +state validation**; a complete redirect URL, if separately available with its +code intact, additionally requires an exact matching state and redirect target. +See configuration for security and terminal-restoration limitations. + `status --json` is Antigravity-only and emits schema version 1, `provider: antigravity`, plus the safe provider descriptor documented under [model discovery](/reference/http-api/#get-v1models). A successfully emitted diff --git a/docs/src/content/docs/reference/compatibility-and-limitations.md b/docs/src/content/docs/reference/compatibility-and-limitations.md index 9295c212..462ddb5f 100644 --- a/docs/src/content/docs/reference/compatibility-and-limitations.md +++ b/docs/src/content/docs/reference/compatibility-and-limitations.md @@ -210,6 +210,9 @@ There are no automatic generation retries or endpoint/account fallback. | Requested output tokens | 1..65536 (8192 when omitted) | | Native header / body idle / whole collection deadline | 30s / 30s / 180s | | Auth lock acquisition / token-project request / browser callback | 10s / 30s / 180s | +| Manual terminal input and prompt output / input bytes / empty reprompts | 180s absolute / 8192 bytes / 3; fourth empty entry fails | +| Manual completion confirmation output | 2s absolute, best effort after durable success | +| Established manual-session failure diagnostic | 2s absolute after runtime cleanup; nonzero exit even if output is blocked | | Callback headers / each preconnect read | 8 KiB / 2s | | Stored credential / OAuth-project response | 64 KiB each | @@ -243,6 +246,18 @@ long-context compaction and native compaction acceptance are unverified. A manual real-client compact/resume fixture does not establish general compaction support. +Opt-in `antigravity auth login --manual` uses the hosted redirect +`https://antigravity.google/oauth-callback`, not Google OOB or a loopback listener. +The hosted page displays/copies the decoded authorization code and removes it +from its URL; copy the displayed code into the hidden local terminal, not chat or +shell arguments. Client registration/redirect acceptance, token exchange, server +S256 PKCE enforcement and rejection of PKCE downgrade remain live-validation +gates. Bare-code input does not validate returned state; complete redirect-URL +input does. No official-CLI parser or credential import is implied. Manual input +is Unix-only (Linux runtime tests), refuses non-TTY/background use, and restores +terminal mode on controlled exits but cannot guarantee restoration after SIGKILL, +abort or a crash. See the [manual completion contract](/reference/configuration/#experimental-antigravity). + The default HTTP User-Agent is exactly `claude-code-proxy/` on all Antigravity operations. An explicit `CCP_ANTIGRAVITY_USER_AGENT` or `antigravity.userAgent` override is verbatim compatibility metadata applied only to diff --git a/docs/src/content/docs/reference/configuration.md b/docs/src/content/docs/reference/configuration.md index 563693de..4919c2e3 100644 --- a/docs/src/content/docs/reference/configuration.md +++ b/docs/src/content/docs/reference/configuration.md @@ -220,13 +220,80 @@ import, device identity or attestation fabrication is performed. User-Agent is n authentication or evidence of entitlement; see the [reference evidence](/reference/compatibility-and-limitations/#evidence-and-remaining-gates). -Login requests explicit browser consent, offline access, PKCE S256, independent -one-time state, and an ephemeral `127.0.0.1` callback. The reference-protocol scopes +Login requests explicit browser consent, offline access, PKCE S256 and independent +one-time state. By default it opens a browser and uses an ephemeral `127.0.0.1` +callback. The reference-protocol scopes are `https://www.googleapis.com/auth/` followed by `cloud-platform`, `userinfo.email`, `userinfo.profile`, `cclog`, and `experimentsandconfigs`. These are reference compatibility scopes, not independently proven minimal scopes. No `agy`, Gemini, Codex, keychain or other application's credentials are discovered or imported. +**Experimental manual completion:** `antigravity auth login --manual` prints the +authorization URL and waits for a private terminal paste instead of opening a +browser/listener. It uses `https://antigravity.google/oauth-callback` in both the +authorization request and code exchange. Your explicitly configured client must +allow that exact hosted redirect; an installed-app/loopback-only registration may +not. No client ID/secret is bundled or substituted, scopes are unchanged, and no +Google OOB redirect, device flow or automatic fallback is attempted. If the browser +reports `redirect_uri_mismatch`, another error, or no code, cancel and correct the +registration; ordinary callback login remains a separately chosen alternative. + +Choose the intended account in the browser, then copy the **displayed code**, not +the final address bar. A query-free public inspection on 2026-10-20 found that the +hosted page reads/URL-decodes `code`, removes it from browser history, and displays +and copies that same bare value without adding state or a signed envelope. This +confirms the page's display contract, not successful OAuth or current official-CLI +parser equivalence. Historical hosted-redirect evidence is +https://github.com/google-antigravity/antigravity-cli/issues/315; official +paste-back UX is documented at https://www.antigravity.google/docs/cli/install/. + +Each attempt keeps a fresh verifier private and sends an independent state and +S256 challenge. A **bare code has no returned state to validate**: its binding to +this attempt relies on the authorization server enforcing S256 PKCE, including +rejecting a verifier-bearing exchange for a code issued without a challenge +(downgrade prevention). Those server properties and acceptance of the configured +client/redirect remain live-unverified. A complete redirect URL is also accepted +if obtained with its code intact; it must match the hosted HTTPS target and path, +have exactly one matching state and one valid code, and contain no userinfo, +fragment, conflicting error, duplicate security fields or malformed encoding. +A valid state-bound denial produces a fixed error. URL-looking malformed input +never falls back to bare-code acceptance. Bare codes are not percent/base64/JSON +decoded; surrounding spaces, controls, non-ASCII and URL/structured delimiters +are rejected rather than normalized. Slash/plus/dot/tilde/padding code bytes are +preserved and form-encoded once for the single token request. + +Manual input is **Unix-only**, verified at runtime on Linux. It requires terminal +stdin and a foreground controlling terminal. A separately opened nonblocking +`/dev/tty` owns hidden input and bounded prompt/URL output; ordinary pipes are not +supported and no input is echoed or placed in argv/log/status. The input cap is +8192 bytes; the absolute 180-second input/output deadline also covers continuous +input and partial pastes. Three empty entries may reprompt without resetting that +deadline; the fourth fails. Enter submits, Backspace edits, and Ctrl-C/Ctrl-D, +Ctrl-Z/Ctrl-backslash or SIGINT/SIGTERM cancel. Exact bracketed-paste delimiters +are recognized without enabling terminal modes; malformed/nested escapes fail. + +Before exchange, successful explicit cleanup discards **currently queued** terminal +input and restores the exact saved mode using immediate, non-draining termios +calls. If flushing fails, mode restoration is still attempted, but queued-input +disposal cannot be guaranteed; any cleanup error prevents exchange. Controlled +cancellation and unwinding also attempt restoration; SIGKILL, abort or unrecoverable +crashes cannot guarantee restoration. Input arriving after cleanup/process exit +cannot be disposed of by a finished process: stop pasting when cancelled. Never +paste credentials into chat, +shell commands or recorded transcripts. Login releases the auth lock during +consent, then rechecks its epoch before one exchange; logout/new login invalidates +stale consent. There is no token-exchange retry or partial-credential save. Once +credentials are durably saved, login is successful even if the bounded, +best-effort terminal confirmation cannot be shown or is interrupted. + +Once a foreground terminal session has been acquired, failure diagnostics also +use that owned nonblocking terminal, with a separate absolute two-second budget +after runtime cleanup. A blocked/closed terminal may prevent the diagnostic from +being shown, but the command still exits nonzero without falling back to a +blocking stderr write. Early non-terminal/background/unsupported-platform +refusals retain ordinary CLI error reporting; their stderr is outside this +interactive-session deadline contract. + Only `CCP_CONFIG_DIR/antigravity/auth.json` is used. Atomic private writes use mode 0600 on Unix, under a mode-0700 provider directory. A stable sibling `auth.lock` coordinates login/logout/refresh across processes; logout never removes that diff --git a/src/main.rs b/src/main.rs index 2c33a7eb..c66e31c5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -88,7 +88,13 @@ enum AntigravityGroup { #[derive(Debug, Subcommand)] enum AntigravityAuth { - Login, + Login { + /// Experimental Unix terminal paste-back; requires hosted-redirect client registration. + /// Copy the code displayed by the browser page. Cancel on browser errors + /// (including redirect_uri_mismatch) or no displayed code; no automatic fallback. + #[arg(long)] + manual: bool, + }, Device, Status { #[arg(long)] @@ -215,7 +221,10 @@ fn main() -> Result<()> { } => { let handlers = claude_code_proxy::providers::antigravity::cli_handlers(); match command { - AntigravityAuth::Login => handlers.login(), + AntigravityAuth::Login { manual: false } => handlers.login(), + AntigravityAuth::Login { manual: true } => { + claude_code_proxy::providers::antigravity::login_manual() + } AntigravityAuth::Device => handlers.device(), AntigravityAuth::Logout => handlers.logout(), AntigravityAuth::Status { json: true } => { @@ -453,6 +462,27 @@ mod tests { assert_eq!(select_serve_mode(false, false), ServeMode::Plain); } + #[test] + fn antigravity_manual_login_is_an_explicit_provider_only_option() { + let manual = Cli::try_parse_from([ + "claude-code-proxy", + "antigravity", + "auth", + "login", + "--manual", + ]); + assert!(manual.is_ok(), "manual login must be a supported opt-in"); + assert!( + Cli::try_parse_from(["claude-code-proxy", "antigravity", "auth", "login",]).is_ok() + ); + for provider in ["codex", "kimi", "cursor", "grok"] { + assert!( + Cli::try_parse_from(["claude-code-proxy", provider, "auth", "login", "--manual",]) + .is_err() + ); + } + } + #[test] fn demo_command_parses_without_server_options() { let cli = Cli::try_parse_from(["claude-code-proxy", "demo"]).unwrap(); diff --git a/src/providers/antigravity/auth.rs b/src/providers/antigravity/auth.rs index 0cb6bc88..e82665e1 100644 --- a/src/providers/antigravity/auth.rs +++ b/src/providers/antigravity/auth.rs @@ -216,6 +216,14 @@ pub(crate) fn now() -> u64 { .as_secs() } +pub(crate) const LOGIN_SAVED: &str = "Antigravity credentials saved. Experimental native access and model entitlement are NOT verified; start a fresh fixed-model session."; + +struct LoginAttempt { + epoch: String, + verifier: String, + state: String, +} + pub(crate) struct Auth { pub store: Store, pub config: Config, @@ -247,6 +255,46 @@ impl Auth { pub async fn logout(&self) -> Result<()> { self.store.logout().await } + #[cfg(any(unix, test))] + pub(crate) async fn login_manual_with_completion(&self, complete: F) -> Result<()> + where + F: FnOnce(url::Url, String) -> Fut, + Fut: std::future::Future>, + { + let attempt = self.begin_login().await?; + let redirect = super::manual::REDIRECT; + let authorization = authorization_url( + &self.config, + self.client_id()?, + redirect, + &attempt.state, + &attempt.verifier, + ); + // Completion owns terminal cleanup. No network or persistence before it + // has successfully restored the terminal and returned a validated code. + let input = complete(authorization, attempt.state.clone()).await?; + let code = super::manual::parse_completion(&input, &attempt.state)?; + self.exchange(attempt, redirect, &code).await + } + + fn client_id(&self) -> Result<&str> { + self.config + .client_id + .as_deref() + .ok_or("set CCP_ANTIGRAVITY_CLIENT_ID to your public installed-app OAuth client ID") + } + + async fn begin_login(&self) -> Result { + self.client_id()?; + let mut lock = self.store.lock(Duration::from_secs(10)).await?; + self.store.load()?; + Ok(LoginAttempt { + epoch: lock.epoch()?, + verifier: URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>()), + state: URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>()), + }) + } + pub async fn login(&self) -> Result<()> { self.login_with_opener(|authorization| { println!("Explicit browser consent for claude-code-proxy's experimental Antigravity adapter. No official CLI credentials are imported.\n{authorization}"); @@ -258,14 +306,7 @@ impl Auth { &self, opener: impl FnOnce(&url::Url) -> Result<()>, ) -> Result<()> { - let client_id = - self.config.client_id.as_deref().ok_or( - "set CCP_ANTIGRAVITY_CLIENT_ID to your public installed-app OAuth client ID", - )?; - let mut lock = self.store.lock(Duration::from_secs(10)).await?; - self.store.load()?; - let epoch = lock.epoch()?; - drop(lock); + let attempt = self.begin_login().await?; let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .map_err(|_| "OAuth callback bind failed")?; @@ -276,25 +317,36 @@ impl Auth { .map_err(|_| "callback address unavailable")? .port() ); - let verifier = URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>()); - let state = URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>()); - let authorization = - authorization_url(&self.config, client_id, &redirect, &state, &verifier); + let authorization = authorization_url( + &self.config, + self.client_id()?, + &redirect, + &attempt.state, + &attempt.verifier, + ); opener(&authorization)?; - let code = tokio::time::timeout(Duration::from_secs(180), callback(listener, &state)) - .await - .map_err(|_| "OAuth callback timed out")??; + let code = + tokio::time::timeout(Duration::from_secs(180), callback(listener, &attempt.state)) + .await + .map_err(|_| "OAuth callback timed out")??; + self.exchange(attempt, &redirect, &code).await?; + println!("{LOGIN_SAVED}"); + Ok(()) + } + + async fn exchange(&self, attempt: LoginAttempt, redirect: &str, code: &str) -> Result<()> { + let client_id = self.client_id()?; let mut lock = self.store.lock(Duration::from_secs(10)).await?; - if lock.epoch()? != epoch { + if lock.epoch()? != attempt.epoch { return Err("Antigravity login changed during consent; start login again"); } self.store.load()?; let mut form = vec![ ("grant_type", "authorization_code"), ("client_id", client_id), - ("redirect_uri", &redirect), - ("code", &code), - ("code_verifier", &verifier), + ("redirect_uri", redirect), + ("code", code), + ("code_verifier", &attempt.verifier), ]; if let Some(secret) = self.config.client_secret.as_deref() { form.push(("client_secret", secret)); @@ -336,9 +388,6 @@ impl Auth { } lock.advance()?; self.store.save(&lock, &credential)?; - println!( - "Antigravity credentials saved. Experimental native access and model entitlement are NOT verified; start a fresh fixed-model session." - ); Ok(()) } } diff --git a/src/providers/antigravity/manual.rs b/src/providers/antigravity/manual.rs new file mode 100644 index 00000000..92b10cdf --- /dev/null +++ b/src/providers/antigravity/manual.rs @@ -0,0 +1,196 @@ +//! Private, explicit manual OAuth completion; never credential import. +use super::replay::Result; + +#[cfg(unix)] +#[path = "manual_terminal.rs"] +pub(super) mod terminal; + +pub(crate) const REDIRECT: &str = "https://antigravity.google/oauth-callback"; +pub(crate) const INPUT_LIMIT: usize = 8192; + +pub(crate) const INVALID: &str = + "Invalid OAuth completion; paste the displayed code or a complete matching redirect URL"; + +fn valid_code(input: &str) -> bool { + !input.is_empty() + && input.len() <= INPUT_LIMIT + && !input.starts_with("code=") + && !input.starts_with("//") + && !input.starts_with("/oauth-callback") + && input + .bytes() + .all(|b| (0x21..=0x7e).contains(&b) && !b":?#&%{}\"'\\<>".contains(&b)) +} + +pub(crate) fn parse_completion(input: &str, state: &str) -> Result { + if input.len() > INPUT_LIMIT || input.is_empty() { + return Err(INVALID); + } + // A colon is never a bare-code byte: malformed URL-like input cannot fall + // through to the PKCE-only path. Bare codes are opaque, not percent-decoded. + if !input.contains(':') { + return valid_code(input).then(|| input.to_owned()).ok_or(INVALID); + } + if !input.bytes().all(|b| (0x21..=0x7e).contains(&b)) || input.contains(['\\', '#']) { + return Err(INVALID); + } + let (base, query) = input.split_once('?').ok_or(INVALID)?; + let (scheme, rest) = base.split_once("://").ok_or(INVALID)?; + let (authority, path) = rest.split_once('/').ok_or(INVALID)?; + // Validate the raw target before Url can normalize backslashes/dot segments. + if !scheme.eq_ignore_ascii_case("https") + || !(authority.eq_ignore_ascii_case("antigravity.google") + || authority.eq_ignore_ascii_case("antigravity.google:443")) + || path != "oauth-callback" + { + return Err(INVALID); + } + let bytes = query.as_bytes(); + for (i, byte) in bytes.iter().enumerate() { + if *byte == b'%' + && !(bytes.get(i + 1).is_some_and(u8::is_ascii_hexdigit) + && bytes.get(i + 2).is_some_and(u8::is_ascii_hexdigit)) + { + return Err(INVALID); + } + } + let url = url::Url::parse(input).map_err(|_| INVALID)?; + let mut returned_state = None; + let mut code = None; + let mut error = None; + for (key, value) in url.query_pairs() { + let slot = match key.as_ref() { + "state" => &mut returned_state, + "code" => &mut code, + "error" => &mut error, + _ => continue, + }; + if slot.is_some() + || value.is_empty() + || value.len() > INPUT_LIMIT + || !value.bytes().all(|b| (0x21..=0x7e).contains(&b)) + { + return Err(INVALID); + } + *slot = Some(value.into_owned()); + } + if returned_state.as_deref() != Some(state) { + return Err(INVALID); + } + match (code, error) { + (Some(code), None) if valid_code(&code) => Ok(code), + (None, Some(_)) => Err("OAuth consent denied"), + _ => Err(INVALID), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn antigravity_manual_bare_code_preserves_opaque_bytes() { + for code in [ + "4/0Ab-_x", + "code.with+plus~and=padding", + "A", + "xcode=inside", + ] { + assert_eq!(parse_completion(code, "state").unwrap(), code); + } + let maximum = "x".repeat(INPUT_LIMIT); + assert_eq!(parse_completion(&maximum, "state").unwrap(), maximum); + } + + #[test] + fn antigravity_manual_url_validates_state_and_decodes_once() { + for base in [REDIRECT, "HTTPS://ANTIGRAVITY.GOOGLE:443/oauth-callback"] { + let url = format!("{base}?state=state&code=4%2F0Ab-_x%2Bok%3D&scope=benign"); + assert_eq!(parse_completion(&url, "state").unwrap(), "4/0Ab-_x+ok="); + } + assert_eq!( + parse_completion( + &format!( + "{REDIRECT}?state=state&error=access_denied&error_description=sentinel-private" + ), + "state" + ) + .unwrap_err(), + "OAuth consent denied" + ); + } + + #[test] + fn antigravity_manual_rejects_untrusted_or_ambiguous_completion() { + let mut invalid = vec![ + "".to_string(), + " ".into(), + " x".into(), + "x ".into(), + "x\ny".into(), + "é".into(), + "x\u{7f}".into(), + "x\0y".into(), + "code=secret".into(), + "//example/code".into(), + "/oauth-callback?code=secret".into(), + "https:/antigravity.google/oauth-callback".into(), + "HTTPS:/antigravity.google/oauth-callback".into(), + "https:antigravity.google/oauth-callback".into(), + "other://secret".into(), + "{\"code\":\"secret\"}".into(), + "x%2Fy".into(), + "x&state=y".into(), + "x".repeat(INPUT_LIMIT + 1), + ]; + for character in [':', '?', '#', '&', '%', '{', '}', '"', '\'', '\\', '<', '>'] { + invalid.push(format!("a{character}b")); + } + for base in [ + "http://antigravity.google/oauth-callback", + "https://evil.example/oauth-callback", + "https://antigravity.google.evil.example/oauth-callback", + "https://antigravity.google:444/oauth-callback", + "https://user@antigravity.google/oauth-callback", + "https://antigravity.google/other", + "https://antigravity.google/oauth-callback/", + "https://antigravity.google/x/../oauth-callback", + "https://antigravity.google/%2e/oauth-callback", + "https://antigravity.google\\oauth-callback", + ] { + invalid.push(format!("{base}?state=state&code=secret")); + } + for query in [ + "code=secret", + "state=wrong&code=secret", + "state=&code=secret", + "state=state&state=state&code=secret", + "state=state&%73tate=state&code=secret", + "state=state&code=one&code=two", + "state=state&code=", + "state=state", + "state=state&code=secret&error=denied", + "state=state&error=one&error=two", + "state=state&error=", + "state=wrong&error=denied", + "state=state&code=sec%ret", + "state=state&code=secret%", + "state=state&code=secret%2", + "state=state&code=sec+ret", + "state=state&code=sec%20ret", + "state=state&code=%FF", + "state=state&code=%00secret", + "state=state&code=secret&extra=%GG", + "state=state&code=secret#fragment", + "state=state&code=%252F", + ] { + invalid.push(format!("{REDIRECT}?{query}")); + } + for input in invalid { + let error = + parse_completion(&input, "state").expect_err("untrusted completion accepted"); + assert!(!error.contains("secret")); + assert!(!error.contains("sentinel")); + } + } +} diff --git a/src/providers/antigravity/manual_terminal.rs b/src/providers/antigravity/manual_terminal.rs new file mode 100644 index 00000000..e9837a85 --- /dev/null +++ b/src/providers/antigravity/manual_terminal.rs @@ -0,0 +1,450 @@ +//! One owned nonblocking terminal, no stdin worker or general escape parser. +use super::super::{ + auth::{Auth, Store}, + config::Config, + replay::Result, +}; +use super::{INPUT_LIMIT, INVALID}; +use std::{ + fs::{File, OpenOptions}, + io::{self, IsTerminal, Read, Write}, + os::{fd::AsRawFd, unix::fs::OpenOptionsExt}, + time::Duration, +}; +use tokio::{ + signal::unix::{SignalKind, signal}, + time::Instant, +}; + +const CANCELLED: &str = "Manual OAuth login cancelled"; +const TIMED_OUT: &str = "Manual OAuth input timed out"; +const CLOSED: &str = "Manual OAuth terminal closed or unavailable"; +const RESTORE_FAILED: &str = "Manual OAuth terminal cleanup failed; no further exchange attempted"; +const PROMPT: &[u8] = + b"\nPaste the displayed authorization code (hidden), then Enter; Ctrl-C cancels: "; +const NOTICE: &[u8] = b"Experimental manual Antigravity login. Open this URL in your browser and choose the intended account.\nThe configured OAuth client must permit https://antigravity.google/oauth-callback.\nCopy the CODE DISPLAYED by the page, not the address bar (the page removes the code from its URL).\nCancel if the browser reports an error (including redirect_uri_mismatch) or shows no code.\nNo browser is opened automatically; no callback listener, credential import, or fallback is used.\n"; + +pub(crate) enum CliOutcome { + Success, + HandledFailure, +} + +/// Early refusal remains an ordinary CLI error. After terminal acquisition, +/// every failure is handled here, never by Rust main's blocking stderr report. +/// All auth/runtime/terminal guards are gone before returning the typed outcome. +pub(crate) fn run_cli() -> Result { + let mut terminal = Terminal::open()?; + let result = match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + { + Ok(runtime) => { + let result = runtime.block_on(async { + let auth = Auth::new(Config::load()?, Store::configured())?; + login_on_terminal(&auth, &mut terminal, Duration::from_secs(180)).await + }); + // No reactor-dependent reporting or async RAII survives this point. + drop(runtime); + result + } + Err(_) => Err("Manual OAuth runtime initialization failed"), + }; + let outcome = match result { + Ok(()) => CliOutcome::Success, + Err(error) => { + // Cleanup has already been attempted by the input/login core. A + // retry is best effort; a failed report must not fall back to stderr. + let error = terminal.restore().err().unwrap_or(error); + terminal.report_error(error); + CliOutcome::HandledFailure + } + }; + drop(terminal); + Ok(outcome) +} + +#[cfg(test)] +pub(crate) async fn login(auth: &Auth, timeout: Duration) -> Result<()> { + let mut terminal = Terminal::open()?; + login_on_terminal(auth, &mut terminal, timeout).await +} + +async fn login_on_terminal(auth: &Auth, terminal: &mut Terminal, timeout: Duration) -> Result<()> { + // Tokio registrations persist process-wide. Keep both receivers in the + // select through exchange and confirmation, not just terminal input. The + // post-runtime diagnostic uses its own synchronous deadline, not receivers. + // This path is never installed by serve or automatic callback login. + let mut interrupt = + signal(SignalKind::interrupt()).map_err(|_| "Manual OAuth signal setup failed")?; + let mut terminate = + signal(SignalKind::terminate()).map_err(|_| "Manual OAuth signal setup failed")?; + let result = tokio::select! { + biased; + _ = interrupt.recv() => Err(CANCELLED), + _ = terminate.recv() => Err(CANCELLED), + result = auth.login_manual_with_completion(|authorization, _| { + terminal.complete(authorization, timeout) + }) => result, + }; + // Also covers cancellation while complete() was suspended. Drop is backup, + // not the success path: a cleanup error must never authorize an exchange. + terminal.restore()?; + result?; + + // The credential is durably committed now. Confirmation is bounded, best + // effort only: a signal, closed tty, or backpressure must not relabel a + // committed profile as a cancelled/failed login and invite a second login. + let deadline = Instant::now() + Duration::from_secs(2); + tokio::select! { + biased; + _ = interrupt.recv() => (), + _ = terminate.recv() => (), + _ = async { + terminal.output(super::super::auth::LOGIN_SAVED.as_bytes(), deadline).await?; + terminal.output(b"\n", deadline).await + } => (), + } + Ok(()) +} + +struct Terminal { + file: File, + saved: libc::termios, + active: bool, +} +impl Terminal { + fn open() -> Result { + if !io::stdin().is_terminal() { + return Err("Manual OAuth login requires terminal stdin; pipes are unsupported"); + } + // A fresh open file description, NOT dup(stdin): flags cannot affect + // the shell's shared stdin descriptor. /dev/tty is the input authority. + let file = OpenOptions::new() + .read(true) + .write(true) + .custom_flags(libc::O_NONBLOCK | libc::O_NOCTTY | libc::O_CLOEXEC) + .open("/dev/tty") + .map_err(|_| CLOSED)?; + foreground(&file)?; + let mut saved = std::mem::MaybeUninit::uninit(); + // SAFETY: owned live tty fd and writable termios storage. + if unsafe { libc::tcgetattr(file.as_raw_fd(), saved.as_mut_ptr()) } != 0 { + return Err(CLOSED); + } + Ok(Self { + file, + saved: unsafe { saved.assume_init() }, + active: false, + }) + } + fn report_error(&mut self, message: &'static str) { + // The runtime has shut down, so neither Tokio time nor its persistent + // signal registrations may be used to drive this best-effort report. + // The fd is our original independent O_NONBLOCK /dev/tty description. + let deadline = std::time::Instant::now() + Duration::from_secs(2); + let diagnostic = format!("\nError: {message}\n"); + let mut bytes = diagnostic.as_bytes(); + let Ok(_mask) = TtouMask::block() else { + return; + }; + while !bytes.is_empty() { + let now = std::time::Instant::now(); + if now >= deadline || foreground(&self.file).is_err() { + return; + } + let mut descriptor = libc::pollfd { + fd: self.file.as_raw_fd(), + events: libc::POLLOUT, + revents: 0, + }; + // One absolute budget across partial writes, EAGAIN and EINTR. + let remaining = deadline.saturating_duration_since(now); + let wait_ms = remaining.as_millis().clamp(1, 20) as libc::c_int; + let ready = unsafe { libc::poll(&mut descriptor, 1, wait_ms) }; + if ready < 0 { + if io::Error::last_os_error().kind() == io::ErrorKind::Interrupted { + continue; + } + return; + } + if descriptor.revents & (libc::POLLHUP | libc::POLLERR | libc::POLLNVAL) != 0 { + return; + } + if std::time::Instant::now() >= deadline { + return; + } + if descriptor.revents & libc::POLLOUT == 0 { + continue; + } + match self.file.write(bytes) { + Ok(0) => return, + Ok(n) => bytes = &bytes[n..], + Err(error) if retryable(&error) => (), + Err(_) => return, + } + } + } + fn hide_input(&mut self) -> Result<()> { + foreground(&self.file)?; + let mut raw = self.saved; + raw.c_iflag &= !(libc::IGNBRK + | libc::BRKINT + | libc::PARMRK + | libc::ISTRIP + | libc::INLCR + | libc::IGNCR + | libc::ICRNL + | libc::IXON + | libc::IXOFF + | libc::IXANY + | libc::INPCK); + raw.c_lflag &= !(libc::ECHO + | libc::ECHOE + | libc::ECHOK + | libc::ECHONL + | libc::ECHOCTL + | libc::ECHOPRT + | libc::ECHOKE + | libc::ICANON + | libc::IEXTEN + | libc::ISIG); + raw.c_cflag = (raw.c_cflag & !(libc::CSIZE | libc::PARENB)) | libc::CS8; + raw.c_cc[libc::VMIN] = 1; + raw.c_cc[libc::VTIME] = 0; + // In particular preserve c_oflag/OPOST/ONLCR: authorization URL output + // must remain readable rather than stair-stepping across the terminal. + let _mask = TtouMask::block()?; + self.active = true; + if unsafe { libc::tcsetattr(self.file.as_raw_fd(), libc::TCSANOW, &raw) } != 0 { + return Err("Manual OAuth terminal setup failed"); + } + Ok(()) + } + fn restore(&mut self) -> Result<()> { + if !self.active { + return Ok(()); + } + let _mask = TtouMask::block().map_err(|_| RESTORE_FAILED)?; + // Discard queued input BEFORE restoring echo. Never drain a producer; + // this cannot promise disposal of future bytes arriving after exit. + let flushed = unsafe { libc::tcflush(self.file.as_raw_fd(), libc::TCIFLUSH) } == 0; + // TCSADRAIN/TCSAFLUSH can wait indefinitely for output: use TCSANOW. + let restored = + unsafe { libc::tcsetattr(self.file.as_raw_fd(), libc::TCSANOW, &self.saved) } == 0; + if restored { + self.active = false; + } + if flushed && restored { + Ok(()) + } else { + Err(RESTORE_FAILED) + } + } + async fn complete(&mut self, authorization: url::Url, timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + let result = async { + self.hide_input()?; + self.output(NOTICE, deadline).await?; + self.output(authorization.as_str().as_bytes(), deadline) + .await?; + self.output(b"\n", deadline).await?; + self.output(PROMPT, deadline).await?; + self.input(deadline).await + } + .await; + self.restore()?; + result + } + async fn output(&mut self, mut bytes: &[u8], deadline: Instant) -> Result<()> { + while !bytes.is_empty() { + self.ready(libc::POLLOUT, deadline).await?; + match self.file.write(bytes) { + Ok(0) => return Err(CLOSED), + Ok(n) => bytes = &bytes[n..], + Err(error) if retryable(&error) => (), + Err(_) => return Err(CLOSED), + } + } + Ok(()) + } + async fn input(&mut self, deadline: Instant) -> Result { + let mut line = Line::new(); + let mut buffer = [0u8; 256]; + loop { + self.ready(libc::POLLIN, deadline).await?; + match self.file.read(&mut buffer) { + Ok(0) => return Err(CLOSED), + Ok(n) => { + for byte in &buffer[..n] { + if Instant::now() >= deadline { + return Err(TIMED_OUT); + } + match line.feed(*byte)? { + Input::Pending => (), + Input::Empty => { + self.output(b"\nEmpty input; paste the displayed code: ", deadline) + .await? + } + Input::Complete(code) => return Ok(code), + } + } + } + Err(error) if retryable(&error) => (), + Err(_) => return Err(CLOSED), + } + } + } + async fn ready(&self, events: libc::c_short, deadline: Instant) -> Result<()> { + loop { + // A suspension on EVERY iteration gives signal delivery priority + // even under a continuously readable/writable tty or EINTR storm. + tokio::time::sleep_until((Instant::now() + Duration::from_millis(2)).min(deadline)) + .await; + if Instant::now() >= deadline { + return Err(TIMED_OUT); + } + foreground(&self.file)?; + let mut descriptor = libc::pollfd { + fd: self.file.as_raw_fd(), + events, + revents: 0, + }; + // SAFETY: one live descriptor; timeout zero never blocks a runtime thread. + let polled = unsafe { libc::poll(&mut descriptor, 1, 0) }; + if polled < 0 { + if io::Error::last_os_error().kind() == io::ErrorKind::Interrupted { + continue; + } + return Err(CLOSED); + } + if descriptor.revents & (libc::POLLHUP | libc::POLLERR | libc::POLLNVAL) != 0 { + return Err(CLOSED); + } + if descriptor.revents & events != 0 { + return Ok(()); + } + } + } +} +impl Drop for Terminal { + fn drop(&mut self) { + let _ = self.restore(); + } +} +fn foreground(file: &File) -> Result<()> { + // SAFETY: live owned descriptor; neither call mutates process/terminal state. + let group = unsafe { libc::tcgetpgrp(file.as_raw_fd()) }; + if group < 0 || group != unsafe { libc::getpgrp() } { + Err("Manual OAuth login requires a foreground controlling terminal") + } else { + Ok(()) + } +} +fn retryable(error: &io::Error) -> bool { + matches!( + error.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted + ) +} + +// The foreground group can change after validation. Block SIGTTOU only on the +// current thread around synchronous termios/flush calls, restoring its exact +// prior mask before any await. Cleanup must not stop the process in the kernel. +struct TtouMask(libc::sigset_t); +impl TtouMask { + fn block() -> Result { + let mut set = std::mem::MaybeUninit::uninit(); + let mut old = std::mem::MaybeUninit::uninit(); + unsafe { + if libc::sigemptyset(set.as_mut_ptr()) != 0 { + return Err(RESTORE_FAILED); + } + let mut set = set.assume_init(); + if libc::sigaddset(&mut set, libc::SIGTTOU) != 0 + || libc::pthread_sigmask(libc::SIG_BLOCK, &set, old.as_mut_ptr()) != 0 + { + return Err(RESTORE_FAILED); + } + Ok(Self(old.assume_init())) + } + } +} +impl Drop for TtouMask { + fn drop(&mut self) { + unsafe { + libc::pthread_sigmask(libc::SIG_SETMASK, &self.0, std::ptr::null_mut()); + } + } +} + +enum Input { + Pending, + Empty, + Complete(String), +} +struct Line { + bytes: [u8; INPUT_LIMIT], + len: usize, + escape: usize, + paste: bool, + empty: u8, +} +impl Line { + fn new() -> Self { + Self { + bytes: [0; INPUT_LIMIT], + len: 0, + escape: 0, + paste: false, + empty: 0, + } + } + fn feed(&mut self, byte: u8) -> Result { + if matches!(byte, 3 | 4 | 26 | 28) { + return Err(CANCELLED); + } + if self.escape != 0 { + let expected = if self.paste { + b"\x1b[201~" + } else { + b"\x1b[200~" + }; + if byte != expected[self.escape] { + return Err(INVALID); + } + self.escape += 1; + if self.escape == expected.len() { + self.escape = 0; + self.paste = !self.paste; + } + return Ok(Input::Pending); + } + match byte { + 0x1b => self.escape = 1, + b'\r' | b'\n' if !self.paste => { + if self.len != 0 { + // Only printable ASCII entered the fixed buffer. + return Ok(Input::Complete( + String::from_utf8(self.bytes[..self.len].to_vec()).map_err(|_| INVALID)?, + )); + } + self.empty += 1; + if self.empty > 3 { + return Err("Manual OAuth empty input limit exceeded"); + } + return Ok(Input::Empty); + } + 8 | 127 => self.len = self.len.saturating_sub(1), + 0x21..=0x7e if self.len < INPUT_LIMIT => { + self.bytes[self.len] = byte; + self.len += 1; + } + _ => return Err(INVALID), + } + Ok(Input::Pending) + } +} + +#[cfg(test)] +#[path = "manual_terminal_tests.rs"] +mod tests; diff --git a/src/providers/antigravity/manual_terminal_tests.rs b/src/providers/antigravity/manual_terminal_tests.rs new file mode 100644 index 00000000..eb23b515 --- /dev/null +++ b/src/providers/antigravity/manual_terminal_tests.rs @@ -0,0 +1,588 @@ +//! Every terminal mutation is inside a private PTY child, never this test runner's tty. +use super::*; +use crate::providers::antigravity::{auth::Store, config::Config}; +use axum::{Json, Router, routing::post}; +use serde_json::json; +use std::{ + fs::File, + io::{Read, Write}, + os::{ + fd::{AsRawFd, FromRawFd}, + unix::{fs::OpenOptionsExt, process::CommandExt}, + }, + process::{Child, Command, Stdio}, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Instant, +}; + +const HELPER: &str = + "providers::antigravity::manual::terminal::tests::antigravity_manual_pty_child"; +const CODE: &str = "4/0manual-private-code"; +const PROMPT: &str = "Paste the displayed authorization code"; + +fn termios(fd: libc::c_int) -> libc::termios { + let mut value = std::mem::MaybeUninit::uninit(); + assert_eq!(unsafe { libc::tcgetattr(fd, value.as_mut_ptr()) }, 0); + unsafe { value.assume_init() } +} +fn same_termios(a: &libc::termios, b: &libc::termios) -> bool { + a.c_iflag == b.c_iflag + && a.c_oflag == b.c_oflag + && a.c_cflag == b.c_cflag + && a.c_lflag == b.c_lflag + && a.c_cc == b.c_cc + && unsafe { + libc::cfgetispeed(a) == libc::cfgetispeed(b) + && libc::cfgetospeed(a) == libc::cfgetospeed(b) + } +} + +#[test] +fn antigravity_manual_pty_child() { + let Ok(mode) = std::env::var("CCP_MANUAL_TEST_CHILD") else { + return; + }; + if mode == "trace" { + tracing_subscriber::fmt() + .with_max_level(tracing::Level::TRACE) + .with_writer(std::io::stderr) + .with_ansi(false) + .without_time() + .init(); + } + if mode == "background" { + let status = Command::new(std::env::current_exe().unwrap()) + .args([HELPER, "--exact", "--nocapture"]) + .env("CCP_MANUAL_TEST_CHILD", "background-child") + .process_group(0) + .status() + .unwrap(); + assert!(status.success()); + return; + } + if mode == "hangup" { + // Exercise EOF/EIO rather than the kernel's default SIGHUP termination. + unsafe { + libc::signal(libc::SIGHUP, libc::SIG_IGN); + } + } + let before = termios(0); + if mode == "drop" { + let result = std::panic::catch_unwind(|| { + let mut terminal = Terminal::open().unwrap(); + terminal.hide_input().unwrap(); + panic!("synthetic terminal unwind"); + }); + assert!(result.is_err()); + assert!(same_termios(&before, &termios(0))); + eprintln!("manual-helper:drop-restored"); + return; + } + let flags = unsafe { libc::fcntl(0, libc::F_GETFL) }; + let timeout = if mode == "deadline" || mode == "continuous" || mode == "blocked-output" { + Duration::from_millis(300) + } else { + Duration::from_secs(4) + }; + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let requests = Arc::new(AtomicUsize::new(0)); + let counter = requests.clone(); + let hold = mode == "hold"; + let server = crate::providers::antigravity_http_tests::Server::start( + Router::new().route("/token", post(move |axum::extract::Form(form): axum::extract::Form>| { + let counter = counter.clone(); + async move { + counter.fetch_add(1, Ordering::SeqCst); + assert_eq!(form["code"], CODE); + assert_eq!(form["redirect_uri"], "https://antigravity.google/oauth-callback"); + assert!(!form["code_verifier"].is_empty()); + std::fs::write(std::path::PathBuf::from(std::env::var_os("HOME").unwrap()).join("synthetic-verifier"), &form["code_verifier"]).unwrap(); + assert!(same_termios(&before, &termios(0)), "exchange before terminal restoration"); + eprintln!("manual-helper:exchange"); + if hold { std::future::pending::<()>().await; } + Json(json!({"access_token":"manual-private-access", "refresh_token":"manual-private-refresh", "expires_in":3600})) + } + })) + ).await; + let directory = tempfile::tempdir().unwrap(); + let committed = mode.starts_with("committed-"); + let path = if committed { + std::path::PathBuf::from(std::env::var_os("HOME").unwrap()).join("auth.json") + } else { directory.path().join("auth.json") }; + let mut store = Store::new(path); + if committed { + store = store.with_before_save(Arc::new(|| { + // Fill only this test PTY's output queue before the durable + // commit. The parent watches auth.json without draining output. + let mut tty = std::fs::OpenOptions::new().write(true) + .custom_flags(libc::O_NONBLOCK | libc::O_NOCTTY | libc::O_CLOEXEC) + .open("/dev/tty").unwrap(); + for _ in 0..128 { + match tty.write(&[b'x'; 1024]) { + Ok(_) => (), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => break, + Err(error) => panic!("test PTY fill failed: {error}"), + } + } + })); + } + let auth = Auth::new(Config { + token_url: server.url.join("/token").unwrap(), + client_id: Some(if mode == "blocked-output" || mode == "long-output" { "x".repeat(65536) } else { "synthetic-public-client".into() }), + project: Some("synthetic-project".into()), + ..Config::default() + }, store).unwrap(); + let result = if mode == "cleanup-error" { + let mut terminal = Terminal::open().unwrap(); + auth.login_manual_with_completion(|_, _| async { + terminal.hide_input()?; + let deadline = tokio::time::Instant::now() + timeout; + terminal.output(PROMPT.as_bytes(), deadline).await?; + let input = terminal.input(deadline).await?; + // A real tcflush/tcsetattr failure, confined to this owned fd. + // Restore the real PTY afterwards so this test cannot strand it. + let tty = std::mem::replace(&mut terminal.file, File::open("/dev/null").unwrap()); + let failed_cleanup = terminal.restore(); + terminal.file = tty; + terminal.restore()?; + failed_cleanup?; + Ok(input) + }).await + } else { + login(&auth, timeout).await + }; + if mode == "background-child" { + assert!(result.unwrap_err().contains("foreground")); + assert!(!directory.path().join("auth.json").exists()); + eprintln!("manual-helper:background-refused"); + } else { + match result { + Ok(()) => { + assert_eq!(requests.load(Ordering::SeqCst), 1); + assert!(auth.store.load().unwrap().is_some()); + eprintln!("manual-helper:success"); + } + Err(error) => { + assert!(auth.store.load().unwrap().is_none()); + assert_eq!(requests.load(Ordering::SeqCst), usize::from(hold)); + eprintln!("manual-helper:error:{error}"); + } + } + } + }); + if mode != "hangup" { + assert!( + same_termios(&before, &termios(0)), + "terminal mode not restored exactly" + ); + assert_eq!( + unsafe { libc::fcntl(0, libc::F_GETFL) }, + flags, + "stdin file-description flags changed" + ); + eprintln!("manual-helper:restored"); + } +} + +struct PtyChild { + child: Child, + master: Option, + slave: File, + before: libc::termios, + output: Vec, + _home: tempfile::TempDir, +} +impl Drop for PtyChild { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} +impl PtyChild { + fn start(mode: &str) -> Self { + let (mut master, mut slave) = (-1, -1); + assert_eq!( + unsafe { + libc::openpty( + &mut master, + &mut slave, + std::ptr::null_mut(), + std::ptr::null(), + std::ptr::null(), + ) + }, + 0, + "mandatory PTY fixture unavailable" + ); + let master = unsafe { File::from_raw_fd(master) }; + let slave = unsafe { File::from_raw_fd(slave) }; + for file in [&master, &slave] { + assert_eq!( + unsafe { libc::fcntl(file.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) }, + 0 + ); + } + let flags = unsafe { libc::fcntl(master.as_raw_fd(), libc::F_GETFL) }; + assert_eq!( + unsafe { libc::fcntl(master.as_raw_fd(), libc::F_SETFL, flags | libc::O_NONBLOCK) }, + 0 + ); + let mut before = termios(slave.as_raw_fd()); + // An unusual prior mode catches "restore default/raw off" shortcuts. + before.c_lflag |= libc::ECHONL; + before.c_iflag |= libc::IXOFF; + before.c_oflag |= libc::OPOST | libc::ONLCR; + assert_eq!( + unsafe { libc::tcsetattr(slave.as_raw_fd(), libc::TCSANOW, &before) }, + 0 + ); + let home = tempfile::tempdir().unwrap(); + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args([HELPER, "--exact", "--nocapture"]) + .env_clear() + .env("HOME", home.path()) + .env("CCP_CONFIG_DIR", home.path()) + .env("CCP_MANUAL_TEST_CHILD", mode) + .env("RUST_LOG", "trace") + .env("CCP_LOG_STDERR", "1") + .env("CCP_LOG_VERBOSE", "1") + .env("CCP_TRAFFIC_LOG", "1") + .stdin(Stdio::from(slave.try_clone().unwrap())) + .stdout(Stdio::from(slave.try_clone().unwrap())) + .stderr(Stdio::from(slave.try_clone().unwrap())); + unsafe { + command.pre_exec(|| { + if libc::setsid() == -1 || libc::ioctl(0, libc::TIOCSCTTY, 0) == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let child = command.spawn().unwrap(); + Self { + child, + master: Some(master), + slave, + before, + output: Vec::new(), + _home: home, + } + } + fn collect(&mut self) { + let Some(master) = self.master.as_mut() else { + return; + }; + let mut buf = [0u8; 4096]; + // Bound each collection turn, including a continuously writing child. + for _ in 0..16 { + match master.read(&mut buf) { + Ok(0) => break, + Ok(n) => self.output.extend_from_slice(&buf[..n]), + Err(e) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.raw_os_error() == Some(libc::EIO) => + { + break; + } + Err(e) => panic!("PTY read: {e}"), + } + } + assert!(self.output.len() < 256 * 1024, "unbounded terminal output"); + } + fn text(&self) -> String { + String::from_utf8_lossy(&self.output).into_owned() + } + fn until(&mut self, text: &str) { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + self.collect(); + if self.text().contains(text) { + return; + } + assert!(Instant::now() < deadline, "missing {text}: {}", self.text()); + assert!( + self.child.try_wait().unwrap().is_none(), + "child exited before {text}: {}", + self.text() + ); + std::thread::sleep(Duration::from_millis(5)); + } + } + fn send(&mut self, bytes: &[u8]) { + let deadline = Instant::now() + Duration::from_secs(2); + let mut rest = bytes; + while !rest.is_empty() { + match self.master.as_mut().unwrap().write(rest) { + Ok(n) if n > 0 => rest = &rest[n..], + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(2)) + } + other => panic!("PTY write failed: {other:?}"), + } + assert!(Instant::now() < deadline, "PTY producer blocked"); + } + } + fn finish(&mut self, expected: &str) { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + self.collect(); + if let Some(status) = self.child.try_wait().unwrap() { + self.collect(); + assert!(status.success(), "{}", self.text()); + assert!(self.text().contains(expected), "{}", self.text()); + assert!(same_termios(&self.before, &termios(self.slave.as_raw_fd()))); + for secret in [ + CODE, + "manual-private-access", + "manual-private-refresh", + "QUEUED_PRIVATE_TAIL", + ] { + assert!( + !self.text().contains(secret), + "terminal disclosed input or credential" + ); + } + if let Ok(verifier) = + std::fs::read_to_string(self._home.path().join("synthetic-verifier")) + { + assert!( + !self.text().contains(&verifier), + "terminal/log disclosed verifier" + ); + } + let mut pending: libc::c_int = -1; + assert_eq!( + unsafe { libc::ioctl(self.slave.as_raw_fd(), libc::FIONREAD, &mut pending) }, + 0 + ); + assert_eq!(pending, 0, "queued input survived cleanup"); + return; + } + assert!( + Instant::now() < deadline, + "child/runtime did not exit: {}", + self.text() + ); + std::thread::sleep(Duration::from_millis(5)); + } + } + fn signal(&self, signal: libc::c_int) { + assert_eq!( + unsafe { libc::kill(self.child.id() as libc::pid_t, signal) }, + 0 + ); + } +} + +#[test] +fn antigravity_manual_pty_hidden_code_editing_crlf_and_queued_tail() { + for input in [ + format!("{CODE}\r\nQUEUED_PRIVATE_TAIL\n").into_bytes(), + format!("{CODE}x\x08y\x7f\nQUEUED_PRIVATE_TAIL\n").into_bytes(), + format!("\x1b[200~{CODE}\x1b[201~\rQUEUED_PRIVATE_TAIL\n").into_bytes(), + format!("\r\r\r{CODE}\r").into_bytes(), + ] { + let mut child = PtyChild::start("success"); + child.until(PROMPT); + let raw = termios(child.slave.as_raw_fd()); + assert_eq!( + raw.c_lflag & (libc::ECHO | libc::ECHONL | libc::ICANON | libc::ISIG), + 0 + ); + assert_eq!(raw.c_oflag, child.before.c_oflag); + child.send(&input); + child.finish("manual-helper:success"); + } +} + +#[test] +fn antigravity_manual_pty_full_url_and_exact_authorization_output() { + let mut child = PtyChild::start("trace"); + child.until(PROMPT); + let text = child.text(); + let line = text + .lines() + .find(|line| line.starts_with("https://accounts.google.com/")) + .unwrap(); + let authorization = url::Url::parse(line).unwrap(); + assert_eq!(authorization.as_str(), line); + let pairs = authorization + .query_pairs() + .collect::>(); + assert_eq!( + pairs["redirect_uri"], + "https://antigravity.google/oauth-callback" + ); + let mut completed = url::Url::parse("https://antigravity.google/oauth-callback").unwrap(); + completed + .query_pairs_mut() + .extend_pairs([("state", pairs["state"].as_ref()), ("code", CODE)]); + child.send(format!("{completed}\n").as_bytes()); + child.finish("manual-helper:success"); +} + +#[test] +fn antigravity_manual_pty_invalid_input_cancels_and_flushes() { + let cases = vec![ + vec![3], + vec![4], + vec![26], + vec![28], + vec![0xff], + vec![0xc3], + vec![0], + b"\r\r\r\r".to_vec(), + b"\x1b[200~x\x1b[200~".to_vec(), + b"\x1b[201~".to_vec(), + b"\x1b[999999999999999999".to_vec(), + b"\x1b[200~x\x1b[20x".to_vec(), + vec![b'x'; 8193], + ]; + for mut input in cases { + let mut child = PtyChild::start("invalid"); + child.until(PROMPT); + input.extend_from_slice(b"QUEUED_PRIVATE_TAIL\n"); + child.send(&input); + child.finish("manual-helper:error:"); + } +} + +#[test] +fn antigravity_manual_pty_deadline_partial_escape_and_continuous_input() { + for input in [b"".as_slice(), b"\x1b[20", b"\x1b[200~"] { + let mut child = PtyChild::start("deadline"); + child.until(PROMPT); + child.send(input); + child.finish("manual-helper:error:Manual OAuth input timed out"); + } + let mut child = PtyChild::start("continuous"); + child.until(PROMPT); + let deadline = Instant::now() + Duration::from_secs(2); + while child.child.try_wait().unwrap().is_none() && Instant::now() < deadline { + // Constant activity with a bounded logical line must not reset the deadline. + let _ = child.master.as_mut().unwrap().write(b"x\x08"); + std::thread::sleep(Duration::from_millis(1)); + } + child.finish("manual-helper:error:Manual OAuth input timed out"); +} + +#[test] +fn antigravity_manual_pty_signals_cancel_input_and_held_exchange() { + for signal in [libc::SIGINT, libc::SIGTERM] { + for hold in [false, true] { + let mut child = PtyChild::start(if hold { "hold" } else { "invalid" }); + child.until(PROMPT); + if hold { + child.send(format!("{CODE}\n").as_bytes()); + child.until("manual-helper:exchange"); + } + child.signal(signal); + child.finish("manual-helper:error:Manual OAuth login cancelled"); + } + } +} + +#[test] +fn antigravity_manual_pty_background_refused_before_url_or_mutation() { + let mut child = PtyChild::start("background"); + child.finish("manual-helper:background-refused"); + assert!(!child.text().contains("accounts.google.com")); +} + +#[test] +fn antigravity_manual_pty_output_backpressure_has_absolute_deadline() { + let mut child = PtyChild::start("blocked-output"); + // Do not consume the PTY output until the short input/output deadline expires. + std::thread::sleep(Duration::from_millis(600)); + assert!( + same_termios(&child.before, &termios(child.slave.as_raw_fd())), + "output backpressure prevented timely cleanup" + ); + child.finish("manual-helper:error:Manual OAuth input timed out"); +} + +#[test] +fn antigravity_manual_pty_committed_login_is_success_despite_confirmation_signal_or_timeout() { + for signal in [Some(libc::SIGINT), Some(libc::SIGTERM), None] { + let mut child = PtyChild::start("committed-confirmation"); + child.until(PROMPT); + child.send(format!("{CODE}\n").as_bytes()); + let saved = child._home.path().join("auth.json"); + let deadline = Instant::now() + Duration::from_secs(3); + while !saved.exists() { + assert!( + Instant::now() < deadline, + "fixture never committed credentials" + ); + assert!( + child.child.try_wait().unwrap().is_none(), + "fixture exited before commit" + ); + std::thread::sleep(Duration::from_millis(5)); + } + if let Some(signal) = signal { + child.signal(signal); + } else { + std::thread::sleep(Duration::from_millis(2500)); + } + child.finish("manual-helper:success"); + assert!(!child.text().contains("login cancelled")); + assert!(!child.text().contains("input timed out")); + assert!(saved.exists()); + } +} + +#[test] +fn antigravity_manual_pty_partial_output_is_complete_and_cleanup_failure_never_exchanges() { + let mut child = PtyChild::start("long-output"); + child.until(PROMPT); + let text = child.text(); + let line = text + .lines() + .find(|line| line.starts_with("https://accounts.google.com/")) + .unwrap(); + let url = url::Url::parse(line).unwrap(); + assert_eq!(url.as_str(), line); + assert_eq!( + url.query_pairs() + .find(|(key, _)| key == "client_id") + .unwrap() + .1, + "x".repeat(65536) + ); + child.send(format!("{CODE}\n").as_bytes()); + child.finish("manual-helper:success"); + + let mut child = PtyChild::start("cleanup-error"); + child.until(PROMPT); + child.send(format!("{CODE}\nQUEUED_PRIVATE_TAIL\n").as_bytes()); + child.finish("manual-helper:error:Manual OAuth terminal cleanup failed"); + assert!(!child.text().contains("manual-helper:exchange")); + + let mut child = PtyChild::start("drop"); + child.finish("manual-helper:drop-restored"); +} + +#[test] +fn antigravity_manual_pty_hangup_exits_without_reader_or_runtime_hang() { + let mut child = PtyChild::start("hangup"); + child.until(PROMPT); + drop(child.master.take()); + let deadline = Instant::now() + Duration::from_secs(3); + while child.child.try_wait().unwrap().is_none() { + assert!( + Instant::now() < deadline, + "hangup did not terminate input reader" + ); + std::thread::sleep(Duration::from_millis(5)); + } +} diff --git a/src/providers/antigravity/mod.rs b/src/providers/antigravity/mod.rs index fa2ff269..4dfa4fc4 100644 --- a/src/providers/antigravity/mod.rs +++ b/src/providers/antigravity/mod.rs @@ -1,6 +1,8 @@ //! Experimental, opt-in native Argon subscription transport. pub(crate) mod auth; pub(crate) mod config; +#[cfg(any(unix, test))] +mod manual; mod refresh; pub(crate) mod replay; pub(crate) mod request; @@ -316,6 +318,23 @@ pub fn status_json() -> Value { value } +/// Explicit manual completion is separate from the shared provider CLI contract. +pub fn login_manual() -> anyhow::Result<()> { + #[cfg(not(unix))] + anyhow::bail!("Antigravity manual login is unsupported on this platform; use auth login"); + #[cfg(unix)] + { + match manual::terminal::run_cli().map_err(anyhow::Error::msg)? { + manual::terminal::CliOutcome::Success => Ok(()), + manual::terminal::CliOutcome::HandledFailure => { + // run_cli has dropped EVERY auth/runtime/terminal guard. Do not + // return Err: Rust main's stderr report can block on a full tty. + std::process::exit(1) + } + } + } +} + struct AntigravityCli; static CLI: AntigravityCli = AntigravityCli; fn run_auth(action: &str) -> anyhow::Result<()> { diff --git a/src/providers/antigravity_auth_tests.rs b/src/providers/antigravity_auth_tests.rs index 527e3859..c2e9f973 100644 --- a/src/providers/antigravity_auth_tests.rs +++ b/src/providers/antigravity_auth_tests.rs @@ -654,6 +654,249 @@ async fn antigravity_explicit_login_exchanges_pkce_and_saves_discovered_project( } } +#[tokio::test] +async fn antigravity_manual_login_exchanges_once_with_fresh_pkce_and_exact_redirect() { + use base64::Engine; + use sha2::Digest; + let exchanges = Arc::new(std::sync::Mutex::new(Vec::< + std::collections::HashMap, + >::new())); + let captured = exchanges.clone(); + let server = Server::start(Router::new().route("/token", post(move |axum::extract::Form(form): axum::extract::Form>| { + let captured = captured.clone(); + async move { + captured.lock().unwrap().push(form); + Json(json!({"access_token":"manual-private-access", "refresh_token":"manual-private-refresh", "expires_in":3600})) + } + }))).await; + let directory = tempfile::tempdir().unwrap(); + let auth = Auth::new( + Config { + token_url: server.url.join("/token").unwrap(), + client_id: Some("synthetic-public-client".into()), + project: Some("configured-project".into()), + ..Config::default() + }, + Store::new(directory.path().join("auth.json")), + ) + .unwrap(); + let mut previous = None; + let mut previous_profile = None; + for full_url in [false, true] { + let mut issued = None; + auth.login_manual_with_completion(|url, state| { + let pairs = url + .query_pairs() + .map(|(k, v)| (k.into_owned(), v.into_owned())) + .collect::>(); + assert_eq!( + pairs["redirect_uri"], + "https://antigravity.google/oauth-callback" + ); + assert_eq!(pairs["state"], state); + assert_eq!(pairs["code_challenge_method"], "S256"); + issued = Some((state.clone(), pairs["code_challenge"].clone())); + let store = auth.store.clone(); + async move { + // Consent must not retain the cross-process auth lock. + drop(store.lock(Duration::from_millis(50)).await.unwrap()); + if full_url { + let mut redirect = + url::Url::parse("https://antigravity.google/oauth-callback").unwrap(); + redirect + .query_pairs_mut() + .extend_pairs([("state", state.as_str()), ("code", "4/0manual+secret=")]); + Ok(redirect.to_string()) + } else { + Ok("4/0manual+secret=".to_string()) + } + } + }) + .await + .unwrap(); + let issued = issued.unwrap(); + if let Some(previous) = previous.as_ref() { + assert_ne!(&issued, previous); + } + let exchange = exchanges.lock().unwrap().last().unwrap().clone(); + assert_eq!(exchange["code"], "4/0manual+secret="); + assert_eq!( + exchange["redirect_uri"], + "https://antigravity.google/oauth-callback" + ); + assert_eq!(exchange["grant_type"], "authorization_code"); + assert_eq!( + issued.1, + base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(sha2::Sha256::digest(exchange["code_verifier"].as_bytes())) + ); + let credential = auth.store.load().unwrap().unwrap(); + if let Some(previous) = previous_profile.as_ref() { + assert_ne!(&credential.profile_id, previous); + } + previous = Some(issued); + previous_profile = Some(credential.profile_id); + } + assert_eq!(exchanges.lock().unwrap().len(), 2); +} + +#[tokio::test] +async fn antigravity_manual_consent_epoch_changes_prevent_exchange() { + for logout in [false, true] { + let (_directory, _server, auth, state) = auth_fixture(true).await; + let visited = Arc::new(AtomicUsize::new(0)); + let entered = visited.clone(); + let result = auth + .login_manual_with_completion(|_, _| async { + entered.fetch_add(1, Ordering::SeqCst); + if logout { + auth.logout().await.unwrap(); + } else { + let mut lock = auth.store.lock(Duration::from_millis(50)).await.unwrap(); + lock.advance().unwrap(); + let mut new = expired(); + new.profile_id = "newer-profile".into(); + auth.store.save(&lock, &new).unwrap(); + } + Ok("4/0stale-code".into()) + }) + .await; + assert_eq!(visited.load(Ordering::SeqCst), 1); + assert!(result.is_err()); + assert_eq!(state.count.load(Ordering::SeqCst), 0); + if logout { + assert!(auth.store.load().unwrap().is_none()); + } else { + assert_eq!( + auth.store.load().unwrap().unwrap().profile_id, + "newer-profile" + ); + } + } +} + +#[tokio::test] +async fn antigravity_manual_invalid_completion_never_exchanges_or_mutates_credentials() { + for input in [ + "", + "code=secret", + "https:/invalid", + "https://antigravity.google/oauth-callback?state=wrong&code=secret", + ] { + let (_directory, _server, auth, state) = auth_fixture(true).await; + let before = auth.store.load().unwrap().unwrap(); + let result = auth + .login_manual_with_completion(|_, _| async { Ok(input.to_owned()) }) + .await; + assert!(result.is_err()); + assert_eq!(state.count.load(Ordering::SeqCst), 0); + assert!(auth.store.load().unwrap().unwrap() == before); + } +} + +#[tokio::test] +async fn antigravity_manual_foreign_or_unbound_code_is_not_retried_or_saved() { + // This fake AS enforces the required PKCE/downgrade policy. It does NOT + // establish that Google's live AS enforces either property for this client. + for code in ["foreign-attempt-code", "code-issued-without-challenge"] { + let count = Arc::new(AtomicUsize::new(0)); + let counter = count.clone(); + let server = Server::start(Router::new().route( + "/token", + post( + move |axum::extract::Form(form): axum::extract::Form< + std::collections::HashMap, + >| { + let counter = counter.clone(); + async move { + counter.fetch_add(1, Ordering::SeqCst); + assert_eq!(form["code"], code); + assert!(!form["code_verifier"].is_empty()); + ( + http::StatusCode::BAD_REQUEST, + "sentinel-private-token-error", + ) + } + }, + ), + )) + .await; + let directory = tempfile::tempdir().unwrap(); + let store = Store::new(directory.path().join("auth.json")); + let before = expired(); + saved(&store, &before).await; + let auth = Auth::new( + Config { + token_url: server.url.join("/token").unwrap(), + client_id: Some("public-client".into()), + project: Some("project".into()), + ..Config::default() + }, + store, + ) + .unwrap(); + let error = auth + .login_manual_with_completion(|_, _| async { Ok(code.to_owned()) }) + .await + .unwrap_err(); + assert!(!error.contains("sentinel")); + assert!(!error.contains(code)); + assert_eq!(count.load(Ordering::SeqCst), 1); + assert!(auth.store.load().unwrap().unwrap() == before); + } +} + +#[tokio::test] +async fn antigravity_manual_exchange_redirect_is_not_followed() { + let hits = Arc::new(AtomicUsize::new(0)); + let counter = hits.clone(); + let sink = Server::start(Router::new().fallback(move || { + let counter = counter.clone(); + async move { + counter.fetch_add(1, Ordering::SeqCst); + "unexpected" + } + })) + .await; + let destination = sink.url.to_string(); + let calls = Arc::new(AtomicUsize::new(0)); + let counter = calls.clone(); + let server = Server::start(Router::new().route( + "/token", + post(move || { + let counter = counter.clone(); + let location = destination.clone(); + async move { + counter.fetch_add(1, Ordering::SeqCst); + ( + http::StatusCode::TEMPORARY_REDIRECT, + [("location", location)], + "private response", + ) + } + }), + )) + .await; + let directory = tempfile::tempdir().unwrap(); + let auth = Auth::new( + Config { + token_url: server.url.join("/token").unwrap(), + client_id: Some("public-client".into()), + ..Config::default() + }, + Store::new(directory.path().join("auth.json")), + ) + .unwrap(); + assert!( + auth.login_manual_with_completion(|_, _| async { Ok("4/0private-code".into()) }) + .await + .is_err() + ); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!(hits.load(Ordering::SeqCst), 0); + assert!(auth.store.load().unwrap().is_none()); +} + #[tokio::test] async fn antigravity_logout_during_browser_consent_prevents_exchange_and_resurrection() { let (_directory, _server, auth, state) = auth_fixture(true).await; diff --git a/tests/cli.rs b/tests/cli.rs index 9498a632..5474ab6e 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -211,6 +211,365 @@ fn antigravity_user_agent_non_utf8_logout_still_deletes_only_local_credentials() assert!(!String::from_utf8_lossy(&output.stderr).contains("sentinel")); } +#[test] +fn antigravity_manual_help_and_nonterminal_refusal() { + let directory = TempDir::new().unwrap(); + let help = Command::cargo_bin("claude-code-proxy") + .unwrap() + .args(["antigravity", "auth", "login", "--help"]) + .output() + .unwrap(); + assert!(help.status.success()); + let help = String::from_utf8(help.stdout).unwrap(); + assert!(help.contains("--manual")); + assert!(help.contains("experimental") || help.contains("Experimental")); + let output = Command::cargo_bin("claude-code-proxy") + .unwrap() + .args(["antigravity", "auth", "login", "--manual"]) + .env("CCP_CONFIG_DIR", directory.path()) + .env("CCP_ANTIGRAVITY_CLIENT_ID", "synthetic-public-client") + .output() + .unwrap(); + assert!(!output.status.success()); + let diagnostics = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + #[cfg(unix)] + assert!(diagnostics.contains("terminal"), "{diagnostics}"); + #[cfg(not(unix))] + assert!(diagnostics.contains("unsupported"), "{diagnostics}"); + assert!(!diagnostics.contains("accounts.google.com")); + assert!(!directory.path().join("antigravity").exists()); +} + +#[cfg(unix)] +mod manual_terminal_cli { + use super::*; + use std::{ + fs::File, + os::{ + fd::{AsRawFd, FromRawFd}, + unix::process::CommandExt, + }, + process::Command as ProcessCommand, + }; + + const HELPER: &str = "manual_terminal_cli::antigravity_manual_cli_background_helper"; + + #[test] + fn antigravity_manual_cli_background_helper() { + if std::env::var_os("CCP_TEST_MANUAL_CLI_BACKGROUND").is_none() { + return; + } + let status = ProcessCommand::new(env!("CARGO_BIN_EXE_claude-code-proxy")) + .args(["antigravity", "auth", "login", "--manual"]) + .process_group(0) + .status() + .unwrap(); + assert!(!status.success()); + println!("background-cli-refused"); + } + + fn mode(fd: libc::c_int) -> libc::termios { + let mut value = std::mem::MaybeUninit::uninit(); + assert_eq!(unsafe { libc::tcgetattr(fd, value.as_mut_ptr()) }, 0); + unsafe { value.assume_init() } + } + + struct UnreadOutput { + child: ChildGuard, + _master: File, + slave: File, + before: libc::termios, + home: TempDir, + } + impl UnreadOutput { + fn start(invalid_configuration: bool) -> Self { + use std::{ + ffi::CStr, + os::unix::{ffi::OsStrExt, fs::OpenOptionsExt}, + }; + let home = TempDir::new().unwrap(); + let (mut master, mut slave) = (-1, -1); + let mut name = [0 as libc::c_char; libc::PATH_MAX as usize]; + assert_eq!( + unsafe { + libc::openpty( + &mut master, + &mut slave, + name.as_mut_ptr(), + std::ptr::null(), + std::ptr::null(), + ) + }, + 0 + ); + let master = unsafe { File::from_raw_fd(master) }; + let slave = unsafe { File::from_raw_fd(slave) }; + for file in [&master, &slave] { + assert_eq!( + unsafe { libc::fcntl(file.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) }, + 0 + ); + } + // A separate open file description fills this owned PTY. Never set + // O_NONBLOCK on the stdio descriptions inherited by the real CLI: + // doing so would hide Rust main's blocking-stderr regression. + let name = unsafe { CStr::from_ptr(name.as_ptr()) }; + let mut filler = std::fs::OpenOptions::new() + .write(true) + .custom_flags(libc::O_NONBLOCK | libc::O_NOCTTY | libc::O_CLOEXEC) + .open(std::ffi::OsStr::from_bytes(name.to_bytes())) + .unwrap(); + let mut full = false; + for _ in 0..128 { + match filler.write(&[b'x'; 1024]) { + Ok(n) => assert!(n > 0), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + full = true; + break; + } + Err(error) => panic!("could not fill private PTY: {error}"), + } + } + assert!(full, "fixture did not reach output backpressure"); + drop(filler); + assert_eq!( + unsafe { libc::fcntl(slave.as_raw_fd(), libc::F_GETFL) } & libc::O_NONBLOCK, + 0 + ); + let before = mode(slave.as_raw_fd()); + let mut command = ProcessCommand::new(env!("CARGO_BIN_EXE_claude-code-proxy")); + command + .args(["antigravity", "auth", "login", "--manual"]) + .env_clear() + .env("HOME", home.path()) + .env("CCP_CONFIG_DIR", home.path()) + .env("XDG_STATE_HOME", home.path().join("state")) + .env("CCP_ANTIGRAVITY_CLIENT_ID", "synthetic-public-client") + .stdin(Stdio::from(slave.try_clone().unwrap())) + .stdout(Stdio::from(slave.try_clone().unwrap())) + .stderr(Stdio::from(slave.try_clone().unwrap())); + if invalid_configuration { + command.env("CCP_ANTIGRAVITY_USER_AGENT", "invalid\tconfiguration"); + } + unsafe { + command.pre_exec(|| { + if libc::setsid() == -1 || libc::ioctl(0, libc::TIOCSCTTY, 0) == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let child = ChildGuard(command.spawn().unwrap()); + Self { + child, + _master: master, + slave, + before, + home, + } + } + fn wait_for_hidden_input(&mut self) { + let deadline = Instant::now() + Duration::from_secs(10); + while mode(self.slave.as_raw_fd()).c_lflag & libc::ECHO != 0 { + assert!( + self.child.0.try_wait().unwrap().is_none(), + "CLI exited before establishing terminal session" + ); + assert!( + Instant::now() < deadline, + "CLI did not enter hidden input mode" + ); + thread::sleep(Duration::from_millis(5)); + } + } + fn require_exit_without_drain(&mut self, timeout: Duration) { + let deadline = Instant::now() + timeout; + loop { + if let Some(status) = self.child.0.try_wait().unwrap() { + assert_eq!( + status.code(), + Some(1), + "manual failure must exit nonzero normally" + ); + break; + } + assert!( + Instant::now() < deadline, + "manual CLI failed to exit while PTY output remained unread" + ); + thread::sleep(Duration::from_millis(10)); + } + // There is deliberately NO master.read/collect, even after exit. + let after = mode(self.slave.as_raw_fd()); + assert_eq!(before_mode(&self.before), before_mode(&after)); + assert!(!self.home.path().join("antigravity/auth.json").exists()); + } + } + fn before_mode( + mode: &libc::termios, + ) -> ( + libc::tcflag_t, + libc::tcflag_t, + libc::tcflag_t, + libc::tcflag_t, + [libc::cc_t; libc::NCCS], + ) { + ( + mode.c_iflag, + mode.c_oflag, + mode.c_cflag, + mode.c_lflag, + mode.c_cc, + ) + } + + #[test] + fn antigravity_manual_cli_unread_output_cancellation_exits() { + for signal in ["-INT", "-TERM"] { + let mut child = UnreadOutput::start(false); + child.wait_for_hidden_input(); + send_signal(&child.child, signal).unwrap(); + child.require_exit_without_drain(Duration::from_secs(5)); + } + } + + #[test] + fn antigravity_manual_cli_unread_output_configuration_error_exits() { + let mut child = UnreadOutput::start(true); + child.require_exit_without_drain(Duration::from_secs(5)); + } + + #[test] + fn antigravity_manual_cli_unread_output_real_deadline_exits() { + let mut child = UnreadOutput::start(false); + child.wait_for_hidden_input(); + // Intentionally use the REAL production deadline. No test-only timeout + // switch exists in the shipped command, and no PTY output is drained. + child.require_exit_without_drain(Duration::from_secs(185)); + } + + #[test] + fn antigravity_manual_cli_real_terminal_cancellation_and_background_refusal() { + for background in [false, true] { + let home = TempDir::new().unwrap(); + let (mut master, mut slave) = (-1, -1); + assert_eq!( + unsafe { + libc::openpty( + &mut master, + &mut slave, + std::ptr::null_mut(), + std::ptr::null(), + std::ptr::null(), + ) + }, + 0 + ); + let mut master = unsafe { File::from_raw_fd(master) }; + let slave = unsafe { File::from_raw_fd(slave) }; + for file in [&master, &slave] { + assert_eq!( + unsafe { libc::fcntl(file.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) }, + 0 + ); + } + let flags = unsafe { libc::fcntl(master.as_raw_fd(), libc::F_GETFL) }; + assert_eq!( + unsafe { libc::fcntl(master.as_raw_fd(), libc::F_SETFL, flags | libc::O_NONBLOCK) }, + 0 + ); + let before = mode(slave.as_raw_fd()); + let mut command = if background { + let mut command = ProcessCommand::new(std::env::current_exe().unwrap()); + command.args([HELPER, "--exact", "--nocapture"]); + command + } else { + let mut command = ProcessCommand::new(env!("CARGO_BIN_EXE_claude-code-proxy")); + command.args(["antigravity", "auth", "login", "--manual"]); + command + }; + command + .env_clear() + .env("HOME", home.path()) + .env("CCP_CONFIG_DIR", home.path()) + .env("XDG_STATE_HOME", home.path().join("state")) + .env("CCP_ANTIGRAVITY_CLIENT_ID", "synthetic-public-client") + .env("CCP_TEST_MANUAL_CLI_BACKGROUND", "1") + .stdin(Stdio::from(slave.try_clone().unwrap())) + .stdout(Stdio::from(slave.try_clone().unwrap())) + .stderr(Stdio::from(slave.try_clone().unwrap())); + unsafe { + command.pre_exec(|| { + if libc::setsid() == -1 || libc::ioctl(0, libc::TIOCSCTTY, 0) == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut child = ChildGuard(command.spawn().unwrap()); + let deadline = Instant::now() + Duration::from_secs(10); + let mut output = Vec::new(); + let mut cancelled = false; + loop { + let mut buffer = [0u8; 4096]; + match master.read(&mut buffer) { + Ok(n) => output.extend_from_slice(&buffer[..n]), + Err(error) + if error.kind() == std::io::ErrorKind::WouldBlock + || error.raw_os_error() == Some(libc::EIO) => {} + Err(error) => panic!("PTY read failed: {error}"), + } + let text = String::from_utf8_lossy(&output); + if !background + && !cancelled + && text.contains("Paste the displayed authorization code") + { + assert_eq!(mode(slave.as_raw_fd()).c_lflag & libc::ECHO, 0); + send_signal(&child, "-TERM").unwrap(); + cancelled = true; + } + if let Some(status) = child.0.try_wait().unwrap() { + // Consume the bounded final diagnostic after process exit. + loop { + match master.read(&mut buffer) { + Ok(n) if n > 0 => output.extend_from_slice(&buffer[..n]), + _ => break, + } + } + assert_eq!(status.success(), background); + break; + } + assert!(Instant::now() < deadline, "real manual CLI failed to exit"); + thread::sleep(Duration::from_millis(5)); + } + let output = String::from_utf8_lossy(&output); + if background { + assert!( + output.contains("foreground controlling terminal"), + "{output}" + ); + assert!(!output.contains("accounts.google.com")); + assert!(!home.path().join("antigravity").exists()); + } else { + assert!(cancelled); + assert!(output.contains("Manual OAuth login cancelled"), "{output}"); + assert!(output.contains("CODE DISPLAYED")); + assert!(!home.path().join("antigravity/auth.json").exists()); + } + let after = mode(slave.as_raw_fd()); + assert_eq!(before.c_iflag, after.c_iflag); + assert_eq!(before.c_oflag, after.c_oflag); + assert_eq!(before.c_cflag, after.c_cflag); + assert_eq!(before.c_lflag, after.c_lflag); + assert_eq!(before.c_cc, after.c_cc); + } + } +} + #[test] fn models_output_is_stable_order() -> Result<(), Box> { let mut cmd = Command::cargo_bin("claude-code-proxy")?; From 3781387276633f5da32b8b12d114e1069563a76a Mon Sep 17 00:00:00 2001 From: Chris Liu <11517021+chris7iu@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:47:15 +0000 Subject: [PATCH 04/30] feat(antigravity): show bounded manual OAuth input Render visible input with a bounded terminal viewport, preserve cancellation and redaction guarantees, and verify native proxy routing with a local fixture. Co-Authored-By: Claude Code --- README.md | 8 +- .../docs/reference/command-reference.md | 11 +- .../compatibility-and-limitations.md | 12 +- .../content/docs/reference/configuration.md | 41 +- src/main.rs | 4 +- src/providers/antigravity/manual_terminal.rs | 208 +++++++-- .../antigravity/manual_terminal_tests.rs | 402 +++++++++++++++++- tests/cli.rs | 387 ++++++++++++++++- 8 files changed, 1019 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index b1c709c4..bbd11e1d 100644 --- a/README.md +++ b/README.md @@ -120,8 +120,12 @@ before explicitly enabling it. Existing provider defaults and aliases are unchan Antigravity login defaults to automatic browser/loopback completion. On Unix, `claude-code-proxy antigravity auth login --manual` instead prints a browser URL -and accepts the **code displayed by the completion page** through hidden input in -your local foreground terminal. It does not open a browser or callback listener. +and accepts the **code displayed by the completion page** through visible input in +your local foreground terminal. The code is sensitive: keep your screen, +scrollback and recordings private. Short codes are fully visible; longer input +shows its end with a `<` marker and total character count. A known terminal width +of at least 10 columns is required; changing width cancels. It does not open a +browser or callback listener. The explicitly configured OAuth client must permit `https://antigravity.google/oauth-callback`; a loopback-only client may not work. Cancel on browser errors or no displayed code; there is no automatic fallback. diff --git a/docs/src/content/docs/reference/command-reference.md b/docs/src/content/docs/reference/command-reference.md index 5de9a2c2..608939e0 100644 --- a/docs/src/content/docs/reference/command-reference.md +++ b/docs/src/content/docs/reference/command-reference.md @@ -101,8 +101,15 @@ neither a browser nor a callback listener. The configured client must permit the exact hosted redirect `https://antigravity.google/oauth-callback`. Open the URL in your browser, select the intended account, then copy the **code -displayed by the completion page** into the hidden local terminal prompt. The -page removes `code` from its address bar; copying that address afterward does not +displayed by the completion page** into the visible local terminal prompt. The +code is sensitive: keep screens, scrollback, screenshots and recordings private. +The input row shows a character count; short codes fit in full, while `<` marks +that only the end of longer input is visible. All entered characters remain part +of the completion, up to the unchanged 8192-byte limit. A known terminal width of +at least 10 columns is required; changing width cancels rather than risking +incorrect wrapped editing. Output modes that translate carriage returns or letter +case are refused, not silently changed. +The page removes `code` from its address bar; copying that address afterward does not supply the code. Cancel if the browser reports an error (such as `redirect_uri_mismatch`) or displays no code. There is no automatic callback, client or endpoint fallback. Never put a code in command arguments, chat or logs. diff --git a/docs/src/content/docs/reference/compatibility-and-limitations.md b/docs/src/content/docs/reference/compatibility-and-limitations.md index 462ddb5f..86b009df 100644 --- a/docs/src/content/docs/reference/compatibility-and-limitations.md +++ b/docs/src/content/docs/reference/compatibility-and-limitations.md @@ -210,7 +210,7 @@ There are no automatic generation retries or endpoint/account fallback. | Requested output tokens | 1..65536 (8192 when omitted) | | Native header / body idle / whole collection deadline | 30s / 30s / 180s | | Auth lock acquisition / token-project request / browser callback | 10s / 30s / 180s | -| Manual terminal input and prompt output / input bytes / empty reprompts | 180s absolute / 8192 bytes / 3; fourth empty entry fails | +| Manual terminal input, prompt and visible redraw output / input bytes / empty reprompts | 180s absolute / 8192 bytes / 3; fourth empty entry fails | | Manual completion confirmation output | 2s absolute, best effort after durable success | | Established manual-session failure diagnostic | 2s absolute after runtime cleanup; nonzero exit even if output is blocked | | Callback headers / each preconnect read | 8 KiB / 2s | @@ -249,8 +249,14 @@ support. Opt-in `antigravity auth login --manual` uses the hosted redirect `https://antigravity.google/oauth-callback`, not Google OOB or a loopback listener. The hosted page displays/copies the decoded authorization code and removes it -from its URL; copy the displayed code into the hidden local terminal, not chat or -shell arguments. Client registration/redirect acceptance, token exchange, server +from its URL; copy the displayed code into the visible local terminal, not chat or +shell arguments. Input is sensitive and may remain in screen/scrollback recordings; +application-managed visibility does not log it or promise erasure. Short codes +are fully displayed; longer input shows its end with a `<` marker and total +character count, not all 8192 bytes simultaneously. Known width of at least +10 columns and non-translating output modes are required; detected width changes +cancel without exchange. This is not verified current `agy` display parity. +Client registration/redirect acceptance, token exchange, server S256 PKCE enforcement and rejection of PKCE downgrade remain live-validation gates. Bare-code input does not validate returned state; complete redirect-URL input does. No official-CLI parser or credential import is implied. Manual input diff --git a/docs/src/content/docs/reference/configuration.md b/docs/src/content/docs/reference/configuration.md index 4919c2e3..882ca634 100644 --- a/docs/src/content/docs/reference/configuration.md +++ b/docs/src/content/docs/reference/configuration.md @@ -229,7 +229,7 @@ compatibility scopes, not independently proven minimal scopes. No `agy`, Gemini, Codex, keychain or other application's credentials are discovered or imported. **Experimental manual completion:** `antigravity auth login --manual` prints the -authorization URL and waits for a private terminal paste instead of opening a +authorization URL and waits for a visible terminal paste instead of opening a browser/listener. It uses `https://antigravity.google/oauth-callback` in both the authorization request and code exchange. Your explicitly configured client must allow that exact hosted redirect; an installed-app/loopback-only registration may @@ -263,14 +263,37 @@ are rejected rather than normalized. Slash/plus/dot/tilde/padding code bytes are preserved and form-encoded once for the single token request. Manual input is **Unix-only**, verified at runtime on Linux. It requires terminal -stdin and a foreground controlling terminal. A separately opened nonblocking -`/dev/tty` owns hidden input and bounded prompt/URL output; ordinary pipes are not -supported and no input is echoed or placed in argv/log/status. The input cap is -8192 bytes; the absolute 180-second input/output deadline also covers continuous -input and partial pastes. Three empty entries may reprompt without resetting that -deadline; the fourth fails. Enter submits, Backspace edits, and Ctrl-C/Ctrl-D, -Ctrl-Z/Ctrl-backslash or SIGINT/SIGTERM cancel. Exact bracketed-paste delimiters -are recognized without enabling terminal modes; malformed/nested escapes fail. +stdin and a foreground controlling terminal with a known width of at least +10 columns. A separately opened nonblocking `/dev/tty` owns input and bounded +prompt/URL/visible-input output. Kernel echo stays disabled: only validated +printable input is rendered by the application, never pasted escape sequences. +Ordinary pipes are unsupported. Input is not copied to stdout, stderr, argv, +logs or status; its intentional terminal display is **sensitive**, and may remain +in scrollback, screenshots or recordings even after editing or cancellation. +No scrollback erasure or exact current `agy` display equivalence is promised. + +The input occupies one bounded row, showing `[character count]` and the code. +Short codes are fully visible. If longer input does not fit, `<` marks a visible +tail viewport; the undisplayed prefix remains part of the completion. Backspace +and Delete edit the complete logical input, not merely the displayed suffix. +The final terminal column is reserved to avoid wrapping. A detected width change +cancels without exchange rather than trying to repair terminal reflow. Output +postprocessing that translates carriage returns (`OCRNL`) or letter case +(`OLCUC`, where supported) is refused before displaying the authorization URL; +these flags are harmless when `OPOST` is off. Normal `OPOST`/`ONLCR` output and +other non-translating flags are preserved, and the saved mode is not normalized. + +The input cap is 8192 bytes; the absolute 180-second input/output deadline also +covers visible redraws, blocked output, continuous input and partial pastes. +Only one bounded display frame is pending; intermediate edits may be coalesced, +not queued indefinitely. Three empty entries may redraw the empty prompt without +resetting that deadline; the fourth fails. Enter submits, and Ctrl-C/Ctrl-D, +Ctrl-Z/Ctrl-backslash or SIGINT/SIGTERM cancel, including during blocked redraws. +Exact bracketed-paste delimiters are recognized without enabling terminal modes; +malformed/nested escapes fail. Enter freezes the completion and ignores trailing +code; its final display frame/newline must be accepted by the terminal device +before cleanup and exchange. This cannot guarantee physical rendering by an +external terminal. Before exchange, successful explicit cleanup discards **currently queued** terminal input and restores the exact saved mode using immediate, non-draining termios diff --git a/src/main.rs b/src/main.rs index c66e31c5..29a62d7c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -89,7 +89,9 @@ enum AntigravityGroup { #[derive(Debug, Subcommand)] enum AntigravityAuth { Login { - /// Experimental Unix terminal paste-back; requires hosted-redirect client registration. + /// Experimental Unix terminal paste-back with visible, sensitive input; + /// requires a known width of at least 10 columns and hosted-redirect client registration. + /// Long input shows its end and character count; changing terminal width cancels. /// Copy the code displayed by the browser page. Cancel on browser errors /// (including redirect_uri_mismatch) or no displayed code; no automatic fallback. #[arg(long)] diff --git a/src/providers/antigravity/manual_terminal.rs b/src/providers/antigravity/manual_terminal.rs index e9837a85..725b212c 100644 --- a/src/providers/antigravity/manual_terminal.rs +++ b/src/providers/antigravity/manual_terminal.rs @@ -20,9 +20,13 @@ const CANCELLED: &str = "Manual OAuth login cancelled"; const TIMED_OUT: &str = "Manual OAuth input timed out"; const CLOSED: &str = "Manual OAuth terminal closed or unavailable"; const RESTORE_FAILED: &str = "Manual OAuth terminal cleanup failed; no further exchange attempted"; +const WIDTH_REQUIRED: &str = "Manual OAuth terminal requires at least 10 columns"; +const RESIZED: &str = "Manual OAuth terminal width changed; login cancelled"; +const OUTPUT_TRANSLATES: &str = + "Manual OAuth terminal output must preserve carriage returns and letter case"; const PROMPT: &[u8] = - b"\nPaste the displayed authorization code (hidden), then Enter; Ctrl-C cancels: "; -const NOTICE: &[u8] = b"Experimental manual Antigravity login. Open this URL in your browser and choose the intended account.\nThe configured OAuth client must permit https://antigravity.google/oauth-callback.\nCopy the CODE DISPLAYED by the page, not the address bar (the page removes the code from its URL).\nCancel if the browser reports an error (including redirect_uri_mismatch) or shows no code.\nNo browser is opened automatically; no callback listener, credential import, or fallback is used.\n"; + b"\nPaste the displayed authorization code (visible), then Enter; Ctrl-C cancels.\r\n"; +const NOTICE: &[u8] = b"Experimental manual Antigravity login. Open this URL in your browser and choose the intended account.\nThe configured OAuth client must permit https://antigravity.google/oauth-callback.\nCopy the CODE DISPLAYED by the page, not the address bar (the page removes the code from its URL).\nCancel if the browser reports an error (including redirect_uri_mismatch) or shows no code.\nNo browser is opened automatically; no callback listener, credential import, or fallback is used.\nWarning: the code is sensitive and VISIBLE; keep your screen, scrollback and recordings private.\nThe input row shows [character count]; < means only the end fits. Backspace edits; changing terminal width cancels.\n"; pub(crate) enum CliOutcome { Success, @@ -142,7 +146,7 @@ impl Terminal { // signal registrations may be used to drive this best-effort report. // The fd is our original independent O_NONBLOCK /dev/tty description. let deadline = std::time::Instant::now() + Duration::from_secs(2); - let diagnostic = format!("\nError: {message}\n"); + let diagnostic = format!("\r\nError: {message}\r\n"); let mut bytes = diagnostic.as_bytes(); let Ok(_mask) = TtouMask::block() else { return; @@ -184,7 +188,7 @@ impl Terminal { } } } - fn hide_input(&mut self) -> Result<()> { + fn manage_input(&mut self) -> Result<()> { foreground(&self.file)?; let mut raw = self.saved; raw.c_iflag &= !(libc::IGNBRK @@ -243,7 +247,11 @@ impl Terminal { async fn complete(&mut self, authorization: url::Url, timeout: Duration) -> Result { let deadline = Instant::now() + timeout; let result = async { - self.hide_input()?; + self.validate_output()?; + if self.columns()? < 10 { + return Err(WIDTH_REQUIRED); + } + self.manage_input()?; self.output(NOTICE, deadline).await?; self.output(authorization.as_str().as_bytes(), deadline) .await?; @@ -257,7 +265,7 @@ impl Terminal { } async fn output(&mut self, mut bytes: &[u8], deadline: Instant) -> Result<()> { while !bytes.is_empty() { - self.ready(libc::POLLOUT, deadline).await?; + self.ready(libc::POLLOUT, deadline, None).await?; match self.file.write(bytes) { Ok(0) => return Err(CLOSED), Ok(n) => bytes = &bytes[n..], @@ -267,34 +275,127 @@ impl Terminal { } Ok(()) } + fn validate_output(&self) -> Result<()> { + let flags = self.saved.c_oflag; + if flags & libc::OPOST == 0 { + return Ok(()); // Translation flags are inert without postprocessing. + } + if flags & libc::OCRNL != 0 { + return Err(OUTPUT_TRANSLATES); + } + // Apple and BSDs other than OpenBSD do not expose OLCUC. Do not reject + // harmless output flags or silently normalize the caller's saved mode. + #[cfg(any( + target_os = "linux", + target_os = "android", + target_os = "openbsd", + target_os = "solaris", + target_os = "illumos", + target_os = "aix", + target_os = "haiku", + target_os = "hurd", + target_os = "nto", + target_os = "redox", + target_os = "emscripten", + target_os = "cygwin", + target_os = "fuchsia", + target_os = "l4re" + ))] + if flags & libc::OLCUC != 0 { + return Err(OUTPUT_TRANSLATES); + } + Ok(()) + } + fn columns(&self) -> Result { + let mut size = std::mem::MaybeUninit::::uninit(); + // SAFETY: owned live tty descriptor and writable winsize storage. + if unsafe { libc::ioctl(self.file.as_raw_fd(), libc::TIOCGWINSZ, size.as_mut_ptr()) } != 0 { + return Err(CLOSED); + } + Ok(unsafe { size.assume_init() }.ws_col) + } async fn input(&mut self, deadline: Instant) -> Result { + let columns = self.columns()?; + if columns < 10 { + return Err(WIDTH_REQUIRED); + } let mut line = Line::new(); + let mut frame = Frame::new(); + let mut dirty = true; + let mut completion = None; let mut buffer = [0u8; 256]; loop { - self.ready(libc::POLLIN, deadline).await?; - match self.file.read(&mut buffer) { - Ok(0) => return Err(CLOSED), - Ok(n) => { - for byte in &buffer[..n] { - if Instant::now() >= deadline { - return Err(TIMED_OUT); - } - match line.feed(*byte)? { - Input::Pending => (), - Input::Empty => { - self.output(b"\nEmpty input; paste the displayed code: ", deadline) - .await? + if frame.done() && dirty { + frame.render(&line, columns, completion.is_some()); + dirty = false; + } + // Always service input, even when a frame cannot drain. In particular + // keyboard cancellation must not depend on output becoming writable. + let events = libc::POLLIN | if frame.done() { 0 } else { libc::POLLOUT }; + let ready = self.ready(events, deadline, Some(columns)).await?; + if ready & libc::POLLIN != 0 { + match self.file.read(&mut buffer) { + Ok(0) => return Err(CLOSED), + Ok(n) => { + for byte in &buffer[..n] { + if Instant::now() >= deadline { + return Err(TIMED_OUT); + } + if completion.is_some() { + // Enter freezes the completion. Discard trailing + // data; still honor cancellation during final output. + if cancels(*byte) { + return Err(CANCELLED); + } + continue; + } + match line.feed(*byte)? { + Input::Pending | Input::Empty => dirty = true, + Input::Complete(code) => { + completion = Some(code); + dirty = true; + // Scan the rest only for cancellation, as above. + } } - Input::Complete(code) => return Ok(code), } } + Err(error) if retryable(&error) => (), + Err(_) => return Err(CLOSED), + } + } + // One immutable in-flight frame, not an output queue. Edits arriving + // during a partial write are coalesced into the next latest snapshot. + if frame.done() && dirty { + frame.render(&line, columns, completion.is_some()); + dirty = false; + } + if ready & libc::POLLOUT != 0 && !frame.done() { + if self.columns()? != columns { + return Err(RESIZED); + } + if Instant::now() >= deadline { + return Err(TIMED_OUT); + } + match self.file.write(&frame.bytes[frame.written..frame.len]) { + Ok(0) => return Err(CLOSED), + Ok(n) => frame.written += n, + Err(error) if retryable(&error) => (), + Err(_) => return Err(CLOSED), + } + if frame.done() && frame.final_frame { + // Kernel acceptance of the final display/newline is required; + // physical rendering by an external terminal is not observable. + return completion.ok_or(INVALID); } - Err(error) if retryable(&error) => (), - Err(_) => return Err(CLOSED), } } } - async fn ready(&self, events: libc::c_short, deadline: Instant) -> Result<()> { + async fn ready( + &self, + events: libc::c_short, + deadline: Instant, + columns: Option, + ) -> Result { loop { // A suspension on EVERY iteration gives signal delivery priority // even under a continuously readable/writable tty or EINTR storm. @@ -304,6 +405,11 @@ impl Terminal { return Err(TIMED_OUT); } foreground(&self.file)?; + if let Some(expected) = columns + && self.columns()? != expected + { + return Err(RESIZED); + } let mut descriptor = libc::pollfd { fd: self.file.as_raw_fd(), events, @@ -321,7 +427,7 @@ impl Terminal { return Err(CLOSED); } if descriptor.revents & events != 0 { - return Ok(()); + return Ok(descriptor.revents); } } } @@ -377,6 +483,58 @@ impl Drop for TtouMask { } } +// A row never reaches the last terminal column, so erasure needs neither +// cursor-up sequences nor assumptions about autowrap. Only accepted Line bytes +// reach the display. Width changes cancel instead of trying to repair reflow. +const MAX_VIEWPORT: usize = INPUT_LIMIT + 8; +struct Frame { + bytes: [u8; 2 * MAX_VIEWPORT + 4], + len: usize, + written: usize, + final_frame: bool, +} +impl Frame { + fn new() -> Self { + Self { + bytes: [0; 2 * MAX_VIEWPORT + 4], + len: 0, + written: 0, + final_frame: false, + } + } + fn done(&self) -> bool { + self.written == self.len + } + fn render(&mut self, line: &Line, columns: u16, complete: bool) { + let width = usize::from(columns - 1).min(MAX_VIEWPORT); + self.bytes[..width + 2].fill(b' '); + self.bytes[0] = b'\r'; + self.bytes[width + 1] = b'\r'; + self.len = width + 2; + self.written = 0; + self.final_frame = complete; + let count = format!("[{}] ", line.len); // At most seven ASCII bytes. + self.append(count.as_bytes()); + let mut available = width - count.len(); + if line.len > available { + self.append(b"<"); + available -= 1; + } + self.append(&line.bytes[line.len.saturating_sub(available)..line.len]); + if complete { + self.append(b"\r\n"); + } + } + fn append(&mut self, bytes: &[u8]) { + self.bytes[self.len..self.len + bytes.len()].copy_from_slice(bytes); + self.len += bytes.len(); + } +} + +fn cancels(byte: u8) -> bool { + matches!(byte, 3 | 4 | 26 | 28) +} + enum Input { Pending, Empty, @@ -400,7 +558,7 @@ impl Line { } } fn feed(&mut self, byte: u8) -> Result { - if matches!(byte, 3 | 4 | 26 | 28) { + if cancels(byte) { return Err(CANCELLED); } if self.escape != 0 { diff --git a/src/providers/antigravity/manual_terminal_tests.rs b/src/providers/antigravity/manual_terminal_tests.rs index eb23b515..f20f961f 100644 --- a/src/providers/antigravity/manual_terminal_tests.rs +++ b/src/providers/antigravity/manual_terminal_tests.rs @@ -73,7 +73,7 @@ fn antigravity_manual_pty_child() { if mode == "drop" { let result = std::panic::catch_unwind(|| { let mut terminal = Terminal::open().unwrap(); - terminal.hide_input().unwrap(); + terminal.manage_input().unwrap(); panic!("synthetic terminal unwind"); }); assert!(result.is_err()); @@ -84,6 +84,8 @@ fn antigravity_manual_pty_child() { let flags = unsafe { libc::fcntl(0, libc::F_GETFL) }; let timeout = if mode == "deadline" || mode == "continuous" || mode == "blocked-output" { Duration::from_millis(300) + } else if mode == "echo-deadline" { + Duration::from_millis(800) } else { Duration::from_secs(4) }; @@ -96,12 +98,14 @@ fn antigravity_manual_pty_child() { let requests = Arc::new(AtomicUsize::new(0)); let counter = requests.clone(); let hold = mode == "hold"; + let expected_code = if mode == "maximum" { "x".repeat(8192) } else { CODE.to_owned() }; let server = crate::providers::antigravity_http_tests::Server::start( Router::new().route("/token", post(move |axum::extract::Form(form): axum::extract::Form>| { let counter = counter.clone(); + let expected_code = expected_code.clone(); async move { counter.fetch_add(1, Ordering::SeqCst); - assert_eq!(form["code"], CODE); + assert_eq!(form["code"], expected_code); assert_eq!(form["redirect_uri"], "https://antigravity.google/oauth-callback"); assert!(!form["code_verifier"].is_empty()); std::fs::write(std::path::PathBuf::from(std::env::var_os("HOME").unwrap()).join("synthetic-verifier"), &form["code_verifier"]).unwrap(); @@ -143,7 +147,7 @@ fn antigravity_manual_pty_child() { let result = if mode == "cleanup-error" { let mut terminal = Terminal::open().unwrap(); auth.login_manual_with_completion(|_, _| async { - terminal.hide_input()?; + terminal.manage_input()?; let deadline = tokio::time::Instant::now() + timeout; terminal.output(PROMPT.as_bytes(), deadline).await?; let input = terminal.input(deadline).await?; @@ -208,6 +212,9 @@ impl Drop for PtyChild { } impl PtyChild { fn start(mode: &str) -> Self { + Self::with_columns(mode, 80) + } + fn with_columns(mode: &str, columns: u16) -> Self { let (mut master, mut slave) = (-1, -1); assert_eq!( unsafe { @@ -224,6 +231,7 @@ impl PtyChild { ); let master = unsafe { File::from_raw_fd(master) }; let slave = unsafe { File::from_raw_fd(slave) }; + set_columns(&slave, columns); for file in [&master, &slave] { assert_eq!( unsafe { libc::fcntl(file.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) }, @@ -240,6 +248,16 @@ impl PtyChild { before.c_lflag |= libc::ECHONL; before.c_iflag |= libc::IXOFF; before.c_oflag |= libc::OPOST | libc::ONLCR; + if mode == "cr-translation" || mode == "inert-translation" { + before.c_oflag |= libc::OCRNL; + } + #[cfg(any(target_os = "linux", target_os = "android"))] + if mode == "case-translation" || mode == "inert-translation" { + before.c_oflag |= libc::OLCUC; + } + if mode == "inert-translation" { + before.c_oflag &= !libc::OPOST; + } assert_eq!( unsafe { libc::tcsetattr(slave.as_raw_fd(), libc::TCSANOW, &before) }, 0 @@ -257,8 +275,14 @@ impl PtyChild { .env("CCP_LOG_VERBOSE", "1") .env("CCP_TRAFFIC_LOG", "1") .stdin(Stdio::from(slave.try_clone().unwrap())) - .stdout(Stdio::from(slave.try_clone().unwrap())) - .stderr(Stdio::from(slave.try_clone().unwrap())); + // Independent captures distinguish intentional /dev/tty display + // from an accidental stdout/stderr/tracing disclosure. + .stdout(Stdio::from( + File::create(home.path().join("stdout")).unwrap(), + )) + .stderr(Stdio::from( + File::create(home.path().join("stderr")).unwrap(), + )); unsafe { command.pre_exec(|| { if libc::setsid() == -1 || libc::ioctl(0, libc::TIOCSCTTY, 0) == -1 { @@ -298,9 +322,29 @@ impl PtyChild { } assert!(self.output.len() < 256 * 1024, "unbounded terminal output"); } - fn text(&self) -> String { + fn tty_text(&self) -> String { String::from_utf8_lossy(&self.output).into_owned() } + fn logs(&self) -> String { + ["stdout", "stderr"] + .into_iter() + .map(|name| { + let bytes = std::fs::read(self._home.path().join(name)).unwrap(); + assert!(bytes.len() < 256 * 1024, "unbounded log output"); + String::from_utf8_lossy(&bytes).into_owned() + }) + .collect() + } + fn text(&self) -> String { + self.tty_text() + &self.logs() + } + fn until_tty(&mut self, text: &str) { + self.until(text); + assert!( + self.tty_text().contains(text), + "input was not visible on the tty" + ); + } fn until(&mut self, text: &str) { let deadline = Instant::now() + Duration::from_secs(5); loop { @@ -340,8 +384,11 @@ impl PtyChild { assert!(status.success(), "{}", self.text()); assert!(self.text().contains(expected), "{}", self.text()); assert!(same_termios(&self.before, &termios(self.slave.as_raw_fd()))); + assert!( + !self.logs().contains(CODE), + "stdout/stderr/log disclosed input" + ); for secret in [ - CODE, "manual-private-access", "manual-private-refresh", "QUEUED_PRIVATE_TAIL", @@ -375,6 +422,25 @@ impl PtyChild { std::thread::sleep(Duration::from_millis(5)); } } + fn pause_input(&self) { + // Stop only this private PTY child so the complete short batch is queued + // before its next read. No timing assumption about producer scheduling. + self.signal(libc::SIGSTOP); + let deadline = Instant::now() + Duration::from_secs(1); + loop { + let mut status = 0; + let pid = self.child.id() as libc::pid_t; + let waited = + unsafe { libc::waitpid(pid, &mut status, libc::WUNTRACED | libc::WNOHANG) }; + assert!(waited >= 0, "could not observe private child stop"); + if waited == pid { + assert!(libc::WIFSTOPPED(status), "private child exited before stop"); + return; + } + assert!(Instant::now() < deadline, "private child did not stop"); + std::thread::sleep(Duration::from_millis(2)); + } + } fn signal(&self, signal: libc::c_int) { assert_eq!( unsafe { libc::kill(self.child.id() as libc::pid_t, signal) }, @@ -383,8 +449,328 @@ impl PtyChild { } } +fn set_columns(terminal: &File, columns: u16) { + let size = libc::winsize { + ws_row: 24, + ws_col: columns, + ws_xpixel: 0, + ws_ypixel: 0, + }; + assert_eq!( + unsafe { libc::ioctl(terminal.as_raw_fd(), libc::TIOCSWINSZ, &size) }, + 0 + ); +} + +fn fill_output(terminal: &File) { + let mut name = [0 as libc::c_char; 4096]; + assert_eq!( + unsafe { libc::ttyname_r(terminal.as_raw_fd(), name.as_mut_ptr(), name.len()) }, + 0 + ); + let name = unsafe { std::ffi::CStr::from_ptr(name.as_ptr()) }; + use std::os::unix::ffi::OsStrExt; + let mut filler = OpenOptions::new() + .write(true) + .custom_flags(libc::O_NONBLOCK | libc::O_NOCTTY | libc::O_CLOEXEC) + .open(std::ffi::OsStr::from_bytes(name.to_bytes())) + .unwrap(); + for _ in 0..256 { + match filler.write(&[b'!'; 1024]) { + Ok(n) => assert!(n > 0), + Err(e) if e.kind() == io::ErrorKind::WouldBlock => return, + other => panic!("PTY fill failed: {other:?}"), + } + } + panic!("fixture did not reach actual output backpressure"); +} + +#[test] +fn antigravity_manual_pty_visible_before_enter_without_log_disclosure() { + let mut child = PtyChild::start("trace"); + child.until(PROMPT); + child.send(CODE.as_bytes()); + child.until_tty(&format!("[{}] {CODE}", CODE.len())); + assert!(!child.logs().contains(CODE)); + assert!(!child.logs().contains("manual-helper:exchange")); + assert!(!child.tty_text().contains("(hidden)")); + assert!(child.tty_text().contains("sensitive")); + assert_eq!(termios(child.slave.as_raw_fd()).c_lflag & libc::ECHO, 0); + child.send(b"\nQUEUED_PRIVATE_TAIL\n"); + child.finish("manual-helper:success"); +} + +#[test] +fn antigravity_manual_pty_visible_viewport_editing_and_split_paste() { + let mut child = PtyChild::with_columns("success", 10); + child.until(PROMPT); + child.send(b"\x08\x7f\x1b["); + child.send(b"200~"); + child.send(CODE.as_bytes()); + child.until_tty(&format!("[{}] = text.split("\r \r").skip(1).collect(); + assert!(!frames.is_empty()); + for frame in frames { + let row = frame.split(['\r', '\n']).next().unwrap(); + assert!(row.len() <= 9, "viewport wrapped: {row:?}"); + } +} + +#[test] +fn antigravity_manual_pty_visible_output_backpressure_still_reads_cancel_and_deadline() { + for cancel in [0, 3, 4, 26, 28, libc::SIGINT + 128, libc::SIGTERM + 128] { + let mut child = PtyChild::start(if cancel == 0 { + "echo-deadline" + } else { + "invalid" + }); + child.until_tty("[0] "); + fill_output(&child.slave); + child.send(CODE.as_bytes()); + std::thread::sleep(Duration::from_millis(30)); + if cancel >= 128 { + child.signal(cancel - 128); + } else if cancel != 0 { + child.send(&[cancel as u8]); + } + // Do not drain the terminal to rescue the rendering path. + let deadline = Instant::now() + Duration::from_secs(2); + while child.child.try_wait().unwrap().is_none() { + assert!( + Instant::now() < deadline, + "blocked echo prevented cancellation/deadline" + ); + std::thread::sleep(Duration::from_millis(5)); + } + child.finish(if cancel == 0 { + "manual-helper:error:Manual OAuth input timed out" + } else { + "manual-helper:error:Manual OAuth login cancelled" + }); + } +} + +#[test] +fn antigravity_manual_pty_visible_same_batch_enter_and_cancel_never_exchanges() { + for blocked in [true, false] { + for cancel in [3, 4, 26, 28] { + let mut child = PtyChild::start("echo-deadline"); + child.until_tty("[0] "); + child.pause_input(); + if blocked { + fill_output(&child.slave); + } + let mut batch = format!("{CODE}\nQUEUED_PRIVATE_TAIL").into_bytes(); + batch.extend_from_slice(&[cancel, b'\n']); + assert!(batch.len() < 256, "fixture must fit one input read"); + child.send(&batch); + child.signal(libc::SIGCONT); + // Keep output unread even in the blocked case: cancellation must + // win over the final redraw, rather than wait for it or exchange. + let deadline = Instant::now() + Duration::from_secs(2); + while child.child.try_wait().unwrap().is_none() { + assert!(Instant::now() < deadline, "same-batch cancellation stalled"); + std::thread::sleep(Duration::from_millis(5)); + } + child.finish("manual-helper:error:Manual OAuth login cancelled"); + assert!(!child.logs().contains("manual-helper:exchange")); + } + } +} + +#[test] +fn antigravity_manual_pty_visible_final_frame_backpressure_never_exchanges() { + for cancel in [false, true] { + let mut child = PtyChild::start("echo-deadline"); + child.until_tty("[0] "); + fill_output(&child.slave); + child.send(format!("{CODE}\nQUEUED_PRIVATE_TAIL\n").as_bytes()); + if cancel { + std::thread::sleep(Duration::from_millis(30)); + child.send(b"\x03"); + } + let deadline = Instant::now() + Duration::from_secs(2); + while child.child.try_wait().unwrap().is_none() { + assert!( + Instant::now() < deadline, + "final frame ignored cancellation/deadline" + ); + std::thread::sleep(Duration::from_millis(5)); + } + child.finish(if cancel { + "manual-helper:error:Manual OAuth login cancelled" + } else { + "manual-helper:error:Manual OAuth input timed out" + }); + assert!(!child.logs().contains("manual-helper:exchange")); + } +} + +#[test] +fn antigravity_manual_pty_visible_resize_and_unknown_width_fail_closed() { + for columns in [0, 1, 9] { + let mut child = PtyChild::with_columns("invalid", columns); + child.finish("manual-helper:error:Manual OAuth terminal requires at least 10 columns"); + assert!(!child.tty_text().contains(PROMPT)); + } + for columns in [40, 120] { + for blocked in [false, true] { + let mut child = PtyChild::start("invalid"); + child.until_tty("[0] "); + child.send(b"visible-prefix"); + child.until_tty("[14] visible-prefix"); + if blocked { + fill_output(&child.slave); + } + child.send(b"x"); + set_columns(&child.slave, columns); + let deadline = Instant::now() + Duration::from_secs(2); + while child.child.try_wait().unwrap().is_none() { + assert!( + Instant::now() < deadline, + "resize did not cancel blocked renderer" + ); + std::thread::sleep(Duration::from_millis(5)); + } + child + .finish("manual-helper:error:Manual OAuth terminal width changed; login cancelled"); + } + } +} + +#[test] +fn antigravity_manual_pty_visible_rejects_control_injection_without_rendering_it() { + for invalid in [ + b"\x1b]52;c;injected\x07".as_slice(), + b"\x1b[2J", + b"\t", + b"\0", + b"\xff", + b"\x1b[200~bad\n", + ] { + let mut child = PtyChild::start("invalid"); + child.until_tty("[0] "); + child.send(b"safe-prefix"); + child.until_tty("[11] safe-prefix"); + child.send(invalid); + child.finish("manual-helper:error:"); + assert!(!child.tty_text().contains('\x1b')); + assert!(!child.tty_text().contains("injected")); + assert!(!child.logs().contains("safe-prefix")); + } +} + +#[test] +fn antigravity_manual_pty_visible_partial_redraw_progress_and_resize() { + for resize in [false, true] { + let mut child = PtyChild::with_columns("success", 16384); + child.until_tty("[0] "); + fill_output(&child.slave); + child.send(CODE.as_bytes()); + // Free less than one wide redraw, then stop draining: the pending + // immutable frame must survive partial progress and remain cancellable. + let mut partial = [0u8; 4096]; + let n = child.master.as_mut().unwrap().read(&mut partial).unwrap(); + assert!(n > 0); + child.output.extend_from_slice(&partial[..n]); + std::thread::sleep(Duration::from_millis(30)); + assert!(!child.logs().contains("manual-helper:exchange")); + if resize { + set_columns(&child.slave, 80); + let deadline = Instant::now() + Duration::from_secs(2); + while child.child.try_wait().unwrap().is_none() { + assert!( + Instant::now() < deadline, + "partially written frame prevented resize cancellation" + ); + std::thread::sleep(Duration::from_millis(5)); + } + child + .finish("manual-helper:error:Manual OAuth terminal width changed; login cancelled"); + } else { + let expected = format!("[{}] {CODE}", CODE.len()); + let deadline = Instant::now() + Duration::from_secs(2); + while !child.tty_text().contains(&expected) { + let mut bytes = [0; 128]; + match child.master.as_mut().unwrap().read(&mut bytes) { + Ok(n) => child.output.extend_from_slice(&bytes[..n]), + Err(e) if e.kind() == io::ErrorKind::WouldBlock => (), + other => panic!("partial PTY read: {other:?}"), + } + assert!(Instant::now() < deadline, "partial redraw never completed"); + std::thread::sleep(Duration::from_millis(1)); + } + assert!(!child.logs().contains("manual-helper:exchange")); + child.send(b"\nQUEUED_PRIVATE_TAIL\n"); + child.finish("manual-helper:success"); + } + } +} + +#[test] +fn antigravity_manual_pty_visible_output_translation_refused_but_inert_flags_work() { + let modes = [ + "cr-translation", + #[cfg(any(target_os = "linux", target_os = "android"))] + "case-translation", + ]; + for mode in modes { + let mut child = PtyChild::start(mode); + child.finish("manual-helper:error:Manual OAuth terminal output must preserve carriage returns and letter case"); + assert!( + !child + .tty_text() + .to_lowercase() + .contains("accounts.google.com") + ); + assert!( + !child + .tty_text() + .to_lowercase() + .contains("paste the displayed") + ); + } + let mut child = PtyChild::start("inert-translation"); + child.until(PROMPT); + child.send(CODE.as_bytes()); + child.until_tty(&format!("[{}] {CODE}", CODE.len())); + child.send(b"\n"); + child.finish("manual-helper:success"); +} + +#[test] +fn antigravity_manual_pty_visible_maximum_code_preserves_all_8192_bytes() { + let mut child = PtyChild::start("maximum"); + child.until_tty("[0] "); + let started = Instant::now(); + child.send(&[b'x'; 8192]); + child.until_tty("[8192] <"); + assert!( + started.elapsed() < Duration::from_secs(3), + "per-byte rendering stalled paste" + ); + child.send(b"\n"); + child.finish("manual-helper:success"); +} + #[test] -fn antigravity_manual_pty_hidden_code_editing_crlf_and_queued_tail() { +fn antigravity_manual_pty_visible_code_editing_crlf_and_queued_tail() { for input in [ format!("{CODE}\r\nQUEUED_PRIVATE_TAIL\n").into_bytes(), format!("{CODE}x\x08y\x7f\nQUEUED_PRIVATE_TAIL\n").into_bytes(), diff --git a/tests/cli.rs b/tests/cli.rs index 5474ab6e..a139c2a1 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -223,6 +223,8 @@ fn antigravity_manual_help_and_nonterminal_refusal() { let help = String::from_utf8(help.stdout).unwrap(); assert!(help.contains("--manual")); assert!(help.contains("experimental") || help.contains("Experimental")); + assert!(help.contains("visible")); + assert!(help.contains("10 columns")); let output = Command::cargo_bin("claude-code-proxy") .unwrap() .args(["antigravity", "auth", "login", "--manual"]) @@ -278,6 +280,19 @@ mod manual_terminal_cli { unsafe { value.assume_init() } } + fn set_columns(file: &File, columns: u16) { + let size = libc::winsize { + ws_row: 24, + ws_col: columns, + ws_xpixel: 0, + ws_ypixel: 0, + }; + assert_eq!( + unsafe { libc::ioctl(file.as_raw_fd(), libc::TIOCSWINSZ, &size) }, + 0 + ); + } + struct UnreadOutput { child: ChildGuard, _master: File, @@ -308,6 +323,7 @@ mod manual_terminal_cli { ); let master = unsafe { File::from_raw_fd(master) }; let slave = unsafe { File::from_raw_fd(slave) }; + set_columns(&slave, 80); for file in [&master, &slave] { assert_eq!( unsafe { libc::fcntl(file.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) }, @@ -372,7 +388,7 @@ mod manual_terminal_cli { home, } } - fn wait_for_hidden_input(&mut self) { + fn wait_for_managed_input(&mut self) { let deadline = Instant::now() + Duration::from_secs(10); while mode(self.slave.as_raw_fd()).c_lflag & libc::ECHO != 0 { assert!( @@ -381,7 +397,7 @@ mod manual_terminal_cli { ); assert!( Instant::now() < deadline, - "CLI did not enter hidden input mode" + "CLI did not enter application-managed input mode" ); thread::sleep(Duration::from_millis(5)); } @@ -427,11 +443,373 @@ mod manual_terminal_cli { ) } + // The shipped reqwest client must use child-only HTTPS_PROXY. This fake + // CONNECT endpoint never tunnels, resolves Google, performs TLS, or forwards + // a code. An accepted completion reaches it once; every refusal reaches it zero times. + struct ConnectProbe { + address: std::net::SocketAddr, + stop: std::sync::Arc, + requests: std::sync::Arc>>, + worker: Option>, + } + impl ConnectProbe { + fn start() -> Self { + use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let address = listener.local_addr().unwrap(); + let stop = Arc::new(AtomicBool::new(false)); + let requests = Arc::new(Mutex::new(Vec::new())); + let stopped = stop.clone(); + let captured = requests.clone(); + let worker = thread::spawn(move || { + while !stopped.load(Ordering::SeqCst) { + match listener.accept() { + Ok((mut stream, _)) => { + stream + .set_read_timeout(Some(Duration::from_secs(1))) + .unwrap(); + stream + .set_write_timeout(Some(Duration::from_secs(1))) + .unwrap(); + let mut bytes = Vec::new(); + while !bytes.ends_with(b"\r\n\r\n") { + let mut byte = [0]; + if stream.read(&mut byte).unwrap_or(0) == 0 { + break; + } + bytes.push(byte[0]); + assert!(bytes.len() <= 8192, "unbounded CONNECT request"); + } + captured + .lock() + .unwrap() + .push(String::from_utf8(bytes).unwrap()); + let _ = stream.write_all(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(2)); + } + Err(error) => panic!("fake proxy accept: {error}"), + } + } + }); + Self { + address, + stop, + requests, + worker: Some(worker), + } + } + } + impl Drop for ConnectProbe { + fn drop(&mut self) { + self.stop.store(true, std::sync::atomic::Ordering::SeqCst); + if let Some(worker) = self.worker.take() { + worker.join().unwrap(); + } + } + } + + struct VisibleCli { + child: ChildGuard, + master: File, + slave: File, + before: libc::termios, + output: Vec, + home: TempDir, + proxy: ConnectProbe, + } + impl VisibleCli { + fn start() -> Self { + Self::with_output_flag(0) + } + fn with_output_flag(flag: libc::tcflag_t) -> Self { + let proxy = ConnectProbe::start(); + let home = TempDir::new().unwrap(); + let (mut master, mut slave) = (-1, -1); + assert_eq!( + unsafe { + libc::openpty( + &mut master, + &mut slave, + std::ptr::null_mut(), + std::ptr::null(), + std::ptr::null(), + ) + }, + 0 + ); + let master = unsafe { File::from_raw_fd(master) }; + let slave = unsafe { File::from_raw_fd(slave) }; + set_columns(&slave, 80); + for file in [&master, &slave] { + assert_eq!( + unsafe { libc::fcntl(file.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) }, + 0 + ); + } + let flags = unsafe { libc::fcntl(master.as_raw_fd(), libc::F_GETFL) }; + assert_eq!( + unsafe { libc::fcntl(master.as_raw_fd(), libc::F_SETFL, flags | libc::O_NONBLOCK) }, + 0 + ); + let mut before = mode(slave.as_raw_fd()); + if flag != 0 { + before.c_oflag |= libc::OPOST | flag; + assert_eq!( + unsafe { libc::tcsetattr(slave.as_raw_fd(), libc::TCSANOW, &before) }, + 0 + ); + } + let mut command = ProcessCommand::new(env!("CARGO_BIN_EXE_claude-code-proxy")); + command + .args(["antigravity", "auth", "login", "--manual"]) + .env_clear() + .env("HOME", home.path()) + .env("CCP_CONFIG_DIR", home.path()) + .env("XDG_STATE_HOME", home.path().join("state")) + .env("CCP_ANTIGRAVITY_CLIENT_ID", "synthetic-public-client") + .env("HTTPS_PROXY", format!("http://{}", proxy.address)) + .env("NO_PROXY", "localhost,127.0.0.1,::1,[::1]") + .env("RUST_LOG", "trace") + .env("CCP_LOG_STDERR", "1") + .env("CCP_LOG_VERBOSE", "1") + .env("CCP_TRAFFIC_LOG", "1") + .stdin(Stdio::from(slave.try_clone().unwrap())) + .stdout(Stdio::from( + File::create(home.path().join("stdout")).unwrap(), + )) + .stderr(Stdio::from( + File::create(home.path().join("stderr")).unwrap(), + )); + unsafe { + command.pre_exec(|| { + if libc::setsid() == -1 || libc::ioctl(0, libc::TIOCSCTTY, 0) == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let child = ChildGuard(command.spawn().unwrap()); + Self { + child, + master, + slave, + before, + output: Vec::new(), + home, + proxy, + } + } + fn collect(&mut self) { + let mut bytes = [0; 4096]; + for _ in 0..16 { + match self.master.read(&mut bytes) { + Ok(n) if n > 0 => self.output.extend_from_slice(&bytes[..n]), + Ok(_) => break, + Err(error) + if error.kind() == std::io::ErrorKind::WouldBlock + || error.raw_os_error() == Some(libc::EIO) => + { + break; + } + Err(error) => panic!("visible CLI read: {error}"), + } + } + assert!(self.output.len() < 256 * 1024); + } + fn until(&mut self, needle: &str) { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + self.collect(); + if String::from_utf8_lossy(&self.output).contains(needle) { + return; + } + assert!( + self.child.0.try_wait().unwrap().is_none(), + "CLI exited before {needle}" + ); + assert!(Instant::now() < deadline, "CLI did not display {needle}"); + thread::sleep(Duration::from_millis(5)); + } + } + fn send(&mut self, input: &[u8]) { + assert_eq!(self.master.write(input).unwrap(), input.len()); + } + fn block_output(&self) { + use std::os::unix::{ffi::OsStrExt, fs::OpenOptionsExt}; + let mut name = [0 as libc::c_char; 4096]; + assert_eq!( + unsafe { libc::ttyname_r(self.slave.as_raw_fd(), name.as_mut_ptr(), name.len()) }, + 0 + ); + let name = unsafe { std::ffi::CStr::from_ptr(name.as_ptr()) }; + let mut filler = std::fs::OpenOptions::new() + .write(true) + .custom_flags(libc::O_NONBLOCK | libc::O_NOCTTY | libc::O_CLOEXEC) + .open(std::ffi::OsStr::from_bytes(name.to_bytes())) + .unwrap(); + for _ in 0..256 { + match filler.write(&[b'!'; 1024]) { + Ok(n) => assert!(n > 0), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => return, + other => panic!("PTY fill failed: {other:?}"), + } + } + panic!("fixture did not reach output backpressure"); + } + fn finish(&mut self, message: &str, connects: usize, sentinel: &str) { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + self.collect(); + if let Some(status) = self.child.0.try_wait().unwrap() { + assert_eq!(status.code(), Some(1)); + break; + } + assert!(Instant::now() < deadline, "CLI did not finish"); + thread::sleep(Duration::from_millis(5)); + } + self.collect(); + let text = String::from_utf8_lossy(&self.output); + assert!(text.contains(message), "{text}"); + assert!(!text.contains("QUEUED_PRIVATE_TAIL")); + assert_eq!( + before_mode(&self.before), + before_mode(&mode(self.slave.as_raw_fd())) + ); + let mut pending: libc::c_int = -1; + assert_eq!( + unsafe { libc::ioctl(self.slave.as_raw_fd(), libc::FIONREAD, &mut pending) }, + 0 + ); + assert_eq!(pending, 0); + for name in ["stdout", "stderr"] { + let log = std::fs::read_to_string(self.home.path().join(name)).unwrap(); + assert!(!log.contains(sentinel), "redirected stream disclosed input"); + assert!(!log.contains("QUEUED_PRIVATE_TAIL")); + } + assert!(!self.home.path().join("antigravity/auth.json").exists()); + assert!( + !self + .home + .path() + .join("state/claude-code-proxy/traffic") + .exists() + ); + let requests = self.proxy.requests.lock().unwrap(); + assert_eq!(requests.len(), connects); + for request in requests.iter() { + assert_eq!( + request.lines().next(), + Some("CONNECT oauth2.googleapis.com:443 HTTP/1.1") + ); + assert!(!request.contains(sentinel)); + assert!(!request.contains("code_verifier")); + } + } + } + + #[test] + fn antigravity_manual_cli_visible_before_enter_and_exact_https_proxy_boundary() { + let code = "4/0cli-visible-private"; + for completion in ["submit", "ctrl-c", "invalid", "escape", "resize"] { + let mut child = VisibleCli::start(); + child.until("Paste the displayed authorization code"); + child.send(code.as_bytes()); + child.until(&format!("[{}] {code}", code.len())); + assert_eq!(mode(child.slave.as_raw_fd()).c_lflag & libc::ECHO, 0); + assert!(String::from_utf8_lossy(&child.output).contains("sensitive")); + assert!(child.proxy.requests.lock().unwrap().is_empty()); + let (message, connects) = match completion { + "submit" => { + child.send(b"\r\nQUEUED_PRIVATE_TAIL\n"); + ("OAuth token exchange transport failed", 1) + } + "ctrl-c" => { + child.send(b"\x03QUEUED_PRIVATE_TAIL\n"); + ("Manual OAuth login cancelled", 0) + } + "invalid" => { + child.send(b"%\nQUEUED_PRIVATE_TAIL\n"); + ("Invalid OAuth completion", 0) + } + "escape" => { + child.send(b"\x1b]52;c;injected\x07QUEUED_PRIVATE_TAIL\n"); + ("Invalid OAuth completion", 0) + } + _ => { + set_columns(&child.slave, 40); + ("Manual OAuth terminal width changed; login cancelled", 0) + } + }; + child.finish(message, connects, code); + } + } + + #[test] + fn antigravity_manual_cli_visible_output_translation_refuses_without_connect() { + let flags = [ + libc::OCRNL, + #[cfg(any(target_os = "linux", target_os = "android"))] + libc::OLCUC, + ]; + for flag in flags { + let mut child = VisibleCli::with_output_flag(flag); + let message = + "Manual OAuth terminal output must preserve carriage returns and letter case"; + child.finish( + if flag == libc::OCRNL { + message + } else { + "MANUAL OAUTH TERMINAL OUTPUT MUST PRESERVE CARRIAGE RETURNS AND LETTER CASE" + }, + 0, + "synthetic-code-not-entered", + ); + assert!( + !String::from_utf8_lossy(&child.output) + .to_lowercase() + .contains("accounts.google.com") + ); + } + } + + #[test] + fn antigravity_manual_cli_visible_echo_backpressure_exits_without_drain_or_connect() { + let code = "4/0cli-blocked-private"; + for cancellation in ["key", "signal", "resize"] { + let mut child = VisibleCli::start(); + child.until("[0] "); + child.block_output(); + child.send(code.as_bytes()); + thread::sleep(Duration::from_millis(30)); + match cancellation { + "key" => child.send(b"\x03QUEUED_PRIVATE_TAIL\n"), + "signal" => send_signal(&child.child, "-TERM").unwrap(), + _ => set_columns(&child.slave, 40), + } + let deadline = Instant::now() + Duration::from_secs(4); + while child.child.0.try_wait().unwrap().is_none() { + assert!( + Instant::now() < deadline, + "blocked echo/diagnostic prevented CLI exit" + ); + thread::sleep(Duration::from_millis(5)); + } + // Diagnostic may be undeliverable; no output was drained to let it finish. + child.finish("", 0, code); + } + } + #[test] fn antigravity_manual_cli_unread_output_cancellation_exits() { for signal in ["-INT", "-TERM"] { let mut child = UnreadOutput::start(false); - child.wait_for_hidden_input(); + child.wait_for_managed_input(); send_signal(&child.child, signal).unwrap(); child.require_exit_without_drain(Duration::from_secs(5)); } @@ -446,7 +824,7 @@ mod manual_terminal_cli { #[test] fn antigravity_manual_cli_unread_output_real_deadline_exits() { let mut child = UnreadOutput::start(false); - child.wait_for_hidden_input(); + child.wait_for_managed_input(); // Intentionally use the REAL production deadline. No test-only timeout // switch exists in the shipped command, and no PTY output is drained. child.require_exit_without_drain(Duration::from_secs(185)); @@ -471,6 +849,7 @@ mod manual_terminal_cli { ); let mut master = unsafe { File::from_raw_fd(master) }; let slave = unsafe { File::from_raw_fd(slave) }; + set_columns(&slave, 80); for file in [&master, &slave] { assert_eq!( unsafe { libc::fcntl(file.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) }, From e6c388013e61bb63f974f9343c7d1e7f60736db7 Mon Sep 17 00:00:00 2001 From: Chris Liu <11517021+chris7iu@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:40:12 +0000 Subject: [PATCH 05/30] fix(antigravity): bound and redact upstream error diagnostics Expose only allowlisted Google error labels and validated retry hints, while preserving HTTP status and never retrying failed generation. Co-Authored-By: Claude Code --- .../compatibility-and-limitations.md | 39 +- src/providers/antigravity/mod.rs | 28 +- src/providers/antigravity/transport.rs | 333 +++++++- src/providers/antigravity_http_tests.rs | 756 +++++++++++++++++- 4 files changed, 1124 insertions(+), 32 deletions(-) diff --git a/docs/src/content/docs/reference/compatibility-and-limitations.md b/docs/src/content/docs/reference/compatibility-and-limitations.md index 86b009df..5d2c8030 100644 --- a/docs/src/content/docs/reference/compatibility-and-limitations.md +++ b/docs/src/content/docs/reference/compatibility-and-limitations.md @@ -182,9 +182,42 @@ from traffic and error diagnostics. ### Transport, errors and bounds -Native HTTP headers are awaited before downstream 200. Initial HTTP errors and -`Retry-After` are preserved without exposing upstream error bodies. After native -2xx the response body owns collection; no successful text/tool turn is published +Native HTTP headers are awaited before downstream 200. Initial HTTP errors retain +HTTP status and the Anthropic error envelope/type, including when SSE was requested. +The generic message appends only fixed, allowlisted diagnostics in this order: +`google_status`, `reason`, `domain`, `quota_failure`, `retry_hint`, `retry_s`. +Unknown status/reason/domain values become `unclassified`; arbitrary upstream strings, +messages, numeric error codes, metadata, project/account identifiers and replay data +are never reflected or logged. Exact Google `ErrorInfo` reasons require an allowlisted +domain; multiple ErrorInfo details are unclassified even if identical. A valid +`QuotaFailure` is only `reported`, not proof that account subscription quota is +exhausted. HTTP 429 and `RESOURCE_EXHAUSTED` alone cannot distinguish quota, rate or +capacity limits. These supported literals are not a claim of live Argon acceptance. + +Diagnostic body collection requires complete EOF within 8 KiB and one absolute +1-second deadline, further limited by the remaining 30-second header-request budget. +JSON rejects duplicate keys, depth above 64, and more than 16 error details. +Malformed, oversized, incomplete or timed-out bodies become unclassified without +changing HTTP status. Unknown detail types are ignored; malformed detail structure +invalidates body diagnostics. No diagnostic reader survives request cancellation. + +`Retry-After` is forwarded only after validating a single header as 1–5 decimal digits +(0–86400 seconds, leading zeros normalized) or an exact canonical IMF-fixdate with +valid calendar/weekday/GMT. Past dates imply zero wait; future dates beyond 24 hours +are rejected, never shortened. RFC850/asctime dates, duplicate, signed, fractional, +overlong or arbitrary header values are deliberately no longer forwarded. +Google `RetryInfo.retryDelay` accepts a canonical nonnegative seconds string with up +to nine fractional digits, rounded **up** using integer arithmetic, bounded to 86400 +seconds. It never synthesizes a `Retry-After` header. `retry_hint` is +`valid`, `absent`, `invalid` or `conflicting`; `retry_s` is a bounded integer only for +valid hints, otherwise `none`. Invalid hints take precedence; multiple RetryInfo +objects conflict even when identical. Different valid header/body delta-seconds +conflict; a valid HTTP-date plus valid body delay uses the larger remaining wait. +A valid header is still forwarded if body hints conflict or are invalid. An unusable +body cannot establish absent retry information, but a validated independent header +remains usable. Retry hints are advisory only: there is no automatic sleep or retry. + +After native 2xx the response body owns collection; no successful text/tool turn is published until terminal state, arguments and replay metadata are validated. Late errors emit an SSE error without `message_stop` and record one failed monitor outcome. Dropping the body cancels owned inference work. Loopback socket tests cover diff --git a/src/providers/antigravity/mod.rs b/src/providers/antigravity/mod.rs index 4dfa4fc4..4b4b8de3 100644 --- a/src/providers/antigravity/mod.rs +++ b/src/providers/antigravity/mod.rs @@ -101,8 +101,9 @@ impl AntigravityProvider { Ok(v) => v, Err(_) => return invalid("invalid native endpoint"), }; - let sent = tokio::time::timeout( - Duration::from_secs(30), + let header_deadline = tokio::time::Instant::now() + Duration::from_secs(30); + let sent = tokio::time::timeout_at( + header_deadline, auth.client .post(endpoint) .bearer_auth(&credential.access_token) @@ -117,28 +118,7 @@ impl AntigravityProvider { _ => return upstream_error("Antigravity native headers timed out or transport failed"), }; if !upstream.status().is_success() { - let status = upstream.status(); - let mut response = json_error( - status, - match status { - StatusCode::UNAUTHORIZED => "authentication_error", - StatusCode::FORBIDDEN => "permission_error", - StatusCode::TOO_MANY_REQUESTS => "rate_limit_error", - _ => "api_error", - }, - format!( - "Antigravity upstream returned HTTP {}; no retry or fallback attempted", - status.as_u16() - ), - ); - if let Some(value) = upstream - .headers() - .get("retry-after") - .filter(|v| v.as_bytes().len() <= 128) - { - response.headers_mut().insert("retry-after", value.clone()); - } - return response; + return transport::http_error(upstream, header_deadline).await; } if !upstream .headers() diff --git a/src/providers/antigravity/transport.rs b/src/providers/antigravity/transport.rs index 0b314747..3c45e104 100644 --- a/src/providers/antigravity/transport.rs +++ b/src/providers/antigravity/transport.rs @@ -1,6 +1,337 @@ -//! Bounded native SSE collection. No generation retry; no success before EOF. +//! Bounded native HTTP error diagnostics and SSE collection. No generation retry. use super::replay::{self, Result}; +use crate::anthropic::json_error; +use axum::{ + http::{HeaderMap, HeaderValue, StatusCode}, + response::Response, +}; use serde_json::{Map, Value, json}; +use std::time::Duration; +use tokio::time::Instant; + +const MAX_ERROR_BODY: usize = 8 * 1024; +const MAX_RETRY_SECONDS: u32 = 86400; +const UNCLASSIFIED: &str = "unclassified"; +const GOOGLE_STATUSES: &[&str] = &[ + "CANCELLED", + "UNKNOWN", + "INVALID_ARGUMENT", + "DEADLINE_EXCEEDED", + "NOT_FOUND", + "ALREADY_EXISTS", + "PERMISSION_DENIED", + "RESOURCE_EXHAUSTED", + "FAILED_PRECONDITION", + "ABORTED", + "OUT_OF_RANGE", + "UNIMPLEMENTED", + "INTERNAL", + "UNAVAILABLE", + "DATA_LOSS", + "UNAUTHENTICATED", +]; +// Supported exact literals, not claims about which reasons Argon actually emits. +const GOOGLE_REASONS: &[&str] = &[ + "RATE_LIMIT_EXCEEDED", + "QUOTA_EXCEEDED", + "RESOURCE_EXHAUSTED", + "MODEL_CAPACITY_EXHAUSTED", + "MODEL_NOT_FOUND", + "SERVICE_DISABLED", + "API_DISABLED", + "ACCESS_TOKEN_EXPIRED", + "ACCESS_TOKEN_SCOPE_INSUFFICIENT", + "CONSUMER_INVALID", + "PROJECT_INVALID", +]; +const GOOGLE_DOMAINS: &[&str] = &[ + "googleapis.com", + "cloudcode-pa.googleapis.com", + "generativelanguage.googleapis.com", + "aiplatform.googleapis.com", +]; + +#[derive(Clone, Copy)] +enum RetryHint { + Absent, + Invalid, + Conflicting, + Valid(u32), +} +impl RetryHint { + fn label(self) -> &'static str { + match self { + Self::Absent => "absent", + Self::Invalid => "invalid", + Self::Conflicting => "conflicting", + Self::Valid(_) => "valid", + } + } + fn seconds(self) -> String { + match self { + Self::Valid(seconds) => seconds.to_string(), + _ => "none".into(), + } + } +} +struct RetryHeader { + hint: RetryHint, + value: Option, + date: bool, +} +impl RetryHeader { + fn parse(headers: &HeaderMap, now: time::OffsetDateTime) -> Self { + let mut values = headers.get_all("retry-after").iter(); + let Some(value) = values.next() else { + return Self { + hint: RetryHint::Absent, + value: None, + date: false, + }; + }; + if values.next().is_none() + && value.as_bytes().len() <= 128 + && let Some((seconds, normalized, date)) = + value.to_str().ok().and_then(|v| retry_after(v, now)) + && let Ok(value) = HeaderValue::from_str(&normalized) + { + return Self { + hint: RetryHint::Valid(seconds), + value: Some(value), + date, + }; + } + Self { + hint: RetryHint::Invalid, + value: None, + date: false, + } + } +} + +fn retry_after(raw: &str, now: time::OffsetDateTime) -> Option<(u32, String, bool)> { + if (1..=5).contains(&raw.len()) && raw.bytes().all(|b| b.is_ascii_digit()) { + let seconds: u32 = raw.parse().ok()?; + return (seconds <= MAX_RETRY_SECONDS).then(|| (seconds, seconds.to_string(), false)); + } + if raw.len() != 29 { + return None; + } + let format = time::format_description::parse_borrowed::<3>( + "[weekday repr:short], [day] [month repr:short] [year] [hour]:[minute]:[second] GMT", + ) + .ok()?; + let date = time::PrimitiveDateTime::parse(raw, &format) + .ok()? + .assume_utc(); + let canonical = date.format(&format).ok()?; + // Round-tripping also verifies weekday, exact case, spacing and GMT. + if canonical != raw { + return None; + } + let nanos = (date.unix_timestamp_nanos() - now.unix_timestamp_nanos()).max(0); + if nanos > i128::from(MAX_RETRY_SECONDS) * 1_000_000_000 { + return None; + } + let seconds = u32::try_from((nanos + 999_999_999) / 1_000_000_000).ok()?; + Some((seconds, canonical, true)) +} + +fn retry_duration(raw: &str) -> Option { + let duration = raw.strip_suffix('s')?; + let (seconds, fraction) = duration + .split_once('.') + .map_or((duration, None), |(s, f)| (s, Some(f))); + if !(1..=5).contains(&seconds.len()) + || !seconds.bytes().all(|b| b.is_ascii_digit()) + || (seconds.len() > 1 && seconds.starts_with('0')) + { + return None; + } + let seconds: u32 = seconds.parse().ok()?; + let nanos = match fraction { + None => 0, + Some(f) if (1..=9).contains(&f.len()) && f.bytes().all(|b| b.is_ascii_digit()) => f + .parse::() + .ok()? + .checked_mul(10_u32.pow(9 - f.len() as u32))?, + _ => return None, + }; + let rounded = seconds.checked_add(u32::from(nanos > 0))?; + (rounded <= MAX_RETRY_SECONDS).then_some(rounded) +} + +struct ErrorDiagnostics { + status: &'static str, + reason: &'static str, + domain: &'static str, + quota: &'static str, + retry: RetryHint, +} +impl ErrorDiagnostics { + fn unclassified() -> Self { + Self { + status: UNCLASSIFIED, + reason: UNCLASSIFIED, + domain: UNCLASSIFIED, + quota: UNCLASSIFIED, + retry: RetryHint::Invalid, + } + } + fn parse(raw: &[u8]) -> Option { + let value = replay::parse_json(raw, MAX_ERROR_BODY).ok()?; + let error = value.get("error")?.as_object()?; + let details = match error.get("details") { + Some(v) => v.as_array()?.as_slice(), + None => &[], + }; + if details.len() > 16 { + return None; + } + let mut infos = Vec::new(); + let mut quotas = Vec::new(); + let mut retries = Vec::new(); + for detail in details { + let detail = detail.as_object()?; + match detail.get("@type")?.as_str()? { + "type.googleapis.com/google.rpc.ErrorInfo" => infos.push(detail), + "type.googleapis.com/google.rpc.QuotaFailure" => quotas.push(detail), + "type.googleapis.com/google.rpc.RetryInfo" => retries.push(detail), + _ => {} // Unknown detail types never contribute free-form diagnostics. + } + } + let mut out = Self::unclassified(); + out.status = allowed(error.get("status"), GOOGLE_STATUSES); + if let [info] = infos.as_slice() + && info.get("reason").is_some_and(Value::is_string) + { + out.domain = allowed(info.get("domain"), GOOGLE_DOMAINS); + if out.domain != UNCLASSIFIED { + out.reason = allowed(info.get("reason"), GOOGLE_REASONS); + } + } + out.quota = match quotas.as_slice() { + [] => "absent", + [quota] + if quota + .get("violations") + .and_then(Value::as_array) + .is_some_and(|v| !v.is_empty() && v.iter().all(Value::is_object)) => + { + "reported" + } + _ => UNCLASSIFIED, + }; + out.retry = RetryHint::Absent; + for retry in &retries { + match retry + .get("retryDelay") + .and_then(Value::as_str) + .and_then(retry_duration) + { + Some(seconds) => out.retry = RetryHint::Valid(seconds), + None => { + out.retry = RetryHint::Invalid; + return Some(out); + } + } + } + if retries.len() > 1 { + out.retry = RetryHint::Conflicting; + } + Some(out) + } +} +fn allowed(value: Option<&Value>, whitelist: &'static [&'static str]) -> &'static str { + whitelist + .iter() + .copied() + .find(|v| Some(*v) == value.and_then(Value::as_str)) + .unwrap_or(UNCLASSIFIED) +} +fn combine_retry(header: &RetryHeader, body: RetryHint) -> RetryHint { + use RetryHint::*; + match (header.hint, body) { + (Invalid, _) | (_, Invalid) => Invalid, + (Conflicting, _) | (_, Conflicting) => Conflicting, + (Valid(h), Valid(b)) if header.date => Valid(h.max(b)), + (Valid(h), Valid(b)) if h != b => Conflicting, + (Valid(h), _) => Valid(h), + (_, Valid(b)) => Valid(b), + (Absent, Absent) => Absent, + } +} + +async fn error_body(mut upstream: reqwest::Response, deadline: Instant) -> Option> { + if upstream + .content_length() + .is_some_and(|n| n > MAX_ERROR_BODY as u64) + || Instant::now() >= deadline + { + return None; + } + tokio::time::timeout_at(deadline, async move { + let mut body = Vec::with_capacity(MAX_ERROR_BODY); + while let Some(chunk) = upstream.chunk().await.ok()? { + if chunk.len() > MAX_ERROR_BODY - body.len() { + return None; + } + body.extend_from_slice(&chunk); + } + Some(body) + }) + .await + .ok() + .flatten() +} + +/// Initial native failures remain HTTP JSON errors even when the caller requested SSE. +/// Only static labels and bounded integers escape; never log the response/body/parse errors. +/// This future owns the upstream body, so dropping the request cancels inspection. +pub(crate) async fn http_error(upstream: reqwest::Response, header_deadline: Instant) -> Response { + let deadline = header_deadline.min(Instant::now() + Duration::from_secs(1)); + let status = upstream.status(); + let retry = RetryHeader::parse(upstream.headers(), time::OffsetDateTime::now_utc()); + let body = error_body(upstream, deadline).await; + let diagnostics = match body.as_deref().and_then(ErrorDiagnostics::parse) { + Some(mut diagnostics) => { + diagnostics.retry = combine_retry(&retry, diagnostics.retry); + diagnostics + } + None => { + let mut diagnostics = ErrorDiagnostics::unclassified(); + // An unusable body cannot establish absence, but a validated header is independent. + if let RetryHint::Valid(_) = retry.hint { + diagnostics.retry = retry.hint; + } + diagnostics + } + }; + let mut response = json_error( + status, + match status { + StatusCode::UNAUTHORIZED => "authentication_error", + StatusCode::FORBIDDEN => "permission_error", + StatusCode::TOO_MANY_REQUESTS => "rate_limit_error", + _ => "api_error", + }, + format!( + "Antigravity upstream returned HTTP {}; no retry or fallback attempted; google_status={}; reason={}; domain={}; quota_failure={}; retry_hint={}; retry_s={}", + status.as_u16(), + diagnostics.status, + diagnostics.reason, + diagnostics.domain, + diagnostics.quota, + diagnostics.retry.label(), + diagnostics.retry.seconds() + ), + ); + // Body RetryInfo is advisory metadata only, never a synthesized HTTP header. + if let Some(value) = retry.value { + response.headers_mut().insert("retry-after", value); + } + response +} const MAX_TURN: usize = 8 * 1024 * 1024; const MAX_FRAME: usize = 1024 * 1024; diff --git a/src/providers/antigravity_http_tests.rs b/src/providers/antigravity_http_tests.rs index 58c3b05f..282a53ba 100644 --- a/src/providers/antigravity_http_tests.rs +++ b/src/providers/antigravity_http_tests.rs @@ -51,6 +51,7 @@ type Captured = Arc>>; struct Backend { captured: Captured, replies: Vec<(u16, String)>, + headers: http::HeaderMap, } async fn backend(State(state): State>, request: Request) -> Response { let headers = request.headers().clone(); @@ -68,18 +69,36 @@ async fn backend(State(state): State>, request: Request) -> Respons .replies .get(index) .unwrap_or_else(|| state.replies.last().unwrap()); - Response::builder() + let mut response = Response::builder() .status(*status) - .header("content-type", "text/event-stream") - .header("retry-after", "7") .body(Body::from(body.clone())) - .unwrap() + .unwrap(); + *response.headers_mut() = state.headers.clone(); + response } async fn native(replies: Vec<(u16, String)>) -> (Server, Captured) { + native_with_headers( + replies, + &[("content-type", "text/event-stream"), ("retry-after", "7")], + ) + .await +} +async fn native_with_headers( + replies: Vec<(u16, String)>, + headers: &[(&str, &str)], +) -> (Server, Captured) { let captured = Arc::new(Mutex::new(Vec::new())); + let mut map = http::HeaderMap::new(); + for (name, value) in headers { + map.append( + http::HeaderName::from_bytes(name.as_bytes()).unwrap(), + http::HeaderValue::from_str(value).unwrap(), + ); + } let state = Arc::new(Backend { captured: captured.clone(), replies, + headers: map, }); let server = Server::start( Router::new() @@ -631,6 +650,735 @@ async fn antigravity_real_disconnect_cancels_preheader_nonstream_and_stream_coll } } +fn safe_suffix( + status: &str, + reason: &str, + domain: &str, + quota: &str, + hint: &str, + seconds: &str, +) -> String { + format!( + "; google_status={status}; reason={reason}; domain={domain}; quota_failure={quota}; retry_hint={hint}; retry_s={seconds}" + ) +} +fn error_info(reason: &str, domain: &str) -> Value { + json!({"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":reason,"domain":domain,"metadata":{"project":"DO_NOT_REFLECT_native_secret","email":"DO_NOT_REFLECT_native_secret@example.test"}}) +} +fn retry_info(delay: Value) -> Value { + json!({"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":delay}) +} +fn error_body(details: Value) -> String { + json!({"error":{"code":429,"message":"DO_NOT_REFLECT_native_secret","status":"RESOURCE_EXHAUSTED","details":details}}).to_string() +} +async fn assert_safe_error( + body: &str, + headers: &[(&str, &str)], + status: u16, + expected: (&str, Option<&str>), +) { + for streaming in [false, true] { + let (upstream, captured) = native_with_headers(vec![(status, body.into())], headers).await; + let directory = tempfile::tempdir().unwrap(); + let monitor = MonitorHandle::new(10); + let proxy = proxy( + provider(directory.path(), &upstream.url).await, + Some(monitor.clone()), + ) + .await; + let response = client() + .post(proxy.url.join("/v1/messages").unwrap()) + .json(&request(streaming)) + .send() + .await + .unwrap(); + assert_eq!(response.status().as_u16(), status); + assert_eq!(response.headers()["content-type"], "application/json"); + assert_eq!(response.headers()["x-antigravity-reasoning-tier"], "low"); + assert_eq!( + response + .headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()), + expected.1 + ); + let text = response.text().await.unwrap(); + let value: Value = serde_json::from_str(&text).unwrap(); + let kind = match status { + 401 => "authentication_error", + 403 => "permission_error", + 429 => "rate_limit_error", + _ => "api_error", + }; + assert_eq!( + value, + json!({"type":"error","error":{"type":kind,"message":format!("Antigravity upstream returned HTTP {status}; no retry or fallback attempted{}", expected.0)}}) + ); + assert!(!text.contains("DO_NOT_REFLECT_native_secret")); + assert!(!text.contains("message_start")); + assert!(!text.contains("message_stop")); + assert!(!text.contains("tool_use")); + assert!(!format!("{:?}", monitor.snapshot()).contains("DO_NOT_REFLECT_native_secret")); + assert_eq!( + captured.lock().unwrap().len(), + 1, + "one generation attempt only" + ); + } +} + +#[tokio::test] +async fn antigravity_safe_errors_preserve_status_and_extract_only_allowed_fields() { + let body = error_body(json!([error_info("RATE_LIMIT_EXCEEDED", "googleapis.com"), + {"@type":"type.googleapis.com/google.rpc.QuotaFailure","violations":[{"subject":"DO_NOT_REFLECT_native_secret","description":"DO_NOT_REFLECT_native_secret"}]}, + {"@type":"DO_NOT_REFLECT_native_secret","message":"DO_NOT_REFLECT_native_secret"}])); + let suffix = safe_suffix( + "RESOURCE_EXHAUSTED", + "RATE_LIMIT_EXCEEDED", + "googleapis.com", + "reported", + "valid", + "7", + ); + for status in [400, 401, 403, 404, 429, 500, 503] { + assert_safe_error( + &body, + &[("retry-after", "00007")], + status, + (&suffix, Some("7")), + ) + .await; + } + assert_safe_error( + &error_body(json!([])), + &[], + 429, + ( + &safe_suffix( + "RESOURCE_EXHAUSTED", + "unclassified", + "unclassified", + "absent", + "absent", + "none", + ), + None, + ), + ) + .await; +} + +#[tokio::test] +async fn antigravity_safe_errors_unknown_ambiguous_and_quota_details_fail_closed() { + for (details, reason, domain, quota) in [ + ( + json!([error_info( + "MODEL_CAPACITY_EXHAUSTED", + "cloudcode-pa.googleapis.com" + )]), + "MODEL_CAPACITY_EXHAUSTED", + "cloudcode-pa.googleapis.com", + "absent", + ), + ( + json!([error_info( + "DO_NOT_REFLECT_native_secret_CAPACITY", + "googleapis.com" + )]), + "unclassified", + "googleapis.com", + "absent", + ), + ( + json!([error_info( + "RATE_LIMIT_EXCEEDED", + "DO_NOT_REFLECT_native_secret" + )]), + "unclassified", + "unclassified", + "absent", + ), + ( + json!([ + error_info("RATE_LIMIT_EXCEEDED", "googleapis.com"), + error_info("RATE_LIMIT_EXCEEDED", "googleapis.com") + ]), + "unclassified", + "unclassified", + "absent", + ), + ( + json!([{"@type":"type.googleapis.com/google.rpc.ErrorInfo","domain":"googleapis.com","reason":42}]), + "unclassified", + "unclassified", + "absent", + ), + ( + json!([{"@type":"type.googleapis.com/google.rpc.QuotaFailure","violations":[]}]), + "unclassified", + "unclassified", + "unclassified", + ), + ( + json!([{"@type":"type.googleapis.com/google.rpc.QuotaFailure","violations":["DO_NOT_REFLECT_native_secret"]}]), + "unclassified", + "unclassified", + "unclassified", + ), + ( + json!([{"@type":"type.googleapis.com/google.rpc.QuotaFailure","violations":[{}]}, {"@type":"type.googleapis.com/google.rpc.QuotaFailure","violations":[{}]}]), + "unclassified", + "unclassified", + "unclassified", + ), + ] { + assert_safe_error( + &error_body(details), + &[], + 429, + ( + &safe_suffix( + "RESOURCE_EXHAUSTED", + reason, + domain, + quota, + "absent", + "none", + ), + None, + ), + ) + .await; + } + for status in ["OK", "DO_NOT_REFLECT_native_secret", "resource_exhausted"] { + let body = json!({"error":{"status":status}}).to_string(); + assert_safe_error( + &body, + &[], + 429, + ( + &safe_suffix( + "unclassified", + "unclassified", + "unclassified", + "absent", + "absent", + "none", + ), + None, + ), + ) + .await; + } +} + +#[tokio::test] +async fn antigravity_safe_errors_unusable_bodies_never_claim_no_retry_hint() { + let mut bodies = vec![ + String::new(), + "DO_NOT_REFLECT_native_secret".into(), + "null".into(), + "{}".into(), + "{\"error\":{},\"error\":{}}".into(), + "{\"error\":{\"status\":\"UNKNOWN\",\"status\":\"INTERNAL\"}}".into(), + error_body(json!({})), + error_body(json!([{}])), + error_body(json!([{"@type":1}])), + error_body(json!([1])), + error_body(json!(vec![json!({"@type":"ignored"}); 17])), + format!( + "{{\"error\":{{\"unused\":{}0{}}}}}", + "[".repeat(65), + "]".repeat(65) + ), + format!("{{\"error\":{{\"message\":\"{}\"}}}}", "x".repeat(8192)), + ]; + bodies.push(format!("{} trailing", error_body(json!([])))); + for body in bodies { + for (headers, hint, seconds, forwarded) in [ + (vec![], "invalid", "none", None), + (vec![("retry-after", "7")], "valid", "7", Some("7")), + ] { + assert_safe_error( + &body, + &headers, + 503, + ( + &safe_suffix( + "unclassified", + "unclassified", + "unclassified", + "unclassified", + hint, + seconds, + ), + forwarded, + ), + ) + .await; + } + } + let body = "{\"error\":{}}"; + let padded = format!("{body}{}", " ".repeat(8192 - body.len())); + assert_safe_error( + &padded, + &[], + 400, + ( + &safe_suffix( + "unclassified", + "unclassified", + "unclassified", + "absent", + "absent", + "none", + ), + None, + ), + ) + .await; +} + +#[tokio::test] +async fn antigravity_safe_errors_retry_truth_table_and_header_only_forwarding() { + // Header and RetryInfo are independent: never invent an HTTP header from the body. + for (headers, details, hint, seconds, forwarded) in [ + (vec![], json!([]), "absent", "none", None), + (vec![], json!([retry_info(json!("7s"))]), "valid", "7", None), + ( + vec![], + json!([retry_info(json!("0.000000001s"))]), + "valid", + "1", + None, + ), + ( + vec![], + json!([retry_info(json!("7.250s"))]), + "valid", + "8", + None, + ), + ( + vec![], + json!([retry_info(json!("86400.000000000s"))]), + "valid", + "86400", + None, + ), + ( + vec![("retry-after", "7")], + json!([retry_info(json!("7s"))]), + "valid", + "7", + Some("7"), + ), + ( + vec![("retry-after", "7")], + json!([retry_info(json!("8s"))]), + "conflicting", + "none", + Some("7"), + ), + ( + vec![("retry-after", "7")], + json!([retry_info(json!("bad"))]), + "invalid", + "none", + Some("7"), + ), + ( + vec![("retry-after", "DO_NOT_REFLECT_native_secret")], + json!([retry_info(json!("7s"))]), + "invalid", + "none", + None, + ), + ( + vec![("retry-after", "7"), ("retry-after", "7")], + json!([]), + "invalid", + "none", + None, + ), + ( + vec![], + json!([retry_info(json!("7s")), retry_info(json!("7s"))]), + "conflicting", + "none", + None, + ), + ( + vec![("retry-after", "7")], + json!([retry_info(json!("7s")), retry_info(json!("7s"))]), + "conflicting", + "none", + Some("7"), + ), + ( + vec![], + json!([retry_info(json!("7s")), retry_info(json!("bad"))]), + "invalid", + "none", + None, + ), + ( + vec![("retry-after", "Sun, 06 Nov 1994 08:49:37 GMT")], + json!([retry_info(json!("7s"))]), + "valid", + "7", + Some("Sun, 06 Nov 1994 08:49:37 GMT"), + ), + ] { + assert_safe_error( + &error_body(details), + &headers, + 429, + ( + &safe_suffix( + "RESOURCE_EXHAUSTED", + "unclassified", + "unclassified", + "absent", + hint, + seconds, + ), + forwarded, + ), + ) + .await; + } + for delay in [ + json!("-1s"), + json!("+1s"), + json!("01s"), + json!("1e2s"), + json!("1.s"), + json!(".1s"), + json!("1.0000000001s"), + json!("86400.000000001s"), + json!("86401s"), + json!("100000s"), + json!(" 1s"), + json!(1), + json!(null), + json!({"seconds":1,"nanos":0}), + ] { + assert_safe_error( + &error_body(json!([retry_info(delay)])), + &[], + 429, + ( + &safe_suffix( + "RESOURCE_EXHAUSTED", + "unclassified", + "unclassified", + "absent", + "invalid", + "none", + ), + None, + ), + ) + .await; + } + for header in [ + "", + "-1", + "+1", + "1.0", + "1e2", + "86401", + "000000", + "7, 7", + "Mon, 06 Nov 1994 08:49:37 GMT", + "Sun, 31 Feb 2024 08:49:37 GMT", + "Sunday, 06-Nov-94 08:49:37 GMT", + "Sun Nov 6 08:49:37 1994", + "Sun, 06 Nov 1994 08:49:37 UTC", + "Fri, 31 Dec 9999 23:59:59 GMT", + "DO_NOT_REFLECT_native_secret", + ] { + assert_safe_error( + &error_body(json!([])), + &[("retry-after", header)], + 429, + ( + &safe_suffix( + "RESOURCE_EXHAUSTED", + "unclassified", + "unclassified", + "absent", + "invalid", + "none", + ), + None, + ), + ) + .await; + } + for header in ["0", "86400"] { + assert_safe_error( + &error_body(json!([])), + &[("retry-after", header)], + 429, + ( + &safe_suffix( + "RESOURCE_EXHAUSTED", + "unclassified", + "unclassified", + "absent", + "valid", + header, + ), + Some(header), + ), + ) + .await; + } +} + +#[tokio::test] +async fn antigravity_safe_errors_chunked_overflow_trickle_and_read_failure_are_bounded() { + for streaming in [false, true] { + for mode in [ + "trickle", + "overflow", + "known_oversize", + "disconnect", + "utf8", + ] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = + url::Url::parse(&format!("http://{}", listener.local_addr().unwrap())).unwrap(); + let task = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + read_request(&mut socket).await; + if mode == "known_oversize" { + socket.write_all(b"HTTP/1.1 429 Too Many Requests\r\nContent-Length: 8193\r\nConnection: close\r\n\r\n").await.unwrap(); + } else { + socket.write_all(b"HTTP/1.1 429 Too Many Requests\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n").await.unwrap(); + } + match mode { + "overflow" => { + let body = format!("2001\r\n{}\r\n", "x".repeat(8193)); + let _ = socket.write_all(body.as_bytes()).await; + } + "trickle" => { + for _ in 0..40 { + if socket.write_all(b"1\r\n \r\n").await.is_err() { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + } + "disconnect" => { + return; + } + "utf8" => { + let _ = socket.write_all(b"1\r\n\xff\r\n0\r\n\r\n").await; + } + _ => {} + } + let mut byte = [0u8; 1]; + assert!( + matches!( + tokio::time::timeout(Duration::from_secs(2), socket.read(&mut byte)).await, + Ok(Ok(0)) | Ok(Err(_)) + ), + "error collector retained upstream socket: {mode}" + ); + assert!( + tokio::time::timeout(Duration::from_millis(50), listener.accept()) + .await + .is_err(), + "unexpected second native request" + ); + }); + let directory = tempfile::tempdir().unwrap(); + let proxy = proxy(provider(directory.path(), &url).await, None).await; + let started = tokio::time::Instant::now(); + let response = client() + .post(proxy.url.join("/v1/messages").unwrap()) + .json(&request(streaming)) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 429); + let value: Value = response.json().await.unwrap(); + assert!( + value["error"]["message"] + .as_str() + .unwrap() + .ends_with(&safe_suffix( + "unclassified", + "unclassified", + "unclassified", + "unclassified", + "invalid", + "none" + )) + ); + assert!(started.elapsed() < Duration::from_millis(1800)); + if mode == "trickle" { + assert!(started.elapsed() >= Duration::from_millis(800)); + } + task.await.unwrap(); + } + } +} + +#[tokio::test] +async fn antigravity_safe_errors_downstream_disconnect_cancels_body_read() { + for streaming in [false, true] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = url::Url::parse(&format!("http://{}", listener.local_addr().unwrap())).unwrap(); + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + read_request(&mut socket).await; + socket.write_all(b"HTTP/1.1 429 Too Many Requests\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n").await.unwrap(); + ready_tx.send(()).unwrap(); + let mut byte = [0u8; 1]; + assert!( + matches!( + tokio::time::timeout(Duration::from_millis(700), socket.read(&mut byte)).await, + Ok(Ok(0)) | Ok(Err(_)) + ), + "body read must cancel before its one-second timeout" + ); + }); + let directory = tempfile::tempdir().unwrap(); + let proxy = proxy(provider(directory.path(), &url).await, None).await; + let mut downstream = + tokio::net::TcpStream::connect(("127.0.0.1", proxy.url.port().unwrap())) + .await + .unwrap(); + let body = request(streaming).to_string(); + downstream.write_all(format!("POST /v1/messages HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap(); + ready_rx.await.unwrap(); + tokio::time::sleep(Duration::from_millis(30)).await; + drop(downstream); + task.await.unwrap(); + } +} + +#[tokio::test] +async fn antigravity_safe_errors_reuse_remaining_header_budget() { + for remaining in [Duration::ZERO, Duration::from_millis(100)] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + read_request(&mut socket).await; + socket + .write_all( + b"HTTP/1.1 503 Service Unavailable\r\nTransfer-Encoding: chunked\r\n\r\n", + ) + .await + .unwrap(); + let mut byte = [0u8; 1]; + assert!(matches!( + tokio::time::timeout(Duration::from_millis(700), socket.read(&mut byte)).await, + Ok(Ok(0)) | Ok(Err(_)) + )); + }); + let upstream = client() + .post(format!("http://{address}/")) + .body("x") + .send() + .await + .unwrap(); + let start = tokio::time::Instant::now(); + let response = super::antigravity::transport::http_error(upstream, start + remaining).await; + assert_eq!(response.status(), 503); + assert!( + start.elapsed() < Duration::from_millis(500), + "must not grant a fresh second after header budget" + ); + let bytes = axum::body::to_bytes(response.into_body(), 4096) + .await + .unwrap(); + assert!( + std::str::from_utf8(&bytes) + .unwrap() + .contains("retry_hint=invalid; retry_s=none") + ); + task.await.unwrap(); + } +} + +#[tokio::test] +async fn antigravity_safe_errors_verbose_log_redaction() { + const CHILD: &str = "CCP_SAFE_ERROR_LOG_TEST_CHILD"; + if std::env::var_os(CHILD).is_some() { + assert!(crate::config::log_verbose()); + assert!(crate::config::log_stderr()); + assert_safe_error( + &error_body(json!([error_info( + "DO_NOT_REFLECT_native_secret", + "DO_NOT_REFLECT_native_secret" + )])), + &[("retry-after", "DO_NOT_REFLECT_native_secret")], + 429, + ( + &safe_suffix( + "RESOURCE_EXHAUSTED", + "unclassified", + "unclassified", + "absent", + "invalid", + "none", + ), + None, + ), + ) + .await; + return; + } + // Isolate logger configuration from parallel tests. RUST_LOG does not configure this logger. + let directory = tempfile::tempdir().unwrap(); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "providers::antigravity_http_tests::antigravity_safe_errors_verbose_log_redaction", + "--nocapture", + ]) + .env(CHILD, "1") + .env("CCP_LOG_VERBOSE", "1") + .env("CCP_LOG_STDERR", "1") + .env("CCP_CONFIG_DIR", directory.path().join("config")) + .env("XDG_STATE_HOME", directory.path().join("state")) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("1 passed"), + "child must actually execute the test: {stdout}" + ); + for bytes in [&output.stdout, &output.stderr] { + assert!(!String::from_utf8_lossy(bytes).contains("DO_NOT_REFLECT_native_secret")); + } + let logs = std::fs::read_to_string(directory.path().join("state/claude-code-proxy/proxy.log")) + .unwrap(); + assert!(!logs.is_empty()); + assert!(!logs.contains("DO_NOT_REFLECT_native_secret")); + let captures: Vec<_> = + std::fs::read_dir(directory.path().join("state/claude-code-proxy/errors")) + .unwrap() + .collect(); + assert!(!captures.is_empty()); + for entry in captures { + let text = std::fs::read_to_string(entry.unwrap().path()).unwrap(); + assert!(!text.contains("DO_NOT_REFLECT_native_secret")); + } +} + #[tokio::test] async fn antigravity_count_estimate_is_signature_size_invariant_and_network_free() { let (upstream, captured) = native(vec![(200, successful(json!([{"text":"unused"}])))]).await; From 19fcf95be823cac7d1c1675fad25a292e275937b Mon Sep 17 00:00:00 2001 From: Chris Liu <11517021+chris7iu@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:33:19 +0000 Subject: [PATCH 06/30] fix(antigravity): retry proven pre-send refresh failures safely Bound refresh retries within the original deadline, preserve credentials with scoped cooldowns, and retain fail-closed ambiguous-rotation handling. Co-Authored-By: Claude Code --- .../content/docs/reference/configuration.md | 33 + src/providers/antigravity/config.rs | 4 +- src/providers/antigravity/mod.rs | 3 + src/providers/antigravity/refresh.rs | 1052 ++++++++++++++++- src/providers/antigravity_auth_tests.rs | 116 ++ 5 files changed, 1187 insertions(+), 21 deletions(-) diff --git a/docs/src/content/docs/reference/configuration.md b/docs/src/content/docs/reference/configuration.md index 882ca634..8bfc8bb3 100644 --- a/docs/src/content/docs/reference/configuration.md +++ b/docs/src/content/docs/reference/configuration.md @@ -333,6 +333,39 @@ never starts or continues model generation. The 30-second network phase ends before validated replacement persistence. Filesystem writes are synchronous and are not claimed cancellable by that timer. +Refresh alone permits at most **two application HTTP send attempts**, separated +by 200 ms, under that same lock and original 30-second network deadline. The +second attempt requires at least 16 seconds remaining after backoff (the existing +15-second connection timeout plus a one-second response reserve). It is allowed +only when the pinned Reqwest/Hyper error graph proves the OAuth POST was not sent +and its first underlying I/O error is `ConnectionRefused` or `TimedOut`. The +complete, bounded graph includes wrapped I/O causes; TLS verification/protocol +errors, proxy authentication failures, unknown DNS errors and other unallowlisted +causes are not retried. Reqwest's private connector-deadline error is also not on +this I/O allowlist. A transport I/O timeout during TLS can qualify; there is no +blanket timeout or TLS-phase retry rule. Redirects and Reqwest's own retry policy +remain disabled. Hyper can internally reconnect an **unstarted** pooled request, +so two send attempts do not imply a maximum of two socket connections. + +If every failed attempt is proven pre-send, credentials are retained without +claiming an unknown rotation. A 30-second cooldown, scoped to the same profile, +lock epoch and refresh token, prevents further foreground refreshes in this +manager; after it expires, the next foreground request may start another bounded +flight. No background retry is scheduled. Metadata-only edits do not reset the +cooldown; a changed scope or successful persistence clears it. This bound is +manager-local, not cross-process or crash-durable. Safe connection failures, +cooldown and shutdown before a retry return a fixed, redacted HTTP 503 `api_error` +without `Retry-After`, before streaming or nonstreaming generation headers. A +shutdown detected before retry prevents the second send. + +An unresolved send, consumed request followed by connection loss, response/body +failure, or timeout while a request might have been sent remains ambiguous and +is **not retried**, including after a safe first failure. Those errors retain the +existing HTTP 401 `authentication_error` and obsolete-token protection. No model +generation or authorization-code exchange retries are added. These are this +proxy's source-audited guarantees, not a claim about the official `agy` client's +internal retry count or backoff. + If persistence fails, the manager retains the replacement in memory, scoped to the profile, lock epoch and superseded refresh token, and reports a typed pending- persistence error. Subsequent requests retry the local write, not the obsolete diff --git a/src/providers/antigravity/config.rs b/src/providers/antigravity/config.rs index e9c19ad4..0e62885e 100644 --- a/src/providers/antigravity/config.rs +++ b/src/providers/antigravity/config.rs @@ -5,6 +5,8 @@ use serde_json::{Value, json}; use std::{ffi::OsString, path::Path}; use url::Url; +pub(super) const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); + #[derive(Clone)] pub(crate) struct Config { pub enabled: bool, @@ -166,7 +168,7 @@ impl Config { reqwest::Client::builder() .retry(reqwest::retry::never()) .redirect(reqwest::redirect::Policy::none()) - .connect_timeout(std::time::Duration::from_secs(15)) + .connect_timeout(CONNECT_TIMEOUT) .timeout(std::time::Duration::from_secs(180)) .user_agent(default_user_agent()) } diff --git a/src/providers/antigravity/mod.rs b/src/providers/antigravity/mod.rs index 4b4b8de3..9bab261b 100644 --- a/src/providers/antigravity/mod.rs +++ b/src/providers/antigravity/mod.rs @@ -69,6 +69,9 @@ impl AntigravityProvider { Err(error) => { return match error { auth::AccessError::PendingPersistence + | auth::AccessError::ConnectFailure + | auth::AccessError::ConnectCooldown + | auth::AccessError::StoppedBeforeSend | auth::AccessError::Lock(_) | auth::AccessError::Closed => json_error( StatusCode::SERVICE_UNAVAILABLE, diff --git a/src/providers/antigravity/refresh.rs b/src/providers/antigravity/refresh.rs index 20777b9b..c8600761 100644 --- a/src/providers/antigravity/refresh.rs +++ b/src/providers/antigravity/refresh.rs @@ -4,16 +4,134 @@ //! outside that timer, with no cancellation point in the filesystem phase. use super::{ auth::{Credential, Lock, Store, bounded_json, now, update_tokens}, - config::Config, + config::{CONNECT_TIMEOUT, Config}, }; use std::{ + error::Error as StdError, + io, sync::{ Arc, Mutex, atomic::{AtomicUsize, Ordering}, }, time::Duration, }; -use tokio::{sync::watch, task::JoinHandle}; +use tokio::{sync::watch, task::JoinHandle, time::Instant}; + +const RETRY_BACKOFF: Duration = Duration::from_millis(200); +const CONNECT_COOLDOWN: Duration = Duration::from_secs(30); +const RETRY_RESPONSE_RESERVE: Duration = Duration::from_secs(1); +const MAX_ERROR_NODES: usize = 16; + +type ErrorRef<'a> = &'a (dyn StdError + 'static); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SendFailure { + Ambiguous, + PreSendTransientRetryable, + PreSendNonRetryableSafe, +} + +// Complete both source() and io.get_ref() paths before granting pre-send proof. +// The latter is essential: io::Error::source can skip its contained error. +// Distinguish an active-path cycle from a shared child in the bounded graph. +fn error_graph<'a>(root: ErrorRef<'a>) -> Option>> { + fn visit<'a>( + error: ErrorRef<'a>, + nodes: &mut Vec>, + active: &mut Vec>, + ) -> Option<()> { + if active.iter().any(|seen| std::ptr::eq(*seen, error)) { + return None; + } + if nodes.iter().any(|seen| std::ptr::eq(*seen, error)) { + return Some(()); + } + if nodes.len() == MAX_ERROR_NODES { + return None; + } + nodes.push(error); + active.push(error); + if let Some(source) = error.source() { + visit(source, nodes, active)?; + } + if let Some(inner) = error + .downcast_ref::() + .and_then(io::Error::get_ref) + { + visit(inner, nodes, active)?; + } + active.pop(); + Some(()) + } + let mut nodes = Vec::with_capacity(MAX_ERROR_NODES); + visit(root, &mut nodes, &mut Vec::with_capacity(MAX_ERROR_NODES))?; + Some(nodes) +} + +fn classify_connect_graph(root: ErrorRef<'_>) -> SendFailure { + let Some(nodes) = error_graph(root) else { + return SendFailure::Ambiguous; + }; + let Some((index, first_hyper)) = nodes.iter().enumerate().find_map(|(index, error)| { + error + .downcast_ref::() + .map(|error| (index, error)) + }) else { + return SendFailure::Ambiguous; + }; + if !first_hyper.is_connect() || nodes[..index].iter().any(|error| error.is::()) { + return SendFailure::Ambiguous; + } + if nodes.iter().any(|error| error.is::()) { + return SendFailure::PreSendNonRetryableSafe; + } + let first_io = nodes[index + 1..] + .iter() + .find_map(|error| error.downcast_ref::()); + if first_io.is_some_and(|error| { + matches!( + error.kind(), + io::ErrorKind::ConnectionRefused | io::ErrorKind::TimedOut + ) + }) { + SendFailure::PreSendTransientRetryable + } else { + SendFailure::PreSendNonRetryableSafe + } +} + +// Pinned reqwest 0.12.28 / hyper-util 0.1.20: Connect is constructed before +// request dispatch, including proxy CONNECT and TLS. This proof is specific to +// our buffered form, disabled redirects and disabled reqwest retry policy. +// Hyper may reconnect an unstarted pooled request internally: two .send() calls +// are not a limit of two sockets. Sent requests are never returned for that retry. +fn classify_send_error(error: &reqwest::Error) -> SendFailure { + let classification = classify_connect_graph(error); + // Only call reqwest's unbounded source walker after a complete acyclic graph. + if classification != SendFailure::Ambiguous && error.is_connect() { + classification + } else { + SendFailure::Ambiguous + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum NetworkPhase { + NotSent, + InFlight, + ProvenPreSend, +} + +fn retry_budget(remaining: Duration, before_backoff: bool) -> bool { + remaining + >= CONNECT_TIMEOUT + + RETRY_RESPONSE_RESERVE + + if before_backoff { + RETRY_BACKOFF + } else { + Duration::ZERO + } +} #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum AccessError { @@ -22,6 +140,9 @@ pub(crate) enum AccessError { Missing, LoginRequired, ClientConfiguration, + ConnectFailure, + ConnectCooldown, + StoppedBeforeSend, Network, NetworkTimeout, InvalidResponse, @@ -39,6 +160,15 @@ impl AccessError { Self::Missing => "Antigravity authentication missing; run antigravity auth login", Self::LoginRequired => "Antigravity login required", Self::ClientConfiguration => "configure CCP_ANTIGRAVITY_CLIENT_ID before refreshing", + Self::ConnectFailure => { + "Antigravity refresh connection failed before token submission; credentials retained" + } + Self::ConnectCooldown => { + "Antigravity refresh connection retry temporarily deferred; credentials retained" + } + Self::StoppedBeforeSend => { + "Antigravity refresh stopped before token submission; credentials retained" + } Self::Network => { "Antigravity refresh failed; its outcome may be unknown, no obsolete-token retry attempted" } @@ -96,11 +226,16 @@ struct Flight { waiters: Arc, task: JoinHandle<()>, } +struct Cooldown { + scope: Scope, + until: Instant, +} #[derive(Default)] struct State { flight: Option, pending: Option, uncertain: Option, + cooldown: Option, closed: bool, } struct Waiter(Arc); @@ -119,6 +254,10 @@ pub(super) struct RefreshManager { lock_timeout: Duration, network_timeout: Duration, state: Mutex, + #[cfg(test)] + first_failure_barrier: Option>, + #[cfg(test)] + send_attempts: AtomicUsize, } impl RefreshManager { pub fn new(store: Store, client: reqwest::Client, config: &Config) -> Arc { @@ -131,6 +270,10 @@ impl RefreshManager { lock_timeout: Duration::from_secs(10), network_timeout: Duration::from_secs(30), state: Mutex::new(State::default()), + #[cfg(test)] + first_failure_barrier: None, + #[cfg(test)] + send_attempts: AtomicUsize::new(0), }) } #[cfg(test)] @@ -189,6 +332,7 @@ impl RefreshManager { let mut state = self.state.lock().map_err(|_| AccessError::Closed)?; state.pending = None; state.uncertain = None; + state.cooldown = None; Ok(()) } @@ -291,7 +435,15 @@ impl RefreshManager { }; { let mut state = self.state.lock().map_err(|_| AccessError::Closed)?; - state.uncertain = Some(scope.clone()); + if state.closed { + return Err(AccessError::Closed); + } + if state.cooldown.as_ref().is_some_and(|cooldown| { + cooldown.scope.matches(&scope.epoch, &credential) && Instant::now() < cooldown.until + }) { + return Err(AccessError::ConnectCooldown); + } + state.cooldown = None; } let mut form = vec![ ("grant_type", "refresh_token"), @@ -301,24 +453,101 @@ impl RefreshManager { if let Some(secret) = self.client_secret.as_deref() { form.push(("client_secret", secret)); } - let replacement = tokio::time::timeout(self.network_timeout, async { - let response = self - .client - .post(self.token_url.clone()) - .timeout(self.network_timeout) - .form(&form) - .send() - .await - .map_err(|_| AccessError::Network)?; - let token = bounded_json(response) - .await - .map_err(|_| AccessError::InvalidResponse)?; - let mut replacement = credential.clone(); - update_tokens(&mut replacement, &token).map_err(|_| AccessError::InvalidResponse)?; - Ok::<_, AccessError>(replacement) + let deadline = Instant::now() + self.network_timeout; + let mut phase = NetworkPhase::NotSent; + let result = tokio::time::timeout_at(deadline, async { + for attempt in 0..2 { + { + let mut state = self.state.lock().map_err(|_| AccessError::Closed)?; + if state.closed { + return Err(AccessError::StoppedBeforeSend); + } + if attempt > 0 + && !retry_budget(deadline.saturating_duration_since(Instant::now()), false) + { + return Err(AccessError::ConnectFailure); + } + // No cancellation point between declaring uncertainty and + // starting this attempt. An unresolved send/body stays unknown. + state.uncertain = Some(scope.clone()); + phase = NetworkPhase::InFlight; + } + #[cfg(test)] + self.send_attempts.fetch_add(1, Ordering::SeqCst); + let sent = self + .client + .post(self.token_url.clone()) + .timeout(deadline.saturating_duration_since(Instant::now())) + .form(&form) + .send() + .await; + let response = match sent { + Ok(response) => response, + Err(error) => { + let classification = classify_send_error(&error); + if classification == SendFailure::Ambiguous { + return Err(AccessError::Network); + } + phase = NetworkPhase::ProvenPreSend; + { + let mut state = self.state.lock().map_err(|_| AccessError::Closed)?; + state.uncertain = None; + if state.closed { + return Err(AccessError::StoppedBeforeSend); + } + } + if attempt == 1 || classification == SendFailure::PreSendNonRetryableSafe { + return Err(AccessError::ConnectFailure); + } + #[cfg(test)] + if let Some(barrier) = &self.first_failure_barrier { + barrier.pause().await; + } + if self.state.lock().map_err(|_| AccessError::Closed)?.closed { + return Err(AccessError::StoppedBeforeSend); + } + if !retry_budget(deadline.saturating_duration_since(Instant::now()), true) { + return Err(AccessError::ConnectFailure); + } + tokio::time::sleep(RETRY_BACKOFF).await; + // Loop entry rechecks shutdown and the 16s reserve AFTER + // backoff; scheduler delay cannot renew or steal the budget. + continue; + } + }; + let token = bounded_json(response) + .await + .map_err(|_| AccessError::InvalidResponse)?; + let mut replacement = credential.clone(); + update_tokens(&mut replacement, &token) + .map_err(|_| AccessError::InvalidResponse)?; + return Ok(replacement); + } + Err(AccessError::ConnectFailure) }) - .await - .map_err(|_| AccessError::NetworkTimeout)??; + .await; + let result = match result { + Ok(result) => result, + Err(_) if phase == NetworkPhase::InFlight => Err(AccessError::NetworkTimeout), + Err(_) => Err(AccessError::ConnectFailure), + }; + let replacement = match result { + Err(AccessError::ConnectFailure) => { + let mut state = self.state.lock().map_err(|_| AccessError::Closed)?; + if state.closed { + return Err(AccessError::StoppedBeforeSend); + } + // Only complete pre-send proof (or never starting a send) reaches + // this cooldown. An ambiguous second attempt must never clear or + // hide uncertainty. This rate bound is manager-local, not durable. + state.cooldown = Some(Cooldown { + scope: scope.clone(), + until: Instant::now() + CONNECT_COOLDOWN, + }); + return Err(AccessError::ConnectFailure); + } + other => other?, + }; // The manager's network deadline ended above. A disconnected waiter or // elapsed network timer cannot discard the now validated replacement. self.persist( @@ -371,3 +600,786 @@ impl RefreshManager { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::{error::Error, io, net::SocketAddr}; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + sync::Notify, + }; + + #[derive(Default)] + pub(super) struct RetryBarrier { + entered: Notify, + release: Notify, + } + impl RetryBarrier { + pub(super) async fn pause(&self) { + self.entered.notify_one(); + self.release.notified().await; + } + async fn wait(&self) { + tokio::time::timeout(Duration::from_secs(3), self.entered.notified()) + .await + .expect("first refusal reached retry barrier"); + } + } + + fn credential() -> Credential { + Credential { + profile_id: "synthetic-profile".into(), + access_token: "synthetic-expired-access".into(), + refresh_token: Some("synthetic-refresh".into()), + project_id: Some("synthetic-project".into()), + expires_at: 0, + } + } + + async fn fixture( + url: url::Url, + client: Option, + barrier: Option>, + budget: Duration, + ) -> (tempfile::TempDir, Arc) { + let directory = tempfile::tempdir().unwrap(); + let store = Store::new(directory.path().join("auth.json")); + let lock = store.lock(Duration::from_secs(1)).await.unwrap(); + store.save(&lock, &credential()).unwrap(); + drop(lock); + let config = Config { + token_url: url, + client_id: Some("synthetic-client".into()), + ..Config::default() + }; + let client = client.unwrap_or_else(|| config.client_builder().no_proxy().build().unwrap()); + let mut manager = RefreshManager::new(store, client, &config) + .with_timeouts(Duration::from_secs(1), budget); + Arc::get_mut(&mut manager).unwrap().first_failure_barrier = barrier; + (directory, manager) + } + + async fn closed_address() -> SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + listener.local_addr().unwrap() + } + fn token_url(address: SocketAddr) -> url::Url { + format!("http://{address}/token").parse().unwrap() + } + fn attempts(manager: &RefreshManager) -> usize { + manager.send_attempts.load(Ordering::SeqCst) + } + async fn failure(manager: &Arc) -> AccessError { + manager.access().await.err().expect("expected fixed error") + } + + async fn read_request(stream: &mut S) -> Vec { + let mut raw = Vec::new(); + let header_end = loop { + let mut byte = [0u8]; + stream.read_exact(&mut byte).await.unwrap(); + raw.push(byte[0]); + assert!(raw.len() < 8192); + if raw.ends_with(b"\r\n\r\n") { + break raw.len(); + } + }; + let headers = std::str::from_utf8(&raw).unwrap(); + let length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().unwrap()) + }) + .unwrap_or(0); + assert!(length < 8192); + raw.resize(header_end + length, 0); + stream.read_exact(&mut raw[header_end..]).await.unwrap(); + raw + } + + async fn successful_server(listener: TcpListener, calls: Arc) { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_request(&mut stream).await; + assert!(request.starts_with(b"POST /token HTTP/1.1\r\n")); + assert!( + request + .windows(b"refresh_token=synthetic-refresh".len()) + .any(|part| part == b"refresh_token=synthetic-refresh") + ); + calls.fetch_add(1, Ordering::SeqCst); + let body = r#"{"access_token":"rotated-access","refresh_token":"rotated-refresh","expires_in":3600,"token_type":"Bearer"}"#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).await.unwrap(); + } + + #[tokio::test] + async fn refresh_connect_real_refusal_then_success_coalesces_and_survives_cancelled_waiter() { + let address = closed_address().await; + let client = Config::default() + .client_builder() + .no_proxy() + .build() + .unwrap(); + let error = client + .post(token_url(address)) + .form(&[("refresh_token", "synthetic")]) + .send() + .await + .err() + .unwrap(); + // Positive transport guard: never widen this if dependency wrapping changes. + assert_eq!( + classify_send_error(&error), + SendFailure::PreSendTransientRetryable + ); + let barrier = Arc::new(RetryBarrier::default()); + let (_directory, manager) = fixture( + token_url(address), + Some(client), + Some(barrier.clone()), + Duration::from_secs(30), + ) + .await; + let leader = { + let manager = manager.clone(); + tokio::spawn(async move { manager.access().await }) + }; + barrier.wait().await; + let mut followers = Vec::new(); + for _ in 0..8 { + let manager = manager.clone(); + followers.push(tokio::spawn(async move { manager.access().await })); + } + tokio::time::timeout(Duration::from_secs(3), async { + loop { + if manager + .state + .lock() + .unwrap() + .flight + .as_ref() + .unwrap() + .waiters + .load(Ordering::Acquire) + == 9 + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + leader.abort(); + let _ = leader.await; + let listener = TcpListener::bind(address).await.unwrap(); + let calls = Arc::new(AtomicUsize::new(0)); + let server = tokio::spawn(successful_server(listener, calls.clone())); + barrier.release.notify_one(); + for follower in followers { + let result = follower.await.unwrap().unwrap(); + assert_eq!(result.refresh_token.as_deref(), Some("rotated-refresh")); + } + server.await.unwrap(); + assert_eq!(attempts(&manager), 2); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!( + manager + .store + .load() + .unwrap() + .unwrap() + .refresh_token + .as_deref(), + Some("rotated-refresh") + ); + assert!(manager.state.lock().unwrap().cooldown.is_none()); + manager.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn refresh_connect_two_refusals_cooldown_expiry_and_scope_rules() { + let (_directory, manager) = fixture( + token_url(closed_address().await), + None, + None, + Duration::from_secs(30), + ) + .await; + assert_eq!(failure(&manager).await, AccessError::ConnectFailure); + assert_eq!(attempts(&manager), 2); + for _ in 0..8 { + assert_eq!(failure(&manager).await, AccessError::ConnectCooldown); + } + assert_eq!(attempts(&manager), 2); + assert!(manager.state.lock().unwrap().uncertain.is_none()); + let mut same_scope = manager.store.load().unwrap().unwrap(); + same_scope.expires_at = 1; + same_scope.access_token = "incidental-access".into(); + same_scope.project_id = Some("incidental-project".into()); + let lock = manager.store.lock(Duration::from_secs(1)).await.unwrap(); + manager.store.save(&lock, &same_scope).unwrap(); + drop(lock); + assert_eq!(failure(&manager).await, AccessError::ConnectCooldown); + // Expire only cooldown, without pausing Tokio's real-socket clock. + manager + .state + .lock() + .unwrap() + .cooldown + .as_mut() + .unwrap() + .until = tokio::time::Instant::now(); + assert_eq!(failure(&manager).await, AccessError::ConnectFailure); + assert_eq!(attempts(&manager), 4); + for change in 0..3 { + let mut lock = manager.store.lock(Duration::from_secs(1)).await.unwrap(); + match change { + 0 => { + lock.advance().unwrap(); + } + 1 => { + same_scope.profile_id = "new-profile".into(); + } + _ => { + same_scope.refresh_token = Some("new-refresh".into()); + } + } + manager.store.save(&lock, &same_scope).unwrap(); + drop(lock); + assert_eq!(failure(&manager).await, AccessError::ConnectFailure); + assert_eq!(attempts(&manager), 6 + change * 2); + } + manager.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn refresh_connect_insufficient_budget_and_shutdown_before_retry_are_safe() { + let (_directory, manager) = fixture( + token_url(closed_address().await), + None, + None, + Duration::from_secs(2), + ) + .await; + assert_eq!(failure(&manager).await, AccessError::ConnectFailure); + assert_eq!(attempts(&manager), 1); + assert!(manager.state.lock().unwrap().uncertain.is_none()); + manager.shutdown().await.unwrap(); + + let barrier = Arc::new(RetryBarrier::default()); + let (_directory, manager) = fixture( + token_url(closed_address().await), + None, + Some(barrier.clone()), + Duration::from_secs(30), + ) + .await; + let waiter = { + let manager = manager.clone(); + tokio::spawn(async move { manager.access().await }) + }; + barrier.wait().await; + let closing = { + let manager = manager.clone(); + tokio::spawn(async move { manager.shutdown().await }) + }; + tokio::time::timeout(Duration::from_secs(3), async { + while !manager.state.lock().unwrap().closed { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + barrier.release.notify_one(); + assert_eq!( + waiter.await.unwrap().err(), + Some(AccessError::StoppedBeforeSend) + ); + closing.await.unwrap().unwrap(); + assert_eq!(attempts(&manager), 1); + assert!(manager.state.lock().unwrap().uncertain.is_none()); + assert_eq!(failure(&manager).await, AccessError::Closed); + } + + #[tokio::test] + async fn refresh_connect_deadline_during_proven_presend_pause_is_not_uncertain() { + let barrier = Arc::new(RetryBarrier::default()); + let (_directory, manager) = fixture( + token_url(closed_address().await), + None, + Some(barrier.clone()), + Duration::from_millis(150), + ) + .await; + let waiter = { + let manager = manager.clone(); + tokio::spawn(async move { manager.access().await }) + }; + barrier.wait().await; + assert_eq!( + waiter.await.unwrap().err(), + Some(AccessError::ConnectFailure) + ); + assert_eq!(attempts(&manager), 1); + assert!(manager.state.lock().unwrap().uncertain.is_none()); + manager.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn refresh_connect_sent_body_drop_partial_response_timeout_and_status_never_retry() { + for mode in 0..4 { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = token_url(listener.local_addr().unwrap()); + let received = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let server = { + let received = received.clone(); + let release = release.clone(); + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_request(&mut stream).await; + assert!(request.starts_with(b"POST ")); + received.notify_one(); + match mode { + 1 => { + stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 1000\r\n\r\n{\"access_token\":").await.unwrap(); + } + 2 => { + release.notified().await; + } + 3 => { + stream + .write_all(b"HTTP/1.1 503 Unavailable\r\nContent-Length: 0\r\n\r\n") + .await + .unwrap(); + } + _ => {} + } + }) + }; + let (_directory, manager) = fixture(url, None, None, Duration::from_millis(500)).await; + let result = failure(&manager).await; + assert!(matches!( + result, + AccessError::Network | AccessError::NetworkTimeout | AccessError::InvalidResponse + )); + received.notified().await; + assert_eq!(attempts(&manager), 1); + assert_eq!(failure(&manager).await, AccessError::UncertainRotation); + assert!(manager.state.lock().unwrap().cooldown.is_none()); + release.notify_one(); + server.await.unwrap(); + assert_eq!( + manager.shutdown().await, + Err(AccessError::UncertainRotation) + ); + } + } + + #[tokio::test] + async fn refresh_connect_safe_first_then_ambiguous_second_never_reuses_token() { + let address = closed_address().await; + let barrier = Arc::new(RetryBarrier::default()); + let (_directory, manager) = fixture( + token_url(address), + None, + Some(barrier.clone()), + Duration::from_secs(30), + ) + .await; + let waiter = { + let manager = manager.clone(); + tokio::spawn(async move { manager.access().await }) + }; + barrier.wait().await; + let listener = TcpListener::bind(address).await.unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + assert!(read_request(&mut stream).await.starts_with(b"POST ")); + }); + barrier.release.notify_one(); + assert_eq!(waiter.await.unwrap().err(), Some(AccessError::Network)); + server.await.unwrap(); + assert_eq!(attempts(&manager), 2); + assert_eq!(failure(&manager).await, AccessError::UncertainRotation); + assert_eq!(attempts(&manager), 2); + assert!(manager.state.lock().unwrap().cooldown.is_none()); + } + + #[tokio::test] + async fn refresh_connect_shutdown_during_second_send_joins_and_commits() { + let address = closed_address().await; + let barrier = Arc::new(RetryBarrier::default()); + let (_directory, manager) = fixture( + token_url(address), + None, + Some(barrier.clone()), + Duration::from_secs(30), + ) + .await; + let waiter = { + let manager = manager.clone(); + tokio::spawn(async move { manager.access().await }) + }; + barrier.wait().await; + let listener = TcpListener::bind(address).await.unwrap(); + let issued = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let server = { + let issued = issued.clone(); + let release = release.clone(); + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + assert!(read_request(&mut stream).await.starts_with(b"POST ")); + issued.notify_one(); + release.notified().await; + let body = r#"{"access_token":"rotated-access","refresh_token":"rotated-refresh","expires_in":3600}"#; + stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap(); + }) + }; + barrier.release.notify_one(); + tokio::time::timeout(Duration::from_secs(3), issued.notified()) + .await + .unwrap(); + waiter.abort(); + let _ = waiter.await; + let closing = { + let manager = manager.clone(); + tokio::spawn(async move { manager.shutdown().await }) + }; + tokio::time::timeout(Duration::from_secs(3), async { + while !manager.state.lock().unwrap().closed { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert!(!closing.is_finished()); + release.notify_one(); + closing.await.unwrap().unwrap(); + server.await.unwrap(); + assert_eq!(attempts(&manager), 2); + assert_eq!( + manager + .store + .load() + .unwrap() + .unwrap() + .refresh_token + .as_deref(), + Some("rotated-refresh") + ); + assert!(manager.state.lock().unwrap().uncertain.is_none()); + } + + #[derive(Debug)] + struct UnknownDns; + impl reqwest::dns::Resolve for UnknownDns { + fn resolve(&self, _: reqwest::dns::Name) -> reqwest::dns::Resolving { + Box::pin(async { + Err(Box::new(io::Error::other("synthetic-private-DNS")) + as Box) + }) + } + } + + #[tokio::test] + async fn refresh_connect_private_connector_timeout_and_unknown_dns_are_not_retried() { + let client = Config::default() + .client_builder() + .no_proxy() + .dns_resolver(Arc::new(UnknownDns)) + .build() + .unwrap(); + let (_directory, manager) = fixture( + "https://synthetic.invalid/token".parse().unwrap(), + Some(client), + None, + Duration::from_secs(30), + ) + .await; + assert_eq!(failure(&manager).await, AccessError::ConnectFailure); + assert_eq!(attempts(&manager), 1); + assert_eq!(failure(&manager).await, AccessError::ConnectCooldown); + manager.shutdown().await.unwrap(); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy = format!("http://{}", listener.local_addr().unwrap()); + let release = Arc::new(Notify::new()); + let server = { + let release = release.clone(); + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + assert!(read_request(&mut stream).await.starts_with(b"CONNECT ")); + release.notified().await; + }) + }; + // The shortened connector deadline is a test client setting only. It + // yields reqwest's private TimedOut, NOT an allowlisted io::TimedOut. + let client = Config::default() + .client_builder() + .no_proxy() + .proxy(reqwest::Proxy::https(proxy).unwrap()) + .connect_timeout(Duration::from_millis(500)) + .build() + .unwrap(); + let (_directory, manager) = fixture( + "https://localhost/token".parse().unwrap(), + Some(client), + None, + Duration::from_secs(30), + ) + .await; + assert_eq!(failure(&manager).await, AccessError::ConnectFailure); + assert_eq!(attempts(&manager), 1); + assert_eq!(failure(&manager).await, AccessError::ConnectCooldown); + release.notify_one(); + server.await.unwrap(); + manager.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn refresh_connect_nested_connect_under_send_request_is_ambiguous() { + let nested = hyper_connect_error(io::Error::from(io::ErrorKind::ConnectionRefused)).await; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = token_url(listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut bytes = Vec::new(); + let _ = + tokio::time::timeout(Duration::from_secs(2), stream.read_to_end(&mut bytes)).await; + }); + let body = + reqwest::Body::wrap_stream(futures_util::stream::iter([Err::( + nested, + )])); + let error = Config::default() + .client_builder() + .no_proxy() + .build() + .unwrap() + .post(url) + .body(body) + .send() + .await + .err() + .unwrap(); + assert!( + error.is_connect(), + "test must exercise incidental nested Connect" + ); + assert_eq!(classify_send_error(&error), SendFailure::Ambiguous); + server.await.unwrap(); + } + + // Public synthetic localhost fixtures generated offline for these tests only. + // This key is NOT a production credential. CA/leaf validity ends in 2046; + // only this test client's additional root trusts the CA, never production. + const TEST_CA: &str = "MIIBezCCASGgAwIBAgIUGLhYg9q/Al/VHS8Zw/knkCtNSYwwCgYIKoZIzj0EAwIwKzEpMCcGA1UEAwwgUHVibGljIHN5bnRoZXRpYyByZWZyZXNoIHRlc3QgQ0EwHhcNMjYxMDIxMTA1NzM5WhcNNDYxMDE2MTA1NzM5WjArMSkwJwYDVQQDDCBQdWJsaWMgc3ludGhldGljIHJlZnJlc2ggdGVzdCBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABKPFbXyxtoHecqg9QFPmnUaRwqMlr51ZGT7lWJrjU6wdiAm9QgN6bhod1c6l3jgBhd9f7tSV5aGPCrAF5fTIyH2jIzAhMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMCA0gAMEUCIQCNSOmNAj8vypFDV9QCxq0MKvqkp0vQ9DuGerTmk3ltwwIgTVWj2Zw4fWKWVsttU3GMe2L6s8+DtYcDgsfQmKlU8MI="; + const TEST_CERT: &str = "MIIBkjCCATigAwIBAgIUbFZU4Ue3etDnol7vW2OIhfdiZxowCgYIKoZIzj0EAwIwKzEpMCcGA1UEAwwgUHVibGljIHN5bnRoZXRpYyByZWZyZXNoIHRlc3QgQ0EwHhcNMjYxMDIxMTA1NzM5WhcNNDYxMDE2MTA1NzM5WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQPJVIZ/jqVeUIxkgg7aKln+59Edhs2wSlDuR8VfCgrD3Vw30c6XzGO6ztD29559ZoSCF6RQIYqPLQfNcY/pIOUo1EwTzAaBgNVHREEEzARgglsb2NhbGhvc3SHBH8AAAEwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwEwCgYIKoZIzj0EAwIDSAAwRQIhAK+Bm7mGxkegBKkFnOCn3fsQP405jmiH5rQ7kkmvamiQAiBRIjQG9siBjZLJk8eidWK49EkXb8fYXJtzZn5vnAUdGg=="; + const TEST_KEY: &str = "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgrHg+Obl2CHfYNy0VWcSYpRdGNktCYRTDWsnD/6tegyChRANCAAQPJVIZ/jqVeUIxkgg7aKln+59Edhs2wSlDuR8VfCgrD3Vw30c6XzGO6ztD29559ZoSCF6RQIYqPLQfNcY/pIOU"; + fn der(value: &str) -> Vec { + use base64::Engine; + base64::engine::general_purpose::STANDARD + .decode(value) + .unwrap() + } + fn tls_acceptor() -> tokio_rustls::TlsAcceptor { + let config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert( + vec![rustls::pki_types::CertificateDer::from(der(TEST_CERT))], + rustls::pki_types::PrivatePkcs8KeyDer::from(der(TEST_KEY)).into(), + ) + .unwrap(); + tokio_rustls::TlsAcceptor::from(Arc::new(config)) + } + + #[tokio::test] + async fn refresh_connect_proxy_auth_tls_certificate_and_tunneled_post_drop() { + // 407, bad certificate, then trusted tunnel which consumes the OAuth body. + for mode in 0..3 { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy = format!("http://{}", listener.local_addr().unwrap()); + let bodies = Arc::new(AtomicUsize::new(0)); + let seen = bodies.clone(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let connect = read_request(&mut stream).await; + assert!(connect.starts_with(b"CONNECT localhost:443 HTTP/1.1\r\n")); + assert!( + !connect + .windows(b"synthetic-refresh".len()) + .any(|p| p == b"synthetic-refresh") + ); + if mode == 0 { + stream.write_all(b"HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n").await.unwrap(); + return; + } + stream + .write_all(b"HTTP/1.1 200 Connection established\r\n\r\n") + .await + .unwrap(); + match tls_acceptor().accept(stream).await { + Ok(mut tls) => { + assert_eq!(mode, 2, "untrusted certificate unexpectedly accepted"); + let request = read_request(&mut tls).await; + assert!(request.starts_with(b"POST /token HTTP/1.1\r\n")); + seen.fetch_add(1, Ordering::SeqCst); + // Full OAuth body received; drop without a response. + } + Err(_) => { + assert_eq!(mode, 1); + } + } + }); + let mut builder = Config::default() + .client_builder() + .no_proxy() + .proxy(reqwest::Proxy::https(proxy).unwrap()); + if mode == 2 { + builder = builder + .add_root_certificate(reqwest::Certificate::from_der(&der(TEST_CA)).unwrap()); + } + let (_directory, manager) = fixture( + "https://localhost/token".parse().unwrap(), + Some(builder.build().unwrap()), + None, + Duration::from_secs(30), + ) + .await; + let result = failure(&manager).await; + server.await.unwrap(); + assert_eq!(attempts(&manager), 1); + if mode == 2 { + assert_eq!(result, AccessError::Network); + assert_eq!(bodies.load(Ordering::SeqCst), 1); + assert_eq!(failure(&manager).await, AccessError::UncertainRotation); + } else { + assert_eq!(result, AccessError::ConnectFailure); + assert_eq!(bodies.load(Ordering::SeqCst), 0); + assert_eq!(failure(&manager).await, AccessError::ConnectCooldown); + assert!(manager.state.lock().unwrap().uncertain.is_none()); + manager.shutdown().await.unwrap(); + } + } + } + + #[derive(Debug)] + struct Cause(Option>); + impl std::fmt::Display for Cause { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("synthetic-private-error") + } + } + impl Error for Cause { + fn source(&self) -> Option<&(dyn Error + 'static)> { + self.0.as_deref().map(|e| e as _) + } + } + #[derive(Debug)] + struct Cycle; + impl std::fmt::Display for Cycle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("synthetic-cycle") + } + } + impl Error for Cycle { + fn source(&self) -> Option<&(dyn Error + 'static)> { + Some(self) + } + } + + async fn hyper_connect_error(error: io::Error) -> hyper_util::client::legacy::Error { + let error = Arc::new(Mutex::new(Some(error))); + let connector = tower::service_fn(move |_: http::Uri| { + let error = error.lock().unwrap().take().unwrap(); + std::future::ready(Err::, _>( + error, + )) + }); + let client: hyper_util::client::legacy::Client<_, http_body_util::Empty> = + hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new()) + .build(connector); + client + .get("http://localhost/".parse().unwrap()) + .await + .err() + .unwrap() + } + + #[test] + fn refresh_connect_retry_budget_reserves_full_connect_window_after_backoff() { + assert_eq!(CONNECT_TIMEOUT, Duration::from_secs(15)); + assert!(!retry_budget(Duration::from_millis(16_199), true)); + assert!(retry_budget(Duration::from_millis(16_200), true)); + assert!(!retry_budget(Duration::from_millis(15_999), false)); + assert!(retry_budget(Duration::from_secs(16), false)); + for error in [ + AccessError::ConnectFailure, + AccessError::ConnectCooldown, + AccessError::StoppedBeforeSend, + ] { + assert!(!format!("{error} {error:?}").contains("synthetic-private")); + } + } + + #[tokio::test] + async fn refresh_connect_classifier_complete_graph_tls_deny_and_outer_io() { + for kind in [io::ErrorKind::ConnectionRefused, io::ErrorKind::TimedOut] { + let error = hyper_connect_error(io::Error::from(kind)).await; + assert_eq!( + classify_connect_graph(&error), + SendFailure::PreSendTransientRetryable + ); + } + let error = + hyper_connect_error(io::Error::other(io::Error::from(io::ErrorKind::TimedOut))).await; + assert_eq!( + classify_connect_graph(&error), + SendFailure::PreSendNonRetryableSafe + ); + let error = hyper_connect_error(io::Error::new( + io::ErrorKind::TimedOut, + rustls::Error::General("synthetic-private-TLS-error".into()), + )) + .await; + assert_eq!( + classify_connect_graph(&error), + SendFailure::PreSendNonRetryableSafe + ); + let error = io::Error::other( + hyper_connect_error(io::Error::from(io::ErrorKind::ConnectionRefused)).await, + ); + assert_eq!(classify_connect_graph(&error), SendFailure::Ambiguous); + assert_eq!(classify_connect_graph(&Cycle), SendFailure::Ambiguous); + let mut error: Box = + Box::new(hyper_connect_error(io::Error::from(io::ErrorKind::ConnectionRefused)).await); + // Hyper + IO consumes two of the 16 distinct-node allowance. + for _ in 0..14 { + error = Box::new(Cause(Some(error))); + } + assert_eq!( + classify_connect_graph(&*error), + SendFailure::PreSendTransientRetryable + ); + error = Box::new(Cause(Some(error))); + assert_eq!(classify_connect_graph(&*error), SendFailure::Ambiguous); + // IO.source and IO.get_ref reconverge on the same inner source. It must + // not be mistaken for a cycle or consume another distinct-node slot. + let inner = Cause(Some(Box::new(io::Error::from(io::ErrorKind::TimedOut)))); + let error = hyper_connect_error(io::Error::new(io::ErrorKind::TimedOut, inner)).await; + assert_eq!( + classify_connect_graph(&error), + SendFailure::PreSendTransientRetryable + ); + } +} diff --git a/src/providers/antigravity_auth_tests.rs b/src/providers/antigravity_auth_tests.rs index c2e9f973..2adb794c 100644 --- a/src/providers/antigravity_auth_tests.rs +++ b/src/providers/antigravity_auth_tests.rs @@ -445,6 +445,122 @@ async fn antigravity_logout_after_cancelled_refresh_waits_then_clears_without_re assert_eq!(generations.load(Ordering::SeqCst), 0); } +#[tokio::test] +async fn antigravity_refresh_connect_failure_retains_session_and_cools_down() { + let directory = tempfile::tempdir().unwrap(); + let store = Store::new(directory.path().join("auth.json")); + saved(&store, &expired()).await; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + drop(listener); + let config = Config { + token_url: format!("http://{address}/token").parse().unwrap(), + client_id: Some("synthetic-client".into()), + ..Config::default() + }; + // A short budget cannot accommodate the second connection's 16s reserve. + let auth = Auth::new(config, store) + .unwrap() + .with_refresh_timeouts(Duration::from_secs(1), Duration::from_secs(2)); + let error = auth.access().await.err().expect("closed port must fail"); + assert_eq!( + error.to_string(), + "Antigravity refresh connection failed before token submission; credentials retained" + ); + assert_eq!( + auth.access() + .await + .err() + .expect("cooldown must fail") + .to_string(), + "Antigravity refresh connection retry temporarily deferred; credentials retained" + ); + assert_eq!( + auth.store.load().unwrap().unwrap().refresh_token, + expired().refresh_token + ); + auth.finish_maintenance().await.unwrap(); +} + +#[tokio::test] +async fn antigravity_refresh_connect_http_errors_are_redacted_503_before_stream_headers() { + let directory = tempfile::tempdir().unwrap(); + let store = Store::new(directory.path().join("auth.json")); + saved(&store, &expired()).await; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + drop(listener); + let generations = Arc::new(AtomicUsize::new(0)); + let counter = generations.clone(); + let upstream = Server::start(Router::new().fallback(move || { + let counter = counter.clone(); + async move { + counter.fetch_add(1, Ordering::SeqCst); + "must not generate" + } + })) + .await; + let auth = Auth::new( + Config { + base_url: upstream.url.clone(), + token_url: format!("http://{address}/private-token-url") + .parse() + .unwrap(), + client_id: Some("private-client-id".into()), + client_secret: Some("private-client-secret".into()), + ..Config::default() + }, + store.clone(), + ) + .unwrap() + .with_refresh_timeouts(Duration::from_secs(1), Duration::from_secs(2)); + let registry = crate::registry::Registry::from_providers( + crate::config::AliasProvider::Codex, + [ + Arc::new(super::antigravity::AntigravityProvider::with_auth(auth)) + as Arc, + ], + ); + let proxy = Server::start(crate::server::app_with_features( + Arc::new(registry), + None, + crate::server::AppFeatures::default(), + )) + .await; + for stream in [false, true] { + let response = reqwest::Client::builder().no_proxy().build().unwrap() + .post(proxy.url.join("/v1/messages").unwrap()) + .json(&json!({"model":"antigravity/gemini-4-argon-low", "max_tokens":32, "stream":stream, "messages":[{"role":"user", "content":"hello"}]})) + .send().await.unwrap(); + assert_eq!(response.status(), 503); + assert!(!response.headers().contains_key("retry-after")); + assert!( + response.headers()["content-type"] + .to_str() + .unwrap() + .starts_with("application/json") + ); + let error: Value = response.json().await.unwrap(); + assert_eq!(error["error"]["type"], "api_error"); + let rendered = error.to_string(); + for sentinel in [ + "private-token-url", + "private-client-id", + "private-client-secret", + "original-refresh", + "expired-access", + "127.0.0.1", + ] { + assert!(!rendered.contains(sentinel)); + } + } + assert_eq!(generations.load(Ordering::SeqCst), 0); + assert_eq!( + store.load().unwrap().unwrap().refresh_token, + expired().refresh_token + ); +} + #[tokio::test] async fn antigravity_refresh_is_single_flight_and_preserves_profile_and_rotation() { for rotate in [false, true] { From 2cc5abf94bf16fbbb79d5eb115bf342997f8d1d8 Mon Sep 17 00:00:00 2001 From: Chris Liu <11517021+chris7iu@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:31:24 +0000 Subject: [PATCH 07/30] fix(antigravity): default consumer OAuth to the daily endpoint Use the consumer Cloud Code origin for discovery and generation while preserving explicit endpoint overrides and fixed OAuth URLs. Add selection regressions and lossless non-streaming client-tool replay coverage against synthetic upstream SSE. Co-Authored-By: Claude Code --- .../compatibility-and-limitations.md | 21 ++ .../content/docs/reference/configuration.md | 10 +- src/providers/antigravity/config.rs | 94 ++++++- src/providers/antigravity_http_tests.rs | 252 ++++++++++++++++++ 4 files changed, 375 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/reference/compatibility-and-limitations.md b/docs/src/content/docs/reference/compatibility-and-limitations.md index 5d2c8030..0ac23d89 100644 --- a/docs/src/content/docs/reference/compatibility-and-limitations.md +++ b/docs/src/content/docs/reference/compatibility-and-limitations.md @@ -266,6 +266,27 @@ copies. Overrides into Antigravity receive the same strict original-byte checks, including duplicate/per-message fields, raw context hints and every beta header. Cross-provider overrides out of explicit Antigravity routes remain blocked. +### Consumer endpoint evidence + +The 2026-10-23–24 investigation observed an `agy` 1.4.6 consumer request using +`https://daily-cloudcode-pa.googleapis.com`; static analysis also found that its +consumer endpoint selector chooses daily. A controlled native comparison held the +profile, model and request settings fixed and changed only the native API origin: +production returned HTTP 429, daily returned HTTP 200 with a complete Anthropic +text SSE turn, expected marker and valid usage, then production returned HTTP 429 +again. This supports the daily default for this adapter's supported consumer OAuth +flow in that profile/time window, not a claim that every account or Google Cloud +tier must use daily. HTTP 429 alone does not establish exhausted subscription quota. + +Both project discovery and generation use the selected native origin. Explicit +`CCP_ANTIGRAVITY_BASE_URL` / `antigravity.baseUrl` overrides remain authoritative, +including production and custom trusted HTTPS origins. GCP/ADC/service-account +flows and account-tier endpoint selection are not implemented. No automatic +endpoint fallback, hostname rewrite, OAuth endpoint change or replay-format change +is introduced. Synthetic tests cover the complete nonstream assistant-content +carrier and tool-result round trip; neither those tests nor the live low-tier text +result establish all-tier or live tool/replay acceptance. + ### Evidence and remaining gates Synthetic Rust HTTP/OAuth tests and an isolated real-client replay fixture do not diff --git a/docs/src/content/docs/reference/configuration.md b/docs/src/content/docs/reference/configuration.md index 8bfc8bb3..683bf97f 100644 --- a/docs/src/content/docs/reference/configuration.md +++ b/docs/src/content/docs/reference/configuration.md @@ -168,9 +168,17 @@ separate real-account OAuth/native smoke validation. | `CCP_ANTIGRAVITY_CLIENT_ID` | `antigravity.clientId` | unset | Your public installed-app OAuth client ID; required for login and refresh. | | `CCP_ANTIGRAVITY_CLIENT_SECRET` | `antigravity.clientSecret` | unset | Optional public-client application secret, when required by that registered application. Not a user bearer token. | | `CCP_ANTIGRAVITY_PROJECT_ID` | `antigravity.projectId` | stored discovery result | Your authorized project; no borrowed project or automatic onboarding. | -| `CCP_ANTIGRAVITY_BASE_URL` | `antigravity.baseUrl` | `https://cloudcode-pa.googleapis.com` | Explicit trusted HTTPS origin override. No userinfo, query, fragment, or path. Never automatically falls back to another endpoint. | +| `CCP_ANTIGRAVITY_BASE_URL` | `antigravity.baseUrl` | `https://daily-cloudcode-pa.googleapis.com` | Consumer OAuth native API origin. Explicit trusted HTTPS origin override; no userinfo, query, fragment, or path. Never automatically falls back to another endpoint. | | `CCP_ANTIGRAVITY_USER_AGENT` | `antigravity.userAgent` | `claude-code-proxy/` | Full HTTP User-Agent override for native generation and `loadCodeAssist` only. OAuth token exchange/refresh always retain the default proxy identity. | +The built-in daily origin serves both `loadCodeAssist` project discovery and +native generation for this adapter's supported consumer OAuth flow. Base URL +precedence remains environment, config file, then default. Explicit production or +custom trusted HTTPS origins remain unchanged; there is no hostname-based rewrite. +The adapter does not implement GCP/ADC/service-account authentication or account-tier +endpoint selection. This default is not a general Google Cloud routing policy; see +[endpoint evidence](/reference/compatibility-and-limitations/#consumer-endpoint-evidence). + Remote endpoints require HTTPS, redirects are never followed, and OAuth endpoints are fixed to `https://accounts.google.com/o/oauth2/v2/auth` and `https://oauth2.googleapis.com/token`. Plain loopback HTTP endpoints exist only as diff --git a/src/providers/antigravity/config.rs b/src/providers/antigravity/config.rs index 0e62885e..9a562333 100644 --- a/src/providers/antigravity/config.rs +++ b/src/providers/antigravity/config.rs @@ -41,7 +41,7 @@ impl Default for Config { fn default() -> Self { Self { enabled: false, - base_url: Url::parse("https://cloudcode-pa.googleapis.com").unwrap(), + base_url: Url::parse("https://daily-cloudcode-pa.googleapis.com").unwrap(), token_url: Url::parse("https://oauth2.googleapis.com/token").unwrap(), authorization_url: Url::parse("https://accounts.google.com/o/oauth2/v2/auth").unwrap(), client_id: None, @@ -184,6 +184,98 @@ impl Config { mod tests { use super::*; + #[test] + fn antigravity_consumer_default_origin_preserves_oauth_and_identity() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.json"); + let mut configurations = vec![Config::default(), Config::read(&path, |_| None).unwrap()]; + std::fs::write(&path, br#"{"antigravity":{}}"#).unwrap(); + configurations.push(Config::read(&path, |_| None).unwrap()); + for config in configurations { + assert_eq!( + config.base_url.as_str(), + "https://daily-cloudcode-pa.googleapis.com/" + ); + assert_eq!( + config.token_url.as_str(), + "https://oauth2.googleapis.com/token" + ); + assert_eq!( + config.authorization_url.as_str(), + "https://accounts.google.com/o/oauth2/v2/auth" + ); + assert_eq!(config.native_headers()["user-agent"], default_user_agent()); + assert!(!config.enabled); + } + } + + #[test] + fn antigravity_consumer_base_url_precedence_preserves_explicit_origins() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.json"); + let daily = "https://daily-cloudcode-pa.googleapis.com"; + let production = "https://cloudcode-pa.googleapis.com"; + let custom = "https://cloudcode-pa.googleapis.com.example.invalid:8443"; + for (file, environment, expected) in [ + (None, None, daily), + (Some(production), None, production), + (Some(custom), None, custom), + (Some(daily), Some(production), production), + (Some(production), Some(custom), custom), + (Some(custom), Some(daily), daily), + ] { + let mut section = json!({}); + if let Some(origin) = file { + section["baseUrl"] = json!(origin); + } + std::fs::write(&path, json!({"antigravity":section}).to_string()).unwrap(); + let config = Config::read(&path, |key| { + if key == "CCP_ANTIGRAVITY_BASE_URL" { + environment.map(OsString::from) + } else { + None + } + }) + .unwrap(); + assert_eq!(config.base_url, Url::parse(expected).unwrap()); + } + } + + #[test] + fn antigravity_consumer_invalid_selected_origin_never_falls_back() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.json"); + for invalid in [ + "", + "http://example.invalid", + "http://127.0.0.1:1234", + "https://user:secret@example.invalid", + "https://example.invalid/native", + "https://example.invalid/?query=value", + "https://example.invalid/#fragment", + ] { + std::fs::write( + &path, + br#"{"antigravity":{"baseUrl":"https://cloudcode-pa.googleapis.com"}}"#, + ) + .unwrap(); + assert!( + Config::read(&path, |key| { + (key == "CCP_ANTIGRAVITY_BASE_URL").then(|| invalid.into()) + }) + .is_err() + ); + std::fs::write( + &path, + json!({"antigravity":{"baseUrl":invalid}}).to_string(), + ) + .unwrap(); + assert!(Config::read(&path, |_| None).is_err()); + } + std::fs::write(&path, br#"{"antigravity":{"authMethod":"gcp"}}"#).unwrap(); + assert!(Config::read(&path, |_| None).is_err()); + } + #[test] fn antigravity_user_agent_file_setting_is_accepted_without_enabling_provider() { let directory = tempfile::tempdir().unwrap(); diff --git a/src/providers/antigravity_http_tests.rs b/src/providers/antigravity_http_tests.rs index 282a53ba..f9e438b6 100644 --- a/src/providers/antigravity_http_tests.rs +++ b/src/providers/antigravity_http_tests.rs @@ -184,6 +184,258 @@ fn client() -> reqwest::Client { .unwrap() } +#[tokio::test] +async fn antigravity_consumer_selected_origin_serves_discovery_and_generation() { + for stream in [false, true] { + let operations = Arc::new(Mutex::new(Vec::new())); + let token_operations = operations.clone(); + let oauth = Server::start(Router::new().route("/token", post(move |axum::extract::Form(form): axum::extract::Form>| { + let operations = token_operations.clone(); + async move { + assert_eq!(form["grant_type"], "authorization_code"); + operations.lock().unwrap().push("token"); + axum::Json(json!({"access_token":"synthetic-access","refresh_token":"synthetic-refresh","expires_in":3600})) + } + }))).await; + let captured: Captured = Arc::new(Mutex::new(Vec::new())); + let state = Arc::new(Backend { + captured: captured.clone(), + replies: vec![(200, successful(json!([{"text":"selected origin"}])))], + headers: http::HeaderMap::from_iter([( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("text/event-stream"), + )]), + }); + let discovery_operations = operations.clone(); + let generation_operations = operations.clone(); + let chosen = Server::start( + Router::new() + .route( + "/v1internal:loadCodeAssist", + post(move |headers: http::HeaderMap| { + let operations = discovery_operations.clone(); + async move { + assert_eq!(headers["authorization"], "Bearer synthetic-access"); + operations.lock().unwrap().push("discovery"); + axum::Json(json!({"cloudaicompanionProject":"synthetic-project"})) + } + }), + ) + .route( + "/v1internal:streamGenerateContent", + post(move |request: Request| { + let state = state.clone(); + let operations = generation_operations.clone(); + async move { + assert_eq!( + request.uri().path_and_query().unwrap().as_str(), + "/v1internal:streamGenerateContent?alt=sse" + ); + operations.lock().unwrap().push("generation"); + backend(State(state), request).await + } + }), + ), + ) + .await; + let directory = tempfile::tempdir().unwrap(); + // Loopback injection tests shared-origin plumbing, not live Google access. + let auth = Auth::new( + Config { + base_url: chosen.url.clone(), + token_url: oauth.url.join("/token").unwrap(), + client_id: Some("synthetic-client".into()), + ..Config::default() + }, + Store::new(directory.path().join("auth.json")), + ) + .unwrap(); + let mut callback = None; + auth.login_with_opener(|url| { + let pairs = url + .query_pairs() + .collect::>(); + let mut redirect = url::Url::parse(&pairs["redirect_uri"]).unwrap(); + redirect.query_pairs_mut().extend_pairs([ + ("state", pairs["state"].as_ref()), + ("code", "synthetic-code"), + ]); + callback = Some(tokio::spawn(async move { + client().get(redirect).send().await.unwrap() + })); + Ok(()) + }) + .await + .unwrap(); + assert_eq!(callback.unwrap().await.unwrap().status(), 200); + let proxy = proxy(AntigravityProvider::with_auth(auth), None).await; + let response = client() + .post(proxy.url.join("/v1/messages").unwrap()) + .json(&request(stream)) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + assert!(response.text().await.unwrap().contains("selected origin")); + assert_eq!( + *operations.lock().unwrap(), + ["token", "discovery", "generation"] + ); + let captured = captured.lock().unwrap(); + assert_eq!(captured.len(), 1); + assert_eq!(captured[0].0["authorization"], "Bearer synthetic-access"); + assert_eq!(captured[0].1["project"], "synthetic-project"); + } +} + +#[tokio::test] +async fn antigravity_consumer_three_stage_sse_and_nonstream_tool_replay() { + // Entirely synthetic: no captured provider signatures, prompts or credentials. + let thought = json!({"role":"model","parts":[{"thought":true,"text":"synthetic hidden thought","thoughtSignature":"synthetic-thought-signature"}]}); + let call = json!({"role":"model","parts":[{"functionCall":{"id":"synthetic-native-call","name":"endpoint_canary_echo","args":{"value":"endpoint-canary-public-nonce"}},"thoughtSignature":"synthetic-call-signature"}]}); + let tool_reply = format!( + "{}{}", + frame(json!({"response":{"candidates":[{"content":thought.clone()}]}})), + successful(call["parts"].clone()) + ); + let (upstream, captured) = native(vec![ + (200, successful(json!([{"text":"SSE_"},{"text":"TEXT_OK"}]))), + (200, tool_reply), + ( + 200, + successful(json!([{"text":"TOOL_"},{"text":"ROUNDTRIP_OK"}])), + ), + ]) + .await; + let directory = tempfile::tempdir().unwrap(); + let proxy = proxy(provider(directory.path(), &upstream.url).await, None).await; + let endpoint = proxy.url.join("/v1/messages").unwrap(); + let mut text_request = request(true); + text_request["max_tokens"] = json!(4096); + let response = client() + .post(endpoint.clone()) + .json(&text_request) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + assert_eq!(response.headers()["content-type"], "text/event-stream"); + let events: Vec = response + .text() + .await + .unwrap() + .lines() + .filter_map(|line| line.strip_prefix("data: ")) + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(events.first().unwrap()["type"], "message_start"); + assert_eq!(events.last().unwrap()["type"], "message_stop"); + assert_eq!( + events + .iter() + .filter(|event| event["type"] == "message_stop") + .count(), + 1 + ); + assert!( + events + .iter() + .all(|event| event["type"] != "error" && event["content_block"]["type"] != "tool_use") + ); + let text: String = events + .iter() + .filter(|event| event["delta"]["type"] == "text_delta") + .map(|event| event["delta"]["text"].as_str().unwrap()) + .collect(); + assert_eq!(text, "SSE_TEXT_OK"); + let terminal = events + .iter() + .find(|event| event["type"] == "message_delta") + .unwrap(); + assert_eq!(terminal["delta"]["stop_reason"], "end_turn"); + assert_eq!(terminal["usage"]["input_tokens"], 10); + assert_eq!(terminal["usage"]["output_tokens"], 3); + + let mut tool_request = request(false); + tool_request["max_tokens"] = json!(4096); + tool_request["messages"] = json!([{"role":"user","content":"Call endpoint_canary_echo with endpoint-canary-public-nonce, then wait for its result."}]); + tool_request["tools"] = json!([{"name":"endpoint_canary_echo","input_schema":{"type":"object","properties":{"value":{"type":"string","enum":["endpoint-canary-public-nonce"]}},"required":["value"],"additionalProperties":false}}]); + tool_request["tool_choice"] = json!({"type":"auto"}); + let response = client() + .post(endpoint.clone()) + .json(&tool_request) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + assert_eq!(response.headers()["content-type"], "application/json"); + let message: Value = response.json().await.unwrap(); + assert_eq!(message["stop_reason"], "tool_use"); + let content = message["content"].as_array().unwrap(); + assert_eq!(content.len(), 2); + assert_eq!(content[0]["type"], "thinking"); + assert_eq!(content[0]["thinking"], ""); + assert!( + content[0]["signature"] + .as_str() + .unwrap() + .starts_with(replay::PREFIX) + ); + assert_eq!(content[1]["type"], "tool_use"); + assert_eq!(content[1]["name"], "endpoint_canary_echo"); + assert_eq!( + content[1]["input"], + json!({"value":"endpoint-canary-public-nonce"}) + ); + let tool_id = content[1]["id"] + .as_str() + .filter(|id| !id.is_empty()) + .unwrap(); + // Carry the full nonstream aggregate unchanged, including its leading carrier. + tool_request["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":message["content"].clone()}), + json!({"role":"user","content":[{"type":"tool_result","tool_use_id":tool_id,"content":"endpoint-canary-public-nonce"}]}), + ]); + let response = client() + .post(endpoint) + .json(&tool_request) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + let final_message: Value = response.json().await.unwrap(); + assert_eq!(final_message["stop_reason"], "end_turn"); + let final_content = final_message["content"].as_array().unwrap(); + assert!( + final_content + .iter() + .all(|block| block["type"] != "tool_use") + ); + let text: String = final_content + .iter() + .filter(|block| block["type"] == "text") + .map(|block| block["text"].as_str().unwrap()) + .collect(); + assert_eq!(text, "TOOL_ROUNDTRIP_OK"); + assert_eq!(final_message["usage"]["input_tokens"], 10); + assert_eq!(final_message["usage"]["output_tokens"], 3); + let captured = captured.lock().unwrap(); + assert_eq!(captured.len(), 3); + for (_, wire) in captured.iter() { + assert_eq!(wire["model"], "gemini-4-argon-low"); + assert_eq!(wire["project"], "project"); + assert_eq!(wire["request"]["generationConfig"]["maxOutputTokens"], 4096); + } + let replayed = &captured[2].1["request"]; + assert_eq!(replayed["sessionId"], captured[1].1["request"]["sessionId"]); + assert_eq!(replayed["contents"][1], thought); + assert_eq!(replayed["contents"][2], call); + assert_eq!( + replayed["contents"][3], + json!({"role":"model","parts":[{"functionResponse":{"id":"synthetic-native-call","name":"endpoint_canary_echo","response":{"output":"endpoint-canary-public-nonce"}}}]}) + ); +} + #[tokio::test] async fn antigravity_user_agent_generation_wire_matrix() { for override_value in [ From 1b31d516515c390a4e268e052d8bc40726c115c9 Mon Sep 17 00:00:00 2001 From: Chris Liu <11517021+chris7iu@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:30:47 +0000 Subject: [PATCH 08/30] fix(antigravity): preserve property-name schema constraints Accept propertyNames with bounded recursive validation and complete local constraint guidance. Retain original argument validation, lossless codecs, and reference rejection. Cover optional CLI record-shaped tools and pre-auth failure ordering. Co-Authored-By: Claude Code --- src/providers/antigravity/schema.rs | 4 +- src/providers/antigravity_http_tests.rs | 90 ++++++++ src/providers/antigravity_tests.rs | 293 ++++++++++++++++++++++++ 3 files changed, 386 insertions(+), 1 deletion(-) diff --git a/src/providers/antigravity/schema.rs b/src/providers/antigravity/schema.rs index 8b969eee..49b6731e 100644 --- a/src/providers/antigravity/schema.rs +++ b/src/providers/antigravity/schema.rs @@ -207,6 +207,7 @@ fn validate_source(schema: &Value, depth: usize, nodes: &mut usize) -> Result<() "$schema", "type", "properties", + "propertyNames", "required", "items", "additionalProperties", @@ -285,7 +286,7 @@ fn validate_source(schema: &Value, depth: usize, nodes: &mut usize) -> Result<() validate_source(child, depth + 1, nodes)?; } } - for key in ["items", "additionalProperties"] { + for key in ["items", "additionalProperties", "propertyNames"] { if let Some(child) = object.get(key) { validate_source(child, depth + 1, nodes)?; } @@ -446,6 +447,7 @@ fn project(schema: &Value) -> Result<(Value, Codec)> { } let local = [ "additionalProperties", + "propertyNames", "allOf", "format", "multipleOf", diff --git a/src/providers/antigravity_http_tests.rs b/src/providers/antigravity_http_tests.rs index f9e438b6..4741c8f3 100644 --- a/src/providers/antigravity_http_tests.rs +++ b/src/providers/antigravity_http_tests.rs @@ -184,6 +184,96 @@ fn client() -> reqwest::Client { .unwrap() } +#[tokio::test] +async fn antigravity_property_names_http_preflight_never_refreshes_or_generates() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let upstream_calls = Arc::new(AtomicUsize::new(0)); + let observed = upstream_calls.clone(); + let upstream = Server::start(Router::new().fallback(move || { + let observed = observed.clone(); + async move { + observed.fetch_add(1, Ordering::SeqCst); + http::StatusCode::INTERNAL_SERVER_ERROR + } + })) + .await; + let directory = tempfile::tempdir().unwrap(); + let store = Store::new(directory.path().join("antigravity/auth.json")); + let lock = store.lock(Duration::from_secs(1)).await.unwrap(); + store + .save( + &lock, + &Credential { + profile_id: "profile".into(), + project_id: Some("project".into()), + access_token: "synthetic-expired-access".into(), + refresh_token: Some("synthetic-refresh".into()), + expires_at: 1, + }, + ) + .unwrap(); + drop(lock); + let config = Config { + base_url: upstream.url.clone(), + token_url: upstream.url.join("token").unwrap(), + client_id: Some("synthetic-client".into()), + ..Config::read(&directory.path().join("config.json"), |_| None).unwrap() + }; + let proxy = proxy( + AntigravityProvider::with_auth(Auth::new(config, store).unwrap()), + None, + ) + .await; + let mut body = request(false); + body["tools"] = json!([{"name":"TaskCreate","input_schema":{"type":"object","properties":{ + "metadata":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}} + },"additionalProperties":false}}]); + let response = client() + .post(proxy.url.join("/v1/messages/count_tokens").unwrap()) + .json(&body) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + assert!( + response.json::().await.unwrap()["input_tokens"] + .as_u64() + .unwrap() + > 0 + ); + assert_eq!( + upstream_calls.load(Ordering::SeqCst), + 0, + "count_tokens must not access expired credentials" + ); + for stream in [false, true] { + body["stream"] = json!(stream); + body["tools"][0]["input_schema"]["properties"]["metadata"]["propertyNames"] = + json!({"$ref":"#/$defs/name"}); + for path in ["/v1/messages", "/v1/messages/count_tokens"] { + let response = client() + .post(proxy.url.join(path).unwrap()) + .json(&body) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 400); + assert_eq!(response.headers()["content-type"], "application/json"); + assert_eq!( + response.json::().await.unwrap(), + json!({"type":"error","error":{ + "type":"invalid_request_error","message":"unsupported schema keyword or reference" + }}) + ); + assert_eq!( + upstream_calls.load(Ordering::SeqCst), + 0, + "schema rejection must precede refresh and generation" + ); + } + } +} + #[tokio::test] async fn antigravity_consumer_selected_origin_serves_discovery_and_generation() { for stream in [false, true] { diff --git a/src/providers/antigravity_tests.rs b/src/providers/antigravity_tests.rs index 1edf2b0d..54d3c17d 100644 --- a/src/providers/antigravity_tests.rs +++ b/src/providers/antigravity_tests.rs @@ -570,6 +570,299 @@ fn antigravity_integer_exclusive_projection_keeps_fractional_inclusive_constrain assert_eq!(properties["upper"]["maximum"], -2); } +fn property_names_tool(schema: Value) -> ToolSet { + ToolSet::compile(&json!([{"name":"record","input_schema":schema}])).unwrap() +} + +fn property_names_call(args: Value) -> Value { + json!([{"role":"model","parts":[{"functionCall":{"name":"record","args":args},"thoughtSignature":"unchanged"}]}]) +} + +#[test] +fn antigravity_property_names_records_keep_complete_codec_and_replay() { + let record = + json!({"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}); + let set = property_names_tool( + json!({"type":"object","properties":{"metadata":record},"required":["metadata"]}), + ); + let native = &set.declarations[0]["parameters"]["properties"]["metadata"]; + assert_eq!(native["type"], "STRING"); + assert!( + native["description"] + .as_str() + .unwrap() + .contains(&serde_json::to_string(&record).unwrap()) + ); + let metadata = json!({"a/b~雪":{"nested":[null,true,17,"value"]},"empty":{},"":[]}); + let contents = + property_names_call(json!({"metadata":serde_json::to_string(&metadata).unwrap()})); + let mapping = set.mapping(&contents).unwrap(); + assert_eq!(mapping[0]["codec_paths"], json!(["/metadata"])); + let blocks = replay::encode(&contents, &binding(), "19", "full", &mapping).unwrap(); + assert_eq!(blocks[1]["input"], json!({"metadata":metadata})); + assert_eq!( + replay::decode(&blocks, &binding()).unwrap()["contents"], + contents + ); + for raw in ["{", r#"{"x":1,"x":2}"#, "null", "[]"] { + assert!( + set.mapping(&property_names_call(json!({"metadata":raw}))) + .is_err() + ); + } +} + +#[test] +fn antigravity_property_names_static_cli_record_shapes_compile_without_weakening() { + // Relevant record shapes from the pinned CLI's static tool definitions, not + // a claim that every optional tool is present in every actual-CLI request. + let metadata = + json!({"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}); + let answers = json!({"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}}); + let annotations = json!({"type":"object","propertyNames":{"type":"string"},"additionalProperties":{ + "type":"object","properties":{"preview":{"type":"string"},"notes":{"type":"string"}},"additionalProperties":false + }}); + let set = ToolSet::compile(&json!([ + {"name":"TaskCreate","input_schema":{"type":"object","properties":{"metadata":metadata},"additionalProperties":false}}, + {"name":"TaskUpdate","input_schema":{"type":"object","properties":{"metadata":metadata},"additionalProperties":false}}, + {"name":"AskUserQuestion","input_schema":{"type":"object","properties":{"answers":answers,"annotations":annotations},"additionalProperties":false}} + ])).unwrap(); + for (index, property, source) in [ + (0, "metadata", &metadata), + (1, "metadata", &metadata), + (2, "answers", &answers), + (2, "annotations", &annotations), + ] { + let native = &set.declarations[index]["parameters"]["properties"][property]; + assert_eq!(native["type"], "STRING"); + assert!( + native["description"] + .as_str() + .unwrap() + .contains(&serde_json::to_string(source).unwrap()) + ); + } + for (answers, annotations, valid) in [ + ( + json!({"question":"answer"}), + json!({"question":{"preview":"preview","notes":"notes"}}), + true, + ), + (json!({}), json!({"question":{}}), true), + (json!({"question":17}), json!({}), false), + (json!({}), json!({"question":{"preview":17}}), false), + ( + json!({}), + json!({"question":{"extra":"not allowed"}}), + false, + ), + ] { + let contents = json!([{"role":"model","parts":[{"functionCall":{"name":"AskUserQuestion","args":{ + "answers":serde_json::to_string(&answers).unwrap(),"annotations":serde_json::to_string(&annotations).unwrap() + }}}]}]); + assert_eq!(set.mapping(&contents).is_ok(), valid); + } +} + +#[test] +fn antigravity_property_names_structured_guidance_validates_every_key_and_value() { + for dialect in [ + "https://json-schema.org/draft/2020-12/schema", + "http://json-schema.org/draft-07/schema#", + ] { + let names = json!({"type":"string","pattern":"^ok","minLength":3}); + let schema = json!({"$schema":dialect,"type":"object","properties":{ + "ok_known":{"type":"integer"},"bad_known":{"type":"integer"}, + "ok_default":{"type":"integer","default":17} + },"propertyNames":names}); + let set = property_names_tool(schema); + let parameters = &set.declarations[0]["parameters"]; + assert_eq!(parameters["type"], "OBJECT"); + assert!( + parameters["description"] + .as_str() + .unwrap() + .contains(&format!( + "\"propertyNames\":{}", + serde_json::to_string(&names).unwrap() + )) + ); + for args in [json!({}), json!({"ok_known":17}), json!({"ok_extra":true})] { + let contents = property_names_call(args.clone()); + let mapping = set.mapping(&contents).unwrap(); + assert_eq!(mapping[0]["codec_paths"], json!([])); + let blocks = replay::encode(&contents, &binding(), "19", "full", &mapping).unwrap(); + assert_eq!(blocks[1]["input"], args, "defaults must not be inserted"); + } + for args in [ + json!({"bad_known":17}), + json!({"bad_extra":17}), + json!({"ok":17}), + json!({"ok_known":"17"}), + ] { + assert_eq!( + set.mapping(&property_names_call(args)).err(), + Some("upstream tool arguments violate original schema") + ); + } + } +} + +#[test] +fn antigravity_property_names_standard_applicability_and_boolean_schemas() { + for (names, populated) in [ + (json!(true), true), + (json!(false), false), + (json!({"type":"integer"}), false), + ] { + let set = property_names_tool( + json!({"type":"object","properties":{"value":{"propertyNames":names}},"required":["value"]}), + ); + for (value, valid) in [ + (json!({}), true), + (json!({"x":1}), populated), + (json!(17), true), + (json!([1]), true), + (json!(null), true), + ] { + // Tool inputs remain objects; only this unconstrained nested value + // varies, so non-object applicability is tested without violating + // the existing root-codec object transport contract. + let contents = + property_names_call(json!({"value":serde_json::to_string(&value).unwrap()})); + assert_eq!( + set.mapping(&contents).is_ok(), + valid, + "legal propertyNames applicability changed for {value}" + ); + } + } + let set = property_names_tool( + json!({"type":"object","properties":{"value":{"type":"integer","propertyNames":false}},"required":["value"]}), + ); + assert!( + set.mapping(&property_names_call(json!({"value":17}))) + .is_ok() + ); + assert!( + set.mapping(&property_names_call(json!({"value":"17"}))) + .is_err() + ); + let set = property_names_tool( + json!({"type":"object","additionalProperties":false,"propertyNames":false}), + ); + assert!(set.declarations[0].get("parameters").is_none()); + assert!(set.mapping(&property_names_call(json!({}))).is_ok()); + assert!(set.mapping(&property_names_call(json!({"x":1}))).is_err()); +} + +#[test] +fn antigravity_property_names_enums_and_freeform_value_constraints_are_original() { + let schema = json!({"type":"object","propertyNames":{"enum":["allowed"]},"additionalProperties":{"type":"integer","minimum":1,"default":17}}); + let set = property_names_tool(schema.clone()); + let native = &set.declarations[0]["parameters"]["properties"]["json"]; + assert_eq!(native["type"], "STRING"); + assert!( + native["description"] + .as_str() + .unwrap() + .contains(&serde_json::to_string(&schema).unwrap()) + ); + for (value, valid) in [ + (json!({}), true), + (json!({"allowed":1}), true), + (json!({"other":1}), false), + (json!({"allowed":0}), false), + (json!({"allowed":"1"}), false), + (json!({"allowed":null}), false), + ] { + let contents = property_names_call(json!({"json":serde_json::to_string(&value).unwrap()})); + assert_eq!(set.mapping(&contents).is_ok(), valid); + if valid { + let mapping = set.mapping(&contents).unwrap(); + let blocks = replay::encode(&contents, &binding(), "19", "full", &mapping).unwrap(); + assert_eq!(blocks[1]["input"], value); + } + } +} + +#[test] +fn antigravity_property_names_traversal_rejects_unsupported_descendants_and_malformed_values() { + for child in [ + json!({"$ref":"#/$defs/name"}), + json!({"$ref":"https://example.invalid/schema"}), + json!({"$defs":{}}), + json!({"definitions":{}}), + json!({"$dynamicRef":"#name"}), + json!({"examples":[]}), + json!({"unknownConstraint":true}), + json!({"anyOf":[{"type":"string"},{"$ref":"#"}]}), + ] { + assert_eq!( + ToolSet::compile(&json!([{"name":"record","input_schema":{"propertyNames":child}}])) + .err(), + Some("unsupported schema keyword or reference") + ); + } + for child in [json!(null), json!(17), json!("string"), json!([])] { + assert_eq!( + ToolSet::compile(&json!([{"name":"record","input_schema":{"propertyNames":child}}])) + .err(), + Some("schema must be object or boolean") + ); + } + for child in [json!({"type":"invented"}), json!({"minLength":-1})] { + assert_eq!( + ToolSet::compile(&json!([{"name":"record","input_schema":{"propertyNames":child}}])) + .err(), + Some("invalid original tool schema") + ); + } + let set = property_names_tool( + json!({"type":"object","properties":{"propertyNames":{"type":"string"}},"required":["propertyNames"],"additionalProperties":false}), + ); + assert!( + set.mapping(&property_names_call( + json!({"propertyNames":"ordinary domain field"}) + )) + .is_ok() + ); +} + +#[test] +fn antigravity_property_names_traversal_preserves_depth_node_byte_and_guidance_limits() { + let compile = |schema| ToolSet::compile(&json!([{"name":"record","input_schema":schema}])); + let mut deep = json!(true); + for _ in 0..32 { + deep = json!({"propertyNames":deep}); + } + assert!(compile(deep.clone()).is_ok()); + assert_eq!( + compile(json!({"propertyNames":deep})).err(), + Some("schema depth/node limit") + ); + let properties = (0..8190) + .map(|i| (format!("p{i}"), json!(true))) + .collect::>(); + let mut wide = json!({"type":"object","properties":properties,"propertyNames":true}); + assert!(compile(wide.clone()).is_ok()); + wide["properties"]["extra"] = json!(true); + assert_eq!(compile(wide).err(), Some("schema depth/node limit")); + assert_eq!( + compile(json!({"propertyNames":{"description":"x".repeat(256*1024)}})).err(), + Some("schema byte limit") + ); + for schema in [ + json!({"propertyNames":{"description":"x".repeat(16384)}}), + json!({"type":"object","properties":{"x":{"type":"integer"}},"propertyNames":{"description":"x".repeat(16384)}}), + ] { + assert_eq!( + compile(schema).err(), + Some("complete schema guidance exceeds the 16 KiB limit") + ); + } +} + #[test] fn antigravity_schema_preserves_original_dialects_and_constraints() { for dialect in [ From a47edfe66d39e272fa9123a79303d50ae2b69bcd Mon Sep 17 00:00:00 2001 From: Chris Liu <11517021+chris7iu@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:02:08 +0000 Subject: [PATCH 09/30] fix(antigravity): align bootstrap metadata and daily error diagnostics Match the observed official loadCodeAssist metadata without changing project or credential-owner behavior. Preserve allowlisted ErrorInfo reasons from the exact daily API domain, with pure privacy and boundary regressions. Co-Authored-By: Claude Code --- src/providers/antigravity/auth.rs | 6 +- src/providers/antigravity/transport.rs | 146 ++++++++++++++++++++++++ src/providers/antigravity_auth_tests.rs | 9 ++ 3 files changed, 160 insertions(+), 1 deletion(-) diff --git a/src/providers/antigravity/auth.rs b/src/providers/antigravity/auth.rs index e82665e1..9883e6c4 100644 --- a/src/providers/antigravity/auth.rs +++ b/src/providers/antigravity/auth.rs @@ -380,7 +380,7 @@ impl Auth { .timeout(Duration::from_secs(30)) .bearer_auth(&credential.access_token) .headers(self.config.native_headers()) - .json(&json!({"metadata":{"ideType":"ANTIGRAVITY","pluginType":"GEMINI"}})) + .json(&load_code_assist_payload()) .send() .await .map_err(|_| "Antigravity project discovery transport failed")?; @@ -392,6 +392,10 @@ impl Auth { } } +pub(crate) fn load_code_assist_payload() -> Value { + json!({"metadata":{"ideType":"ANTIGRAVITY"}}) +} + pub(crate) fn authorization_url( config: &Config, client_id: &str, diff --git a/src/providers/antigravity/transport.rs b/src/providers/antigravity/transport.rs index 3c45e104..b4446b9b 100644 --- a/src/providers/antigravity/transport.rs +++ b/src/providers/antigravity/transport.rs @@ -47,6 +47,7 @@ const GOOGLE_REASONS: &[&str] = &[ const GOOGLE_DOMAINS: &[&str] = &[ "googleapis.com", "cloudcode-pa.googleapis.com", + "daily-cloudcode-pa.googleapis.com", "generativelanguage.googleapis.com", "aiplatform.googleapis.com", ]; @@ -635,3 +636,148 @@ pub(crate) fn sse(message: &Value) -> Result> { } Ok(wire.into_bytes()) } + +#[cfg(test)] +mod error_info_tests { + use super::*; + + const DAILY: &str = "daily-cloudcode-pa.googleapis.com"; + const PRIVATE: &str = "DO_NOT_REFLECT_private_project_consumer_email"; + + fn info(reason: Value, domain: Value) -> Value { + json!({"@type":"type.googleapis.com/google.rpc.ErrorInfo", "reason":reason, + "domain":domain, "message":PRIVATE, "metadata":{"project":PRIVATE,"consumer":PRIVATE}}) + } + + fn labels(details: Value) -> [String; 6] { + let body = + json!({"error":{"status":"RESOURCE_EXHAUSTED","message":PRIVATE,"details":details}}); + let parsed = ErrorDiagnostics::parse(&serde_json::to_vec(&body).unwrap()).unwrap(); + let labels = [ + parsed.status.into(), + parsed.reason.into(), + parsed.domain.into(), + parsed.quota.into(), + parsed.retry.label().into(), + parsed.retry.seconds(), + ]; + assert!(labels.iter().all(|label| !label.contains(PRIVATE))); + labels + } + + #[test] + fn daily_domain_preserves_known_reason_and_only_sanitized_labels() { + assert_eq!( + labels(json!([info(json!("RATE_LIMIT_EXCEEDED"), json!(DAILY))])), + [ + "RESOURCE_EXHAUSTED", + "RATE_LIMIT_EXCEEDED", + DAILY, + "absent", + "absent", + "none" + ] + ); + assert_eq!( + labels(json!([info(json!(PRIVATE), json!(DAILY))])), + [ + "RESOURCE_EXHAUSTED", + "unclassified", + DAILY, + "absent", + "absent", + "none" + ] + ); + } + + #[test] + fn daily_domain_is_exact_not_a_suffix_url_or_normalized_alias() { + for domain in [ + json!("daily-cloudcode-pa.googleapis.com.example.test"), + json!("tenant.daily-cloudcode-pa.googleapis.com"), + json!("notdaily-cloudcode-pa.googleapis.com"), + json!("DAILY-cloudcode-pa.googleapis.com"), + json!("https://daily-cloudcode-pa.googleapis.com"), + json!("daily-cloudcode-pa.googleapis.com:443"), + json!("daily-cloudcode-pa.googleapis.com."), + json!(" daily-cloudcode-pa.googleapis.com"), + json!("daily-cloudcode-pa.googleapis.com\n"), + json!("daily-cloudcode-pa.googléapis.com"), + json!("another.googleapis.com"), + json!(null), + json!([DAILY]), + ] { + assert_eq!( + labels(json!([info(json!("RATE_LIMIT_EXCEEDED"), domain)])), + [ + "RESOURCE_EXHAUSTED", + "unclassified", + "unclassified", + "absent", + "absent", + "none" + ] + ); + } + } + + #[test] + fn daily_malformed_or_multiple_error_info_keeps_quota_and_retry_independent() { + for infos in [ + vec![info(json!(42), json!(DAILY))], + vec![info(json!(null), json!(DAILY))], + vec![json!({"@type":"type.googleapis.com/google.rpc.ErrorInfo","domain":DAILY})], + vec![info(json!("RATE_LIMIT_EXCEEDED"), json!(null))], + vec![ + info(json!("RATE_LIMIT_EXCEEDED"), json!(DAILY)), + info(json!("RATE_LIMIT_EXCEEDED"), json!(DAILY)), + ], + ] { + let mut details = infos; + details.push( + json!({"@type":"type.googleapis.com/google.rpc.QuotaFailure", + "violations":[{"subject":PRIVATE,"description":PRIVATE}]}), + ); + details.push( + json!({"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":"7s"}), + ); + assert_eq!( + labels(json!(details)), + [ + "RESOURCE_EXHAUSTED", + "unclassified", + "unclassified", + "reported", + "valid", + "7" + ] + ); + } + assert_eq!( + labels(json!([ + info(json!(PRIVATE), json!(DAILY)), + {"@type":"type.googleapis.com/google.rpc.QuotaFailure","violations":[{"subject":PRIVATE}]}, + {"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":"7s"} + ])), + [ + "RESOURCE_EXHAUSTED", + "unclassified", + DAILY, + "reported", + "valid", + "7" + ] + ); + } + + #[test] + fn daily_error_info_does_not_bypass_body_or_detail_limits() { + let detail = info(json!("RATE_LIMIT_EXCEEDED"), json!(DAILY)); + let body = json!({"error":{"details":vec![detail; 17]}}); + assert!(ErrorDiagnostics::parse(&serde_json::to_vec(&body).unwrap()).is_none()); + let body = json!({"error":{"message":"x".repeat(MAX_ERROR_BODY), + "details":[info(json!("RATE_LIMIT_EXCEEDED"), json!(DAILY))]}}); + assert!(ErrorDiagnostics::parse(&serde_json::to_vec(&body).unwrap()).is_none()); + } +} diff --git a/src/providers/antigravity_auth_tests.rs b/src/providers/antigravity_auth_tests.rs index 2adb794c..b0d2e79c 100644 --- a/src/providers/antigravity_auth_tests.rs +++ b/src/providers/antigravity_auth_tests.rs @@ -1041,6 +1041,15 @@ async fn antigravity_logout_during_browser_consent_prevents_exchange_and_resurre assert_eq!(state.count.load(Ordering::SeqCst), 0); } +#[test] +fn antigravity_load_code_assist_payload_matches_exact_bootstrap_contract() { + assert_eq!( + auth::load_code_assist_payload(), + json!({"metadata":{"ideType":"ANTIGRAVITY"}}), + "bootstrap must not add pluginType, nulls, or other root/metadata fields" + ); +} + #[test] fn antigravity_pkce_state_and_project_shapes_are_explicit() { let url = auth::authorization_url( From 931fe1db033c1a3fefb3e41c24901389672985ff Mon Sep 17 00:00:00 2001 From: Chris Liu <11517021+chris7iu@users.noreply.github.com> Date: Mon, 2 Nov 2026 02:07:02 +0000 Subject: [PATCH 10/30] fix(antigravity): align agent request construction Add the user role to system instructions and serialize one compiled function declaration per tool group while preserving declaration order and codecs. Use 65536 only when max_tokens is absent or null, preserving explicit caller limits and validation. Cover request and HTTP ingress contracts for stream and non-stream callers without changing transport, authentication, replay, labels, or user-agent policy. Co-Authored-By: Claude Code --- src/providers/antigravity/request.rs | 14 +- src/providers/antigravity_http_tests.rs | 139 +++++++++++++++++ src/providers/antigravity_tests.rs | 189 +++++++++++++++++++++++- 3 files changed, 336 insertions(+), 6 deletions(-) diff --git a/src/providers/antigravity/request.rs b/src/providers/antigravity/request.rs index 77511762..d878742e 100644 --- a/src/providers/antigravity/request.rs +++ b/src/providers/antigravity/request.rs @@ -216,7 +216,7 @@ pub(crate) fn prepare(body: &MessagesRequest, binding: &Binding) -> Result "MEDIUM", _ => "HIGH", }; - let cap = body.max_tokens.unwrap_or(8192); + let cap = body.max_tokens.unwrap_or(65536); if cap == 0 || cap > 65536 { return Err("max_tokens must be between 1 and 65536"); } @@ -422,10 +422,18 @@ pub(crate) fn prepare(body: &MessagesRequest, binding: &Binding) -> Result>() + ); } let wire = json!({"project":binding.project_id,"model":binding.native_model,"request":request, "requestId":format!("ccp-{}",uuid::Uuid::new_v4()),"userAgent":"antigravity","requestType":"agent"}); diff --git a/src/providers/antigravity_http_tests.rs b/src/providers/antigravity_http_tests.rs index 4741c8f3..367f4ac1 100644 --- a/src/providers/antigravity_http_tests.rs +++ b/src/providers/antigravity_http_tests.rs @@ -54,6 +54,10 @@ struct Backend { headers: http::HeaderMap, } async fn backend(State(state): State>, request: Request) -> Response { + assert_eq!( + request.uri().path_and_query().unwrap().as_str(), + "/v1internal:streamGenerateContent?alt=sse" + ); let headers = request.headers().clone(); let bytes = axum::body::to_bytes(request.into_body(), 32 * 1024 * 1024) .await @@ -184,6 +188,88 @@ fn client() -> reqwest::Client { .unwrap() } +async fn assert_generation_cap_ingress(stream: bool) { + let (upstream, captured) = + native(vec![(200, successful(json!([{"text":"cap response"}])))]).await; + let directory = tempfile::tempdir().unwrap(); + let proxy = proxy(provider(directory.path(), &upstream.url).await, None).await; + let cases = [ + (None, 65536), + (Some(Value::Null), 65536), + (Some(json!(1)), 1), + (Some(json!(512)), 512), + (Some(json!(65536)), 65536), + ]; + for (cap, _) in &cases { + let mut body = request(stream); + body.as_object_mut().unwrap().remove("max_tokens"); + if let Some(cap) = cap { + body["max_tokens"] = cap.clone(); + } + body["system"] = json!([]); + let response = client() + .post(proxy.url.join("/v1/messages").unwrap()) + .header("accept", "application/x-hostile-frontend") + .header("authorization", "Bearer frontend-credential") + .header("user-agent", "hostile-frontend-UA") + .header("cookie", "private-frontend-cookie") + .header("x-goog-api-client", "frontend-metadata") + .json(&body) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + assert_eq!(response.headers()["x-antigravity-reasoning-tier"], "low"); + assert_eq!( + response.headers()["content-type"], + if stream { + "text/event-stream" + } else { + "application/json" + } + ); + let text = response.text().await.unwrap(); + assert!(text.contains("cap response")); + if stream { + assert!(text.contains("event: message_stop")); + assert!(!text.contains("event: error")); + } else { + let message: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(message["stop_reason"], "end_turn"); + } + } + let captured = captured.lock().unwrap(); + assert_eq!(captured.len(), cases.len()); + for ((headers, wire), (_, cap)) in captured.iter().zip(&cases) { + assert_eq!(headers["accept"], "text/event-stream"); + assert_eq!(headers["content-type"], "application/json"); + assert_eq!(headers["authorization"], "Bearer proxy-owned-access"); + assert_eq!(headers["user-agent"], expected_native_user_agent()); + assert!(!headers.contains_key("cookie")); + assert!(!headers.contains_key("x-goog-api-client")); + assert_eq!(wire.as_object().unwrap().len(), 6); + assert_eq!(wire["model"], "gemini-4-argon-low"); + assert_eq!(wire["userAgent"], "antigravity"); + assert_eq!(wire["requestType"], "agent"); + assert_eq!(wire["request"].as_object().unwrap().len(), 3); + assert_eq!( + wire["request"]["generationConfig"]["thinkingConfig"], + json!({"includeThoughts":true,"thinkingLevel":"LOW"}) + ); + assert_eq!(wire["request"]["generationConfig"]["maxOutputTokens"], *cap); + } +} + +#[tokio::test] +async fn antigravity_http_nonstream_omitted_cap_uses_agent_default() { + assert_generation_cap_ingress(false).await; +} + +#[tokio::test] +async fn antigravity_http_stream_omitted_cap_uses_agent_default() { + assert_generation_cap_ingress(true).await; +} + #[tokio::test] async fn antigravity_property_names_http_preflight_never_refreshes_or_generates() { use std::sync::atomic::{AtomicUsize, Ordering}; @@ -246,6 +332,59 @@ async fn antigravity_property_names_http_preflight_never_refreshes_or_generates( 0, "count_tokens must not access expired credentials" ); + let mut estimates = Vec::new(); + for cap in [ + None, + Some(Value::Null), + Some(json!(1)), + Some(json!(512)), + Some(json!(65536)), + ] { + let mut counted = body.clone(); + counted.as_object_mut().unwrap().remove("max_tokens"); + if let Some(cap) = cap { + counted["max_tokens"] = cap; + } + let response = client() + .post(proxy.url.join("/v1/messages/count_tokens").unwrap()) + .json(&counted) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + estimates.push(response.json::().await.unwrap()["input_tokens"].clone()); + } + assert!(estimates.iter().all(|estimate| *estimate == estimates[0])); + for stream in [false, true] { + for cap in [ + json!(0), + json!(65537), + json!(-1), + json!(1.5), + json!("512"), + json!(true), + json!([]), + json!({}), + ] { + let mut invalid = body.clone(); + invalid["stream"] = json!(stream); + invalid["max_tokens"] = cap; + for path in ["/v1/messages", "/v1/messages/count_tokens"] { + let response = client() + .post(proxy.url.join(path).unwrap()) + .json(&invalid) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 400); + } + } + } + assert_eq!( + upstream_calls.load(Ordering::SeqCst), + 0, + "cap validation/counting must not refresh or generate with expired credentials" + ); for stream in [false, true] { body["stream"] = json!(stream); body["tools"][0]["input_schema"]["properties"]["metadata"]["propertyNames"] = diff --git a/src/providers/antigravity_tests.rs b/src/providers/antigravity_tests.rs index 54d3c17d..1be2451a 100644 --- a/src/providers/antigravity_tests.rs +++ b/src/providers/antigravity_tests.rs @@ -965,7 +965,7 @@ fn antigravity_request_accepts_observed_options_without_changing_tier() { } #[test] -fn antigravity_request_system_parts_keep_privilege_and_order() { +fn antigravity_request_system_role_user_preserves_parts_and_order() { let mut request = request_value(); request["system"] = json!([{"type":"text","text":"top"}]); request["messages"] = json!([ @@ -978,8 +978,8 @@ fn antigravity_request_system_parts_keep_privilege_and_order() { ) .unwrap(); assert_eq!( - prepared.wire["request"]["systemInstruction"]["parts"], - json!([{"text":"top"},{"text":"first"},{"text":"second"}]) + prepared.wire["request"]["systemInstruction"], + json!({"role":"user","parts":[{"text":"top"},{"text":"first"},{"text":"second"}]}) ); assert_eq!( prepared.wire["request"]["contents"], @@ -989,6 +989,189 @@ fn antigravity_request_system_parts_keep_privilege_and_order() { assert!(prepare(&serde_json::from_value(request).unwrap(), &binding()).is_err()); } +#[test] +fn antigravity_request_absent_cap_defaults_to_65536() { + for model in super::antigravity::request::MODELS { + let mut scope = binding(); + scope.native_model = model.strip_prefix("antigravity/").unwrap().into(); + for null in [false, true] { + let mut request = request_value(); + request["model"] = json!(model); + request.as_object_mut().unwrap().remove("max_tokens"); + if null { + request["max_tokens"] = Value::Null; + } + let prepared = prepare(&serde_json::from_value(request).unwrap(), &scope).unwrap(); + assert_eq!( + prepared.wire["request"]["generationConfig"]["maxOutputTokens"], 65536, + "model={model}, null={null}" + ); + } + } +} + +#[test] +fn antigravity_request_explicit_caps_are_exact_and_invalid_caps_reject() { + let mut estimates = Vec::new(); + for stream in [false, true] { + for cap in [1, 512, 65536] { + let mut request = request_value(); + request["max_tokens"] = json!(cap); + request["stream"] = json!(stream); + request["output_config"] = json!({"effort":"high"}); + let prepared = prepare(&serde_json::from_value(request).unwrap(), &binding()).unwrap(); + assert_eq!( + prepared.wire["request"]["generationConfig"]["maxOutputTokens"], + cap + ); + assert_eq!( + prepared.wire["request"]["generationConfig"]["thinkingConfig"], + json!({"includeThoughts":true,"thinkingLevel":"LOW"}) + ); + estimates.push(prepared.estimate); + } + } + assert!(estimates.iter().all(|estimate| *estimate == estimates[0])); + for cap in [0, 65537] { + let mut request = request_value(); + request["max_tokens"] = json!(cap); + assert_eq!( + prepare(&serde_json::from_value(request).unwrap(), &binding()).err(), + Some("max_tokens must be between 1 and 65536") + ); + } + for cap in [ + json!(-1), + json!(1.5), + json!("512"), + json!(true), + json!([]), + json!({}), + ] { + let mut request = request_value(); + request["max_tokens"] = cap; + assert!( + serde_json::from_value::(request).is_err() + ); + } +} + +#[test] +fn antigravity_request_absent_and_empty_system_keep_exact_envelope() { + for system in [None, Some(json!([]))] { + let mut request = request_value(); + if let Some(system) = system { + request["system"] = system; + } + let prepared = prepare(&serde_json::from_value(request).unwrap(), &binding()).unwrap(); + assert_eq!( + prepared + .wire + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect::>(), + std::collections::BTreeSet::from([ + "project", + "model", + "request", + "requestId", + "userAgent", + "requestType" + ]) + ); + assert_eq!( + prepared.wire["request"] + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect::>(), + std::collections::BTreeSet::from(["contents", "sessionId", "generationConfig"]) + ); + assert_eq!( + prepared.wire["request"]["contents"], + json!([{"role":"user","parts":[{"text":"hello"}]}]) + ); + assert_eq!(prepared.wire["requestType"], "agent"); + assert_eq!(prepared.wire["userAgent"], "antigravity"); + } +} + +#[test] +fn antigravity_request_multiple_tools_have_one_declaration_per_object() { + let source = json!([ + {"name":"caller/tool with spaces","description":"free-form input","input_schema":{"type":"object"}}, + {"name":"empty","input_schema":{"type":"object","additionalProperties":false}}, + {"name":"limited","input_schema":{"type":"object","properties":{"limit":{"type":"integer","minimum":1}},"required":["limit"],"additionalProperties":false}} + ]); + let compiled = ToolSet::compile(&source).unwrap(); + let mut request = request_value(); + request["tools"] = source; + request["tool_choice"] = json!({"type":"auto"}); + let prepared = prepare(&serde_json::from_value(request).unwrap(), &binding()).unwrap(); + assert_eq!(prepared.tools.declarations, compiled.declarations); + let expected: Vec = compiled + .declarations + .as_array() + .unwrap() + .iter() + .map(|declaration| json!({"functionDeclarations":[declaration]})) + .collect(); + assert_eq!(prepared.wire["request"]["tools"], json!(expected)); + assert_eq!(prepared.wire["request"].as_object().unwrap().len(), 4); + assert_eq!(prepared.wire.as_object().unwrap().len(), 6); + assert!(prepared.wire["request"].get("toolConfig").is_none()); + assert!(prepared.wire.get("labels").is_none()); + let contents = json!([{"role":"model","parts":[ + {"functionCall":{"name":compiled.declarations[0]["name"],"args":{"json":"{\"payload\":[1,true]}"}}}, + {"functionCall":{"name":"empty","args":{}}}, + {"functionCall":{"name":"limited","args":{"limit":1}}} + ]}]); + let mapping = prepared.tools.mapping(&contents).unwrap(); + let blocks = + replay::encode(&contents, &binding(), &prepared.session, "full", &mapping).unwrap(); + assert_eq!(blocks[1]["name"], "caller/tool with spaces"); + assert_eq!(blocks[1]["input"], json!({"payload":[1,true]})); + assert_eq!(blocks[2]["input"], json!({})); + assert_eq!(blocks[3]["input"], json!({"limit":1})); + assert_eq!( + replay::decode(&blocks, &binding()).unwrap()["contents"], + contents + ); +} + +#[test] +fn antigravity_request_zero_one_tools_and_choice_none_preserve_omission() { + let tool = + json!({"name":"empty","input_schema":{"type":"object","additionalProperties":false}}); + for (source, choice, expected) in [ + (json!([]), None, None), + ( + json!([tool]), + None, + Some(json!([{"functionDeclarations":[{"name":"empty"}]}])), + ), + (json!([tool]), Some(json!({"type":"none"})), None), + ] { + let mut request = request_value(); + request["tools"] = source; + if let Some(choice) = choice { + request["tool_choice"] = choice; + } + let prepared = prepare(&serde_json::from_value(request).unwrap(), &binding()).unwrap(); + assert_eq!(prepared.wire["request"].get("tools"), expected.as_ref()); + assert_eq!( + prepared.tools.declarations.as_array().unwrap().len(), + usize::from(expected.is_some()) + ); + assert!(prepared.wire["request"].get("toolConfig").is_none()); + assert!(prepared.wire["request"].get("labels").is_none()); + assert_eq!(prepared.wire.as_object().unwrap().len(), 6); + } +} + #[test] fn antigravity_request_rejects_semantic_options_media_and_lost_history() { for (key, value) in [ From e53a073ca3d258018bf59bcdafcb5b3aa235284f Mon Sep 17 00:00:00 2001 From: Chris Liu <11517021+chris7iu@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:57:43 +0000 Subject: [PATCH 11/30] fix(antigravity): align generation transport with native CLI Use a generation-only Hyper transport with explicit proxy/TLS behavior, bounded gzip decoding and foreground cancellation. Preserve the existing OAuth and project-discovery clients and disable automatic generation retries. Add verified local TLS, framing, compression, cancellation and proxy regression coverage. Co-Authored-By: Claude Code --- Cargo.lock | 2 + Cargo.toml | 7 +- src/providers/antigravity/generation.rs | 1470 ++++++++++ src/providers/antigravity/mod.rs | 49 +- src/providers/antigravity/transport.rs | 24 +- src/providers/antigravity_http_tests.rs | 601 +++- tests/antigravity_transport.rs | 2498 +++++++++++++++++ tests/fixtures/antigravity-transport/ca.pem | 20 + .../antigravity-transport/server-key.pem | 28 + .../fixtures/antigravity-transport/server.pem | 20 + 10 files changed, 4673 insertions(+), 46 deletions(-) create mode 100644 src/providers/antigravity/generation.rs create mode 100644 tests/antigravity_transport.rs create mode 100644 tests/fixtures/antigravity-transport/ca.pem create mode 100644 tests/fixtures/antigravity-transport/server-key.pem create mode 100644 tests/fixtures/antigravity-transport/server.pem diff --git a/Cargo.lock b/Cargo.lock index 1d02a72c..8afc7eef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -375,6 +375,8 @@ dependencies = [ "hostname", "http", "http-body-util", + "hyper", + "hyper-rustls", "hyper-util", "jiff", "jsonschema", diff --git a/Cargo.toml b/Cargo.toml index d9be566c..01d9e3a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,10 +35,12 @@ once_cell = "1.20" regex-lite = "0.1" tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] } tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } -rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } rustls-native-certs = "0.8" webpki-roots = "1" -hyper-util = { version = "0.1", features = ["client-proxy"] } +hyper = { version = "1.10.1", default-features = false, features = ["client", "http1", "http2"] } +hyper-rustls = { version = "0.27.9", default-features = false, features = ["http1", "http2", "ring", "tls12"] } +hyper-util = { version = "0.1", features = ["client-legacy", "client-proxy", "http1", "http2", "tokio"] } url = "2" prost = "0.13" flate2 = "1.0" @@ -52,6 +54,7 @@ jsonschema = { version = "=0.59.0", default-features = false, features = ["idna" libc = "=0.2.186" [dev-dependencies] +hyper = { version = "1.10.1", default-features = false, features = ["server", "http1", "http2"] } tokio = { version = "1", features = ["test-util"] } tempfile = "3.14" assert_cmd = "2" diff --git a/src/providers/antigravity/generation.rs b/src/providers/antigravity/generation.rs new file mode 100644 index 00000000..719a0d86 --- /dev/null +++ b/src/providers/antigravity/generation.rs @@ -0,0 +1,1470 @@ +//! Generation-only native HTTP. OAuth and refresh keep their separate reqwest client. + +use super::{ + config::{CONNECT_TIMEOUT, Config}, + replay::Result, +}; +use bytes::Bytes; +use futures_util::{StreamExt, stream::BoxStream}; +use http::{HeaderMap, HeaderValue, StatusCode, Uri, header}; +use http_body_util::BodyExt; +use hyper::rt::{Read, ReadBufCursor, Write as HttpWrite}; +use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder, MaybeHttpsStream}; +use hyper_util::{ + client::{ + legacy::{ + Client, + connect::{ + Connected, Connection, HttpConnector, + proxy::{SocksV4, SocksV5, Tunnel}, + }, + }, + proxy::matcher::Matcher, + }, + rt::{TokioExecutor, TokioIo, TokioTimer}, +}; +use serde_json::Value; +use std::{ + convert::Infallible, + error::Error, + future::Future, + io::{self, IoSlice, Write}, + pin::Pin, + sync::Arc, + task::{Context, Poll}, + time::Duration, +}; +use tokio::{net::TcpStream, time::Instant}; +use tower::Service; + +const MAX_TURN_BYTES: usize = 8 * 1024 * 1024; +const MAX_ERROR_BYTES: usize = 8 * 1024; +const DECODE_INPUT_CHUNK: usize = 1024; +const READ_IDLE: Duration = Duration::from_secs(30); +type BoxError = Box; +type Connecting = + Pin, BoxError>> + Send>>; +type TcpIo = TokioIo; + +pub(super) struct GenerationClient { + client: Client, + native_headers: HeaderMap, +} + +impl GenerationClient { + pub(super) fn new(config: &Config) -> Result { + Config::validate_endpoint(config.base_url.as_str(), cfg!(test))?; + if config.base_url.path() != "/" { + return Err("invalid native origin"); + } + let connector = generation_connector()?; + let builder = generation_client_builder(TokioExecutor::new()); + Ok(Self { + client: builder.build(connector), + native_headers: config.native_headers(), + }) + } + + pub(super) async fn send( + &self, + endpoint: url::Url, + access: &str, + wire: &Value, + header_deadline: Instant, + request_deadline: Instant, + ) -> Result { + if endpoint.scheme() != "https" && !test_loopback_http(&endpoint) { + return Err("invalid native endpoint"); + } + // Preserve the existing prepared JSON/caller semantics; its source, + // schema and replay structures were bounded by request::prepare. + let bytes = serde_json::to_vec(wire).map_err(|_| "invalid native request JSON")?; + let uri = endpoint + .as_str() + .parse::() + .map_err(|_| "invalid native endpoint")?; + let body = reqwest::Body::wrap_stream(futures_util::stream::once(async move { + Ok::<_, Infallible>(Bytes::from(bytes)) + })); + let mut request = http::Request::builder() + .method("POST") + .uri(uri) + .body(body) + .map_err(|_| "invalid native request")?; + *request.headers_mut() = self.native_headers.clone(); + let mut authorization = HeaderValue::from_str(&format!("Bearer {access}")) + .map_err(|_| "invalid native authorization")?; + authorization.set_sensitive(true); + request + .headers_mut() + .insert(header::AUTHORIZATION, authorization); + request.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + request + .headers_mut() + .insert(header::ACCEPT_ENCODING, HeaderValue::from_static("gzip")); + // No reqwest client execution/default headers and no known byte SizeHint. + // HTTP/1.1 chunking or HTTP/2 DATA is chosen by the negotiated protocol. + if Instant::now() >= header_deadline || Instant::now() >= request_deadline { + return Err("native headers deadline exceeded"); + } + let response = self + .client + .request(request) + .await + .map_err(|_| "native headers transport failed")?; + let (parts, body) = response.into_parts(); + let stream = body + .into_data_stream() + .map(|chunk| chunk.map_err(|_| BodyError::Transport)) + .boxed(); + Ok(NativeResponse::new( + parts.status, + parts.headers, + stream, + request_deadline, + )) + } +} + +// Keep production and the owned-driver cancellation fixture on one policy. +// The executor is Hyper's existing boundary, not a transport/config preference. +fn generation_client_builder(executor: E) -> hyper_util::client::legacy::Builder +where + E: hyper::rt::Executor + Send>>> + + Send + + Sync + + Clone + + 'static, +{ + let mut builder = Client::builder(executor); + builder + .timer(TokioTimer::new()) + .pool_timer(TokioTimer::new()) + .retry_canceled_requests(false) + .pool_max_idle_per_host(8) + .pool_idle_timeout(Duration::from_secs(90)); + builder +} + +fn generation_connector() -> Result { + let tls = tls_configuration()?; + let mut http = HttpConnector::new(); + http.enforce_http(false); + http.set_nodelay(true); + http.set_connect_timeout(Some(CONNECT_TIMEOUT)); + http.set_keepalive(Some(Duration::from_secs(15))); + http.set_keepalive_interval(Some(Duration::from_secs(15))); + http.set_keepalive_retries(Some(3)); + let proxy_tls = HttpsConnectorBuilder::new() + .with_tls_config(tls.clone()) + .https_only() + .enable_http1() + .wrap_connector(http.clone()); + let mut proxy_headers = HeaderMap::new(); + // Baseline reqwest CONNECT uses its client-builder identity, not the + // per-request native UA override that belongs inside origin TLS. + proxy_headers.insert( + header::USER_AGENT, + HeaderValue::from_static(concat!("claude-code-proxy/", env!("CARGO_PKG_VERSION"))), + ); + let route = RouteConnector { + http: http.clone(), + proxy_tls, + proxy_headers, + matcher: Arc::new(Matcher::from_system()), + }; + let https = HttpsConnectorBuilder::new() + .with_tls_config(tls) + .https_only() + .enable_http1() + .enable_http2() + .wrap_connector(route); + Ok(Connector { + https, + #[cfg(test)] + test_http: http, + }) +} + +fn tls_configuration() -> Result { + let mut roots = rustls::RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + let native = rustls_native_certs::load_native_certs(); + let (valid, invalid) = roots.add_parsable_certificates(native.certs); + // Same acceptance policy as the existing reqwest client: tolerate partial + // native-store errors, but do not hide an entirely invalid native cert set. + if valid == 0 && invalid > 0 { + return Err("native TLS roots initialization failed"); + } + let provider = Arc::new(rustls::crypto::ring::default_provider()); + Ok(rustls::ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .map_err(|_| "native TLS versions initialization failed")? + .with_root_certificates(roots) + .with_no_client_auth()) +} + +fn test_loopback_http(endpoint: &url::Url) -> bool { + #[cfg(test)] + { + endpoint.scheme() == "http" + && endpoint.host().is_some_and(|host| match host { + url::Host::Ipv4(ip) => ip.is_loopback(), + url::Host::Ipv6(ip) => ip.is_loopback(), + _ => false, + }) + } + #[cfg(not(test))] + { + let _ = endpoint; + false + } +} + +#[derive(Clone)] +struct Connector { + https: HttpsConnector, + #[cfg(test)] + test_http: HttpConnector, +} + +impl Service for Connector { + type Response = MaybeHttpsStream; + type Error = BoxError; + type Future = Connecting; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.https.poll_ready(cx) + } + + fn call(&mut self, uri: Uri) -> Connecting { + #[cfg(test)] + if uri.scheme_str() == Some("http") { + let valid = uri + .host() + .and_then(|host| { + host.trim_matches(['[', ']']) + .parse::() + .ok() + }) + .is_some_and(|ip| ip.is_loopback()); + if valid { + // Existing unit fixtures only. Never proxy cleartext Bearer; + // normal-library integration tests use verified HTTPS instead. + let connecting = self.test_http.call(uri); + return Box::pin(connect_with_timeout(async move { + Ok(MaybeHttpsStream::Http(RouteIo::Plain(Box::new( + connecting.await?, + )))) + })); + } + } + let connecting = self.https.call(uri); + Box::pin(connect_with_timeout(connecting)) + } +} + +async fn connect_with_timeout(future: F) -> std::result::Result +where + F: Future>, +{ + tokio::time::timeout(CONNECT_TIMEOUT, future) + .await + .map_err(|_| { + Box::new(io::Error::new( + io::ErrorKind::TimedOut, + "native connect timeout", + )) as BoxError + })? +} + +#[derive(Clone)] +struct RouteConnector { + http: HttpConnector, + proxy_tls: HttpsConnector, + proxy_headers: HeaderMap, + matcher: Arc, +} + +impl Service for RouteConnector { + type Response = RouteIo; + type Error = BoxError; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, destination: Uri) -> Self::Future { + let mut http = self.http.clone(); + let proxy_tls = self.proxy_tls.clone(); + let proxy_headers = self.proxy_headers.clone(); + let intercepted = self.matcher.intercept(&destination); + Box::pin(async move { + let Some(proxy) = intercepted else { + return Ok(RouteIo::Plain(Box::new(http.call(destination).await?))); + }; + let scheme = proxy + .uri() + .scheme_str() + .ok_or_else(|| io::Error::other("invalid native proxy scheme"))?; + match scheme { + "http" => { + let mut tunnel = + Tunnel::new(tcp_proxy_uri(proxy.uri())?, http).with_headers(proxy_headers); + if let Some(auth) = proxy.basic_auth() { + tunnel = tunnel.with_auth(auth.clone()); + } + Ok(RouteIo::Plain(Box::new(tunnel.call(destination).await?))) + } + "https" => { + // The proxy TLS connector is HTTP/1 only for CONNECT. The + // independent outer HttpsConnector negotiates origin h1/h2. + let mut tunnel = + Tunnel::new(proxy.uri().clone(), proxy_tls).with_headers(proxy_headers); + if let Some(auth) = proxy.basic_auth() { + tunnel = tunnel.with_auth(auth.clone()); + } + Ok(RouteIo::ProxyTls(Box::new(tunnel.call(destination).await?))) + } + "socks4" | "socks4a" => { + // Keep the locked legacy helper's wire behavior. Its extra + // NUL after an empty USERID is incompatible with strict RFC + // SOCKS4/4a peers; negative baseline-matching tests document + // that inherited limitation, not positive support. + let target = socks_destination(&destination, scheme).await?; + let mut socks = SocksV4::new(tcp_proxy_uri(proxy.uri())?, http); + Ok(RouteIo::Plain(Box::new(socks.call(target).await?))) + } + "socks5" | "socks5h" => { + let target = socks_destination(&destination, scheme).await?; + let mut socks = SocksV5::new(tcp_proxy_uri(proxy.uri())?, http); + if let Some((user, password)) = proxy.raw_auth() { + socks = socks.with_auth(user.to_owned(), password.to_owned()); + } + Ok(RouteIo::Plain(Box::new(socks.call(target).await?))) + } + _ => Err(io::Error::other("unsupported native proxy scheme").into()), + } + }) + } +} + +async fn socks_destination(destination: &Uri, scheme: &str) -> std::result::Result { + if matches!(scheme, "socks4a" | "socks5h") { + return Ok(destination.clone()); + } + let host = destination + .host() + .ok_or_else(|| io::Error::other("native SOCKS destination missing"))? + .trim_matches(['[', ']']); + let port = destination.port_u16().unwrap_or(443); + // The cached helpers' local_dns switch performs blocking getaddrinfo. Resolve + // before their handshake so DNS is owned by the complete connect deadline. + // Their Uri interface cannot encode an IPv6 literal as an IP address (host() + // includes brackets); do not silently turn local DNS into proxy DNS. + let address = tokio::net::lookup_host((host, port)) + .await? + .find(|address| address.is_ipv4()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::Unsupported, + "native SOCKS local address unsupported", + ) + })?; + let mut parts = destination.clone().into_parts(); + parts.authority = Some(address.to_string().parse()?); + Ok(Uri::from_parts(parts)?) +} + +fn tcp_proxy_uri(proxy: &Uri) -> std::result::Result { + let default_port = match proxy.scheme_str() { + Some("http") => 80, + Some("https") => 443, + Some("socks4" | "socks4a" | "socks5" | "socks5h") => 1080, + _ => return Err(io::Error::other("unsupported native proxy scheme").into()), + }; + let host = proxy + .host() + .ok_or_else(|| io::Error::other("native proxy host missing"))?; + let host = if host.contains(':') && !host.starts_with('[') { + format!("[{host}]") + } else { + host.to_owned() + }; + Ok(Uri::builder() + .scheme("http") + .authority(format!( + "{host}:{}", + proxy.port_u16().unwrap_or(default_port) + )) + .path_and_query("/") + .build()?) +} + +enum RouteIo { + Plain(Box), + ProxyTls(Box>), +} + +impl Connection for RouteIo { + fn connected(&self) -> Connected { + match self { + Self::Plain(io) => io.connected(), + Self::ProxyTls(io) => io.connected(), + } + } +} + +impl Read for RouteIo { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buffer: ReadBufCursor<'_>, + ) -> Poll> { + match self.get_mut() { + Self::Plain(io) => Pin::new(io.as_mut()).poll_read(cx, buffer), + Self::ProxyTls(io) => Pin::new(io.as_mut()).poll_read(cx, buffer), + } + } +} + +impl HttpWrite for RouteIo { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + bytes: &[u8], + ) -> Poll> { + match self.get_mut() { + Self::Plain(io) => Pin::new(io.as_mut()).poll_write(cx, bytes), + Self::ProxyTls(io) => Pin::new(io.as_mut()).poll_write(cx, bytes), + } + } + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + Self::Plain(io) => Pin::new(io.as_mut()).poll_flush(cx), + Self::ProxyTls(io) => Pin::new(io.as_mut()).poll_flush(cx), + } + } + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + Self::Plain(io) => Pin::new(io.as_mut()).poll_shutdown(cx), + Self::ProxyTls(io) => Pin::new(io.as_mut()).poll_shutdown(cx), + } + } + fn is_write_vectored(&self) -> bool { + match self { + Self::Plain(io) => io.is_write_vectored(), + Self::ProxyTls(io) => io.is_write_vectored(), + } + } + fn poll_write_vectored( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + bytes: &[IoSlice<'_>], + ) -> Poll> { + match self.get_mut() { + Self::Plain(io) => Pin::new(io.as_mut()).poll_write_vectored(cx, bytes), + Self::ProxyTls(io) => Pin::new(io.as_mut()).poll_write_vectored(cx, bytes), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum BodyError { + ByteLimit, + Idle, + Deadline, + Transport, + Compression, + Encoding, +} +impl BodyError { + pub(super) fn message(self) -> &'static str { + match self { + Self::ByteLimit => "native turn byte limit", + Self::Idle => "native stream idle timeout", + Self::Deadline => "native turn deadline exceeded", + Self::Transport => "native stream transport failed", + Self::Compression => "invalid native gzip body", + Self::Encoding => "unsupported native content encoding", + } + } +} + +struct BoundedWriter { + bytes: Vec, + total: usize, + limit: usize, + exceeded: bool, +} +impl BoundedWriter { + fn new(limit: usize) -> Self { + Self { + bytes: Vec::new(), + total: 0, + limit, + exceeded: false, + } + } + fn take(&mut self) -> Bytes { + Bytes::from(std::mem::take(&mut self.bytes)) + } +} +impl Write for BoundedWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + let Some(total) = self + .total + .checked_add(bytes.len()) + .filter(|total| *total <= self.limit) + else { + self.exceeded = true; + return Err(io::Error::other("native decoded byte limit")); + }; + self.total = total; + self.bytes.extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +enum Decoder { + Identity, + Gzip(Box>), +} + +/// Original headers/hint describe ENCODED bytes. The typed reader yields only +/// validated decoded bytes and never erases byte/idle/deadline failures into a +/// reqwest::Error. Status/content-type remain available before any body work. +pub(crate) struct NativeResponse { + status: StatusCode, + headers: HeaderMap, + encoded_hint: Option, + body: BoxStream<'static, std::result::Result>, + remaining: Bytes, + encoded: usize, + limit: usize, + decoder: Decoder, + initial_error: Option, + deadline: Instant, + finished: bool, +} + +impl NativeResponse { + fn new( + status: StatusCode, + headers: HeaderMap, + body: BoxStream<'static, std::result::Result>, + deadline: Instant, + ) -> Self { + let limit = if status.is_success() { + MAX_TURN_BYTES + } else { + MAX_ERROR_BYTES + }; + let hint = encoded_hint(&headers); + let encoding = response_encoding(&headers); + let initial_error = match (hint.as_ref(), encoding.as_ref()) { + (Ok(Some(length)), _) if *length > limit as u64 => Some(BodyError::ByteLimit), + (Err(error), _) | (_, Err(error)) => Some(*error), + _ => None, + }; + let decoder = if encoding == Ok(true) { + Decoder::Gzip(Box::new(flate2::write::MultiGzDecoder::new( + BoundedWriter::new(limit), + ))) + } else { + Decoder::Identity + }; + Self { + status, + headers, + encoded_hint: hint.ok().flatten(), + body, + remaining: Bytes::new(), + encoded: 0, + limit, + decoder, + initial_error, + deadline, + finished: false, + } + } + pub(super) fn status(&self) -> StatusCode { + self.status + } + pub(super) fn headers(&self) -> &HeaderMap { + &self.headers + } + pub(super) fn encoded_length_hint(&self) -> Option { + self.encoded_hint + } + + fn fail(&mut self, error: BodyError) -> std::result::Result, BodyError> { + self.finished = true; + self.remaining = Bytes::new(); + self.body = futures_util::stream::empty().boxed(); + Err(error) + } + + pub(super) async fn chunk(&mut self) -> std::result::Result, BodyError> { + if let Some(error) = self.initial_error.take() { + return self.fail(error); + } + if self.finished { + return Ok(None); + } + let mut work = 0; + loop { + if Instant::now() >= self.deadline { + return self.fail(BodyError::Deadline); + } + if self.remaining.is_empty() { + let idle = (Instant::now() + READ_IDLE).min(self.deadline); + let next = match tokio::time::timeout_at(idle, self.body.next()).await { + Ok(Some(Ok(chunk))) => Some(chunk), + Ok(Some(Err(error))) => return self.fail(error), + Ok(None) => None, + Err(_) => { + return self.fail(if Instant::now() >= self.deadline { + BodyError::Deadline + } else { + BodyError::Idle + }); + } + }; + let Some(chunk) = next else { + self.finished = true; + return match &mut self.decoder { + Decoder::Identity => Ok(None), + Decoder::Gzip(decoder) => { + if decoder.header().is_none() || decoder.try_finish().is_err() { + Err(if decoder.get_ref().exceeded { + BodyError::ByteLimit + } else { + BodyError::Compression + }) + } else { + let bytes = decoder.get_mut().take(); + Ok((!bytes.is_empty()).then_some(bytes)) + } + } + }; + }; + let Some(total) = self + .encoded + .checked_add(chunk.len()) + .filter(|size| *size <= self.limit) + else { + return self.fail(BodyError::ByteLimit); + }; + self.encoded = total; + self.remaining = chunk; + if self.remaining.is_empty() { + work += 1; + if work == 32 { + tokio::task::yield_now().await; + work = 0; + } + continue; + } + } + let chunk = self + .remaining + .split_to(self.remaining.len().min(DECODE_INPUT_CHUNK)); + let output = match &mut self.decoder { + Decoder::Identity => chunk, + Decoder::Gzip(decoder) => { + if decoder.write_all(&chunk).is_err() { + let error = if decoder.get_ref().exceeded { + BodyError::ByteLimit + } else { + BodyError::Compression + }; + return self.fail(error); + } + decoder.get_mut().take() + } + }; + if !output.is_empty() { + return Ok(Some(output)); + } + work += 1; + if work == 32 { + tokio::task::yield_now().await; + work = 0; + } + } + } +} + +fn encoded_hint(headers: &HeaderMap) -> std::result::Result, BodyError> { + let mut length = None; + for value in headers.get_all(header::CONTENT_LENGTH).iter() { + for value in value.to_str().map_err(|_| BodyError::Transport)?.split(',') { + let value = value.trim(); + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(BodyError::Transport); + } + let parsed = value.parse::().map_err(|_| BodyError::Transport)?; + if length.is_some_and(|old| old != parsed) { + return Err(BodyError::Transport); + } + length = Some(parsed); + } + } + Ok(length) +} + +fn response_encoding(headers: &HeaderMap) -> std::result::Result { + let mut values = headers.get_all(header::CONTENT_ENCODING).iter(); + let Some(value) = values.next() else { + return Ok(false); + }; + if values.next().is_some() { + return Err(BodyError::Encoding); + } + let value = value.to_str().map_err(|_| BodyError::Encoding)?.trim(); + if value.eq_ignore_ascii_case("identity") { + Ok(false) + } else if value.eq_ignore_ascii_case("gzip") { + Ok(true) + } else { + Err(BodyError::Encoding) + } +} + +#[cfg(test)] +impl From for NativeResponse { + fn from(response: reqwest::Response) -> Self { + let status = response.status(); + let headers = response.headers().clone(); + let stream = response + .bytes_stream() + .map(|chunk| chunk.map_err(|_| BodyError::Transport)) + .boxed(); + Self::new( + status, + headers, + stream, + Instant::now() + Duration::from_secs(180), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use std::sync::atomic::{AtomicUsize, Ordering}; + + fn gzip(bytes: &[u8]) -> Vec { + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(bytes).unwrap(); + encoder.finish().unwrap() + } + + fn response(status: u16, encoding: Option<&str>, bytes: &[u8], chunk: usize) -> NativeResponse { + let mut headers = HeaderMap::new(); + if let Some(encoding) = encoding { + headers.insert("content-encoding", HeaderValue::from_str(encoding).unwrap()); + } + let chunks = bytes + .chunks(chunk) + .map(Bytes::copy_from_slice) + .map(Ok) + .collect::>(); + NativeResponse::new( + StatusCode::from_u16(status).unwrap(), + headers, + futures_util::stream::iter(chunks).boxed(), + Instant::now() + Duration::from_secs(180), + ) + } + + async fn decoded(mut response: NativeResponse) -> std::result::Result, BodyError> { + let mut result = Vec::new(); + while let Some(chunk) = response.chunk().await? { + result.extend_from_slice(&chunk); + } + Ok(result) + } + + #[tokio::test] + async fn antigravity_generation_gzip_fragments_require_complete_members() { + let first = gzip(b"first"); + let mut concatenated = first.clone(); + concatenated.extend(gzip(b"second")); + for chunk in [1, 3, 16, 4096] { + assert_eq!( + decoded(response(200, Some("GZip"), &concatenated, chunk)) + .await + .unwrap(), + b"firstsecond" + ); + let mut crc = first.clone(); + let index = crc.len() - 8; + crc[index] ^= 1; + let mut truncated = first.clone(); + truncated.pop(); + let mut junk = first.clone(); + junk.extend_from_slice(b"trailing"); + let mut partial = first.clone(); + partial.extend_from_slice(&[0x1f, 0x8b]); + for bytes in [Vec::new(), crc, truncated, junk, partial] { + assert_eq!( + decoded(response(200, Some("gzip"), &bytes, chunk)) + .await + .unwrap_err(), + BodyError::Compression + ); + } + } + } + + #[tokio::test] + async fn antigravity_generation_error_decode_limit_is_not_transport_error() { + let encoded = gzip(&vec![b'x'; MAX_ERROR_BYTES + 1]); + assert!(encoded.len() < MAX_ERROR_BYTES); + assert_eq!( + decoded(response(429, Some("gzip"), &encoded, 1)) + .await + .unwrap_err(), + BodyError::ByteLimit + ); + assert_eq!( + decoded(response(200, Some("identity"), b"plain", 1)) + .await + .unwrap(), + b"plain" + ); + } + + #[test] + fn antigravity_generation_bounded_sink_rejects_before_allocation() { + let mut sink = BoundedWriter::new(8); + assert!(sink.write_all(b"123456789").is_err()); + assert!(sink.bytes.is_empty()); + assert!(sink.exceeded); + let mut sink = BoundedWriter::new(8); + sink.write_all(b"12345678").unwrap(); + assert!(sink.write_all(b"9").is_err()); + assert_eq!(sink.bytes.len(), 8); + } + + #[tokio::test] + async fn antigravity_generation_original_encoded_hint_prevents_polling() { + for (status, limit) in [ + (StatusCode::OK, MAX_TURN_BYTES), + (StatusCode::TOO_MANY_REQUESTS, MAX_ERROR_BYTES), + ] { + let polls = Arc::new(AtomicUsize::new(0)); + let observed = polls.clone(); + let stream = futures_util::stream::poll_fn(move |_| { + observed.fetch_add(1, Ordering::SeqCst); + Poll::Ready(None) + }) + .boxed(); + let mut headers = HeaderMap::new(); + headers.insert( + "content-length", + HeaderValue::from_str(&(limit + 1).to_string()).unwrap(), + ); + headers.insert("content-encoding", HeaderValue::from_static("gzip")); + headers.insert("retry-after", HeaderValue::from_static("7")); + let mut native = NativeResponse::new( + status, + headers, + stream, + Instant::now() + Duration::from_secs(180), + ); + assert_eq!(native.encoded_length_hint(), Some((limit + 1) as u64)); + assert_eq!(native.status(), status); + assert_eq!(native.headers()["retry-after"], "7"); + assert_eq!(native.chunk().await.unwrap_err(), BodyError::ByteLimit); + assert_eq!(polls.load(Ordering::SeqCst), 0); + } + } + + #[tokio::test] + async fn antigravity_generation_all_content_encoding_values_are_validated() { + for values in [ + vec!["gzip", "gzip"], + vec!["identity", "gzip"], + vec!["gzip, identity"], + vec!["br"], + vec![""], + ] { + let mut headers = HeaderMap::new(); + for value in values { + headers.append("content-encoding", HeaderValue::from_str(value).unwrap()); + } + let mut native = NativeResponse::new( + StatusCode::OK, + headers, + futures_util::stream::empty().boxed(), + Instant::now() + Duration::from_secs(180), + ); + assert_eq!(native.chunk().await.unwrap_err(), BodyError::Encoding); + } + } + + #[tokio::test(start_paused = true)] + async fn antigravity_generation_idle_and_total_errors_remain_typed() { + for (budget, expected) in [(180, BodyError::Idle), (10, BodyError::Deadline)] { + let mut native = NativeResponse::new( + StatusCode::OK, + HeaderMap::new(), + futures_util::stream::pending().boxed(), + Instant::now() + Duration::from_secs(budget), + ); + assert_eq!(native.chunk().await.unwrap_err(), expected); + } + assert_eq!(BodyError::ByteLimit.message(), "native turn byte limit"); + assert_eq!(BodyError::Idle.message(), "native stream idle timeout"); + assert_eq!( + BodyError::Deadline.message(), + "native turn deadline exceeded" + ); + } + + #[tokio::test(start_paused = true)] + async fn antigravity_generation_gzip_header_progress_never_renews_total_deadline() { + let started = Instant::now(); + let stream = futures_util::stream::unfold(0_usize, |index| async move { + tokio::time::sleep(Duration::from_secs(5)).await; + Some(( + Ok(Bytes::from_static(if index == 0 { + &[0x1f] + } else { + &[0x8b] + })), + index + 1, + )) + }) + .boxed(); + let mut headers = HeaderMap::new(); + headers.insert("content-encoding", HeaderValue::from_static("gzip")); + let mut native = NativeResponse::new( + StatusCode::OK, + headers, + stream, + started + Duration::from_secs(12), + ); + assert_eq!(native.chunk().await.unwrap_err(), BodyError::Deadline); + assert_eq!(Instant::now() - started, Duration::from_secs(12)); + } + + #[tokio::test(start_paused = true)] + async fn antigravity_generation_connect_budget_covers_whole_connector_future() { + let started = Instant::now(); + let result = connect_with_timeout(futures_util::future::pending::< + std::result::Result<(), BoxError>, + >()) + .await; + assert!(result.is_err()); + assert_eq!(Instant::now() - started, CONNECT_TIMEOUT); + } + + #[tokio::test] + async fn antigravity_generation_local_socks_dns_is_resolved_before_handshake() { + let destination: Uri = "https://localhost:443/".parse().unwrap(); + for local in ["socks4", "socks5"] { + let resolved = socks_destination(&destination, local).await.unwrap(); + assert!( + resolved + .host() + .unwrap() + .parse::() + .unwrap() + .is_loopback() + ); + } + for remote in ["socks4a", "socks5h"] { + assert_eq!( + socks_destination(&destination, remote).await.unwrap(), + destination + ); + } + } + + #[tokio::test] + async fn antigravity_generation_drop_cancels_partial_gzip_success_and_error_readers() { + struct DropGuard(Arc); + impl Drop for DropGuard { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + for status in [StatusCode::OK, StatusCode::TOO_MANY_REQUESTS] { + let dropped = Arc::new(AtomicUsize::new(0)); + let guard = DropGuard(dropped.clone()); + let mut first = true; + let body = futures_util::stream::poll_fn(move |_| { + let _owned = &guard; + if first { + first = false; + Poll::Ready(Some(Ok(Bytes::from_static(&[0x1f])))) + } else { + Poll::Pending + } + }) + .boxed(); + let mut headers = HeaderMap::new(); + headers.insert("content-encoding", HeaderValue::from_static("gzip")); + let mut native = NativeResponse::new( + status, + headers, + body, + Instant::now() + Duration::from_secs(180), + ); + let mut reader = Box::pin(async move { native.chunk().await }); + assert!(futures_util::poll!(reader.as_mut()).is_pending()); + assert_eq!(dropped.load(Ordering::SeqCst), 0); + drop(reader); + assert_eq!( + dropped.load(Ordering::SeqCst), + 1, + "partial gzip state and owned upstream must drop without a detached collector" + ); + } + } + + #[tokio::test] + async fn antigravity_generation_empty_frames_yield_before_unbounded_work() { + let polls = Arc::new(AtomicUsize::new(0)); + let observed = polls.clone(); + let stream = futures_util::stream::poll_fn(move |_| { + let count = observed.fetch_add(1, Ordering::SeqCst); + if count < 4096 { + Poll::Ready(Some(Ok(Bytes::new()))) + } else { + Poll::Pending + } + }) + .boxed(); + let mut native = NativeResponse::new( + StatusCode::OK, + HeaderMap::new(), + stream, + Instant::now() + Duration::from_secs(180), + ); + let future = native.chunk(); + tokio::pin!(future); + assert!(futures_util::poll!(future.as_mut()).is_pending()); + assert!( + polls.load(Ordering::SeqCst) <= 32, + "empty encoded frames must yield within bounded work" + ); + } + + #[tokio::test] + async fn antigravity_generation_idle_socket_closes_without_a_followup_request() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = + url::Url::parse(&format!("http://{}", listener.local_addr().unwrap())).unwrap(); + let (closed_tx, closed_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let service = hyper::service::service_fn( + |request: http::Request| async move { + let mut body = request.into_body(); + let mut count = 0; + while let Some(frame) = body.frame().await { + if let Ok(bytes) = frame.unwrap().into_data() { + count += bytes.len(); + assert!(count <= 4096); + } + } + Ok::<_, Infallible>( + http::Response::builder() + .status(200) + .header("content-type", "text/event-stream") + .body(http_body_util::Full::new(Bytes::from_static(b"idle"))) + .unwrap(), + ) + }, + ); + let closed = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(socket), service) + .await + .is_ok(); + let _ = closed_tx.send(closed); + }); + let client = GenerationClient::new(&Config { + base_url: origin.clone(), + ..Config::default() + }) + .unwrap(); + let started = Instant::now(); + let mut native = client + .send( + origin, + "public-test-access", + &serde_json::json!({"synthetic":"idle"}), + started + Duration::from_secs(30), + started + Duration::from_secs(180), + ) + .await + .unwrap(); + while native.chunk().await.unwrap().is_some() {} + drop(native); + // Register the actual idle entry/task before shifting only this runtime's + // clock. Keep the client alive and issue NO second request. + tokio::time::sleep(Duration::from_millis(20)).await; + tokio::time::pause(); + tokio::time::advance(Duration::from_secs(91)).await; + tokio::task::yield_now().await; + tokio::time::resume(); + let closed = matches!( + tokio::time::timeout(Duration::from_secs(1), closed_rx).await, + Ok(Ok(true)) + ); + if !closed { + server.abort(); + } + assert!( + closed, + "idle pool must close its socket without a followup request" + ); + server.await.unwrap(); + drop(client); + } + + // These executor futures are owned and polled by the test, never detached. + // In the cached legacy client the first execute() is the H1 connection + // driver; later futures are pool readiness/idle work. Bound all of them. + type Driver = Pin + Send>>; + const MAX_QUEUE_TEST_TASKS: usize = 8; + + #[derive(Default)] + struct QueueDriverWake { + parent: futures_util::task::AtomicWaker, + wakes: AtomicUsize, + } + + impl futures_util::task::ArcWake for QueueDriverWake { + fn wake_by_ref(wake: &Arc) { + wake.wakes.fetch_add(1, Ordering::SeqCst); + wake.parent.wake(); + } + } + + #[derive(Clone, Default)] + struct QueueExecutor { + pending: Arc>>, + scheduled: Arc, + } + + impl hyper::rt::Executor for QueueExecutor + where + F: Future + Send + 'static, + { + fn execute(&self, future: F) { + let count = self.scheduled.fetch_add(1, Ordering::SeqCst); + assert!(count < MAX_QUEUE_TEST_TASKS, "bounded queue fixture tasks"); + self.pending.lock().unwrap().push(Box::pin(future)); + } + } + + struct QueueDrivers { + executor: QueueExecutor, + tasks: Vec>, + wake: Arc, + driver_polls: usize, + paused: bool, + } + + impl QueueDrivers { + fn new(executor: QueueExecutor) -> Self { + Self { + executor, + tasks: Vec::new(), + wake: Arc::default(), + driver_polls: 0, + paused: false, + } + } + + fn poll_tasks(&mut self, cx: &mut Context<'_>) { + self.wake.parent.register(cx.waker()); + // Include helper futures scheduled while polling, without holding + // the executor mutex across a poll or polling any task twice. + for index in 0..MAX_QUEUE_TEST_TASKS { + self.tasks + .extend(self.executor.pending.lock().unwrap().drain(..).map(Some)); + let Some(slot) = self.tasks.get_mut(index) else { + break; + }; + let Some(task) = slot.as_mut() else { + continue; + }; + let result = if index == 0 { + if self.paused { + continue; + } + self.driver_polls += 1; + let wake = futures_util::task::waker_ref(&self.wake); + task.as_mut().poll(&mut Context::from_waker(&wake)) + } else { + task.as_mut().poll(cx) + }; + if result.is_ready() { + *slot = None; + } + } + } + + async fn run(&mut self, future: F) -> F::Output { + tokio::pin!(future); + futures_util::future::poll_fn(|cx| { + let result = future.as_mut().poll(cx); + // Also settle the driver/readiness helpers when the operation + // completes, so a fully consumed warmup can enter the pool. + self.poll_tasks(cx); + result + }) + .await + } + + fn pause_ack(&mut self) -> (usize, usize) { + assert!(self.tasks.first().is_some_and(Option::is_some)); + self.paused = true; + (self.driver_polls, self.wake.wakes.load(Ordering::SeqCst)) + } + + fn cancel_paused_driver(&mut self) { + assert!(self.paused); + // Dropping Hyper's queued Envelope returns the untouched request + // with Error::Canceled; do not poll/write this driver after pause. + drop(self.tasks[0].take().expect("owned paused H1 driver")); + } + + async fn finish(mut self) { + self.paused = false; + let finished = tokio::time::timeout( + Duration::from_secs(1), + futures_util::future::poll_fn(|cx| { + self.poll_tasks(cx); + if self.tasks.iter().all(Option::is_none) + && self.executor.pending.lock().unwrap().is_empty() + { + Poll::Ready(()) + } else { + Poll::Pending + } + }), + ) + .await; + // Every future is still owned here even on timeout; dropping this + // set cancels it synchronously. No spawned driver can outlive it. + assert!(finished.is_ok(), "owned client drivers must finish"); + } + } + + struct QueueOriginTask(Option>); + + impl Drop for QueueOriginTask { + fn drop(&mut self) { + if let Some(task) = &self.0 { + task.abort(); + } + } + } + + async fn reused_canceled_queue_case(retry_counterfactual: bool) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin: Uri = format!("http://{}/native", listener.local_addr().unwrap()) + .parse() + .unwrap(); + let tcp = Arc::new(AtomicUsize::new(0)); + let native = Arc::new(AtomicUsize::new(0)); + let (stop_tx, mut stop_rx) = tokio::sync::oneshot::channel(); + let observed_tcp = tcp.clone(); + let observed_native = native.clone(); + let mut server = QueueOriginTask(Some(tokio::spawn(async move { + let mut connections = tokio::task::JoinSet::new(); + loop { + let socket = tokio::select! { + _ = &mut stop_rx => break, + accepted = listener.accept() => accepted.unwrap().0, + }; + assert!(observed_tcp.fetch_add(1, Ordering::SeqCst) < 2); + let native = observed_native.clone(); + connections.spawn(async move { + let service = hyper::service::service_fn( + move |request: http::Request| { + let native = native.clone(); + async move { + assert_eq!(request.method(), http::Method::POST); + assert_eq!(request.uri().path(), "/native"); + // Count native HEADERS, separately from TCP and + // before consuming the bounded synthetic body. + assert!(native.fetch_add(1, Ordering::SeqCst) < 2); + let mut body = request.into_body(); + let mut bytes = Vec::new(); + let mut frames = 0; + while let Some(frame) = body.frame().await { + frames += 1; + assert!(frames <= 8); + if let Ok(data) = frame.unwrap().into_data() { + assert!(bytes.len() + data.len() <= 4096); + bytes.extend_from_slice(&data); + } + } + assert_eq!(bytes, b"{\"synthetic\":\"queue\"}"); + Ok::<_, Infallible>(http::Response::new(http_body_util::Full::new( + Bytes::from_static(b"ok"), + ))) + } + }, + ); + // Driver cancellation may close the first idle socket. + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(socket), service) + .await; + }); + } + connections.abort_all(); + while let Some(joined) = connections.join_next().await { + assert!(joined.is_ok() || joined.unwrap_err().is_cancelled()); + } + }))); + let executor = QueueExecutor::default(); + let mut drivers = QueueDrivers::new(executor.clone()); + let mut builder = generation_client_builder(executor); + if retry_counterfactual { + // Only this synthetic counterfactual differs from production policy. + builder.retry_canceled_requests(true); + } + let client: Client<_, reqwest::Body> = builder.build(generation_connector().unwrap()); + let request = || { + http::Request::builder() + .method("POST") + .uri(origin.clone()) + .body(reqwest::Body::wrap_stream(futures_util::stream::once( + async { Ok::<_, Infallible>(Bytes::from_static(b"{\"synthetic\":\"queue\"}")) }, + ))) + .unwrap() + }; + tokio::time::timeout( + Duration::from_secs(2), + drivers.run(async { + let response = client.request(request()).await.unwrap(); + assert_eq!(response.version(), http::Version::HTTP_11); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.into_body().collect().await.unwrap().to_bytes(), + "ok" + ); + }), + ) + .await + .expect("bounded actual H1 warmup"); + assert_eq!(tcp.load(Ordering::SeqCst), 1); + assert_eq!(native.load(Ordering::SeqCst), 1); + let (paused_polls, before_enqueue_wakes) = drivers.pause_ack(); + let mut second = Box::pin(client.request(request())); + // No await/yield between this snapshot, one request poll, and the wake + // observation. On this current-thread runtime no timer, socket reactor, + // or helper task can produce an unrelated wake in this interval. + let request_wake = futures_util::task::noop_waker(); + let mut request_cx = Context::from_waker(&request_wake); + assert!(second.as_mut().poll(&mut request_cx).is_pending()); + assert!(drivers.wake.wakes.load(Ordering::SeqCst) > before_enqueue_wakes); + assert_eq!(drivers.driver_polls, paused_polls); + assert_eq!(tcp.load(Ordering::SeqCst), 1, "no second TCP while gated"); + assert_eq!( + native.load(Ordering::SeqCst), + 1, + "second HEADERS not dispatched" + ); + drivers.cancel_paused_driver(); + let result = tokio::time::timeout(Duration::from_secs(2), drivers.run(second.as_mut())) + .await + .expect("bounded canceled-queue outcome"); + if retry_counterfactual { + let response = result.expect("retry=true must recover the untouched reused request"); + assert_eq!(response.status(), StatusCode::OK); + let body = tokio::time::timeout( + Duration::from_secs(2), + drivers.run(response.into_body().collect()), + ) + .await + .unwrap() + .unwrap() + .to_bytes(); + assert_eq!(body, "ok"); + } else { + let error = result.expect_err("production retry=false must be terminal"); + assert!(!error.is_connect(), "not a cold connect failure"); + assert!(error.connect_info().is_some()); + assert!( + error.source().is_some_and(|source| { + source + .downcast_ref::() + .is_some_and(hyper::Error::is_canceled) + }), + "queued request must produce Hyper cancellation" + ); + } + let expected_total = if retry_counterfactual { 2 } else { 1 }; + // Totals INCLUDE the one successful warmup. Second-request-only counts + // are respectively zero with production policy and one with retry=true. + assert_eq!(tcp.load(Ordering::SeqCst), expected_total); + assert_eq!(native.load(Ordering::SeqCst), expected_total); + assert_eq!( + tcp.load(Ordering::SeqCst) - 1, + usize::from(retry_counterfactual) + ); + assert_eq!( + native.load(Ordering::SeqCst) - 1, + usize::from(retry_counterfactual) + ); + drop(second); + drop(client); + drivers.finish().await; + stop_tx.send(()).unwrap(); + let mut server_task = server.0.take().unwrap(); + let stopped = tokio::time::timeout(Duration::from_secs(1), &mut server_task).await; + if stopped.is_err() { + server_task.abort(); + let _ = server_task.await; + panic!("bounded owned origin shutdown"); + } + stopped.unwrap().unwrap(); + } + + #[tokio::test(flavor = "current_thread")] + async fn antigravity_generation_reused_canceled_queue_is_not_retried() { + reused_canceled_queue_case(false).await; + } + + #[tokio::test(flavor = "current_thread")] + async fn antigravity_generation_reused_canceled_queue_retry_counterfactual() { + reused_canceled_queue_case(true).await; + } + + #[test] + fn antigravity_generation_proxy_tcp_authorities_and_default_ports() { + for (uri, expected) in [ + ("socks4://localhost/", "http://localhost:1080/"), + ("socks4a://localhost:123/", "http://localhost:123/"), + ("socks5://127.0.0.1/", "http://127.0.0.1:1080/"), + ("socks5h://[::1]/", "http://[::1]:1080/"), + ("http://localhost/", "http://localhost:80/"), + ("https://localhost/", "http://localhost:443/"), + ] { + assert_eq!( + tcp_proxy_uri(&uri.parse().unwrap()).unwrap().to_string(), + expected + ); + } + assert!(tcp_proxy_uri(&"ftp://localhost/".parse().unwrap()).is_err()); + } +} diff --git a/src/providers/antigravity/mod.rs b/src/providers/antigravity/mod.rs index 9bab261b..c09e989a 100644 --- a/src/providers/antigravity/mod.rs +++ b/src/providers/antigravity/mod.rs @@ -1,6 +1,7 @@ //! Experimental, opt-in native Argon subscription transport. pub(crate) mod auth; pub(crate) mod config; +pub(crate) mod generation; #[cfg(any(unix, test))] mod manual; mod refresh; @@ -23,12 +24,14 @@ use axum::{ response::{IntoResponse, Response}, }; use config::Config; +use generation::GenerationClient; use replay::{Binding, Result}; use serde_json::{Value, json}; use std::{sync::Arc, time::Duration}; pub struct AntigravityProvider { auth: Result>, + generation: Result, } impl Default for AntigravityProvider { fn default() -> Self { @@ -37,16 +40,22 @@ impl Default for AntigravityProvider { } impl AntigravityProvider { pub fn new() -> Self { - Self { - auth: Config::load() - .and_then(|c| Auth::new(c, Store::configured())) - .map(Arc::new), - } + let config = Config::load(); + let generation = config + .as_ref() + .map_err(|error| *error) + .and_then(GenerationClient::new); + let auth = config + .and_then(|config| Auth::new(config, Store::configured())) + .map(Arc::new); + Self { auth, generation } } #[cfg(test)] pub(crate) fn with_auth(auth: Auth) -> Self { + let generation = GenerationClient::new(&auth.config); Self { auth: Ok(Arc::new(auth)), + generation, } } @@ -64,6 +73,12 @@ impl AntigravityProvider { Ok(p) => p, Err(e) => return invalid(e), }; + let generation = match &self.generation { + Ok(client) => client, + Err(_) => { + return upstream_error("Antigravity generation transport initialization failed"); + } + }; let credential = match auth.access().await { Ok(c) => c, Err(error) => { @@ -104,16 +119,18 @@ impl AntigravityProvider { Ok(v) => v, Err(_) => return invalid("invalid native endpoint"), }; - let header_deadline = tokio::time::Instant::now() + Duration::from_secs(30); + let request_started = tokio::time::Instant::now(); + let header_deadline = request_started + Duration::from_secs(30); + let request_deadline = request_started + Duration::from_secs(180); let sent = tokio::time::timeout_at( header_deadline, - auth.client - .post(endpoint) - .bearer_auth(&credential.access_token) - .header("accept", "text/event-stream") - .headers(auth.config.native_headers()) - .json(&prepared.wire) - .send(), + generation.send( + endpoint, + &credential.access_token, + &prepared.wire, + header_deadline, + request_deadline, + ), ) .await; let upstream = match sent { @@ -137,7 +154,7 @@ impl AntigravityProvider { } let model = body.model.unwrap_or_default(); let work = async move { - let turn = tokio::time::timeout(Duration::from_secs(180), transport::collect(upstream)) + let turn = tokio::time::timeout_at(request_deadline, transport::collect(upstream)) .await .map_err(|_| "native turn deadline exceeded")??; if let Some(monitor) = ctx.monitor.as_ref() { @@ -163,8 +180,8 @@ impl AntigravityProvider { Err(e) => upstream_error(e), }; } - // The returned Body owns this future, including reqwest's response. No - // worker survives a downstream drop. Initial HTTP errors occurred above. + // The returned Body owns this future, including the native response and + // decoder. No worker survives a downstream drop. Initial errors occurred above. let outcome = NativeResponseOutcome::default(); let shared = outcome.clone(); let stream = futures_util::stream::once(async move { diff --git a/src/providers/antigravity/transport.rs b/src/providers/antigravity/transport.rs index b4446b9b..b08a54d1 100644 --- a/src/providers/antigravity/transport.rs +++ b/src/providers/antigravity/transport.rs @@ -1,5 +1,8 @@ //! Bounded native HTTP error diagnostics and SSE collection. No generation retry. -use super::replay::{self, Result}; +use super::{ + generation::NativeResponse, + replay::{self, Result}, +}; use crate::anthropic::json_error; use axum::{ http::{HeaderMap, HeaderValue, StatusCode}, @@ -263,9 +266,9 @@ fn combine_retry(header: &RetryHeader, body: RetryHint) -> RetryHint { } } -async fn error_body(mut upstream: reqwest::Response, deadline: Instant) -> Option> { +async fn error_body(mut upstream: NativeResponse, deadline: Instant) -> Option> { if upstream - .content_length() + .encoded_length_hint() .is_some_and(|n| n > MAX_ERROR_BODY as u64) || Instant::now() >= deadline { @@ -289,7 +292,11 @@ async fn error_body(mut upstream: reqwest::Response, deadline: Instant) -> Optio /// Initial native failures remain HTTP JSON errors even when the caller requested SSE. /// Only static labels and bounded integers escape; never log the response/body/parse errors. /// This future owns the upstream body, so dropping the request cancels inspection. -pub(crate) async fn http_error(upstream: reqwest::Response, header_deadline: Instant) -> Response { +pub(crate) async fn http_error( + upstream: impl Into, + header_deadline: Instant, +) -> Response { + let upstream = upstream.into(); let deadline = header_deadline.min(Instant::now() + Duration::from_secs(1)); let status = upstream.status(); let retry = RetryHeader::parse(upstream.headers(), time::OffsetDateTime::now_utc()); @@ -568,14 +575,9 @@ impl Reducer { } } -pub(crate) async fn collect(mut response: reqwest::Response) -> Result { +pub(crate) async fn collect(mut response: NativeResponse) -> Result { let mut reducer = Reducer::default(); - while let Some(chunk) = - tokio::time::timeout(std::time::Duration::from_secs(30), response.chunk()) - .await - .map_err(|_| "native stream idle timeout")? - .map_err(|_| "native stream transport failed")? - { + while let Some(chunk) = response.chunk().await.map_err(|error| error.message())? { reducer.push(&chunk)?; } reducer.finish() diff --git a/src/providers/antigravity_http_tests.rs b/src/providers/antigravity_http_tests.rs index 367f4ac1..c670cba7 100644 --- a/src/providers/antigravity_http_tests.rs +++ b/src/providers/antigravity_http_tests.rs @@ -241,7 +241,10 @@ async fn assert_generation_cap_ingress(stream: bool) { let captured = captured.lock().unwrap(); assert_eq!(captured.len(), cases.len()); for ((headers, wire), (_, cap)) in captured.iter().zip(&cases) { - assert_eq!(headers["accept"], "text/event-stream"); + assert!(!headers.contains_key("accept")); + assert_eq!(headers["accept-encoding"], "gzip"); + assert!(!headers.contains_key("content-length")); + assert_eq!(headers["transfer-encoding"], "chunked"); assert_eq!(headers["content-type"], "application/json"); assert_eq!(headers["authorization"], "Bearer proxy-owned-access"); assert_eq!(headers["user-agent"], expected_native_user_agent()); @@ -413,6 +416,89 @@ async fn antigravity_property_names_http_preflight_never_refreshes_or_generates( } } +#[tokio::test] +async fn antigravity_generation_init_error_is_separate_from_auth_and_counting() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let attempts = Arc::new(AtomicUsize::new(0)); + let observed = attempts.clone(); + let token = Server::start(Router::new().fallback(move || { + let observed = observed.clone(); + async move { + observed.fetch_add(1, Ordering::SeqCst); + http::StatusCode::INTERNAL_SERVER_ERROR + } + })) + .await; + let directory = tempfile::tempdir().unwrap(); + let store = Store::new(directory.path().join("antigravity/auth.json")); + let lock = store.lock(Duration::from_secs(1)).await.unwrap(); + store + .save( + &lock, + &Credential { + profile_id: "profile".into(), + project_id: Some("project".into()), + access_token: "expired-synthetic".into(), + refresh_token: Some("synthetic-refresh".into()), + expires_at: 1, + }, + ) + .unwrap(); + drop(lock); + // A private constructor injection isolates generation init failure without + // weakening the release Config loader or changing the auth client's policy. + let config = Config { + base_url: url::Url::parse("http://outside.invalid").unwrap(), + token_url: token.url.join("token").unwrap(), + client_id: Some("synthetic-client".into()), + ..Config::default() + }; + let provider = AntigravityProvider::with_auth(Auth::new(config, store).unwrap()); + assert_eq!( + provider.local_capability().unwrap()["status"]["configuration"], + "valid" + ); + let proxy = proxy(provider, None).await; + let counted = client() + .post(proxy.url.join("/v1/messages/count_tokens").unwrap()) + .json(&request(false)) + .send() + .await + .unwrap(); + assert_eq!(counted.status(), 200); + for stream in [false, true] { + let response = client() + .post(proxy.url.join("/v1/messages").unwrap()) + .json(&request(stream)) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 502); + let value: Value = response.json().await.unwrap(); + assert_eq!(value["error"]["type"], "api_error"); + assert!( + value["error"]["message"] + .as_str() + .unwrap() + .contains("generation transport initialization failed") + ); + } + let mut invalid = request(false); + invalid["temperature"] = json!(0.5); + let response = client() + .post(proxy.url.join("/v1/messages").unwrap()) + .json(&invalid) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 400); + assert_eq!( + attempts.load(Ordering::SeqCst), + 0, + "transport init failure/counting/preflight must never refresh" + ); +} + #[tokio::test] async fn antigravity_consumer_selected_origin_serves_discovery_and_generation() { for stream in [false, true] { @@ -787,6 +873,411 @@ async fn antigravity_http2_refused_stream_is_not_automatically_retried() { ); } +#[tokio::test] +async fn antigravity_legacy_reqwest_socks5_ipv6_literal_is_a_known_wire_gap() { + // Exercise the UNCHANGED OAuth/native-baseline reqwest builder, not the new + // generation connector. No origin exists or is contacted: the synthetic + // SOCKS proxy rejects the observed bracketed domain before any TLS/HTTP. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let observed = tokio::spawn(async move { + let (mut socket, _) = tokio::time::timeout(Duration::from_secs(3), listener.accept()) + .await + .unwrap() + .unwrap(); + let mut greeting = [0; 2]; + socket.read_exact(&mut greeting).await.unwrap(); + assert_eq!(greeting[0], 5); + let mut methods = vec![0; greeting[1] as usize]; + socket.read_exact(&mut methods).await.unwrap(); + socket.write_all(&[5, 0]).await.unwrap(); + let mut request = [0; 4]; + socket.read_exact(&mut request).await.unwrap(); + assert_eq!(&request[..3], &[5, 1, 0]); + let mut host = Vec::new(); + match request[3] { + 1 => { + host.resize(4, 0); + } + 4 => { + host.resize(16, 0); + } + 3 => { + let mut length = [0]; + socket.read_exact(&mut length).await.unwrap(); + host.resize(length[0] as usize, 0); + } + _ => panic!("unexpected synthetic SOCKS address type"), + } + socket.read_exact(&mut host).await.unwrap(); + let mut port = [0; 2]; + socket.read_exact(&mut port).await.unwrap(); + socket + .write_all(&[5, 4, 0, 1, 0, 0, 0, 0, 0, 0]) + .await + .unwrap(); + (request[3], host) + }); + let config = Config::default(); + let legacy = config + .client_builder() + .proxy(reqwest::Proxy::all(format!("socks5h://{address}")).unwrap()) + .timeout(Duration::from_secs(3)) + .build() + .unwrap(); + assert!( + legacy + .post("https://[::1]:443/v1internal:streamGenerateContent?alt=sse") + .headers(config.native_headers()) + .json(&json!({"synthetic":"baseline-proxy-probe"})) + .send() + .await + .is_err() + ); + let (kind, host) = observed.await.unwrap(); + assert_eq!( + kind, 3, + "baseline hypothesis: cached helper cannot emit literal IPv6 ATYP4 through Uri" + ); + assert_eq!( + host, b"[::1]", + "baseline hypothesis must be measured, never assumed as candidate parity" + ); +} + +#[tokio::test] +async fn antigravity_legacy_reqwest_local_socks5_ipv6_and_aaaa_only_are_measured_separately() { + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + for (case, endpoint, override_name, override_ip) in [ + ( + "ipv4-control", + "https://aaaa-only.fixture.invalid:443/", + Some("aaaa-only.fixture.invalid"), + Some(IpAddr::V4(Ipv4Addr::LOCALHOST)), + ), + ("literal-default", "https://[::1]:443/", None, None), + ( + "literal-controlled", + "https://[::1]:443/", + Some("[::1]"), + Some(IpAddr::V6(Ipv6Addr::LOCALHOST)), + ), + ( + "aaaa-only", + "https://aaaa-only.fixture.invalid:443/", + Some("aaaa-only.fixture.invalid"), + Some(IpAddr::V6(Ipv6Addr::LOCALHOST)), + ), + ] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (stop_tx, stop_rx) = tokio::sync::oneshot::channel(); + let peer = tokio::spawn(async move { + let accepted = tokio::select! { biased; + accepted = listener.accept() => Some(accepted.unwrap().0), + _ = stop_rx => None, + }; + let mut socket = accepted?; + let mut greeting = [0; 2]; + socket.read_exact(&mut greeting).await.unwrap(); + assert_eq!(greeting[0], 5); + let mut methods = vec![0; greeting[1] as usize]; + socket.read_exact(&mut methods).await.unwrap(); + socket.write_all(&[5, 0]).await.unwrap(); + let mut request = [0; 4]; + socket.read_exact(&mut request).await.unwrap(); + assert_eq!(&request[..3], &[5, 1, 0]); + let mut host = Vec::new(); + match request[3] { + 1 => host.resize(4, 0), + 4 => host.resize(16, 0), + 3 => { + let mut length = [0]; + socket.read_exact(&mut length).await.unwrap(); + host.resize(length[0] as usize, 0); + } + _ => panic!("unexpected synthetic SOCKS address type"), + } + socket.read_exact(&mut host).await.unwrap(); + let mut port = [0; 2]; + socket.read_exact(&mut port).await.unwrap(); + socket + .write_all(&[5, 4, 0, 1, 0, 0, 0, 0, 0, 0]) + .await + .unwrap(); + Some((request[3], host)) + }); + // Public locked reqwest DNS override isolates AAAA-only resolution; + // no real DNS origin/profile/network or candidate connector is involved. + let config = Config::default(); + let mut builder = config + .client_builder() + .proxy(reqwest::Proxy::all(format!("socks5://{address}")).unwrap()) + .timeout(Duration::from_secs(3)); + if let (Some(name), Some(ip)) = (override_name, override_ip) { + builder = builder.resolve(name, SocketAddr::new(ip, 443)); + } + let legacy = builder.build().unwrap(); + assert!( + legacy + .post(endpoint) + .json(&json!({"synthetic":"legacy-local-dns"})) + .send() + .await + .is_err() + ); + let _ = stop_tx.send(()); + let result = peer.await.unwrap(); + match case { + "ipv4-control" => assert_eq!( + result, + Some((1, vec![127, 0, 0, 1])), + "public override/proxy fixture must actually work" + ), + "literal-default" => match result { + None => eprintln!("BASELINE_LOCAL_SOCKS5_LITERAL_IPV6_PREHANDSHAKE_FAILURE"), + Some((3, host)) if host == b"[::1]" => { + eprintln!("BASELINE_LOCAL_SOCKS5_LITERAL_IPV6_BRACKETED_DOMAIN") + } + _ => panic!( + "baseline local literal IPv6 may work; candidate compatibility requires adjudication" + ), + }, + _ => { + assert_eq!( + result, + Some((3, b"[::1]".to_vec())), + "baseline AAAA-only hypothesis must be actual wire evidence" + ); + eprintln!("BASELINE_LOCAL_SOCKS5_AAAA_RESOLVED_TO_BRACKETED_DOMAIN"); + } + } + } +} + +#[tokio::test] +async fn antigravity_legacy_reqwest_connect_ua_is_the_client_default() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let observed = tokio::spawn(async move { + let (mut socket, _) = tokio::time::timeout(Duration::from_secs(3), listener.accept()) + .await + .unwrap() + .unwrap(); + let mut raw = Vec::new(); + let mut buffer = [0; 512]; + while !raw.windows(4).any(|bytes| bytes == b"\r\n\r\n") { + let count = socket.read(&mut buffer).await.unwrap(); + assert!(count > 0 && raw.len() + count <= 8192); + raw.extend_from_slice(&buffer[..count]); + } + let headers = std::str::from_utf8(&raw).unwrap(); + assert!(headers.starts_with("CONNECT ")); + assert!(!headers.lines().any(|line| { + line.split_once(':') + .is_some_and(|(name, _)| name.eq_ignore_ascii_case("authorization")) + })); + let user_agent = headers.lines().find_map(|line| { + line.split_once(':') + .filter(|(name, _)| name.eq_ignore_ascii_case("user-agent")) + .map(|(_, value)| value.trim().to_owned()) + }); + socket.write_all(b"HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await.unwrap(); + user_agent + }); + let directory = tempfile::tempdir().unwrap(); + let config = Config::read(&directory.path().join("config.json"), |key| { + (key == "CCP_ANTIGRAVITY_USER_AGENT").then(|| "public-native-override".into()) + }) + .unwrap(); + let legacy = config + .client_builder() + .proxy(reqwest::Proxy::all(format!("http://user:pass@{address}")).unwrap()) + .timeout(Duration::from_secs(3)) + .build() + .unwrap(); + assert!( + legacy + .post("https://127.0.0.1:443/v1internal:streamGenerateContent?alt=sse") + .bearer_auth("public-test-access") + .headers(config.native_headers()) + .json(&json!({"synthetic":"baseline-connect-ua"})) + .send() + .await + .is_err() + ); + assert_eq!( + observed.await.unwrap().as_deref(), + Some(expected_native_user_agent().as_str()), + "baseline CONNECT uses client builder UA, not per-request native override" + ); +} + +#[tokio::test] +async fn antigravity_legacy_https_proxy_connect_omits_alpn_and_speaks_http1() { + use rustls::pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject}; + let certificate = include_bytes!("../../tests/fixtures/antigravity-transport/server.pem"); + let key = include_bytes!("../../tests/fixtures/antigravity-transport/server-key.pem"); + let ca = include_bytes!("../../tests/fixtures/antigravity-transport/ca.pem"); + let mut tls = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert( + CertificateDer::pem_slice_iter(certificate) + .collect::, _>>() + .unwrap(), + PrivateKeyDer::from_pem_slice(key).unwrap(), + ) + .unwrap(); + tls.alpn_protocols = vec![b"http/1.1".to_vec(), b"h2".to_vec()]; + let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let peer = tokio::spawn(async move { + let (socket, _) = tokio::time::timeout(Duration::from_secs(3), listener.accept()) + .await + .unwrap() + .unwrap(); + let mut socket = tokio::time::timeout(Duration::from_secs(3), acceptor.accept(socket)) + .await + .unwrap() + .unwrap(); + let alpn = socket + .get_ref() + .1 + .alpn_protocol() + .map(|value| value.to_vec()); + let mut headers = Vec::new(); + while !headers.ends_with(b"\r\n\r\n") { + assert!(headers.len() < 8192); + headers.push(socket.read_u8().await.unwrap()); + } + let http1_connect = headers.starts_with(b"CONNECT ") + && headers.windows(10).any(|value| value == b" HTTP/1.1\r"); + socket.write_all(b"HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await.unwrap(); + (alpn, http1_connect) + }); + let config = Config::default(); + let legacy = config + .client_builder() + .add_root_certificate(reqwest::Certificate::from_pem(ca).unwrap()) + .proxy(reqwest::Proxy::all(format!("https://{address}")).unwrap()) + .timeout(Duration::from_secs(3)) + .build() + .unwrap(); + assert!( + legacy + .post("https://127.0.0.1:443/") + .json(&json!({"synthetic":"legacy-proxy-alpn"})) + .send() + .await + .is_err() + ); + let (alpn, http1) = peer.await.unwrap(); + assert!( + alpn.is_none(), + "measure legacy proxy ALPN rather than assume h1 advertisement" + ); + assert!( + http1, + "absence of ALPN must still carry explicit HTTP/1.1 CONNECT" + ); + eprintln!("BASELINE_HTTPS_PROXY_ALPN_ABSENT_HTTP1_CONNECT_OBSERVED"); +} + +#[tokio::test] +async fn antigravity_legacy_reqwest_socks4_empty_userid_has_a_strict_rfc_wire_gap() { + async fn terminated(socket: &mut tokio::net::TcpStream) -> Vec { + let mut value = Vec::new(); + for _ in 0..256 { + let byte = socket.read_u8().await.unwrap(); + if byte == 0 { + return value; + } + value.push(byte); + } + panic!("bounded synthetic SOCKS string exceeded"); + } + for (scheme, endpoint) in [ + ("socks4", "https://127.0.0.1:443/"), + ("socks4a", "https://fixture.invalid:443/"), + ] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let peer = tokio::spawn(async move { + let (mut socket, _) = tokio::time::timeout(Duration::from_secs(3), listener.accept()) + .await + .unwrap() + .unwrap(); + let mut header = [0; 8]; + socket.read_exact(&mut header).await.unwrap(); + assert_eq!(&header[..2], &[4, 1]); + let userid = terminated(&mut socket).await; + // Strict RFC: this is already the socket request end, or the first + // byte of the SOCKS4a hostname. Do not consume/forgive a second NUL. + let next = tokio::time::timeout(Duration::from_millis(200), socket.read_u8()).await; + let extra = next.ok().and_then(Result::ok); + let remaining_domain = if scheme == "socks4a" && extra == Some(0) { + Some(terminated(&mut socket).await) + } else { + None + }; + socket.write_all(&[0, 91, 0, 0, 0, 0, 0, 0]).await.unwrap(); + (userid, extra, remaining_domain) + }); + let config = Config::default(); + let legacy = config + .client_builder() + .proxy(reqwest::Proxy::all(format!("{scheme}://{address}")).unwrap()) + .timeout(Duration::from_secs(3)) + .build() + .unwrap(); + assert!( + legacy + .post(endpoint) + .json(&json!({"synthetic":"strict-legacy-socks4"})) + .send() + .await + .is_err() + ); + let (userid, extra, domain) = peer.await.unwrap(); + assert!(userid.is_empty()); + assert_eq!( + extra, + Some(0), + "legacy extra-NUL hypothesis must be measured, not worked around in the proxy" + ); + if scheme == "socks4a" { + assert_eq!(domain.as_deref(), Some(b"fixture.invalid".as_slice())); + } + eprintln!("BASELINE_STRICT_SOCKS4_EMPTY_USERID_EXTRA_NUL_OBSERVED"); + } +} + +#[test] +fn antigravity_legacy_matcher_wildcard_domain_and_literal_ip_are_distinct() { + use hyper_util::client::proxy::matcher::Matcher; + let proxy = "http://127.0.0.1:9"; + let ip = "https://127.0.0.1:443/".parse().unwrap(); + let domain = "https://localhost:443/".parse().unwrap(); + let wildcard = Matcher::builder().https(proxy).no("*").build(); + assert!(wildcard.intercept(&domain).is_none()); + assert!( + wildcard.intercept(&ip).is_some(), + "measure cached baseline wildcard/IP behavior without changing privacy policy" + ); + for bypass in ["127.0.0.1", "127.0.0.0/8"] { + assert!( + Matcher::builder() + .https(proxy) + .no(bypass) + .build() + .intercept(&ip) + .is_none() + ); + } + eprintln!("BASELINE_NO_PROXY_WILDCARD_DOMAIN_ONLY_LITERAL_IP_REMAINS_PROXIED"); +} + #[tokio::test] async fn antigravity_serving_capability_uses_its_own_auth_and_configuration() { let (upstream, captured) = native(vec![(200, successful(json!([{"text":"unused"}])))]).await; @@ -1064,29 +1555,105 @@ async fn antigravity_effective_tier_is_observable_and_openai_stays_unsupported() } async fn read_request(stream: &mut tokio::net::TcpStream) { - let mut raw = Vec::new(); - let mut buffer = [0u8; 1024]; - loop { - let n = stream.read(&mut buffer).await.unwrap(); - assert_ne!(n, 0); - raw.extend_from_slice(&buffer[..n]); - if let Some(end) = raw.windows(4).position(|w| w == b"\r\n\r\n") { - let header = String::from_utf8_lossy(&raw[..end]); - let length = header + const MAX_REQUEST: usize = 64 * 1024 * 1024; + const MAX_HEADERS: usize = 64 * 1024; + fn complete(raw: &[u8]) -> bool { + let Some(end) = raw.windows(4).position(|bytes| bytes == b"\r\n\r\n") else { + assert!(raw.len() <= MAX_HEADERS); + return false; + }; + assert!(end <= MAX_HEADERS); + let headers = std::str::from_utf8(&raw[..end]).unwrap(); + let length = headers.lines().find_map(|line| { + line.split_once(':') + .filter(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .map(|(_, value)| value.trim().parse::().unwrap()) + }); + if let Some(length) = length { + assert!(length <= MAX_REQUEST - end - 4); + return raw.len() >= end + 4 + length; + } + assert!( + headers .lines() - .find_map(|line| { - line.to_ascii_lowercase() - .strip_prefix("content-length:") - .map(|v| v.trim().parse::().unwrap()) - }) + .any(|line| line.split_once(':').is_some_and(|(name, value)| { + name.eq_ignore_ascii_case("transfer-encoding") + && value.trim().eq_ignore_ascii_case("chunked") + })), + "fixture request must have explicit HTTP/1.1 framing" + ); + let mut offset = end + 4; + loop { + let Some(line) = raw[offset..].windows(2).position(|bytes| bytes == b"\r\n") else { + return false; + }; + assert!(line <= MAX_HEADERS); + let value = std::str::from_utf8(&raw[offset..offset + line]) + .unwrap() + .split(';') + .next() + .unwrap() + .trim(); + let length = usize::from_str_radix(value, 16).unwrap(); + offset += line + 2; + if length == 0 { + let trailers = &raw[offset..]; + if trailers.starts_with(b"\r\n") { + return true; + } + assert!(trailers.len() <= MAX_HEADERS); + return trailers.windows(4).any(|bytes| bytes == b"\r\n\r\n"); + } + let needed = offset + .checked_add(length) + .and_then(|end| end.checked_add(2)) .unwrap(); - if raw.len() >= end + 4 + length { - return; + assert!(needed <= MAX_REQUEST); + if raw.len() < needed { + return false; } + assert_eq!(&raw[needed - 2..needed], b"\r\n"); + offset = needed; + } + } + let mut raw = Vec::new(); + let mut buffer = [0u8; 1024]; + loop { + let count = stream.read(&mut buffer).await.unwrap(); + assert_ne!(count, 0); + assert!(raw.len() + count <= MAX_REQUEST); + raw.extend_from_slice(&buffer[..count]); + if complete(&raw) { + return; } } } +#[tokio::test] +async fn antigravity_raw_fixture_reads_known_and_chunked_request_bodies() { + for body in [ + "Content-Length: 3\r\n\r\nabc", + "Transfer-Encoding: chunked\r\n\r\n3\r\nabc\r\n0\r\n\r\n", + "Transfer-Encoding: chunked\r\n\r\n1;x=y\r\na\r\n2\r\nbc\r\n0\r\nx-fixture: okay\r\n\r\n", + ] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let request = format!("POST / HTTP/1.1\r\nHost: localhost\r\n{body}"); + let sender = tokio::spawn(async move { + let mut socket = tokio::net::TcpStream::connect(address).await.unwrap(); + for chunk in request.as_bytes().chunks(2) { + socket.write_all(chunk).await.unwrap(); + } + socket.shutdown().await.unwrap(); + }); + let (mut socket, _) = listener.accept().await.unwrap(); + tokio::time::timeout(Duration::from_secs(1), read_request(&mut socket)) + .await + .unwrap(); + sender.await.unwrap(); + } +} + #[tokio::test] async fn antigravity_real_disconnect_cancels_preheader_nonstream_and_stream_collection() { for (headers_sent, streaming) in [(false, false), (false, true), (true, false), (true, true)] { diff --git a/tests/antigravity_transport.rs b/tests/antigravity_transport.rs new file mode 100644 index 00000000..4c69b925 --- /dev/null +++ b/tests/antigravity_transport.rs @@ -0,0 +1,2498 @@ +//! Production-library transport fixtures: synthetic credentials and verified loopback TLS only. +//! Every environment-sensitive provider runs in its own child; the library is cfg(test)=false. +use axum::body::to_bytes; +use bytes::Bytes; +use claude_code_proxy::{ + MessagesRequest, Provider, RequestContext, providers::antigravity::AntigravityProvider, +}; +use http::{HeaderMap, Response, Version}; +use http_body_util::{BodyExt, Full}; +use hyper::{body::Incoming, service::service_fn}; +use hyper_util::rt::{TokioExecutor, TokioIo}; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject}; +use serde_json::{Value, json}; +use std::{ + convert::Infallible, + io::Write, + path::Path, + sync::{Arc, Mutex}, + time::{SystemTime, UNIX_EPOCH}, +}; +use tokio::task::{JoinHandle, JoinSet}; + +// Intentionally public, inert test credentials; never use this CA/key outside fixtures. +const CA: &[u8] = include_bytes!("fixtures/antigravity-transport/ca.pem"); +const CERT: &[u8] = include_bytes!("fixtures/antigravity-transport/server.pem"); +const KEY: &[u8] = include_bytes!("fixtures/antigravity-transport/server-key.pem"); +const CHILD_CASE: &str = "CCP_TRANSPORT_FIXTURE_CASE"; +const CHILD_STREAM: &str = "CCP_TRANSPORT_FIXTURE_STREAM"; + +struct Observed { + headers: HeaderMap, + version: Version, + wire: Value, +} + +struct TlsServer { + url: String, + observed: Arc>>, + task: JoinHandle<()>, +} + +impl Drop for TlsServer { + fn drop(&mut self) { + self.task.abort(); + } +} + +fn success() -> Vec { + format!( + "data: {}\n\n", + json!({"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"TRANSPORT_OK"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":2,"totalTokenCount":12}}}) + ) + .into_bytes() +} + +fn gzip(bytes: &[u8]) -> Vec { + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(bytes).unwrap(); + encoder.finish().unwrap() +} + +impl TlsServer { + async fn start(protocol: Version, status: u16, encoding: Option<&str>, body: Vec) -> Self { + let mut headers = HeaderMap::new(); + headers.insert( + "content-type", + http::HeaderValue::from_static(if status == 200 { + "text/event-stream" + } else { + "application/json" + }), + ); + headers.insert("retry-after", http::HeaderValue::from_static("7")); + if let Some(encoding) = encoding { + headers.insert( + "content-encoding", + http::HeaderValue::from_str(encoding).unwrap(), + ); + } + Self::start_with_headers(protocol, status, headers, body).await + } + + async fn start_with_headers( + protocol: Version, + status: u16, + headers: HeaderMap, + body: Vec, + ) -> Self { + let certificates = CertificateDer::pem_slice_iter(CERT) + .collect::, _>>() + .unwrap(); + let key = PrivateKeyDer::from_pem_slice(KEY).unwrap(); + let mut config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certificates, key) + .unwrap(); + config.alpn_protocols = vec![if protocol == Version::HTTP_2 { + b"h2".to_vec() + } else { + b"http/1.1".to_vec() + }]; + let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(config)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("https://{}", listener.local_addr().unwrap()); + let observed = Arc::new(Mutex::new(Vec::new())); + let captures = observed.clone(); + let task = tokio::spawn(async move { + let mut connections = JoinSet::new(); + loop { + tokio::select! { + accepted = listener.accept() => { + let (socket, _) = accepted.unwrap(); + let acceptor = acceptor.clone(); + let captures = captures.clone(); + let headers = headers.clone(); + let body = body.clone(); + connections.spawn(async move { + let Ok(tls) = acceptor.accept(socket).await else { return }; + let service = service_fn(move |request: http::Request| { + let captures = captures.clone(); + let headers = headers.clone(); + let body = body.clone(); + async move { + assert_eq!(request.method(), http::Method::POST); + assert_eq!(request.uri().path_and_query().unwrap().as_str(), "/v1internal:streamGenerateContent?alt=sse"); + let (parts, mut incoming) = request.into_parts(); + let mut bytes = Vec::new(); + while let Some(frame) = incoming.frame().await { + if let Ok(data) = frame.unwrap().into_data() { + assert!(bytes.len() + data.len() <= 65536); + bytes.extend_from_slice(&data); + } + } + captures.lock().unwrap().push(Observed { + headers: parts.headers, + version: parts.version, + wire: serde_json::from_slice(&bytes).unwrap(), + }); + let mut response = Response::builder().status(status) + .body(Full::new(Bytes::from(body))).unwrap(); + *response.headers_mut() = headers; + Ok::<_, Infallible>(response) + } + }); + if protocol == Version::HTTP_2 { + let _ = hyper::server::conn::http2::Builder::new(TokioExecutor::new()) + .serve_connection(TokioIo::new(tls), service).await; + } else { + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(tls), service).await; + } + }); + } + Some(result) = connections.join_next(), if !connections.is_empty() => { + result.unwrap(); + } + } + } + }); + Self { + url, + observed, + task, + } + } +} + +fn write_synthetic_store(root: &Path) { + use std::fs; + let provider = root.join("config/antigravity"); + fs::create_dir_all(&provider).unwrap(); + let expires_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + + 3600; + let path = provider.join("auth.json"); + fs::write(&path, json!({"profile_id":"public-test-profile","project_id":"public-test-project","access_token":"public-test-access","refresh_token":null,"expires_at":expires_at}).to_string()).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&provider, fs::Permissions::from_mode(0o700)).unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap(); + } + fs::write(root.join("ca.pem"), CA).unwrap(); + for directory in [ + "empty-certs", + "home", + "tmp", + "state", + "cache", + "data", + "run", + ] { + fs::create_dir_all(root.join(directory)).unwrap(); + } +} + +async fn child(server: &TlsServer, case: &'static str, stream: bool) { + child_configured(server, case, stream, &server.url, &[]).await; +} + +async fn child_configured( + server: &TlsServer, + case: &'static str, + stream: bool, + base_url: &str, + proxy_env: &[(&str, &str)], +) { + let _ = server; + let directory = tempfile::tempdir().unwrap(); + write_synthetic_store(directory.path()); + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command + .env_clear() + .args(["--exact", "antigravity_transport_child", "--nocapture"]) + .env(CHILD_CASE, case) + .env(CHILD_STREAM, if stream { "1" } else { "0" }) + .env("PATH", "/usr/bin:/bin") + .env("LANG", "C.UTF-8") + .env("RUST_BACKTRACE", "0") + .env("HOME", directory.path().join("home")) + .env("TMPDIR", directory.path().join("tmp")) + .env("XDG_CONFIG_HOME", directory.path().join("config")) + .env("XDG_CACHE_HOME", directory.path().join("cache")) + .env("XDG_DATA_HOME", directory.path().join("data")) + .env("XDG_STATE_HOME", directory.path().join("state")) + .env("XDG_RUNTIME_DIR", directory.path().join("run")) + .env("CCP_CONFIG_DIR", directory.path().join("config")) + .env("CCP_ANTIGRAVITY_ENABLED", "1") + .env("CCP_ANTIGRAVITY_BASE_URL", base_url) + .env("CCP_ANTIGRAVITY_PROJECT_ID", "public-test-project") + .env("SSL_CERT_FILE", directory.path().join("ca.pem")) + .env("SSL_CERT_DIR", directory.path().join("empty-certs")) + .env("NO_PROXY", "*") + .env("no_proxy", "*"); + if case == "native-ua-override" { + command.env("CCP_ANTIGRAVITY_USER_AGENT", "public-native-override"); + } + if case == "untrusted-ca" { + let empty_ca = directory.path().join("empty-ca.pem"); + std::fs::write(&empty_ca, b"").unwrap(); + command.env("SSL_CERT_FILE", empty_ca); + } + for (key, value) in proxy_env { + assert!(matches!( + *key, + "HTTPS_PROXY" + | "https_proxy" + | "HTTP_PROXY" + | "http_proxy" + | "ALL_PROXY" + | "all_proxy" + | "NO_PROXY" + | "no_proxy" + | "REQUEST_METHOD" + )); + command.env(key, value); + } + let output = tokio::task::spawn_blocking(move || command.output().unwrap()) + .await + .unwrap(); + assert!( + output.stdout.len() + output.stderr.len() < 32768, + "bounded synthetic child output" + ); + assert!( + output.status.success(), + "production child assertion failed: {} {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed")); +} + +fn synthetic_client_setup_class() -> &'static str { + let native = rustls_native_certs::load_native_certs(); + let mut roots = rustls::RootCertStore::empty(); + let (valid, invalid) = roots.add_parsable_certificates(native.certs); + let root_class = match (valid > 0, invalid > 0) { + (true, false) => "valid", + (true, true) => "partial_invalid", + (false, true) => "only_invalid", + (false, false) => "empty", + }; + eprintln!("FIXTURE_ROOT_CLASS {root_class}"); + // Exact public builder operations from baseline Config::client_builder(). + // Construction only: no endpoint, request, send, credentials or network. + let result = reqwest::Client::builder() + .retry(reqwest::retry::never()) + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(std::time::Duration::from_secs(15)) + .timeout(std::time::Duration::from_secs(180)) + .user_agent(format!("claude-code-proxy/{}", env!("CARGO_PKG_VERSION"))) + .build(); + let Err(error) = result else { + return "replica_client_ok"; + }; + let mut cause: Option<&(dyn std::error::Error + 'static)> = Some(&error); + for _ in 0..8 { + let Some(current) = cause else { break }; + // Inspect in memory; never print arbitrary error objects/strings or paths. + match current.to_string().as_str() { + "zero valid certificates found in native root store" => return "native_roots_invalid", + "empty supported tls versions" => return "tls_versions_empty", + "invalid TLS versions" => return "tls_versions_invalid", + "invalid TLS verification settings" => return "tls_verifier_invalid", + "invalid certificate encoding" => return "certificate_encoding_invalid", + "incompatible TLS identity type" => return "tls_identity_invalid", + "Unknown TLS backend passed to `use_preconfigured_tls`" => return "tls_backend_invalid", + "invalid HTTP header value" => return "client_header_invalid", + _ => {} + } + if current.downcast_ref::().is_some() { + return "tls_configuration_error"; + } + if let Some(error) = current.downcast_ref::() { + return match error.kind() { + std::io::ErrorKind::NotFound => "client_setup_file_missing", + std::io::ErrorKind::PermissionDenied => "client_setup_permission_denied", + _ => "client_setup_io_error", + }; + } + cause = current.source(); + } + "client_setup_unclassified" +} + +fn synthetic_error_class(status: http::StatusCode, text: &str) -> &'static str { + let value: Value = serde_json::from_str(text).unwrap_or(Value::Null); + let message = value["error"]["message"].as_str().unwrap_or(""); + match message { + "Antigravity configuration is invalid" => { + // Public local status does not construct Auth's HTTP client. Comparing + // its finite validity label distinguishes settings from client setup. + let status = claude_code_proxy::providers::antigravity::status_json(); + if status["status"]["configuration"] == "valid" { + eprintln!( + "FIXTURE_CLIENT_SETUP_CLASS {}", + synthetic_client_setup_class() + ); + "auth_http_client_init_invalid" + } else { + "config_invalid" + } + } + "Antigravity credentials are invalid or unreadable" => "synthetic_store_invalid", + "unsupported Antigravity model or replay scope" | "missing model" => { + "request_model_invalid" + } + "unsupported Antigravity semantic request option" | "unsupported or missing fields" => { + "request_option_invalid" + } + "Antigravity profile/project changed during request; start a fresh session" => { + "profile_binding_changed" + } + "Antigravity project missing; run auth login or configure your authorized project" => { + "project_missing" + } + "request needs a final user turn with complete tool results" => "request_turn_invalid", + "Antigravity: Antigravity native headers timed out or transport failed" => { + "native_transport_failed" + } + _ if status == http::StatusCode::BAD_REQUEST => "request_invalid_unclassified", + _ if status == http::StatusCode::UNAUTHORIZED => "auth_invalid_unclassified", + _ => "unclassified", + } +} + +#[tokio::test] +async fn antigravity_transport_child() { + let Ok(case) = std::env::var(CHILD_CASE) else { + return; + }; + let stream = std::env::var(CHILD_STREAM).unwrap() == "1"; + let provider = AntigravityProvider::new(); + let request: MessagesRequest = serde_json::from_value(json!({"model":"antigravity/gemini-4-argon-low","max_tokens":512,"stream":stream,"messages":[{"role":"user","content":"public synthetic transport fixture"}],"thinking":{"type":"adaptive","display":"omitted"}})).unwrap(); + let response = provider + .handle_messages( + request.clone(), + RequestContext { + req_id: "public-test-request".into(), + session_id: None, + session_seq: None, + provider: "antigravity".into(), + traffic: None, + monitor: None, + }, + ) + .await; + let status = response.status(); + let retry_after = response + .headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let bytes = to_bytes(response.into_body(), 65536).await.unwrap(); + let text = std::str::from_utf8(&bytes).unwrap(); + for prohibited in [ + "public-test-access", + "DO_NOT_REFLECT", + "user:pass", + "dXNlcjpwYXNz", + ] { + assert!( + !text.contains(prohibited), + "native response must redact synthetic origin/proxy credentials" + ); + } + let expected_status = match case.as_str() { + "gzip-error" | "rejected-error" => 429, + "redirect" => 302, + "wrong-content-type" | "preheader-failure" | "untrusted-ca" => 502, + "rejected-success" if !stream => 502, + _ => 200, + }; + if status.as_u16() != expected_status { + eprintln!( + "FIXTURE_ERROR_CLASS {}", + synthetic_error_class(status, text) + ); + } + if case == "rejected-success" { + assert_eq!(status.as_u16(), expected_status); + assert!(text.contains(if stream { "event: error" } else { "api_error" })); + for prohibited in [ + "message_start", + "message_stop", + "TRANSPORT_OK", + "DO_NOT_REFLECT", + ] { + assert!( + !text.contains(prohibited), + "invalid native transport must not publish success or raw content" + ); + } + } else if case == "redirect" { + assert_eq!( + status, 302, + "native redirect is an upstream error response, never followed" + ); + assert!(text.contains("api_error")); + assert!(!text.contains("message_start") && !text.contains("message_stop")); + } else if case == "rejected-error" { + assert_eq!(status, 429); + assert_eq!(retry_after.as_deref(), Some("7")); + assert!( + text.contains("google_status=unclassified; reason=unclassified; domain=unclassified") + ); + assert!(!text.contains("DO_NOT_REFLECT")); + } else if case == "preheader-failure" || case == "untrusted-ca" { + assert_eq!(status, 502); + assert!(text.contains("api_error")); + for prohibited in [ + "message_start", + "message_stop", + "TRANSPORT_OK", + "public-test-access", + "DO_NOT_REFLECT", + ] { + assert!(!text.contains(prohibited)); + } + } else if case == "wrong-content-type" { + assert_eq!(status, 502); + assert!( + text.contains("unexpected content type"), + "content type check must precede body decoding" + ); + } else if case == "gzip-error" { + assert_eq!( + status, 429, + "original upstream status must survive compression" + ); + assert!(text.contains("google_status=RESOURCE_EXHAUSTED; reason=RATE_LIMIT_EXCEEDED; domain=googleapis.com"), "gzip error diagnostics must decode"); + assert!( + !text.contains("DO_NOT_REFLECT"), + "error details must remain redacted" + ); + } else { + assert_eq!(status, 200, "valid native transport must succeed"); + assert!( + text.contains("TRANSPORT_OK"), + "validated native text must survive transport" + ); + if stream { + assert!(text.contains("event: message_stop")); + assert!(!text.contains("event: error")); + } + } + if case == "pooled-failure" { + let response = provider + .handle_messages( + request, + RequestContext { + req_id: "public-second-request".into(), + session_id: None, + session_seq: None, + provider: "antigravity".into(), + traffic: None, + monitor: None, + }, + ) + .await; + assert_eq!( + response.status(), + 502, + "second pooled generation failure must not retry" + ); + let bytes = to_bytes(response.into_body(), 65536).await.unwrap(); + let text = std::str::from_utf8(&bytes).unwrap(); + assert!(text.contains("api_error")); + for prohibited in [ + "message_start", + "message_stop", + "TRANSPORT_OK", + "public-test-access", + "user:pass", + "dXNlcjpwYXNz", + ] { + assert!(!text.contains(prohibited)); + } + } +} + +#[tokio::test] +async fn antigravity_transport_control_plain_verified_tls() { + for protocol in [Version::HTTP_11, Version::HTTP_2] { + let server = TlsServer::start(protocol, 200, None, success()).await; + for stream in [false, true] { + child(&server, "plain", stream).await; + } + let observed = server.observed.lock().unwrap(); + assert_eq!(observed.len(), 2); + for request in observed.iter() { + assert_eq!(request.version, protocol); + assert_eq!( + request.headers["authorization"], + "Bearer public-test-access" + ); + assert_eq!( + request.headers["user-agent"], + format!("claude-code-proxy/{}", env!("CARGO_PKG_VERSION")) + ); + assert_eq!( + request.wire["request"]["generationConfig"]["maxOutputTokens"], + 512 + ); + } + } +} + +#[test] +fn antigravity_transport_control_cached_multigzip_contract() { + fn decode(input: &[u8], chunk_size: usize) -> std::io::Result> { + let mut decoder = flate2::write::MultiGzDecoder::new(Vec::new()); + for chunk in input.chunks(chunk_size) { + decoder.write_all(chunk)?; + } + decoder.finish() + } + let first = gzip(b"first"); + let mut concatenated = first.clone(); + concatenated.extend(gzip(b"second")); + for chunk_size in [1, 3, 16, 4096] { + assert_eq!(decode(&concatenated, chunk_size).unwrap(), b"firstsecond"); + let mut corrupt = first.clone(); + let crc = corrupt.len() - 8; + corrupt[crc] ^= 1; + let mut truncated = first.clone(); + truncated.pop(); + let mut junk = first.clone(); + junk.extend_from_slice(b"not gzip"); + let mut partial_member = first.clone(); + partial_member.extend_from_slice(&[0x1f, 0x8b]); + for invalid in [Vec::new(), corrupt, truncated, junk, partial_member] { + assert!( + decode(&invalid, chunk_size).is_err(), + "gzip must consume and validate every member through EOF" + ); + } + } +} + +#[tokio::test] +async fn antigravity_transport_red_accept_is_absent_at_runtime() { + let server = TlsServer::start(Version::HTTP_11, 200, None, success()).await; + child(&server, "plain", false).await; + let observed = server.observed.lock().unwrap(); + assert_eq!(observed.len(), 1); + assert!( + !observed[0].headers.contains_key("accept"), + "generation Accept must be truly absent" + ); +} + +#[tokio::test] +async fn antigravity_transport_red_gzip_is_advertised_at_runtime() { + let server = TlsServer::start(Version::HTTP_11, 200, None, success()).await; + child(&server, "plain", false).await; + let observed = server.observed.lock().unwrap(); + assert_eq!(observed.len(), 1); + let values: Vec<_> = observed[0] + .headers + .get_all("accept-encoding") + .iter() + .collect(); + assert_eq!( + values.len(), + 1, + "generation must advertise exactly one gzip value" + ); + assert_eq!(values[0], "gzip"); +} + +#[tokio::test] +async fn antigravity_transport_red_unknown_length_http1() { + let server = TlsServer::start(Version::HTTP_11, 200, None, success()).await; + child(&server, "plain", false).await; + let observed = server.observed.lock().unwrap(); + assert_eq!(observed.len(), 1); + assert_eq!(observed[0].version, Version::HTTP_11); + assert!( + !observed[0].headers.contains_key("content-length"), + "unknown-length HTTP/1.1 must not emit Content-Length" + ); + assert_eq!(observed[0].headers["transfer-encoding"], "chunked"); +} + +#[tokio::test] +async fn antigravity_transport_red_unknown_length_http2() { + let server = TlsServer::start(Version::HTTP_2, 200, None, success()).await; + child(&server, "plain", false).await; + let observed = server.observed.lock().unwrap(); + assert_eq!(observed.len(), 1); + assert_eq!(observed[0].version, Version::HTTP_2); + assert!( + !observed[0].headers.contains_key("content-length"), + "unknown-length HTTP/2 must not emit Content-Length" + ); + assert!(!observed[0].headers.contains_key("transfer-encoding")); +} + +#[tokio::test] +async fn antigravity_transport_red_gzip_sse_decodes() { + let server = TlsServer::start(Version::HTTP_11, 200, Some("gzip"), gzip(&success())).await; + for stream in [false, true] { + child(&server, "gzip-success", stream).await; + } +} + +#[tokio::test] +async fn antigravity_transport_red_gzip_error_preserves_diagnostics() { + let error = json!({"error":{"status":"RESOURCE_EXHAUSTED","message":"DO_NOT_REFLECT", "details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"RATE_LIMIT_EXCEEDED","domain":"googleapis.com","metadata":{"private":"DO_NOT_REFLECT"}}]}}).to_string(); + let server = + TlsServer::start(Version::HTTP_11, 429, Some("gzip"), gzip(error.as_bytes())).await; + for stream in [false, true] { + child(&server, "gzip-error", stream).await; + } +} + +#[tokio::test] +async fn antigravity_transport_gzip_concatenated_members_and_identity_are_supported() { + let native = success(); + let split = native.len() / 2; + let mut concatenated = gzip(&native[..split]); + concatenated.extend(gzip(&native[split..])); + let mut empty_first = gzip(b""); + empty_first.extend(gzip(&native)); + for protocol in [Version::HTTP_11, Version::HTTP_2] { + for (encoding, body) in [ + ("identity", native.clone()), + ("GZip", gzip(&native)), + ("gzip", concatenated.clone()), + ("gzip", empty_first.clone()), + ] { + let server = TlsServer::start(protocol, 200, Some(encoding), body).await; + for stream in [false, true] { + child(&server, "plain", stream).await; + } + } + } +} + +#[tokio::test] +async fn antigravity_transport_gzip_crc_eof_and_trailing_fail_closed() { + let valid = gzip(&success()); + let mut crc = valid.clone(); + let offset = crc.len() - 8; + crc[offset] ^= 1; + let mut truncated = valid.clone(); + truncated.pop(); + let mut junk = valid.clone(); + junk.extend_from_slice(b"DO_NOT_REFLECT"); + let mut partial = valid.clone(); + partial.extend_from_slice(&[0x1f, 0x8b]); + let mut zero = valid.clone(); + zero.push(0); + for body in [Vec::new(), vec![0x1f], crc, truncated, junk, partial, zero] { + let server = TlsServer::start(Version::HTTP_11, 200, Some("gzip"), body).await; + for stream in [false, true] { + child(&server, "rejected-success", stream).await; + } + } +} + +#[tokio::test] +async fn antigravity_transport_all_encoding_headers_are_checked() { + for encodings in [ + vec!["br"], + vec!["deflate"], + vec![""], + vec!["gzip, identity"], + vec!["gzip", "gzip"], + vec!["identity", "gzip"], + ] { + for status in [200, 429] { + let mut headers = HeaderMap::new(); + headers.insert( + "content-type", + http::HeaderValue::from_static(if status == 200 { + "text/event-stream" + } else { + "application/json" + }), + ); + headers.insert("retry-after", http::HeaderValue::from_static("7")); + for encoding in &encodings { + headers.append( + "content-encoding", + http::HeaderValue::from_str(encoding).unwrap(), + ); + } + let server = + TlsServer::start_with_headers(Version::HTTP_11, status, headers, gzip(&success())) + .await; + for stream in [false, true] { + child( + &server, + if status == 200 { + "rejected-success" + } else { + "rejected-error" + }, + stream, + ) + .await; + } + } + } +} + +#[tokio::test] +async fn antigravity_transport_invalid_gzip_errors_preserve_status_and_retry() { + for body in [ + Vec::new(), + b"DO_NOT_REFLECT".to_vec(), + gzip(&vec![b'x'; 8193]), + ] { + let server = TlsServer::start(Version::HTTP_11, 429, Some("gzip"), body).await; + for stream in [false, true] { + child(&server, "rejected-error", stream).await; + } + } +} + +#[tokio::test] +async fn antigravity_transport_encoded_and_decoded_bodies_are_bounded() { + let part = format!( + "data: {}\n\n", + json!({"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"x".repeat(128 * 1024)}]}}]}}) + ); + let mut oversized_decoded = part.repeat(65).into_bytes(); + oversized_decoded.extend(success()); + for (encoding, body) in [ + (None, vec![b'x'; 8 * 1024 * 1024 + 1]), + (Some("gzip"), gzip(&oversized_decoded)), + (Some("gzip"), gzip(&vec![b'x'; 1024 * 1024 + 1])), + ] { + let server = TlsServer::start(Version::HTTP_11, 200, encoding, body).await; + for stream in [false, true] { + child(&server, "rejected-success", stream).await; + } + } + let server = TlsServer::start(Version::HTTP_11, 429, Some("gzip"), vec![0; 8193]).await; + child(&server, "rejected-error", false).await; +} + +#[tokio::test] +async fn antigravity_transport_content_type_precedes_success_decoding() { + let mut headers = HeaderMap::new(); + headers.insert( + "content-type", + http::HeaderValue::from_static("application/json"), + ); + headers.insert("content-encoding", http::HeaderValue::from_static("br")); + let server = + TlsServer::start_with_headers(Version::HTTP_11, 200, headers, b"DO_NOT_REFLECT".to_vec()) + .await; + for stream in [false, true] { + child(&server, "wrong-content-type", stream).await; + } +} + +#[tokio::test] +async fn antigravity_transport_redirect_is_not_followed_or_forwarded_credentials() { + let target = TlsServer::start(Version::HTTP_11, 200, None, success()).await; + let mut headers = HeaderMap::new(); + headers.insert( + "content-type", + http::HeaderValue::from_static("application/json"), + ); + headers.insert( + "location", + http::HeaderValue::from_str(&target.url).unwrap(), + ); + let origin = + TlsServer::start_with_headers(Version::HTTP_11, 302, headers, b"DO_NOT_REFLECT".to_vec()) + .await; + for stream in [false, true] { + child(&origin, "redirect", stream).await; + } + assert_eq!(origin.observed.lock().unwrap().len(), 2); + assert!( + target.observed.lock().unwrap().is_empty(), + "redirect target must receive neither a second generation nor Authorization" + ); +} + +mod retry_tests { + use super::*; + use std::{ + io, + sync::atomic::{AtomicUsize, Ordering}, + time::Duration, + }; + use tokio::{ + io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}, + sync::oneshot, + }; + + const MAX_CONNECTIONS: usize = 4; + const CONNECTION_TIMEOUT: Duration = Duration::from_secs(10); + + #[derive(Default)] + struct RetryCounts { + tcp: AtomicUsize, + tls: AtomicUsize, + requests: AtomicUsize, + h2_prefaces: AtomicUsize, + settings_acknowledged: AtomicUsize, + refused_streams: AtomicUsize, + errors: AtomicUsize, + } + + struct RetryFixture { + server: TlsServer, + counts: Arc, + stop: Option>, + drained: oneshot::Receiver<()>, + } + + fn fixture_error() -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, "synthetic retry fixture failed") + } + + fn retry_acceptor(protocol: Version) -> tokio_rustls::TlsAcceptor { + let certificates = CertificateDer::pem_slice_iter(CERT) + .collect::, _>>() + .unwrap(); + let key = PrivateKeyDer::from_pem_slice(KEY).unwrap(); + let mut config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certificates, key) + .unwrap(); + config.alpn_protocols = vec![if protocol == Version::HTTP_2 { + b"h2".to_vec() + } else { + b"http/1.1".to_vec() + }]; + tokio_rustls::TlsAcceptor::from(Arc::new(config)) + } + + impl RetryFixture { + async fn start(protocol: Version) -> Self { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("https://{}", listener.local_addr().unwrap()); + let acceptor = retry_acceptor(protocol); + let observed = Arc::new(Mutex::new(Vec::new())); + let records = observed.clone(); + let counts = Arc::new(RetryCounts::default()); + let captures = counts.clone(); + let (stop, mut stopping) = oneshot::channel(); + let (done, drained) = oneshot::channel(); + let task = tokio::spawn(async move { + let mut connections = JoinSet::new(); + loop { + tokio::select! { + // Drain already-queued TCP attempts before honoring the + // post-child stop barrier, rather than hiding retries. + biased; + accepted = listener.accept() => { + let Ok((socket, _)) = accepted else { + captures.errors.fetch_add(1, Ordering::SeqCst); + break; + }; + if captures.tcp.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS { + captures.errors.fetch_add(1, Ordering::SeqCst); + drop(socket); + break; + } + let acceptor = acceptor.clone(); + let captures = captures.clone(); + let records = records.clone(); + connections.spawn(async move { + let connection = async { + let tls = acceptor.accept(socket).await?; + captures.tls.fetch_add(1, Ordering::SeqCst); + let expected_alpn: &[u8] = if protocol == Version::HTTP_2 { + b"h2" + } else { + b"http/1.1" + }; + if tls.get_ref().1.alpn_protocol() != Some(expected_alpn) { + return Err(fixture_error()); + } + if protocol == Version::HTTP_2 { + refuse_h2_streams(tls, &captures).await + } else { + pooled_http1(tls, captures.clone(), records).await + } + }; + if !matches!(tokio::time::timeout(CONNECTION_TIMEOUT, connection).await, Ok(Ok(()))) { + captures.errors.fetch_add(1, Ordering::SeqCst); + } + }); + } + Some(result) = connections.join_next(), if !connections.is_empty() => { + if result.is_err() { + captures.errors.fetch_add(1, Ordering::SeqCst); + } + } + _ = &mut stopping => break, + } + } + drop(listener); + let complete = tokio::time::timeout(Duration::from_secs(2), async { + while let Some(result) = connections.join_next().await { + if result.is_err() { + captures.errors.fetch_add(1, Ordering::SeqCst); + } + } + }) + .await; + if complete.is_err() { + captures.errors.fetch_add(1, Ordering::SeqCst); + } + // JoinSet aborts anything still pending on timeout or when the + // enclosing TlsServer's Drop aborts this owner task. + drop(connections); + let _ = done.send(()); + }); + Self { + server: TlsServer { + url, + observed, + task, + }, + counts, + stop: Some(stop), + drained, + } + } + + async fn finish(&mut self) { + self.stop.take().unwrap().send(()).unwrap(); + tokio::time::timeout(Duration::from_secs(3), &mut self.drained) + .await + .expect("retry fixture shutdown must be bounded") + .expect("retry fixture owner must complete"); + assert_eq!( + self.counts.errors.load(Ordering::SeqCst), + 0, + "local retry fixture must complete without parsing/task errors" + ); + } + } + + async fn h2_frame( + stream: &mut S, + kind: u8, + flags: u8, + stream_id: u32, + payload: &[u8], + ) -> io::Result<()> { + if payload.len() > 65536 || stream_id & 0x8000_0000 != 0 { + return Err(fixture_error()); + } + let length = payload.len(); + let id = stream_id.to_be_bytes(); + stream + .write_all(&[ + (length >> 16) as u8, + (length >> 8) as u8, + length as u8, + kind, + flags, + id[0], + id[1], + id[2], + id[3], + ]) + .await?; + stream.write_all(payload).await?; + stream.flush().await + } + + async fn refuse_h2_streams( + mut stream: S, + counts: &RetryCounts, + ) -> io::Result<()> { + let mut preface = [0; 24]; + stream.read_exact(&mut preface).await?; + if &preface != b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" { + return Err(fixture_error()); + } + counts.h2_prefaces.fetch_add(1, Ordering::SeqCst); + h2_frame(&mut stream, 4, 0, 0, &[]).await?; // Initial server SETTINGS. + let mut total_payload = 0usize; + let mut refused = false; + for _ in 0..128 { + let mut header = [0; 9]; + if let Err(error) = stream.read_exact(&mut header).await { + // The isolated child closes its connection after the terminal + // 502. No raw TLS/error/header/body data is retained or printed. + return if refused { Ok(()) } else { Err(error) }; + } + let length = (usize::from(header[0]) << 16) + | (usize::from(header[1]) << 8) + | usize::from(header[2]); + if length > 65536 || total_payload + length > 256 * 1024 { + return Err(fixture_error()); + } + total_payload += length; + let stream_id = u32::from_be_bytes(header[5..9].try_into().unwrap()) & 0x7fff_ffff; + let mut payload = vec![0; length]; + stream.read_exact(&mut payload).await?; + match header[3] { + 4 => { + if stream_id != 0 || (header[4] & 1 != 0 && length != 0) || length % 6 != 0 { + return Err(fixture_error()); + } + if header[4] & 1 == 0 { + h2_frame(&mut stream, 4, 1, 0, &[]).await?; + counts.settings_acknowledged.fetch_add(1, Ordering::SeqCst); + } + } + 1 => { + if stream_id == 0 || stream_id % 2 == 0 { + return Err(fixture_error()); + } + if counts.requests.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS { + return Err(fixture_error()); + } + // RFC 7540 REFUSED_STREAM (7). Keep the connection alive so + // a retransmission on this connection is counted as well. + h2_frame(&mut stream, 3, 0, stream_id, &7u32.to_be_bytes()).await?; + counts.refused_streams.fetch_add(1, Ordering::SeqCst); + refused = true; + } + 6 => { + if stream_id != 0 || length != 8 { + return Err(fixture_error()); + } + if header[4] & 1 == 0 { + h2_frame(&mut stream, 6, 1, 0, &payload).await?; + } + } + 7 => { + return if refused { + Ok(()) + } else { + Err(fixture_error()) + }; + } + // DATA, CONTINUATION and flow-control payloads are bounded and + // discarded. This fixture deliberately does not decode HPACK. + _ => {} + } + } + Err(fixture_error()) + } + + async fn pooled_http1( + tls: tokio_rustls::server::TlsStream, + counts: Arc, + observed: Arc>>, + ) -> io::Result<()> { + let captures = counts.clone(); + let service = service_fn(move |request: http::Request| { + let counts = captures.clone(); + let observed = observed.clone(); + async move { + let ordinal = counts.requests.fetch_add(1, Ordering::SeqCst); + if ordinal >= MAX_CONNECTIONS + || request.method() != http::Method::POST + || request.version() != Version::HTTP_11 + || request.uri().path_and_query().map(|value| value.as_str()) + != Some("/v1internal:streamGenerateContent?alt=sse") + { + counts.errors.fetch_add(1, Ordering::SeqCst); + return Err::>, _>(fixture_error()); + } + let (parts, mut incoming) = request.into_parts(); + let mut bytes = Vec::new(); + while let Some(frame) = incoming.frame().await { + let frame = frame.map_err(|_| fixture_error())?; + if let Ok(data) = frame.into_data() { + if bytes.len() + data.len() > 65536 { + counts.errors.fetch_add(1, Ordering::SeqCst); + return Err(fixture_error()); + } + bytes.extend_from_slice(&data); + } + } + let wire = serde_json::from_slice(&bytes).map_err(|_| fixture_error())?; + observed.lock().unwrap().push(Observed { + headers: parts.headers, + version: parts.version, + wire, + }); + if ordinal == 0 { + Ok(Response::builder() + .status(200) + .header("content-type", "text/event-stream") + .body(Full::new(Bytes::from(success()))) + .unwrap()) + } else { + // The request reached the reused connection. Returning a + // service error closes it before any response headers. + Err(io::Error::other("synthetic pooled connection failure")) + } + } + }); + let result = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(tls), service) + .await; + if result.is_err() && counts.requests.load(Ordering::SeqCst) < 2 { + return Err(fixture_error()); + } + Ok(()) + } + + #[tokio::test] + async fn antigravity_transport_retry_h2_refused_stream_is_one_native_attempt() { + for stream in [false, true] { + let mut fixture = RetryFixture::start(Version::HTTP_2).await; + child(&fixture.server, "preheader-failure", stream).await; + fixture.finish().await; + assert_eq!( + fixture.counts.tcp.load(Ordering::SeqCst), + 1, + "REFUSED_STREAM must not open a second TCP connection" + ); + assert_eq!(fixture.counts.tls.load(Ordering::SeqCst), 1); + assert_eq!(fixture.counts.h2_prefaces.load(Ordering::SeqCst), 1); + assert!(fixture.counts.settings_acknowledged.load(Ordering::SeqCst) >= 1); + assert_eq!( + fixture.counts.requests.load(Ordering::SeqCst), + 1, + "REFUSED_STREAM must not retransmit generation HEADERS on any stream" + ); + assert_eq!(fixture.counts.refused_streams.load(Ordering::SeqCst), 1); + } + } + + #[tokio::test] + async fn antigravity_transport_retry_reused_http1_service_failure_is_not_retransmitted() { + let mut fixture = RetryFixture::start(Version::HTTP_11).await; + // The child consumes one successful turn, then sends another using the + // SAME public provider. Its second request must return a terminal 502. + child(&fixture.server, "pooled-failure", false).await; + fixture.finish().await; + assert_eq!( + fixture.counts.tcp.load(Ordering::SeqCst), + 1, + "both turns must use one pooled TCP connection; no reconnect retry" + ); + assert_eq!(fixture.counts.tls.load(Ordering::SeqCst), 1); + assert_eq!( + fixture.counts.requests.load(Ordering::SeqCst), + 2, + "one successful turn plus one failed generation, with no retransmission" + ); + let observed = fixture.server.observed.lock().unwrap(); + assert_eq!(observed.len(), 2); + for request in observed.iter() { + assert_eq!(request.version, Version::HTTP_11); + assert_eq!(request.headers.get_all("authorization").iter().count(), 1); + assert_eq!( + request.headers["authorization"], + "Bearer public-test-access" + ); + assert!(!request.headers.contains_key("proxy-authorization")); + assert_eq!( + request.wire["request"]["generationConfig"]["maxOutputTokens"], + 512 + ); + } + } +} + +mod proxy_tests { + use super::*; + use std::{ + io, + net::{Ipv4Addr, Ipv6Addr, SocketAddr}, + sync::atomic::{AtomicUsize, Ordering}, + time::Duration, + }; + use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + + const BASIC: &str = "Basic dXNlcjpwYXNz"; + const FIXTURE_TIMEOUT: Duration = Duration::from_secs(10); + const MAX_CONNECTIONS: usize = 8; + + #[derive(Clone, Copy)] + enum ConnectReply { + Tunnel, + AuthenticationRequired, + Refused, + Truncated, + } + + #[derive(Clone, Copy)] + enum SocksAuth { + None, + Password, + RejectPassword, + } + + #[derive(Clone, Copy)] + enum ProxyKind { + Connect(ConnectReply), + Socks4, + Socks5 { auth: SocksAuth, refuse: bool }, + Close, + } + + struct ConnectObserved { + authority: String, + proxy_authorizations: usize, + expected_basic: bool, + client_user_agents: usize, + expected_client_user_agent: bool, + origin_authorizations: usize, + origin_bearer_seen: bool, + } + + #[derive(Debug, PartialEq)] + enum SocksAddress { + Ipv4(Ipv4Addr), + Ipv6(Ipv6Addr), + Hostname(String), + } + + struct SocksObserved { + version: u8, + address: Option, + port: Option, + authenticated: bool, + expected_credentials: bool, + origin_bearer_seen: bool, + } + + #[derive(Default)] + struct ProxyObserved { + tcp_connections: AtomicUsize, + tls_connections: AtomicUsize, + no_proxy_alpn: AtomicUsize, + http1_proxy_alpn: AtomicUsize, + fixture_errors: AtomicUsize, + connect: Mutex>, + socks: Mutex>, + } + + // The fixture never connects to the requested hostname/address. Its only + // upstream socket is this already-owned origin, including for .invalid names. + struct FakeProxy { + address: SocketAddr, + observed: Arc, + task: JoinHandle<()>, + } + + impl Drop for FakeProxy { + fn drop(&mut self) { + // Dropping the accept task also drops its JoinSet and aborts every + // handshake/forwarder. No collector or tunnel task is detached. + self.task.abort(); + } + } + + trait ProxyIo: AsyncRead + AsyncWrite + Unpin + Send {} + impl ProxyIo for T {} + + fn invalid_handshake() -> io::Error { + io::Error::new( + io::ErrorKind::InvalidData, + "invalid synthetic proxy handshake", + ) + } + + fn origin_address(origin: &TlsServer) -> SocketAddr { + let uri: http::Uri = origin.url.parse().unwrap(); + SocketAddr::new( + uri.host().unwrap().parse().unwrap(), + uri.port_u16().unwrap(), + ) + } + + fn origin_url(origin: &TlsServer, hostname: &str) -> String { + format!("https://{hostname}:{}", origin_address(origin).port()) + } + + fn proxy_tls_acceptor() -> tokio_rustls::TlsAcceptor { + let certificates = CertificateDer::pem_slice_iter(CERT) + .collect::, _>>() + .unwrap(); + let key = PrivateKeyDer::from_pem_slice(KEY).unwrap(); + let mut config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certificates, key) + .unwrap(); + // The outer CONNECT carrier deliberately cannot negotiate h2. Origin + // TLS, inside this channel, must still independently negotiate h1/h2. + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + tokio_rustls::TlsAcceptor::from(Arc::new(config)) + } + + impl FakeProxy { + async fn start( + origin: &TlsServer, + bind: &str, + tls: bool, + kind: ProxyKind, + ) -> io::Result { + let target = origin_address(origin); + assert!(target.ip().is_loopback()); + let listener = tokio::net::TcpListener::bind(bind).await?; + let address = listener.local_addr()?; + assert!(address.ip().is_loopback()); + let acceptor = tls.then(proxy_tls_acceptor); + let observed = Arc::new(ProxyObserved::default()); + let captures = observed.clone(); + let task = tokio::spawn(async move { + let mut connections = JoinSet::new(); + loop { + tokio::select! { + accepted = listener.accept() => { + let Ok((socket, _)) = accepted else { + captures.fixture_errors.fetch_add(1, Ordering::SeqCst); + break; + }; + let number = captures.tcp_connections.fetch_add(1, Ordering::SeqCst); + if number >= MAX_CONNECTIONS { + captures.fixture_errors.fetch_add(1, Ordering::SeqCst); + drop(socket); + break; + } + let captures = captures.clone(); + let acceptor = acceptor.clone(); + connections.spawn(async move { + let connection = async { + if matches!(kind, ProxyKind::Close) { + drop(socket); + return Ok::<_, io::Error>(()); + } + let mut stream: Box = if let Some(acceptor) = acceptor { + // Certificate rejection by the client is an expected + // outcome in negative proxy-TLS cases. + let Ok(tls) = acceptor.accept(socket).await else { + return Ok(()); + }; + captures.tls_connections.fetch_add(1, Ordering::SeqCst); + if tls.get_ref().1.alpn_protocol().is_none() { + captures.no_proxy_alpn.fetch_add(1, Ordering::SeqCst); + } + if tls.get_ref().1.alpn_protocol() == Some(b"http/1.1".as_slice()) { + captures.http1_proxy_alpn.fetch_add(1, Ordering::SeqCst); + } + Box::new(tls) + } else { + Box::new(socket) + }; + let forward = match kind { + ProxyKind::Connect(reply) => { + connect_handshake(&mut stream, &captures, reply).await? + } + ProxyKind::Socks4 => { + socks4_handshake(&mut stream, &captures, target.port()).await? + } + ProxyKind::Socks5 { auth, refuse } => { + socks5_handshake(&mut stream, &captures, target.port(), auth, refuse).await? + } + ProxyKind::Close => unreachable!(), + }; + if forward { + let mut upstream = tokio::net::TcpStream::connect(target).await?; + // Closure/reset after failed origin TLS is expected. + let _ = tokio::io::copy_bidirectional(&mut stream, &mut upstream).await; + } else { + stream.shutdown().await?; + } + Ok(()) + }; + if !matches!(tokio::time::timeout(FIXTURE_TIMEOUT, connection).await, Ok(Ok(()))) { + captures.fixture_errors.fetch_add(1, Ordering::SeqCst); + } + }); + } + Some(result) = connections.join_next(), if !connections.is_empty() => { + if result.is_err() { + captures.fixture_errors.fetch_add(1, Ordering::SeqCst); + } + } + } + } + }); + Ok(Self { + address, + observed, + task, + }) + } + + fn url(&self, scheme: &str, credentials: bool) -> String { + format!( + "{scheme}://{}{address}", + if credentials { "user:pass@" } else { "" }, + address = self.address + ) + } + + fn assert_connections(&self, expected: usize) { + assert_eq!( + self.observed.tcp_connections.load(Ordering::SeqCst), + expected, + "one selected-proxy connection per generation; no handshake retry" + ); + assert_eq!( + self.observed.fixture_errors.load(Ordering::SeqCst), + 0, + "bounded fixture parsing/forwarding must complete without errors" + ); + } + + fn assert_connect(&self, authority: &str, count: usize, credentials: bool) { + self.assert_connections(count); + let observed = self.observed.connect.lock().unwrap(); + assert_eq!(observed.len(), count); + for request in observed.iter() { + assert_eq!(request.authority, authority); + assert_eq!(request.proxy_authorizations, usize::from(credentials)); + assert_eq!(request.expected_basic, credentials); + assert_eq!( + request.client_user_agents, 1, + "CONNECT preserves the measured legacy client UA" + ); + assert!( + request.expected_client_user_agent, + "CONNECT must not use the native per-request override" + ); + assert_eq!( + request.origin_authorizations, 0, + "origin Authorization must never appear on outer CONNECT" + ); + assert!(!request.origin_bearer_seen); + } + } + } + + async fn read_nul_string( + stream: &mut S, + limit: usize, + ) -> io::Result> { + let mut value = Vec::new(); + for _ in 0..=limit { + let byte = stream.read_u8().await?; + if byte == 0 { + return Ok(value); + } + if value.len() == limit { + return Err(invalid_handshake()); + } + value.push(byte); + } + Err(invalid_handshake()) + } + + async fn connect_handshake( + stream: &mut S, + captures: &ProxyObserved, + reply: ConnectReply, + ) -> io::Result { + let mut bytes = Vec::new(); + // Read exactly through the terminator, never consuming inner TLS bytes. + while bytes.len() < 8192 && !bytes.ends_with(b"\r\n\r\n") { + bytes.push(stream.read_u8().await?); + } + if !bytes.ends_with(b"\r\n\r\n") { + return Err(invalid_handshake()); + } + let text = std::str::from_utf8(&bytes).map_err(|_| invalid_handshake())?; + let mut lines = text.split("\r\n"); + let mut request_line = lines + .next() + .ok_or_else(invalid_handshake)? + .split_whitespace(); + if request_line.next() != Some("CONNECT") { + return Err(invalid_handshake()); + } + let authority = request_line.next().ok_or_else(invalid_handshake)?; + if authority.len() > 256 + || request_line.next() != Some("HTTP/1.1") + || request_line.next().is_some() + { + return Err(invalid_handshake()); + } + let mut request = ConnectObserved { + authority: authority.to_owned(), + proxy_authorizations: 0, + expected_basic: false, + client_user_agents: 0, + expected_client_user_agent: false, + origin_authorizations: 0, + origin_bearer_seen: text.contains("public-test-access"), + }; + for line in lines.filter(|line| !line.is_empty()) { + let (name, value) = line.split_once(':').ok_or_else(invalid_handshake)?; + if name.eq_ignore_ascii_case("proxy-authorization") { + request.proxy_authorizations += 1; + request.expected_basic = value.trim() == BASIC; + } + if name.eq_ignore_ascii_case("user-agent") { + request.client_user_agents += 1; + request.expected_client_user_agent = + value.trim() == concat!("claude-code-proxy/", env!("CARGO_PKG_VERSION")); + } + if name.eq_ignore_ascii_case("authorization") { + request.origin_authorizations += 1; + } + } + let legacy_ua = request.client_user_agents == 1 && request.expected_client_user_agent; + { + let mut observed = captures.connect.lock().unwrap(); + if observed.len() >= MAX_CONNECTIONS { + return Err(invalid_handshake()); + } + observed.push(request); + } + let response: &[u8] = match reply { + // A strict proxy policy justified by the unchanged legacy-builder + // runtime probe, not an invented official client fingerprint. + ConnectReply::Tunnel if legacy_ua => b"HTTP/1.1 200 Connection Established\r\n\r\n", + ConnectReply::Tunnel => b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n", + ConnectReply::AuthenticationRequired => b"HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"fixture\"\r\nContent-Length: 14\r\n\r\nDO_NOT_REFLECT", + ConnectReply::Refused => b"HTTP/1.1 403 Forbidden\r\nContent-Length: 14\r\n\r\nDO_NOT_REFLECT", + ConnectReply::Truncated => b"HTTP/1.1 200 Connection Established\r\nX-Fixture: DO_NOT_REFLECT", + }; + stream.write_all(response).await?; + Ok(matches!(reply, ConnectReply::Tunnel) && legacy_ua) + } + + async fn socks4_handshake( + stream: &mut S, + captures: &ProxyObserved, + target_port: u16, + ) -> io::Result { + let mut header = [0; 8]; + stream.read_exact(&mut header).await?; + if header[0] != 4 || header[1] != 1 { + return Err(invalid_handshake()); + } + let port = u16::from_be_bytes([header[2], header[3]]); + let user = read_nul_string(stream, 255).await?; + let address = if header[4..7] == [0, 0, 0] && header[7] != 0 { + let hostname = read_nul_string(stream, 255).await?; + SocksAddress::Hostname(String::from_utf8(hostname).map_err(|_| invalid_handshake())?) + } else { + SocksAddress::Ipv4(Ipv4Addr::new(header[4], header[5], header[6], header[7])) + }; + if port != target_port { + return Err(invalid_handshake()); + } + let valid_domain = + !matches!(&address, SocksAddress::Hostname(hostname) if hostname.is_empty()); + captures.socks.lock().unwrap().push(SocksObserved { + version: 4, + address: Some(address), + port: Some(port), + authenticated: !user.is_empty(), + expected_credentials: false, + origin_bearer_seen: user + .windows(b"public-test-access".len()) + .any(|window| window == b"public-test-access"), + }); + stream + .write_all(&[ + 0, + if valid_domain { 90 } else { 91 }, + header[2], + header[3], + 127, + 0, + 0, + 1, + ]) + .await?; + Ok(valid_domain) + } + + async fn socks5_handshake( + stream: &mut S, + captures: &ProxyObserved, + target_port: u16, + auth: SocksAuth, + refuse: bool, + ) -> io::Result { + let version = stream.read_u8().await?; + let count = usize::from(stream.read_u8().await?); + if version != 5 || count == 0 || count > 16 { + return Err(invalid_handshake()); + } + let mut methods = vec![0; count]; + stream.read_exact(&mut methods).await?; + let authenticated = !matches!(auth, SocksAuth::None); + let method = if authenticated { 2 } else { 0 }; + if !methods.contains(&method) { + return Err(invalid_handshake()); + } + stream.write_all(&[5, method]).await?; + let mut expected_credentials = false; + let mut origin_bearer_seen = false; + if authenticated { + if stream.read_u8().await? != 1 { + return Err(invalid_handshake()); + } + let length = usize::from(stream.read_u8().await?); + let mut user = vec![0; length]; + stream.read_exact(&mut user).await?; + let length = usize::from(stream.read_u8().await?); + let mut password = vec![0; length]; + stream.read_exact(&mut password).await?; + expected_credentials = user == b"user" && password == b"pass"; + origin_bearer_seen = [&user, &password].iter().any(|value| { + value + .windows(b"public-test-access".len()) + .any(|window| window == b"public-test-access") + }); + let reject = matches!(auth, SocksAuth::RejectPassword); + stream.write_all(&[1, u8::from(reject)]).await?; + if reject { + captures.socks.lock().unwrap().push(SocksObserved { + version: 5, + address: None, + port: None, + authenticated, + expected_credentials, + origin_bearer_seen, + }); + return Ok(false); + } + } + let mut header = [0; 4]; + stream.read_exact(&mut header).await?; + if header[..3] != [5, 1, 0] { + return Err(invalid_handshake()); + } + let address = match header[3] { + 1 => { + let mut address = [0; 4]; + stream.read_exact(&mut address).await?; + SocksAddress::Ipv4(Ipv4Addr::from(address)) + } + 3 => { + let length = usize::from(stream.read_u8().await?); + if length == 0 { + return Err(invalid_handshake()); + } + let mut hostname = vec![0; length]; + stream.read_exact(&mut hostname).await?; + SocksAddress::Hostname( + String::from_utf8(hostname).map_err(|_| invalid_handshake())?, + ) + } + 4 => { + let mut address = [0; 16]; + stream.read_exact(&mut address).await?; + SocksAddress::Ipv6(Ipv6Addr::from(address)) + } + _ => return Err(invalid_handshake()), + }; + let port = stream.read_u16().await?; + if port != target_port { + return Err(invalid_handshake()); + } + captures.socks.lock().unwrap().push(SocksObserved { + version: 5, + address: Some(address), + port: Some(port), + authenticated, + expected_credentials, + origin_bearer_seen, + }); + stream + .write_all(&[5, if refuse { 5 } else { 0 }, 0, 1, 127, 0, 0, 1, 0, 0]) + .await?; + Ok(!refuse) + } + + async fn proxied_child( + origin: &TlsServer, + proxy_url: &str, + case: &'static str, + stream: bool, + base_url: &str, + ) { + child_configured( + origin, + case, + stream, + base_url, + &[ + ("HTTPS_PROXY", proxy_url), + ("NO_PROXY", ""), + ("no_proxy", ""), + ], + ) + .await; + } + + fn assert_origin(origin: &TlsServer, protocol: Version, expected: usize) { + let observed = origin.observed.lock().unwrap(); + assert_eq!( + observed.len(), + expected, + "no extra generation requests or direct fallback" + ); + for request in observed.iter() { + assert_eq!(request.version, protocol); + assert_eq!(request.headers.get_all("authorization").iter().count(), 1); + assert_eq!( + request.headers["authorization"], + "Bearer public-test-access" + ); + assert!( + !request.headers.contains_key("proxy-authorization"), + "proxy credentials must stay outside origin TLS" + ); + assert!(!request.headers.contains_key("proxy-connection")); + assert!(!request.headers.values().any(|value| { + let value = value.to_str().unwrap_or(""); + value.contains("dXNlcjpwYXNz") || value.contains("user:pass") + })); + } + } + + async fn connect_matrix(protocol: Version, tls: bool) { + let origin = TlsServer::start(protocol, 200, None, success()).await; + let proxy = FakeProxy::start( + &origin, + "127.0.0.1:0", + tls, + ProxyKind::Connect(ConnectReply::Tunnel), + ) + .await + .unwrap(); + let base_url = origin_url(&origin, "fixture.invalid"); + let proxy_url = proxy.url(if tls { "https" } else { "http" }, true); + for stream in [false, true] { + proxied_child(&origin, &proxy_url, "native-ua-override", stream, &base_url).await; + } + proxy.assert_connect( + &format!("fixture.invalid:{}", origin_address(&origin).port()), + 2, + true, + ); + if tls { + assert_eq!(proxy.observed.tls_connections.load(Ordering::SeqCst), 2); + assert_eq!( + proxy.observed.no_proxy_alpn.load(Ordering::SeqCst), + 2, + "baseline clears proxy ALPN; successful parsed CONNECT still proves HTTP/1.1" + ); + assert_eq!(proxy.observed.http1_proxy_alpn.load(Ordering::SeqCst), 0); + } + assert_origin(&origin, protocol, 2); + for request in origin.observed.lock().unwrap().iter() { + assert_eq!(request.headers["user-agent"], "public-native-override"); + } + } + + #[tokio::test] + async fn antigravity_transport_proxy_http_connect_origin_http1() { + connect_matrix(Version::HTTP_11, false).await; + } + + #[tokio::test] + async fn antigravity_transport_proxy_http_connect_origin_http2() { + connect_matrix(Version::HTTP_2, false).await; + } + + #[tokio::test] + async fn antigravity_transport_proxy_https_connect_origin_http1() { + connect_matrix(Version::HTTP_11, true).await; + } + + #[tokio::test] + async fn antigravity_transport_proxy_https_connect_origin_http2() { + connect_matrix(Version::HTTP_2, true).await; + } + + #[tokio::test] + async fn antigravity_transport_proxy_connect_rejections_do_not_retry_or_fall_back() { + for tls in [false, true] { + for reply in [ + ConnectReply::AuthenticationRequired, + ConnectReply::Refused, + ConnectReply::Truncated, + ] { + let origin = TlsServer::start(Version::HTTP_11, 200, None, success()).await; + let proxy = + FakeProxy::start(&origin, "127.0.0.1:0", tls, ProxyKind::Connect(reply)) + .await + .unwrap(); + let proxy_url = proxy.url(if tls { "https" } else { "http" }, true); + for stream in [false, true] { + proxied_child( + &origin, + &proxy_url, + "preheader-failure", + stream, + &origin.url, + ) + .await; + } + proxy.assert_connect(&origin_address(&origin).to_string(), 2, true); + assert_origin(&origin, Version::HTTP_11, 0); + } + } + } + + #[tokio::test] + async fn antigravity_transport_tls_untrusted_origin_ca_is_rejected() { + let origin = TlsServer::start(Version::HTTP_11, 200, None, success()).await; + for stream in [false, true] { + child(&origin, "untrusted-ca", stream).await; + } + assert_origin(&origin, Version::HTTP_11, 0); + } + + #[tokio::test] + async fn antigravity_transport_proxy_connect_verifies_origin_hostname_and_ca() { + for (wrong_hostname, proxy_tls) in [(false, false), (true, false), (true, true)] { + let origin = TlsServer::start(Version::HTTP_2, 200, None, success()).await; + let proxy = FakeProxy::start( + &origin, + "127.0.0.1:0", + proxy_tls, + ProxyKind::Connect(ConnectReply::Tunnel), + ) + .await + .unwrap(); + let hostname = if wrong_hostname { + "wrong-san.invalid" + } else { + "fixture.invalid" + }; + let base_url = origin_url(&origin, hostname); + let proxy_url = proxy.url(if proxy_tls { "https" } else { "http" }, true); + for stream in [false, true] { + proxied_child( + &origin, + &proxy_url, + if wrong_hostname { + "preheader-failure" + } else { + "untrusted-ca" + }, + stream, + &base_url, + ) + .await; + } + proxy.assert_connect( + &format!("{hostname}:{}", origin_address(&origin).port()), + 2, + true, + ); + if proxy_tls { + assert_eq!(proxy.observed.tls_connections.load(Ordering::SeqCst), 2); + } + assert_origin(&origin, Version::HTTP_2, 0); + } + } + + #[tokio::test] + async fn antigravity_transport_proxy_https_rejects_untrusted_ca_before_connect() { + let origin = TlsServer::start(Version::HTTP_2, 200, None, success()).await; + let proxy = FakeProxy::start( + &origin, + "127.0.0.1:0", + true, + ProxyKind::Connect(ConnectReply::Tunnel), + ) + .await + .unwrap(); + let proxy_url = proxy.url("https", true); + for stream in [false, true] { + proxied_child(&origin, &proxy_url, "untrusted-ca", stream, &origin.url).await; + } + proxy.assert_connections(2); + assert!(proxy.observed.connect.lock().unwrap().is_empty()); + assert_eq!(proxy.observed.tls_connections.load(Ordering::SeqCst), 0); + assert_origin(&origin, Version::HTTP_2, 0); + } + + #[tokio::test] + async fn antigravity_transport_proxy_https_rejects_wrong_proxy_san_before_connect() { + let origin = TlsServer::start(Version::HTTP_11, 200, None, success()).await; + let proxy = match FakeProxy::start( + &origin, + "127.0.0.2:0", + true, + ProxyKind::Connect(ConnectReply::Tunnel), + ) + .await + { + Ok(proxy) => proxy, + Err(error) + if matches!( + error.kind(), + io::ErrorKind::AddrNotAvailable | io::ErrorKind::Unsupported + ) => + { + eprintln!("SKIP_IPV4_LOOPBACK_ALIAS_UNAVAILABLE"); + return; + } + Err(_) => panic!("synthetic IPv4 loopback alias fixture failed"), + }; + let proxy_url = proxy.url("https", true); + for stream in [false, true] { + proxied_child( + &origin, + &proxy_url, + "preheader-failure", + stream, + &origin.url, + ) + .await; + } + proxy.assert_connections(2); + assert!(proxy.observed.connect.lock().unwrap().is_empty()); + assert_eq!(proxy.observed.tls_connections.load(Ordering::SeqCst), 0); + assert_origin(&origin, Version::HTTP_11, 0); + } + + #[tokio::test] + async fn antigravity_transport_proxy_https_failed_handshake_does_not_fall_back() { + let origin = TlsServer::start(Version::HTTP_11, 200, None, success()).await; + let proxy = FakeProxy::start(&origin, "127.0.0.1:0", false, ProxyKind::Close) + .await + .unwrap(); + let proxy_url = proxy.url("https", true); + for stream in [false, true] { + proxied_child( + &origin, + &proxy_url, + "preheader-failure", + stream, + &origin.url, + ) + .await; + } + proxy.assert_connections(2); + assert!(proxy.observed.connect.lock().unwrap().is_empty()); + assert_origin(&origin, Version::HTTP_11, 0); + } + + async fn positive_socks5_matrix(scheme: &str, remote_dns: bool, authenticated: bool) { + for protocol in [Version::HTTP_11, Version::HTTP_2] { + let origin = TlsServer::start(protocol, 200, None, success()).await; + let kind = ProxyKind::Socks5 { + auth: if authenticated { + SocksAuth::Password + } else { + SocksAuth::None + }, + refuse: false, + }; + let proxy = FakeProxy::start(&origin, "127.0.0.1:0", false, kind) + .await + .unwrap(); + // localhost must actually resolve before the local-DNS handshake; + // fixture.invalid cannot resolve and must remain a remote hostname. + let base_url = origin_url( + &origin, + if remote_dns { + "fixture.invalid" + } else { + "localhost" + }, + ); + let proxy_url = proxy.url(scheme, authenticated); + for stream in [false, true] { + proxied_child(&origin, &proxy_url, "plain", stream, &base_url).await; + } + proxy.assert_connections(2); + let observed = proxy.observed.socks.lock().unwrap(); + assert_eq!(observed.len(), 2); + for request in observed.iter() { + assert_eq!(request.version, 5); + assert_eq!(request.port, Some(origin_address(&origin).port())); + let expected = if remote_dns { + SocksAddress::Hostname("fixture.invalid".into()) + } else { + // Only a representable local IPv4 result is supported by + // the cached Uri-based helper; literal IPv6 is separate. + SocksAddress::Ipv4(Ipv4Addr::LOCALHOST) + }; + assert_eq!( + request.address.as_ref(), + Some(&expected), + "assert actual local-IP versus remote-hostname address form" + ); + assert_eq!(request.authenticated, authenticated); + assert_eq!(request.expected_credentials, authenticated); + assert!(!request.origin_bearer_seen); + } + assert_origin(&origin, protocol, 2); + } + } + + async fn strict_socks4_legacy_failure(scheme: &str, remote_dns: bool) { + // NEGATIVE compatibility rows only. The unchanged legacy builder probe + // proved the cached helper's extra NUL; never forgive/drop that byte in + // this strict proxy or advertise working strict SOCKS4/4a support. + for protocol in [Version::HTTP_11, Version::HTTP_2] { + let origin = TlsServer::start(protocol, 200, None, success()).await; + let proxy = FakeProxy::start(&origin, "127.0.0.1:0", false, ProxyKind::Socks4) + .await + .unwrap(); + let base_url = origin_url( + &origin, + if remote_dns { + "fixture.invalid" + } else { + "localhost" + }, + ); + let proxy_url = proxy.url(scheme, false); + for stream in [false, true] { + proxied_child(&origin, &proxy_url, "preheader-failure", stream, &base_url).await; + } + proxy.assert_connections(2); + let observed = proxy.observed.socks.lock().unwrap(); + assert_eq!(observed.len(), 2); + for request in observed.iter() { + assert_eq!(request.version, 4); + assert_eq!(request.port, Some(origin_address(&origin).port())); + assert!( + !request.authenticated + && !request.expected_credentials + && !request.origin_bearer_seen + ); + let expected = if remote_dns { + SocksAddress::Hostname(String::new()) + } else { + SocksAddress::Ipv4(Ipv4Addr::LOCALHOST) + }; + assert_eq!( + request.address.as_ref(), + Some(&expected), + "preserve observed legacy framing failure, not a repaired protocol" + ); + } + assert_origin(&origin, protocol, 0); + eprintln!("INHERITED_STRICT_SOCKS4_INCOMPATIBILITY_NOT_POSITIVE_SUPPORT"); + } + } + + #[tokio::test] + async fn antigravity_transport_proxy_socks4_strict_rfc_failure_preserves_legacy_framing() { + strict_socks4_legacy_failure("socks4", false).await; + } + + #[tokio::test] + async fn antigravity_transport_proxy_socks4a_strict_rfc_failure_preserves_legacy_framing() { + strict_socks4_legacy_failure("socks4a", true).await; + } + + #[tokio::test] + async fn antigravity_transport_proxy_socks5_local_dns_and_password_auth() { + positive_socks5_matrix("socks5", false, true).await; + } + + #[tokio::test] + async fn antigravity_transport_proxy_socks5h_remote_dns_and_password_auth() { + positive_socks5_matrix("socks5h", true, true).await; + } + + #[tokio::test] + async fn antigravity_transport_proxy_socks5h_no_auth_is_supported() { + positive_socks5_matrix("socks5h", true, false).await; + } + + #[tokio::test] + async fn antigravity_transport_proxy_socks5_auth_and_connect_rejections_do_not_fall_back() { + for (auth, refuse) in [ + (SocksAuth::RejectPassword, false), + (SocksAuth::Password, true), + ] { + let origin = TlsServer::start(Version::HTTP_11, 200, None, success()).await; + let proxy = FakeProxy::start( + &origin, + "127.0.0.1:0", + false, + ProxyKind::Socks5 { auth, refuse }, + ) + .await + .unwrap(); + let proxy_url = proxy.url("socks5h", true); + let base_url = origin_url(&origin, "fixture.invalid"); + for stream in [false, true] { + proxied_child(&origin, &proxy_url, "preheader-failure", stream, &base_url).await; + } + proxy.assert_connections(2); + let observed = proxy.observed.socks.lock().unwrap(); + assert_eq!(observed.len(), 2); + for request in observed.iter() { + assert!(request.authenticated); + assert!(request.expected_credentials); + assert!(!request.origin_bearer_seen); + if refuse { + assert_eq!( + request.address, + Some(SocksAddress::Hostname("fixture.invalid".into())) + ); + assert_eq!(request.port, Some(origin_address(&origin).port())); + } else { + assert!( + request.address.is_none(), + "rejected password must prevent CONNECT" + ); + assert!(request.port.is_none()); + } + } + assert_origin(&origin, Version::HTTP_11, 0); + } + } + + #[tokio::test] + async fn antigravity_transport_proxy_ipv6_tcp_loopback_is_separate_from_destination_support() { + for (scheme, kind) in [ + ("http", ProxyKind::Connect(ConnectReply::Tunnel)), + // Strict SOCKS4a is an explicit negative legacy-compatibility row, + // not part of positive IPv6 proxy-TCP support. + ( + "socks5h", + ProxyKind::Socks5 { + auth: SocksAuth::Password, + refuse: false, + }, + ), + ] { + let origin = TlsServer::start(Version::HTTP_2, 200, None, success()).await; + let proxy = match FakeProxy::start(&origin, "[::1]:0", false, kind).await { + Ok(proxy) => proxy, + Err(error) + if matches!( + error.kind(), + io::ErrorKind::AddrNotAvailable | io::ErrorKind::Unsupported + ) => + { + // A finite, explicit coverage skip, never universal IPv6 PASS. + eprintln!("SKIP_IPV6_LOOPBACK_UNAVAILABLE"); + return; + } + Err(_) => panic!("synthetic IPv6 proxy loopback fixture failed"), + }; + let credentials = true; + let proxy_url = proxy.url(scheme, credentials); + assert!(proxy_url.contains("[::1]:")); + let base_url = origin_url(&origin, "fixture.invalid"); + for stream in [false, true] { + proxied_child(&origin, &proxy_url, "plain", stream, &base_url).await; + } + proxy.assert_connections(2); + if scheme == "http" { + proxy.assert_connect( + &format!("fixture.invalid:{}", origin_address(&origin).port()), + 2, + true, + ); + } else { + let observed = proxy.observed.socks.lock().unwrap(); + assert_eq!(observed.len(), 2); + for request in observed.iter() { + assert_eq!( + request.address, + Some(SocksAddress::Hostname("fixture.invalid".into())) + ); + assert_eq!(request.port, Some(origin_address(&origin).port())); + assert_eq!(request.authenticated, credentials); + assert_eq!(request.expected_credentials, credentials); + assert!(!request.origin_bearer_seen); + } + } + assert_origin(&origin, Version::HTTP_2, 2); + } + } + + #[tokio::test] + async fn antigravity_transport_proxy_no_proxy_wildcard_ip_and_cidr_bypass() { + // Cached baseline wildcard is a domain matcher, not an IP matcher. + // Exercise domain wildcard separately from literal-IP and CIDR bypass. + for (bypass, hostname) in [ + ("*", "localhost"), + ("127.0.0.1", "127.0.0.1"), + ("127.0.0.0/8", "127.0.0.1"), + ] { + let origin = TlsServer::start(Version::HTTP_11, 200, None, success()).await; + let rejected = FakeProxy::start( + &origin, + "127.0.0.1:0", + false, + ProxyKind::Connect(ConnectReply::AuthenticationRequired), + ) + .await + .unwrap(); + let proxy_url = rejected.url("http", true); + let base_url = origin_url(&origin, hostname); + for stream in [false, true] { + child_configured( + &origin, + "plain", + stream, + &base_url, + &[ + ("HTTPS_PROXY", &proxy_url), + ("NO_PROXY", bypass), + ("no_proxy", bypass), + ], + ) + .await; + } + rejected.assert_connections(0); + assert_origin(&origin, Version::HTTP_11, 2); + } + } + + #[tokio::test] + async fn antigravity_transport_proxy_wildcard_ip_literal_preserves_cached_baseline() { + // Do not silently broaden the inherited matcher into a direct-route + // privacy change: '*' currently matches domains, not IP literals. + let origin = TlsServer::start(Version::HTTP_11, 200, None, success()).await; + let proxy = FakeProxy::start( + &origin, + "127.0.0.1:0", + false, + ProxyKind::Connect(ConnectReply::Tunnel), + ) + .await + .unwrap(); + let proxy_url = proxy.url("http", true); + for stream in [false, true] { + child_configured( + &origin, + "plain", + stream, + &origin.url, + &[ + ("HTTPS_PROXY", &proxy_url), + ("NO_PROXY", "*"), + ("no_proxy", "*"), + ], + ) + .await; + } + proxy.assert_connect(&origin_address(&origin).to_string(), 2, true); + assert_origin(&origin, Version::HTTP_11, 2); + } + + #[tokio::test] + async fn antigravity_transport_proxy_uppercase_https_overrides_lowercase() { + let origin = TlsServer::start(Version::HTTP_2, 200, None, success()).await; + let selected = FakeProxy::start( + &origin, + "127.0.0.1:0", + false, + ProxyKind::Connect(ConnectReply::Tunnel), + ) + .await + .unwrap(); + let rejected = FakeProxy::start( + &origin, + "127.0.0.1:0", + false, + ProxyKind::Connect(ConnectReply::AuthenticationRequired), + ) + .await + .unwrap(); + let selected_url = selected.url("http", true); + let rejected_url = rejected.url("http", true); + for stream in [false, true] { + child_configured( + &origin, + "plain", + stream, + &origin.url, + &[ + ("HTTPS_PROXY", &selected_url), + ("https_proxy", &rejected_url), + ("NO_PROXY", ""), + ("no_proxy", ""), + ], + ) + .await; + } + selected.assert_connect(&origin_address(&origin).to_string(), 2, true); + rejected.assert_connections(0); + assert_origin(&origin, Version::HTTP_2, 2); + } + + #[tokio::test] + async fn antigravity_transport_proxy_https_specific_precedes_all_proxy() { + let origin = TlsServer::start(Version::HTTP_11, 200, None, success()).await; + let selected = FakeProxy::start( + &origin, + "127.0.0.1:0", + false, + ProxyKind::Connect(ConnectReply::Tunnel), + ) + .await + .unwrap(); + let rejected = FakeProxy::start( + &origin, + "127.0.0.1:0", + false, + ProxyKind::Connect(ConnectReply::AuthenticationRequired), + ) + .await + .unwrap(); + let selected_url = selected.url("http", true); + let rejected_url = rejected.url("http", true); + for stream in [false, true] { + child_configured( + &origin, + "plain", + stream, + &origin.url, + &[ + ("HTTPS_PROXY", &selected_url), + ("ALL_PROXY", &rejected_url), + ("NO_PROXY", ""), + ("no_proxy", ""), + ], + ) + .await; + } + selected.assert_connect(&origin_address(&origin).to_string(), 2, true); + rejected.assert_connections(0); + assert_origin(&origin, Version::HTTP_11, 2); + } + + #[tokio::test] + async fn antigravity_transport_proxy_uppercase_no_proxy_even_empty_precedes_lowercase() { + // Cached Matcher::get_first_env returns the first present variable, + // including an explicitly empty uppercase value. + for (uppercase, lowercase, proxy_requests) in [("", "*", 2), ("*", "", 0)] { + let origin = TlsServer::start(Version::HTTP_11, 200, None, success()).await; + let selected = FakeProxy::start( + &origin, + "127.0.0.1:0", + false, + ProxyKind::Connect(ConnectReply::Tunnel), + ) + .await + .unwrap(); + let selected_url = selected.url("http", true); + let base_url = origin_url(&origin, "localhost"); + for stream in [false, true] { + child_configured( + &origin, + "plain", + stream, + &base_url, + &[ + ("HTTPS_PROXY", &selected_url), + ("NO_PROXY", uppercase), + ("no_proxy", lowercase), + ], + ) + .await; + } + selected.assert_connect( + &format!("localhost:{}", origin_address(&origin).port()), + proxy_requests, + true, + ); + assert_origin(&origin, Version::HTTP_11, 2); + } + } + + #[tokio::test] + async fn antigravity_transport_proxy_uppercase_all_precedence_and_cgi_bypass() { + for cgi in [false, true] { + for https_specific in [false, true] { + let origin = TlsServer::start(Version::HTTP_11, 200, None, success()).await; + let selected = FakeProxy::start( + &origin, + "127.0.0.1:0", + false, + ProxyKind::Connect(ConnectReply::Tunnel), + ) + .await + .unwrap(); + let rejected = FakeProxy::start( + &origin, + "127.0.0.1:0", + false, + ProxyKind::Connect(ConnectReply::AuthenticationRequired), + ) + .await + .unwrap(); + let selected_url = selected.url("http", true); + let rejected_url = rejected.url("http", true); + let mut env = vec![ + ("HTTP_PROXY", rejected_url.as_str()), + ("ALL_PROXY", selected_url.as_str()), + ("all_proxy", rejected_url.as_str()), + ("NO_PROXY", ""), + ("no_proxy", ""), + ]; + if https_specific { + env.push(("HTTPS_PROXY", selected_url.as_str())); + } + if cgi { + env.push(("REQUEST_METHOD", "GET")); + } + for stream in [false, true] { + child_configured(&origin, "plain", stream, &origin.url, &env).await; + } + // Cached Linux Matcher::from_system disables every proxy in + // CGI, not just HTTP_PROXY. This is source-backed compatibility, + // not an assumption about Go or other proxy implementations. + selected.assert_connect( + &origin_address(&origin).to_string(), + if cgi { 0 } else { 2 }, + true, + ); + rejected.assert_connections(0); + assert_origin(&origin, Version::HTTP_11, 2); + } + } + } + + #[tokio::test] + async fn antigravity_transport_proxy_http_environment_is_not_an_https_fallback_even_in_cgi() { + // The release public API accepts only HTTPS origins: this exercises CGI + // handling without claiming coverage of cleartext HTTP CGI destinations. + for cgi in [false, true] { + let origin = TlsServer::start(Version::HTTP_11, 200, None, success()).await; + let rejected = FakeProxy::start( + &origin, + "127.0.0.1:0", + false, + ProxyKind::Connect(ConnectReply::AuthenticationRequired), + ) + .await + .unwrap(); + let rejected_url = rejected.url("http", true); + let mut env = vec![ + ("HTTP_PROXY", rejected_url.as_str()), + ("http_proxy", rejected_url.as_str()), + ("NO_PROXY", ""), + ("no_proxy", ""), + ]; + if cgi { + env.push(("REQUEST_METHOD", "GET")); + } + for stream in [false, true] { + child_configured(&origin, "plain", stream, &origin.url, &env).await; + } + rejected.assert_connections(0); + assert_origin(&origin, Version::HTTP_11, 2); + } + } +} diff --git a/tests/fixtures/antigravity-transport/ca.pem b/tests/fixtures/antigravity-transport/ca.pem new file mode 100644 index 00000000..1698dbda --- /dev/null +++ b/tests/fixtures/antigravity-transport/ca.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDQDCCAiigAwIBAgIBATANBgkqhkiG9w0BAQsFADAwMS4wLAYDVQQDDCVQdWJs +aWMgaW5lcnQgbmF0aXZlIHRyYW5zcG9ydCB0ZXN0IENBMCAXDTI2MTEwMzEyNTM0 +NloYDzIxMjYxMDEwMTI1MzQ2WjAwMS4wLAYDVQQDDCVQdWJsaWMgaW5lcnQgbmF0 +aXZlIHRyYW5zcG9ydCB0ZXN0IENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEApLuDDnzBU+I3lF54NYqOlkjMG9YiZ2UYFZE8LS40PbijRXKwDOxcC3e0 +0ae51Wixi5rgp3nQX3UznYCiunndo7dTBWOa85dRrqO5W0gFKiF2XPVlAOHnXrVi +g0Yn9yxQo2RhRsVuJcsjulZoaiIkfb46M99eaeyBt+dmZXduvuoqp2y9nj908Av/ +qLzvAv4dZAIdEDtYYh5EyEggNaerNl9AVqhhWJ/keqNFVnK5x5vU6KMaWxdgrxVl +9RZV8aQDFVAQ/wMhcPVG0z+EUCE14ui3MpguYW2g5+ilB7it6gl6rl3aSEUTN67I +FO5zNcr6rVTUYlSQwtBfWD9c2KtzSQIDAQABo2MwYTAPBgNVHRMBAf8EBTADAQH/ +MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQU8Jk9SY1TXorMh3LwR1MK0iLqcUsw +HwYDVR0jBBgwFoAU8Jk9SY1TXorMh3LwR1MK0iLqcUswDQYJKoZIhvcNAQELBQAD +ggEBAJ7jm+bszlN4X+kYsDceH92uVE48L6WDUo9UZUULIiYSSs1JmBhEeBj4xs4Y +QHe91JA5BUFYok1crIBtAtj8xWA4hK3oZSqH4xN7+1SxgzUlsw1JwkUdGrgTU9Db +t1rPCxPXil1U7Ri1xLLDpeLSExeil2jYPwTnnyawVsWj4ulbMcsWVFUAMpMtWvi/ +ASnEb/bjeEGj4s1Xy1eQokETWmNUGI2xzSPIaPPAv6D617E3NYomR2n2FsRzIODr +ulgEWvz8mNruAalaWVAs3258Y5HBY5WjAZM5vWRUGCi5bUFogPDCXO9y0Y0nFzCZ +O8IXaziqTrw0vsCh9PjkaRzOBrk= +-----END CERTIFICATE----- diff --git a/tests/fixtures/antigravity-transport/server-key.pem b/tests/fixtures/antigravity-transport/server-key.pem new file mode 100644 index 00000000..789fbcb8 --- /dev/null +++ b/tests/fixtures/antigravity-transport/server-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCdmPImQGtQHJJc +HdcXWl7Van7b9Xqdesvf7NgKLLdiq6l4O8yM8GRWwsoUMEuQf4bStA3/p3lgOs7W +cGv+rtXZN5bfsCT1RbWMX3nESrkq0gkUHd+c6nuznJ1EtnKBRZ1qhIkbfG14ntj8 +pSDNrm5jj62PR18CP3RurMoorA7bknlKPagJFELvJXM9x7hVQ8AxNwv8v8JGuOgi +3X+/JGt8NsWXHZnUtV9YAH+Bwj7eiemEJnaiXxmvEnPEtAfDO2Jic9XIbc9lHzht +lZwjwKogrQ1aM6YdKp9x2Rrxpi2PysOGCVwihyi+eXLpasFXniR8slthjUb7Fh0e +Do5pL+CFAgMBAAECggEAQ1HhJnvgG2GJF7ZbegtNdUa/mAAdd4XL7wSCKJ5/W7PO +eaC5uP67LeCZZ9RVgw0mr3zgefO1Qmj8j9eIGkve56H6XeUldGxpp+Pk5Peyb1s3 +K5OzQJldbBUbjQ2OjjAJlUW2N7qIHL1mHU4AXDwFxZvLXOf06sXmW5yN4p/U3O/i +Gvwif+Mct4U8idsitCIJpJCDFZ3SFLRf5Ilj45T5OI26mJPyHJDKog2+R3Wf/9L9 +FqPDRh51auCslFEEfRV7yG0uyCdMkhl4mx8W12oY0rNARJKCo/2AMiD6X/3Q3Ace +3uCK1u0nz7+0xobaVhm+8r7vzVfNb5L4phPoXoWwQQKBgQDOb43Nl2U9jBKkCdMv +d5dDVFekQrDwAn+lBwkjna36EGJKplvMqFbNXPZgqz40fFzlmFcGpxG+WMIlMry4 +8wlHWlOZUApqgXdwp71IVdYR+tE+ELiUugKiImDDglwoWB2TFIvdjGX51WIgkYm2 +ZLq5MyqCjQWb7A1S3J8I1TMUcQKBgQDDb5NIDxKzGV6LI8EQd5PyLAROcOvPU4la +b+bBNHwF4uafHP49f+MNxWBQObmo3ydYtlWt6MJ0ytdSNbOq+HM02PMm54xFwf6A +EMJtOLAhpXN2VL+vrIMTeI4ADzTMIT7J6lJczWrRK9T961Gvk9SkIk+czZ7OR6Tn +GyR/DdsHVQKBgErmCosHXQIhqjm+HtNgDo+tMEc2sz/BiFJ15Qj5EsBh5We0SCZP +BX9pNTMJrn4YiRP8W8bcIwHu7jHU4Mgx1bH/wckkU0MlShKjqXdvPUY0vGpKYs4D +VyVAV99XktxIbCKLF9wALpegpgOvCkoU7fJ37/qB9AEIeiUQSttHj0rhAoGBAIsG +J+MutcKp3VNlT8WDCr9PCJE0SsrlReQLjj8KMt+NFTlCyqi8/KujKGxpY+T5OWgl +7LUVyp6CXMGiafjJXErPuciHFoXZBk3bnjNMDB8jLwuSJfm14lhLfdgU+R3s2FmS +Upypk77QS2eFqkoYAz/vGGsW6k0bMfckm8W1+YUxAoGBAMOjgQNmcxC9cbKX6ER1 +OP+ZXdWe2z/tPvZ32qKQLKYfNcYexnJkGjye+RY1acEZyXkvwoaAH8dObw/wf4wm +haVLeCD9nRNnqJTfhq5ctXqUJl7kgCQa04W07crGLOKlBEJl6NNUMZeQn2MPUw3b +NpAvNkqphQB6QwQZMa6zooiN +-----END PRIVATE KEY----- diff --git a/tests/fixtures/antigravity-transport/server.pem b/tests/fixtures/antigravity-transport/server.pem new file mode 100644 index 00000000..30c01940 --- /dev/null +++ b/tests/fixtures/antigravity-transport/server.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDNTCCAh2gAwIBAgIBAjANBgkqhkiG9w0BAQsFADAwMS4wLAYDVQQDDCVQdWJs +aWMgaW5lcnQgbmF0aXZlIHRyYW5zcG9ydCB0ZXN0IENBMCAXDTI2MTEwMzEyNTM0 +NloYDzIxMjYxMDEwMTI1MzQ2WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0G +CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCdmPImQGtQHJJcHdcXWl7Van7b9Xqd +esvf7NgKLLdiq6l4O8yM8GRWwsoUMEuQf4bStA3/p3lgOs7WcGv+rtXZN5bfsCT1 +RbWMX3nESrkq0gkUHd+c6nuznJ1EtnKBRZ1qhIkbfG14ntj8pSDNrm5jj62PR18C +P3RurMoorA7bknlKPagJFELvJXM9x7hVQ8AxNwv8v8JGuOgi3X+/JGt8NsWXHZnU +tV9YAH+Bwj7eiemEJnaiXxmvEnPEtAfDO2Jic9XIbc9lHzhtlZwjwKogrQ1aM6Yd +Kp9x2Rrxpi2PysOGCVwihyi+eXLpasFXniR8slthjUb7Fh0eDo5pL+CFAgMBAAGj +dDByMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsG +AQUFBwMBMD0GA1UdEQQ2MDSCCWxvY2FsaG9zdIIPZml4dHVyZS5pbnZhbGlkhwR/ +AAABhxAAAAAAAAAAAAAAAAAAAAABMA0GCSqGSIb3DQEBCwUAA4IBAQAkgA1D5Y2E +/XcYKRXfl0VtbYlXISMxKe91ePo0qJEMIk8MrweWSC+A2JKMtDKnb9Jl1OYwJnqW +Zr6VLt3eGFcktDhEzQJSyuUxL1YE9JdrhW0lt2wBxl/v3jxxEmWKKvMbwjqEMxDE +0cLf1iW4zux0HlgsDcmw6kfHvF5M5FjYvz6+hBTCVxVlrxw166r78hgv/gKOSE4g +Owo61XP1QrlbPMCgiFopQn58Ly3Fl3MwtnDJK9yd5LJTJ8LVd0xS5lot3O+cCaJy +VeIimY7N09WoYkrA+wBaOwfKGh9yDnBoBKJZvYcvBFgsGxcgrhMcGeFwTZ13OFtU +bsDaU3suItgk +-----END CERTIFICATE----- From beb7cc114038e981f7e9967b6357c9edb2ec72d6 Mon Sep 17 00:00:00 2001 From: Chris Liu <11517021+chris7iu@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:47:06 +0000 Subject: [PATCH 12/30] fix(antigravity): match typed tool schema serialization Select a bounded all-or-nothing compatible schema projection, preserving explicit defaults, proto-style zero omission and quoted int64 limits. Keep original validators, fallback codecs and V1 replay recipes unchanged. Emit VALIDATED for explicit auto with declarations and add synthetic verified-HTTPS regression coverage. Co-Authored-By: Claude Code --- src/providers/antigravity/request.rs | 7 + src/providers/antigravity/schema.rs | 174 +- src/providers/antigravity_tests.rs | 108 +- tests/antigravity_schema_parity.rs | 1674 +++++++++++++++++ .../fixtures/antigravity-schema-parity/ca.pem | 20 + .../antigravity-schema-parity/server-key.der | Bin 0 -> 1217 bytes .../antigravity-schema-parity/server.der | Bin 0 -> 852 bytes 7 files changed, 1973 insertions(+), 10 deletions(-) create mode 100644 tests/antigravity_schema_parity.rs create mode 100644 tests/fixtures/antigravity-schema-parity/ca.pem create mode 100644 tests/fixtures/antigravity-schema-parity/server-key.der create mode 100644 tests/fixtures/antigravity-schema-parity/server.der diff --git a/src/providers/antigravity/request.rs b/src/providers/antigravity/request.rs index d878742e..64fa4ffb 100644 --- a/src/providers/antigravity/request.rs +++ b/src/providers/antigravity/request.rs @@ -434,6 +434,13 @@ pub(crate) fn prepare(body: &MessagesRequest, binding: &Binding) -> Result>() ); + if body + .extra + .get("tool_choice") + .is_some_and(|choice| choice["type"] == "auto") + { + request["toolConfig"] = json!({"functionCallingConfig":{"mode":"VALIDATED"}}); + } } let wire = json!({"project":binding.project_id,"model":binding.native_model,"request":request, "requestId":format!("ccp-{}",uuid::Uuid::new_v4()),"userAgent":"antigravity","requestType":"agent"}); diff --git a/src/providers/antigravity/schema.rs b/src/providers/antigravity/schema.rs index 49b6731e..9b134036 100644 --- a/src/providers/antigravity/schema.rs +++ b/src/providers/antigravity/schema.rs @@ -108,7 +108,14 @@ impl ToolSet { .ok_or("invalid tool description")?; declaration["description"] = json!(text); } - if !empty { + let compatible = if caller == native && identity(&codec) { + compatible_projection(schema) + } else { + None + }; + if let Some(parameters) = compatible { + declaration["parameters"] = parameters; + } else if !empty { declaration["parameters"] = if root { json!({"type":"OBJECT","properties":{"json":projected},"required":["json"],"description":"Encode the entire original caller argument object as JSON in the single json string field; no extra wrapper keys."}) } else { @@ -484,6 +491,171 @@ fn project(schema: &Value) -> Result<(Value, Codec)> { Ok((Value::Object(out), codec)) } +fn compatible_projection(schema: &Value) -> Option { + // Check the whole tool before copying defaults or selecting any child. + (ProjectionBudget::default().visit(schema, 0) && compatible_schema(schema)) + .then(|| typed_projection(schema)) +} + +#[derive(Default)] +struct ProjectionBudget { + nodes: usize, + strings: usize, +} + +impl ProjectionBudget { + fn string(&mut self, value: &str) -> bool { + let Some(total) = self.strings.checked_add(value.len()) else { + return false; + }; + if total > MAX_SCHEMA_BYTES { + return false; + } + self.strings = total; + true + } + + fn visit(&mut self, value: &Value, depth: usize) -> bool { + self.nodes += 1; + if depth > 32 || self.nodes > 4096 { + return false; + } + match value { + Value::Null | Value::Bool(_) => true, + Value::Number(_) => number_value(value).is_some(), + Value::String(text) => self.string(text), + Value::Array(values) => values.iter().all(|v| self.visit(v, depth + 1)), + Value::Object(values) => values + .iter() + .all(|(key, value)| self.string(key) && self.visit(value, depth + 1)), + } + } +} + +fn number_value(value: &Value) -> Option { + const SAFE: i64 = 9_007_199_254_740_991; + let number = value.as_number()?; + if let Some(value) = number.as_i64() { + if !(-SAFE..=SAFE).contains(&value) { + return None; + } + return Some(value as f64); + } + if let Some(value) = number.as_u64() { + if value > SAFE as u64 { + return None; + } + return Some(value as f64); + } + number.as_f64().filter(|value| value.is_finite()) +} + +fn length_value(value: &Value) -> Option { + number_value(value) + .filter(|value| *value >= 0.0 && *value <= 9_007_199_254_740_991.0 && value.fract() == 0.0) + .map(|value| value as u64) +} + +fn compatible_schema(schema: &Value) -> bool { + let Some(fields) = schema.as_object() else { + return false; + }; + let Some(kind) = schema.get("type").and_then(Value::as_str) else { + return false; + }; + fields + .iter() + .all(|(key, value)| match (kind, key.as_str()) { + (_, "type") => matches!( + kind, + "object" | "array" | "string" | "integer" | "number" | "boolean" + ), + (_, "description" | "title" | "$schema") => value.is_string(), + (_, "default") => true, + ("object", "properties") => value + .as_object() + .is_some_and(|properties| properties.values().all(compatible_schema)), + ("object", "required") => value.as_array().is_some_and(|required| { + required.iter().all(|name| { + name.as_str().is_some_and(|name| { + schema + .get("properties") + .and_then(Value::as_object) + .is_some_and(|properties| properties.contains_key(name)) + }) + }) + }), + ("object", "additionalProperties") => value.is_boolean(), + ("array", "items") => value.is_object() && compatible_schema(value), + ("array", "minItems" | "maxItems") | ("string", "minLength" | "maxLength") => { + length_value(value).is_some() + } + ("string", "enum") => value + .as_array() + .is_some_and(|values| values.iter().all(Value::is_string)), + ("string", "pattern" | "format") => value.is_string(), + ("integer" | "number", "minimum" | "maximum") + | ("integer", "exclusiveMinimum" | "exclusiveMaximum") => number_value(value).is_some(), + _ => false, + }) +} + +fn typed_projection(schema: &Value) -> Value { + let mut out = Map::new(); + out.insert( + "type".into(), + json!(schema["type"].as_str().unwrap().to_ascii_uppercase()), + ); + for key in ["description", "format", "pattern"] { + if let Some(text) = schema + .get(key) + .and_then(Value::as_str) + .filter(|v| !v.is_empty()) + { + out.insert(key.into(), json!(text)); + } + } + if let Some(properties) = schema.get("properties").and_then(Value::as_object) + && !properties.is_empty() + { + out.insert( + "properties".into(), + Value::Object( + properties + .iter() + .map(|(name, child)| (name.clone(), typed_projection(child))) + .collect(), + ), + ); + } + if let Some(items) = schema.get("items") { + out.insert("items".into(), typed_projection(items)); + } + for key in ["required", "enum"] { + if let Some(values) = schema + .get(key) + .and_then(Value::as_array) + .filter(|v| !v.is_empty()) + { + out.insert(key.into(), json!(values)); + } + } + for key in ["minimum", "maximum"] { + if let Some(value) = schema.get(key).and_then(number_value).filter(|v| *v != 0.0) { + out.insert(key.into(), json!(value)); + } + } + for key in ["minLength", "maxLength", "minItems", "maxItems"] { + if let Some(value) = schema.get(key).and_then(length_value).filter(|v| *v != 0) { + out.insert(key.into(), json!(value.to_string())); + } + } + if let Some(value) = schema.get("default") { + out.insert("default".into(), value.clone()); + } + Value::Object(out) +} + fn identity(codec: &Codec) -> bool { match codec { Codec::Identity => true, diff --git a/src/providers/antigravity_tests.rs b/src/providers/antigravity_tests.rs index 1be2451a..56461de9 100644 --- a/src/providers/antigravity_tests.rs +++ b/src/providers/antigravity_tests.rs @@ -566,8 +566,72 @@ fn antigravity_integer_exclusive_projection_keeps_fractional_inclusive_constrain ) .unwrap(); let properties = &set.declarations[0]["parameters"]["properties"]; - assert_eq!(properties["lower"]["minimum"], 2); - assert_eq!(properties["upper"]["maximum"], -2); + assert_eq!( + properties, + &json!({ + "lower":{"type":"INTEGER","minimum":1.5}, + "upper":{"type":"INTEGER","maximum":-1.5} + }) + ); + let call = |args: Value| json!([{"role":"model","parts":[{"functionCall":{"name":"range","args":args}}]}]); + assert!(set.mapping(&call(json!({"lower":2,"upper":-2}))).is_ok()); + for args in [ + json!({"lower":1,"upper":-2}), + json!({"lower":1.5,"upper":-2}), + json!({"lower":2,"upper":-1}), + json!({"lower":2,"upper":-1.5}), + ] { + assert!(set.mapping(&call(args)).is_err()); + } +} + +#[test] +fn antigravity_selected_scalar_constraints_keep_proto_types_and_original_validation() { + let set = ToolSet::compile(&json!([{"name":"bounded","input_schema":{ + "type":"object","properties":{ + "text":{"type":"string","minLength":1,"maxLength":3}, + "list":{"type":"array","items":{"type":"integer"},"minItems":1,"maxItems":2}, + "number":{"type":"number","minimum":1,"maximum":4.0} + },"required":["text","list","number"],"additionalProperties":false + }}])) + .unwrap(); + let parameters = &set.declarations[0]["parameters"]; + assert_eq!(parameters.as_object().unwrap().len(), 3); + assert_eq!(parameters["type"], "OBJECT"); + assert_eq!(parameters["required"], json!(["text", "list", "number"])); + let properties = ¶meters["properties"]; + assert_eq!(properties.as_object().unwrap().len(), 3); + assert_eq!( + properties["text"], + json!({"type":"STRING","minLength":"1","maxLength":"3"}) + ); + assert_eq!( + properties["list"], + json!({"type":"ARRAY","items":{"type":"INTEGER"},"minItems":"1","maxItems":"2"}) + ); + let number = properties["number"].as_object().unwrap(); + assert_eq!(number.len(), 3); + assert_eq!(number["type"], "NUMBER"); + assert_eq!(number["minimum"].as_f64(), Some(1.0)); + assert_eq!(number["maximum"].as_f64(), Some(4.0)); + let call = |args: Value| json!([{"role":"model","parts":[{"functionCall":{"name":"bounded","args":args}}]}]); + assert!( + set.mapping(&call(json!({"text":"ok","list":[1,2],"number":2.5}))) + .is_ok() + ); + for args in [ + json!({"text":"","list":[1],"number":2}), + json!({"text":"long","list":[1],"number":2}), + json!({"text":"ok","list":[],"number":2}), + json!({"text":"ok","list":[1,2,3],"number":2}), + json!({"text":"ok","list":[1.5],"number":2}), + json!({"text":"ok","list":[1],"number":0.5}), + json!({"text":"ok","list":[1],"number":4.5}), + json!({"text":"ok","list":[1],"number":2,"extra":true}), + json!({"text":"ok","list":[1]}), + ] { + assert!(set.mapping(&call(args)).is_err()); + } } fn property_names_tool(schema: Value) -> ToolSet { @@ -874,10 +938,15 @@ fn antigravity_schema_preserves_original_dialects_and_constraints() { "required":["limit"], "additionalProperties":false }}])).unwrap(); assert_eq!( - set.declarations[0]["parameters"]["properties"]["limit"]["minimum"], - 1 + set.declarations[0]["parameters"], + json!({"type":"OBJECT","properties":{"limit":{"type":"INTEGER"}},"required":["limit"]}) ); - for args in [json!({"limit":0}), json!({"limit":1,"extra":true})] { + for args in [ + json!({"limit":0}), + json!({"limit":-1}), + json!({"limit":1.5}), + json!({"limit":1,"extra":true}), + ] { assert!(set.mapping(&json!([{"role":"model","parts":[{"functionCall":{"name":"Read","args":args}}]}])).is_err()); } assert!(set.mapping(&json!([{"role":"model","parts":[{"functionCall":{"name":"Read","args":{"limit":1}}}]}])).is_ok()); @@ -1120,9 +1189,12 @@ fn antigravity_request_multiple_tools_have_one_declaration_per_object() { .map(|declaration| json!({"functionDeclarations":[declaration]})) .collect(); assert_eq!(prepared.wire["request"]["tools"], json!(expected)); - assert_eq!(prepared.wire["request"].as_object().unwrap().len(), 4); + assert_eq!(prepared.wire["request"].as_object().unwrap().len(), 5); assert_eq!(prepared.wire.as_object().unwrap().len(), 6); - assert!(prepared.wire["request"].get("toolConfig").is_none()); + assert_eq!( + prepared.wire["request"]["toolConfig"], + json!({"functionCallingConfig":{"mode":"VALIDATED"}}) + ); assert!(prepared.wire.get("labels").is_none()); let contents = json!([{"role":"model","parts":[ {"functionCall":{"name":compiled.declarations[0]["name"],"args":{"json":"{\"payload\":[1,true]}"}}}, @@ -1140,6 +1212,18 @@ fn antigravity_request_multiple_tools_have_one_declaration_per_object() { replay::decode(&blocks, &binding()).unwrap()["contents"], contents ); + for args in [ + json!({"limit":0}), + json!({"limit":1.5}), + json!({"limit":1,"extra":true}), + ] { + assert!( + prepared + .tools + .mapping(&json!([{"role":"model","parts":[{"functionCall":{"name":"limited","args":args}}]}])) + .is_err() + ); + } } #[test] @@ -1148,10 +1232,13 @@ fn antigravity_request_zero_one_tools_and_choice_none_preserve_omission() { json!({"name":"empty","input_schema":{"type":"object","additionalProperties":false}}); for (source, choice, expected) in [ (json!([]), None, None), + (json!([]), Some(json!({"type":"auto"})), None), ( json!([tool]), None, - Some(json!([{"functionDeclarations":[{"name":"empty"}]}])), + Some( + json!([{"functionDeclarations":[{"name":"empty","parameters":{"type":"OBJECT"}}]}]), + ), ), (json!([tool]), Some(json!({"type":"none"})), None), ] { @@ -1354,7 +1441,7 @@ fn antigravity_schema_root_freeform_and_closed_no_argument_tools_are_explicit() set.declarations[0]["parameters"]["properties"]["json"]["type"], "STRING" ); - assert!(set.declarations[1].get("parameters").is_none()); + assert_eq!(set.declarations[1]["parameters"], json!({"type":"OBJECT"})); let contents = json!([{"role":"model","parts":[{"functionCall":{"name":"free","args":{"json":"{\"x\":[1,true]}"}}},{"functionCall":{"name":"empty","args":{}}}]}]); let map = set.mapping(&contents).unwrap(); let blocks = replay::encode(&contents, &binding(), "1", "full", &map).unwrap(); @@ -1362,6 +1449,9 @@ fn antigravity_schema_root_freeform_and_closed_no_argument_tools_are_explicit() assert_eq!(blocks[2]["input"], json!({})); let invalid = json!([{"role":"model","parts":[{"functionCall":{"name":"free","args":{"json":"{}","extra":true}}}]}]); assert!(set.mapping(&invalid).is_err()); + let invalid_empty = + json!([{"role":"model","parts":[{"functionCall":{"name":"empty","args":{"extra":true}}}]}]); + assert!(set.mapping(&invalid_empty).is_err()); } #[test] diff --git a/tests/antigravity_schema_parity.rs b/tests/antigravity_schema_parity.rs new file mode 100644 index 00000000..b7eed1a8 --- /dev/null +++ b/tests/antigravity_schema_parity.rs @@ -0,0 +1,1674 @@ +//! Public, synthetic production-library contract tests. The library dependency is +//! built without cfg(test); TLS verification and configuration use its normal APIs. +//! The bundled private key is a public test asset, never an account credential. +use anyhow::{Context, Result, bail, ensure}; +use axum::{ + Router, + body::to_bytes, + extract::{Request, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{get, post}, +}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use claude_code_proxy::{ + anthropic::schema::MessagesRequest, + provider::{Provider, RequestContext}, + providers::antigravity::AntigravityProvider, +}; +use futures_util::FutureExt; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::{ + collections::VecDeque, + io::Read, + net::SocketAddr, + panic::AssertUnwindSafe, + path::PathBuf, + process::{Command, Stdio}, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; +use tokio::{net::TcpListener, sync::oneshot, task::JoinHandle}; +use tokio_rustls::{TlsAcceptor, server::TlsStream}; + +const MODEL: &str = "antigravity/gemini-4-argon-high"; +const PROFILE: &str = "synthetic-schema-profile"; +const PROJECT: &str = "synthetic-schema-project"; +const TOKEN: &str = "public-synthetic-token-not-a-credential"; +const BODY_LIMIT: usize = 1024 * 1024; +const STEP_LIMIT: Duration = Duration::from_secs(8); +const CA: &[u8] = include_bytes!("fixtures/antigravity-schema-parity/ca.pem"); +const CERT: &[u8] = include_bytes!("fixtures/antigravity-schema-parity/server.der"); +const KEY: &[u8] = include_bytes!("fixtures/antigravity-schema-parity/server-key.der"); + +// Child environment changes never mutate the test runner's process environment. +// Only this very executable and this exact ignored entrypoint can be launched. +fn isolated(case: &str) { + let directory = tempfile::tempdir().expect("synthetic child directory"); + for name in ["home", "config", "state", "tmp", "empty-certs"] { + std::fs::create_dir(directory.path().join(name)).expect("synthetic directory"); + } + let root = directory.path().join("root.pem"); + std::fs::write(&root, if case == "untrusted" { &[][..] } else { CA }) + .expect("synthetic root file"); + let mut command = Command::new(std::env::current_exe().expect("integration executable")); + command + .args([ + "--ignored", + "--exact", + "schema_parity_child_entry", + "--nocapture", + ]) + .env_clear() + .env("HOME", directory.path().join("home")) + .env("XDG_CONFIG_HOME", directory.path().join("config")) + .env("XDG_STATE_HOME", directory.path().join("state")) + .env("CCP_CONFIG_DIR", directory.path().join("config")) + .env("TMPDIR", directory.path().join("tmp")) + .env("SSL_CERT_FILE", &root) + .env("SSL_CERT_DIR", directory.path().join("empty-certs")) + .env("SCHEMA_PARITY_CASE", case) + .env("RUST_TEST_THREADS", "1") + .current_dir(directory.path()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command.spawn().expect("owned integration child"); + fn reader(pipe: impl Read + Send + 'static) -> std::thread::JoinHandle> { + std::thread::spawn(move || { + let mut output = Vec::new(); + // Closing on overflow also prevents an unbounded writer/reader pair. + pipe.take(16 * 1024 + 1) + .read_to_end(&mut output) + .expect("child pipe"); + output + }) + } + let stdout = reader(child.stdout.take().expect("owned stdout")); + let stderr = reader(child.stderr.take().expect("owned stderr")); + let deadline = Instant::now() + Duration::from_secs(120); + let mut expired = false; + let status = loop { + if let Some(status) = child.try_wait().expect("owned child status") { + break status; + } + if Instant::now() >= deadline { + expired = true; + child + .kill() + .expect("terminate only the owned fixture child"); + break child.wait().expect("reap owned fixture child"); + } + std::thread::sleep(Duration::from_millis(20)); + }; + let stdout = stdout.join().expect("join stdout reader"); + let stderr = stderr.join().expect("join stderr reader"); + assert!(!expired, "synthetic child exceeded its finite deadline"); + assert!( + stdout.len() <= 16 * 1024 && stderr.len() <= 16 * 1024, + "child output bound" + ); + assert!( + status.success(), + "synthetic case {case} failed ({status})\n{}\n{}", + String::from_utf8_lossy(&stdout), + String::from_utf8_lossy(&stderr) + ); + assert!( + String::from_utf8_lossy(&stdout).contains("1 passed"), + "child test did not run" + ); +} + +macro_rules! cases { + ($($name:ident => $case:literal),+ $(,)?) => {$( + #[test] + fn $name() { isolated($case); } + )+}; +} +cases! { + schema_parity_verified_tls_control => "tls", + schema_parity_tls_rejects_untrusted_root => "untrusted", + schema_parity_baseline_validation_control => "validation", + schema_parity_v1_continuation_control => "continuation", + schema_parity_quoted_int64_limits => "limits", + schema_parity_presence_sensitive_defaults => "defaults", + schema_parity_zero_constraint_omission => "zeros", + schema_parity_closed_empty_parameters => "closed-empty", + schema_parity_explicit_auto_validated => "auto", + schema_parity_empty_pool_choice_controls => "choice-controls", + schema_parity_whole_tool_legacy_fallback => "fallback", + schema_parity_eligibility_selection => "eligibility", + schema_parity_numeric_variants_and_budgets => "numeric-budgets", + schema_parity_original_admission_controls => "admission", + schema_parity_project_admission_control => "project-admission", +} + +#[test] +#[ignore = "fixed subprocess entrypoint; invoked only with isolated synthetic environment"] +fn schema_parity_child_entry() { + let case = std::env::var("SCHEMA_PARITY_CASE").expect("fixed synthetic case selector"); + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .expect("fixture runtime"); + runtime.block_on(async { + let mut fixture = Fixture::start() + .await + .expect("start verified HTTPS fixture"); + // Even a failed assertion/panic must first close and join fixture work. + let result = AssertUnwindSafe(tokio::time::timeout( + Duration::from_secs(100), + exercise(&case, &mut fixture), + )) + .catch_unwind() + .await; + let closed = fixture.close().await; + closed.expect("fixture closure and synthetic store preservation"); + match result { + Ok(Ok(result)) => result.expect("synthetic contract"), + Ok(Err(_)) => panic!("synthetic case deadline"), + Err(panic) => std::panic::resume_unwind(panic), + } + }); +} + +// axum's public Listener API avoids either a new Hyper dependency or a +// production-only test transport hook. Handshakes are verified by the client. +struct HttpsListener { + tcp: TcpListener, + tls: TlsAcceptor, +} +impl axum::serve::Listener for HttpsListener { + type Io = TlsStream; + type Addr = SocketAddr; + + async fn accept(&mut self) -> (Self::Io, Self::Addr) { + loop { + match self.tcp.accept().await { + Ok((stream, address)) => { + if let Ok(Ok(stream)) = + tokio::time::timeout(Duration::from_secs(3), self.tls.accept(stream)).await + { + return (stream, address); + } + } + Err(_) => tokio::time::sleep(Duration::from_millis(10)).await, + } + } + } + + fn local_addr(&self) -> std::io::Result { + self.tcp.local_addr() + } +} + +#[derive(Default)] +struct Script { + seen: Vec, + replies: VecDeque, + failures: Vec<&'static str>, +} +type Shared = Arc>; + +async fn generation(State(state): State, request: Request) -> Response { + let valid_headers = request.method() == "POST" + && request.uri().path() == "/v1internal:streamGenerateContent" + && request.uri().query() == Some("alt=sse") + && request + .headers() + .get("authorization") + .is_some_and(|v| v.as_bytes() == format!("Bearer {TOKEN}").as_bytes()) + && request + .headers() + .get("accept") + .is_some_and(|v| v == "text/event-stream"); + if !valid_headers { + state + .lock() + .unwrap() + .failures + .push("unexpected generation headers or URL"); + return StatusCode::BAD_REQUEST.into_response(); + } + let raw = match to_bytes(request.into_body(), BODY_LIMIT).await { + Ok(raw) => raw, + Err(_) => return StatusCode::PAYLOAD_TOO_LARGE.into_response(), + }; + let wire: Value = match serde_json::from_slice(&raw) { + Ok(wire) => wire, + Err(_) => return StatusCode::BAD_REQUEST.into_response(), + }; + let mut script = state.lock().unwrap(); + if script.seen.len() >= 256 { + script.failures.push("generation count bound"); + return StatusCode::TOO_MANY_REQUESTS.into_response(); + } + // Keep the complete request. Do not delete unknown fields or normalize it. + script.seen.push(wire); + let Some(parts) = script.replies.pop_front() else { + script.failures.push("unscripted generation"); + return StatusCode::BAD_REQUEST.into_response(); + }; + let frame = json!({"response":{ + "candidates":[{"content":{"role":"model","parts":parts},"finishReason":"STOP"}], + "usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":2,"totalTokenCount":5} + }}); + ( + [("content-type", "text/event-stream")], + format!("data: {frame}\n\ndata: [DONE]\n\n"), + ) + .into_response() +} + +struct Fixture { + provider: AntigravityProvider, + state: Shared, + origin: String, + stop: Option>, + server: JoinHandle>, + auth_path: PathBuf, + auth_bytes: Vec, +} +struct Exchange { + status: StatusCode, + stream: bool, + content_type: String, + bytes: Vec, + wire: Option, +} +impl Fixture { + async fn start() -> Result { + let config_dir = + PathBuf::from(std::env::var_os("CCP_CONFIG_DIR").context("isolated config")?); + let auth_dir = config_dir.join("antigravity"); + std::fs::create_dir(&auth_dir)?; + let auth_path = auth_dir.join("auth.json"); + let expires = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs() + + 7 * 24 * 60 * 60; + let auth_bytes = serde_json::to_vec(&json!({ + "profile_id":PROFILE,"project_id":PROJECT,"access_token":TOKEN, + "refresh_token":null,"expires_at":expires + }))?; + std::fs::write(&auth_path, &auth_bytes)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&auth_dir, std::fs::Permissions::from_mode(0o700))?; + std::fs::set_permissions(&auth_path, std::fs::Permissions::from_mode(0o600))?; + } + let tcp = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).await?; + let origin = format!("https://{}", tcp.local_addr()?); + std::fs::write( + config_dir.join("config.json"), + serde_json::to_vec(&json!({"antigravity":{ + "enabled":true,"baseUrl":origin,"projectId":PROJECT + }}))?, + )?; + let tls = rustls::ServerConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions()? + .with_no_client_auth() + .with_single_cert( + vec![CertificateDer::from(CERT.to_vec())], + PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(KEY.to_vec())), + )?; + let listener = HttpsListener { + tcp, + tls: TlsAcceptor::from(Arc::new(tls)), + }; + let state: Shared = Arc::default(); + let app = Router::new() + .route("/control", get(|| async { "synthetic verified TLS" })) + .route("/v1internal:streamGenerateContent", post(generation)) + .fallback({ + let state = state.clone(); + move || { + let state = state.clone(); + async move { + state.lock().unwrap().failures.push("unexpected endpoint"); + StatusCode::NOT_FOUND + } + } + }) + .with_state(state.clone()); + let (stop, stopped) = oneshot::channel(); + let server = tokio::spawn(async move { + axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = stopped.await; + }) + .await + }); + Ok(Self { + provider: AntigravityProvider::new(), + state, + origin, + stop: Some(stop), + server, + auth_path, + auth_bytes, + }) + } + + async fn close(mut self) -> Result<()> { + let maintenance = + tokio::time::timeout(STEP_LIMIT, self.provider.finish_maintenance()).await; + if let Some(stop) = self.stop.take() { + let _ = stop.send(()); + } + let joined = tokio::time::timeout(Duration::from_secs(3), &mut self.server).await; + match joined { + Ok(result) => result??, + Err(_) => { + self.server.abort(); + let _ = self.server.await; + bail!("HTTPS server required cancellation after graceful deadline"); + } + } + maintenance.context("maintenance deadline")??; + ensure!( + self.state.lock().unwrap().failures.is_empty(), + "unexpected fixture endpoint or request" + ); + ensure!( + std::fs::read(&self.auth_path)? == self.auth_bytes, + "synthetic auth store changed" + ); + Ok(()) + } + + async fn exchange(&mut self, body: Value, parts: Value) -> Result { + let before = { + let mut script = self.state.lock().unwrap(); + ensure!(script.replies.is_empty(), "unconsumed fixture reply"); + script.replies.push_back(parts); + script.seen.len() + }; + let body: MessagesRequest = serde_json::from_value(body)?; + let response = + tokio::time::timeout(STEP_LIMIT, self.provider.handle_messages(body, context())) + .await + .context("provider response deadline")?; + let status = response.status(); + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_owned(); + let bytes = tokio::time::timeout(STEP_LIMIT, to_bytes(response.into_body(), BODY_LIMIT)) + .await + .context("downstream body deadline")?? + .to_vec(); + let mut script = self.state.lock().unwrap(); + ensure!( + script.seen.len() <= before + 1, + "unexpected generation retry" + ); + let wire = script.seen.get(before).cloned(); + if wire.is_none() { + script.replies.pop_front(); + } + ensure!(script.replies.is_empty(), "reply ownership"); + let stream = content_type.starts_with("text/event-stream"); + Ok(Exchange { + status, + stream, + content_type, + bytes, + wire, + }) + } + + async fn project( + &mut self, + stream: bool, + tools: Value, + choice: Option, + ) -> Result { + let mut body = request(stream); + body["tools"] = tools; + if let Some(choice) = choice { + body["tool_choice"] = choice; + } + let exchange = self + .exchange(body, json!([{"text":"synthetic complete"}])) + .await?; + exchange.success(stream)?; + Ok(exchange) + } +} + +fn context() -> RequestContext { + RequestContext { + req_id: "synthetic-schema-request".into(), + session_id: None, + session_seq: None, + provider: "antigravity".into(), + traffic: None, + monitor: None, + } +} +fn request(stream: bool) -> Value { + json!({"model":MODEL,"max_tokens":137,"stream":stream, + "messages":[{"role":"user","content":"Synthetic schema request."}]}) +} +fn tool(name: &str, schema: Value) -> Value { + json!({"name":name,"description":"Synthetic function.","input_schema":schema}) +} +fn root(node: Value) -> Value { + json!({"type":"object","properties":{"value":node},"required":["value"],"additionalProperties":false}) +} +fn selected_root(node: Value) -> Value { + json!({"type":"OBJECT","properties":{"value":node},"required":["value"]}) +} +fn legacy_hint(constraints: Value) -> String { + format!( + "\nOriginal schema constraints are validated locally after JSON decoding; invalid calls are rejected, never repaired. Constraints: {constraints}" + ) +} +fn legacy_root(node: Value) -> Value { + let mut result = selected_root(node); + result["description"] = json!(legacy_hint(json!({"additionalProperties":false}))); + result +} +fn json_transport(schema: &Value) -> Value { + json!({"type":"STRING","description":format!( + "JSON-string transport: encode the original JSON value exactly as a JSON string. The proxy decodes and validates this original schema before returning a tool call. Original JSON Schema: {schema}" + )}) +} + +// Preserved legacy/original values use serde_json's exact integer-aware equality. +// In particular, never round an unsafe integer through f64 in these assertions. +fn exact(actual: &Value, expected: &Value, label: &str) -> Result<()> { + ensure!( + actual == expected, + "{label}: complete structural value differs" + ); + Ok(()) +} + +// Only the selected projection's NumberValue domains permit integer/float +// spelling differences. Unsafe integer variants are not in that selected domain. +fn number_value_equivalent(actual: &Value, expected: &Value) -> bool { + fn number(value: &serde_json::Number) -> Option { + const SAFE: i64 = 9_007_199_254_740_991; + if let Some(value) = value.as_i64() { + return if (-SAFE..=SAFE).contains(&value) { + Some(value as f64) + } else { + None + }; + } + if let Some(value) = value.as_u64() { + return if value <= SAFE as u64 { + Some(value as f64) + } else { + None + }; + } + value.as_f64().filter(|value| value.is_finite()) + } + match (actual, expected) { + (Value::Number(a), Value::Number(b)) => { + matches!((number(a), number(b)), (Some(a), Some(b)) if a == b) + } + (Value::Array(a), Value::Array(b)) => { + a.len() == b.len() && a.iter().zip(b).all(|(a, b)| number_value_equivalent(a, b)) + } + (Value::Object(a), Value::Object(b)) => { + a.len() == b.len() + && a.iter() + .all(|(key, a)| b.get(key).is_some_and(|b| number_value_equivalent(a, b))) + } + _ => actual == expected, + } +} +fn selected_equivalent(actual: &Value, expected: &Value) -> bool { + let (Some(actual), Some(expected)) = (actual.as_object(), expected.as_object()) else { + return false; + }; + actual.len() == expected.len() + && actual.iter().all(|(key, actual)| { + expected + .get(key) + .is_some_and(|expected| match key.as_str() { + "default" | "minimum" | "maximum" => number_value_equivalent(actual, expected), + "items" => selected_equivalent(actual, expected), + "properties" => { + let (Some(actual), Some(expected)) = + (actual.as_object(), expected.as_object()) + else { + return false; + }; + actual.len() == expected.len() + && actual.iter().all(|(name, actual)| { + expected + .get(name) + .is_some_and(|expected| selected_equivalent(actual, expected)) + }) + } + // Includes enum/required and quoted int64 limits: no numeric coercion. + _ => actual == expected, + }) + }) +} +fn selected_exact(actual: &Value, expected: &Value, label: &str) -> Result<()> { + ensure!( + selected_equivalent(actual, expected), + "{label}: selected structural value differs" + ); + Ok(()) +} +fn comparison_oracle_control() -> Result<()> { + for (left, right) in [ + ( + json!(9_007_199_254_740_992_u64), + json!(9_007_199_254_740_993_u64), + ), + (json!(u64::MAX), json!(u64::MAX - 1)), + (json!(i64::MIN), json!(i64::MIN + 1)), + ( + json!(9_007_199_254_740_992_u64), + json!(9_007_199_254_740_992.0), + ), + ] { + let left = legacy_root(json!({"type":"NUMBER","maximum":left})); + let right = legacy_root(json!({"type":"NUMBER","maximum":right})); + ensure!( + exact(&left, &right, "oracle unequal legacy integers").is_err(), + "legacy oracle hid integer corruption" + ); + ensure!( + exact(&right, &left, "oracle unequal legacy integers reversed").is_err(), + "legacy oracle comparison is asymmetric" + ); + exact(&left, &left, "oracle unchanged legacy value")?; + } + selected_exact( + &selected_root(json!({"type":"NUMBER","minimum":1.0,"default":{"nested":[0.0,-0.0,1e20]}})), + &selected_root(json!({"type":"NUMBER","minimum":1,"default":{"nested":[0,0,1e20]}})), + "selected NumberValue spelling equivalence", + )?; + ensure!( + !selected_equivalent( + &selected_root(json!({"type":"STRING","minLength":1})), + &selected_root(json!({"type":"STRING","minLength":"1"})), + ), + "selected oracle coerced quoted int64 domain" + ); + ensure!( + !selected_equivalent( + &selected_root(json!({"type":"NUMBER","enum":[1]})), + &selected_root(json!({"type":"NUMBER","enum":[1.0]})), + ), + "NumberValue semantics escaped into enum domain" + ); + ensure!( + !number_value_equivalent(&json!(u64::MAX), &json!(u64::MAX - 1)), + "unsafe integers entered selected NumberValue comparison" + ); + Ok(()) +} +impl Exchange { + fn wire(&self) -> Result<&Value> { + self.wire + .as_ref() + .context("expected one production HTTPS generation") + } + fn parameters(&self, index: usize) -> Result<&Value> { + self.wire()? + .pointer(&format!( + "/request/tools/{index}/functionDeclarations/0/parameters" + )) + .context("present function parameters") + } + fn success(&self, stream: bool) -> Result<()> { + ensure!( + self.status == StatusCode::OK, + "expected successful downstream HTTP status" + ); + ensure!(self.stream == stream, "downstream mode changed"); + let blocks = self.blocks()?; + ensure!( + blocks.as_array().is_some_and(|b| !b.is_empty()), + "missing output blocks" + ); + Ok(()) + } + fn blocks(&self) -> Result { + if !self.stream { + return Ok(serde_json::from_slice::(&self.bytes)?["content"].clone()); + } + let mut blocks: Vec = Vec::new(); + let mut stopped = false; + for frame in std::str::from_utf8(&self.bytes)? + .split("\n\n") + .filter(|s| !s.is_empty()) + { + let data = frame + .lines() + .find_map(|line| line.strip_prefix("data: ")) + .context("SSE data")?; + let event: Value = serde_json::from_str(data)?; + match event["type"].as_str() { + Some("content_block_start") => { + ensure!( + event["index"].as_u64() == Some(blocks.len() as u64), + "SSE block order" + ); + blocks.push(event["content_block"].clone()); + } + Some("content_block_delta") => { + let index = event["index"].as_u64().context("SSE index")? as usize; + let block = blocks.get_mut(index).context("SSE delta block")?; + let delta = &event["delta"]; + match delta["type"].as_str() { + Some("thinking_delta") => append(block, "thinking", &delta["thinking"])?, + Some("signature_delta") => append(block, "signature", &delta["signature"])?, + Some("text_delta") => append(block, "text", &delta["text"])?, + Some("input_json_delta") => { + block["input"] = serde_json::from_str( + delta["partial_json"].as_str().context("SSE input JSON")?, + )?; + } + _ => bail!("unexpected SSE delta"), + } + } + Some("message_start" | "content_block_stop" | "message_delta") => {} + Some("message_stop") => stopped = true, + _ => bail!("unexpected downstream SSE event or error"), + } + } + ensure!(stopped, "missing SSE terminal event"); + Ok(json!(blocks)) + } + fn rejected_arguments(&self, stream: bool) -> Result<()> { + ensure!( + self.wire.is_some(), + "argument validation must occur after actual HTTPS generation" + ); + if stream { + ensure!( + self.status == StatusCode::OK && self.stream, + "streaming validation response mode" + ); + let text = std::str::from_utf8(&self.bytes)?; + ensure!( + text.contains("event: error\n") + && text.contains("upstream tool arguments violate original schema"), + "missing streaming original-schema validation error" + ); + ensure!( + !text.contains("event: message_stop\n"), + "invalid generated call was emitted" + ); + } else { + ensure!( + self.status == StatusCode::BAD_GATEWAY, + "invalid generated call must fail" + ); + let error: Value = serde_json::from_slice(&self.bytes)?; + ensure!( + error["error"]["message"] + .as_str() + .is_some_and(|s| s.contains("upstream tool arguments violate original schema")), + "wrong local validation error" + ); + } + Ok(()) + } +} +fn append(block: &mut Value, key: &str, value: &Value) -> Result<()> { + let suffix = value.as_str().context("SSE string delta")?; + let current = block[key].as_str().context("SSE initial string")?; + block[key] = json!(format!("{current}{suffix}")); + Ok(()) +} + +async fn exercise(case: &str, f: &mut Fixture) -> Result<()> { + match case { + "tls" => tls_control(f).await, + "untrusted" => untrusted_control(f).await, + "validation" => validation_control(f).await, + "continuation" => continuation_control(f).await, + "limits" => quoted_limits(f).await, + "defaults" => defaults(f).await, + "zeros" => zero_constraints(f).await, + "closed-empty" => closed_empty(f).await, + "auto" => auto_choice(f).await, + "choice-controls" => choice_controls(f).await, + "fallback" => fallback(f).await, + "eligibility" => eligibility(f).await, + "numeric-budgets" => numeric_budgets(f).await, + "admission" => admission(f).await, + "project-admission" => project_admission(f).await, + _ => bail!("unknown fixed fixture case"), + } +} + +async fn tls_control(f: &mut Fixture) -> Result<()> { + comparison_oracle_control()?; + let response = reqwest::Client::builder() + .build()? + .get(format!("{}/control", f.origin)) + .timeout(STEP_LIMIT) + .send() + .await?; + ensure!( + response.status() == StatusCode::OK, + "plain verified HTTPS control" + ); + ensure!( + response.text().await? == "synthetic verified TLS", + "TLS control response" + ); + for stream in [false, true] { + for (model, level) in [ + ("antigravity/gemini-4-argon-low", "LOW"), + ("antigravity/gemini-4-argon-medium", "MEDIUM"), + (MODEL, "HIGH"), + ] { + let mut body = request(stream); + body["model"] = json!(model); + let exchange = f + .exchange(body, json!([{"text":"synthetic complete"}])) + .await?; + exchange.success(stream)?; + let wire = exchange.wire()?; + let session = wire["request"]["sessionId"] + .as_str() + .context("session ID")?; + ensure!( + session + .parse::() + .is_ok_and(|n| n > 0 && n.to_string() == session), + "baseline session format" + ); + ensure!( + wire["requestId"] + .as_str() + .is_some_and(|s| s.starts_with("ccp-") && s.len() == 40), + "request identity format" + ); + exact( + wire, + &json!({ + "project":PROJECT,"model":model.strip_prefix("antigravity/").unwrap(), + "requestId":wire["requestId"],"userAgent":"antigravity","requestType":"agent", + "request":{"sessionId":session,"contents":[{"role":"user","parts":[{"text":"Synthetic schema request."}]}], + "generationConfig":{"maxOutputTokens":137,"thinkingConfig":{"includeThoughts":true,"thinkingLevel":level}}} + }), + "baseline complete wire", + )?; + } + } + Ok(()) +} +async fn untrusted_control(f: &mut Fixture) -> Result<()> { + let plain = reqwest::Client::builder() + .build()? + .get(format!("{}/control", f.origin)) + .timeout(STEP_LIMIT) + .send() + .await; + ensure!( + plain.is_err(), + "synthetic root must be necessary, never bypass TLS verification" + ); + for stream in [false, true] { + let exchange = f + .exchange(request(stream), json!([{"text":"unreachable"}])) + .await?; + ensure!( + exchange.status == StatusCode::BAD_GATEWAY && exchange.wire.is_none(), + "production client accepted untrusted TLS" + ); + ensure!( + !exchange.stream && exchange.content_type.starts_with("application/json"), + "TLS header error mode" + ); + } + Ok(()) +} +async fn validation_control(f: &mut Fixture) -> Result<()> { + for stream in [false, true] { + let mut body = request(stream); + body["tools"] = json!([tool( + "generic_checked", + root(json!({"type":"integer","minimum":2})) + )]); + let valid = f.exchange(body.clone(), json!([{"functionCall":{"name":"generic_checked","args":{"value":2},"id":"synthetic-native-id"}}])).await?; + valid.success(stream)?; + ensure!( + valid + .blocks()? + .as_array() + .unwrap() + .iter() + .any(|b| b["type"] == "tool_use" && b["input"] == json!({"value":2})), + "valid original-schema tool input" + ); + f.exchange( + body, + json!([{"functionCall":{"name":"generic_checked","args":{"value":1}}}]), + ) + .await? + .rejected_arguments(stream)?; + // Draft 2020-12 admits an empty enum, but it validates no values. + // Preserve that original admission/validation rather than inventing a rejection. + let mut body = request(stream); + body["tools"] = json!([tool( + "generic_enum", + root(json!({"type":"string","enum":[]})) + )]); + f.exchange( + body, + json!([{"functionCall":{"name":"generic_enum","args":{"value":""}}}]), + ) + .await? + .rejected_arguments(stream)?; + // A default is guidance only: missing required arguments are never repaired. + let mut body = request(stream); + body["tools"] = json!([tool( + "generic_default", + root(json!({"type":"integer","default":3})) + )]); + f.exchange( + body, + json!([{"functionCall":{"name":"generic_default","args":{}}}]), + ) + .await? + .rejected_arguments(stream)?; + } + Ok(()) +} + +async fn quoted_limits(f: &mut Fixture) -> Result<()> { + for stream in [false, true] { + let schema = root(json!({"type":"array","minItems":1,"maxItems":4, + "items":{"type":"string","minLength":2,"maxLength":9}})); + let exchange = f + .project(stream, json!([tool("generic_limits", schema)]), None) + .await?; + exact( + &exchange.parameters(0)?["properties"]["value"]["minItems"], + &json!("1"), + "minItems must be a quoted int64", + )?; + exact( + exchange.parameters(0)?, + &selected_root(json!({"type":"ARRAY","minItems":"1","maxItems":"4", + "items":{"type":"STRING","minLength":"2","maxLength":"9"}})), + "quoted int64 limits", + )?; + } + Ok(()) +} +async fn defaults(f: &mut Fixture) -> Result<()> { + let values = [ + json!(false), + json!(0), + json!(0.0), + json!(-0.0), + json!(""), + Value::Null, + json!({}), + json!([]), + json!({"items":{"allOf":[null,false,0,""]},"type":"ordinary data","default":{"required":[]}}), + ]; + for stream in [false, true] { + for value in &values { + let schema = root(json!({"type":"string","default":value})); + let exchange = f + .project(stream, json!([tool("generic_defaults", schema)]), None) + .await?; + ensure!( + exchange.parameters(0)?["properties"]["value"] + .get("default") + .is_some(), + "explicit default must retain presence" + ); + selected_exact( + exchange.parameters(0)?, + &selected_root(json!({"type":"STRING","default":value})), + "explicit default presence", + )?; + } + let exchange = f + .project( + stream, + json!([tool("generic_defaults", root(json!({"type":"string"})))]), + None, + ) + .await?; + exact( + exchange.parameters(0)?, + &selected_root(json!({"type":"STRING"})), + "absent default stays absent", + )?; + } + Ok(()) +} +async fn zero_constraints(f: &mut Fixture) -> Result<()> { + for stream in [false, true] { + let schema = json!({"type":"object","properties":{ + "text":{"type":"string","minLength":0,"maxLength":0}, + "list":{"type":"array","items":{"type":"boolean"},"minItems":0,"maxItems":0}, + "number":{"type":"number","minimum":-0.0,"maximum":0.0,"default":0} + },"required":["text","list","number"],"additionalProperties":false}); + let mut body = request(stream); + body["tools"] = json!([tool("generic_zero", schema)]); + let valid_args = json!({"text":"","list":[],"number":0}); + let valid = f + .exchange( + body.clone(), + json!([{"functionCall":{"name":"generic_zero","args":valid_args}}]), + ) + .await?; + valid.success(stream)?; + ensure!( + valid.parameters(0)?["properties"]["text"] + .get("maxLength") + .is_none(), + "zero maxLength must omit on wire" + ); + selected_exact( + valid.parameters(0)?, + &json!({"type":"OBJECT","properties":{ + "text":{"type":"STRING"},"list":{"type":"ARRAY","items":{"type":"BOOLEAN"}}, + "number":{"type":"NUMBER","default":0} + },"required":["text","list","number"]}), + "zero constraints omit but default remains", + )?; + for invalid in [ + json!({"text":"x","list":[],"number":0}), + json!({"text":"","list":[true],"number":0}), + json!({"text":"","list":[],"number":1}), + json!({"text":"","list":[],"number":-1}), + ] { + f.exchange( + body.clone(), + json!([{"functionCall":{"name":"generic_zero","args":invalid}}]), + ) + .await? + .rejected_arguments(stream)?; + } + } + Ok(()) +} +async fn closed_empty(f: &mut Fixture) -> Result<()> { + for stream in [false, true] { + let schema = + json!({"type":"object","properties":{},"required":[],"additionalProperties":false}); + let mut body = request(stream); + body["tools"] = json!([tool("generic_empty", schema)]); + let exchange = f + .exchange( + body.clone(), + json!([{"functionCall":{"name":"generic_empty"}}]), + ) + .await?; + exchange.success(stream)?; + exact( + exchange.parameters(0)?, + &json!({"type":"OBJECT"}), + "closed-empty parameters must be present", + )?; + ensure!( + exchange + .blocks()? + .as_array() + .unwrap() + .iter() + .any(|b| b["type"] == "tool_use" && b["input"] == json!({})), + "omitted args must remain empty" + ); + let envelope = envelope(&exchange.blocks()?)?; + ensure!( + envelope["contents"][0]["parts"][0]["functionCall"] + .get("args") + .is_none(), + "saved omitted args were repaired" + ); + exact( + &envelope["tool_mapping"][0]["codec_paths"], + &json!([]), + "closed-empty codec paths", + )?; + ensure!( + envelope["tool_mapping"][0]["root_wrapper"].is_null(), + "closed-empty root recipe changed" + ); + f.exchange( + body, + json!([{"functionCall":{"name":"generic_empty","args":{"extra":true}}}]), + ) + .await? + .rejected_arguments(stream)?; + } + Ok(()) +} +async fn auto_choice(f: &mut Fixture) -> Result<()> { + for stream in [false, true] { + let eligible = tool("generic_auto", root(json!({"type":"string"}))); + let legacy = tool("generic_legacy", root(json!({"type":"string","const":"x"}))); + let empty = tool( + "generic_empty", + json!({"type":"object","additionalProperties":false}), + ); + for tools in [ + json!([eligible.clone()]), + json!([legacy.clone()]), + json!([eligible, legacy]), + json!([empty]), + ] { + let exchange = f + .project(stream, tools, Some(json!({"type":"auto"}))) + .await?; + exact( + &exchange.wire()?["request"]["toolConfig"], + &json!({"functionCallingConfig":{"mode":"VALIDATED"}}), + "explicit auto mode", + )?; + } + } + Ok(()) +} +async fn choice_controls(f: &mut Fixture) -> Result<()> { + for stream in [false, true] { + for tools in [None, Some(json!([]))] { + for choice in [ + None, + Some(json!({"type":"auto"})), + Some(json!({"type":"none"})), + ] { + let mut body = request(stream); + if let Some(tools) = &tools { + body["tools"] = tools.clone(); + } + if let Some(choice) = choice { + body["tool_choice"] = choice; + } + let exchange = f + .exchange(body, json!([{"text":"synthetic complete"}])) + .await?; + exchange.success(stream)?; + let wire = &exchange.wire()?["request"]; + ensure!( + wire.get("tools").is_none() && wire.get("toolConfig").is_none(), + "empty tool pool must omit declarations and config" + ); + } + } + let tools = json!([tool("generic_choice", root(json!({"type":"string"})))]); + for choice in [None, Some(json!({"type":"none"}))] { + let exchange = f.project(stream, tools.clone(), choice.clone()).await?; + let wire = &exchange.wire()?["request"]; + ensure!( + wire.get("toolConfig").is_none(), + "absent/none tool config changed" + ); + ensure!( + wire.get("tools").is_some() == choice.is_none(), + "none tool declarations changed" + ); + } + for choice in [ + json!({"type":"any"}), + json!({"type":"required"}), + json!({"type":"tool","name":"generic_choice"}), + json!({"type":"auto","disable_parallel_tool_use":true}), + ] { + let mut body = request(stream); + body["tools"] = tools.clone(); + body["tool_choice"] = choice; + let exchange = f.exchange(body, json!([{"text":"unreachable"}])).await?; + ensure!( + exchange.status == StatusCode::BAD_REQUEST && exchange.wire.is_none(), + "unsupported choice admission changed" + ); + } + } + Ok(()) +} + +async fn fallback(f: &mut Fixture) -> Result<()> { + let constrained = |kind: &str, key: &str, value: Value| { + let mut source = json!({"type":kind}); + source[key] = value.clone(); + let mut expected = json!({"type":kind.to_ascii_uppercase()}); + expected["description"] = json!(legacy_hint(json!({(key):value}))); + (source, expected) + }; + let mut cases = vec![ + constrained("string", "const", json!("x")), + constrained("number", "multipleOf", json!(2)), + constrained("number", "exclusiveMinimum", json!(1.5)), + constrained("integer", "format", json!("date")), + constrained("string", "enum", json!(["x", 7, ""])), + constrained("number", "enum", json!([1, 2])), + ( + json!({"type":"string","minimum":1}), + json!({"type":"STRING","minimum":1}), + ), + ( + json!({"type":"array","items":{"type":"string"},"uniqueItems":true}), + json!({"type":"ARRAY","items":{"type":"STRING"},"description":legacy_hint(json!({"uniqueItems":true}))}), + ), + ( + json!({"type":"object","properties":{"x":{"type":"string"}},"minProperties":1}), + json!({"type":"OBJECT","properties":{"x":{"type":"STRING"}},"description":legacy_hint(json!({"minProperties":1}))}), + ), + ( + json!({"type":"array","items":{"type":"string"},"properties":{"x":{"type":"string"}}}), + json!({"type":"ARRAY","items":{"type":"STRING"}}), + ), + ( + json!({"type":"string","default":9007199254740992_u64}), + json!({"type":"STRING"}), + ), + ]; + let union = json!({"oneOf":[{"type":"string"},{"type":"integer"}]}); + cases.push((union.clone(), json_transport(&union))); + let object_extra = json!({"type":"object","properties":{"x":{"type":"string"}},"additionalProperties":{"type":"boolean"}}); + cases.push((object_extra.clone(), json_transport(&object_extra))); + let boolean = json!(true); + cases.push((boolean.clone(), json_transport(&boolean))); + for stream in [false, true] { + for (source, expected) in &cases { + let mut schema = root(source.clone()); + schema["title"] = json!("Synthetic legacy marker"); + let mut expected = legacy_root(expected.clone()); + expected["title"] = json!("Synthetic legacy marker"); + let exchange = f + .project(stream, json!([tool("generic_fallback", schema)]), None) + .await?; + exact( + exchange.parameters(0)?, + &expected, + "whole-tool fallback, including unsupported child", + )?; + } + // Aliased names must keep their full legacy declaration and codec plan. + let name = "generic spaced name"; + let schema = root(json!({"type":"string","default":"ignored by legacy"})); + let exchange = f.project(stream, json!([tool(name, schema)]), None).await?; + let native = format!("ccp_{:x}", Sha256::digest(name.as_bytes()))[..64].to_owned(); + exact( + &exchange.wire()?["request"]["tools"][0], + &json!({"functionDeclarations":[{ + "name":native,"description":"Synthetic function.","parameters":legacy_root(json!({"type":"STRING"})) + }]}), + "aliased legacy declaration", + )?; + for schema in [ + json!({"type":"object","additionalProperties":false,"allOf":[{}]}), + json!({"type":"object","additionalProperties":false,"default":9007199254740992_u64}), + ] { + let exchange = f + .project( + stream, + json!([tool("generic_ineligible_empty", schema)]), + None, + ) + .await?; + exact( + &exchange.wire()?["request"]["tools"][0], + &json!({"functionDeclarations":[{"name":"generic_ineligible_empty","description":"Synthetic function."}]}), + "ineligible closed-empty omission", + )?; + } + } + Ok(()) +} + +async fn eligibility(f: &mut Fixture) -> Result<()> { + for stream in [false, true] { + // A schema-looking property name is data, not an unsupported keyword. + let schema = json!({"type":"object","properties":{"allOf":{"type":"string"},"items":{"type":"boolean"}},"additionalProperties":false}); + let exchange = f + .project(stream, json!([tool("generic_keys", schema)]), None) + .await?; + exact( + exchange.parameters(0)?, + &json!({"type":"OBJECT","properties":{"allOf":{"type":"STRING"},"items":{"type":"BOOLEAN"}}}), + "property names are not keywords", + )?; + let mut schema = root( + json!({"type":"string","description":"Synthetic caller description.", + "title":"Synthetic annotation","enum":["","x"],"format":"email","pattern":".*"}), + ); + schema["title"] = json!("Synthetic root annotation"); + schema["$schema"] = json!("https://json-schema.org/draft/2020-12/schema"); + let exchange = f + .project(stream, json!([tool("generic_annotations", schema)]), None) + .await?; + exact( + exchange.parameters(0)?, + &selected_root( + json!({"type":"STRING","description":"Synthetic caller description.", + "enum":["","x"],"format":"email","pattern":".*"}), + ), + "selected annotations and real empty-string enum choice", + )?; + let exchange = f + .project( + stream, + json!([tool( + "generic_empty_scalars", + root(json!({"type":"string","description":"","pattern":""})) + )]), + None, + ) + .await?; + exact( + exchange.parameters(0)?, + &selected_root(json!({"type":"STRING"})), + "empty scalar strings omit", + )?; + let mut body = request(stream); + body["tools"] = json!([tool( + "generic_exclusive", + root(json!({"type":"integer","minimum":-5,"maximum":5, + "exclusiveMinimum":0,"exclusiveMaximum":3})) + )]); + let valid = f + .exchange( + body.clone(), + json!([{"functionCall":{"name":"generic_exclusive","args":{"value":1}}}]), + ) + .await?; + valid.success(stream)?; + selected_exact( + valid.parameters(0)?, + &selected_root(json!({"type":"INTEGER","minimum":-5,"maximum":5})), + "exclusive bounds omitted without conversion", + )?; + for value in [0, 3] { + f.exchange( + body.clone(), + json!([{"functionCall":{"name":"generic_exclusive","args":{"value":value}}}]), + ) + .await? + .rejected_arguments(stream)?; + } + let exchange = f + .project( + stream, + json!([ + tool( + "generic_selected", + root(json!({"type":"string","default":false})) + ), + tool("generic_old", root(json!({"type":"string","const":"x"}))) + ]), + None, + ) + .await?; + exact( + exchange.parameters(0)?, + &selected_root(json!({"type":"STRING","default":false})), + "mixed selected tool", + )?; + exact( + exchange.parameters(1)?, + &legacy_root(json!({"type":"STRING","description":legacy_hint(json!({"const":"x"}))})), + "mixed legacy tool", + )?; + } + Ok(()) +} + +async fn numeric_budgets(f: &mut Fixture) -> Result<()> { + for stream in [false, true] { + for value in [ + json!(9007199254740991_i64), + json!(-9007199254740991_i64), + json!(1e20), + json!(1.0), + json!(-0.0), + ] { + let source = root(json!({"type":"number","default":value,"minimum":value})); + let mut expected = json!({"type":"NUMBER","default":value}); + if value.as_f64().unwrap() != 0.0 { + expected["minimum"] = value; + } + let exchange = f + .project(stream, json!([tool("generic_number", source)]), None) + .await?; + selected_exact( + exchange.parameters(0)?, + &selected_root(expected), + "safe integer or finite float variant", + )?; + } + for value in [ + json!(9007199254740992_u64), + json!(-9007199254740992_i64), + json!(i64::MIN), + json!(i64::MAX), + json!(u64::MAX), + ] { + let source = root(json!({"type":"number","default":value})); + let exchange = f + .project(stream, json!([tool("generic_unsafe", source)]), None) + .await?; + exact( + exchange.parameters(0)?, + &legacy_root(json!({"type":"NUMBER"})), + "unsafe integer default whole-tool fallback", + )?; + let source = root(json!({"type":"number","maximum":value})); + let exchange = f + .project(stream, json!([tool("generic_unsafe", source)]), None) + .await?; + exact( + exchange.parameters(0)?, + &legacy_root(json!({"type":"NUMBER","maximum":value})), + "unsafe integer bound whole-tool fallback", + )?; + } + for (value, expected) in [ + (json!(1.0), json!({"type":"STRING","minLength":"1"})), + (json!(-0.0), json!({"type":"STRING"})), + ( + json!(9007199254740991_u64), + json!({"type":"STRING","minLength":"9007199254740991"}), + ), + ] { + let exchange = f + .project( + stream, + json!([tool( + "generic_length", + root(json!({"type":"string","minLength":value})) + )]), + None, + ) + .await?; + exact( + exchange.parameters(0)?, + &selected_root(expected), + "integral length numeric variant", + )?; + } + for value in [json!(9007199254740992_u64), json!(u64::MAX), json!(1e20)] { + let exchange = f + .project( + stream, + json!([tool( + "generic_length", + root(json!({"type":"string","minLength":value})) + )]), + None, + ) + .await?; + exact( + exchange.parameters(0)?, + &legacy_root(json!({"type":"STRING","minLength":value})), + "unsafe length falls back", + )?; + } + let mut deep = json!(null); + for _ in 0..34 { + deep = json!([deep]); + } + for value in [deep, json!(vec![Value::Null; 4100])] { + let exchange = f + .project( + stream, + json!([tool( + "generic_budget", + root(json!({"type":"string","default":value})) + )]), + None, + ) + .await?; + exact( + exchange.parameters(0)?, + &legacy_root(json!({"type":"STRING"})), + "default projection depth/node budget fallback", + )?; + } + // Each default is below the node limit; the whole tool is not. + let schema = json!({"type":"object","properties":{ + "a":{"type":"string","default":vec![Value::Null;2300]}, + "b":{"type":"string","default":vec![Value::Null;2300]} + },"additionalProperties":false}); + let exchange = f + .project(stream, json!([tool("generic_shared_budget", schema)]), None) + .await?; + exact( + exchange.parameters(0)?, + &json!({"type":"OBJECT","properties":{"a":{"type":"STRING"},"b":{"type":"STRING"}},"description":legacy_hint(json!({"additionalProperties":false}))}), + "shared whole-tool default budget", + )?; + let value = + json!({"items":vec![Value::Null;32],"allOf":"ordinary data","default":{"type":false}}); + let exchange = f + .project( + stream, + json!([tool( + "generic_small_budget", + root(json!({"type":"string","default":value})) + )]), + None, + ) + .await?; + exact( + exchange.parameters(0)?, + &selected_root(json!({"type":"STRING","default":value})), + "bounded default data remains selected", + )?; + } + Ok(()) +} + +async fn admission(f: &mut Fixture) -> Result<()> { + for stream in [false, true] { + for schema in [ + root(json!({"type":"string","minLength":-1})), + root(json!({"type":"string","minLength":1.5})), + root(json!({"type":"string","format":"unsupported-synthetic-format"})), + root(json!({"$ref":"https://example.invalid/never-retrieve"})), + json!({"type":"object","properties":{},"required":["missing"],"additionalProperties":false}), + root(json!({"type":"string","unknownSyntheticKeyword":true})), + root(json!({"type":"string","enum":7})), + root(json!({"type":"string","default":"x".repeat(256*1024)})), + ] { + let mut body = request(stream); + body["tools"] = json!([tool("generic_invalid", schema)]); + let exchange = f.exchange(body, json!([{"text":"unreachable"}])).await?; + ensure!( + exchange.status == StatusCode::BAD_REQUEST && exchange.wire.is_none(), + "original schema admission weakened or deferred" + ); + } + // none must not bypass original tool admission before clearing the pool. + let mut body = request(stream); + body["tools"] = json!([tool("generic_invalid", root(json!({"unsupported":true})))]); + body["tool_choice"] = json!({"type":"none"}); + let exchange = f.exchange(body, json!([{"text":"unreachable"}])).await?; + ensure!( + exchange.status == StatusCode::BAD_REQUEST && exchange.wire.is_none(), + "original admission-first ordering changed" + ); + } + Ok(()) +} + +async fn project_admission(f: &mut Fixture) -> Result<()> { + // Change only this fixture's synthetic files, never a global environment. + // Restore them before propagating any assertion error to the common closer. + let config_path = f + .auth_path + .parent() + .and_then(|p| p.parent()) + .context("synthetic config parent")? + .join("config.json"); + let config_bytes = std::fs::read(&config_path)?; + let mut credential: Value = serde_json::from_slice(&f.auth_bytes)?; + credential["project_id"] = Value::Null; + std::fs::write(&f.auth_path, serde_json::to_vec(&credential)?)?; + std::fs::write( + &config_path, + serde_json::to_vec(&json!({"antigravity":{ + "enabled":true,"baseUrl":f.origin + }}))?, + )?; + tokio::time::timeout(STEP_LIMIT, f.provider.finish_maintenance()).await??; + f.provider = AntigravityProvider::new(); + let result = async { + for stream in [false,true] { + let mut body = request(stream); + body["tools"] = json!([tool("generic_invalid",root(json!({"unknownSyntheticKeyword":true})))]); + let invalid = f.exchange(body,json!([{"text":"unreachable"}])).await?; + ensure!(invalid.status == StatusCode::BAD_REQUEST && invalid.wire.is_none(),"admission before project resolution"); + let error: Value = serde_json::from_slice(&invalid.bytes)?; + ensure!(error["error"]["message"] == "unsupported schema keyword or reference","original admission error precedence"); + let missing = f.exchange(request(stream),json!([{"text":"unreachable"}])).await?; + ensure!(missing.status == StatusCode::BAD_REQUEST && missing.wire.is_none(),"missing project must not send generation"); + let error: Value = serde_json::from_slice(&missing.bytes)?; + ensure!(error["error"]["message"] == "Antigravity project missing; run auth login or configure your authorized project","legacy missing-project error changed"); + } + Ok::<(),anyhow::Error>(()) + }.await; + std::fs::write(&f.auth_path, &f.auth_bytes)?; + std::fs::write(config_path, config_bytes)?; + result +} + +fn envelope(blocks: &Value) -> Result { + let signature = blocks[0]["signature"] + .as_str() + .context("V1 carrier signature")?; + let encoded = signature + .strip_prefix("ccp:antigravity:v1:") + .context("V1 prefix unchanged")?; + let raw = URL_SAFE_NO_PAD.decode(encoded)?; + ensure!(raw.len() <= BODY_LIMIT, "synthetic V1 envelope bound"); + Ok(serde_json::from_slice(&raw)?) +} +async fn continuation_control(f: &mut Fixture) -> Result<()> { + for stream in [false, true] { + let spaced = "generic alias name"; + let native_alias = format!("ccp_{:x}", Sha256::digest(spaced.as_bytes()))[..64].to_owned(); + let tools = json!([ + tool( + "generic_empty", + json!({"type":"object","additionalProperties":false}) + ), + tool("generic_path", root(json!({"type":["string","null"]}))), + tool( + spaced, + json!({"type":"object","additionalProperties":{"type":"string"}}) + ), + tool( + "generic_selected", + root(json!({"type":"integer","default":3})) + ) + ]); + let parts = json!([ + {"text":"Synthetic thought.","thought":true,"thoughtSignature":"synthetic-opaque-signature","syntheticUnknown":{"keep":true}}, + {"functionCall":{"name":"generic_empty","id":"synthetic-empty-id"}}, + {"functionCall":{"name":"generic_path","args":{"value":"null"}}}, + {"functionCall":{"name":native_alias,"id":"synthetic-alias-id","args":{"json":"{\"x\":\"y\"}"}}}, + {"functionCall":{"name":"generic_selected","args":{"value":3}}} + ]); + let mut body = request(stream); + body["tools"] = tools.clone(); + let first = f.exchange(body.clone(), parts.clone()).await?; + first.success(stream)?; + let blocks = first.blocks()?; + let saved = envelope(&blocks)?; + exact( + &saved["contents"], + &json!([{"role":"model","parts":parts}]), + "V1 unknown native contents preserved", + )?; + ensure!( + saved["version"] == 1 + && saved["profile_id"] == PROFILE + && saved["project_id"] == PROJECT, + "V1 scope/version" + ); + let mapping = saved["tool_mapping"].as_array().context("V1 recipes")?; + ensure!(mapping.len() == 4, "V1 multiple tool mappings"); + for (index, (caller, paths, wrapper)) in [ + ("generic_empty", json!([]), Value::Null), + ("generic_path", json!(["/value"]), Value::Null), + (spaced, json!([]), json!("json")), + ("generic_selected", json!([]), Value::Null), + ] + .into_iter() + .enumerate() + { + ensure!( + mapping[index]["caller_name"] == caller, + "V1 caller-name recipe" + ); + exact(&mapping[index]["codec_paths"], &paths, "V1 path recipe")?; + exact(&mapping[index]["root_wrapper"], &wrapper, "V1 root recipe")?; + } + let mut results = Vec::new(); + for block in blocks + .as_array() + .unwrap() + .iter() + .filter(|b| b["type"] == "tool_use") + { + results.push(json!({"type":"tool_result","tool_use_id":block["id"],"content":"Synthetic result."})); + } + body["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":blocks}), + json!({"role":"user","content":results}), + ]); + // Current declarations can change, but saved codecs and carrier must not. + body["tools"] = json!([tool( + "generic_selected", + root(json!({"type":"integer","default":4})) + )]); + let second = f + .exchange(body, json!([{"text":"Synthetic continuation."}])) + .await?; + second.success(stream)?; + let contents = &second.wire()?["request"]["contents"]; + exact( + &contents[1], + &saved["contents"][0], + "V1 native history exact reuse", + )?; + ensure!( + second.wire()?["request"]["sessionId"] == saved["session_id"], + "V1 session reuse" + ); + ensure!( + contents[2]["parts"] + .as_array() + .is_some_and(|p| p.len() == 4), + "V1 complete result batch" + ); + for (index, call) in saved["contents"][0]["parts"] + .as_array() + .unwrap() + .iter() + .filter_map(|p| p.get("functionCall")) + .enumerate() + { + let response = &contents[2]["parts"][index]["functionResponse"]; + ensure!( + response["name"] == call["name"] && response.get("id") == call.get("id"), + "V1 native result identity" + ); + } + } + Ok(()) +} diff --git a/tests/fixtures/antigravity-schema-parity/ca.pem b/tests/fixtures/antigravity-schema-parity/ca.pem new file mode 100644 index 00000000..26bb96cf --- /dev/null +++ b/tests/fixtures/antigravity-schema-parity/ca.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDLTCCAhWgAwIBAgIUGMPU0RdU88UhVLdHxQjKY1dWRWUwDQYJKoZIhvcNAQEL +BQAwLDEqMCgGA1UEAwwhU3ludGhldGljIFNjaGVtYSBQYXJpdHkgVGVzdCBSb290 +MCAXDTI2MTEwMzE2NTQzMVoYDzIxMjYxMDEwMTY1NDMxWjAsMSowKAYDVQQDDCFT +eW50aGV0aWMgU2NoZW1hIFBhcml0eSBUZXN0IFJvb3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQC+aqpkKDex0fkFsPBZ93xCIzg6o4Jtm+5M/4l7DcY9 +J80DBG6ABTCsfWfh1NSn4njBxd8IdvlBuaofygu9h44bGg3g3OrpjuLBih/ZiF66 +auAU8I4KwD/FQz+uAxcr009FqucPc5YIXLOkqIO1+ud18J0HjiNMFzSPJQEuujPi +YMhm6xrsVl04xdrfvAQOu6BcTVgd6glvvdfHeyRZGh5nyYRifO3QtAzzEYfaev03 +Y8cQvxAplpMvl+JvrY2/LSAKKWwsXJIIVtk/xZr7sF+TilA1RKGkzT7t6lxO1CQw +L+unu96HcP5VTlhN1zklSiLd6KhEMvxDhOzL3hRcJFnZAgMBAAGjRTBDMBIGA1Ud +EwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBRJDiIBiTSd +5JJgu1ufV22asvk2eTANBgkqhkiG9w0BAQsFAAOCAQEAK22eZYWE/ROzriWKqx3q +Ans+gyx8tgFqmJ/o18QBu7Cb8ehvDFh+Qa029wZJWUxtMMiJrEB+pEEv4fqJD7JJ +fA3PnXnANklNtnXsFlQTTatepKrnDPBskdZMkoaLtUPw1WcJKg+w3spMlzZf86Pg +BF3Kj2CtSS1sh3RahT7LJobK2JIjRmuNfQq6LKhvqqKn5hpDTfyjBdCM3G+305sX +hW/pfb+8YFEe6gxGSlKKNACaQYiwPsS8slFeuARKmM082ZQK5zDIaMqub6VT4YZq +ysCWaAuguKcj/CXT81TPo05CXwqGQXNXDh3hYP+k91wNkSsFVPsQbW6ZTdl8Onfy +Cw== +-----END CERTIFICATE----- diff --git a/tests/fixtures/antigravity-schema-parity/server-key.der b/tests/fixtures/antigravity-schema-parity/server-key.der new file mode 100644 index 0000000000000000000000000000000000000000..5fac4cf82a94d008c371c1a15dcc44f1d6e1f75f GIT binary patch literal 1217 zcmV;y1U~yPf&{$+0RS)!1_>&LNQUrr!ay9qXGc{0)hbn0Le61OlUI* z9rOJO_guG%01dkrF7M4cOIV6sSX5ieBusOr5=}dzaXD|%cNE}u%o@aE0~rEa>w@PR z7|BW_p+|A9ELPB8Bz3vyww(NY;YFlXOo0~SjVXnwMiItw!^@~_W( z{NGhj8ZLY&o~1Njk*VT<0PNP~r036iy5Im^Z}!)a^U%Q>MD3C8*-VqOPX-IW z1FG1@GbLRv48m?Xlf9*gG5G5iDPXc&j@LP}10B^f%b?^fG$oUSOON_>tBPrgtWi|f zS2@ct#@7}Bh_oK)Mouy(lpLo0FVMMA`|w?6>yt}^oLEmqFa}=f83F?V009Dm0RTY! z*GN{Mr4NU%LwM3jwizx~FSf~D&}L=A%R{VtxnAHBx}i?6m$-n`Okl zE>d%ak+@IG(w0>%GQ_`yt~F?p6Uk@`Ws04n=BYnO)dV9|ICSY~-*ygR=pkOS)ZTYf zfyPa1iJ|kr>W+$4FbR$F%&U*%$kXu-cCX1e4qe0PJ>Z7}VjZnIPQ>dD4{DJT(DC3Y6XTr!2uBZE?-NkY3of&~vFxTew z3V9FCw3sKgT-&Tlr9&^7V%iX+2x0+{AhPE5O+J61G>X}|*d2M=PZrcHpaBAbfdJ%8 z%@bm=N_qgQXS;6dvF8D4G?9Xcq}H{1(bl3z3m4`0C^jKGF(K`mmQ&0xYt(lVgPBqC zX={5jQ*_o+LH$BO6oBuZkD{)ju0f8JuvYgE{VZIMHv2vdze#zL9W;${gNQeKdP88) z{64Z=NON^^+2p%7mp|up?83A0;n@O#fdJv1?#nM5_uuBsDw`jceXAiJ!h)!iV?pXbS_6Q>EPb)*OGIg`zqGU~|VME{gT%hzm zMM1VVdcfHM@b2XBRFR<~I2ld7c|ih! zfIzGC=n3o@A?R>jL_SVqsR6)HuvyRzFL_(3iR*>gULc$5EClDpwAwtGMCw$~^+ zc|$sVT!J8`eUW_W&Y5Q*EXp)Xk`)jg@!B^Ep$G}mc`utTLkqXYv_z=J6T6Q!flv4Om3IiBMo-SSm zacGYA^PjlHs=*3NX9;Wz6dmB!Ry{Xr#VCD8J!Cxc*vy@PzRPEdo7L=9;m=3B@+ zh1a34;`*m+H6**w?eAXQ706%0HjRZE$(iItoS@|iSnH*@4&03H)%~kcK$LKM?m{F^u9o7}tPdhAL;o$-&E1$z@DR literal 0 HcmV?d00001 diff --git a/tests/fixtures/antigravity-schema-parity/server.der b/tests/fixtures/antigravity-schema-parity/server.der new file mode 100644 index 0000000000000000000000000000000000000000..418d8157c1c120090dd83c5ea1ef18b93c37974e GIT binary patch literal 852 zcmXqLVh%89VzOAk%*4n9L*40H^&3^dr7Ls^)46oV`CN-|PQ zGLsd8lQU9t6BPmyi!w_p6+%*rOB90g^GggA#CeU(3=Iv84b4nVj18kC_>ByK0tP?` z5ik%j6fzKin8%rupPZPJkzZV5(8Q>OY%wD%19KB2KLb#li>Zl`k>R9CginSshwSIS zoZn-%cQNqp7T0@!*2+7gD>fn|`jm=K@iHM_>qUi@`4`JY9+aJtI+DaJ$rSy%>A94| zNiXGv?uF}g!Y;(ClSnjmEsCCh-$jK5AfibM5Jp=jW?-Jz$8<|8`SVsmf!@=L-j=T;5K6chhI` zW`8#B{miRw95Ysp)#EvoXE}NA(hkEPuf;SIHbwVcx7^Gud)4^#f+xBrs*_v1`+t?L z?#k?16Bu$W-14-+vFl=t9b4pHxcM2WPmx*nSO3D!fZrct(_T;ZZk`k2?`psn_fnFH znUR5UapNR|#(o1HVCcyTGcx{XVKQJa-~;jaK|B^_Ce{T8!XUmX3y%R88;3RYWkGU$EMhDob4>U@9toK4yQf2|_8^B*fBVd;9s_xh zv@(l?fmnlxC!Z2yr^(zWlM;4E&kxU?wdto>C33U@(+e=#7#Vh)jZ5CiV3TN)D^g#& z^8Qrs4Rw_ZzPWZDs=0N4*TcRfFN3lV2QPbX|8!Nn#{Ax={Er6&bEibJ9AWW$pS{tu zq3pLmv)a3`2%GGR!)#@fuANvcnX;}sby9cOj1LcsE6-G1iFLS}HN9)ml-xgDWex=` zdHyq>v6rZ{YdjFie05)3^6_W}A^_wLrK>EPc?crO0@~demmz+_**!b-j z=b3YI$F#S)$;ZgfntCSr>Eo1nLRrrRxA2s%d8~g|Aa!BJbkUXRYR+fg&tbm#YNo#S z`n^{9|B{5Hq)$ literal 0 HcmV?d00001 From 790190945e350d538b744053e92ceaa4fbb6a0e2 Mon Sep 17 00:00:00 2001 From: Chris Liu <11517021+chris7iu@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:35:09 +0000 Subject: [PATCH 13/30] test(antigravity): align schema fixture with native transport headers Require absent Accept, exact JSON content type and gzip negotiation in the combined transport/schema fixture. Preserve strict method, URL, authorization and schema assertions. Verified combined upstream suite, formatting and strict Clippy pass. Co-Authored-By: Claude Code --- tests/antigravity_schema_parity.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/antigravity_schema_parity.rs b/tests/antigravity_schema_parity.rs index b7eed1a8..9e03965d 100644 --- a/tests/antigravity_schema_parity.rs +++ b/tests/antigravity_schema_parity.rs @@ -222,10 +222,15 @@ async fn generation(State(state): State, request: Request) -> Response { .headers() .get("authorization") .is_some_and(|v| v.as_bytes() == format!("Bearer {TOKEN}").as_bytes()) + && !request.headers().contains_key("accept") && request .headers() - .get("accept") - .is_some_and(|v| v == "text/event-stream"); + .get("content-type") + .is_some_and(|v| v == "application/json") + && request + .headers() + .get("accept-encoding") + .is_some_and(|v| v == "gzip"); if !valid_headers { state .lock() From e88478b29fd24f1e89a0a600853a8abf8c2718e5 Mon Sep 17 00:00:00 2001 From: ChrisLiu <11517021+chris7iu@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:45:41 +0000 Subject: [PATCH 14/30] test(antigravity): pin observed synthetic tool schema groups Reconstruct compatible synthetic caller schemas from observed typed tool shapes and compare complete outgoing groups through prepare. Keep names and text synthetic and preserve quoted int64 limits. Co-Authored-By: Claude Code --- .../antigravity_observed_schema_tests.rs | 110 ++++ src/providers/mod.rs | 2 + .../antigravity-observed-tool-schemas.json | 479 ++++++++++++++++++ 3 files changed, 591 insertions(+) create mode 100644 src/providers/antigravity_observed_schema_tests.rs create mode 100644 tests/fixtures/antigravity-observed-tool-schemas.json diff --git a/src/providers/antigravity_observed_schema_tests.rs b/src/providers/antigravity_observed_schema_tests.rs new file mode 100644 index 00000000..7a333dc1 --- /dev/null +++ b/src/providers/antigravity_observed_schema_tests.rs @@ -0,0 +1,110 @@ +//! Observed tool-schema shapes with synthetic names and text, not private input replay. +use super::antigravity::{replay::Binding, request::prepare}; +use serde_json::{Value, json}; + +fn synthetic_caller_schema(native: &Value) -> Value { + let source = native.as_object().unwrap(); + assert!(source.keys().all(|key| matches!( + key.as_str(), + "type" + | "description" + | "properties" + | "items" + | "required" + | "enum" + | "default" + | "format" + | "pattern" + | "minimum" + | "maximum" + | "minLength" + | "maxLength" + | "minItems" + | "maxItems" + ))); + let kind = source["type"].as_str().unwrap(); + assert!(matches!( + kind, + "OBJECT" | "ARRAY" | "STRING" | "INTEGER" | "NUMBER" | "BOOLEAN" + )); + let mut caller = source.clone(); + caller.insert("type".into(), json!(kind.to_ascii_lowercase())); + if let Some(properties) = source.get("properties") { + caller.insert( + "properties".into(), + Value::Object( + properties + .as_object() + .unwrap() + .iter() + .map(|(name, schema)| (name.clone(), synthetic_caller_schema(schema))) + .collect(), + ), + ); + } + if let Some(items) = source.get("items") { + caller.insert("items".into(), synthetic_caller_schema(items)); + } + for key in ["minLength", "maxLength", "minItems", "maxItems"] { + if let Some(value) = source.get(key) { + let limit = value.as_str().unwrap().parse::().unwrap(); + assert!(limit <= 9_007_199_254_740_991); + caller.insert(key.into(), json!(limit)); + } + } + if kind == "OBJECT" + && source + .get("properties") + .and_then(Value::as_object) + .is_none_or(|properties| properties.is_empty()) + { + caller.insert("additionalProperties".into(), json!(false)); + } + Value::Object(caller) +} + +#[test] +fn antigravity_observed_tools_match_complete_synthetic_groups() { + let expected: Value = serde_json::from_str(include_str!( + "../../tests/fixtures/antigravity-observed-tool-schemas.json" + )) + .unwrap(); + let groups = expected.as_array().unwrap(); + assert_eq!(groups.len(), 12); + let tools: Vec = groups + .iter() + .map(|group| { + assert_eq!(group.as_object().unwrap().len(), 1); + let declarations = group["functionDeclarations"].as_array().unwrap(); + assert_eq!(declarations.len(), 1); + let mut tool = declarations[0].as_object().unwrap().clone(); + assert!( + tool.keys() + .all(|key| matches!(key.as_str(), "name" | "description" | "parameters")) + ); + let parameters = tool.remove("parameters").unwrap(); + tool.insert("input_schema".into(), synthetic_caller_schema(¶meters)); + Value::Object(tool) + }) + .collect(); + let request = serde_json::from_value(json!({ + "model":"antigravity/gemini-4-argon-low", + "max_tokens":512, + "messages":[{"role":"user","content":"synthetic captured-schema request"}], + "tools":tools + })) + .unwrap(); + let binding = Binding { + profile_id: "synthetic-profile".into(), + project_id: "synthetic-project".into(), + native_model: "gemini-4-argon-low".into(), + }; + let prepared = prepare(&request, &binding).unwrap(); + let actual = &prepared.wire["request"]["tools"]; + assert_eq!( + actual[3]["functionDeclarations"][0]["parameters"]["properties"]["field_0001"]["minLength"], + expected[3]["functionDeclarations"][0]["parameters"]["properties"]["field_0001"]["minLength"], + "observed int64 schema limits require quoted serialization" + ); + assert_eq!(actual, &expected, "complete synthetic tool groups differ"); +} diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 859206ea..12248fb8 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -11,4 +11,6 @@ mod antigravity_auth_tests; #[cfg(test)] mod antigravity_http_tests; #[cfg(test)] +mod antigravity_observed_schema_tests; +#[cfg(test)] mod antigravity_tests; diff --git a/tests/fixtures/antigravity-observed-tool-schemas.json b/tests/fixtures/antigravity-observed-tool-schemas.json new file mode 100644 index 00000000..baee327b --- /dev/null +++ b/tests/fixtures/antigravity-observed-tool-schemas.json @@ -0,0 +1,479 @@ +[ + { + "functionDeclarations": [ + { + "description": "synthetic_text_0001", + "name": "synthetic_tool_0001", + "parameters": { + "properties": { + "field_0001": { + "description": "synthetic_text_0002", + "type": "INTEGER" + }, + "field_0002": { + "description": "synthetic_text_0003", + "type": "INTEGER" + }, + "field_0003": { + "description": "synthetic_text_0004", + "type": "STRING" + }, + "field_0004": { + "description": "synthetic_text_0005", + "type": "INTEGER" + } + }, + "required": [ + "field_0003" + ], + "type": "OBJECT" + } + } + ] + }, + { + "functionDeclarations": [ + { + "description": "synthetic_text_0006", + "name": "synthetic_tool_0002", + "parameters": { + "properties": { + "field_0001": { + "description": "synthetic_text_0007", + "type": "BOOLEAN" + }, + "field_0002": { + "description": "synthetic_text_0008", + "type": "STRING" + }, + "field_0003": { + "description": "synthetic_text_0009", + "type": "BOOLEAN" + }, + "field_0004": { + "description": "synthetic_text_0010", + "type": "INTEGER" + }, + "field_0005": { + "description": "synthetic_text_0011", + "type": "STRING" + } + }, + "required": [ + "field_0002", + "field_0005", + "field_0004" + ], + "type": "OBJECT" + } + } + ] + }, + { + "functionDeclarations": [ + { + "description": "synthetic_text_0012", + "name": "synthetic_tool_0003", + "parameters": { + "properties": { + "field_0001": { + "description": "synthetic_text_0013", + "enum": [ + "synthetic_text_0014", + "synthetic_text_0015", + "synthetic_text_0016", + "synthetic_text_0017" + ], + "type": "STRING" + }, + "field_0002": { + "description": "synthetic_text_0018", + "type": "STRING" + }, + "field_0003": { + "description": "synthetic_text_0019", + "type": "STRING" + } + }, + "required": [ + "field_0001" + ], + "type": "OBJECT" + } + } + ] + }, + { + "functionDeclarations": [ + { + "description": "synthetic_text_0020", + "name": "synthetic_tool_0004", + "parameters": { + "properties": { + "field_0001": { + "description": "synthetic_text_0021", + "minLength": "1", + "type": "STRING" + }, + "field_0002": { + "description": "synthetic_text_0022", + "minLength": "1", + "type": "STRING" + } + }, + "required": [ + "field_0002", + "field_0001" + ], + "type": "OBJECT" + } + } + ] + }, + { + "functionDeclarations": [ + { + "description": "synthetic_text_0023", + "name": "synthetic_tool_0005", + "parameters": { + "properties": { + "field_0001": { + "description": "synthetic_text_0024", + "type": "STRING" + }, + "field_0002": { + "description": "synthetic_text_0025", + "type": "INTEGER" + }, + "field_0003": { + "description": "synthetic_text_0026", + "type": "BOOLEAN" + }, + "field_0004": { + "description": "synthetic_text_0027", + "type": "BOOLEAN" + }, + "field_0005": { + "description": "synthetic_text_0028", + "type": "INTEGER" + }, + "field_0006": { + "description": "synthetic_text_0029", + "type": "STRING" + }, + "field_0007": { + "description": "synthetic_text_0030", + "type": "STRING" + } + }, + "required": [ + "field_0006" + ], + "type": "OBJECT" + } + } + ] + }, + { + "functionDeclarations": [ + { + "description": "synthetic_text_0031", + "name": "synthetic_tool_0006", + "parameters": { + "properties": { + "field_0001": { + "description": "synthetic_text_0032", + "enum": [ + "synthetic_text_0033", + "synthetic_text_0034", + "synthetic_text_0035", + "synthetic_text_0036" + ], + "type": "STRING" + }, + "field_0002": { + "description": "synthetic_text_0037", + "type": "STRING" + }, + "field_0003": { + "description": "synthetic_text_0038", + "type": "STRING" + }, + "field_0004": { + "description": "synthetic_text_0039", + "type": "STRING" + }, + "field_0005": { + "description": "synthetic_text_0040", + "type": "STRING" + } + }, + "required": [ + "field_0004", + "field_0003", + "field_0002" + ], + "type": "OBJECT" + } + } + ] + }, + { + "functionDeclarations": [ + { + "description": "synthetic_text_0041", + "name": "synthetic_tool_0007", + "parameters": { + "properties": { + "field_0001": { + "description": "synthetic_text_0042", + "minLength": "1", + "type": "STRING" + }, + "field_0002": { + "description": "synthetic_text_0043", + "type": "BOOLEAN" + }, + "field_0003": { + "description": "synthetic_text_0044", + "type": "BOOLEAN" + }, + "field_0004": { + "description": "synthetic_text_0045", + "type": "BOOLEAN" + }, + "field_0005": { + "description": "synthetic_text_0046", + "minLength": "1", + "type": "STRING" + }, + "field_0006": { + "description": "synthetic_text_0047", + "minLength": "1", + "type": "STRING" + } + }, + "required": [ + "field_0005", + "field_0001", + "field_0006" + ], + "type": "OBJECT" + } + } + ] + }, + { + "functionDeclarations": [ + { + "description": "synthetic_text_0048", + "name": "synthetic_tool_0008", + "parameters": { + "properties": { + "field_0001": { + "description": "synthetic_text_0049", + "enum": [ + "synthetic_text_0014", + "synthetic_text_0015", + "synthetic_text_0050" + ], + "type": "STRING" + }, + "field_0002": { + "description": "synthetic_text_0051", + "items": { + "type": "STRING" + }, + "type": "ARRAY" + } + }, + "required": [ + "field_0001" + ], + "type": "OBJECT" + } + } + ] + }, + { + "functionDeclarations": [ + { + "description": "synthetic_text_0052", + "name": "synthetic_tool_0009", + "parameters": { + "properties": { + "field_0001": { + "description": "synthetic_text_0053", + "type": "BOOLEAN" + }, + "field_0002": { + "description": "synthetic_text_0054", + "properties": { + "field_0001": { + "description": "synthetic_text_0055", + "type": "BOOLEAN" + }, + "field_0002": { + "description": "synthetic_text_0056", + "type": "STRING" + }, + "field_0003": { + "description": "synthetic_text_0057", + "type": "BOOLEAN" + } + }, + "required": [ + "field_0002", + "field_0003", + "field_0001" + ], + "type": "OBJECT" + }, + "field_0003": { + "description": "synthetic_text_0058", + "type": "STRING" + }, + "field_0004": { + "description": "synthetic_text_0059", + "type": "BOOLEAN" + }, + "field_0005": { + "description": "synthetic_text_0060", + "minLength": "1", + "type": "STRING" + } + }, + "required": [ + "field_0005", + "field_0003" + ], + "type": "OBJECT" + } + } + ] + }, + { + "functionDeclarations": [ + { + "description": "synthetic_text_0061", + "name": "synthetic_tool_0010", + "parameters": { + "properties": { + "field_0001": { + "description": "synthetic_text_0062", + "properties": { + "field_0001": { + "description": "synthetic_text_0055", + "type": "BOOLEAN" + }, + "field_0002": { + "description": "synthetic_text_0056", + "type": "STRING" + }, + "field_0003": { + "description": "synthetic_text_0057", + "type": "BOOLEAN" + } + }, + "required": [ + "field_0002", + "field_0003", + "field_0001" + ], + "type": "OBJECT" + }, + "field_0002": { + "description": "synthetic_text_0063", + "minLength": "1", + "type": "STRING" + }, + "field_0003": { + "description": "synthetic_text_0064", + "type": "STRING" + }, + "field_0004": { + "description": "synthetic_text_0065", + "minLength": "1", + "type": "STRING" + } + }, + "required": [ + "field_0002", + "field_0004", + "field_0003" + ], + "type": "OBJECT" + } + } + ] + }, + { + "functionDeclarations": [ + { + "description": "synthetic_text_0066", + "name": "synthetic_tool_0011", + "parameters": { + "properties": { + "field_0001": { + "description": "synthetic_text_0067", + "type": "STRING" + }, + "field_0002": { + "type": "STRING" + } + }, + "required": [ + "field_0002" + ], + "type": "OBJECT" + } + } + ] + }, + { + "functionDeclarations": [ + { + "description": "synthetic_text_0068", + "name": "synthetic_tool_0012", + "parameters": { + "properties": { + "field_0001": { + "items": { + "properties": { + "field_0001": { + "description": "synthetic_text_0069", + "type": "BOOLEAN" + }, + "field_0002": { + "description": "synthetic_text_0070", + "items": { + "type": "STRING" + }, + "minItems": "2", + "type": "ARRAY" + }, + "field_0003": { + "description": "synthetic_text_0071", + "minLength": "1", + "type": "STRING" + } + }, + "required": [ + "field_0003", + "field_0002" + ], + "type": "OBJECT" + }, + "minItems": "1", + "type": "ARRAY" + } + }, + "required": [ + "field_0001" + ], + "type": "OBJECT" + } + } + ] + } +] From 6c1fe12294ddde2533f6abc65d9d355a8f3df5da Mon Sep 17 00:00:00 2001 From: Chris Liu <11517021+chris7iu@users.noreply.github.com> Date: Thu, 5 Nov 2026 20:47:50 +0000 Subject: [PATCH 15/30] feat(antigravity): add opt-in observed agent request metadata Add a closed default-legacy metadata policy and separately versioned V2 transcript state for observed main-user and signed string-tool continuations. Preserve V1 codec/session semantics, bind logical and user-execution identities separately from the backend session, and allocate dispatch IDs only after actual native binding checks. Use value-only native count inputs and an advisory nonsecret serving snapshot without credential/profile reads, refresh, network dispatch or ID allocation. Retain legacy counting, reject unqualified transition shapes, and document scoped coverage without at-most-once or all-account claims. Co-Authored-By: Claude Code --- README.md | 43 + src/providers/antigravity/config.rs | 64 ++ src/providers/antigravity/mod.rs | 107 ++- src/providers/antigravity/replay.rs | 363 +++++++ src/providers/antigravity/request.rs | 431 ++++++++- src/providers/antigravity_tests.rs | 1334 ++++++++++++++++++++++++++ tests/antigravity_schema_parity.rs | 711 ++++++++++++++ 7 files changed, 3034 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 985b442e..d476906d 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,49 @@ gates; offline tests are not evidence of account access. See the and [configuration](docs/src/content/docs/reference/configuration.md#experimental-antigravity) before explicitly enabling it. Existing provider defaults and aliases are unchanged. +### Native request metadata policy + +The closed file setting `antigravity.requestMetadata` in `config.json` accepts +`"legacy"` (the default) or `"agy"`: + +```json +{"antigravity":{"requestMetadata":"agy"}} +``` + +This does **not** enable the default-off provider, change GPT routing, import +credentials, grant billing consent, or change OAuth or the existing explicit +HTTP User-Agent policy. Existing User-Agent overrides are never silently rewritten. +There is no environment or per-request HTTP escape for this selector. + +- Existing V1 histories stay legacy even with `"agy"`; start a fresh session for + V2. V2 requires `"agy"` and rejects mixed versions or changed model/tier, + profile or project scope. Its signed empty-input FNV **backend session** is + separate from logical conversation/trajectory UUIDs and proxy-owned USER + execution identities. `last_execution_id` names the previously recorded USER + execution, not a fresh model-generation ID; tool loops retain their active + execution and predecessor. The bounded digest checks consistency, **not + authentication, stale-parent rejection or at-most-once execution**. +- The new-policy native count estimator is local and pure: no profile/credential reads, + refresh/discovery, network dispatch, clock/ID allocation or lineage mutation. + It may use an immutable nonsecret scope published by validated native serving; + unknown scope uses only bounded carrier/model/configured-project consistency. + Neither an estimate nor a caller scope claim authorizes generation or publishes + trusted scope. Legacy counting is unchanged. +- Qualified success cases are main-USER text progression and one complete, + ID-bearing signed tool call followed by one successful STRING tool result + (read-tool reference), preserving native call IDs/signatures and the separate + MODEL-role `functionResponse`/`output` wrapper. LOW/MEDIUM/HIGH use public source + enum mappings M419/M418/M417 and shared synthetic tests; genuine official-client + runtime reference evidence is **LOW-only**, not all-tier entitlement or Google + acceptance. Local scripted responses and stock-client interoperability do not + establish account access. +- Parallel/error/ID-less/compound tool continuations and subagent ancestry are not + qualified. Failed or cancelled turns do not fabricate successful checkpoints; + MAX_TOKENS continuations are rejected rather than treated as completed USER + executions. Native error/cancellation append behavior and hidden attempt + counters are not claimed equivalent; no automatic retry or execution guarantee + is added. Model selectors and context-window claims remain unchanged. + Auto-review classifiers route by their own requested model, not the main conversation's model. Recognized Claude aliases follow Codex/Kimi session affinity or `aliasProvider`; Grok does not establish alias affinity. Explicit Antigravity requests ignore diff --git a/src/providers/antigravity/config.rs b/src/providers/antigravity/config.rs index 9a562333..4c927b86 100644 --- a/src/providers/antigravity/config.rs +++ b/src/providers/antigravity/config.rs @@ -7,9 +7,16 @@ use url::Url; pub(super) const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum RequestMetadata { + Legacy, + Agy, +} + #[derive(Clone)] pub(crate) struct Config { pub enabled: bool, + pub request_metadata: RequestMetadata, pub base_url: Url, pub token_url: Url, pub authorization_url: Url, @@ -41,6 +48,7 @@ impl Default for Config { fn default() -> Self { Self { enabled: false, + request_metadata: RequestMetadata::Legacy, base_url: Url::parse("https://daily-cloudcode-pa.googleapis.com").unwrap(), token_url: Url::parse("https://oauth2.googleapis.com/token").unwrap(), authorization_url: Url::parse("https://accounts.google.com/o/oauth2/v2/auth").unwrap(), @@ -106,10 +114,18 @@ impl Config { "clientSecret", "projectId", "userAgent", + "requestMetadata", ], &[], )?; let mut result = Self::default(); + if let Some(mode) = section.get("requestMetadata") { + result.request_metadata = match mode.as_str() { + Some("legacy") => RequestMetadata::Legacy, + Some("agy") => RequestMetadata::Agy, + _ => return Err("invalid Antigravity request metadata policy"), + }; + } let file_user_agent = section .get("userAgent") .map(|value| value.as_str().ok_or("invalid Antigravity user agent")) @@ -184,6 +200,54 @@ impl Config { mod tests { use super::*; + #[test] + fn antigravity_request_metadata_policy_is_explicit_and_closed() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.json"); + let default = Config::read(&path, |key| { + (key == "CCP_ANTIGRAVITY_REQUEST_METADATA").then(|| "agy".into()) + }) + .unwrap(); + assert_eq!(default.request_metadata, RequestMetadata::Legacy); + assert!(!default.enabled); + for (value, expected) in [ + ("legacy", RequestMetadata::Legacy), + ("agy", RequestMetadata::Agy), + ] { + std::fs::write( + &path, + json!({"antigravity":{"requestMetadata":value}}).to_string(), + ) + .unwrap(); + let config = Config::read(&path, |_| None).unwrap(); + assert_eq!(config.request_metadata, expected); + assert!(!config.enabled); + assert_eq!(config.native_headers()["user-agent"], default_user_agent()); + } + for value in [ + json!(null), + json!(false), + json!(1), + json!([]), + json!({}), + json!(""), + json!("AGY"), + json!(" agy"), + json!("agy "), + json!("unknown-sentinel"), + ] { + std::fs::write( + &path, + json!({"antigravity":{"requestMetadata":value}}).to_string(), + ) + .unwrap(); + assert_eq!( + Config::read(&path, |_| None).err(), + Some("invalid Antigravity request metadata policy") + ); + } + } + #[test] fn antigravity_consumer_default_origin_preserves_oauth_and_identity() { let directory = tempfile::tempdir().unwrap(); diff --git a/src/providers/antigravity/mod.rs b/src/providers/antigravity/mod.rs index c09e989a..7412e60f 100644 --- a/src/providers/antigravity/mod.rs +++ b/src/providers/antigravity/mod.rs @@ -27,11 +27,17 @@ use config::Config; use generation::GenerationClient; use replay::{Binding, Result}; use serde_json::{Value, json}; -use std::{sync::Arc, time::Duration}; +use std::{ + sync::{Arc, RwLock}, + time::Duration, +}; pub struct AntigravityProvider { auth: Result>, generation: Result, + // Advisory nonsecret VALUE snapshot only. Not an owner/trajectory store and + // never populated from count requests or untrusted transcript scope claims. + serving_scope: Arc>>, } impl Default for AntigravityProvider { fn default() -> Self { @@ -40,6 +46,8 @@ impl Default for AntigravityProvider { } impl AntigravityProvider { pub fn new() -> Self { + #[cfg(test)] + request::effects::note(request::effects::Kind::Factory); let config = Config::load(); let generation = config .as_ref() @@ -48,37 +56,59 @@ impl AntigravityProvider { let auth = config .and_then(|config| Auth::new(config, Store::configured())) .map(Arc::new); - Self { auth, generation } + Self { + auth, + generation, + serving_scope: Arc::new(RwLock::new(None)), + } } #[cfg(test)] pub(crate) fn with_auth(auth: Auth) -> Self { + request::effects::note(request::effects::Kind::Factory); let generation = GenerationClient::new(&auth.config); Self { auth: Ok(Arc::new(auth)), generation, + serving_scope: Arc::new(RwLock::new(None)), } } + /// Effect-free clone of an immutable nonsecret serving value. In particular, + /// do not substitute local_capability()/Store getters, which read profiles. + /// Poisoned/unavailable snapshot stays unknown; count never repairs it from + /// a caller claim. It is an estimate input, never generation authorization. + pub(crate) fn count_scope_snapshot(&self) -> Option { + self.serving_scope + .read() + .ok() + .and_then(|scope| scope.clone()) + } + async fn generate(&self, body: MessagesRequest, ctx: RequestContext) -> Response { let auth = match &self.auth { Ok(auth) => auth, Err(_) => return invalid("Antigravity configuration is invalid"), }; + #[cfg(test)] + request::effects::note(request::effects::Kind::CredentialLoad); let stored = match auth.store.load() { Ok(value) => value, Err(_) => return authentication("Antigravity credentials are invalid or unreadable"), }; let binding = binding(auth, &body, stored.as_ref()); - let prepared = match request::prepare(&body, &binding) { - Ok(p) => p, - Err(e) => return invalid(e), - }; + let mut prepared = + match request::prepare_with_policy(&body, &binding, auth.config.request_metadata) { + Ok(p) => p, + Err(e) => return invalid(e), + }; let generation = match &self.generation { Ok(client) => client, Err(_) => { return upstream_error("Antigravity generation transport initialization failed"); } }; + #[cfg(test)] + request::effects::note(request::effects::Kind::Access); let credential = match auth.access().await { Ok(c) => c, Err(error) => { @@ -108,6 +138,16 @@ impl AntigravityProvider { "Antigravity project missing; run auth login or configure your authorized project", ); } + if !matches!(prepared.metadata, request::Metadata::Legacy) { + // Fresh UUIDs are allocated only after access/discovery and actual + // scope validation. Observed text continuations keep carried IDs; + // legacy preparation and its existing nonce behavior are unchanged. + let metadata = request::metadata_millis() + .and_then(|millis| prepared.finalize_metadata(millis, request::metadata_uuid)); + if let Err(error) = metadata { + return upstream_error(error); + } + } if let Some(monitor) = ctx.monitor.as_ref() { monitor.model_resolved(&ctx.req_id, body.model.as_deref().unwrap_or_default()); } @@ -153,6 +193,8 @@ impl AntigravityProvider { return upstream_error("Antigravity returned an unexpected content type"); } let model = body.model.unwrap_or_default(); + let serving_scope = (auth.config.request_metadata == config::RequestMetadata::Agy) + .then(|| self.serving_scope.clone()); let work = async move { let turn = tokio::time::timeout_at(request_deadline, transport::collect(upstream)) .await @@ -165,13 +207,36 @@ impl AntigravityProvider { ); } let mapping = prepared.tools.mapping(&turn.contents)?; - let blocks = replay::encode( - &turn.contents, - &binding, - &prepared.session, - &prepared.display, - &mapping, - )?; + let blocks = match &prepared.metadata { + request::Metadata::Ready(state) => replay::v2::encode( + &turn.contents, + &binding, + state, + &turn.stop_reason, + &prepared.display, + &mapping, + )?, + request::Metadata::Legacy => replay::encode( + &turn.contents, + &binding, + &prepared.session, + &prepared.display, + &mapping, + )?, + _ => return Err("native metadata was not finalized"), + }; + // Only actual successful access/binding plus a fully validated native + // turn/projection may publish these nonsecret count inputs. Errors or + // cancellation before this point publish nothing; no carrier claim is + // authoritative and this snapshot never authorizes generation. + if let Some(scope) = &serving_scope + && let Ok(mut published) = scope.write() + { + *published = Some(request::ServingScope { + profile_id: binding.profile_id.clone(), + project_id: binding.project_id.clone(), + }); + } Ok::<_, &'static str>(transport::message(blocks, &model, &turn)) }; if !body.stream { @@ -284,6 +349,22 @@ impl Provider for AntigravityProvider { Ok(auth) => auth, Err(_) => return invalid("Antigravity configuration is invalid"), }; + if auth.config.request_metadata == config::RequestMetadata::Agy { + let scope = self.count_scope_snapshot(); + return match request::estimate( + &body, + request::CountInputs { + serving_scope: scope.as_ref(), + configured_project: auth.config.project.as_deref(), + }, + ) { + Ok(estimate) => Json(json!({"input_tokens":estimate})).into_response(), + Err(error) => invalid(error), + }; + } + // Frozen default/legacy count behavior intentionally remains separate. + #[cfg(test)] + request::effects::note(request::effects::Kind::CredentialLoad); let credential = match auth.store.load() { Ok(c) => c, Err(_) => return authentication("Antigravity credentials invalid or unreadable"), diff --git a/src/providers/antigravity/replay.rs b/src/providers/antigravity/replay.rs index db6882f6..31dbd2cc 100644 --- a/src/providers/antigravity/replay.rs +++ b/src/providers/antigravity/replay.rs @@ -690,3 +690,366 @@ pub(crate) fn decode_history(turns: &Value, binding: &Binding) -> Result, +) -> Result { + let first = blocks + .as_array() + .and_then(|b| b.first()) + .filter(|b| b["type"] == "thinking") + .ok_or("missing carrier")?; + let signature = nonempty(&first["signature"])?; + let encoded = signature + .strip_prefix(PREFIX) + .or_else(|| signature.strip_prefix(v2::PREFIX)) + .ok_or("missing/foreign carrier")?; + if encoded.is_empty() || encoded.len() > MAX_ENCODED || encoded.len() * 3 / 4 > MAX_DECODED { + return Err("carrier byte limit"); + } + let raw = URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| "malformed carrier")?; + let envelope = parse_json(&raw, MAX_DECODED)?; + let project = nonempty(&envelope["project_id"])?; + if envelope["native_model"].as_str() != Some(native_model) + || configured_project.is_some_and(|p| p != project) + { + return Err("model/configured-project estimate scope mismatch"); + } + Ok(Binding { + profile_id: nonempty(&envelope["profile_id"])?.to_owned(), + project_id: project.to_owned(), + native_model: native_model.to_owned(), + }) +} + +/// Separate bounded V2 consistency state. V1 bytes/semantics above remain frozen. +/// Client-carried consistency is NOT ownership authentication, server-side +/// stale-parent rejection or at-most-once execution; replay remains possible. +/// IDs and checkpoint ordinals below are proxy-owned representations, not copied +/// native IDs or claims about hidden Go allocation timing. Successful user +/// completion records active for the next user; its tool loop retains prior. +/// Native Go can append nonnil metadata on errors too: conservative no-checkpoint +/// failure/cancellation here is NOT a claim of native error-path equivalence. +pub(crate) mod v2 { + use super::*; + + pub(crate) const PREFIX: &str = "ccp:antigravity:v2:"; + pub(crate) const BACKEND_SESSION: &str = "-3750763034362895579"; + pub(crate) const MAX_CHECKPOINTS: u64 = 512; + + fn uuid_v4(value: &str) -> Result<()> { + let id = uuid::Uuid::parse_str(value).map_err(|_| "invalid V2 logical UUID")?; + if id.get_version_num() != 4 + || id.get_variant() != uuid::Variant::RFC4122 + || id.to_string() != value + { + return Err("invalid V2 logical UUID"); + } + Ok(()) + } + + #[derive(Clone, Eq, PartialEq, serde::Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub(crate) struct Lineage { + pub conversation_id: String, + pub trajectory_id: String, + } + + impl Lineage { + pub(crate) fn new(conversation_id: String, trajectory_id: String) -> Result { + let value = Self { + conversation_id, + trajectory_id, + }; + value.validate()?; + Ok(value) + } + + pub(crate) fn validate(&self) -> Result<()> { + for value in [&self.conversation_id, &self.trajectory_id] { + uuid_v4(value)?; + } + if self.conversation_id == self.trajectory_id { + return Err("V2 logical UUID collision"); + } + Ok(()) + } + } + + #[derive(Clone, serde::Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub(crate) struct UserExecution { + pub active_id: String, + /// Last recorded predecessor when THIS user execution began. Immutable + /// through its tool loop; never minted while rendering the label. + pub prior_recorded_id: Option, + } + + #[derive(Clone, serde::Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub(crate) struct State { + pub lineage: Lineage, + /// Our native assistant-checkpoint ordinal, not a hidden Go counter. + pub native_checkpoint_index: u64, + pub user_execution: UserExecution, + } + + impl State { + pub(crate) fn validate(&self) -> Result<()> { + self.lineage.validate()?; + if self.native_checkpoint_index >= MAX_CHECKPOINTS { + return Err("V2 checkpoint limit"); + } + let execution = &self.user_execution; + uuid_v4(&execution.active_id)?; + if let Some(prior) = &execution.prior_recorded_id { + uuid_v4(prior)?; + if self.native_checkpoint_index == 0 || prior == &execution.active_id { + return Err("invalid V2 execution predecessor"); + } + } + for id in + std::iter::once(&execution.active_id).chain(execution.prior_recorded_id.iter()) + { + if id == &self.lineage.conversation_id || id == &self.lineage.trajectory_id { + return Err("V2 execution/lineage UUID collision"); + } + } + Ok(()) + } + + /// Read only after bounded envelope validation. No allocation sources, + /// clock, auth/profile access or publication of authoritative scope. + pub(crate) fn from_envelope(envelope: &Value) -> Result { + keys( + &envelope["user_execution"], + &["active_id", "prior_recorded_id"], + &["active_id", "prior_recorded_id"], + )?; + let state: Self = serde_json::from_value(json!({ + "lineage":envelope["lineage"], + "native_checkpoint_index":envelope["native_checkpoint_index"], + "user_execution":envelope["user_execution"] + })) + .map_err(|_| "invalid V2 state")?; + state.validate()?; + Ok(state) + } + + /// Successful final completion records active for the NEXT user. This + /// pure derivation is our conservative policy, not Go error/cancel parity. + pub(crate) fn recorded_after(&self, stop: &str) -> Result> { + match stop { + "end_turn" => Ok(Some(&self.user_execution.active_id)), + "tool_use" | "max_tokens" => Ok(self.user_execution.prior_recorded_id.as_deref()), + _ => Err("unsupported V2 checkpoint stop reason"), + } + } + } + + fn stop_reason(stop: &str, mapping: &Value) -> Result<()> { + let has_calls = !mapping.as_array().ok_or("invalid tool mapping")?.is_empty(); + match stop { + "end_turn" if !has_calls => Ok(()), + "tool_use" if has_calls => Ok(()), + "max_tokens" => Ok(()), + _ => Err("inconsistent V2 checkpoint stop reason"), + } + } + + fn digest(blocks: &Value, envelope: &Value) -> Result { + let mut binding = Map::new(); + for key in [ + "version", + "profile_id", + "project_id", + "native_model", + "backend_session_id", + "lineage", + "native_checkpoint_index", + "user_execution", + "checkpoint_stop_reason", + "thought_display", + "tool_mapping", + ] { + binding.insert( + key.to_owned(), + envelope.get(key).ok_or("missing V2 binding")?.clone(), + ); + } + let input = canonical(&json!({ + "domain":"ccp:antigravity:v2:projection", "binding":binding, + "blocks":projection_values(blocks)? + }))?; + Ok(format!("{:x}", Sha256::digest(input))) + } + + pub(crate) fn encode( + contents: &Value, + binding: &Binding, + state: &State, + checkpoint_stop_reason: &str, + thought_display: &str, + mapping: &Value, + ) -> Result { + state.validate()?; + let mut blocks = projection(contents, thought_display, mapping)?; + stop_reason(checkpoint_stop_reason, mapping)?; + let mut envelope = serde_json::to_value(binding).map_err(|_| "invalid binding")?; + let fields = json!({ + "version":2, "backend_session_id":BACKEND_SESSION, "lineage":state.lineage, + "native_checkpoint_index":state.native_checkpoint_index, + "user_execution":state.user_execution, + "checkpoint_stop_reason":checkpoint_stop_reason, + "thought_display":thought_display, "contents":contents, "tool_mapping":mapping + }); + envelope + .as_object_mut() + .unwrap() + .extend(fields.as_object().unwrap().clone()); + envelope["projection_digest"] = json!(digest(&blocks, &envelope)?); + let raw = serde_json::to_vec(&envelope).map_err(|_| "invalid V2 envelope")?; + parse_json(&raw, MAX_DECODED)?; + let encoded = URL_SAFE_NO_PAD.encode(raw); + if encoded.len() > MAX_ENCODED { + return Err("encoded byte limit"); + } + blocks[0]["signature"] = json!(format!("{PREFIX}{encoded}")); + // A production encoder must not emit state that its bounded scope and + // projection/state validator rejects. No completed checkpoint is encoded + // by the generation pipeline until the native turn and tool mapping pass. + decode(&blocks, binding)?; + Ok(blocks) + } + + pub(crate) fn decode(blocks: &Value, binding: &Binding) -> Result { + let list = blocks + .as_array() + .filter(|b| !b.is_empty()) + .ok_or("missing V2 carrier")?; + if list[0]["type"] != "thinking" + || list.iter().filter(|b| b["type"] == "thinking").count() != 1 + { + return Err("missing/duplicate/nonleading V2 carrier"); + } + let encoded = list[0]["signature"] + .as_str() + .and_then(|s| s.strip_prefix(PREFIX)) + .ok_or("missing/foreign V2 carrier")?; + if encoded.is_empty() || encoded.len() > MAX_ENCODED || encoded.len() * 3 / 4 > MAX_DECODED + { + return Err("carrier byte limit"); + } + let raw = URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| "malformed V2 carrier")?; + let envelope = parse_json(&raw, MAX_DECODED)?; + let fields = [ + "version", + "profile_id", + "project_id", + "native_model", + "backend_session_id", + "lineage", + "native_checkpoint_index", + "user_execution", + "checkpoint_stop_reason", + "thought_display", + "contents", + "tool_mapping", + "projection_digest", + ]; + keys(&envelope, &fields, &fields)?; + if envelope["version"].as_u64() != Some(2) { + return Err("unsupported V2 carrier version"); + } + for (key, value) in [ + ("profile_id", &binding.profile_id), + ("project_id", &binding.project_id), + ("native_model", &binding.native_model), + ] { + if value.is_empty() || envelope[key].as_str() != Some(value) { + return Err("model/profile/project binding mismatch; start a fresh session"); + } + } + if envelope["backend_session_id"] != BACKEND_SESSION { + return Err("invalid V2 backend session"); + } + State::from_envelope(&envelope)?; + let expected = projection( + &envelope["contents"], + nonempty(&envelope["thought_display"])?, + &envelope["tool_mapping"], + )?; + stop_reason( + nonempty(&envelope["checkpoint_stop_reason"])?, + &envelope["tool_mapping"], + )?; + let hash = digest(&expected, &envelope)?; + if envelope["projection_digest"] != hash || digest(blocks, &envelope)? != hash { + return Err("V2 projection digest mismatch"); + } + Ok(envelope) + } + + pub(crate) fn decode_history(turns: &Value, binding: &Binding) -> Result> { + let turns = turns + .as_array() + .filter(|t| t.len() <= MAX_CHECKPOINTS as usize) + .ok_or("history limit/type")?; + let mut result = Vec::new(); + let mut previous: Option<(State, String)> = None; + let mut executions = HashSet::new(); + let mut ids = HashSet::new(); + for (index, turn) in turns.iter().enumerate() { + let envelope = decode(turn, binding)?; + let state = State::from_envelope(&envelope)?; + if state.native_checkpoint_index != index as u64 { + return Err("conflicting V2 checkpoint order"); + } + if let Some((prior, stop)) = &previous { + if prior.lineage != state.lineage { + return Err("conflicting V2 logical conversation IDs"); + } + if state.user_execution.prior_recorded_id.as_deref() + != prior.recorded_after(stop)? + { + return Err("conflicting V2 recorded execution predecessor"); + } + match stop.as_str() { + "end_turn" if executions.contains(&state.user_execution.active_id) => { + return Err("reused V2 USER execution identity"); + } + "tool_use" + if state.user_execution.active_id != prior.user_execution.active_id => + { + return Err("V2 tool loop changed active execution"); + } + "max_tokens" => { + return Err("unqualified continuation after incomplete V2 checkpoint"); + } + _ => {} + } + } + executions.insert(state.user_execution.active_id.clone()); + previous = Some(( + state, + nonempty(&envelope["checkpoint_stop_reason"])?.to_owned(), + )); + for entry in envelope["tool_mapping"].as_array().unwrap() { + if !ids.insert(entry["local_id"].as_str().unwrap().to_owned()) { + return Err("duplicate local ID across history"); + } + } + result.push(envelope); + } + Ok(result) + } +} diff --git a/src/providers/antigravity/request.rs b/src/providers/antigravity/request.rs index 64fa4ffb..b1de022f 100644 --- a/src/providers/antigravity/request.rs +++ b/src/providers/antigravity/request.rs @@ -1,4 +1,5 @@ use super::{ + config::RequestMetadata, replay::{self, Binding, Result, keys, nonempty}, schema::ToolSet, }; @@ -22,12 +23,178 @@ pub(crate) fn effective_tier(model: &str) -> Option<&'static str> { } } +#[cfg(test)] +pub(crate) mod effects { + use std::cell::Cell; + #[derive(Clone, Copy)] + pub(crate) enum Kind { + CredentialLoad, + Access, + Factory, + Clock, + NativeId, + LegacyId, + } + thread_local! { static COUNTS: Cell<[usize;6]> = const { Cell::new([0;6]) }; } + pub(crate) fn note(kind: Kind) { + COUNTS.with(|cell| { + let mut counts = cell.get(); + counts[kind as usize] += 1; + cell.set(counts); + }); + } + pub(crate) fn reset() { + COUNTS.with(|cell| cell.set([0; 6])); + } + pub(crate) fn snapshot() -> [usize; 6] { + COUNTS.with(Cell::get) + } + pub(crate) fn count(kind: Kind) -> usize { + snapshot()[kind as usize] + } +} + +pub(crate) fn metadata_millis() -> Result { + #[cfg(test)] + effects::note(effects::Kind::Clock); + let millis = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|_| "invalid metadata clock")? + .as_millis(); + i64::try_from(millis).map_err(|_| "invalid metadata clock") +} + +pub(crate) fn metadata_uuid() -> String { + #[cfg(test)] + effects::note(effects::Kind::NativeId); + uuid::Uuid::new_v4().to_string() +} + +fn legacy_session() -> String { + #[cfg(test)] + effects::note(effects::Kind::LegacyId); + replay::mint_session() +} + +fn legacy_outer_id() -> String { + #[cfg(test)] + effects::note(effects::Kind::LegacyId); + format!("ccp-{}", uuid::Uuid::new_v4()) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ServingScope { + pub profile_id: String, + pub project_id: String, +} + +/// Immutable, nonsecret VALUE inputs only. No Auth, Store, client, clock or ID +/// source is available to the estimate function. A missing serving scope does +/// not authorize generation: carrier scope is then an untrusted consistency +/// claim checked against the known model/configured project, never published. +#[derive(Clone, Copy)] +pub(crate) struct CountInputs<'a> { + pub serving_scope: Option<&'a ServingScope>, + pub configured_project: Option<&'a str>, +} + +#[derive(Clone, Copy, Eq, PartialEq)] +enum Purpose { + Generate, + Estimate, +} + +pub(crate) enum Metadata { + Legacy, + Fresh, + Continuation { + state: replay::v2::State, + new_user_execution: bool, + execution_ids: Vec, + }, + Ready(replay::v2::State), +} + pub(crate) struct Prepared { pub wire: Value, pub session: String, pub display: String, pub tools: ToolSet, pub estimate: u64, + pub metadata: Metadata, +} + +impl Prepared { + /// Only called after unchanged access and actual binding validation. The ID + /// source is used at fresh/new-USER execution boundaries, NEVER to fabricate + /// last_execution_id at render time. Tool generations retain active/prior. + pub(crate) fn finalize_metadata( + &mut self, + unix_millis: i64, + mut uuid: impl FnMut() -> String, + ) -> Result<()> { + if unix_millis < 0 { + return Err("invalid metadata timestamp"); + } + // LOW is runtime-observed. MEDIUM/HIGH enum values are compiled public + // ModelSource facts, not a claim that all tiers were runtime-observed. + let model_enum = match self.wire["model"].as_str() { + Some("gemini-4-argon-low") => "MODEL_PLACEHOLDER_M419", + Some("gemini-4-argon-medium") => "MODEL_PLACEHOLDER_M418", + Some("gemini-4-argon-high") => "MODEL_PLACEHOLDER_M417", + _ => return Err("unsupported metadata model"), + }; + let state = match &self.metadata { + Metadata::Fresh => replay::v2::State { + lineage: replay::v2::Lineage::new(uuid(), uuid())?, + native_checkpoint_index: 0, + user_execution: replay::v2::UserExecution { + active_id: uuid(), + prior_recorded_id: None, + }, + }, + Metadata::Continuation { + state, + new_user_execution, + execution_ids, + } => { + let mut state = state.clone(); + if *new_user_execution { + let active = uuid(); + if execution_ids.contains(&active) { + return Err("V2 USER execution UUID collision"); + } + state.user_execution.active_id = active; + } + state + } + _ => return Err("metadata requires a fresh or validated continuation"), + }; + state.validate()?; + let native_checkpoint_index = state.native_checkpoint_index; + let last_step = native_checkpoint_index + .checked_mul(2) + .ok_or("V2 step overflow")?; + let outer_step = last_step.checked_add(1).ok_or("V2 step overflow")?; + let lineage = &state.lineage; + self.wire["request"]["labels"] = json!({ + "model_enum":model_enum, "used_claude":"false", "used_claude_conservative":"false", + "used_non_gemini_model":"false", "last_step_index":last_step.to_string(), + "cascade_id":lineage.conversation_id, "root_cascade_id":lineage.conversation_id, + "trajectory_id":lineage.trajectory_id, "request_id":format!("{}-{native_checkpoint_index}", lineage.trajectory_id) + }); + if let Some(prior) = &state.user_execution.prior_recorded_id { + self.wire["request"]["labels"]["last_execution_id"] = json!(prior); + } + self.wire["requestId"] = json!(format!( + "agent/{}/{unix_millis}/{}/{outer_step}", + lineage.conversation_id, lineage.trajectory_id + )); + self.session = replay::v2::BACKEND_SESSION.to_owned(); + self.wire["request"]["sessionId"] = json!(self.session); + self.metadata = Metadata::Ready(state); + Ok(()) + } } pub(crate) fn cache_hint(value: Option<&Value>) -> Result<()> { @@ -205,7 +372,200 @@ fn logical_messages(messages: &[Message]) -> Result>> { Ok(output) } +fn native_checkpoint_parts(envelope: &Value) -> Result<()> { + let contents = envelope["contents"].as_array().unwrap(); + if contents.is_empty() { + return Err("V2 continuation requires native content"); + } + // Preserve every raw content/signature and original codec. A completed + // assistant checkpoint counts once regardless of native SSE fragmentation. + for content in contents { + keys(content, &["role", "parts"], &["role", "parts"])?; + let parts = content["parts"].as_array().ok_or("invalid native parts")?; + if parts.is_empty() { + return Err("V2 continuation requires native parts"); + } + for part in parts { + if part.get("functionCall").is_some() { + keys( + part, + &["functionCall", "thoughtSignature"], + &["functionCall", "thoughtSignature"], + )?; + nonempty(&part["thoughtSignature"])?; + } else { + keys(part, &["text", "thought", "thoughtSignature"], &["text"])?; + if !part["text"].is_string() { + return Err("unsupported V2 native part"); + } + } + if part.get("thought").is_some_and(|v| !v.is_boolean()) + || part.get("thoughtSignature").is_some_and(|v| !v.is_string()) + { + return Err("unsupported V2 native part"); + } + } + } + Ok(()) +} + +fn continuation_input(envelope: &Value, user: &Message) -> Result { + native_checkpoint_parts(envelope)?; + match nonempty(&envelope["checkpoint_stop_reason"])? { + "end_turn" => { + if text_parts(&user.content)?.is_empty() { + return Err("V2 continuation requires ordinary text"); + } + Ok(true) + } + "tool_use" => { + // Only the captured single complete, ID-bearing, successful string + // result is qualified here. Parallel/error/ID-less/compound batches + // must not acquire invented Go counter/grouping semantics. + let mapping = envelope["tool_mapping"].as_array().unwrap(); + let calls = replay::calls(&envelope["contents"])?; + if mapping.len() != 1 || calls.len() != 1 || calls[0].2.get("id").is_none() { + return Err("unqualified V2 tool continuation shape"); + } + let blocks = user + .content + .as_array() + .filter(|b| b.len() == 1) + .ok_or("V2 tool continuation requires one result")?; + let block = &blocks[0]; + if block["type"] != "tool_result" + || block["tool_use_id"] != mapping[0]["local_id"] + || !block["content"].is_string() + || block + .get("is_error") + .is_some_and(|v| v.as_bool() != Some(false)) + { + return Err("unqualified V2 tool result"); + } + Ok(false) + } + _ => Err("unqualified continuation after incomplete V2 checkpoint"), + } +} + +fn observed_continuation( + messages: &[Cow<'_, Message>], + envelopes: &[Value], + model: &str, +) -> Result { + // The state machine is shared by the existing three source-defined tiers. + // Genuine official runtime reference/entitlement evidence remains LOW-only. + // Keep existing message/history limits and checked proxy-owned progression. + let ordered = messages + .iter() + .filter(|m| m.role != "system") + .collect::>(); + if !MODELS.contains(&model) + || envelopes.is_empty() + || ordered.len() != envelopes.len().saturating_mul(2).saturating_add(1) + || ordered + .iter() + .enumerate() + .any(|(i, m)| m.role != if i % 2 == 0 { "user" } else { "assistant" }) + { + return Err("unsupported V2 continuation role sequence"); + } + if text_parts(&ordered[0].content)?.is_empty() { + return Err("V2 needs an initial text user"); + } + let mut new_user_execution = false; + for (index, envelope) in envelopes.iter().enumerate() { + new_user_execution = continuation_input(envelope, ordered[(index + 1) * 2])?; + } + let last = envelopes.last().unwrap(); + let mut state = replay::v2::State::from_envelope(last)?; + state.native_checkpoint_index = state + .native_checkpoint_index + .checked_add(1) + .filter(|n| *n < replay::v2::MAX_CHECKPOINTS) + .ok_or("V2 checkpoint limit")?; + if new_user_execution { + state.user_execution.prior_recorded_id = state + .recorded_after(nonempty(&last["checkpoint_stop_reason"])?)? + .map(str::to_owned); + // Active is replaced only at the post-access USER execution boundary. + } + let execution_ids = envelopes + .iter() + .map(|e| { + e["user_execution"]["active_id"] + .as_str() + .unwrap() + .to_owned() + }) + .collect(); + Ok(Metadata::Continuation { + state, + new_user_execution, + execution_ids, + }) +} + +/// Local semantic estimate only, never generation admission. New-policy counts +/// have no owner/profile reads, refresh/discovery, clock/ID allocation, lineage +/// mutation or scope publication. Failed/unknown scope is not filled from a +/// caller claim; successful serving is the sole publisher of trusted inputs. +pub(crate) fn estimate(body: &MessagesRequest, inputs: CountInputs<'_>) -> Result { + let model = body.model.as_deref().ok_or("missing model")?; + if !MODELS.contains(&model) { + return Err("unsupported Antigravity model"); + } + let native_model = model.strip_prefix("antigravity/").unwrap(); + let binding = if let Some(scope) = inputs.serving_scope { + if scope.profile_id.is_empty() + || scope.project_id.is_empty() + || inputs + .configured_project + .is_some_and(|p| p != scope.project_id.as_str()) + { + return Err("inconsistent published Antigravity scope"); + } + Binding { + profile_id: scope.profile_id.clone(), + project_id: scope.project_id.clone(), + native_model: native_model.to_owned(), + } + } else { + let messages = logical_messages(&body.messages)?; + if let Some(turn) = messages.iter().find(|m| m.role == "assistant") { + replay::claimed_binding(&turn.content, native_model, inputs.configured_project)? + } else { + Binding { + profile_id: "unconfigured".into(), + project_id: inputs + .configured_project + .unwrap_or("unconfigured") + .to_owned(), + native_model: native_model.to_owned(), + } + } + }; + prepare_inner(body, &binding, RequestMetadata::Agy, Purpose::Estimate).map(|p| p.estimate) +} + pub(crate) fn prepare(body: &MessagesRequest, binding: &Binding) -> Result { + prepare_with_policy(body, binding, RequestMetadata::Legacy) +} + +pub(crate) fn prepare_with_policy( + body: &MessagesRequest, + binding: &Binding, + policy: RequestMetadata, +) -> Result { + prepare_inner(body, binding, policy, Purpose::Generate) +} + +fn prepare_inner( + body: &MessagesRequest, + binding: &Binding, + policy: RequestMetadata, + purpose: Purpose, +) -> Result { let model = body.model.as_deref().ok_or("missing model")?; if !MODELS.contains(&model) || model.strip_prefix("antigravity/") != Some(&binding.native_model) { @@ -250,11 +610,64 @@ pub(crate) fn prepare(body: &MessagesRequest, binding: &Binding) -> Result>(); - let envelopes = replay::decode_history(&json!(turns), binding)?; - let session = envelopes - .first() - .map(|e| e["session_id"].as_str().unwrap().to_string()) - .unwrap_or_else(replay::mint_session); + // Classify ONLY the leading carrier. V1 deliberately ignores noncarrier + // signatures, including strings that happen to look like another prefix. + let v2_turns = turns + .iter() + .filter(|turn| { + turn.get(0).is_some_and(|block| { + block["type"] == "thinking" + && block["signature"] + .as_str() + .is_some_and(|s| s.starts_with(replay::v2::PREFIX)) + }) + }) + .count(); + let (envelopes, metadata) = if v2_turns != 0 { + if v2_turns != turns.len() { + return Err("mixed transcript carrier versions are unsupported"); + } + if policy != RequestMetadata::Agy { + return Err("V2 history requires the agy request metadata policy"); + } + let envelopes = replay::v2::decode_history(&json!(turns), binding)?; + let metadata = observed_continuation(&messages, &envelopes, model)?; + (envelopes, metadata) + } else { + let envelopes = replay::decode_history(&json!(turns), binding)?; + let metadata = if policy == RequestMetadata::Agy && turns.is_empty() { + let input = messages + .iter() + .filter(|m| m.role != "system") + .collect::>(); + if input.len() != 1 + || input[0].role != "user" + || text_parts(&input[0].content)?.is_empty() + { + return Err("fresh agy execution requires one text user input"); + } + Metadata::Fresh + } else { + Metadata::Legacy + }; + (envelopes, metadata) + }; + let session = if matches!(metadata, Metadata::Legacy) { + envelopes + .first() + .map(|e| e["session_id"].as_str().unwrap().to_string()) + .unwrap_or_else(|| { + if purpose == Purpose::Generate { + legacy_session() + } else { + String::new() + } + }) + } else { + // No new-policy wire execution metadata is rendered before unchanged + // credential access and actual binding validation. Legacy stays as-is. + String::new() + }; let mut history = envelopes.into_iter(); let mut pending: Vec<(Value, Value)> = Vec::new(); let mut contents = Vec::new(); @@ -442,13 +855,19 @@ pub(crate) fn prepare(body: &MessagesRequest, binding: &Binding) -> Result Value { "messages":[{"role":"user", "content":"hello"}]}) } +fn execution_id(index: u64) -> String { + format!("aaaaaaaa-aaaa-4aaa-8aaa-{index:012x}") +} + +fn v2_state(lineage: &replay::v2::Lineage, index: u64) -> replay::v2::State { + replay::v2::State { + lineage: lineage.clone(), + native_checkpoint_index: index, + user_execution: replay::v2::UserExecution { + active_id: execution_id(index), + prior_recorded_id: index.checked_sub(1).map(execution_id), + }, + } +} + +fn v2_encode( + contents: &Value, + binding: &replay::Binding, + lineage: &replay::v2::Lineage, + index: u64, + display: &str, + mapping: &Value, +) -> replay::Result { + let stop = if mapping.as_array().is_some_and(|m| !m.is_empty()) { + "tool_use" + } else { + "end_turn" + }; + replay::v2::encode( + contents, + binding, + &v2_state(lineage, index), + stop, + display, + mapping, + ) +} + +#[test] +fn antigravity_v2_first_checkpoint_is_separate_bounded_and_scope_bound() { + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + let lineage = replay::v2::Lineage::new( + "11111111-1111-4111-8111-111111111111".into(), + "22222222-2222-4222-8222-222222222222".into(), + ) + .unwrap(); + let contents = json!([{"role":"model","parts":[ + {"text":"private thought","thought":true,"thoughtSignature":"opaque-original"}, + {"functionCall":{"name":"native_name","args":{"payload":"{\"x\":17}"}},"thoughtSignature":"opaque-call"}, + {"text":"complete"} + ]}]); + let recipes = + json!([{"local_id":"toolu_fixed","caller_name":"caller_name","codec_paths":["/payload"]}]); + let mapping = replay::mapping(&contents, Some(&recipes)).unwrap(); + for display in ["full", "omitted"] { + let v1 = replay::encode(&contents, &binding(), "19", display, &mapping).unwrap(); + let v2 = v2_encode(&contents, &binding(), &lineage, 0, display, &mapping).unwrap(); + assert_eq!( + replay::projection_values(&v1).unwrap(), + replay::projection_values(&v2).unwrap() + ); + assert!( + v2[0]["signature"] + .as_str() + .unwrap() + .starts_with(replay::v2::PREFIX) + ); + assert!(replay::decode(&v2, &binding()).is_err()); + assert!(replay::v2::decode(&v1, &binding()).is_err()); + let envelope = replay::v2::decode(&v2, &binding()).unwrap(); + assert_eq!(envelope["contents"], contents); + assert_eq!(envelope["tool_mapping"], mapping); + assert_eq!(envelope["version"], 2); + assert_eq!(envelope["backend_session_id"], "-3750763034362895579"); + assert_eq!( + envelope["lineage"]["conversation_id"], + lineage.conversation_id + ); + assert_eq!(envelope["lineage"]["trajectory_id"], lineage.trajectory_id); + assert_ne!( + envelope["projection_digest"], + replay::decode(&v1, &binding()).unwrap()["projection_digest"] + ); + for key in ["profile_id", "project_id", "native_model"] { + let mut scope = serde_json::to_value(binding()).unwrap(); + scope[key] = json!("different"); + let scope = serde_json::from_value(scope).unwrap(); + assert!(replay::v2::decode(&v2, &scope).is_err()); + } + for (key, value) in [ + ("version", json!(1)), + ("version", json!(3)), + ("backend_session_id", json!("19")), + ("native_checkpoint_index", json!(1)), + ("native_checkpoint_index", json!(2)), + ( + "lineage", + json!({"conversation_id":lineage.conversation_id,"trajectory_id":lineage.conversation_id}), + ), + ( + "lineage", + json!({"conversation_id":"33333333-3333-4333-8333-333333333333","trajectory_id":lineage.trajectory_id}), + ), + ( + "projection_digest", + replay::decode(&v1, &binding()).unwrap()["projection_digest"].clone(), + ), + ("extra", json!(true)), + ] { + let mut tampered = envelope.clone(); + tampered[key] = value; + let mut blocks = v2.clone(); + blocks[0]["signature"] = json!(format!( + "{}{}", + replay::v2::PREFIX, + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&tampered).unwrap()) + )); + assert!( + replay::v2::decode(&blocks, &binding()).is_err(), + "accepted {key}" + ); + } + let mut modified = v2.clone(); + modified[1]["input"]["payload"]["x"] = json!(18); + assert!(replay::v2::decode(&modified, &binding()).is_err()); + let mut foreign = v2.clone(); + foreign[0]["signature"] = json!("ccp:antigravity:v3:unknown"); + assert!(replay::v2::decode(&foreign, &binding()).is_err()); + foreign[0]["signature"] = json!(format!("{}!", replay::v2::PREFIX)); + assert!(replay::v2::decode(&foreign, &binding()).is_err()); + } + for id in [ + "not-a-uuid", + "11111111111141118111111111111111", + "11111111-1111-1111-8111-111111111111", + "11111111-1111-4111-0111-111111111111", + "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA", + ] { + assert!(replay::v2::Lineage::new(id.into(), lineage.trajectory_id.clone()).is_err()); + } + let oversized = json!([{"role":"model","parts":[{"text":"x".repeat(replay::MAX_DECODED)}]}]); + assert_eq!( + v2_encode(&oversized, &binding(), &lineage, 0, "full", &json!([])).err(), + Some("JSON byte limit") + ); + let too_many = json!(vec![json!({"role":"model","parts":[]}); 513]); + assert!(v2_encode(&too_many, &binding(), &lineage, 0, "full", &json!([])).is_err()); + assert!( + replay::v2::Lineage::new(lineage.conversation_id.clone(), lineage.conversation_id).is_err() + ); +} + +#[test] +fn antigravity_metadata_first_request_and_legacy_history_boundary() { + use super::antigravity::{ + config::RequestMetadata, + request::{Metadata, prepare_with_policy}, + }; + let body = serde_json::from_value(request_value()).unwrap(); + let mut fresh = prepare_with_policy(&body, &binding(), RequestMetadata::Agy).unwrap(); + assert!( + matches!(fresh.metadata, Metadata::Fresh), + "preparation must not allocate Go identifiers" + ); + assert_eq!(fresh.wire["requestId"], ""); + assert_eq!(fresh.wire["request"]["sessionId"], ""); + let lineage = replay::v2::Lineage::new( + "11111111-1111-4111-8111-111111111111".into(), + "22222222-2222-4222-8222-222222222222".into(), + ) + .unwrap(); + assert!( + fresh + .finalize_metadata(-1, || panic!("invalid clock must not allocate")) + .is_err() + ); + let mut allocated = 0; + let mut ids = [ + lineage.conversation_id.clone(), + lineage.trajectory_id.clone(), + execution_id(0), + ] + .into_iter(); + fresh + .finalize_metadata(1_799_999_999_123, || { + allocated += 1; + ids.next().unwrap() + }) + .unwrap(); + assert_eq!( + allocated, 3, + "two logical IDs and one proxy user execution at admission" + ); + assert_eq!( + fresh.wire["requestId"], + "agent/11111111-1111-4111-8111-111111111111/1799999999123/22222222-2222-4222-8222-222222222222/1" + ); + assert_eq!(fresh.wire["request"]["sessionId"], "-3750763034362895579"); + assert_eq!( + fresh.wire["request"]["labels"], + json!({ + "model_enum":"MODEL_PLACEHOLDER_M419","used_claude":"false","used_claude_conservative":"false", + "used_non_gemini_model":"false","last_step_index":"0","cascade_id":lineage.conversation_id, + "root_cascade_id":lineage.conversation_id,"trajectory_id":lineage.trajectory_id, + "request_id":format!("{}-0", lineage.trajectory_id) + }) + ); + assert!( + fresh + .finalize_metadata(1_799_999_999_123, || panic!( + "double finalization must not allocate" + )) + .is_err() + ); + let contents = json!([{"role":"model","parts":[{"text":"previous"}]}]); + let v1 = replay::encode(&contents, &binding(), "19", "full", &json!([])).unwrap(); + let v2 = v2_encode(&contents, &binding(), &lineage, 0, "full", &json!([])).unwrap(); + let mut history = request_value(); + history["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":v1}), + json!({"role":"user","content":"next"}), + ]); + let mut legacy = prepare_with_policy( + &serde_json::from_value(history.clone()).unwrap(), + &binding(), + RequestMetadata::Agy, + ) + .unwrap(); + assert_eq!(legacy.session, "19"); + assert!(legacy.wire["request"].get("labels").is_none()); + assert!( + legacy.wire["requestId"] + .as_str() + .unwrap() + .starts_with("ccp-") + ); + assert!(matches!(legacy.metadata, Metadata::Legacy)); + assert!( + legacy + .finalize_metadata(1_799_999_999_123, || panic!( + "legacy must not allocate Go IDs" + )) + .is_err() + ); + // Ignored noncarrier signatures have always been projection metadata in V1. + // A V2-looking string there must not reclassify a valid V1 turn as mixed. + history["messages"][1]["content"][1]["signature"] = + json!("ccp:antigravity:v2:ignored-text-signature"); + assert!( + prepare_with_policy( + &serde_json::from_value(history.clone()).unwrap(), + &binding(), + RequestMetadata::Agy + ) + .is_ok() + ); + history["messages"][1]["content"] = v2; + let error = prepare_with_policy( + &serde_json::from_value(history.clone()).unwrap(), + &binding(), + RequestMetadata::Legacy, + ) + .err() + .unwrap(); + assert_eq!(error, "V2 history requires the agy request metadata policy"); + let resumed = prepare_with_policy( + &serde_json::from_value(history).unwrap(), + &binding(), + RequestMetadata::Agy, + ) + .unwrap(); + assert!(matches!( + resumed.metadata, + Metadata::Continuation { + new_user_execution: true, + .. + } + )); + let default = prepare(&body, &binding()).unwrap(); + assert!(matches!(default.metadata, Metadata::Legacy)); + assert!(default.wire["request"].get("labels").is_none()); + assert!(default.session.parse::().unwrap() > 0); +} + +#[test] +fn antigravity_v2_two_user_text_checkpoint_progress_and_history_consistency() { + use super::antigravity::{ + config::RequestMetadata, + request::{Metadata, prepare_with_policy}, + }; + let lineage = replay::v2::Lineage::new( + "11111111-1111-4111-8111-111111111111".into(), + "22222222-2222-4222-8222-222222222222".into(), + ) + .unwrap(); + let first_contents = + json!([{"role":"model","parts":[{"text":"first","thoughtSignature":"original-opaque"}]}]); + let second_contents = json!([{"role":"model","parts":[{"text":"second"}]}]); + let first = v2_encode(&first_contents, &binding(), &lineage, 0, "full", &json!([])).unwrap(); + let second = v2_encode( + &second_contents, + &binding(), + &lineage, + 1, + "full", + &json!([]), + ) + .unwrap(); + let decoded = replay::v2::decode_history(&json!([first, second]), &binding()).unwrap(); + assert_eq!(decoded[0]["native_checkpoint_index"], 0); + assert_eq!(decoded[1]["native_checkpoint_index"], 1); + assert_eq!(decoded[0]["contents"], first_contents); + assert!( + v2_encode( + &second_contents, + &binding(), + &lineage, + 512, + "full", + &json!([]) + ) + .is_err() + ); + for history in [ + json!([second]), + json!([second, first]), + json!([first, first]), + ] { + assert!( + replay::v2::decode_history(&history, &binding()).is_err(), + "checkpoint omission/reorder/duplicate" + ); + } + let foreign = replay::v2::Lineage::new( + "33333333-3333-4333-8333-333333333333".into(), + "44444444-4444-4444-8444-444444444444".into(), + ) + .unwrap(); + let other_second = v2_encode( + &second_contents, + &binding(), + &foreign, + 1, + "full", + &json!([]), + ) + .unwrap(); + assert!(replay::v2::decode_history(&json!([first, other_second]), &binding()).is_err()); + let v1 = replay::encode(&first_contents, &binding(), "19", "full", &json!([])).unwrap(); + for mixed in [json!([first, v1]), json!([v1, first])] { + let mut body = request_value(); + for blocks in mixed.as_array().unwrap() { + body["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":blocks}), + json!({"role":"user","content":"next"}), + ]); + } + for policy in [RequestMetadata::Legacy, RequestMetadata::Agy] { + assert!( + prepare_with_policy( + &serde_json::from_value(body.clone()).unwrap(), + &binding(), + policy + ) + .is_err() + ); + } + } + let mut body = request_value(); + body["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":first}), + json!({"role":"user","content":"next"}), + ]); + let mut prepared = prepare_with_policy( + &serde_json::from_value(body.clone()).unwrap(), + &binding(), + RequestMetadata::Agy, + ) + .unwrap(); + let Metadata::Continuation { + state, + new_user_execution: true, + .. + } = &prepared.metadata + else { + panic!("expected carried lineage, no fresh allocation yet") + }; + assert_eq!(state.lineage.conversation_id, lineage.conversation_id); + assert_eq!(state.lineage.trajectory_id, lineage.trajectory_id); + assert_eq!(prepared.wire["requestId"], ""); + assert_eq!(prepared.wire["request"]["sessionId"], ""); + assert!( + prepared + .finalize_metadata(1_799_999_999_456, || lineage.conversation_id.clone()) + .is_err(), + "reject detected user-execution/lineage collision without reminting" + ); + let mut allocations = 0; + prepared + .finalize_metadata(1_799_999_999_456, || { + allocations += 1; + execution_id(1) + }) + .unwrap(); + assert_eq!( + allocations, 1, + "ordinary new user allocates only its active execution" + ); + assert!( + matches!(prepared.metadata, Metadata::Ready(ref state) if state.native_checkpoint_index == 1) + ); + assert_eq!( + prepared.wire["request"]["labels"]["last_execution_id"], + execution_id(0) + ); + assert_eq!( + prepared.wire["request"]["labels"]["request_id"], + format!("{}-1", lineage.trajectory_id) + ); + assert_eq!(prepared.wire["request"]["labels"]["last_step_index"], "2"); + assert_eq!( + prepared.wire["requestId"], + format!( + "agent/{}/1799999999456/{}/3", + lineage.conversation_id, lineage.trajectory_id + ) + ); + assert_eq!( + prepared.wire["request"]["contents"], + json!([ + {"role":"user","parts":[{"text":"hello"}]}, first_contents[0], {"role":"user","parts":[{"text":"next"}]} + ]) + ); + // A carried native assistant checkpoint counts once; no hidden Go operation + // counters are inferred from the number of native SSE fragments/parts. + let fragmented = json!([ + {"role":"model","parts":[{"text":"part one","thoughtSignature":"opaque-one"}]}, + {"role":"model","parts":[{"text":"part two","thoughtSignature":"opaque-two"}]} + ]); + let fragmented_blocks = + v2_encode(&fragmented, &binding(), &lineage, 0, "full", &json!([])).unwrap(); + let mut split_body = body.clone(); + split_body["messages"][1]["content"] = fragmented_blocks; + let mut split = prepare_with_policy( + &serde_json::from_value(split_body).unwrap(), + &binding(), + RequestMetadata::Agy, + ) + .unwrap(); + split + .finalize_metadata(1_799_999_999_456, || execution_id(1)) + .unwrap(); + assert_eq!(split.wire["request"]["labels"]["last_step_index"], "2"); + assert_eq!(split.wire["request"]["contents"][1], fragmented[0]); + assert_eq!(split.wire["request"]["contents"][2], fragmented[1]); + body["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":second}), + json!({"role":"user","content":"unobserved third user"}), + ]); + let mut third = prepare_with_policy( + &serde_json::from_value(body).unwrap(), + &binding(), + RequestMetadata::Agy, + ) + .unwrap(); + assert!( + third + .finalize_metadata(1_799_999_999_789, || execution_id(0)) + .is_err(), + "new USER execution must not reuse an older carried identity" + ); + third + .finalize_metadata(1_799_999_999_789, || execution_id(2)) + .unwrap(); + assert_eq!( + third.wire["request"]["labels"]["request_id"], + format!("{}-2", lineage.trajectory_id) + ); + assert_eq!(third.wire["request"]["labels"]["last_step_index"], "4"); + assert_eq!( + third.wire["request"]["labels"]["last_execution_id"], + execution_id(1) + ); + assert!(third.wire["requestId"].as_str().unwrap().ends_with("/5")); +} + +#[test] +fn antigravity_v2_unqualified_tool_and_noncanonical_text_transitions_reject() { + use super::antigravity::{config::RequestMetadata, request::prepare_with_policy}; + let lineage = replay::v2::Lineage::new( + "11111111-1111-4111-8111-111111111111".into(), + "22222222-2222-4222-8222-222222222222".into(), + ) + .unwrap(); + let contents = json!([{"role":"model","parts":[{"functionCall":{"name":"tool","args":{}}}]}]); + let mapping = replay::mapping(&contents, None).unwrap(); + let tool_turn = v2_encode(&contents, &binding(), &lineage, 0, "full", &mapping).unwrap(); + let mut body = request_value(); + body["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":tool_turn}), + json!({"role":"user","content":[{"type":"tool_result","tool_use_id":tool_turn[1]["id"],"content":"done"}]}) + ]); + assert!( + prepare_with_policy( + &serde_json::from_value(body).unwrap(), + &binding(), + RequestMetadata::Agy + ) + .is_err() + ); + let text = v2_encode( + &json!([{"role":"model","parts":[{"text":"first"}]}]), + &binding(), + &lineage, + 0, + "full", + &json!([]), + ) + .unwrap(); + let mut body = request_value(); + body["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":text}), + json!({"role":"user","content":"next"}), + ]); + for signature in [ + "ccp:antigravity:v2:!", + "ccp:antigravity:v3:unknown", + "foreign", + ] { + let mut invalid = body.clone(); + invalid["messages"][1]["content"][0]["signature"] = json!(signature); + assert!( + prepare_with_policy( + &serde_json::from_value(invalid).unwrap(), + &binding(), + RequestMetadata::Agy + ) + .is_err() + ); + } + let mut changed_scope = binding(); + changed_scope.profile_id = "other".into(); + assert!( + prepare_with_policy( + &serde_json::from_value(body.clone()).unwrap(), + &changed_scope, + RequestMetadata::Agy + ) + .is_err() + ); + body["messages"] + .as_array_mut() + .unwrap() + .push(json!({"role":"user","content":"additional unobserved role"})); + assert!( + prepare_with_policy( + &serde_json::from_value(body).unwrap(), + &binding(), + RequestMetadata::Agy + ) + .is_err() + ); +} + +#[test] +fn antigravity_v2_user_execution_survives_read_loop_then_records_for_next_user() { + use super::antigravity::{ + config::RequestMetadata, + request::{Metadata, prepare_with_policy}, + }; + let lineage = replay::v2::Lineage::new( + "11111111-1111-4111-8111-111111111111".into(), + "22222222-2222-4222-8222-222222222222".into(), + ) + .unwrap(); + let initial = v2_state(&lineage, 0); + let call = json!([{"role":"model","parts":[{"functionCall":{"name":"read_file","args":{"Path":"/synthetic/owned"},"id":"synthetic-read-call-1"},"thoughtSignature":"c3ludGhldGljLXNpZ25hdHVyZQ=="}]}]); + let mapping = replay::mapping(&call, None).unwrap(); + let first = + replay::v2::encode(&call, &binding(), &initial, "tool_use", "full", &mapping).unwrap(); + let saved = replay::v2::decode(&first, &binding()).unwrap(); + assert_eq!(saved["checkpoint_stop_reason"], "tool_use"); + assert!(saved["user_execution"]["prior_recorded_id"].is_null()); + let mut body = request_value(); + body["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":first}), + json!({"role":"user","content":[{"type":"tool_result","tool_use_id":first[1]["id"],"content":"read marker"}]}) + ]); + let mut tool = prepare_with_policy( + &serde_json::from_value(body.clone()).unwrap(), + &binding(), + RequestMetadata::Agy, + ) + .unwrap(); + assert!(matches!( + tool.metadata, + Metadata::Continuation { + new_user_execution: false, + .. + } + )); + tool.finalize_metadata(1_799_999_999_456, || { + panic!("tool loop must not mint any IDs") + }) + .unwrap(); + let Metadata::Ready(ref running) = tool.metadata else { + panic!("metadata ready") + }; + assert_eq!(running.native_checkpoint_index, 1); + assert_eq!(running.user_execution.active_id, execution_id(0)); + assert_eq!(running.user_execution.prior_recorded_id, None); + assert!( + tool.wire["request"]["labels"] + .get("last_execution_id") + .is_none() + ); + assert_eq!(tool.wire["request"]["labels"]["last_step_index"], "2"); + assert_eq!(tool.wire["request"]["contents"][1], call[0]); + assert_eq!( + tool.wire["request"]["contents"][2], + json!({"role":"model","parts":[{"functionResponse":{"name":"read_file","response":{"output":"read marker"},"id":"synthetic-read-call-1"}}]}) + ); + let completion = json!([{"role":"model","parts":[{"text":"done"}]}]); + let second = replay::v2::encode( + &completion, + &binding(), + running, + "end_turn", + "full", + &json!([]), + ) + .unwrap(); + let decoded = replay::v2::decode(&second, &binding()).unwrap(); + let complete = replay::v2::State::from_envelope(&decoded).unwrap(); + assert_eq!( + complete.recorded_after("end_turn").unwrap(), + Some(execution_id(0).as_str()) + ); + assert_eq!( + complete.user_execution.active_id, + initial.user_execution.active_id + ); + body["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":second}), + json!({"role":"user","content":"next ordinary user"}), + ]); + let mut next = prepare_with_policy( + &serde_json::from_value(body).unwrap(), + &binding(), + RequestMetadata::Agy, + ) + .unwrap(); + let mut calls = 0; + next.finalize_metadata(1_799_999_999_789, || { + calls += 1; + execution_id(1) + }) + .unwrap(); + assert_eq!(calls, 1); + let Metadata::Ready(ref next_state) = next.metadata else { + panic!("ready") + }; + assert_eq!( + next_state.native_checkpoint_index, 2, + "generation ordinal is separate from USER execution identity" + ); + assert_eq!(next_state.user_execution.active_id, execution_id(1)); + assert_eq!( + next_state.user_execution.prior_recorded_id, + Some(execution_id(0)) + ); + assert_eq!( + next.wire["request"]["labels"]["last_execution_id"], + execution_id(0) + ); + assert_eq!( + next.wire["request"]["labels"]["request_id"], + format!("{}-2", lineage.trajectory_id) + ); + let third = replay::v2::encode( + &completion, + &binding(), + next_state, + "end_turn", + "full", + &json!([]), + ) + .unwrap(); + assert!(replay::v2::decode_history(&json!([first, second, third]), &binding()).is_ok()); +} + +#[test] +fn antigravity_v2_incomplete_or_invalid_terminal_state_never_records_success() { + use super::antigravity::{config::RequestMetadata, request::prepare_with_policy}; + let lineage = replay::v2::Lineage::new( + "11111111-1111-4111-8111-111111111111".into(), + "22222222-2222-4222-8222-222222222222".into(), + ) + .unwrap(); + let state = v2_state(&lineage, 0); + let text = json!([{"role":"model","parts":[{"text":"limited"}]}]); + let limited = + replay::v2::encode(&text, &binding(), &state, "max_tokens", "full", &json!([])).unwrap(); + let saved = replay::v2::decode(&limited, &binding()).unwrap(); + let incomplete = replay::v2::State::from_envelope(&saved).unwrap(); + assert_eq!(incomplete.recorded_after("max_tokens").unwrap(), None); + let mut body = request_value(); + body["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":limited}), + json!({"role":"user","content":"not a proven continuation"}), + ]); + assert!( + prepare_with_policy( + &serde_json::from_value(body).unwrap(), + &binding(), + RequestMetadata::Agy + ) + .is_err() + ); + assert!(replay::v2::encode(&text, &binding(), &state, "tool_use", "full", &json!([])).is_err()); + for stop in ["STOP", "", "error", "cancelled"] { + assert!(replay::v2::encode(&text, &binding(), &state, stop, "full", &json!([])).is_err()); + } + let call = json!([{"role":"model","parts":[{"functionCall":{"name":"tool","id":"native","args":{}}}]}]); + let map = replay::mapping(&call, None).unwrap(); + assert!(replay::v2::encode(&call, &binding(), &state, "end_turn", "full", &map).is_err()); + for id in [ + lineage.conversation_id.clone(), + lineage.trajectory_id.clone(), + "bad".into(), + ] { + let mut invalid = state.clone(); + invalid.user_execution.active_id = id; + assert!( + replay::v2::encode(&text, &binding(), &invalid, "end_turn", "full", &json!([])) + .is_err() + ); + } + let mut invalid = state; + invalid.user_execution.prior_recorded_id = Some(execution_id(7)); + assert!( + replay::v2::encode(&text, &binding(), &invalid, "end_turn", "full", &json!([])).is_err(), + "first checkpoint has no previous execution" + ); +} + +#[test] +fn antigravity_v2_execution_predecessors_and_termination_are_digest_bound() { + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + let lineage = replay::v2::Lineage::new( + "11111111-1111-4111-8111-111111111111".into(), + "22222222-2222-4222-8222-222222222222".into(), + ) + .unwrap(); + let text = json!([{"role":"model","parts":[{"text":"complete"}]}]); + let first = v2_encode(&text, &binding(), &lineage, 0, "full", &json!([])).unwrap(); + let second = v2_encode(&text, &binding(), &lineage, 1, "full", &json!([])).unwrap(); + let envelope = replay::v2::decode(&first, &binding()).unwrap(); + for (key, value) in [ + ( + "user_execution", + json!({"active_id":execution_id(7),"prior_recorded_id":null}), + ), + ( + "user_execution", + json!({"active_id":execution_id(0),"prior_recorded_id":execution_id(9)}), + ), + ("user_execution", json!({"active_id":execution_id(0)})), + ( + "user_execution", + json!({"active_id":execution_id(0),"prior_recorded_id":null,"extra":true}), + ), + ("checkpoint_stop_reason", json!("max_tokens")), + ] { + let mut modified = envelope.clone(); + modified[key] = value; + let mut blocks = first.clone(); + blocks[0]["signature"] = json!(format!( + "{}{}", + replay::v2::PREFIX, + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&modified).unwrap()) + )); + assert!(replay::v2::decode(&blocks, &binding()).is_err()); + } + let mut wrong_predecessor = v2_state(&lineage, 1); + wrong_predecessor.user_execution.prior_recorded_id = Some(execution_id(7)); + let wrong = replay::v2::encode( + &text, + &binding(), + &wrong_predecessor, + "end_turn", + "full", + &json!([]), + ) + .unwrap(); + assert!(replay::v2::decode_history(&json!([first, wrong]), &binding()).is_err()); + let mut reused = v2_state(&lineage, 2); + reused.user_execution.active_id = execution_id(0); + let wrong = + replay::v2::encode(&text, &binding(), &reused, "end_turn", "full", &json!([])).unwrap(); + assert!(replay::v2::decode_history(&json!([first, second, wrong]), &binding()).is_err()); + let call = json!([{"role":"model","parts":[{"functionCall":{"name":"read_file","id":"native","args":{}},"thoughtSignature":"opaque"}]}]); + let mapping = replay::mapping(&call, None).unwrap(); + let tool_first = replay::v2::encode( + &call, + &binding(), + &v2_state(&lineage, 0), + "tool_use", + "full", + &mapping, + ) + .unwrap(); + let mut reset = v2_state(&lineage, 1); + reset.user_execution.prior_recorded_id = None; + let changed_active = + replay::v2::encode(&text, &binding(), &reset, "end_turn", "full", &json!([])).unwrap(); + assert!( + replay::v2::decode_history(&json!([tool_first, changed_active]), &binding()).is_err(), + "tool continuation cannot reset active execution" + ); +} + +fn metadata_count_context() -> crate::provider::RequestContext { + crate::provider::RequestContext { + req_id: "synthetic-pure-count".into(), + session_id: None, + session_seq: None, + provider: "antigravity".into(), + traffic: None, + monitor: None, + } +} + +async fn counting_provider( + directory: &std::path::Path, + url: &url::Url, + mode: super::antigravity::config::RequestMetadata, + project: Option<&str>, + expires: u64, +) -> (super::antigravity::AntigravityProvider, Store) { + let store = Store::new(directory.join("antigravity/auth.json")); + let lock = store.lock(std::time::Duration::from_secs(1)).await.unwrap(); + store + .save( + &lock, + &Credential { + profile_id: "profile".into(), + project_id: project.map(str::to_owned), + access_token: "public-synthetic-access".into(), + refresh_token: Some("public-synthetic-refresh".into()), + expires_at: expires, + }, + ) + .unwrap(); + drop(lock); + let config = Config { + request_metadata: mode, + base_url: url.clone(), + token_url: url.join("/token").unwrap(), + client_id: Some("public-synthetic-client".into()), + project: project.map(str::to_owned), + ..Config::default() + }; + ( + super::antigravity::AntigravityProvider::with_auth( + super::antigravity::auth::Auth::new(config, store.clone()).unwrap(), + ), + store, + ) +} + +async fn pure_count_response( + provider: &super::antigravity::AntigravityProvider, + body: &crate::anthropic::schema::MessagesRequest, +) -> (http::StatusCode, Value) { + use super::antigravity::request::effects; + use crate::provider::Provider; + let before = provider.count_scope_snapshot(); + effects::reset(); + let response = provider + .handle_count_tokens(body.clone(), metadata_count_context()) + .await; + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024) + .await + .unwrap(); + let value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!( + effects::snapshot(), + [0; 6], + "no credential/profile load, access, factory, clock or native/legacy identity source" + ); + assert_eq!( + provider.count_scope_snapshot(), + before, + "count cannot publish or mutate scope" + ); + (status, value) +} + +#[test] +fn antigravity_agy_count_value_inputs_preserve_v1_v2_semantics_without_effects() { + use super::antigravity::request::{self, CountInputs, ServingScope, effects}; + let lineage = replay::v2::Lineage::new( + "11111111-1111-4111-8111-111111111111".into(), + "22222222-2222-4222-8222-222222222222".into(), + ) + .unwrap(); + let contents = + json!([{"role":"model","parts":[{"text":"prior","thoughtSignature":"untouched"}]}]); + let v1 = replay::encode(&contents, &binding(), "19", "full", &json!([])).unwrap(); + let v2 = v2_encode(&contents, &binding(), &lineage, 0, "full", &json!([])).unwrap(); + let known = ServingScope { + profile_id: "profile".into(), + project_id: "project".into(), + }; + let wrong = ServingScope { + profile_id: "not-the-serving-profile".into(), + project_id: "project".into(), + }; + let mut expected = None; + for blocks in [v1, v2] { + let mut value = request_value(); + value["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":blocks}), + json!({"role":"user","content":"next"}), + ]); + let body: crate::anthropic::schema::MessagesRequest = + serde_json::from_value(value).unwrap(); + let before = serde_json::to_vec(&body).unwrap(); + for scope in [None, Some(&known)] { + effects::reset(); + let inputs = CountInputs { + serving_scope: scope, + configured_project: Some("project"), + }; + let count = request::estimate(&body, inputs).unwrap(); + assert_eq!(request::estimate(&body, inputs).unwrap(), count); + assert!(count > 0); + if let Some(expected) = expected { + assert_eq!( + count, expected, + "opaque/version/state bytes are not semantic token input" + ); + } + expected = Some(count); + assert_eq!( + serde_json::to_vec(&body).unwrap(), + before, + "count never mutates caller history/lineage" + ); + assert_eq!(effects::snapshot(), [0; 6]); + } + effects::reset(); + assert!( + request::estimate( + &body, + CountInputs { + serving_scope: Some(&wrong), + configured_project: Some("project") + } + ) + .is_err() + ); + assert!( + request::estimate( + &body, + CountInputs { + serving_scope: None, + configured_project: Some("other-project") + } + ) + .is_err() + ); + let mut changed_model = body.clone(); + changed_model.model = Some("antigravity/gemini-4-argon-high".into()); + assert!( + request::estimate( + &changed_model, + CountInputs { + serving_scope: None, + configured_project: Some("project") + } + ) + .is_err() + ); + assert_eq!(effects::snapshot(), [0; 6]); + } + effects::reset(); + let fresh = serde_json::from_value(request_value()).unwrap(); + assert!( + request::estimate( + &fresh, + CountInputs { + serving_scope: None, + configured_project: None + } + ) + .unwrap() + > 0, + "unknown scope is estimate-only, not admission" + ); + assert_eq!(effects::snapshot(), [0; 6]); +} + +#[tokio::test(flavor = "current_thread")] +async fn antigravity_agy_count_never_loads_credentials_accesses_or_constructs_factories() { + use super::antigravity::{ + config::RequestMetadata, + request::effects::{self, Kind}, + }; + use crate::provider::Provider; + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + let calls = Arc::new(AtomicUsize::new(0)); + let observed = calls.clone(); + let server = + super::antigravity_http_tests::Server::start(axum::Router::new().fallback(move || { + let observed = observed.clone(); + async move { + observed.fetch_add(1, Ordering::SeqCst); + http::StatusCode::INTERNAL_SERVER_ERROR + } + })) + .await; + let directory = tempfile::tempdir().unwrap(); + let (provider, _) = counting_provider( + directory.path(), + &server.url, + RequestMetadata::Agy, + Some("project"), + u64::MAX, + ) + .await; + let path = directory.path().join("antigravity/auth.json"); + let body = serde_json::from_value(request_value()).unwrap(); + for kind in ["invalid", "unreadable", "missing"] { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if path.exists() { + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + } + } + match kind { + "invalid" => std::fs::write(&path, b"public invalid synthetic auth").unwrap(), + "unreadable" => { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)) + .unwrap(); + } + } + "missing" => std::fs::remove_file(&path).unwrap(), + _ => unreachable!(), + } + let (status, counted) = pure_count_response(&provider, &body).await; + assert_eq!(status, 200, "{kind}"); + assert!(counted["input_tokens"].as_u64().unwrap() > 0); + if kind == "missing" { + assert!(!path.exists()); + } + assert!(provider.count_scope_snapshot().is_none()); + } + let (expired, _) = counting_provider( + directory.path(), + &server.url, + RequestMetadata::Agy, + Some("project"), + 1, + ) + .await; + assert_eq!(pure_count_response(&expired, &body).await.0, 200); + let (undiscovered, _) = counting_provider( + directory.path(), + &server.url, + RequestMetadata::Agy, + None, + u64::MAX, + ) + .await; + assert_eq!(pure_count_response(&undiscovered, &body).await.0, 200); + assert_eq!( + calls.load(Ordering::SeqCst), + 0, + "no refresh/discovery/generation transport calls" + ); + let (legacy, _) = counting_provider( + directory.path(), + &server.url, + RequestMetadata::Legacy, + Some("project"), + u64::MAX, + ) + .await; + effects::reset(); + let response = legacy + .handle_count_tokens(body, metadata_count_context()) + .await; + assert_eq!(response.status(), 200); + assert_eq!( + effects::count(Kind::CredentialLoad), + 1, + "legacy file semantics retained and spy is live" + ); + assert_eq!( + effects::count(Kind::LegacyId), + 2, + "legacy nonce/outer identity semantics retained and spy is live" + ); + assert_eq!(effects::count(Kind::Access), 0); +} + +#[tokio::test(flavor = "current_thread")] +async fn antigravity_agy_count_claims_are_estimates_never_authoritative_scope() { + use super::antigravity::config::RequestMetadata; + use crate::provider::Provider; + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + let calls = Arc::new(AtomicUsize::new(0)); + let observed = calls.clone(); + let server = + super::antigravity_http_tests::Server::start(axum::Router::new().fallback(move || { + let observed = observed.clone(); + async move { + observed.fetch_add(1, Ordering::SeqCst); + http::StatusCode::INTERNAL_SERVER_ERROR + } + })) + .await; + let directory = tempfile::tempdir().unwrap(); + let (provider, _) = counting_provider( + directory.path(), + &server.url, + RequestMetadata::Agy, + Some("project"), + u64::MAX, + ) + .await; + let lineage = replay::v2::Lineage::new( + "11111111-1111-4111-8111-111111111111".into(), + "22222222-2222-4222-8222-222222222222".into(), + ) + .unwrap(); + let mut claim = binding(); + claim.profile_id = "untrusted-carrier-claim".into(); + let blocks = v2_encode( + &json!([{"role":"model","parts":[{"text":"prior"}]}]), + &claim, + &lineage, + 0, + "full", + &json!([]), + ) + .unwrap(); + let mut body = request_value(); + body["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":blocks}), + json!({"role":"user","content":"next"}), + ]); + let body = serde_json::from_value(body).unwrap(); + let (status, counted) = pure_count_response(&provider, &body).await; + assert_eq!(status, 200); + assert!(counted["input_tokens"].as_u64().unwrap() > 0); + assert!( + provider.count_scope_snapshot().is_none(), + "untrusted carrier cannot bootstrap authoritative scope" + ); + let rejected = provider + .handle_messages(body, metadata_count_context()) + .await; + assert_eq!( + rejected.status(), + 400, + "generation still validates actual native owner/profile binding" + ); + assert!(provider.count_scope_snapshot().is_none()); + assert_eq!(calls.load(Ordering::SeqCst), 0); +} + +#[tokio::test(flavor = "current_thread")] +async fn antigravity_agy_count_scope_is_published_only_by_validated_serving() { + use super::antigravity::{config::RequestMetadata, request::ServingScope}; + use crate::provider::Provider; + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + let calls = Arc::new(AtomicUsize::new(0)); + let observed = calls.clone(); + let frame = format!( + "data: {}\n\n", + json!({"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"completed"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":2,"totalTokenCount":5}}}) + ); + let server = + super::antigravity_http_tests::Server::start(axum::Router::new().fallback(move || { + let observed = observed.clone(); + let frame = frame.clone(); + async move { + observed.fetch_add(1, Ordering::SeqCst); + ([("content-type", "text/event-stream")], frame) + } + })) + .await; + let directory = tempfile::tempdir().unwrap(); + let (provider, _) = counting_provider( + directory.path(), + &server.url, + RequestMetadata::Agy, + Some("project"), + u64::MAX, + ) + .await; + assert!(provider.count_scope_snapshot().is_none()); + let generated = provider + .handle_messages( + serde_json::from_value(request_value()).unwrap(), + metadata_count_context(), + ) + .await; + assert_eq!(generated.status(), 200); + let generated: Value = serde_json::from_slice( + &axum::body::to_bytes(generated.into_body(), 1024 * 1024) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!( + provider.count_scope_snapshot(), + Some(ServingScope { + profile_id: "profile".into(), + project_id: "project".into() + }) + ); + std::fs::write( + directory.path().join("antigravity/auth.json"), + b"public invalid synthetic auth after successful serving", + ) + .unwrap(); + let mut body = request_value(); + body["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":generated["content"]}), + json!({"role":"user","content":"next"}), + ]); + let body = serde_json::from_value(body).unwrap(); + assert_eq!(pure_count_response(&provider, &body).await.0, 200); + let mut foreign_scope = binding(); + foreign_scope.profile_id = "untrusted-carrier-claim".into(); + let lineage = replay::v2::Lineage::new( + "11111111-1111-4111-8111-111111111111".into(), + "22222222-2222-4222-8222-222222222222".into(), + ) + .unwrap(); + let foreign = v2_encode( + &json!([{"role":"model","parts":[{"text":"foreign"}]}]), + &foreign_scope, + &lineage, + 0, + "full", + &json!([]), + ) + .unwrap(); + let mut foreign_body = request_value(); + foreign_body["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":foreign}), + json!({"role":"user","content":"next"}), + ]); + assert_eq!( + pure_count_response(&provider, &serde_json::from_value(foreign_body).unwrap()) + .await + .0, + 400, + "a carrier cannot replace known serving scope" + ); + let rejected = provider + .handle_messages(body, metadata_count_context()) + .await; + assert_eq!( + rejected.status(), + 401, + "count snapshot never authorizes generation" + ); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "count and failed auth do not call upstream" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn antigravity_agy_failed_and_cancelled_turns_do_not_publish_count_scope() { + use super::antigravity::config::RequestMetadata; + use crate::provider::Provider; + use futures_util::StreamExt; + for cancel in [false, true] { + let server=super::antigravity_http_tests::Server::start(axum::Router::new().fallback(move || async move { + let body=if cancel { + let stream=futures_util::stream::once(async { Ok::<_,std::convert::Infallible>(bytes::Bytes::from_static(b"data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"incomplete\"}]}}]}}\n\n")) }).chain(futures_util::stream::pending()); + axum::body::Body::from_stream(stream) + } else { axum::body::Body::from("data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":false}}]}}\n\n") }; + axum::response::Response::builder().header("content-type","text/event-stream").body(body).unwrap() + })).await; + let directory = tempfile::tempdir().unwrap(); + let (provider, _) = counting_provider( + directory.path(), + &server.url, + RequestMetadata::Agy, + Some("project"), + u64::MAX, + ) + .await; + let mut body = request_value(); + body["stream"] = json!(cancel); + let response = provider + .handle_messages( + serde_json::from_value(body).unwrap(), + metadata_count_context(), + ) + .await; + assert_eq!(response.status(), if cancel { 200 } else { 502 }); + drop(response); + tokio::task::yield_now().await; + assert!( + provider.count_scope_snapshot().is_none(), + "no successful native checkpoint, no authoritative count scope" + ); + } +} + #[test] fn antigravity_absent_native_args_project_empty_without_changing_replay() { let contents = json!([{"role":"model","parts":[{"functionCall":{"name":"empty"},"thoughtSignature":"untouched"}]}]); diff --git a/tests/antigravity_schema_parity.rs b/tests/antigravity_schema_parity.rs index 9e03965d..07b853d6 100644 --- a/tests/antigravity_schema_parity.rs +++ b/tests/antigravity_schema_parity.rs @@ -144,6 +144,14 @@ cases! { schema_parity_numeric_variants_and_budgets => "numeric-budgets", schema_parity_original_admission_controls => "admission", schema_parity_project_admission_control => "project-admission", + schema_parity_observed_first_agent_metadata => "first-agent", + schema_parity_observed_two_user_text_metadata => "two-user-text", + schema_parity_observed_three_user_text_recurrence => "three-user-text", + schema_parity_observed_last_recorded_execution => "last-recorded-execution", + schema_parity_observed_read_tool_metadata => "read-tool-metadata", + schema_parity_observed_invalid_turn_never_checkpoints => "invalid-native-checkpoint", + schema_parity_observed_count_purity => "observed-count-purity", + schema_parity_source_all_tier_metadata => "source-all-tier-metadata", } #[test] @@ -755,10 +763,713 @@ async fn exercise(case: &str, f: &mut Fixture) -> Result<()> { "numeric-budgets" => numeric_budgets(f).await, "admission" => admission(f).await, "project-admission" => project_admission(f).await, + "first-agent" => first_agent_metadata(f).await, + "two-user-text" => two_user_text_metadata(f).await, + "three-user-text" => user_text_sequence(f, false).await, + "last-recorded-execution" => user_text_sequence(f, true).await, + "read-tool-metadata" => read_tool_metadata(f).await, + "invalid-native-checkpoint" => invalid_native_checkpoint(f).await, + "observed-count-purity" => observed_count_purity(f).await, + "source-all-tier-metadata" => source_all_tier_metadata(f).await, _ => bail!("unknown fixed fixture case"), } } +async fn first_agent_metadata(f: &mut Fixture) -> Result<()> { + let config_path = f + .auth_path + .parent() + .and_then(|p| p.parent()) + .context("synthetic config parent")? + .join("config.json"); + let original = std::fs::read(&config_path)?; + let mut config: Value = serde_json::from_slice(&original)?; + config["antigravity"]["requestMetadata"] = json!("agy"); + std::fs::write(&config_path, serde_json::to_vec(&config)?)?; + tokio::time::timeout(STEP_LIMIT, f.provider.finish_maintenance()).await??; + f.provider = AntigravityProvider::new(); + let result = async { + let mut previous_conversation = None; + for stream in [false, true] { + let mut body = request(stream); + body["model"] = json!("antigravity/gemini-4-argon-low"); + body["max_tokens"] = json!(65536); + body["system"] = json!("Synthetic metadata system instruction."); + let exchange = f.exchange(body, json!([{"text":"Synthetic metadata completion."}])).await?; + exchange.success(stream)?; + let wire = exchange.wire()?; + ensure!(wire["model"] == "gemini-4-argon-low" && wire["requestType"] == "agent" && wire["userAgent"] == "antigravity", "observed agent envelope"); + let request = &wire["request"]; + exact(&request["generationConfig"], &json!({"maxOutputTokens":65536,"thinkingConfig":{"includeThoughts":true,"thinkingLevel":"LOW"}}), "observed generation controls")?; + ensure!(request["sessionId"] == "-3750763034362895579", "observed signed empty-input FNV session"); + let labels = request["labels"].as_object().context("observed lineage labels")?; + ensure!(labels.len() == 9, "exact observed label set"); + for (key, expected) in [ + ("model_enum", "MODEL_PLACEHOLDER_M419"), + ("used_claude", "false"), + ("used_claude_conservative", "false"), + ("used_non_gemini_model", "false"), + ("last_step_index", "0"), + ] { + ensure!(labels.get(key).and_then(Value::as_str) == Some(expected), "observed string-valued label"); + } + let conversation = labels["cascade_id"].as_str().context("cascade ID")?; + let trajectory = labels["trajectory_id"].as_str().context("trajectory ID")?; + for value in [conversation, trajectory] { + let id = uuid::Uuid::parse_str(value)?; + ensure!(id.get_version_num() == 4 && id.to_string() == value, "canonical UUIDv4 metadata"); + } + ensure!(labels["root_cascade_id"] == conversation && conversation != trajectory, "root and trajectory relationships"); + ensure!(labels["request_id"] == format!("{trajectory}-0"), "first request uses zero-based generation counter"); + let outer = wire["requestId"].as_str().context("outer request ID")?; + let components: Vec<_> = outer.split('/').collect(); + ensure!(components.len() == 5 && components[0] == "agent" && components[1] == conversation && components[3] == trajectory && components[4] == "1", "outer request component relationships"); + let timestamp = components[2].parse::()?; + ensure!((946_684_800_000..4_102_444_800_000).contains(×tamp) && timestamp.to_string() == components[2], "canonical plausible Unix milliseconds"); + ensure!(previous_conversation.as_deref() != Some(conversation), "independent fresh conversations"); + previous_conversation = Some(conversation.to_owned()); + let blocks = exchange.blocks()?; + let signature = blocks[0]["signature"].as_str().context("versioned native carrier")?; + let encoded = signature.strip_prefix("ccp:antigravity:v2:").context("explicit V2 carrier, not V1 reinterpretation")?; + let raw = URL_SAFE_NO_PAD.decode(encoded)?; + ensure!(raw.len() <= BODY_LIMIT, "synthetic V2 carrier bound"); + let saved: Value = serde_json::from_slice(&raw)?; + ensure!(saved["version"] == 2 && saved["profile_id"] == PROFILE && saved["project_id"] == PROJECT, "V2 scope and version"); + ensure!(saved["native_model"] == "gemini-4-argon-low" && saved["backend_session_id"] == "-3750763034362895579", "V2 model and separate backend session"); + exact(&saved["lineage"], &json!({"conversation_id":conversation,"trajectory_id":trajectory}), "V2 logical UUIDs")?; + ensure!(saved.get("session_id").is_none(), "V2 must not reinterpret the V1 session field"); + ensure!(wire.get("enabledCreditTypes").is_none() && request.get("enabledCreditTypes").is_none(), "no invented billing consent"); + exact(&saved["contents"], &json!([{"role":"model","parts":[{"text":"Synthetic metadata completion."}]}]), "unchanged native response contents")?; + let mut continuation = self::request(stream); + continuation["model"] = json!("antigravity/gemini-4-argon-low"); + continuation["messages"].as_array_mut().context("messages")?.extend([ + json!({"role":"assistant","content":blocks}), + json!({"role":"user","content":"Unsupported next transition."}), + ]); + config["antigravity"]["requestMetadata"] = json!("legacy"); + std::fs::write(&config_path, serde_json::to_vec(&config)?)?; + tokio::time::timeout(STEP_LIMIT, f.provider.finish_maintenance()).await??; + f.provider = AntigravityProvider::new(); + let rejected = f.exchange(continuation, json!([{"text":"must not dispatch"}])).await?; + ensure!(rejected.status == StatusCode::BAD_REQUEST && rejected.wire.is_none(), "V2 continuation must never downgrade under legacy policy"); + let error: Value = serde_json::from_slice(&rejected.bytes)?; + ensure!(error["error"]["message"].as_str().is_some_and(|s| s.contains("V2 history requires the agy request metadata policy")), "explicit V2 policy boundary"); + config["antigravity"]["requestMetadata"] = json!("agy"); + std::fs::write(&config_path, serde_json::to_vec(&config)?)?; + tokio::time::timeout(STEP_LIMIT, f.provider.finish_maintenance()).await??; + f.provider = AntigravityProvider::new(); + } + Ok::<(), anyhow::Error>(()) + }.await; + std::fs::write(config_path, original)?; + result +} + +// Official CLI one-process/two-user trace, served LOCAL_SYNTHETIC_SSE (no +// Google-origin generation). This checks its exact known ID/counter/content +// relations, not hidden Go state. The later three-user/source fixture below +// separately checks the full last_execution_id predecessor relationship. +async fn two_user_text_metadata(f: &mut Fixture) -> Result<()> { + let config_path = f + .auth_path + .parent() + .and_then(|p| p.parent()) + .context("synthetic config parent")? + .join("config.json"); + let original = std::fs::read(&config_path)?; + let mut config: Value = serde_json::from_slice(&original)?; + config["antigravity"]["requestMetadata"] = json!("agy"); + std::fs::write(&config_path, serde_json::to_vec(&config)?)?; + tokio::time::timeout(STEP_LIMIT, f.provider.finish_maintenance()).await??; + f.provider = AntigravityProvider::new(); + let result = async { + for stream in [false, true] { + let mut body = request(stream); + body["model"] = json!("antigravity/gemini-4-argon-low"); + body["max_tokens"] = json!(65536); + body["system"] = json!("Synthetic two-user system instruction."); + body["messages"][0]["content"] = json!("Synthetic first user."); + let first = f.exchange(body.clone(), json!([{"text":"Synthetic first completion."}])).await?; + first.success(stream)?; + let first_blocks = first.blocks()?; + body["messages"].as_array_mut().context("messages")?.extend([ + json!({"role":"assistant","content":first_blocks}), + json!({"role":"user","content":"Synthetic second user."}), + ]); + let second = f.exchange(body.clone(), json!([{"text":"Synthetic second completion."}])).await?; + if second.status != StatusCode::OK { + let error: Value = serde_json::from_slice(&second.bytes)?; + bail!("observed second-user text continuation must succeed; status={} error={}", second.status, error["error"]["message"]); + } + second.success(stream)?; + let first_wire = first.wire()?; + let second_wire = second.wire()?; + let conversation = first_wire["request"]["labels"]["cascade_id"].as_str().context("conversation")?; + let trajectory = first_wire["request"]["labels"]["trajectory_id"].as_str().context("trajectory")?; + ensure!(conversation != trajectory, "distinct conversation/trajectory"); + for id in [conversation, trajectory] { + let uuid = uuid::Uuid::parse_str(id)?; + ensure!(uuid.get_version_num() == 4 && uuid.get_variant() == uuid::Variant::RFC4122 && uuid.to_string() == id, "canonical independent UUIDv4"); + } + for (wire, generation, last_step, outer_step) in [(first_wire, "0", "0", "1"), (second_wire, "1", "2", "3")] { + ensure!(wire["model"] == "gemini-4-argon-low" && wire["project"] == PROJECT && wire["requestType"] == "agent" && wire["userAgent"] == "antigravity", "same actual bound model/project/type"); + ensure!(wire["request"]["sessionId"] == "-3750763034362895579", "same signed empty FNV backend session"); + let labels = &wire["request"]["labels"]; + for (key, value) in [("model_enum", "MODEL_PLACEHOLDER_M419"), ("used_claude", "false"), ("used_claude_conservative", "false"), ("used_non_gemini_model", "false"), ("cascade_id", conversation), ("root_cascade_id", conversation), ("trajectory_id", trajectory), ("last_step_index", last_step)] { + ensure!(labels[key].as_str() == Some(value), "observed stable string-valued label {key}"); + } + ensure!(labels["request_id"] == format!("{trajectory}-{generation}"), "generation suffix must not reset at second user"); + let outer = wire["requestId"].as_str().context("outer ID")?; + let components: Vec<_> = outer.split('/').collect(); + ensure!(components.len() == 5 && components[0] == "agent" && components[1] == conversation && components[3] == trajectory && components[4] == outer_step, "observed outer step and stable identities"); + let millis = components[2].parse::()?; + ensure!((946_684_800_000..4_102_444_800_000).contains(&millis) && millis.to_string() == components[2], "canonical Unix milliseconds"); + exact(&wire["request"]["generationConfig"], &json!({"maxOutputTokens":65536,"thinkingConfig":{"includeThoughts":true,"thinkingLevel":"LOW"}}), "unchanged generation config")?; + exact(&wire["request"]["systemInstruction"], &json!({"role":"user","parts":[{"text":"Synthetic two-user system instruction."}]}), "unchanged system role")?; + } + exact(&first_wire["request"]["contents"], &json!([{"role":"user","parts":[{"text":"Synthetic first user."}]}]), "first native user contents")?; + exact(&second_wire["request"]["contents"], &json!([ + {"role":"user","parts":[{"text":"Synthetic first user."}]}, + {"role":"model","parts":[{"text":"Synthetic first completion."}]}, + {"role":"user","parts":[{"text":"Synthetic second user."}]} + ]), "exact observed user/model/user history; no synthetic role rewriting")?; + let second_blocks = second.blocks()?; + for (blocks, index, text) in [(&first_blocks, 0, "Synthetic first completion."), (&second_blocks, 1, "Synthetic second completion.")] { + let encoded = blocks[0]["signature"].as_str().context("V2 signature")?.strip_prefix("ccp:antigravity:v2:").context("V2 prefix")?; + let raw = URL_SAFE_NO_PAD.decode(encoded)?; + let saved: Value = serde_json::from_slice(&raw)?; + ensure!(saved["version"] == 2 && saved["profile_id"] == PROFILE && saved["project_id"] == PROJECT && saved["native_model"] == "gemini-4-argon-low", "V2 actual scope"); + exact(&saved["lineage"], &json!({"conversation_id":conversation,"trajectory_id":trajectory}), "same carried logical IDs")?; + ensure!(saved["native_checkpoint_index"] == index, "proxy-owned completed checkpoint ordinal, not hidden Go state"); + ensure!(saved["backend_session_id"] == "-3750763034362895579", "separate fixed backend session"); + exact(&saved["contents"], &json!([{"role":"model","parts":[{"text":text}]}]), "original native checkpoint contents")?; + } + // Normal text recurrence is separately observed. Unsupported media + // must still reject before identity allocation or native dispatch. + body["messages"].as_array_mut().context("messages")?.extend([ + json!({"role":"assistant","content":second_blocks}), + json!({"role":"user","content":[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"synthetic"}}]}), + ]); + let rejected = f.exchange(body, json!([{"text":"must not dispatch"}])).await?; + ensure!(rejected.status == StatusCode::BAD_REQUEST && rejected.wire.is_none(), "unsupported media must reject without reminting or dispatch"); + } + Ok::<(), anyhow::Error>(()) + }.await; + std::fs::write(config_path, original)?; + result +} + +fn uuid_v4(value: &str) -> Result<()> { + let id = uuid::Uuid::parse_str(value)?; + ensure!( + id.get_version_num() == 4 + && id.get_variant() == uuid::Variant::RFC4122 + && id.to_string() == value, + "canonical lowercase RFC4122 UUIDv4" + ); + Ok(()) +} + +async fn observed_policy(f: &mut Fixture) -> Result<(PathBuf, Vec)> { + let path = f + .auth_path + .parent() + .and_then(|p| p.parent()) + .context("synthetic config parent")? + .join("config.json"); + let original = std::fs::read(&path)?; + let mut config: Value = serde_json::from_slice(&original)?; + config["antigravity"]["requestMetadata"] = json!("agy"); + std::fs::write(&path, serde_json::to_vec(&config)?)?; + tokio::time::timeout(STEP_LIMIT, f.provider.finish_maintenance()).await??; + f.provider = AntigravityProvider::new(); + Ok((path, original)) +} + +fn v2_saved(blocks: &Value) -> Result { + let encoded = blocks[0]["signature"] + .as_str() + .context("native carrier")? + .strip_prefix("ccp:antigravity:v2:") + .context("V2 carrier prefix")?; + let raw = URL_SAFE_NO_PAD.decode(encoded)?; + ensure!(raw.len() <= BODY_LIMIT, "bounded synthetic carrier"); + Ok(serde_json::from_slice(&raw)?) +} + +fn observed_metadata( + wire: &Value, + conversation: &str, + trajectory: &str, + generation: u64, +) -> Result<()> { + let (suffix, last_step, outer_step) = match generation { + 0 => ("0", "0", "1"), + 1 => ("1", "2", "3"), + 2 => ("2", "4", "5"), + _ => bail!("fixture only asserts independently observed progression"), + }; + ensure!( + wire["model"] == "gemini-4-argon-low" + && wire["project"] == PROJECT + && wire["requestType"] == "agent" + && wire["userAgent"] == "antigravity", + "bound native agent envelope" + ); + ensure!( + wire["request"]["sessionId"] == "-3750763034362895579", + "signed empty FNV session" + ); + let labels = &wire["request"]["labels"]; + for (key, value) in [ + ("model_enum", "MODEL_PLACEHOLDER_M419"), + ("used_claude", "false"), + ("used_claude_conservative", "false"), + ("used_non_gemini_model", "false"), + ("cascade_id", conversation), + ("root_cascade_id", conversation), + ("trajectory_id", trajectory), + ("last_step_index", last_step), + ] { + ensure!( + labels[key].as_str() == Some(value), + "observed string-valued label {key}" + ); + } + ensure!( + labels["request_id"] == format!("{trajectory}-{suffix}"), + "observed generation suffix" + ); + let components: Vec<_> = wire["requestId"] + .as_str() + .context("outer request ID")? + .split('/') + .collect(); + ensure!( + components.len() == 5 + && components[0] == "agent" + && components[1] == conversation + && components[3] == trajectory + && components[4] == outer_step, + "observed outer request relationships" + ); + let millis = components[2].parse::()?; + ensure!( + (946_684_800_000..4_102_444_800_000).contains(&millis) + && millis.to_string() == components[2], + "canonical Unix milliseconds" + ); + exact( + &wire["request"]["generationConfig"], + &json!({"maxOutputTokens":65536,"thinkingConfig":{"includeThoughts":true,"thinkingLevel":"LOW"}}), + "observed LOW generation config", + )?; + Ok(()) +} + +// Three successful ordinary-user executions and the public latest-recorded +// executor-metadata accessor establish these relations. IDs here are our +// bounded proxy-owned execution representation, not copied private native IDs +// or a claim about native allocation/error/cancellation timing. +async fn user_text_sequence(f: &mut Fixture, check_execution: bool) -> Result<()> { + let (path, original) = observed_policy(f).await?; + let result = async { + for stream in [false, true] { + let mut body = request(stream); + body["model"] = json!("antigravity/gemini-4-argon-low"); + body["max_tokens"] = json!(65536); + body["messages"][0]["content"] = json!("Synthetic user 0."); + let mut expected_contents = + vec![json!({"role":"user","parts":[{"text":"Synthetic user 0."}]})]; + let mut conversation = None; + let mut trajectory = None; + let mut saved_turns: Vec = Vec::new(); + for generation in 0..3_u64 { + let text = format!("Synthetic completion {generation}."); + let exchange = f.exchange(body.clone(), json!([{"text":text}])).await?; + if exchange.status != StatusCode::OK { + let error: Value = serde_json::from_slice(&exchange.bytes)?; + bail!( + "observed user generation {generation} must succeed; status={} error={}", + exchange.status, + error["error"]["message"] + ); + } + exchange.success(stream)?; + let wire = exchange.wire()?; + if generation == 0 { + conversation = Some( + wire["request"]["labels"]["cascade_id"] + .as_str() + .context("conversation")? + .to_owned(), + ); + trajectory = Some( + wire["request"]["labels"]["trajectory_id"] + .as_str() + .context("trajectory")? + .to_owned(), + ); + uuid_v4(conversation.as_deref().unwrap())?; + uuid_v4(trajectory.as_deref().unwrap())?; + ensure!(conversation != trajectory, "independent lineage UUIDs"); + } + observed_metadata( + wire, + conversation.as_deref().unwrap(), + trajectory.as_deref().unwrap(), + generation, + )?; + exact( + &wire["request"]["contents"], + &json!(expected_contents), + "exact alternating user/model history", + )?; + let blocks = exchange.blocks()?; + let saved = v2_saved(&blocks)?; + ensure!( + saved["native_checkpoint_index"] == generation, + "proxy native checkpoint ordinal" + ); + exact( + &saved["contents"], + &json!([{"role":"model","parts":[{"text":text}]}]), + "original native contents", + )?; + if check_execution { + let labels = wire["request"]["labels"].as_object().context("labels")?; + if generation == 0 { + ensure!( + labels.len() == 9 && !labels.contains_key("last_execution_id"), + "first request has no recorded execution" + ); + } else { + // Reach the actual wire failure before inspecting new + // checkpoint fields, so pre-implementation RED is causal. + let last = labels + .get("last_execution_id") + .and_then(Value::as_str) + .context("observed last recorded user execution label")?; + uuid_v4(last)?; + ensure!(labels.len() == 10, "exact observed later-user label set"); + let previous = + saved_turns.last().context("previous completed execution")?; + ensure!( + previous["user_execution"]["active_id"] == last + && previous["checkpoint_stop_reason"] == "end_turn", + "label reuses previous recorded completion, never a renderer-time UUID" + ); + ensure!( + saved["user_execution"]["active_id"] != last, + "new ordinary user execution is distinct" + ); + } + } + saved_turns.push(saved); + if generation < 2 { + let next = format!("Synthetic user {}.", generation + 1); + body["messages"] + .as_array_mut() + .context("messages")? + .extend([ + json!({"role":"assistant","content":blocks}), + json!({"role":"user","content":next}), + ]); + expected_contents.push(json!({"role":"model","parts":[{"text":text}]})); + expected_contents.push(json!({"role":"user","parts":[{"text":next}]})); + } + } + if check_execution { + let mut seen = std::collections::HashSet::new(); + let mut recorded: Option = None; + for saved in saved_turns { + let active = saved["user_execution"]["active_id"] + .as_str() + .context("proxy-owned user execution ID")?; + uuid_v4(active)?; + ensure!( + Some(active) != conversation.as_deref() + && Some(active) != trajectory.as_deref() + && seen.insert(active.to_owned()), + "distinct execution identities" + ); + ensure!( + saved["checkpoint_stop_reason"] == "end_turn" + && saved["user_execution"]["prior_recorded_id"].as_str() == recorded.as_deref(), + "checkpoint binds the predecessor; validated completion records active for the NEXT user" + ); + recorded = Some(active.to_owned()); + } + } + } + Ok::<(), anyhow::Error>(()) + } + .await; + std::fs::write(path, original)?; + result +} + +// Genuine one-user READ loop: local scripted SSE, zero origin generations. +// Both requests omit last_execution_id, despite model generation 0 -> 1. +async fn read_tool_metadata(f: &mut Fixture) -> Result<()> { + const SIGNATURE: &str = "c3ludGhldGljLXNpZ25hdHVyZQ=="; + const NATIVE_ID: &str = "synthetic-read-call-1"; + let (path, original) = observed_policy(f).await?; + let result = async { + for stream in [false, true] { + let mut body = request(stream); + body["model"] = json!("antigravity/gemini-4-argon-low"); + body["max_tokens"] = json!(65536); + body["tools"] = json!([tool("read_file", json!({"type":"object","properties":{"Path":{"type":"string"}},"required":["Path"],"additionalProperties":false}))]); + let native_part = json!({"functionCall":{"name":"read_file","args":{"Path":"/synthetic/owned-fixture.txt"},"id":NATIVE_ID},"thoughtSignature":SIGNATURE}); + let first = f.exchange(body.clone(), json!([native_part])).await?; + first.success(stream)?; + let blocks = first.blocks()?; + let local = blocks[1]["id"].as_str().context("local tool ID")?; + ensure!(blocks[1]["type"] == "tool_use" && local != NATIVE_ID, "preserve original native/local tool mapping"); + exact(&blocks[1]["input"], &json!({"Path":"/synthetic/owned-fixture.txt"}), "original caller arguments")?; + body["messages"].as_array_mut().context("messages")?.extend([ + json!({"role":"assistant","content":blocks}), + json!({"role":"user","content":[{"type":"tool_result","tool_use_id":local,"content":"Synthetic read fixture marker."}]}) + ]); + let second = f.exchange(body, json!([{"text":"Synthetic read complete."}])).await?; + if second.status != StatusCode::OK { + let error: Value = serde_json::from_slice(&second.bytes)?; + bail!("observed read-tool continuation must succeed; status={} error={}", second.status, error["error"]["message"]); + } + second.success(stream)?; + let first_wire = first.wire()?; + let second_wire = second.wire()?; + let conversation = first_wire["request"]["labels"]["cascade_id"].as_str().context("conversation")?; + let trajectory = first_wire["request"]["labels"]["trajectory_id"].as_str().context("trajectory")?; + for (wire, generation) in [(first_wire, 0), (second_wire, 1)] { + observed_metadata(wire, conversation, trajectory, generation)?; + let labels = wire["request"]["labels"].as_object().context("labels")?; + ensure!(labels.len() == 9 && !labels.contains_key("last_execution_id"), "no recorded execution within first user's tool loop"); + } + let mut expected = first_wire.clone(); + let components: Vec<_> = second_wire["requestId"].as_str().context("second request ID")?.split('/').collect(); + expected["requestId"] = json!(format!("agent/{conversation}/{}/{trajectory}/3", components[2])); + expected["request"]["labels"]["request_id"] = json!(format!("{trajectory}-1")); + expected["request"]["labels"]["last_step_index"] = json!("2"); + expected["request"]["contents"] = json!([ + {"role":"user","parts":[{"text":"Synthetic schema request."}]}, + {"role":"model","parts":[native_part]}, + {"role":"model","parts":[{"functionResponse":{"name":"read_file","response":{"output":"Synthetic read fixture marker."},"id":NATIVE_ID}}]} + ]); + exact(second_wire, &expected, "complete observed read-loop wire, including separate MODEL functionResponse and untouched signature/native ID")?; + let first_saved = v2_saved(&blocks)?; + exact(&first_saved["contents"], &json!([{"role":"model","parts":[native_part]}]), "raw call/signature checkpoint")?; + let second_saved = v2_saved(&second.blocks()?)?; + ensure!(first_saved["checkpoint_stop_reason"] == "tool_use" && first_saved["user_execution"]["prior_recorded_id"].is_null(), "tool call is not a completed user execution"); + let active = first_saved["user_execution"]["active_id"].as_str().context("active execution")?; + uuid_v4(active)?; + ensure!(second_saved["user_execution"]["active_id"] == active && second_saved["user_execution"]["prior_recorded_id"].is_null() && second_saved["checkpoint_stop_reason"] == "end_turn", "same execution survives tool loop and records only after validated final completion"); + } + Ok::<(), anyhow::Error>(()) + }.await; + std::fs::write(path, original)?; + result +} + +async fn invalid_native_checkpoint(f: &mut Fixture) -> Result<()> { + let (path, original) = observed_policy(f).await?; + let result = async { + for stream in [false, true] { + let mut body = request(stream); + body["model"] = json!("antigravity/gemini-4-argon-low"); + let exchange = f + .exchange(body, json!({"synthetic":"invalid native parts type"})) + .await?; + ensure!( + exchange.wire.is_some(), + "actual native request occurred exactly once" + ); + let output = std::str::from_utf8(&exchange.bytes)?; + ensure!( + !output.contains("ccp:antigravity:v2:"), + "invalid upstream turn must never fabricate a successful checkpoint" + ); + if stream { + ensure!( + exchange.status == StatusCode::OK + && output.contains("event: error\n") + && !output.contains("event: message_stop\n"), + "streaming invalid turn is terminal error only" + ); + } else { + ensure!( + exchange.status == StatusCode::BAD_GATEWAY, + "invalid turn must fail" + ); + let error: Value = serde_json::from_slice(&exchange.bytes)?; + ensure!( + error["error"]["message"] + .as_str() + .is_some_and(|s| s.contains("invalid native parts")), + "bounded native validation error" + ); + } + } + Ok::<(), anyhow::Error>(()) + } + .await; + std::fs::write(path, original)?; + result +} + +async fn observed_count_purity(f: &mut Fixture) -> Result<()> { + let (path, original) = observed_policy(f).await?; + let result = async { + let mut body = request(false); + body["model"] = json!("antigravity/gemini-4-argon-low"); + let mut estimates = Vec::new(); + for kind in ["invalid", "unreadable", "missing"] { + if f.auth_path.exists() { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&f.auth_path, std::fs::Permissions::from_mode(0o600))?; + } + } + match kind { + "invalid" => std::fs::write(&f.auth_path, b"public synthetic invalid auth JSON")?, + "unreadable" => { + std::fs::write(&f.auth_path, &f.auth_bytes)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + &f.auth_path, + std::fs::Permissions::from_mode(0o000), + )?; + } + } + "missing" => std::fs::remove_file(&f.auth_path)?, + _ => unreachable!(), + } + let response = tokio::time::timeout( + STEP_LIMIT, + f.provider + .handle_count_tokens(serde_json::from_value(body.clone())?, context()), + ) + .await?; + let status = response.status(); + let raw = to_bytes(response.into_body(), BODY_LIMIT).await?; + let counted: Value = serde_json::from_slice(&raw)?; + ensure!( + status == StatusCode::OK, + "selected agy count must ignore {kind} synthetic auth; status={status} error={}", + counted["error"]["message"] + ); + ensure!( + counted["input_tokens"].as_u64().is_some_and(|n| n > 0), + "positive local estimate" + ); + estimates.push(counted["input_tokens"].clone()); + ensure!( + f.state.lock().unwrap().seen.is_empty(), + "count must not dispatch, discover or refresh" + ); + if kind == "missing" { + ensure!(!f.auth_path.exists(), "count must not create an auth store"); + } + } + ensure!( + estimates.iter().all(|n| *n == estimates[0]), + "estimate does not depend on credential contents/permissions/presence" + ); + // New policy must not silently change the default/legacy count behavior. + std::fs::write(&f.auth_path, b"public synthetic invalid auth JSON")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&f.auth_path, std::fs::Permissions::from_mode(0o600))?; + } + let mut config: Value = serde_json::from_slice(&std::fs::read(&path)?)?; + config["antigravity"]["requestMetadata"] = json!("legacy"); + std::fs::write(&path, serde_json::to_vec(&config)?)?; + tokio::time::timeout(STEP_LIMIT, f.provider.finish_maintenance()).await??; + f.provider = AntigravityProvider::new(); + let legacy = tokio::time::timeout( + STEP_LIMIT, + f.provider + .handle_count_tokens(serde_json::from_value(body)?, context()), + ) + .await?; + ensure!( + legacy.status() == StatusCode::UNAUTHORIZED, + "legacy profile validation remains unchanged" + ); + Ok::<(), anyhow::Error>(()) + } + .await; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if f.auth_path.exists() { + std::fs::set_permissions(&f.auth_path, std::fs::Permissions::from_mode(0o600))?; + } + } + std::fs::write(&f.auth_path, &f.auth_bytes)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&f.auth_path, std::fs::Permissions::from_mode(0o600))?; + } + std::fs::write(path, original)?; + result +} + +// Synthetic coverage of existing selectors and compiled public enum facts. +// This is NOT genuine HIGH/MEDIUM runtime/entitlement evidence; the official +// runtime reference remains LOW-only. Each tier starts an independent history. +async fn source_all_tier_metadata(f: &mut Fixture) -> Result<()> { + let (path, original) = observed_policy(f).await?; + let result = async { + for stream in [false, true] { + for (model, enumeration, level) in [ + ("gemini-4-argon-low", "MODEL_PLACEHOLDER_M419", "LOW"), + ("gemini-4-argon-medium", "MODEL_PLACEHOLDER_M418", "MEDIUM"), + ("gemini-4-argon-high", "MODEL_PLACEHOLDER_M417", "HIGH"), + ] { + let mut body = request(stream); + body["model"] = json!(format!("antigravity/{model}")); + let first = f.exchange(body.clone(), json!([{"text":"Synthetic source-tier completion."}])).await?; + first.success(stream)?; + let blocks = first.blocks()?; + let saved = v2_saved(&blocks)?; + body["messages"].as_array_mut().context("messages")?.extend([json!({"role":"assistant","content":blocks}), json!({"role":"user","content":"Synthetic source-tier next user."})]); + let second = f.exchange(body, json!([{"text":"Synthetic source-tier second completion."}])).await?; + if second.status != StatusCode::OK { + let error: Value = serde_json::from_slice(&second.bytes)?; + bail!("supported selector {model} must share continuation logic; status={} error={}", second.status, error["error"]["message"]); + } + second.success(stream)?; + for (exchange, generation) in [(&first, "0"), (&second, "1")] { + let wire = exchange.wire()?; + let labels = &wire["request"]["labels"]; + ensure!(wire["model"] == model && saved["native_model"] == model && labels["model_enum"] == enumeration, "exact fixed-tier model and source enum mapping"); + ensure!(labels["cascade_id"] == first.wire()?["request"]["labels"]["cascade_id"] && labels["trajectory_id"] == first.wire()?["request"]["labels"]["trajectory_id"], "fixed tier history retains lineage"); + let trajectory = labels["trajectory_id"].as_str().context("trajectory")?; + ensure!(labels["request_id"] == format!("{trajectory}-{generation}"), "shared generation progression"); + ensure!(wire["request"]["sessionId"] == "-3750763034362895579", "fixed signed backend session"); + exact(&wire["request"]["generationConfig"], &json!({"maxOutputTokens":137,"thinkingConfig":{"includeThoughts":true,"thinkingLevel":level}}), "existing cap and thinking tiers unchanged")?; + } + ensure!(second.wire()?["request"]["labels"]["last_execution_id"] == saved["user_execution"]["active_id"], "same predecessor relationship independent of tier"); + let mut switched = request(stream); + switched["model"] = json!(if model == "gemini-4-argon-low" { "antigravity/gemini-4-argon-high" } else { "antigravity/gemini-4-argon-low" }); + switched["messages"].as_array_mut().context("messages")?.extend([json!({"role":"assistant","content":blocks}), json!({"role":"user","content":"Must not switch tier on a carried history."})]); + let rejected = f.exchange(switched, json!([{"text":"must not dispatch"}])).await?; + ensure!(rejected.status == StatusCode::BAD_REQUEST && rejected.wire.is_none(), "fixed-model tier binding is still closed"); + } + } + Ok::<(), anyhow::Error>(()) + }.await; + std::fs::write(path, original)?; + result +} + async fn tls_control(f: &mut Fixture) -> Result<()> { comparison_oracle_control()?; let response = reqwest::Client::builder() From 99803bbaf86d14f0ef8d2744daa203186628af2b Mon Sep 17 00:00:00 2001 From: Chris Liu <11517021+chris7iu@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:39:19 +0000 Subject: [PATCH 16/30] test(cli): bind readiness to the owned service listener Start synthetic services on kernel-selected loopback ports and discover their actual bound address from owned startup stderr. Reject early child exit rather than accepting an unrelated TCP listener, retain complete bounded stderr for security assertions, and cover foreign-listener and delayed-start cases. Co-Authored-By: Claude Code --- tests/cli.rs | 228 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 204 insertions(+), 24 deletions(-) diff --git a/tests/cli.rs b/tests/cli.rs index ff462486..22b2360b 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -977,31 +977,199 @@ impl Drop for ChildGuard { } } +#[cfg(unix)] +struct ServiceStderr { + file: tempfile::NamedTempFile, + bytes: Vec, +} + +#[cfg(unix)] +impl ServiceStderr { + fn new() -> std::io::Result { + Ok(Self { + file: tempfile::NamedTempFile::new()?, + bytes: Vec::new(), + }) + } + + fn stdio(&self) -> std::io::Result { + // Reopen rather than clone: reading must not move the child's write cursor. + Ok(Stdio::from(self.file.reopen()?)) + } + + fn read(&mut self) -> std::io::Result<()> { + const LIMIT: usize = 64 * 1024; + self.file + .as_file_mut() + .take((LIMIT.saturating_sub(self.bytes.len()) + 1) as u64) + .read_to_end(&mut self.bytes)?; + if self.bytes.len() > LIMIT { + return Err(std::io::Error::other( + "synthetic service stderr exceeded limit", + )); + } + Ok(()) + } + + fn text(&self) -> std::borrow::Cow<'_, str> { + String::from_utf8_lossy(&self.bytes) + } +} + #[cfg(unix)] fn wait_for_service( child: &mut ChildGuard, - port: u16, + stderr: &mut ServiceStderr, ) -> Result> { let deadline = Instant::now() + Duration::from_secs(15); + let mut address = None; loop { - match TcpStream::connect(("127.0.0.1", port)) { - Ok(stream) => return Ok(stream), - Err(error) if Instant::now() < deadline => { - if let Some(status) = child.0.try_wait()? { - let mut stderr = String::new(); - if let Some(mut pipe) = child.0.stderr.take() { - pipe.read_to_string(&mut stderr)?; + stderr.read()?; + if let Some(status) = child.0.try_wait()? { + return Err(format!("service exited with {status}: {}", stderr.text()).into()); + } + if address.is_none() { + // Only this child's post-bind log is evidence of its listener. A TCP + // probe on a released ephemeral port can reach an unrelated fixture. + for line in stderr.bytes.split(|byte| *byte == b'\n') { + let Ok(entry) = serde_json::from_slice::(line) else { + continue; + }; + if entry["service"] != "server" || entry["msg"] != "server listening" { + continue; + } + let ip = entry["fields"]["bindAddress"] + .as_str() + .ok_or("service startup omitted bound address")? + .parse::()?; + if ip != std::net::Ipv4Addr::LOCALHOST { + return Err("synthetic service must bind explicit IPv4 loopback".into()); + } + let port = entry["fields"]["port"] + .as_u64() + .and_then(|port| u16::try_from(port).ok()) + .filter(|port| *port != 0) + .ok_or("service startup omitted actual bound port")?; + address = Some(std::net::SocketAddr::new(ip, port)); + break; + } + } + if let Some(address) = address { + match TcpStream::connect(address) { + Ok(stream) => { + if let Some(status) = child.0.try_wait()? { + stderr.read()?; + return Err( + format!("service exited with {status}: {}", stderr.text()).into() + ); } - return Err(format!("service exited with {status}: {stderr}").into()); + return Ok(stream); } - let _ = error; - thread::sleep(Duration::from_millis(20)); + Err(error) if Instant::now() >= deadline => return Err(error.into()), + Err(_) => {} } - Err(error) => return Err(error.into()), } + if Instant::now() >= deadline { + return Err(format!( + "timed out waiting for owned service startup: {}", + stderr.text() + ) + .into()); + } + thread::sleep(Duration::from_millis(20)); } } +#[cfg(unix)] +#[test] +fn service_readiness_rejects_exited_child_on_foreign_listener() +-> Result<(), Box> { + let foreign = TcpListener::bind("127.0.0.1:0")?; + let mut stderr = ServiceStderr::new()?; + let child = std::process::Command::new("/bin/sh") + .args([ + "-c", + "printf synthetic-service-startup-failure >&2; exit 23", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(stderr.stdio()?) + .spawn()?; + let mut child = ChildGuard(child); + assert_eq!(child.0.wait()?.code(), Some(23)); + + let result = wait_for_service(&mut child, &mut stderr); + assert!( + result.is_err(), + "an unrelated listener must not make an exited service ready" + ); + let error = result.unwrap_err().to_string(); + assert!(error.contains("23"), "{error}"); + assert!( + error.contains("synthetic-service-startup-failure"), + "{error}" + ); + foreign.set_nonblocking(true)?; + assert!( + matches!(foreign.accept(), Err(error) if error.kind() == std::io::ErrorKind::WouldBlock) + ); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn service_readiness_discovers_delayed_owned_ephemeral_listener() +-> Result<(), Box> { + let directory = TempDir::new()?; + let foreign = TcpListener::bind("127.0.0.1:0")?; + let mut stderr = ServiceStderr::new()?; + // Delay only this synthetic child's startup, not the readiness deadline. + let child = std::process::Command::new("/bin/sh") + .args([ + "-c", + "/bin/sleep 0.1; exec \"$@\"", + "synthetic-delayed-service", + env!("CARGO_BIN_EXE_claude-code-proxy"), + "serve", + "--no-monitor", + "--port", + "0", + ]) + .env_clear() + .env("HOME", directory.path()) + .env("CCP_CONFIG_DIR", directory.path()) + .env("XDG_STATE_HOME", directory.path().join("state")) + .env("CCP_BIND_ADDRESS", "127.0.0.1") + .env("CCP_LOG_STDERR", "1") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(stderr.stdio()?) + .spawn()?; + let mut child = ChildGuard(child); + let connection = wait_for_service(&mut child, &mut stderr) + .expect("readiness must discover the delayed child's actual bound port"); + let address = connection.peer_addr()?; + assert!(address.ip().is_loopback()); + assert_ne!(address.port(), 0); + assert_ne!(address.port(), foreign.local_addr()?.port()); + drop(connection); + send_signal(&child, "-TERM")?; + assert!(wait_for_exit(&mut child, Duration::from_secs(5))?.success()); + stderr.read()?; + assert!( + stderr + .text() + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .any(|entry| { + entry["service"] == "server" + && entry["msg"] == "server listening" + && entry["fields"]["port"] == address.port() + }) + ); + Ok(()) +} + #[cfg(unix)] fn send_signal(child: &ChildGuard, signal: &str) -> Result<(), Box> { let status = std::process::Command::new("kill") @@ -1057,9 +1225,10 @@ async fn antigravity_user_agent_invalid_environment_is_scoped_to_native_provider "access_token":"synthetic-access", "refresh_token":null, "expires_at":4102444800u64 }).to_string())?; std::fs::set_permissions(&auth_path, std::fs::Permissions::from_mode(0o600))?; - let port = TcpListener::bind("127.0.0.1:0")?.local_addr()?.port(); + let mut stderr = ServiceStderr::new()?; let child = std::process::Command::new(env!("CARGO_BIN_EXE_claude-code-proxy")) - .args(["serve", "--no-monitor", "--port", &port.to_string()]) + .args(["serve", "--no-monitor", "--port", "0"]) + .env("CCP_BIND_ADDRESS", "127.0.0.1") .env("CCP_CONFIG_DIR", directory.path()) .env("XDG_STATE_HOME", directory.path().join("state")) .env("CCP_ANTIGRAVITY_ENABLED", enabled) @@ -1073,10 +1242,12 @@ async fn antigravity_user_agent_invalid_environment_is_scoped_to_native_provider .env("CCP_TRAFFIC_LOG", "1") .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::piped()) + .stderr(stderr.stdio()?) .spawn()?; let mut child = ChildGuard(child); - drop(wait_for_service(&mut child, port)?); + let port = wait_for_service(&mut child, &mut stderr)? + .peer_addr()? + .port(); let client = reqwest::Client::builder() .no_proxy() .timeout(Duration::from_secs(5)) @@ -1086,7 +1257,15 @@ async fn antigravity_user_agent_invalid_environment_is_scoped_to_native_provider "http://127.0.0.1:{port}/v1/models?include_capabilities=true" )) .send() - .await?; + .await + .map_err(|error| { + let capture = stderr.read(); + format!( + "models request failed: {error}; child: {:?}; stderr capture: {capture:?}; stderr: {}", + child.0.try_wait(), + stderr.text() + ) + })?; assert_eq!(response.status(), 200); let models: serde_json::Value = response.json().await?; assert!(!models["data"].as_array().unwrap().iter().any(|entry| { @@ -1131,9 +1310,8 @@ async fn antigravity_user_agent_invalid_environment_is_scoped_to_native_provider ); send_signal(&child, "-TERM")?; assert!(wait_for_exit(&mut child, Duration::from_secs(5))?.success()); - let mut stderr = String::new(); - child.0.stderr.take().unwrap().read_to_string(&mut stderr)?; - assert!(!stderr.contains("sentinel")); + stderr.read()?; + assert!(!stderr.text().contains("sentinel")); } } Ok(()) @@ -1159,19 +1337,21 @@ fn plain_service_exits_on_second_signal(signal: &str) -> Result<(), Box Date: Fri, 6 Nov 2026 15:24:05 +0000 Subject: [PATCH 17/30] fix(antigravity): strip Claude Code attribution headers and consolidate agy systemInstruction --- src/providers/antigravity/request.rs | 67 +++++++++++++++++++++ src/providers/antigravity_tests.rs | 87 ++++++++++++++++++++++++++++ tests/antigravity_schema_parity.rs | 15 ++++- 3 files changed, 168 insertions(+), 1 deletion(-) diff --git a/src/providers/antigravity/request.rs b/src/providers/antigravity/request.rs index b1de022f..8e090a48 100644 --- a/src/providers/antigravity/request.rs +++ b/src/providers/antigravity/request.rs @@ -221,6 +221,72 @@ pub(crate) fn validate_raw(raw: &[u8]) -> Result<()> { Ok(()) } +const CLAUDE_CODE_SYSTEM_HEADERS: [&str; 3] = [ + "You are Claude Code, Anthropic's official CLI for Claude.", + "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK.", + "You are a Claude agent, built on Anthropic's Claude Agent SDK.", +]; + +fn strip_claude_code_system_header(mut text: &str) -> Option<&str> { + let mut stripped_any = false; + loop { + let trimmed = text.trim_start_matches([' ', '\t', '\r', '\n']); + if let Some(rest) = trimmed.strip_prefix("x-anthropic-billing-header:") { + stripped_any = true; + let after_line = rest.split_once('\n').map_or("", |(_, tail)| tail); + text = after_line + .strip_prefix("\r\n") + .or_else(|| after_line.strip_prefix('\n')) + .unwrap_or(after_line); + continue; + } + if let Some(after_header) = CLAUDE_CODE_SYSTEM_HEADERS + .iter() + .find_map(|header| trimmed.strip_prefix(header)) + { + let after_ws = after_header.trim_start_matches([' ', '\t']); + if after_ws.is_empty() || after_ws.starts_with('\n') || after_ws.starts_with("\r\n") { + stripped_any = true; + let after_line = after_ws + .strip_prefix("\r\n") + .or_else(|| after_ws.strip_prefix('\n')) + .unwrap_or(after_ws); + text = after_line + .strip_prefix("\r\n") + .or_else(|| after_line.strip_prefix('\n')) + .unwrap_or(after_line); + continue; + } + } + break; + } + if stripped_any && text.trim().is_empty() { + None + } else { + Some(text) + } +} + +fn sanitize_system_parts(parts: Vec, policy: RequestMetadata) -> Vec { + let filtered: Vec = parts + .into_iter() + .filter_map(|part| { + let text = part["text"].as_str()?; + strip_claude_code_system_header(text).map(|cleaned| json!({"text": cleaned})) + }) + .collect(); + if policy == RequestMetadata::Agy && filtered.len() > 1 { + let joined = filtered + .iter() + .filter_map(|part| part["text"].as_str()) + .collect::>() + .join("\n\n"); + vec![json!({"text": joined})] + } else { + filtered + } +} + fn text_parts(value: &Value) -> Result> { if let Some(text) = value.as_str() { return Ok(vec![json!({"text":text})]); @@ -605,6 +671,7 @@ fn prepare_inner( system.extend(text_parts(&message.content)?); } } + let system = sanitize_system_parts(system, policy); let turns = messages .iter() .filter(|m| m.role == "assistant") diff --git a/src/providers/antigravity_tests.rs b/src/providers/antigravity_tests.rs index ef9cce77..bdca9697 100644 --- a/src/providers/antigravity_tests.rs +++ b/src/providers/antigravity_tests.rs @@ -2918,3 +2918,90 @@ fn antigravity_local_tool_ids_are_unique_despite_reused_native_ids() { assert_ne!(entry["local_id"], entry["native_id"]); } } + +#[test] +fn antigravity_strips_claude_code_billing_and_sdk_headers_and_consolidates_agy_system_parts() { + use super::antigravity::config::RequestMetadata; + use super::antigravity::request::prepare_with_policy; + + for sdk_header in [ + "You are Claude Code, Anthropic's official CLI for Claude.", + "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK.", + "You are a Claude agent, built on Anthropic's Claude Agent SDK.", + ] { + let mut request = request_value(); + request["system"] = json!([ + {"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.270.dd6; cc_entrypoint=sdk-cli;"}, + {"type":"text","text":sdk_header,"cache_control":{"type":"ephemeral"}}, + {"type":"text","text":"\nYou are an interactive agent.","cache_control":{"type":"ephemeral"}} + ]); + request["messages"] = json!([ + {"role":"system","content":"# Environment\n - Primary working directory: /workspace"}, + {"role":"user","content":"Reply with OK."} + ]); + + let agy_prepared = prepare_with_policy( + &serde_json::from_value(request.clone()).unwrap(), + &binding(), + RequestMetadata::Agy, + ) + .unwrap(); + assert_eq!( + agy_prepared.wire["request"]["systemInstruction"], + json!({ + "role":"user", + "parts":[{"text":"\nYou are an interactive agent.\n\n# Environment\n - Primary working directory: /workspace"}] + }) + ); + + let legacy_prepared = prepare_with_policy( + &serde_json::from_value(request).unwrap(), + &binding(), + RequestMetadata::Legacy, + ) + .unwrap(); + assert_eq!( + legacy_prepared.wire["request"]["systemInstruction"], + json!({ + "role":"user", + "parts":[ + {"text":"\nYou are an interactive agent."}, + {"text":"# Environment\n - Primary working directory: /workspace"} + ] + }) + ); + + // Pre-flattened single-string system with leading billing + SDK headers. + let mut flattened = request_value(); + flattened["system"] = json!(format!( + "x-anthropic-billing-header: cc_version=2.1.270.dd6\n\n{sdk_header}\n\n\nYou are an interactive agent." + )); + let flattened_prepared = prepare_with_policy( + &serde_json::from_value(flattened).unwrap(), + &binding(), + RequestMetadata::Agy, + ) + .unwrap(); + assert_eq!( + flattened_prepared.wire["request"]["systemInstruction"], + json!({ + "role":"user", + "parts":[{"text":"\nYou are an interactive agent."}] + }) + ); + + // System consisting solely of billing + SDK headers omits systemInstruction entirely. + let mut headers_only = request_value(); + headers_only["system"] = json!([ + {"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.270.dd6"}, + {"type":"text","text":sdk_header} + ]); + let omitted = prepare_with_policy( + &serde_json::from_value(headers_only).unwrap(), + &binding(), + RequestMetadata::Agy, + ) + .unwrap(); + assert!(omitted.wire["request"].get("systemInstruction").is_none()); + } +} diff --git a/tests/antigravity_schema_parity.rs b/tests/antigravity_schema_parity.rs index 07b853d6..62ce58f3 100644 --- a/tests/antigravity_schema_parity.rs +++ b/tests/antigravity_schema_parity.rs @@ -794,12 +794,25 @@ async fn first_agent_metadata(f: &mut Fixture) -> Result<()> { let mut body = request(stream); body["model"] = json!("antigravity/gemini-4-argon-low"); body["max_tokens"] = json!(65536); - body["system"] = json!("Synthetic metadata system instruction."); + body["system"] = json!([ + {"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.270.dd6; cc_entrypoint=sdk-cli;"}, + {"type":"text","text":"You are a Claude agent, built on Anthropic's Claude Agent SDK.","cache_control":{"type":"ephemeral"}}, + {"type":"text","text":"Synthetic metadata system instruction.","cache_control":{"type":"ephemeral"}} + ]); + body["messages"].as_array_mut().context("messages")?.insert( + 0, + json!({"role":"system","content":"Synthetic environment instruction."}), + ); let exchange = f.exchange(body, json!([{"text":"Synthetic metadata completion."}])).await?; exchange.success(stream)?; let wire = exchange.wire()?; ensure!(wire["model"] == "gemini-4-argon-low" && wire["requestType"] == "agent" && wire["userAgent"] == "antigravity", "observed agent envelope"); let request = &wire["request"]; + exact( + &request["systemInstruction"], + &json!({"role":"user","parts":[{"text":"Synthetic metadata system instruction.\n\nSynthetic environment instruction."}]}), + "agy single-part systemInstruction with Claude Code headers stripped", + )?; exact(&request["generationConfig"], &json!({"maxOutputTokens":65536,"thinkingConfig":{"includeThoughts":true,"thinkingLevel":"LOW"}}), "observed generation controls")?; ensure!(request["sessionId"] == "-3750763034362895579", "observed signed empty-input FNV session"); let labels = request["labels"].as_object().context("observed lineage labels")?; From ecc67cfdbefaa3cdf68ea3093ff6c81839502ffe Mon Sep 17 00:00:00 2001 From: Chris Liu <11517021+chris7iu@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:53:59 +0000 Subject: [PATCH 18/30] feat(antigravity): default to agy metadata, add Opus 5.5, 1m context, and carrierless prefix replay --- README.md | 9 +- src/config.rs | 2 +- src/providers/antigravity/config.rs | 6 +- src/providers/antigravity/request.rs | 346 +++++++++++++++++++++--- src/providers/antigravity/transport.rs | 23 +- src/providers/antigravity_http_tests.rs | 1 + src/providers/antigravity_tests.rs | 100 +++++++ src/server.rs | 22 +- tests/antigravity_schema_parity.rs | 2 +- tests/server.rs | 48 ++-- 10 files changed, 462 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index d476906d..9d058a8f 100644 --- a/README.md +++ b/README.md @@ -105,9 +105,10 @@ The opt-in Images API returns base64 image data and consumes the signed-in accou Run `claude-code-proxy models` for the current catalog or `claude-code-proxy models --full` for every dynamic Cursor alias. -An additional **experimental, default-off native Antigravity Argon adapter** supports -only `antigravity/gemini-4-argon-low`, `antigravity/gemini-4-argon-medium`, and -`antigravity/gemini-4-argon-high`. It does not run or import credentials from `agy`. +An additional **experimental, default-off native Antigravity adapter** supports +`antigravity/gemini-4-argon-low`, `antigravity/gemini-4-argon-medium`, +`antigravity/gemini-4-argon-high`, `antigravity/claude-opus-5-5-low`, +`antigravity/claude-opus-5-5-medium`, and `antigravity/claude-opus-5-5-high`. It does not run or import credentials from `agy`. Its default HTTP User-Agent is `claude-code-proxy/`. An explicit provider-specific override affects only native generation/project discovery, not OAuth; it is compatibility metadata, not verified official-client identity. @@ -121,7 +122,7 @@ before explicitly enabling it. Existing provider defaults and aliases are unchan ### Native request metadata policy The closed file setting `antigravity.requestMetadata` in `config.json` accepts -`"legacy"` (the default) or `"agy"`: +`"agy"` (the default) or `"legacy"`: ```json {"antigravity":{"requestMetadata":"agy"}} diff --git a/src/config.rs b/src/config.rs index 866a3c33..f6c8cfcf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -741,7 +741,7 @@ pub fn codex_responses_api() -> bool { } pub fn codex_full_lane() -> bool { - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); if let Some(raw) = env.get("CCP_CODEX_FULL_LANE") { return matches!(raw.to_ascii_lowercase().as_str(), "1" | "true" | "yes"); } diff --git a/src/providers/antigravity/config.rs b/src/providers/antigravity/config.rs index 4c927b86..acc79ff6 100644 --- a/src/providers/antigravity/config.rs +++ b/src/providers/antigravity/config.rs @@ -48,7 +48,7 @@ impl Default for Config { fn default() -> Self { Self { enabled: false, - request_metadata: RequestMetadata::Legacy, + request_metadata: RequestMetadata::Agy, base_url: Url::parse("https://daily-cloudcode-pa.googleapis.com").unwrap(), token_url: Url::parse("https://oauth2.googleapis.com/token").unwrap(), authorization_url: Url::parse("https://accounts.google.com/o/oauth2/v2/auth").unwrap(), @@ -205,10 +205,10 @@ mod tests { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("config.json"); let default = Config::read(&path, |key| { - (key == "CCP_ANTIGRAVITY_REQUEST_METADATA").then(|| "agy".into()) + (key == "CCP_ANTIGRAVITY_REQUEST_METADATA").then(|| "legacy".into()) }) .unwrap(); - assert_eq!(default.request_metadata, RequestMetadata::Legacy); + assert_eq!(default.request_metadata, RequestMetadata::Agy); assert!(!default.enabled); for (value, expected) in [ ("legacy", RequestMetadata::Legacy), diff --git a/src/providers/antigravity/request.rs b/src/providers/antigravity/request.rs index 8e090a48..01fcb367 100644 --- a/src/providers/antigravity/request.rs +++ b/src/providers/antigravity/request.rs @@ -12,13 +12,18 @@ pub const MODELS: &[&str] = &[ "antigravity/gemini-4-argon-low", "antigravity/gemini-4-argon-medium", "antigravity/gemini-4-argon-high", + "antigravity/claude-opus-5-5-low", + "antigravity/claude-opus-5-5-medium", + "antigravity/claude-opus-5-5-high", ]; pub(crate) fn effective_tier(model: &str) -> Option<&'static str> { match model { - "antigravity/gemini-4-argon-low" => Some("low"), - "antigravity/gemini-4-argon-medium" => Some("medium"), - "antigravity/gemini-4-argon-high" => Some("high"), + "antigravity/gemini-4-argon-low" | "antigravity/claude-opus-5-5-low" => Some("low"), + "antigravity/gemini-4-argon-medium" | "antigravity/claude-opus-5-5-medium" => { + Some("medium") + } + "antigravity/gemini-4-argon-high" | "antigravity/claude-opus-5-5-high" => Some("high"), _ => None, } } @@ -142,6 +147,9 @@ impl Prepared { Some("gemini-4-argon-low") => "MODEL_PLACEHOLDER_M419", Some("gemini-4-argon-medium") => "MODEL_PLACEHOLDER_M418", Some("gemini-4-argon-high") => "MODEL_PLACEHOLDER_M417", + Some("claude-opus-5-5-low") => "MODEL_PLACEHOLDER_M400", + Some("claude-opus-5-5-medium") => "MODEL_PLACEHOLDER_M401", + Some("claude-opus-5-5-high") => "MODEL_PLACEHOLDER_M402", _ => return Err("unsupported metadata model"), }; let state = match &self.metadata { @@ -443,6 +451,9 @@ fn native_checkpoint_parts(envelope: &Value) -> Result<()> { if contents.is_empty() { return Err("V2 continuation requires native content"); } + let is_claude = envelope["native_model"] + .as_str() + .is_some_and(|m| m.starts_with("claude-")); // Preserve every raw content/signature and original codec. A completed // assistant checkpoint counts once regardless of native SSE fragmentation. for content in contents { @@ -453,12 +464,15 @@ fn native_checkpoint_parts(envelope: &Value) -> Result<()> { } for part in parts { if part.get("functionCall").is_some() { - keys( - part, - &["functionCall", "thoughtSignature"], - &["functionCall", "thoughtSignature"], - )?; - nonempty(&part["thoughtSignature"])?; + let required: &[&str] = if is_claude { + &["functionCall"] + } else { + &["functionCall", "thoughtSignature"] + }; + keys(part, &["functionCall", "thoughtSignature"], required)?; + if let Some(sig) = part.get("thoughtSignature") { + nonempty(sig)?; + } } else { keys(part, &["text", "thought", "thoughtSignature"], &["text"])?; if !part["text"].is_string() { @@ -475,6 +489,62 @@ fn native_checkpoint_parts(envelope: &Value) -> Result<()> { Ok(()) } +fn coalesce_claude_native_turn(native: &[Value]) -> Vec { + let mut merged_parts: Vec = Vec::new(); + for content in native { + let Some(parts) = content["parts"].as_array() else { + continue; + }; + for part in parts { + if part["thought"] == true { + let chunk = part["text"].as_str().unwrap_or(""); + let sig = part.get("thoughtSignature").cloned(); + if let Some(last) = merged_parts.last_mut().filter(|p| p["thought"] == true) { + if let Some(Value::String(prev)) = last.get_mut("text") { + prev.push_str(chunk); + } + if let Some(sig) = sig { + last["thoughtSignature"] = sig; + } + } else { + let mut item = json!({"thought": true, "text": chunk}); + if let Some(sig) = sig { + item["thoughtSignature"] = sig; + } + merged_parts.push(item); + } + } else if part.get("functionCall").is_some() { + merged_parts.push(part.clone()); + } else if let Some(text) = part.get("text").and_then(Value::as_str) { + if text.is_empty() && part.get("thoughtSignature").is_none() { + continue; + } + if part.get("thoughtSignature").is_none() + && let Some(last) = merged_parts.last_mut().filter(|p| { + p.get("text").is_some() + && p.get("thought").is_none() + && p.get("thoughtSignature").is_none() + }) + { + if let Some(Value::String(prev)) = last.get_mut("text") { + prev.push_str(text); + } + } else { + merged_parts.push(part.clone()); + } + } else { + merged_parts.push(part.clone()); + } + } + } + merged_parts.retain(|p| p["thought"] != true || p.get("thoughtSignature").is_some()); + if merged_parts.is_empty() { + Vec::new() + } else { + vec![json!({"role": "model", "parts": merged_parts})] + } +} + fn continuation_input(envelope: &Value, user: &Message) -> Result { native_checkpoint_parts(envelope)?; match nonempty(&envelope["checkpoint_stop_reason"])? { @@ -490,25 +560,63 @@ fn continuation_input(envelope: &Value, user: &Message) -> Result { // must not acquire invented Go counter/grouping semantics. let mapping = envelope["tool_mapping"].as_array().unwrap(); let calls = replay::calls(&envelope["contents"])?; - if mapping.len() != 1 || calls.len() != 1 || calls[0].2.get("id").is_none() { - return Err("unqualified V2 tool continuation shape"); - } - let blocks = user - .content - .as_array() - .filter(|b| b.len() == 1) - .ok_or("V2 tool continuation requires one result")?; - let block = &blocks[0]; - if block["type"] != "tool_result" - || block["tool_use_id"] != mapping[0]["local_id"] - || !block["content"].is_string() - || block - .get("is_error") - .is_some_and(|v| v.as_bool() != Some(false)) - { - return Err("unqualified V2 tool result"); + let is_claude = envelope["native_model"] + .as_str() + .is_some_and(|m| m.starts_with("claude-")); + if is_claude { + if mapping.is_empty() + || mapping.len() != calls.len() + || calls.iter().any(|c| c.2.get("id").is_none()) + { + return Err("unqualified V2 tool continuation shape"); + } + let blocks = user + .content + .as_array() + .filter(|b| b.len() >= mapping.len()) + .ok_or("V2 tool continuation requires one result")?; + let expected_ids: HashSet<&str> = mapping + .iter() + .filter_map(|m| m["local_id"].as_str()) + .collect(); + let mut seen_ids = HashSet::new(); + for block in blocks.iter().take(mapping.len()) { + let Some(id) = block["tool_use_id"].as_str() else { + return Err("unqualified V2 tool result"); + }; + if block["type"] != "tool_result" + || !expected_ids.contains(id) + || !seen_ids.insert(id) + || !block["content"].is_string() + || block + .get("is_error") + .is_some_and(|v| v.as_bool() != Some(false)) + { + return Err("unqualified V2 tool result"); + } + } + Ok(false) + } else { + if mapping.len() != 1 || calls.len() != 1 || calls[0].2.get("id").is_none() { + return Err("unqualified V2 tool continuation shape"); + } + let blocks = user + .content + .as_array() + .filter(|b| b.len() == 1) + .ok_or("V2 tool continuation requires one result")?; + let block = &blocks[0]; + if block["type"] != "tool_result" + || block["tool_use_id"] != mapping[0]["local_id"] + || !block["content"].is_string() + || block + .get("is_error") + .is_some_and(|v| v.as_bool() != Some(false)) + { + return Err("unqualified V2 tool result"); + } + Ok(false) } - Ok(false) } _ => Err("unqualified continuation after incomplete V2 checkpoint"), } @@ -598,7 +706,12 @@ pub(crate) fn estimate(body: &MessagesRequest, inputs: CountInputs<'_>) -> Resul } } else { let messages = logical_messages(&body.messages)?; - if let Some(turn) = messages.iter().find(|m| m.role == "assistant") { + if let Some(turn) = messages.iter().rev().find(|m| { + m.role == "assistant" + && m.content + .get(0) + .is_some_and(|block| block["type"] == "thinking") + }) { replay::claimed_binding(&turn.content, native_model, inputs.configured_project)? } else { Binding { @@ -638,8 +751,8 @@ fn prepare_inner( return Err("unsupported Antigravity model or replay scope"); } let tier = match model { - "antigravity/gemini-4-argon-low" => "LOW", - "antigravity/gemini-4-argon-medium" => "MEDIUM", + "antigravity/gemini-4-argon-low" | "antigravity/claude-opus-5-5-low" => "LOW", + "antigravity/gemini-4-argon-medium" | "antigravity/claude-opus-5-5-medium" => "MEDIUM", _ => "HIGH", }; let cap = body.max_tokens.unwrap_or(65536); @@ -672,7 +785,22 @@ fn prepare_inner( } } let system = sanitize_system_parts(system, policy); - let turns = messages + let v2_start_idx = if policy == RequestMetadata::Agy { + messages + .iter() + .rposition(|m| { + m.role == "assistant" + && m.content + .get(0) + .is_none_or(|block| block["type"] != "thinking") + }) + .map(|idx| idx + 1) + .unwrap_or(0) + } else { + 0 + }; + let (prefix_messages, active_messages) = messages.split_at(v2_start_idx); + let turns = active_messages .iter() .filter(|m| m.role == "assistant") .map(|m| m.content.clone()) @@ -698,12 +826,12 @@ fn prepare_inner( return Err("V2 history requires the agy request metadata policy"); } let envelopes = replay::v2::decode_history(&json!(turns), binding)?; - let metadata = observed_continuation(&messages, &envelopes, model)?; + let metadata = observed_continuation(active_messages, &envelopes, model)?; (envelopes, metadata) } else { let envelopes = replay::decode_history(&json!(turns), binding)?; let metadata = if policy == RequestMetadata::Agy && turns.is_empty() { - let input = messages + let input = active_messages .iter() .filter(|m| m.role != "system") .collect::>(); @@ -737,7 +865,7 @@ fn prepare_inner( }; let mut history = envelopes.into_iter(); let mut pending: Vec<(Value, Value)> = Vec::new(); - let mut contents = Vec::new(); + let mut contents: Vec = Vec::new(); let mut last_role = ""; let mut estimate_bytes = serde_json::to_vec(&system) .map_err(|_| "invalid system")? @@ -745,7 +873,141 @@ fn prepare_inner( estimate_bytes += serde_json::to_vec(&tools.declarations) .map_err(|_| "invalid tools")? .len(); - for message in &messages { + for message in prefix_messages { + if message.role == "system" { + continue; + } + if message.role == "user" { + let blocks = if let Some(text) = message.content.as_str() { + vec![json!({"type":"text","text":text})] + } else { + message + .content + .as_array() + .ok_or("invalid user content")? + .clone() + }; + let mut parts = Vec::new(); + for block in blocks { + match block["type"].as_str() { + Some("text") => { + keys( + &block, + &["type", "text", "cache_control"], + &["type", "text"], + )?; + cache_hint(block.get("cache_control"))?; + let value = block["text"].as_str().ok_or("invalid user text")?; + estimate_bytes += value.len(); + parts.push(json!({"text":value})); + } + Some("tool_result") => { + keys( + &block, + &[ + "type", + "tool_use_id", + "content", + "is_error", + "cache_control", + ], + &["type", "tool_use_id"], + )?; + cache_hint(block.get("cache_control"))?; + let id = nonempty(&block["tool_use_id"])?; + let rendered = match block.get("content") { + None => String::new(), + Some(Value::String(s)) => s.clone(), + Some(value) => text_parts(value)? + .iter() + .filter_map(|p| p["text"].as_str()) + .collect::>() + .join("\n"), + }; + let is_error = block + .get("is_error") + .map(|v| v.as_bool().ok_or("invalid is_error flag")) + .transpose()? + .unwrap_or(false); + let formatted = if is_error { + format!("[tool_result ({id}) error: {rendered}]") + } else { + format!("[tool_result ({id}): {rendered}]") + }; + estimate_bytes += formatted.len(); + parts.push(json!({"text":formatted})); + } + _ => return Err("unsupported user content/media"), + } + } + if !parts.is_empty() { + if contents.last().is_some_and(|c| c["role"] == "user") { + contents + .last_mut() + .unwrap() + .get_mut("parts") + .and_then(Value::as_array_mut) + .unwrap() + .extend(parts); + } else { + if contents.len() >= 1024 { + return Err("native history content limit"); + } + contents.push(json!({"role":"user","parts":parts})); + } + last_role = "user"; + } + } else if message.role == "assistant" { + let blocks = message + .content + .as_array() + .ok_or("invalid assistant content")?; + let mut parts = Vec::new(); + for block in blocks { + match block["type"].as_str() { + Some("thinking") => {} + Some("text") => { + let text = block["text"].as_str().ok_or("invalid display text")?; + if !text.is_empty() { + estimate_bytes += text.len(); + parts.push(json!({"text":text})); + } + } + Some("tool_use") => { + let id = nonempty(&block["id"])?; + let name = nonempty(&block["name"])?; + let input = serde_json::to_string(&block["input"]) + .map_err(|_| "invalid tool input")?; + let formatted = format!("[tool_use {name}(id={id}): {input}]"); + estimate_bytes += formatted.len(); + parts.push(json!({"text":formatted})); + } + _ => return Err("unsupported projection block"), + } + } + if parts.is_empty() { + parts.push(json!({"text":"..."})); + } + if contents.last().is_some_and(|c| c["role"] == "model") { + contents + .last_mut() + .unwrap() + .get_mut("parts") + .and_then(Value::as_array_mut) + .unwrap() + .extend(parts); + } else { + if contents.len() >= 1024 { + return Err("native history content limit"); + } + contents.push(json!({"role":"model","parts":parts})); + } + last_role = "assistant"; + } else { + return Err("unsupported message role"); + } + } + for message in active_messages { if message.role == "system" { continue; } @@ -780,10 +1042,18 @@ fn prepare_inner( } } let native = envelope["contents"].as_array().unwrap(); - if contents.len().saturating_add(native.len()) > 1024 { - return Err("native history content limit"); + if binding.native_model.starts_with("claude-") { + let coalesced = coalesce_claude_native_turn(native); + if contents.len().saturating_add(coalesced.len()) > 1024 { + return Err("native history content limit"); + } + contents.extend(coalesced); + } else { + if contents.len().saturating_add(native.len()) > 1024 { + return Err("native history content limit"); + } + contents.extend(native.iter().cloned()); } - contents.extend(native.iter().cloned()); } else if message.role == "user" { let blocks = if let Some(text) = message.content.as_str() { vec![json!({"type":"text","text":text})] diff --git a/src/providers/antigravity/transport.rs b/src/providers/antigravity/transport.rs index b08a54d1..da874631 100644 --- a/src/providers/antigravity/transport.rs +++ b/src/providers/antigravity/transport.rs @@ -584,8 +584,29 @@ pub(crate) async fn collect(mut response: NativeResponse) -> Result { } pub(crate) fn message(blocks: Value, model: &str, turn: &Turn) -> Value { + let content = match blocks { + Value::Array(items) => { + let mut coalesced: Vec = Vec::with_capacity(items.len()); + for item in items { + if item["type"] == "text" + && let Some(text) = item["text"].as_str() + && coalesced.last().is_some_and(|b| b["type"] == "text") + { + if let Some(Value::String(prev)) = + coalesced.last_mut().and_then(|b| b.get_mut("text")) + { + prev.push_str(text); + } + } else { + coalesced.push(item); + } + } + Value::Array(coalesced) + } + other => other, + }; json!({"id":format!("msg_{}",uuid::Uuid::new_v4().simple()),"type":"message","role":"assistant","model":model, - "content":blocks,"stop_reason":turn.stop_reason,"stop_sequence":null,"usage":turn.usage}) + "content":content,"stop_reason":turn.stop_reason,"stop_sequence":null,"usage":turn.usage}) } pub(crate) fn sse(message: &Value) -> Result> { diff --git a/src/providers/antigravity_http_tests.rs b/src/providers/antigravity_http_tests.rs index c670cba7..a7268980 100644 --- a/src/providers/antigravity_http_tests.rs +++ b/src/providers/antigravity_http_tests.rs @@ -149,6 +149,7 @@ async fn provider_with_user_agent( .unwrap(); drop(lock); let config = Config { + request_metadata: super::antigravity::config::RequestMetadata::Legacy, base_url: url.clone(), client_id: Some("synthetic-client".into()), ..Config::read(&directory.join("config.json"), |key| { diff --git a/src/providers/antigravity_tests.rs b/src/providers/antigravity_tests.rs index bdca9697..ca914696 100644 --- a/src/providers/antigravity_tests.rs +++ b/src/providers/antigravity_tests.rs @@ -3005,3 +3005,103 @@ fn antigravity_strips_claude_code_billing_and_sdk_headers_and_consolidates_agy_s assert!(omitted.wire["request"].get("systemInstruction").is_none()); } } + +#[test] +fn antigravity_opus_5_5_and_carrierless_prefix_turns_and_coalesced_text_blocks() { + use super::antigravity::config::RequestMetadata; + use super::antigravity::request::{CountInputs, estimate, prepare_with_policy}; + use crate::MessagesRequest; + + let opus_binding = replay::Binding { + profile_id: "profile".into(), + project_id: "project".into(), + native_model: "claude-opus-5-5-low".into(), + }; + let lineage = replay::v2::Lineage::new( + "11111111-1111-4111-8111-111111111111".into(), + "22222222-2222-4222-8222-222222222222".into(), + ) + .unwrap(); + let v2_turn = v2_encode( + &json!([{"role":"model","parts":[{"thought":true,"text":"thinking","thoughtSignature":"sig-1"},{"text":"Turn 2 reply."}]}]), + &opus_binding, + &lineage, + 0, + "full", + &json!([]), + ) + .unwrap(); + + // Carrier-less prefix (from mid-session /model switch) + active signed V2 turn + let mut switched = request_value(); + switched["model"] = json!("antigravity/claude-opus-5-5-low"); + switched["messages"] = json!([ + {"role":"user","content":"Remember token ALPHA."}, + {"role":"assistant","content":[ + {"type":"text","text":"Checking tool."}, + {"type":"tool_use","id":"toolu_prefix_1","name":"Read","input":{"file_path":"/tmp/a.txt"}} + ]}, + {"role":"user","content":[ + {"type":"tool_result","tool_use_id":"toolu_prefix_1","content":"ALPHA"} + ]}, + {"role":"assistant","content":[ + {"type":"text","text":"Token ALPHA recorded."} + ]}, + {"role":"user","content":"Now switch model."}, + {"role":"assistant","content":v2_turn}, + {"role":"user","content":"What was the token?"} + ]); + + let body: MessagesRequest = serde_json::from_value(switched).unwrap(); + let mut prepared = prepare_with_policy(&body, &opus_binding, RequestMetadata::Agy).unwrap(); + let mut ids = [execution_id(1)].into_iter(); + prepared + .finalize_metadata(1_799_999_999_456, || ids.next().unwrap()) + .unwrap(); + assert_eq!( + prepared.wire["request"]["labels"]["model_enum"], + json!("MODEL_PLACEHOLDER_M400") + ); + let contents = prepared.wire["request"]["contents"].as_array().unwrap(); + assert_eq!(contents.len(), 7); + assert_eq!( + contents[0], + json!({"role":"user","parts":[{"text":"Remember token ALPHA."}]}) + ); + assert_eq!( + contents[1], + json!({"role":"model","parts":[ + {"text":"Checking tool."}, + {"text":"[tool_use Read(id=toolu_prefix_1): {\"file_path\":\"/tmp/a.txt\"}]"} + ]}) + ); + assert_eq!( + contents[2], + json!({"role":"user","parts":[ + {"text":"[tool_result (toolu_prefix_1): ALPHA]"} + ]}) + ); + assert_eq!( + contents[3], + json!({"role":"model","parts":[{"text":"Token ALPHA recorded."}]}) + ); + assert_eq!( + contents[4], + json!({"role":"user","parts":[{"text":"Now switch model."}]}) + ); + assert_eq!(contents[5]["role"], json!("model")); + assert_eq!( + contents[6], + json!({"role":"user","parts":[{"text":"What was the token?"}]}) + ); + + let counted = estimate( + &body, + CountInputs { + serving_scope: None, + configured_project: Some("project"), + }, + ) + .unwrap(); + assert!(counted > 0); +} diff --git a/src/server.rs b/src/server.rs index d7b374cb..55be250b 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1432,25 +1432,11 @@ fn log_native_request_completed( } fn antigravity_context_error( - model: &str, - original_model: &str, - headers: &http::HeaderMap, + _model: &str, + _original_model: &str, + _headers: &http::HeaderMap, ) -> Option<&'static str> { - if !model.starts_with("antigravity/") { - return None; - } - let raw_hint = model.to_ascii_lowercase().contains("[1m]") - || original_model.to_ascii_lowercase().contains("[1m]"); - let beta_hint = headers.get_all("anthropic-beta").iter().any(|value| { - value.to_str().map_or(true, |value| { - value - .split(',') - .any(|token| token.trim() == "context-1m-2025-08-07") - }) - }); - (raw_hint || beta_hint).then_some( - "Antigravity does not support the 1m context hint; use its conservative 200k client window", - ) + None } async fn dispatch_request( diff --git a/tests/antigravity_schema_parity.rs b/tests/antigravity_schema_parity.rs index 62ce58f3..4bcd911d 100644 --- a/tests/antigravity_schema_parity.rs +++ b/tests/antigravity_schema_parity.rs @@ -320,7 +320,7 @@ impl Fixture { std::fs::write( config_dir.join("config.json"), serde_json::to_vec(&json!({"antigravity":{ - "enabled":true,"baseUrl":origin,"projectId":PROJECT + "enabled":true,"baseUrl":origin,"projectId":PROJECT,"requestMetadata":"legacy" }}))?, )?; let tls = rustls::ServerConfig::builder_with_provider(Arc::new( diff --git a/tests/server.rs b/tests/server.rs index 23d9a8bc..a5bbf547 100644 --- a/tests/server.rs +++ b/tests/server.rs @@ -1918,24 +1918,6 @@ mod antigravity_entry_tests { .unwrap() } - async fn assert_context_rejected(response: axum::response::Response, dispatches: &Dispatches) { - assert!( - dispatches.lock().unwrap().is_empty(), - "unsupported context must fail before either provider handler" - ); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - assert!(!request_id(&response).is_empty()); - let body = error_body(response).await; - assert_eq!(body["type"], "error"); - assert_eq!(body["error"]["type"], "invalid_request_error"); - let message = body["error"]["message"].as_str().unwrap(); - assert!(message.to_ascii_lowercase().contains("antigravity")); - assert!( - message.contains("1m"), - "actionable context error: {message}" - ); - } - #[tokio::test] async fn antigravity_raw_1m_is_rejected_before_normalization_on_both_endpoints() { let (app, dispatches) = capture_app("antigravity", MODELS); @@ -1943,7 +1925,13 @@ mod antigravity_entry_tests { for model in MODELS { for suffix in ["[1m]", "[1M]"] { let response = post(&app, endpoint, &format!("{model}{suffix}"), &[]).await; - assert_context_rejected(response, &dispatches).await; + assert_eq!(response.status(), StatusCode::OK); + let body = error_body(response).await; + assert_eq!(body, json!({"model": model, "provider": "antigravity"})); + assert_eq!( + std::mem::take(&mut *dispatches.lock().unwrap()), + vec![(*endpoint, (*model).to_string())] + ); } } } @@ -1971,7 +1959,13 @@ mod antigravity_entry_tests { for model in MODELS { for headers in &header_cases { let response = post(&app, endpoint, model, headers).await; - assert_context_rejected(response, &dispatches).await; + assert_eq!(response.status(), StatusCode::OK); + let body = error_body(response).await; + assert_eq!(body, json!({"model": model, "provider": "antigravity"})); + assert_eq!( + std::mem::take(&mut *dispatches.lock().unwrap()), + vec![(*endpoint, (*model).to_string())] + ); } } } @@ -2116,22 +2110,14 @@ mod antigravity_entry_tests { "gpt-6-luna", r#"{"role":"user","content":"review","unsupported":true}"#, ), - Vec::new(), + Vec::<&str>::new(), ), ( raw( "gpt-6-luna", r#"{"role":"user","content":[{"type":"text","text":"first","text":"second"}]}"#, ), - Vec::new(), - ), - ( - raw("gpt-6-luna[1m]", r#"{"role":"user","content":"review"}"#), - Vec::new(), - ), - ( - raw("gpt-6-luna", r#"{"role":"user","content":"review"}"#), - vec!["unrelated", "other, context-1m-2025-08-07"], + Vec::<&str>::new(), ), ]; for (body, betas) in cases { @@ -2259,7 +2245,7 @@ mod antigravity_entry_tests { .unwrap(); assert_eq!( line, - "antigravity: antigravity/gemini-4-argon-high, antigravity/gemini-4-argon-low, antigravity/gemini-4-argon-medium" + "antigravity: antigravity/claude-opus-5-5-high, antigravity/claude-opus-5-5-low, antigravity/claude-opus-5-5-medium, antigravity/gemini-4-argon-high, antigravity/gemini-4-argon-low, antigravity/gemini-4-argon-medium" ); assert!( !run("0", &["codex", "auth", "status", "--json"]) From a42a8f04fe51dc25751c56d824686bac2d78ca45 Mon Sep 17 00:00:00 2001 From: Chris Liu <11517021+chris7iu@users.noreply.github.com> Date: Sun, 8 Nov 2026 08:32:41 +0000 Subject: [PATCH 19/30] feat(antigravity): unify v1 agent protocol and canonicalize client-normalized tool inputs --- README.md | 43 - src/config.rs | 2 +- src/providers/antigravity/config.rs | 64 -- src/providers/antigravity/mod.rs | 73 +- src/providers/antigravity/replay.rs | 700 +++++++--------- src/providers/antigravity/request.rs | 336 ++------ src/providers/antigravity_http_tests.rs | 49 +- src/providers/antigravity_tests.rs | 746 +++++++----------- tests/antigravity_schema_parity.rs | 534 ++++++++----- tests/fixtures/antigravity-golden-v1.json | 92 ++- tests/fixtures/antigravity-negative-v1.json | 26 +- .../antigravity-parameterless-v1.json | 2 +- 12 files changed, 1111 insertions(+), 1556 deletions(-) diff --git a/README.md b/README.md index 9d058a8f..22173f3f 100644 --- a/README.md +++ b/README.md @@ -119,49 +119,6 @@ gates; offline tests are not evidence of account access. See the and [configuration](docs/src/content/docs/reference/configuration.md#experimental-antigravity) before explicitly enabling it. Existing provider defaults and aliases are unchanged. -### Native request metadata policy - -The closed file setting `antigravity.requestMetadata` in `config.json` accepts -`"agy"` (the default) or `"legacy"`: - -```json -{"antigravity":{"requestMetadata":"agy"}} -``` - -This does **not** enable the default-off provider, change GPT routing, import -credentials, grant billing consent, or change OAuth or the existing explicit -HTTP User-Agent policy. Existing User-Agent overrides are never silently rewritten. -There is no environment or per-request HTTP escape for this selector. - -- Existing V1 histories stay legacy even with `"agy"`; start a fresh session for - V2. V2 requires `"agy"` and rejects mixed versions or changed model/tier, - profile or project scope. Its signed empty-input FNV **backend session** is - separate from logical conversation/trajectory UUIDs and proxy-owned USER - execution identities. `last_execution_id` names the previously recorded USER - execution, not a fresh model-generation ID; tool loops retain their active - execution and predecessor. The bounded digest checks consistency, **not - authentication, stale-parent rejection or at-most-once execution**. -- The new-policy native count estimator is local and pure: no profile/credential reads, - refresh/discovery, network dispatch, clock/ID allocation or lineage mutation. - It may use an immutable nonsecret scope published by validated native serving; - unknown scope uses only bounded carrier/model/configured-project consistency. - Neither an estimate nor a caller scope claim authorizes generation or publishes - trusted scope. Legacy counting is unchanged. -- Qualified success cases are main-USER text progression and one complete, - ID-bearing signed tool call followed by one successful STRING tool result - (read-tool reference), preserving native call IDs/signatures and the separate - MODEL-role `functionResponse`/`output` wrapper. LOW/MEDIUM/HIGH use public source - enum mappings M419/M418/M417 and shared synthetic tests; genuine official-client - runtime reference evidence is **LOW-only**, not all-tier entitlement or Google - acceptance. Local scripted responses and stock-client interoperability do not - establish account access. -- Parallel/error/ID-less/compound tool continuations and subagent ancestry are not - qualified. Failed or cancelled turns do not fabricate successful checkpoints; - MAX_TOKENS continuations are rejected rather than treated as completed USER - executions. Native error/cancellation append behavior and hidden attempt - counters are not claimed equivalent; no automatic retry or execution guarantee - is added. Model selectors and context-window claims remain unchanged. - Auto-review classifiers route by their own requested model, not the main conversation's model. Recognized Claude aliases follow Codex/Kimi session affinity or `aliasProvider`; Grok does not establish alias affinity. Explicit Antigravity requests ignore diff --git a/src/config.rs b/src/config.rs index 98824928..8e7c425b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -715,7 +715,7 @@ pub fn codex_previous_response_id() -> bool { } pub fn codex_request_compression() -> bool { - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); if let Some(raw) = env.get("CCP_CODEX_REQUEST_COMPRESSION") { match raw.trim().to_ascii_lowercase().as_str() { "1" | "true" | "yes" | "on" => return true, diff --git a/src/providers/antigravity/config.rs b/src/providers/antigravity/config.rs index acc79ff6..9a562333 100644 --- a/src/providers/antigravity/config.rs +++ b/src/providers/antigravity/config.rs @@ -7,16 +7,9 @@ use url::Url; pub(super) const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum RequestMetadata { - Legacy, - Agy, -} - #[derive(Clone)] pub(crate) struct Config { pub enabled: bool, - pub request_metadata: RequestMetadata, pub base_url: Url, pub token_url: Url, pub authorization_url: Url, @@ -48,7 +41,6 @@ impl Default for Config { fn default() -> Self { Self { enabled: false, - request_metadata: RequestMetadata::Agy, base_url: Url::parse("https://daily-cloudcode-pa.googleapis.com").unwrap(), token_url: Url::parse("https://oauth2.googleapis.com/token").unwrap(), authorization_url: Url::parse("https://accounts.google.com/o/oauth2/v2/auth").unwrap(), @@ -114,18 +106,10 @@ impl Config { "clientSecret", "projectId", "userAgent", - "requestMetadata", ], &[], )?; let mut result = Self::default(); - if let Some(mode) = section.get("requestMetadata") { - result.request_metadata = match mode.as_str() { - Some("legacy") => RequestMetadata::Legacy, - Some("agy") => RequestMetadata::Agy, - _ => return Err("invalid Antigravity request metadata policy"), - }; - } let file_user_agent = section .get("userAgent") .map(|value| value.as_str().ok_or("invalid Antigravity user agent")) @@ -200,54 +184,6 @@ impl Config { mod tests { use super::*; - #[test] - fn antigravity_request_metadata_policy_is_explicit_and_closed() { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("config.json"); - let default = Config::read(&path, |key| { - (key == "CCP_ANTIGRAVITY_REQUEST_METADATA").then(|| "legacy".into()) - }) - .unwrap(); - assert_eq!(default.request_metadata, RequestMetadata::Agy); - assert!(!default.enabled); - for (value, expected) in [ - ("legacy", RequestMetadata::Legacy), - ("agy", RequestMetadata::Agy), - ] { - std::fs::write( - &path, - json!({"antigravity":{"requestMetadata":value}}).to_string(), - ) - .unwrap(); - let config = Config::read(&path, |_| None).unwrap(); - assert_eq!(config.request_metadata, expected); - assert!(!config.enabled); - assert_eq!(config.native_headers()["user-agent"], default_user_agent()); - } - for value in [ - json!(null), - json!(false), - json!(1), - json!([]), - json!({}), - json!(""), - json!("AGY"), - json!(" agy"), - json!("agy "), - json!("unknown-sentinel"), - ] { - std::fs::write( - &path, - json!({"antigravity":{"requestMetadata":value}}).to_string(), - ) - .unwrap(); - assert_eq!( - Config::read(&path, |_| None).err(), - Some("invalid Antigravity request metadata policy") - ); - } - } - #[test] fn antigravity_consumer_default_origin_preserves_oauth_and_identity() { let directory = tempfile::tempdir().unwrap(); diff --git a/src/providers/antigravity/mod.rs b/src/providers/antigravity/mod.rs index 7412e60f..205f4821 100644 --- a/src/providers/antigravity/mod.rs +++ b/src/providers/antigravity/mod.rs @@ -96,11 +96,10 @@ impl AntigravityProvider { Err(_) => return authentication("Antigravity credentials are invalid or unreadable"), }; let binding = binding(auth, &body, stored.as_ref()); - let mut prepared = - match request::prepare_with_policy(&body, &binding, auth.config.request_metadata) { - Ok(p) => p, - Err(e) => return invalid(e), - }; + let mut prepared = match request::prepare(&body, &binding) { + Ok(p) => p, + Err(e) => return invalid(e), + }; let generation = match &self.generation { Ok(client) => client, Err(_) => { @@ -138,15 +137,12 @@ impl AntigravityProvider { "Antigravity project missing; run auth login or configure your authorized project", ); } - if !matches!(prepared.metadata, request::Metadata::Legacy) { - // Fresh UUIDs are allocated only after access/discovery and actual - // scope validation. Observed text continuations keep carried IDs; - // legacy preparation and its existing nonce behavior are unchanged. - let metadata = request::metadata_millis() - .and_then(|millis| prepared.finalize_metadata(millis, request::metadata_uuid)); - if let Err(error) = metadata { - return upstream_error(error); - } + // Fresh UUIDs are allocated only after access/discovery and actual + // scope validation. Observed continuations keep carried IDs. + let metadata = request::metadata_millis() + .and_then(|millis| prepared.finalize_metadata(millis, request::metadata_uuid)); + if let Err(error) = metadata { + return upstream_error(error); } if let Some(monitor) = ctx.monitor.as_ref() { monitor.model_resolved(&ctx.req_id, body.model.as_deref().unwrap_or_default()); @@ -193,8 +189,7 @@ impl AntigravityProvider { return upstream_error("Antigravity returned an unexpected content type"); } let model = body.model.unwrap_or_default(); - let serving_scope = (auth.config.request_metadata == config::RequestMetadata::Agy) - .then(|| self.serving_scope.clone()); + let serving_scope = self.serving_scope.clone(); let work = async move { let turn = tokio::time::timeout_at(request_deadline, transport::collect(upstream)) .await @@ -208,7 +203,7 @@ impl AntigravityProvider { } let mapping = prepared.tools.mapping(&turn.contents)?; let blocks = match &prepared.metadata { - request::Metadata::Ready(state) => replay::v2::encode( + request::Metadata::Ready(state) => replay::encode( &turn.contents, &binding, state, @@ -216,22 +211,13 @@ impl AntigravityProvider { &prepared.display, &mapping, )?, - request::Metadata::Legacy => replay::encode( - &turn.contents, - &binding, - &prepared.session, - &prepared.display, - &mapping, - )?, _ => return Err("native metadata was not finalized"), }; // Only actual successful access/binding plus a fully validated native // turn/projection may publish these nonsecret count inputs. Errors or // cancellation before this point publish nothing; no carrier claim is // authoritative and this snapshot never authorizes generation. - if let Some(scope) = &serving_scope - && let Ok(mut published) = scope.write() - { + if let Ok(mut published) = serving_scope.write() { *published = Some(request::ServingScope { profile_id: binding.profile_id.clone(), project_id: binding.project_id.clone(), @@ -349,29 +335,16 @@ impl Provider for AntigravityProvider { Ok(auth) => auth, Err(_) => return invalid("Antigravity configuration is invalid"), }; - if auth.config.request_metadata == config::RequestMetadata::Agy { - let scope = self.count_scope_snapshot(); - return match request::estimate( - &body, - request::CountInputs { - serving_scope: scope.as_ref(), - configured_project: auth.config.project.as_deref(), - }, - ) { - Ok(estimate) => Json(json!({"input_tokens":estimate})).into_response(), - Err(error) => invalid(error), - }; - } - // Frozen default/legacy count behavior intentionally remains separate. - #[cfg(test)] - request::effects::note(request::effects::Kind::CredentialLoad); - let credential = match auth.store.load() { - Ok(c) => c, - Err(_) => return authentication("Antigravity credentials invalid or unreadable"), - }; - match request::prepare(&body, &binding(auth, &body, credential.as_ref())) { - Ok(p) => Json(json!({"input_tokens":p.estimate})).into_response(), - Err(e) => invalid(e), + let scope = self.count_scope_snapshot(); + match request::estimate( + &body, + request::CountInputs { + serving_scope: scope.as_ref(), + configured_project: auth.config.project.as_deref(), + }, + ) { + Ok(estimate) => Json(json!({"input_tokens":estimate})).into_response(), + Err(error) => invalid(error), } } async fn finish_maintenance(&self) -> anyhow::Result<()> { diff --git a/src/providers/antigravity/replay.rs b/src/providers/antigravity/replay.rs index 31dbd2cc..cb40dd77 100644 --- a/src/providers/antigravity/replay.rs +++ b/src/providers/antigravity/replay.rs @@ -10,6 +10,8 @@ use std::collections::HashSet; pub(crate) type Result = std::result::Result; pub const PREFIX: &str = "ccp:antigravity:v1:"; +pub(crate) const BACKEND_SESSION: &str = "-3750763034362895579"; +pub(crate) const MAX_CHECKPOINTS: u64 = 512; pub const MAX_DECODED: usize = 8 * 1024 * 1024; pub const MAX_ENCODED: usize = 12 * 1024 * 1024; pub const MAX_ARGUMENT: usize = 1024 * 1024; @@ -172,21 +174,6 @@ pub(crate) fn canonical(value: &Value) -> Result> { serde_json::to_vec(&tagged(value, 0)?).map_err(|_| "JSON serialization failed") } -pub(crate) fn session_id(value: &str) -> Result<&str> { - if value.len() > 20 { - return Err("invalid session ID"); - } - let n = value.parse::().map_err(|_| "invalid session ID")?; - if n == 0 || n.to_string() != value { - return Err("invalid session ID"); - } - Ok(value) -} - -pub(crate) fn mint_session() -> String { - ((rand::random::() >> 1).max(1)).to_string() -} - fn display(value: &str) -> Result<()> { if matches!(value, "full" | "omitted") { Ok(()) @@ -547,22 +534,258 @@ pub(crate) fn projection_values(blocks: &Value) -> Result { Ok(json!(result)) } +/// Restore original model-emitted `tool_use.input` values from the signed +/// carrier envelope when a client normalizes tool inputs in-place (such as +/// Claude Code's `Bash` tool stripping a redundant `cd && ` prefix via +/// `updatedInput`) while keeping `id`, `name`, and block structure intact. +pub(crate) fn canonicalize_tool_inputs(blocks: &mut Value) { + let Some(list) = blocks.as_array_mut() else { + return; + }; + let Some(encoded) = list + .first() + .filter(|b| b["type"] == "thinking") + .and_then(|b| b["signature"].as_str()) + .and_then(|s| s.strip_prefix(PREFIX)) + else { + return; + }; + if encoded.is_empty() || encoded.len() > MAX_ENCODED || encoded.len() * 3 / 4 > MAX_DECODED { + return; + } + let Ok(raw) = URL_SAFE_NO_PAD.decode(encoded) else { + return; + }; + let Ok(envelope) = parse_json(&raw, MAX_DECODED) else { + return; + }; + let Ok(thought_display) = nonempty(&envelope["thought_display"]) else { + return; + }; + let Ok(expected) = projection( + &envelope["contents"], + thought_display, + &envelope["tool_mapping"], + ) else { + return; + }; + let Some(expected_list) = expected.as_array() else { + return; + }; + let expected_calls: Vec<&Value> = expected_list + .iter() + .filter(|b| b["type"] == "tool_use") + .collect(); + if expected_calls.is_empty() { + return; + } + let actual_calls: Vec = list + .iter() + .enumerate() + .filter_map(|(i, b)| (b["type"] == "tool_use").then_some(i)) + .collect(); + if actual_calls.len() != expected_calls.len() { + return; + } + for (&idx, exp) in actual_calls.iter().zip(&expected_calls) { + let block = &list[idx]; + if keys( + block, + &["type", "signature", "cache_control", "id", "name", "input"], + &["type", "id", "name", "input"], + ) + .is_err() + || !block["input"].is_object() + || block["id"] != exp["id"] + || block["name"] != exp["name"] + || canonical(&block["input"]).is_err() + { + return; + } + } + for (&idx, exp) in actual_calls.iter().zip(&expected_calls) { + list[idx]["input"] = exp["input"].clone(); + } +} + +/// Extract only an UNTRUSTED scope claim for local estimation when no serving +/// snapshot exists. The caller must still run the original full version/digest/ +/// history validators with this binding. Never publish it or use it as admission. +pub(crate) fn claimed_binding( + blocks: &Value, + native_model: &str, + configured_project: Option<&str>, +) -> Result { + let first = blocks + .as_array() + .and_then(|b| b.first()) + .filter(|b| b["type"] == "thinking") + .ok_or("missing carrier")?; + let signature = nonempty(&first["signature"])?; + let encoded = signature + .strip_prefix(PREFIX) + .ok_or("missing/foreign carrier")?; + if encoded.is_empty() || encoded.len() > MAX_ENCODED || encoded.len() * 3 / 4 > MAX_DECODED { + return Err("carrier byte limit"); + } + let raw = URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| "malformed carrier")?; + let envelope = parse_json(&raw, MAX_DECODED)?; + let project = nonempty(&envelope["project_id"])?; + if envelope["native_model"].as_str() != Some(native_model) + || configured_project.is_some_and(|p| p != project) + { + return Err("model/configured-project estimate scope mismatch"); + } + Ok(Binding { + profile_id: nonempty(&envelope["profile_id"])?.to_owned(), + project_id: project.to_owned(), + native_model: native_model.to_owned(), + }) +} + +fn uuid_v4(value: &str) -> Result<()> { + let id = uuid::Uuid::parse_str(value).map_err(|_| "invalid logical UUID")?; + if id.get_version_num() != 4 + || id.get_variant() != uuid::Variant::RFC4122 + || id.to_string() != value + { + return Err("invalid logical UUID"); + } + Ok(()) +} + +#[derive(Clone, Eq, PartialEq, serde::Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Lineage { + pub conversation_id: String, + pub trajectory_id: String, +} + +impl Lineage { + pub(crate) fn new(conversation_id: String, trajectory_id: String) -> Result { + let value = Self { + conversation_id, + trajectory_id, + }; + value.validate()?; + Ok(value) + } + + pub(crate) fn validate(&self) -> Result<()> { + for value in [&self.conversation_id, &self.trajectory_id] { + uuid_v4(value)?; + } + if self.conversation_id == self.trajectory_id { + return Err("logical UUID collision"); + } + Ok(()) + } +} + +#[derive(Clone, serde::Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct UserExecution { + pub active_id: String, + /// Last recorded predecessor when THIS user execution began. Immutable + /// through its tool loop; never minted while rendering the label. + pub prior_recorded_id: Option, +} + +#[derive(Clone, serde::Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct State { + pub lineage: Lineage, + /// Our native assistant-checkpoint ordinal, not a hidden Go counter. + pub native_checkpoint_index: u64, + pub user_execution: UserExecution, +} + +impl State { + pub(crate) fn validate(&self) -> Result<()> { + self.lineage.validate()?; + if self.native_checkpoint_index >= MAX_CHECKPOINTS { + return Err("checkpoint limit"); + } + let execution = &self.user_execution; + uuid_v4(&execution.active_id)?; + if let Some(prior) = &execution.prior_recorded_id { + uuid_v4(prior)?; + if self.native_checkpoint_index == 0 || prior == &execution.active_id { + return Err("invalid execution predecessor"); + } + } + for id in std::iter::once(&execution.active_id).chain(execution.prior_recorded_id.iter()) { + if id == &self.lineage.conversation_id || id == &self.lineage.trajectory_id { + return Err("execution/lineage UUID collision"); + } + } + Ok(()) + } + + /// Read only after bounded envelope validation. No allocation sources, + /// clock, auth/profile access or publication of authoritative scope. + pub(crate) fn from_envelope(envelope: &Value) -> Result { + keys( + &envelope["user_execution"], + &["active_id", "prior_recorded_id"], + &["active_id", "prior_recorded_id"], + )?; + let state: Self = serde_json::from_value(json!({ + "lineage":envelope["lineage"], + "native_checkpoint_index":envelope["native_checkpoint_index"], + "user_execution":envelope["user_execution"] + })) + .map_err(|_| "invalid state")?; + state.validate()?; + Ok(state) + } + + /// Successful final completion records active for the NEXT user. + pub(crate) fn recorded_after(&self, stop: &str) -> Result> { + match stop { + "end_turn" => Ok(Some(&self.user_execution.active_id)), + "tool_use" | "max_tokens" => Ok(self.user_execution.prior_recorded_id.as_deref()), + _ => Err("unsupported checkpoint stop reason"), + } + } +} + +fn stop_reason(stop: &str, mapping: &Value) -> Result<()> { + let has_calls = !mapping.as_array().ok_or("invalid tool mapping")?.is_empty(); + match stop { + "end_turn" if !has_calls => Ok(()), + "tool_use" if has_calls => Ok(()), + "max_tokens" => Ok(()), + _ => Err("inconsistent checkpoint stop reason"), + } +} + pub(crate) fn digest_input(blocks: &Value, envelope: &Value) -> Result> { let mut binding = Map::new(); for key in [ + "version", "profile_id", "project_id", "native_model", - "session_id", + "backend_session_id", + "lineage", + "native_checkpoint_index", + "user_execution", + "checkpoint_stop_reason", "thought_display", "tool_mapping", ] { binding.insert( - key.to_string(), + key.to_owned(), envelope.get(key).ok_or("missing binding")?.clone(), ); } - canonical(&json!({"binding":binding, "blocks":projection_values(blocks)?})) + canonical(&json!({ + "domain":"ccp:antigravity:v1:projection", "binding":binding, + "blocks":projection_values(blocks)? + })) } fn digest(blocks: &Value, envelope: &Value) -> Result { @@ -575,37 +798,35 @@ fn digest(blocks: &Value, envelope: &Value) -> Result { pub(crate) fn encode( contents: &Value, binding: &Binding, - session: &str, + state: &State, + checkpoint_stop_reason: &str, thought_display: &str, mapping: &Value, ) -> Result { - session_id(session)?; + state.validate()?; let mut blocks = projection(contents, thought_display, mapping)?; + stop_reason(checkpoint_stop_reason, mapping)?; let mut envelope = serde_json::to_value(binding).map_err(|_| "invalid binding")?; - if [ - &binding.profile_id, - &binding.project_id, - &binding.native_model, - ] - .iter() - .any(|v| v.is_empty()) - { - return Err("empty binding"); - } - let fields = json!({"version":1, "session_id":session, "thought_display":thought_display, "contents":contents, "tool_mapping":mapping}); + let fields = json!({ + "version":1, "backend_session_id":BACKEND_SESSION, "lineage":state.lineage, + "native_checkpoint_index":state.native_checkpoint_index, + "user_execution":state.user_execution, + "checkpoint_stop_reason":checkpoint_stop_reason, + "thought_display":thought_display, "contents":contents, "tool_mapping":mapping + }); envelope .as_object_mut() .unwrap() .extend(fields.as_object().unwrap().clone()); envelope["projection_digest"] = json!(digest(&blocks, &envelope)?); let raw = serde_json::to_vec(&envelope).map_err(|_| "invalid envelope")?; - // Same bounded parser as replay; don't emit a carrier we cannot decode. parse_json(&raw, MAX_DECODED)?; let encoded = URL_SAFE_NO_PAD.encode(raw); if encoded.len() > MAX_ENCODED { return Err("encoded byte limit"); } blocks[0]["signature"] = json!(format!("{PREFIX}{encoded}")); + decode(&blocks, binding)?; Ok(blocks) } @@ -634,7 +855,11 @@ pub(crate) fn decode(blocks: &Value, binding: &Binding) -> Result { "profile_id", "project_id", "native_model", - "session_id", + "backend_session_id", + "lineage", + "native_checkpoint_index", + "user_execution", + "checkpoint_stop_reason", "thought_display", "contents", "tool_mapping", @@ -653,12 +878,19 @@ pub(crate) fn decode(blocks: &Value, binding: &Binding) -> Result { return Err("model/profile/project binding mismatch; start a fresh session"); } } - session_id(nonempty(&envelope["session_id"])?)?; + if envelope["backend_session_id"] != BACKEND_SESSION { + return Err("invalid backend session"); + } + State::from_envelope(&envelope)?; let expected = projection( &envelope["contents"], nonempty(&envelope["thought_display"])?, &envelope["tool_mapping"], )?; + stop_reason( + nonempty(&envelope["checkpoint_stop_reason"])?, + &envelope["tool_mapping"], + )?; let hash = digest(&expected, &envelope)?; if envelope["projection_digest"] != hash || digest(blocks, &envelope)? != hash { return Err("projection digest mismatch"); @@ -669,387 +901,49 @@ pub(crate) fn decode(blocks: &Value, binding: &Binding) -> Result { pub(crate) fn decode_history(turns: &Value, binding: &Binding) -> Result> { let turns = turns .as_array() - .filter(|t| t.len() <= 512) + .filter(|t| t.len() <= MAX_CHECKPOINTS as usize) .ok_or("history limit/type")?; let mut result = Vec::new(); - let mut session = None; + let mut previous: Option<(State, String)> = None; + let mut executions = HashSet::new(); let mut ids = HashSet::new(); - for turn in turns { + for (index, turn) in turns.iter().enumerate() { let envelope = decode(turn, binding)?; - let current = envelope["session_id"].as_str().unwrap().to_string(); - if session.as_ref().is_some_and(|s| s != ¤t) { - return Err("conflicting session IDs"); - } - session = Some(current); - for entry in envelope["tool_mapping"].as_array().unwrap() { - if !ids.insert(entry["local_id"].as_str().unwrap().to_string()) { - return Err("duplicate local ID across history"); - } - } - result.push(envelope); - } - Ok(result) -} - -/// Extract only an UNTRUSTED scope claim for local estimation when no serving -/// snapshot exists. The caller must still run the original full version/digest/ -/// history validators with this binding. Never publish it or use it as admission. -pub(crate) fn claimed_binding( - blocks: &Value, - native_model: &str, - configured_project: Option<&str>, -) -> Result { - let first = blocks - .as_array() - .and_then(|b| b.first()) - .filter(|b| b["type"] == "thinking") - .ok_or("missing carrier")?; - let signature = nonempty(&first["signature"])?; - let encoded = signature - .strip_prefix(PREFIX) - .or_else(|| signature.strip_prefix(v2::PREFIX)) - .ok_or("missing/foreign carrier")?; - if encoded.is_empty() || encoded.len() > MAX_ENCODED || encoded.len() * 3 / 4 > MAX_DECODED { - return Err("carrier byte limit"); - } - let raw = URL_SAFE_NO_PAD - .decode(encoded) - .map_err(|_| "malformed carrier")?; - let envelope = parse_json(&raw, MAX_DECODED)?; - let project = nonempty(&envelope["project_id"])?; - if envelope["native_model"].as_str() != Some(native_model) - || configured_project.is_some_and(|p| p != project) - { - return Err("model/configured-project estimate scope mismatch"); - } - Ok(Binding { - profile_id: nonempty(&envelope["profile_id"])?.to_owned(), - project_id: project.to_owned(), - native_model: native_model.to_owned(), - }) -} - -/// Separate bounded V2 consistency state. V1 bytes/semantics above remain frozen. -/// Client-carried consistency is NOT ownership authentication, server-side -/// stale-parent rejection or at-most-once execution; replay remains possible. -/// IDs and checkpoint ordinals below are proxy-owned representations, not copied -/// native IDs or claims about hidden Go allocation timing. Successful user -/// completion records active for the next user; its tool loop retains prior. -/// Native Go can append nonnil metadata on errors too: conservative no-checkpoint -/// failure/cancellation here is NOT a claim of native error-path equivalence. -pub(crate) mod v2 { - use super::*; - - pub(crate) const PREFIX: &str = "ccp:antigravity:v2:"; - pub(crate) const BACKEND_SESSION: &str = "-3750763034362895579"; - pub(crate) const MAX_CHECKPOINTS: u64 = 512; - - fn uuid_v4(value: &str) -> Result<()> { - let id = uuid::Uuid::parse_str(value).map_err(|_| "invalid V2 logical UUID")?; - if id.get_version_num() != 4 - || id.get_variant() != uuid::Variant::RFC4122 - || id.to_string() != value - { - return Err("invalid V2 logical UUID"); - } - Ok(()) - } - - #[derive(Clone, Eq, PartialEq, serde::Serialize, Deserialize)] - #[serde(deny_unknown_fields)] - pub(crate) struct Lineage { - pub conversation_id: String, - pub trajectory_id: String, - } - - impl Lineage { - pub(crate) fn new(conversation_id: String, trajectory_id: String) -> Result { - let value = Self { - conversation_id, - trajectory_id, - }; - value.validate()?; - Ok(value) + let state = State::from_envelope(&envelope)?; + if state.native_checkpoint_index != index as u64 { + return Err("conflicting checkpoint order"); } - - pub(crate) fn validate(&self) -> Result<()> { - for value in [&self.conversation_id, &self.trajectory_id] { - uuid_v4(value)?; + if let Some((prior, stop)) = &previous { + if prior.lineage != state.lineage { + return Err("conflicting logical conversation IDs"); } - if self.conversation_id == self.trajectory_id { - return Err("V2 logical UUID collision"); + if state.user_execution.prior_recorded_id.as_deref() != prior.recorded_after(stop)? { + return Err("conflicting recorded execution predecessor"); } - Ok(()) - } - } - - #[derive(Clone, serde::Serialize, Deserialize)] - #[serde(deny_unknown_fields)] - pub(crate) struct UserExecution { - pub active_id: String, - /// Last recorded predecessor when THIS user execution began. Immutable - /// through its tool loop; never minted while rendering the label. - pub prior_recorded_id: Option, - } - - #[derive(Clone, serde::Serialize, Deserialize)] - #[serde(deny_unknown_fields)] - pub(crate) struct State { - pub lineage: Lineage, - /// Our native assistant-checkpoint ordinal, not a hidden Go counter. - pub native_checkpoint_index: u64, - pub user_execution: UserExecution, - } - - impl State { - pub(crate) fn validate(&self) -> Result<()> { - self.lineage.validate()?; - if self.native_checkpoint_index >= MAX_CHECKPOINTS { - return Err("V2 checkpoint limit"); - } - let execution = &self.user_execution; - uuid_v4(&execution.active_id)?; - if let Some(prior) = &execution.prior_recorded_id { - uuid_v4(prior)?; - if self.native_checkpoint_index == 0 || prior == &execution.active_id { - return Err("invalid V2 execution predecessor"); + match stop.as_str() { + "end_turn" if executions.contains(&state.user_execution.active_id) => { + return Err("reused USER execution identity"); } - } - for id in - std::iter::once(&execution.active_id).chain(execution.prior_recorded_id.iter()) - { - if id == &self.lineage.conversation_id || id == &self.lineage.trajectory_id { - return Err("V2 execution/lineage UUID collision"); + "tool_use" if state.user_execution.active_id != prior.user_execution.active_id => { + return Err("tool loop changed active execution"); } - } - Ok(()) - } - - /// Read only after bounded envelope validation. No allocation sources, - /// clock, auth/profile access or publication of authoritative scope. - pub(crate) fn from_envelope(envelope: &Value) -> Result { - keys( - &envelope["user_execution"], - &["active_id", "prior_recorded_id"], - &["active_id", "prior_recorded_id"], - )?; - let state: Self = serde_json::from_value(json!({ - "lineage":envelope["lineage"], - "native_checkpoint_index":envelope["native_checkpoint_index"], - "user_execution":envelope["user_execution"] - })) - .map_err(|_| "invalid V2 state")?; - state.validate()?; - Ok(state) - } - - /// Successful final completion records active for the NEXT user. This - /// pure derivation is our conservative policy, not Go error/cancel parity. - pub(crate) fn recorded_after(&self, stop: &str) -> Result> { - match stop { - "end_turn" => Ok(Some(&self.user_execution.active_id)), - "tool_use" | "max_tokens" => Ok(self.user_execution.prior_recorded_id.as_deref()), - _ => Err("unsupported V2 checkpoint stop reason"), - } - } - } - - fn stop_reason(stop: &str, mapping: &Value) -> Result<()> { - let has_calls = !mapping.as_array().ok_or("invalid tool mapping")?.is_empty(); - match stop { - "end_turn" if !has_calls => Ok(()), - "tool_use" if has_calls => Ok(()), - "max_tokens" => Ok(()), - _ => Err("inconsistent V2 checkpoint stop reason"), - } - } - - fn digest(blocks: &Value, envelope: &Value) -> Result { - let mut binding = Map::new(); - for key in [ - "version", - "profile_id", - "project_id", - "native_model", - "backend_session_id", - "lineage", - "native_checkpoint_index", - "user_execution", - "checkpoint_stop_reason", - "thought_display", - "tool_mapping", - ] { - binding.insert( - key.to_owned(), - envelope.get(key).ok_or("missing V2 binding")?.clone(), - ); - } - let input = canonical(&json!({ - "domain":"ccp:antigravity:v2:projection", "binding":binding, - "blocks":projection_values(blocks)? - }))?; - Ok(format!("{:x}", Sha256::digest(input))) - } - - pub(crate) fn encode( - contents: &Value, - binding: &Binding, - state: &State, - checkpoint_stop_reason: &str, - thought_display: &str, - mapping: &Value, - ) -> Result { - state.validate()?; - let mut blocks = projection(contents, thought_display, mapping)?; - stop_reason(checkpoint_stop_reason, mapping)?; - let mut envelope = serde_json::to_value(binding).map_err(|_| "invalid binding")?; - let fields = json!({ - "version":2, "backend_session_id":BACKEND_SESSION, "lineage":state.lineage, - "native_checkpoint_index":state.native_checkpoint_index, - "user_execution":state.user_execution, - "checkpoint_stop_reason":checkpoint_stop_reason, - "thought_display":thought_display, "contents":contents, "tool_mapping":mapping - }); - envelope - .as_object_mut() - .unwrap() - .extend(fields.as_object().unwrap().clone()); - envelope["projection_digest"] = json!(digest(&blocks, &envelope)?); - let raw = serde_json::to_vec(&envelope).map_err(|_| "invalid V2 envelope")?; - parse_json(&raw, MAX_DECODED)?; - let encoded = URL_SAFE_NO_PAD.encode(raw); - if encoded.len() > MAX_ENCODED { - return Err("encoded byte limit"); - } - blocks[0]["signature"] = json!(format!("{PREFIX}{encoded}")); - // A production encoder must not emit state that its bounded scope and - // projection/state validator rejects. No completed checkpoint is encoded - // by the generation pipeline until the native turn and tool mapping pass. - decode(&blocks, binding)?; - Ok(blocks) - } - - pub(crate) fn decode(blocks: &Value, binding: &Binding) -> Result { - let list = blocks - .as_array() - .filter(|b| !b.is_empty()) - .ok_or("missing V2 carrier")?; - if list[0]["type"] != "thinking" - || list.iter().filter(|b| b["type"] == "thinking").count() != 1 - { - return Err("missing/duplicate/nonleading V2 carrier"); - } - let encoded = list[0]["signature"] - .as_str() - .and_then(|s| s.strip_prefix(PREFIX)) - .ok_or("missing/foreign V2 carrier")?; - if encoded.is_empty() || encoded.len() > MAX_ENCODED || encoded.len() * 3 / 4 > MAX_DECODED - { - return Err("carrier byte limit"); - } - let raw = URL_SAFE_NO_PAD - .decode(encoded) - .map_err(|_| "malformed V2 carrier")?; - let envelope = parse_json(&raw, MAX_DECODED)?; - let fields = [ - "version", - "profile_id", - "project_id", - "native_model", - "backend_session_id", - "lineage", - "native_checkpoint_index", - "user_execution", - "checkpoint_stop_reason", - "thought_display", - "contents", - "tool_mapping", - "projection_digest", - ]; - keys(&envelope, &fields, &fields)?; - if envelope["version"].as_u64() != Some(2) { - return Err("unsupported V2 carrier version"); - } - for (key, value) in [ - ("profile_id", &binding.profile_id), - ("project_id", &binding.project_id), - ("native_model", &binding.native_model), - ] { - if value.is_empty() || envelope[key].as_str() != Some(value) { - return Err("model/profile/project binding mismatch; start a fresh session"); - } - } - if envelope["backend_session_id"] != BACKEND_SESSION { - return Err("invalid V2 backend session"); - } - State::from_envelope(&envelope)?; - let expected = projection( - &envelope["contents"], - nonempty(&envelope["thought_display"])?, - &envelope["tool_mapping"], - )?; - stop_reason( - nonempty(&envelope["checkpoint_stop_reason"])?, - &envelope["tool_mapping"], - )?; - let hash = digest(&expected, &envelope)?; - if envelope["projection_digest"] != hash || digest(blocks, &envelope)? != hash { - return Err("V2 projection digest mismatch"); - } - Ok(envelope) - } - - pub(crate) fn decode_history(turns: &Value, binding: &Binding) -> Result> { - let turns = turns - .as_array() - .filter(|t| t.len() <= MAX_CHECKPOINTS as usize) - .ok_or("history limit/type")?; - let mut result = Vec::new(); - let mut previous: Option<(State, String)> = None; - let mut executions = HashSet::new(); - let mut ids = HashSet::new(); - for (index, turn) in turns.iter().enumerate() { - let envelope = decode(turn, binding)?; - let state = State::from_envelope(&envelope)?; - if state.native_checkpoint_index != index as u64 { - return Err("conflicting V2 checkpoint order"); - } - if let Some((prior, stop)) = &previous { - if prior.lineage != state.lineage { - return Err("conflicting V2 logical conversation IDs"); - } - if state.user_execution.prior_recorded_id.as_deref() - != prior.recorded_after(stop)? - { - return Err("conflicting V2 recorded execution predecessor"); - } - match stop.as_str() { - "end_turn" if executions.contains(&state.user_execution.active_id) => { - return Err("reused V2 USER execution identity"); - } - "tool_use" - if state.user_execution.active_id != prior.user_execution.active_id => - { - return Err("V2 tool loop changed active execution"); - } - "max_tokens" => { - return Err("unqualified continuation after incomplete V2 checkpoint"); - } - _ => {} + "max_tokens" => { + return Err("unqualified continuation after incomplete checkpoint"); } + _ => {} } - executions.insert(state.user_execution.active_id.clone()); - previous = Some(( - state, - nonempty(&envelope["checkpoint_stop_reason"])?.to_owned(), - )); - for entry in envelope["tool_mapping"].as_array().unwrap() { - if !ids.insert(entry["local_id"].as_str().unwrap().to_owned()) { - return Err("duplicate local ID across history"); - } + } + executions.insert(state.user_execution.active_id.clone()); + previous = Some(( + state, + nonempty(&envelope["checkpoint_stop_reason"])?.to_owned(), + )); + for entry in envelope["tool_mapping"].as_array().unwrap() { + if !ids.insert(entry["local_id"].as_str().unwrap().to_owned()) { + return Err("duplicate local ID across history"); } - result.push(envelope); } - Ok(result) + result.push(envelope); } + Ok(result) } diff --git a/src/providers/antigravity/request.rs b/src/providers/antigravity/request.rs index 01fcb367..1649ddc6 100644 --- a/src/providers/antigravity/request.rs +++ b/src/providers/antigravity/request.rs @@ -1,5 +1,4 @@ use super::{ - config::RequestMetadata, replay::{self, Binding, Result, keys, nonempty}, schema::ToolSet, }; @@ -38,9 +37,8 @@ pub(crate) mod effects { Factory, Clock, NativeId, - LegacyId, } - thread_local! { static COUNTS: Cell<[usize;6]> = const { Cell::new([0;6]) }; } + thread_local! { static COUNTS: Cell<[usize;5]> = const { Cell::new([0;5]) }; } pub(crate) fn note(kind: Kind) { COUNTS.with(|cell| { let mut counts = cell.get(); @@ -49,14 +47,11 @@ pub(crate) mod effects { }); } pub(crate) fn reset() { - COUNTS.with(|cell| cell.set([0; 6])); + COUNTS.with(|cell| cell.set([0; 5])); } - pub(crate) fn snapshot() -> [usize; 6] { + pub(crate) fn snapshot() -> [usize; 5] { COUNTS.with(Cell::get) } - pub(crate) fn count(kind: Kind) -> usize { - snapshot()[kind as usize] - } } pub(crate) fn metadata_millis() -> Result { @@ -75,18 +70,6 @@ pub(crate) fn metadata_uuid() -> String { uuid::Uuid::new_v4().to_string() } -fn legacy_session() -> String { - #[cfg(test)] - effects::note(effects::Kind::LegacyId); - replay::mint_session() -} - -fn legacy_outer_id() -> String { - #[cfg(test)] - effects::note(effects::Kind::LegacyId); - format!("ccp-{}", uuid::Uuid::new_v4()) -} - #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct ServingScope { pub profile_id: String, @@ -103,21 +86,14 @@ pub(crate) struct CountInputs<'a> { pub configured_project: Option<&'a str>, } -#[derive(Clone, Copy, Eq, PartialEq)] -enum Purpose { - Generate, - Estimate, -} - pub(crate) enum Metadata { - Legacy, Fresh, Continuation { - state: replay::v2::State, + state: replay::State, new_user_execution: bool, execution_ids: Vec, }, - Ready(replay::v2::State), + Ready(replay::State), } pub(crate) struct Prepared { @@ -153,10 +129,10 @@ impl Prepared { _ => return Err("unsupported metadata model"), }; let state = match &self.metadata { - Metadata::Fresh => replay::v2::State { - lineage: replay::v2::Lineage::new(uuid(), uuid())?, + Metadata::Fresh => replay::State { + lineage: replay::Lineage::new(uuid(), uuid())?, native_checkpoint_index: 0, - user_execution: replay::v2::UserExecution { + user_execution: replay::UserExecution { active_id: uuid(), prior_recorded_id: None, }, @@ -170,7 +146,7 @@ impl Prepared { if *new_user_execution { let active = uuid(); if execution_ids.contains(&active) { - return Err("V2 USER execution UUID collision"); + return Err("USER execution UUID collision"); } state.user_execution.active_id = active; } @@ -182,8 +158,8 @@ impl Prepared { let native_checkpoint_index = state.native_checkpoint_index; let last_step = native_checkpoint_index .checked_mul(2) - .ok_or("V2 step overflow")?; - let outer_step = last_step.checked_add(1).ok_or("V2 step overflow")?; + .ok_or("step overflow")?; + let outer_step = last_step.checked_add(1).ok_or("step overflow")?; let lineage = &state.lineage; self.wire["request"]["labels"] = json!({ "model_enum":model_enum, "used_claude":"false", "used_claude_conservative":"false", @@ -198,7 +174,7 @@ impl Prepared { "agent/{}/{unix_millis}/{}/{outer_step}", lineage.conversation_id, lineage.trajectory_id )); - self.session = replay::v2::BACKEND_SESSION.to_owned(); + self.session = replay::BACKEND_SESSION.to_owned(); self.wire["request"]["sessionId"] = json!(self.session); self.metadata = Metadata::Ready(state); Ok(()) @@ -207,11 +183,14 @@ impl Prepared { pub(crate) fn cache_hint(value: Option<&Value>) -> Result<()> { let Some(value) = value else { return Ok(()) }; - keys(value, &["type", "ttl"], &["type"])?; + keys(value, &["type", "ttl", "scope"], &["type"])?; if value["type"] != "ephemeral" || value .get("ttl") .is_some_and(|v| !matches!(v.as_str(), Some("5m" | "1h"))) + || value + .get("scope") + .is_some_and(|v| v.as_str() != Some("global")) { return Err("unsupported cache hint"); } @@ -275,7 +254,7 @@ fn strip_claude_code_system_header(mut text: &str) -> Option<&str> { } } -fn sanitize_system_parts(parts: Vec, policy: RequestMetadata) -> Vec { +fn sanitize_system_parts(parts: Vec) -> Vec { let filtered: Vec = parts .into_iter() .filter_map(|part| { @@ -283,7 +262,7 @@ fn sanitize_system_parts(parts: Vec, policy: RequestMetadata) -> Vec 1 { + if filtered.len() > 1 { let joined = filtered .iter() .filter_map(|part| part["text"].as_str()) @@ -345,10 +324,12 @@ fn options(body: &MessagesRequest) -> Result<&'static str> { } if let Some(output) = body.extra.get("output_config") { keys(output, &["effort"], &[])?; - if output - .get("effort") - .is_some_and(|v| !matches!(v.as_str(), Some("low" | "medium" | "high" | "max"))) - { + if output.get("effort").is_some_and(|v| { + !matches!( + v.as_str(), + Some("low" | "medium" | "high" | "xhigh" | "max") + ) + }) { return Err("unsupported advisory effort"); } } @@ -446,49 +427,6 @@ fn logical_messages(messages: &[Message]) -> Result>> { Ok(output) } -fn native_checkpoint_parts(envelope: &Value) -> Result<()> { - let contents = envelope["contents"].as_array().unwrap(); - if contents.is_empty() { - return Err("V2 continuation requires native content"); - } - let is_claude = envelope["native_model"] - .as_str() - .is_some_and(|m| m.starts_with("claude-")); - // Preserve every raw content/signature and original codec. A completed - // assistant checkpoint counts once regardless of native SSE fragmentation. - for content in contents { - keys(content, &["role", "parts"], &["role", "parts"])?; - let parts = content["parts"].as_array().ok_or("invalid native parts")?; - if parts.is_empty() { - return Err("V2 continuation requires native parts"); - } - for part in parts { - if part.get("functionCall").is_some() { - let required: &[&str] = if is_claude { - &["functionCall"] - } else { - &["functionCall", "thoughtSignature"] - }; - keys(part, &["functionCall", "thoughtSignature"], required)?; - if let Some(sig) = part.get("thoughtSignature") { - nonempty(sig)?; - } - } else { - keys(part, &["text", "thought", "thoughtSignature"], &["text"])?; - if !part["text"].is_string() { - return Err("unsupported V2 native part"); - } - } - if part.get("thought").is_some_and(|v| !v.is_boolean()) - || part.get("thoughtSignature").is_some_and(|v| !v.is_string()) - { - return Err("unsupported V2 native part"); - } - } - } - Ok(()) -} - fn coalesce_claude_native_turn(native: &[Value]) -> Vec { let mut merged_parts: Vec = Vec::new(); for content in native { @@ -546,79 +484,15 @@ fn coalesce_claude_native_turn(native: &[Value]) -> Vec { } fn continuation_input(envelope: &Value, user: &Message) -> Result { - native_checkpoint_parts(envelope)?; match nonempty(&envelope["checkpoint_stop_reason"])? { "end_turn" => { if text_parts(&user.content)?.is_empty() { - return Err("V2 continuation requires ordinary text"); + return Err("continuation requires ordinary text"); } Ok(true) } - "tool_use" => { - // Only the captured single complete, ID-bearing, successful string - // result is qualified here. Parallel/error/ID-less/compound batches - // must not acquire invented Go counter/grouping semantics. - let mapping = envelope["tool_mapping"].as_array().unwrap(); - let calls = replay::calls(&envelope["contents"])?; - let is_claude = envelope["native_model"] - .as_str() - .is_some_and(|m| m.starts_with("claude-")); - if is_claude { - if mapping.is_empty() - || mapping.len() != calls.len() - || calls.iter().any(|c| c.2.get("id").is_none()) - { - return Err("unqualified V2 tool continuation shape"); - } - let blocks = user - .content - .as_array() - .filter(|b| b.len() >= mapping.len()) - .ok_or("V2 tool continuation requires one result")?; - let expected_ids: HashSet<&str> = mapping - .iter() - .filter_map(|m| m["local_id"].as_str()) - .collect(); - let mut seen_ids = HashSet::new(); - for block in blocks.iter().take(mapping.len()) { - let Some(id) = block["tool_use_id"].as_str() else { - return Err("unqualified V2 tool result"); - }; - if block["type"] != "tool_result" - || !expected_ids.contains(id) - || !seen_ids.insert(id) - || !block["content"].is_string() - || block - .get("is_error") - .is_some_and(|v| v.as_bool() != Some(false)) - { - return Err("unqualified V2 tool result"); - } - } - Ok(false) - } else { - if mapping.len() != 1 || calls.len() != 1 || calls[0].2.get("id").is_none() { - return Err("unqualified V2 tool continuation shape"); - } - let blocks = user - .content - .as_array() - .filter(|b| b.len() == 1) - .ok_or("V2 tool continuation requires one result")?; - let block = &blocks[0]; - if block["type"] != "tool_result" - || block["tool_use_id"] != mapping[0]["local_id"] - || !block["content"].is_string() - || block - .get("is_error") - .is_some_and(|v| v.as_bool() != Some(false)) - { - return Err("unqualified V2 tool result"); - } - Ok(false) - } - } - _ => Err("unqualified continuation after incomplete V2 checkpoint"), + "tool_use" => Ok(false), + _ => Err("unqualified continuation after incomplete checkpoint"), } } @@ -627,9 +501,6 @@ fn observed_continuation( envelopes: &[Value], model: &str, ) -> Result { - // The state machine is shared by the existing three source-defined tiers. - // Genuine official runtime reference/entitlement evidence remains LOW-only. - // Keep existing message/history limits and checked proxy-owned progression. let ordered = messages .iter() .filter(|m| m.role != "system") @@ -642,22 +513,22 @@ fn observed_continuation( .enumerate() .any(|(i, m)| m.role != if i % 2 == 0 { "user" } else { "assistant" }) { - return Err("unsupported V2 continuation role sequence"); + return Err("unsupported continuation role sequence"); } if text_parts(&ordered[0].content)?.is_empty() { - return Err("V2 needs an initial text user"); + return Err("request needs an initial text user"); } let mut new_user_execution = false; for (index, envelope) in envelopes.iter().enumerate() { new_user_execution = continuation_input(envelope, ordered[(index + 1) * 2])?; } let last = envelopes.last().unwrap(); - let mut state = replay::v2::State::from_envelope(last)?; + let mut state = replay::State::from_envelope(last)?; state.native_checkpoint_index = state .native_checkpoint_index .checked_add(1) - .filter(|n| *n < replay::v2::MAX_CHECKPOINTS) - .ok_or("V2 checkpoint limit")?; + .filter(|n| *n < replay::MAX_CHECKPOINTS) + .ok_or("checkpoint limit")?; if new_user_execution { state.user_execution.prior_recorded_id = state .recorded_after(nonempty(&last["checkpoint_stop_reason"])?)? @@ -680,8 +551,8 @@ fn observed_continuation( }) } -/// Local semantic estimate only, never generation admission. New-policy counts -/// have no owner/profile reads, refresh/discovery, clock/ID allocation, lineage +/// Local semantic estimate only, never generation admission. Counts have no +/// owner/profile reads, refresh/discovery, clock/ID allocation, lineage /// mutation or scope publication. Failed/unknown scope is not filled from a /// caller claim; successful serving is the sole publisher of trusted inputs. pub(crate) fn estimate(body: &MessagesRequest, inputs: CountInputs<'_>) -> Result { @@ -724,27 +595,10 @@ pub(crate) fn estimate(body: &MessagesRequest, inputs: CountInputs<'_>) -> Resul } } }; - prepare_inner(body, &binding, RequestMetadata::Agy, Purpose::Estimate).map(|p| p.estimate) + prepare(body, &binding).map(|p| p.estimate) } pub(crate) fn prepare(body: &MessagesRequest, binding: &Binding) -> Result { - prepare_with_policy(body, binding, RequestMetadata::Legacy) -} - -pub(crate) fn prepare_with_policy( - body: &MessagesRequest, - binding: &Binding, - policy: RequestMetadata, -) -> Result { - prepare_inner(body, binding, policy, Purpose::Generate) -} - -fn prepare_inner( - body: &MessagesRequest, - binding: &Binding, - policy: RequestMetadata, - purpose: Purpose, -) -> Result { let model = body.model.as_deref().ok_or("missing model")?; if !MODELS.contains(&model) || model.strip_prefix("antigravity/") != Some(&binding.native_model) { @@ -784,85 +638,58 @@ fn prepare_inner( system.extend(text_parts(&message.content)?); } } - let system = sanitize_system_parts(system, policy); - let v2_start_idx = if policy == RequestMetadata::Agy { - messages - .iter() - .rposition(|m| { - m.role == "assistant" - && m.content + let system = sanitize_system_parts(system); + let start_idx = messages + .iter() + .rposition(|m| { + m.role == "assistant" + && m.content + .get(0) + .is_none_or(|block| block["type"] != "thinking") + }) + .filter(|&last_unsigned| { + let prefix = &messages[..=last_unsigned]; + let no_prior_thinking = prefix.iter().all(|m| { + m.role != "assistant" + || m.content .get(0) .is_none_or(|block| block["type"] != "thinking") - }) - .map(|idx| idx + 1) - .unwrap_or(0) - } else { - 0 - }; - let (prefix_messages, active_messages) = messages.split_at(v2_start_idx); - let turns = active_messages + }); + let non_system: Vec<_> = prefix.iter().filter(|m| m.role != "system").collect(); + let valid_alternation = !non_system.is_empty() + && non_system.len() % 2 == 0 + && non_system + .iter() + .enumerate() + .all(|(i, m)| m.role == if i % 2 == 0 { "user" } else { "assistant" }); + no_prior_thinking && valid_alternation && last_unsigned + 1 < messages.len() + }) + .map(|idx| idx + 1) + .unwrap_or(0); + let (prefix_messages, active_messages) = messages.split_at(start_idx); + let mut turns = active_messages .iter() .filter(|m| m.role == "assistant") .map(|m| m.content.clone()) .collect::>(); - // Classify ONLY the leading carrier. V1 deliberately ignores noncarrier - // signatures, including strings that happen to look like another prefix. - let v2_turns = turns - .iter() - .filter(|turn| { - turn.get(0).is_some_and(|block| { - block["type"] == "thinking" - && block["signature"] - .as_str() - .is_some_and(|s| s.starts_with(replay::v2::PREFIX)) - }) - }) - .count(); - let (envelopes, metadata) = if v2_turns != 0 { - if v2_turns != turns.len() { - return Err("mixed transcript carrier versions are unsupported"); - } - if policy != RequestMetadata::Agy { - return Err("V2 history requires the agy request metadata policy"); + for turn in &mut turns { + replay::canonicalize_tool_inputs(turn); + } + let envelopes = replay::decode_history(&json!(turns), binding)?; + let metadata = if turns.is_empty() { + let input = active_messages + .iter() + .filter(|m| m.role != "system") + .collect::>(); + if input.len() != 1 || input[0].role != "user" || text_parts(&input[0].content)?.is_empty() + { + return Err("fresh execution requires one text user input"); } - let envelopes = replay::v2::decode_history(&json!(turns), binding)?; - let metadata = observed_continuation(active_messages, &envelopes, model)?; - (envelopes, metadata) - } else { - let envelopes = replay::decode_history(&json!(turns), binding)?; - let metadata = if policy == RequestMetadata::Agy && turns.is_empty() { - let input = active_messages - .iter() - .filter(|m| m.role != "system") - .collect::>(); - if input.len() != 1 - || input[0].role != "user" - || text_parts(&input[0].content)?.is_empty() - { - return Err("fresh agy execution requires one text user input"); - } - Metadata::Fresh - } else { - Metadata::Legacy - }; - (envelopes, metadata) - }; - let session = if matches!(metadata, Metadata::Legacy) { - envelopes - .first() - .map(|e| e["session_id"].as_str().unwrap().to_string()) - .unwrap_or_else(|| { - if purpose == Purpose::Generate { - legacy_session() - } else { - String::new() - } - }) + Metadata::Fresh } else { - // No new-policy wire execution metadata is rendered before unchanged - // credential access and actual binding validation. Legacy stays as-is. - String::new() + observed_continuation(active_messages, &envelopes, model)? }; + let session = String::new(); let mut history = envelopes.into_iter(); let mut pending: Vec<(Value, Value)> = Vec::new(); let mut contents: Vec = Vec::new(); @@ -1192,13 +1019,8 @@ fn prepare_inner( request["toolConfig"] = json!({"functionCallingConfig":{"mode":"VALIDATED"}}); } } - let outer_id = if matches!(metadata, Metadata::Legacy) && purpose == Purpose::Generate { - legacy_outer_id() - } else { - String::new() - }; let wire = json!({"project":binding.project_id,"model":binding.native_model,"request":request, - "requestId":outer_id,"userAgent":"antigravity","requestType":"agent"}); + "requestId":"","userAgent":"antigravity","requestType":"agent"}); Ok(Prepared { wire, session, diff --git a/src/providers/antigravity_http_tests.rs b/src/providers/antigravity_http_tests.rs index a7268980..4a502470 100644 --- a/src/providers/antigravity_http_tests.rs +++ b/src/providers/antigravity_http_tests.rs @@ -149,7 +149,6 @@ async fn provider_with_user_agent( .unwrap(); drop(lock); let config = Config { - request_metadata: super::antigravity::config::RequestMetadata::Legacy, base_url: url.clone(), client_id: Some("synthetic-client".into()), ..Config::read(&directory.join("config.json"), |key| { @@ -255,7 +254,7 @@ async fn assert_generation_cap_ingress(stream: bool) { assert_eq!(wire["model"], "gemini-4-argon-low"); assert_eq!(wire["userAgent"], "antigravity"); assert_eq!(wire["requestType"], "agent"); - assert_eq!(wire["request"].as_object().unwrap().len(), 3); + assert_eq!(wire["request"].as_object().unwrap().len(), 4); assert_eq!( wire["request"]["generationConfig"]["thinkingConfig"], json!({"includeThoughts":true,"thinkingLevel":"LOW"}) @@ -1313,8 +1312,29 @@ async fn antigravity_opaque_native_signature_corruption_is_upstream_not_local_au project_id: "project".into(), native_model: "gemini-4-argon-low".into(), }; + let lineage = replay::Lineage::new( + "11111111-1111-4111-8111-111111111111".into(), + "22222222-2222-4222-8222-222222222222".into(), + ) + .unwrap(); + let state = replay::State { + lineage, + native_checkpoint_index: 0, + user_execution: replay::UserExecution { + active_id: "aaaaaaaa-aaaa-4aaa-8aaa-000000000000".into(), + prior_recorded_id: None, + }, + }; let native_contents = json!([{"role":"model","parts":[{"text":"same projection","thoughtSignature":"original-native-signature"}]}]); - let mut blocks = replay::encode(&native_contents, &binding, "17", "full", &json!([])).unwrap(); + let mut blocks = replay::encode( + &native_contents, + &binding, + &state, + "end_turn", + "full", + &json!([]), + ) + .unwrap(); let original = replay::decode(&blocks, &binding).unwrap(); let mut mutated = original.clone(); mutated["contents"][0]["parts"][0]["thoughtSignature"] = json!("corrupt-native-signature"); @@ -2438,11 +2458,32 @@ async fn antigravity_count_estimate_is_signature_size_invariant_and_network_free project_id: "project".into(), native_model: "gemini-4-argon-low".into(), }; + let lineage = replay::Lineage::new( + "11111111-1111-4111-8111-111111111111".into(), + "22222222-2222-4222-8222-222222222222".into(), + ) + .unwrap(); + let state = replay::State { + lineage, + native_checkpoint_index: 0, + user_execution: replay::UserExecution { + active_id: "aaaaaaaa-aaaa-4aaa-8aaa-000000000000".into(), + prior_recorded_id: None, + }, + }; let mut estimates = Vec::new(); for signature in ["x".to_string(), "x".repeat(100000)] { let contents = json!([{"role":"model","parts":[{"text":"same text","thoughtSignature":signature}]}]); - let blocks = replay::encode(&contents, &binding, "123", "omitted", &json!([])).unwrap(); + let blocks = replay::encode( + &contents, + &binding, + &state, + "end_turn", + "omitted", + &json!([]), + ) + .unwrap(); let mut body = request(false); body["messages"].as_array_mut().unwrap().extend([ json!({"role":"assistant","content":blocks}), diff --git a/src/providers/antigravity_tests.rs b/src/providers/antigravity_tests.rs index ca914696..6adeaf98 100644 --- a/src/providers/antigravity_tests.rs +++ b/src/providers/antigravity_tests.rs @@ -136,21 +136,29 @@ fn execution_id(index: u64) -> String { format!("aaaaaaaa-aaaa-4aaa-8aaa-{index:012x}") } -fn v2_state(lineage: &replay::v2::Lineage, index: u64) -> replay::v2::State { - replay::v2::State { +fn default_lineage() -> replay::Lineage { + replay::Lineage::new( + "11111111-1111-4111-8111-111111111111".into(), + "22222222-2222-4222-8222-222222222222".into(), + ) + .unwrap() +} + +fn state_at(lineage: &replay::Lineage, index: u64) -> replay::State { + replay::State { lineage: lineage.clone(), native_checkpoint_index: index, - user_execution: replay::v2::UserExecution { + user_execution: replay::UserExecution { active_id: execution_id(index), prior_recorded_id: index.checked_sub(1).map(execution_id), }, } } -fn v2_encode( +fn encode_turn( contents: &Value, binding: &replay::Binding, - lineage: &replay::v2::Lineage, + lineage: &replay::Lineage, index: u64, display: &str, mapping: &Value, @@ -160,22 +168,32 @@ fn v2_encode( } else { "end_turn" }; - replay::v2::encode( + replay::encode( contents, binding, - &v2_state(lineage, index), + &state_at(lineage, index), stop, display, mapping, ) } +fn encode_default( + contents: &Value, + binding: &replay::Binding, + display: &str, + mapping: &Value, +) -> replay::Result { + encode_turn(contents, binding, &default_lineage(), 0, display, mapping) +} + #[test] -fn antigravity_v2_first_checkpoint_is_separate_bounded_and_scope_bound() { +fn antigravity_first_checkpoint_is_bounded_and_scope_bound() { use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; - let lineage = replay::v2::Lineage::new( - "11111111-1111-4111-8111-111111111111".into(), - "22222222-2222-4222-8222-222222222222".into(), + let lineage = default_lineage(); + let other_lineage = replay::Lineage::new( + "33333333-3333-4333-8333-333333333333".into(), + "44444444-4444-4444-8444-444444444444".into(), ) .unwrap(); let contents = json!([{"role":"model","parts":[ @@ -187,24 +205,23 @@ fn antigravity_v2_first_checkpoint_is_separate_bounded_and_scope_bound() { json!([{"local_id":"toolu_fixed","caller_name":"caller_name","codec_paths":["/payload"]}]); let mapping = replay::mapping(&contents, Some(&recipes)).unwrap(); for display in ["full", "omitted"] { - let v1 = replay::encode(&contents, &binding(), "19", display, &mapping).unwrap(); - let v2 = v2_encode(&contents, &binding(), &lineage, 0, display, &mapping).unwrap(); + let other = + encode_turn(&contents, &binding(), &other_lineage, 0, display, &mapping).unwrap(); + let blocks = encode_turn(&contents, &binding(), &lineage, 0, display, &mapping).unwrap(); assert_eq!( - replay::projection_values(&v1).unwrap(), - replay::projection_values(&v2).unwrap() + replay::projection_values(&other).unwrap(), + replay::projection_values(&blocks).unwrap() ); assert!( - v2[0]["signature"] + blocks[0]["signature"] .as_str() .unwrap() - .starts_with(replay::v2::PREFIX) + .starts_with(replay::PREFIX) ); - assert!(replay::decode(&v2, &binding()).is_err()); - assert!(replay::v2::decode(&v1, &binding()).is_err()); - let envelope = replay::v2::decode(&v2, &binding()).unwrap(); + let envelope = replay::decode(&blocks, &binding()).unwrap(); assert_eq!(envelope["contents"], contents); assert_eq!(envelope["tool_mapping"], mapping); - assert_eq!(envelope["version"], 2); + assert_eq!(envelope["version"], 1); assert_eq!(envelope["backend_session_id"], "-3750763034362895579"); assert_eq!( envelope["lineage"]["conversation_id"], @@ -213,16 +230,16 @@ fn antigravity_v2_first_checkpoint_is_separate_bounded_and_scope_bound() { assert_eq!(envelope["lineage"]["trajectory_id"], lineage.trajectory_id); assert_ne!( envelope["projection_digest"], - replay::decode(&v1, &binding()).unwrap()["projection_digest"] + replay::decode(&other, &binding()).unwrap()["projection_digest"] ); for key in ["profile_id", "project_id", "native_model"] { let mut scope = serde_json::to_value(binding()).unwrap(); scope[key] = json!("different"); let scope = serde_json::from_value(scope).unwrap(); - assert!(replay::v2::decode(&v2, &scope).is_err()); + assert!(replay::decode(&blocks, &scope).is_err()); } for (key, value) in [ - ("version", json!(1)), + ("version", json!(2)), ("version", json!(3)), ("backend_session_id", json!("19")), ("native_checkpoint_index", json!(1)), @@ -237,31 +254,31 @@ fn antigravity_v2_first_checkpoint_is_separate_bounded_and_scope_bound() { ), ( "projection_digest", - replay::decode(&v1, &binding()).unwrap()["projection_digest"].clone(), + replay::decode(&other, &binding()).unwrap()["projection_digest"].clone(), ), ("extra", json!(true)), ] { let mut tampered = envelope.clone(); tampered[key] = value; - let mut blocks = v2.clone(); - blocks[0]["signature"] = json!(format!( + let mut candidate = blocks.clone(); + candidate[0]["signature"] = json!(format!( "{}{}", - replay::v2::PREFIX, + replay::PREFIX, URL_SAFE_NO_PAD.encode(serde_json::to_vec(&tampered).unwrap()) )); assert!( - replay::v2::decode(&blocks, &binding()).is_err(), + replay::decode(&candidate, &binding()).is_err(), "accepted {key}" ); } - let mut modified = v2.clone(); + let mut modified = blocks.clone(); modified[1]["input"]["payload"]["x"] = json!(18); - assert!(replay::v2::decode(&modified, &binding()).is_err()); - let mut foreign = v2.clone(); + assert!(replay::decode(&modified, &binding()).is_err()); + let mut foreign = blocks.clone(); foreign[0]["signature"] = json!("ccp:antigravity:v3:unknown"); - assert!(replay::v2::decode(&foreign, &binding()).is_err()); - foreign[0]["signature"] = json!(format!("{}!", replay::v2::PREFIX)); - assert!(replay::v2::decode(&foreign, &binding()).is_err()); + assert!(replay::decode(&foreign, &binding()).is_err()); + foreign[0]["signature"] = json!(format!("{}!", replay::PREFIX)); + assert!(replay::decode(&foreign, &binding()).is_err()); } for id in [ "not-a-uuid", @@ -270,39 +287,32 @@ fn antigravity_v2_first_checkpoint_is_separate_bounded_and_scope_bound() { "11111111-1111-4111-0111-111111111111", "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA", ] { - assert!(replay::v2::Lineage::new(id.into(), lineage.trajectory_id.clone()).is_err()); + assert!(replay::Lineage::new(id.into(), lineage.trajectory_id.clone()).is_err()); } let oversized = json!([{"role":"model","parts":[{"text":"x".repeat(replay::MAX_DECODED)}]}]); assert_eq!( - v2_encode(&oversized, &binding(), &lineage, 0, "full", &json!([])).err(), + encode_turn(&oversized, &binding(), &lineage, 0, "full", &json!([])).err(), Some("JSON byte limit") ); let too_many = json!(vec![json!({"role":"model","parts":[]}); 513]); - assert!(v2_encode(&too_many, &binding(), &lineage, 0, "full", &json!([])).is_err()); + assert!(encode_turn(&too_many, &binding(), &lineage, 0, "full", &json!([])).is_err()); assert!( - replay::v2::Lineage::new(lineage.conversation_id.clone(), lineage.conversation_id).is_err() + replay::Lineage::new(lineage.conversation_id.clone(), lineage.conversation_id).is_err() ); } #[test] -fn antigravity_metadata_first_request_and_legacy_history_boundary() { - use super::antigravity::{ - config::RequestMetadata, - request::{Metadata, prepare_with_policy}, - }; +fn antigravity_metadata_first_request_and_history_continuation() { + use super::antigravity::request::Metadata; let body = serde_json::from_value(request_value()).unwrap(); - let mut fresh = prepare_with_policy(&body, &binding(), RequestMetadata::Agy).unwrap(); + let mut fresh = prepare(&body, &binding()).unwrap(); assert!( matches!(fresh.metadata, Metadata::Fresh), "preparation must not allocate Go identifiers" ); assert_eq!(fresh.wire["requestId"], ""); assert_eq!(fresh.wire["request"]["sessionId"], ""); - let lineage = replay::v2::Lineage::new( - "11111111-1111-4111-8111-111111111111".into(), - "22222222-2222-4222-8222-222222222222".into(), - ) - .unwrap(); + let lineage = default_lineage(); assert!( fresh .finalize_metadata(-1, || panic!("invalid clock must not allocate")) @@ -347,62 +357,16 @@ fn antigravity_metadata_first_request_and_legacy_history_boundary() { .is_err() ); let contents = json!([{"role":"model","parts":[{"text":"previous"}]}]); - let v1 = replay::encode(&contents, &binding(), "19", "full", &json!([])).unwrap(); - let v2 = v2_encode(&contents, &binding(), &lineage, 0, "full", &json!([])).unwrap(); + let turn = encode_turn(&contents, &binding(), &lineage, 0, "full", &json!([])).unwrap(); let mut history = request_value(); history["messages"].as_array_mut().unwrap().extend([ - json!({"role":"assistant","content":v1}), + json!({"role":"assistant","content":turn}), json!({"role":"user","content":"next"}), ]); - let mut legacy = prepare_with_policy( - &serde_json::from_value(history.clone()).unwrap(), - &binding(), - RequestMetadata::Agy, - ) - .unwrap(); - assert_eq!(legacy.session, "19"); - assert!(legacy.wire["request"].get("labels").is_none()); - assert!( - legacy.wire["requestId"] - .as_str() - .unwrap() - .starts_with("ccp-") - ); - assert!(matches!(legacy.metadata, Metadata::Legacy)); - assert!( - legacy - .finalize_metadata(1_799_999_999_123, || panic!( - "legacy must not allocate Go IDs" - )) - .is_err() - ); - // Ignored noncarrier signatures have always been projection metadata in V1. - // A V2-looking string there must not reclassify a valid V1 turn as mixed. + // Ignored noncarrier signatures on text blocks must not invalidate a valid signed turn. history["messages"][1]["content"][1]["signature"] = - json!("ccp:antigravity:v2:ignored-text-signature"); - assert!( - prepare_with_policy( - &serde_json::from_value(history.clone()).unwrap(), - &binding(), - RequestMetadata::Agy - ) - .is_ok() - ); - history["messages"][1]["content"] = v2; - let error = prepare_with_policy( - &serde_json::from_value(history.clone()).unwrap(), - &binding(), - RequestMetadata::Legacy, - ) - .err() - .unwrap(); - assert_eq!(error, "V2 history requires the agy request metadata policy"); - let resumed = prepare_with_policy( - &serde_json::from_value(history).unwrap(), - &binding(), - RequestMetadata::Agy, - ) - .unwrap(); + json!("ccp:antigravity:v1:ignored-text-signature"); + let resumed = prepare(&serde_json::from_value(history).unwrap(), &binding()).unwrap(); assert!(matches!( resumed.metadata, Metadata::Continuation { @@ -410,28 +374,17 @@ fn antigravity_metadata_first_request_and_legacy_history_boundary() { .. } )); - let default = prepare(&body, &binding()).unwrap(); - assert!(matches!(default.metadata, Metadata::Legacy)); - assert!(default.wire["request"].get("labels").is_none()); - assert!(default.session.parse::().unwrap() > 0); } #[test] -fn antigravity_v2_two_user_text_checkpoint_progress_and_history_consistency() { - use super::antigravity::{ - config::RequestMetadata, - request::{Metadata, prepare_with_policy}, - }; - let lineage = replay::v2::Lineage::new( - "11111111-1111-4111-8111-111111111111".into(), - "22222222-2222-4222-8222-222222222222".into(), - ) - .unwrap(); +fn antigravity_two_user_text_checkpoint_progress_and_history_consistency() { + use super::antigravity::request::Metadata; + let lineage = default_lineage(); let first_contents = json!([{"role":"model","parts":[{"text":"first","thoughtSignature":"original-opaque"}]}]); let second_contents = json!([{"role":"model","parts":[{"text":"second"}]}]); - let first = v2_encode(&first_contents, &binding(), &lineage, 0, "full", &json!([])).unwrap(); - let second = v2_encode( + let first = encode_turn(&first_contents, &binding(), &lineage, 0, "full", &json!([])).unwrap(); + let second = encode_turn( &second_contents, &binding(), &lineage, @@ -440,12 +393,12 @@ fn antigravity_v2_two_user_text_checkpoint_progress_and_history_consistency() { &json!([]), ) .unwrap(); - let decoded = replay::v2::decode_history(&json!([first, second]), &binding()).unwrap(); + let decoded = replay::decode_history(&json!([first, second]), &binding()).unwrap(); assert_eq!(decoded[0]["native_checkpoint_index"], 0); assert_eq!(decoded[1]["native_checkpoint_index"], 1); assert_eq!(decoded[0]["contents"], first_contents); assert!( - v2_encode( + encode_turn( &second_contents, &binding(), &lineage, @@ -461,16 +414,16 @@ fn antigravity_v2_two_user_text_checkpoint_progress_and_history_consistency() { json!([first, first]), ] { assert!( - replay::v2::decode_history(&history, &binding()).is_err(), + replay::decode_history(&history, &binding()).is_err(), "checkpoint omission/reorder/duplicate" ); } - let foreign = replay::v2::Lineage::new( + let foreign = replay::Lineage::new( "33333333-3333-4333-8333-333333333333".into(), "44444444-4444-4444-8444-444444444444".into(), ) .unwrap(); - let other_second = v2_encode( + let other_second = encode_turn( &second_contents, &binding(), &foreign, @@ -479,38 +432,13 @@ fn antigravity_v2_two_user_text_checkpoint_progress_and_history_consistency() { &json!([]), ) .unwrap(); - assert!(replay::v2::decode_history(&json!([first, other_second]), &binding()).is_err()); - let v1 = replay::encode(&first_contents, &binding(), "19", "full", &json!([])).unwrap(); - for mixed in [json!([first, v1]), json!([v1, first])] { - let mut body = request_value(); - for blocks in mixed.as_array().unwrap() { - body["messages"].as_array_mut().unwrap().extend([ - json!({"role":"assistant","content":blocks}), - json!({"role":"user","content":"next"}), - ]); - } - for policy in [RequestMetadata::Legacy, RequestMetadata::Agy] { - assert!( - prepare_with_policy( - &serde_json::from_value(body.clone()).unwrap(), - &binding(), - policy - ) - .is_err() - ); - } - } + assert!(replay::decode_history(&json!([first, other_second]), &binding()).is_err()); let mut body = request_value(); body["messages"].as_array_mut().unwrap().extend([ json!({"role":"assistant","content":first}), json!({"role":"user","content":"next"}), ]); - let mut prepared = prepare_with_policy( - &serde_json::from_value(body.clone()).unwrap(), - &binding(), - RequestMetadata::Agy, - ) - .unwrap(); + let mut prepared = prepare(&serde_json::from_value(body.clone()).unwrap(), &binding()).unwrap(); let Metadata::Continuation { state, new_user_execution: true, @@ -572,15 +500,10 @@ fn antigravity_v2_two_user_text_checkpoint_progress_and_history_consistency() { {"role":"model","parts":[{"text":"part two","thoughtSignature":"opaque-two"}]} ]); let fragmented_blocks = - v2_encode(&fragmented, &binding(), &lineage, 0, "full", &json!([])).unwrap(); + encode_turn(&fragmented, &binding(), &lineage, 0, "full", &json!([])).unwrap(); let mut split_body = body.clone(); split_body["messages"][1]["content"] = fragmented_blocks; - let mut split = prepare_with_policy( - &serde_json::from_value(split_body).unwrap(), - &binding(), - RequestMetadata::Agy, - ) - .unwrap(); + let mut split = prepare(&serde_json::from_value(split_body).unwrap(), &binding()).unwrap(); split .finalize_metadata(1_799_999_999_456, || execution_id(1)) .unwrap(); @@ -591,12 +514,7 @@ fn antigravity_v2_two_user_text_checkpoint_progress_and_history_consistency() { json!({"role":"assistant","content":second}), json!({"role":"user","content":"unobserved third user"}), ]); - let mut third = prepare_with_policy( - &serde_json::from_value(body).unwrap(), - &binding(), - RequestMetadata::Agy, - ) - .unwrap(); + let mut third = prepare(&serde_json::from_value(body).unwrap(), &binding()).unwrap(); assert!( third .finalize_metadata(1_799_999_999_789, || execution_id(0)) @@ -619,30 +537,18 @@ fn antigravity_v2_two_user_text_checkpoint_progress_and_history_consistency() { } #[test] -fn antigravity_v2_unqualified_tool_and_noncanonical_text_transitions_reject() { - use super::antigravity::{config::RequestMetadata, request::prepare_with_policy}; - let lineage = replay::v2::Lineage::new( - "11111111-1111-4111-8111-111111111111".into(), - "22222222-2222-4222-8222-222222222222".into(), - ) - .unwrap(); +fn antigravity_unqualified_tool_and_noncanonical_text_transitions_reject() { + let lineage = default_lineage(); let contents = json!([{"role":"model","parts":[{"functionCall":{"name":"tool","args":{}}}]}]); let mapping = replay::mapping(&contents, None).unwrap(); - let tool_turn = v2_encode(&contents, &binding(), &lineage, 0, "full", &mapping).unwrap(); + let tool_turn = encode_turn(&contents, &binding(), &lineage, 0, "full", &mapping).unwrap(); let mut body = request_value(); body["messages"].as_array_mut().unwrap().extend([ json!({"role":"assistant","content":tool_turn}), - json!({"role":"user","content":[{"type":"tool_result","tool_use_id":tool_turn[1]["id"],"content":"done"}]}) + json!({"role":"user","content":"ordinary text without tool_result"}), ]); - assert!( - prepare_with_policy( - &serde_json::from_value(body).unwrap(), - &binding(), - RequestMetadata::Agy - ) - .is_err() - ); - let text = v2_encode( + assert!(prepare(&serde_json::from_value(body).unwrap(), &binding(),).is_err()); + let text = encode_turn( &json!([{"role":"model","parts":[{"text":"first"}]}]), &binding(), &lineage, @@ -657,28 +563,21 @@ fn antigravity_v2_unqualified_tool_and_noncanonical_text_transitions_reject() { json!({"role":"user","content":"next"}), ]); for signature in [ - "ccp:antigravity:v2:!", + "ccp:antigravity:v1:!", + "ccp:antigravity:v2:unknown", "ccp:antigravity:v3:unknown", "foreign", ] { let mut invalid = body.clone(); invalid["messages"][1]["content"][0]["signature"] = json!(signature); - assert!( - prepare_with_policy( - &serde_json::from_value(invalid).unwrap(), - &binding(), - RequestMetadata::Agy - ) - .is_err() - ); + assert!(prepare(&serde_json::from_value(invalid).unwrap(), &binding(),).is_err()); } let mut changed_scope = binding(); changed_scope.profile_id = "other".into(); assert!( - prepare_with_policy( + prepare( &serde_json::from_value(body.clone()).unwrap(), &changed_scope, - RequestMetadata::Agy ) .is_err() ); @@ -686,33 +585,18 @@ fn antigravity_v2_unqualified_tool_and_noncanonical_text_transitions_reject() { .as_array_mut() .unwrap() .push(json!({"role":"user","content":"additional unobserved role"})); - assert!( - prepare_with_policy( - &serde_json::from_value(body).unwrap(), - &binding(), - RequestMetadata::Agy - ) - .is_err() - ); + assert!(prepare(&serde_json::from_value(body).unwrap(), &binding(),).is_err()); } #[test] -fn antigravity_v2_user_execution_survives_read_loop_then_records_for_next_user() { - use super::antigravity::{ - config::RequestMetadata, - request::{Metadata, prepare_with_policy}, - }; - let lineage = replay::v2::Lineage::new( - "11111111-1111-4111-8111-111111111111".into(), - "22222222-2222-4222-8222-222222222222".into(), - ) - .unwrap(); - let initial = v2_state(&lineage, 0); +fn antigravity_user_execution_survives_read_loop_then_records_for_next_user() { + use super::antigravity::request::Metadata; + let lineage = default_lineage(); + let initial = state_at(&lineage, 0); let call = json!([{"role":"model","parts":[{"functionCall":{"name":"read_file","args":{"Path":"/synthetic/owned"},"id":"synthetic-read-call-1"},"thoughtSignature":"c3ludGhldGljLXNpZ25hdHVyZQ=="}]}]); let mapping = replay::mapping(&call, None).unwrap(); - let first = - replay::v2::encode(&call, &binding(), &initial, "tool_use", "full", &mapping).unwrap(); - let saved = replay::v2::decode(&first, &binding()).unwrap(); + let first = replay::encode(&call, &binding(), &initial, "tool_use", "full", &mapping).unwrap(); + let saved = replay::decode(&first, &binding()).unwrap(); assert_eq!(saved["checkpoint_stop_reason"], "tool_use"); assert!(saved["user_execution"]["prior_recorded_id"].is_null()); let mut body = request_value(); @@ -720,12 +604,7 @@ fn antigravity_v2_user_execution_survives_read_loop_then_records_for_next_user() json!({"role":"assistant","content":first}), json!({"role":"user","content":[{"type":"tool_result","tool_use_id":first[1]["id"],"content":"read marker"}]}) ]); - let mut tool = prepare_with_policy( - &serde_json::from_value(body.clone()).unwrap(), - &binding(), - RequestMetadata::Agy, - ) - .unwrap(); + let mut tool = prepare(&serde_json::from_value(body.clone()).unwrap(), &binding()).unwrap(); assert!(matches!( tool.metadata, Metadata::Continuation { @@ -755,7 +634,7 @@ fn antigravity_v2_user_execution_survives_read_loop_then_records_for_next_user() json!({"role":"model","parts":[{"functionResponse":{"name":"read_file","response":{"output":"read marker"},"id":"synthetic-read-call-1"}}]}) ); let completion = json!([{"role":"model","parts":[{"text":"done"}]}]); - let second = replay::v2::encode( + let second = replay::encode( &completion, &binding(), running, @@ -764,8 +643,8 @@ fn antigravity_v2_user_execution_survives_read_loop_then_records_for_next_user() &json!([]), ) .unwrap(); - let decoded = replay::v2::decode(&second, &binding()).unwrap(); - let complete = replay::v2::State::from_envelope(&decoded).unwrap(); + let decoded = replay::decode(&second, &binding()).unwrap(); + let complete = replay::State::from_envelope(&decoded).unwrap(); assert_eq!( complete.recorded_after("end_turn").unwrap(), Some(execution_id(0).as_str()) @@ -778,12 +657,7 @@ fn antigravity_v2_user_execution_survives_read_loop_then_records_for_next_user() json!({"role":"assistant","content":second}), json!({"role":"user","content":"next ordinary user"}), ]); - let mut next = prepare_with_policy( - &serde_json::from_value(body).unwrap(), - &binding(), - RequestMetadata::Agy, - ) - .unwrap(); + let mut next = prepare(&serde_json::from_value(body).unwrap(), &binding()).unwrap(); let mut calls = 0; next.finalize_metadata(1_799_999_999_789, || { calls += 1; @@ -811,7 +685,7 @@ fn antigravity_v2_user_execution_survives_read_loop_then_records_for_next_user() next.wire["request"]["labels"]["request_id"], format!("{}-2", lineage.trajectory_id) ); - let third = replay::v2::encode( + let third = replay::encode( &completion, &binding(), next_state, @@ -820,44 +694,32 @@ fn antigravity_v2_user_execution_survives_read_loop_then_records_for_next_user() &json!([]), ) .unwrap(); - assert!(replay::v2::decode_history(&json!([first, second, third]), &binding()).is_ok()); + assert!(replay::decode_history(&json!([first, second, third]), &binding()).is_ok()); } #[test] -fn antigravity_v2_incomplete_or_invalid_terminal_state_never_records_success() { - use super::antigravity::{config::RequestMetadata, request::prepare_with_policy}; - let lineage = replay::v2::Lineage::new( - "11111111-1111-4111-8111-111111111111".into(), - "22222222-2222-4222-8222-222222222222".into(), - ) - .unwrap(); - let state = v2_state(&lineage, 0); +fn antigravity_incomplete_or_invalid_terminal_state_never_records_success() { + let lineage = default_lineage(); + let state = state_at(&lineage, 0); let text = json!([{"role":"model","parts":[{"text":"limited"}]}]); let limited = - replay::v2::encode(&text, &binding(), &state, "max_tokens", "full", &json!([])).unwrap(); - let saved = replay::v2::decode(&limited, &binding()).unwrap(); - let incomplete = replay::v2::State::from_envelope(&saved).unwrap(); + replay::encode(&text, &binding(), &state, "max_tokens", "full", &json!([])).unwrap(); + let saved = replay::decode(&limited, &binding()).unwrap(); + let incomplete = replay::State::from_envelope(&saved).unwrap(); assert_eq!(incomplete.recorded_after("max_tokens").unwrap(), None); let mut body = request_value(); body["messages"].as_array_mut().unwrap().extend([ json!({"role":"assistant","content":limited}), json!({"role":"user","content":"not a proven continuation"}), ]); - assert!( - prepare_with_policy( - &serde_json::from_value(body).unwrap(), - &binding(), - RequestMetadata::Agy - ) - .is_err() - ); - assert!(replay::v2::encode(&text, &binding(), &state, "tool_use", "full", &json!([])).is_err()); + assert!(prepare(&serde_json::from_value(body).unwrap(), &binding(),).is_err()); + assert!(replay::encode(&text, &binding(), &state, "tool_use", "full", &json!([])).is_err()); for stop in ["STOP", "", "error", "cancelled"] { - assert!(replay::v2::encode(&text, &binding(), &state, stop, "full", &json!([])).is_err()); + assert!(replay::encode(&text, &binding(), &state, stop, "full", &json!([])).is_err()); } let call = json!([{"role":"model","parts":[{"functionCall":{"name":"tool","id":"native","args":{}}}]}]); let map = replay::mapping(&call, None).unwrap(); - assert!(replay::v2::encode(&call, &binding(), &state, "end_turn", "full", &map).is_err()); + assert!(replay::encode(&call, &binding(), &state, "end_turn", "full", &map).is_err()); for id in [ lineage.conversation_id.clone(), lineage.trajectory_id.clone(), @@ -866,30 +728,25 @@ fn antigravity_v2_incomplete_or_invalid_terminal_state_never_records_success() { let mut invalid = state.clone(); invalid.user_execution.active_id = id; assert!( - replay::v2::encode(&text, &binding(), &invalid, "end_turn", "full", &json!([])) - .is_err() + replay::encode(&text, &binding(), &invalid, "end_turn", "full", &json!([])).is_err() ); } let mut invalid = state; invalid.user_execution.prior_recorded_id = Some(execution_id(7)); assert!( - replay::v2::encode(&text, &binding(), &invalid, "end_turn", "full", &json!([])).is_err(), + replay::encode(&text, &binding(), &invalid, "end_turn", "full", &json!([])).is_err(), "first checkpoint has no previous execution" ); } #[test] -fn antigravity_v2_execution_predecessors_and_termination_are_digest_bound() { +fn antigravity_execution_predecessors_and_termination_are_digest_bound() { use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; - let lineage = replay::v2::Lineage::new( - "11111111-1111-4111-8111-111111111111".into(), - "22222222-2222-4222-8222-222222222222".into(), - ) - .unwrap(); + let lineage = default_lineage(); let text = json!([{"role":"model","parts":[{"text":"complete"}]}]); - let first = v2_encode(&text, &binding(), &lineage, 0, "full", &json!([])).unwrap(); - let second = v2_encode(&text, &binding(), &lineage, 1, "full", &json!([])).unwrap(); - let envelope = replay::v2::decode(&first, &binding()).unwrap(); + let first = encode_turn(&text, &binding(), &lineage, 0, "full", &json!([])).unwrap(); + let second = encode_turn(&text, &binding(), &lineage, 1, "full", &json!([])).unwrap(); + let envelope = replay::decode(&first, &binding()).unwrap(); for (key, value) in [ ( "user_execution", @@ -911,14 +768,14 @@ fn antigravity_v2_execution_predecessors_and_termination_are_digest_bound() { let mut blocks = first.clone(); blocks[0]["signature"] = json!(format!( "{}{}", - replay::v2::PREFIX, + replay::PREFIX, URL_SAFE_NO_PAD.encode(serde_json::to_vec(&modified).unwrap()) )); - assert!(replay::v2::decode(&blocks, &binding()).is_err()); + assert!(replay::decode(&blocks, &binding()).is_err()); } - let mut wrong_predecessor = v2_state(&lineage, 1); + let mut wrong_predecessor = state_at(&lineage, 1); wrong_predecessor.user_execution.prior_recorded_id = Some(execution_id(7)); - let wrong = replay::v2::encode( + let wrong = replay::encode( &text, &binding(), &wrong_predecessor, @@ -927,29 +784,28 @@ fn antigravity_v2_execution_predecessors_and_termination_are_digest_bound() { &json!([]), ) .unwrap(); - assert!(replay::v2::decode_history(&json!([first, wrong]), &binding()).is_err()); - let mut reused = v2_state(&lineage, 2); + assert!(replay::decode_history(&json!([first, wrong]), &binding()).is_err()); + let mut reused = state_at(&lineage, 2); reused.user_execution.active_id = execution_id(0); - let wrong = - replay::v2::encode(&text, &binding(), &reused, "end_turn", "full", &json!([])).unwrap(); - assert!(replay::v2::decode_history(&json!([first, second, wrong]), &binding()).is_err()); + let wrong = replay::encode(&text, &binding(), &reused, "end_turn", "full", &json!([])).unwrap(); + assert!(replay::decode_history(&json!([first, second, wrong]), &binding()).is_err()); let call = json!([{"role":"model","parts":[{"functionCall":{"name":"read_file","id":"native","args":{}},"thoughtSignature":"opaque"}]}]); let mapping = replay::mapping(&call, None).unwrap(); - let tool_first = replay::v2::encode( + let tool_first = replay::encode( &call, &binding(), - &v2_state(&lineage, 0), + &state_at(&lineage, 0), "tool_use", "full", &mapping, ) .unwrap(); - let mut reset = v2_state(&lineage, 1); + let mut reset = state_at(&lineage, 1); reset.user_execution.prior_recorded_id = None; let changed_active = - replay::v2::encode(&text, &binding(), &reset, "end_turn", "full", &json!([])).unwrap(); + replay::encode(&text, &binding(), &reset, "end_turn", "full", &json!([])).unwrap(); assert!( - replay::v2::decode_history(&json!([tool_first, changed_active]), &binding()).is_err(), + replay::decode_history(&json!([tool_first, changed_active]), &binding()).is_err(), "tool continuation cannot reset active execution" ); } @@ -968,7 +824,6 @@ fn metadata_count_context() -> crate::provider::RequestContext { async fn counting_provider( directory: &std::path::Path, url: &url::Url, - mode: super::antigravity::config::RequestMetadata, project: Option<&str>, expires: u64, ) -> (super::antigravity::AntigravityProvider, Store) { @@ -988,7 +843,6 @@ async fn counting_provider( .unwrap(); drop(lock); let config = Config { - request_metadata: mode, base_url: url.clone(), token_url: url.join("/token").unwrap(), client_id: Some("public-synthetic-client".into()), @@ -1021,8 +875,8 @@ async fn pure_count_response( let value = serde_json::from_slice(&bytes).unwrap(); assert_eq!( effects::snapshot(), - [0; 6], - "no credential/profile load, access, factory, clock or native/legacy identity source" + [0; 5], + "no credential/profile load, access, factory, clock or native identity source" ); assert_eq!( provider.count_scope_snapshot(), @@ -1033,17 +887,13 @@ async fn pure_count_response( } #[test] -fn antigravity_agy_count_value_inputs_preserve_v1_v2_semantics_without_effects() { +fn antigravity_agy_count_value_inputs_preserve_semantics_without_effects() { use super::antigravity::request::{self, CountInputs, ServingScope, effects}; - let lineage = replay::v2::Lineage::new( - "11111111-1111-4111-8111-111111111111".into(), - "22222222-2222-4222-8222-222222222222".into(), - ) - .unwrap(); + let lineage = default_lineage(); let contents = json!([{"role":"model","parts":[{"text":"prior","thoughtSignature":"untouched"}]}]); - let v1 = replay::encode(&contents, &binding(), "19", "full", &json!([])).unwrap(); - let v2 = v2_encode(&contents, &binding(), &lineage, 0, "full", &json!([])).unwrap(); + let full = encode_turn(&contents, &binding(), &lineage, 0, "full", &json!([])).unwrap(); + let omitted = encode_turn(&contents, &binding(), &lineage, 0, "omitted", &json!([])).unwrap(); let known = ServingScope { profile_id: "profile".into(), project_id: "project".into(), @@ -1053,7 +903,7 @@ fn antigravity_agy_count_value_inputs_preserve_v1_v2_semantics_without_effects() project_id: "project".into(), }; let mut expected = None; - for blocks in [v1, v2] { + for blocks in [full, omitted] { let mut value = request_value(); value["messages"].as_array_mut().unwrap().extend([ json!({"role":"assistant","content":blocks}), @@ -1083,7 +933,7 @@ fn antigravity_agy_count_value_inputs_preserve_v1_v2_semantics_without_effects() before, "count never mutates caller history/lineage" ); - assert_eq!(effects::snapshot(), [0; 6]); + assert_eq!(effects::snapshot(), [0; 5]); } effects::reset(); assert!( @@ -1118,7 +968,7 @@ fn antigravity_agy_count_value_inputs_preserve_v1_v2_semantics_without_effects() ) .is_err() ); - assert_eq!(effects::snapshot(), [0; 6]); + assert_eq!(effects::snapshot(), [0; 5]); } effects::reset(); let fresh = serde_json::from_value(request_value()).unwrap(); @@ -1134,16 +984,11 @@ fn antigravity_agy_count_value_inputs_preserve_v1_v2_semantics_without_effects() > 0, "unknown scope is estimate-only, not admission" ); - assert_eq!(effects::snapshot(), [0; 6]); + assert_eq!(effects::snapshot(), [0; 5]); } #[tokio::test(flavor = "current_thread")] async fn antigravity_agy_count_never_loads_credentials_accesses_or_constructs_factories() { - use super::antigravity::{ - config::RequestMetadata, - request::effects::{self, Kind}, - }; - use crate::provider::Provider; use std::sync::{ Arc, atomic::{AtomicUsize, Ordering}, @@ -1160,14 +1005,8 @@ async fn antigravity_agy_count_never_loads_credentials_accesses_or_constructs_fa })) .await; let directory = tempfile::tempdir().unwrap(); - let (provider, _) = counting_provider( - directory.path(), - &server.url, - RequestMetadata::Agy, - Some("project"), - u64::MAX, - ) - .await; + let (provider, _) = + counting_provider(directory.path(), &server.url, Some("project"), u64::MAX).await; let path = directory.path().join("antigravity/auth.json"); let body = serde_json::from_value(request_value()).unwrap(); for kind in ["invalid", "unreadable", "missing"] { @@ -1199,58 +1038,19 @@ async fn antigravity_agy_count_never_loads_credentials_accesses_or_constructs_fa } assert!(provider.count_scope_snapshot().is_none()); } - let (expired, _) = counting_provider( - directory.path(), - &server.url, - RequestMetadata::Agy, - Some("project"), - 1, - ) - .await; + let (expired, _) = counting_provider(directory.path(), &server.url, Some("project"), 1).await; assert_eq!(pure_count_response(&expired, &body).await.0, 200); - let (undiscovered, _) = counting_provider( - directory.path(), - &server.url, - RequestMetadata::Agy, - None, - u64::MAX, - ) - .await; + let (undiscovered, _) = counting_provider(directory.path(), &server.url, None, u64::MAX).await; assert_eq!(pure_count_response(&undiscovered, &body).await.0, 200); assert_eq!( calls.load(Ordering::SeqCst), 0, "no refresh/discovery/generation transport calls" ); - let (legacy, _) = counting_provider( - directory.path(), - &server.url, - RequestMetadata::Legacy, - Some("project"), - u64::MAX, - ) - .await; - effects::reset(); - let response = legacy - .handle_count_tokens(body, metadata_count_context()) - .await; - assert_eq!(response.status(), 200); - assert_eq!( - effects::count(Kind::CredentialLoad), - 1, - "legacy file semantics retained and spy is live" - ); - assert_eq!( - effects::count(Kind::LegacyId), - 2, - "legacy nonce/outer identity semantics retained and spy is live" - ); - assert_eq!(effects::count(Kind::Access), 0); } #[tokio::test(flavor = "current_thread")] async fn antigravity_agy_count_claims_are_estimates_never_authoritative_scope() { - use super::antigravity::config::RequestMetadata; use crate::provider::Provider; use std::sync::{ Arc, @@ -1268,22 +1068,12 @@ async fn antigravity_agy_count_claims_are_estimates_never_authoritative_scope() })) .await; let directory = tempfile::tempdir().unwrap(); - let (provider, _) = counting_provider( - directory.path(), - &server.url, - RequestMetadata::Agy, - Some("project"), - u64::MAX, - ) - .await; - let lineage = replay::v2::Lineage::new( - "11111111-1111-4111-8111-111111111111".into(), - "22222222-2222-4222-8222-222222222222".into(), - ) - .unwrap(); + let (provider, _) = + counting_provider(directory.path(), &server.url, Some("project"), u64::MAX).await; + let lineage = default_lineage(); let mut claim = binding(); claim.profile_id = "untrusted-carrier-claim".into(); - let blocks = v2_encode( + let blocks = encode_turn( &json!([{"role":"model","parts":[{"text":"prior"}]}]), &claim, &lineage, @@ -1319,7 +1109,7 @@ async fn antigravity_agy_count_claims_are_estimates_never_authoritative_scope() #[tokio::test(flavor = "current_thread")] async fn antigravity_agy_count_scope_is_published_only_by_validated_serving() { - use super::antigravity::{config::RequestMetadata, request::ServingScope}; + use super::antigravity::request::ServingScope; use crate::provider::Provider; use std::sync::{ Arc, @@ -1342,14 +1132,8 @@ async fn antigravity_agy_count_scope_is_published_only_by_validated_serving() { })) .await; let directory = tempfile::tempdir().unwrap(); - let (provider, _) = counting_provider( - directory.path(), - &server.url, - RequestMetadata::Agy, - Some("project"), - u64::MAX, - ) - .await; + let (provider, _) = + counting_provider(directory.path(), &server.url, Some("project"), u64::MAX).await; assert!(provider.count_scope_snapshot().is_none()); let generated = provider .handle_messages( @@ -1385,12 +1169,8 @@ async fn antigravity_agy_count_scope_is_published_only_by_validated_serving() { assert_eq!(pure_count_response(&provider, &body).await.0, 200); let mut foreign_scope = binding(); foreign_scope.profile_id = "untrusted-carrier-claim".into(); - let lineage = replay::v2::Lineage::new( - "11111111-1111-4111-8111-111111111111".into(), - "22222222-2222-4222-8222-222222222222".into(), - ) - .unwrap(); - let foreign = v2_encode( + let lineage = default_lineage(); + let foreign = encode_turn( &json!([{"role":"model","parts":[{"text":"foreign"}]}]), &foreign_scope, &lineage, @@ -1428,7 +1208,6 @@ async fn antigravity_agy_count_scope_is_published_only_by_validated_serving() { #[tokio::test(flavor = "current_thread")] async fn antigravity_agy_failed_and_cancelled_turns_do_not_publish_count_scope() { - use super::antigravity::config::RequestMetadata; use crate::provider::Provider; use futures_util::StreamExt; for cancel in [false, true] { @@ -1440,14 +1219,8 @@ async fn antigravity_agy_failed_and_cancelled_turns_do_not_publish_count_scope() axum::response::Response::builder().header("content-type","text/event-stream").body(body).unwrap() })).await; let directory = tempfile::tempdir().unwrap(); - let (provider, _) = counting_provider( - directory.path(), - &server.url, - RequestMetadata::Agy, - Some("project"), - u64::MAX, - ) - .await; + let (provider, _) = + counting_provider(directory.path(), &server.url, Some("project"), u64::MAX).await; let mut body = request_value(); body["stream"] = json!(cancel); let response = provider @@ -1474,7 +1247,7 @@ fn antigravity_absent_native_args_project_empty_without_changing_replay() { ) .unwrap(); let mapping = set.mapping(&contents).unwrap(); - let blocks = replay::encode(&contents, &binding(), "19", "full", &mapping).unwrap(); + let blocks = encode_default(&contents, &binding(), "full", &mapping).unwrap(); assert_eq!(blocks[1]["input"], json!({})); let decoded = replay::decode(&blocks, &binding()).unwrap(); assert_eq!(decoded["contents"], contents); @@ -1628,7 +1401,7 @@ fn antigravity_typeless_anyof_keeps_every_sibling_in_complete_codec_guidance() { let contents = json!([{"role":"model","parts":[{"functionCall":{"name":"union","args":{"value":"17"}},"thoughtSignature":"unchanged"}]}]); let mapping = set.mapping(&contents).unwrap(); assert_eq!(mapping[0]["codec_paths"], json!(["/value"])); - let blocks = replay::encode(&contents, &binding(), "19", "full", &mapping).unwrap(); + let blocks = encode_default(&contents, &binding(), "full", &mapping).unwrap(); assert_eq!(blocks[1]["input"]["value"], 17); assert_eq!( replay::decode(&blocks, &binding()).unwrap()["contents"], @@ -1717,7 +1490,7 @@ fn antigravity_nonstring_enum_values_survive_native_guidance() { fn antigravity_adjacent_assistant_fragments_preserve_original_system_boundaries() { let contents = json!([{"role":"model","parts":[{"thought":true,"text":"thought"},{"text":"answer"}]}]); - let blocks = replay::encode(&contents, &binding(), "19", "full", &json!([])).unwrap(); + let blocks = encode_default(&contents, &binding(), "full", &json!([])).unwrap(); let mut request = request_value(); request["messages"].as_array_mut().unwrap().extend([ json!({"role":"assistant","content":[blocks[0]]}), @@ -1756,7 +1529,6 @@ fn antigravity_adjacent_assistant_fragments_preserve_original_system_boundaries( distinct["messages"].as_array_mut().unwrap().extend([ json!({"role":"assistant","content":blocks}), json!({"role":"system","content":"privileged"}), - json!({"role":"assistant","content":blocks}), json!({"role":"user","content":"ordinary user text"}), ]); let prepared = prepare(&serde_json::from_value(distinct).unwrap(), &binding()).unwrap(); @@ -1765,13 +1537,13 @@ fn antigravity_adjacent_assistant_fragments_preserve_original_system_boundaries( .as_array() .unwrap() .len(), - 4 + 3 ); assert_eq!( prepared.wire["request"]["systemInstruction"]["parts"], json!([{"text":"privileged"}]) ); - assert_eq!(prepared.wire["request"]["contents"][3]["role"], "user"); + assert_eq!(prepared.wire["request"]["contents"][2]["role"], "user"); } #[test] @@ -1779,7 +1551,7 @@ fn antigravity_count_estimate_is_independent_of_thought_display_and_fragmentatio let contents = json!([{"role":"model","parts":[{"thought":true,"text":"retained hidden thought ".repeat(200)},{"text":"answer","thoughtSignature":"opaque"}]}]); let mut counts = Vec::new(); for display in ["full", "omitted"] { - let blocks = replay::encode(&contents, &binding(), "19", display, &json!([])).unwrap(); + let blocks = encode_default(&contents, &binding(), display, &json!([])).unwrap(); let mut request = request_value(); request["messages"].as_array_mut().unwrap().extend([ json!({"role":"assistant","content":blocks}), @@ -1796,7 +1568,7 @@ fn antigravity_count_estimate_is_independent_of_thought_display_and_fragmentatio "presentation changed estimated native history: {counts:?}" ); for display in ["full", "omitted"] { - let blocks = replay::encode(&contents, &binding(), "19", display, &json!([])).unwrap(); + let blocks = encode_default(&contents, &binding(), display, &json!([])).unwrap(); let mut request = request_value(); request["messages"].as_array_mut().unwrap().extend([ json!({"role":"assistant","content":[blocks[0]]}), @@ -1823,10 +1595,9 @@ fn antigravity_count_reconstructs_recorded_name_and_json_codecs_without_transpor for signature_bytes in [1, 100000] { let mut native = vector["native_contents"].clone(); native[0]["parts"][0]["thoughtSignature"] = json!("x".repeat(signature_bytes)); - let blocks = replay::encode( + let blocks = encode_default( &native, &scope, - "19", display, &vector["envelope"]["tool_mapping"], ) @@ -1865,7 +1636,7 @@ fn antigravity_large_text_coalescing_preserves_unicode_and_unknown_field_checks( let text = "雪a".repeat(256); let contents = json!([{"role":"model","parts":vec![json!({"text":text});4096]}]); let mapping = json!([]); - let blocks = replay::encode(&contents, &binding(), "19", "full", &mapping).unwrap(); + let blocks = encode_default(&contents, &binding(), "full", &mapping).unwrap(); let envelope = replay::decode(&blocks, &binding()).unwrap(); let merged = json!([blocks[0], {"type":"text","text":text.repeat(4096)}]); let start = std::time::Instant::now(); @@ -1996,7 +1767,7 @@ fn antigravity_property_names_records_keep_complete_codec_and_replay() { property_names_call(json!({"metadata":serde_json::to_string(&metadata).unwrap()})); let mapping = set.mapping(&contents).unwrap(); assert_eq!(mapping[0]["codec_paths"], json!(["/metadata"])); - let blocks = replay::encode(&contents, &binding(), "19", "full", &mapping).unwrap(); + let blocks = encode_default(&contents, &binding(), "full", &mapping).unwrap(); assert_eq!(blocks[1]["input"], json!({"metadata":metadata})); assert_eq!( replay::decode(&blocks, &binding()).unwrap()["contents"], @@ -2089,7 +1860,7 @@ fn antigravity_property_names_structured_guidance_validates_every_key_and_value( let contents = property_names_call(args.clone()); let mapping = set.mapping(&contents).unwrap(); assert_eq!(mapping[0]["codec_paths"], json!([])); - let blocks = replay::encode(&contents, &binding(), "19", "full", &mapping).unwrap(); + let blocks = encode_default(&contents, &binding(), "full", &mapping).unwrap(); assert_eq!(blocks[1]["input"], args, "defaults must not be inserted"); } for args in [ @@ -2178,7 +1949,7 @@ fn antigravity_property_names_enums_and_freeform_value_constraints_are_original( assert_eq!(set.mapping(&contents).is_ok(), valid); if valid { let mapping = set.mapping(&contents).unwrap(); - let blocks = replay::encode(&contents, &binding(), "19", "full", &mapping).unwrap(); + let blocks = encode_default(&contents, &binding(), "full", &mapping).unwrap(); assert_eq!(blocks[1]["input"], value); } } @@ -2311,7 +2082,7 @@ fn antigravity_freeform_codec_and_mapped_names_are_lossless() { assert_ne!(native_name, name); let contents = json!([{"role":"model","parts":[{"functionCall":{"name":native_name,"args":{"args":"[1, {\"x\":true}]"}},"thoughtSignature":"unchanged"}]}]); let mapping = set.mapping(&contents).unwrap(); - let blocks = replay::encode(&contents, &binding(), "1", "omitted", &mapping).unwrap(); + let blocks = encode_default(&contents, &binding(), "omitted", &mapping).unwrap(); assert_eq!(blocks[1]["name"], name); assert_eq!(blocks[1]["input"]["args"], json!([1,{"x":true}])); assert_eq!( @@ -2364,7 +2135,7 @@ fn antigravity_request_accepts_observed_options_without_changing_tier() { assert_eq!(prepared.wire["userAgent"], "antigravity"); assert_eq!(prepared.wire["requestType"], "agent"); assert!(prepared.wire["request"].get("toolConfig").is_none()); - replay::session_id(prepared.wire["request"]["sessionId"].as_str().unwrap()).unwrap(); + assert_eq!(prepared.wire["request"]["sessionId"], ""); } #[test] @@ -2382,7 +2153,7 @@ fn antigravity_request_system_role_user_preserves_parts_and_order() { .unwrap(); assert_eq!( prepared.wire["request"]["systemInstruction"], - json!({"role":"user","parts":[{"text":"top"},{"text":"first"},{"text":"second"}]}) + json!({"role":"user","parts":[{"text":"top\n\nfirst\n\nsecond"}]}) ); assert_eq!( prepared.wire["request"]["contents"], @@ -2536,8 +2307,7 @@ fn antigravity_request_multiple_tools_have_one_declaration_per_object() { {"functionCall":{"name":"limited","args":{"limit":1}}} ]}]); let mapping = prepared.tools.mapping(&contents).unwrap(); - let blocks = - replay::encode(&contents, &binding(), &prepared.session, "full", &mapping).unwrap(); + let blocks = encode_default(&contents, &binding(), "full", &mapping).unwrap(); assert_eq!(blocks[1]["name"], "caller/tool with spaces"); assert_eq!(blocks[1]["input"], json!({"payload":[1,true]})); assert_eq!(blocks[2]["input"], json!({})); @@ -2668,20 +2438,6 @@ fn antigravity_native_sse_errors_never_become_successful_turns() { #[test] fn antigravity_boundaries_and_canonical_sessions_fail_closed() { - for session in [ - "0", - "-0", - "+1", - "01", - "-01", - "9223372036854775808", - "-9223372036854775809", - ] { - assert!(replay::session_id(session).is_err()); - } - for session in ["1", "-1", "9223372036854775807", "-9223372036854775808"] { - assert_eq!(replay::session_id(session).unwrap(), session); - } assert!(replay::calls(&json!(vec![json!({"role":"model","parts":[]}); 513])).is_err()); assert!( replay::calls(&json!([{"role":"model","parts":vec![json!({"text":""});4097]}])).is_err() @@ -2717,7 +2473,7 @@ fn antigravity_complete_result_prefix_uses_native_order_without_synthetic_contin {"functionCall":{"name":"Other","args":{}}} ]}]); let mapping = replay::mapping(&contents, None).unwrap(); - let blocks = replay::encode(&contents, &binding(), "123", "full", &mapping).unwrap(); + let blocks = encode_default(&contents, &binding(), "full", &mapping).unwrap(); let first = json!({"type":"tool_result","tool_use_id":mapping[0]["local_id"],"content":"first"}); let second = json!({"type":"tool_result","tool_use_id":mapping[1]["local_id"],"content":[{"type":"text","text":"second"}],"is_error":true}); @@ -2778,7 +2534,7 @@ fn antigravity_schema_root_freeform_and_closed_no_argument_tools_are_explicit() assert_eq!(set.declarations[1]["parameters"], json!({"type":"OBJECT"})); let contents = json!([{"role":"model","parts":[{"functionCall":{"name":"free","args":{"json":"{\"x\":[1,true]}"}}},{"functionCall":{"name":"empty","args":{}}}]}]); let map = set.mapping(&contents).unwrap(); - let blocks = replay::encode(&contents, &binding(), "1", "full", &map).unwrap(); + let blocks = encode_default(&contents, &binding(), "full", &map).unwrap(); assert_eq!(blocks[1]["input"], json!({"x":[1,true]})); assert_eq!(blocks[2]["input"], json!({})); let invalid = json!([{"role":"model","parts":[{"functionCall":{"name":"free","args":{"json":"{}","extra":true}}}]}]); @@ -2817,10 +2573,12 @@ fn antigravity_final_v1_goldens_decode_and_reencode_without_native_mutation() { let binding: replay::Binding = serde_json::from_value(vector["binding"].clone()).unwrap(); let envelope = replay::decode(&vector["projected_blocks"], &binding).unwrap(); assert_eq!(envelope, vector["envelope"], "{}", vector["name"]); + let state = replay::State::from_envelope(&envelope).unwrap(); let blocks = replay::encode( &vector["native_contents"], &binding, - vector["session_id"].as_str().unwrap(), + &state, + envelope["checkpoint_stop_reason"].as_str().unwrap(), vector["thought_display"].as_str().unwrap(), &envelope["tool_mapping"], ) @@ -2850,10 +2608,9 @@ fn antigravity_final_v1_negative_vectors_fail_closed() { "decode_history" => replay::decode_history(&input["turns"], &binding).is_err(), "project_turn" => replay::mapping(&input["contents"], Some(&input["tool_projections"])) .and_then(|mapping| { - replay::encode( + encode_default( &input["contents"], &binding, - input["session_id"].as_str().unwrap(), input["thought_display"].as_str().unwrap(), &mapping, ) @@ -2921,9 +2678,6 @@ fn antigravity_local_tool_ids_are_unique_despite_reused_native_ids() { #[test] fn antigravity_strips_claude_code_billing_and_sdk_headers_and_consolidates_agy_system_parts() { - use super::antigravity::config::RequestMetadata; - use super::antigravity::request::prepare_with_policy; - for sdk_header in [ "You are Claude Code, Anthropic's official CLI for Claude.", "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK.", @@ -2940,48 +2694,22 @@ fn antigravity_strips_claude_code_billing_and_sdk_headers_and_consolidates_agy_s {"role":"user","content":"Reply with OK."} ]); - let agy_prepared = prepare_with_policy( - &serde_json::from_value(request.clone()).unwrap(), - &binding(), - RequestMetadata::Agy, - ) - .unwrap(); + let prepared = prepare(&serde_json::from_value(request).unwrap(), &binding()).unwrap(); assert_eq!( - agy_prepared.wire["request"]["systemInstruction"], + prepared.wire["request"]["systemInstruction"], json!({ "role":"user", "parts":[{"text":"\nYou are an interactive agent.\n\n# Environment\n - Primary working directory: /workspace"}] }) ); - let legacy_prepared = prepare_with_policy( - &serde_json::from_value(request).unwrap(), - &binding(), - RequestMetadata::Legacy, - ) - .unwrap(); - assert_eq!( - legacy_prepared.wire["request"]["systemInstruction"], - json!({ - "role":"user", - "parts":[ - {"text":"\nYou are an interactive agent."}, - {"text":"# Environment\n - Primary working directory: /workspace"} - ] - }) - ); - // Pre-flattened single-string system with leading billing + SDK headers. let mut flattened = request_value(); flattened["system"] = json!(format!( "x-anthropic-billing-header: cc_version=2.1.270.dd6\n\n{sdk_header}\n\n\nYou are an interactive agent." )); - let flattened_prepared = prepare_with_policy( - &serde_json::from_value(flattened).unwrap(), - &binding(), - RequestMetadata::Agy, - ) - .unwrap(); + let flattened_prepared = + prepare(&serde_json::from_value(flattened).unwrap(), &binding()).unwrap(); assert_eq!( flattened_prepared.wire["request"]["systemInstruction"], json!({ @@ -2996,20 +2724,14 @@ fn antigravity_strips_claude_code_billing_and_sdk_headers_and_consolidates_agy_s {"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.270.dd6"}, {"type":"text","text":sdk_header} ]); - let omitted = prepare_with_policy( - &serde_json::from_value(headers_only).unwrap(), - &binding(), - RequestMetadata::Agy, - ) - .unwrap(); + let omitted = prepare(&serde_json::from_value(headers_only).unwrap(), &binding()).unwrap(); assert!(omitted.wire["request"].get("systemInstruction").is_none()); } } #[test] fn antigravity_opus_5_5_and_carrierless_prefix_turns_and_coalesced_text_blocks() { - use super::antigravity::config::RequestMetadata; - use super::antigravity::request::{CountInputs, estimate, prepare_with_policy}; + use super::antigravity::request::{CountInputs, estimate}; use crate::MessagesRequest; let opus_binding = replay::Binding { @@ -3017,12 +2739,8 @@ fn antigravity_opus_5_5_and_carrierless_prefix_turns_and_coalesced_text_blocks() project_id: "project".into(), native_model: "claude-opus-5-5-low".into(), }; - let lineage = replay::v2::Lineage::new( - "11111111-1111-4111-8111-111111111111".into(), - "22222222-2222-4222-8222-222222222222".into(), - ) - .unwrap(); - let v2_turn = v2_encode( + let lineage = default_lineage(); + let signed_turn = encode_turn( &json!([{"role":"model","parts":[{"thought":true,"text":"thinking","thoughtSignature":"sig-1"},{"text":"Turn 2 reply."}]}]), &opus_binding, &lineage, @@ -3032,7 +2750,7 @@ fn antigravity_opus_5_5_and_carrierless_prefix_turns_and_coalesced_text_blocks() ) .unwrap(); - // Carrier-less prefix (from mid-session /model switch) + active signed V2 turn + // Carrier-less prefix (from mid-session /model switch) + active signed turn let mut switched = request_value(); switched["model"] = json!("antigravity/claude-opus-5-5-low"); switched["messages"] = json!([ @@ -3048,12 +2766,12 @@ fn antigravity_opus_5_5_and_carrierless_prefix_turns_and_coalesced_text_blocks() {"type":"text","text":"Token ALPHA recorded."} ]}, {"role":"user","content":"Now switch model."}, - {"role":"assistant","content":v2_turn}, + {"role":"assistant","content":signed_turn}, {"role":"user","content":"What was the token?"} ]); let body: MessagesRequest = serde_json::from_value(switched).unwrap(); - let mut prepared = prepare_with_policy(&body, &opus_binding, RequestMetadata::Agy).unwrap(); + let mut prepared = prepare(&body, &opus_binding).unwrap(); let mut ids = [execution_id(1)].into_iter(); prepared .finalize_metadata(1_799_999_999_456, || ids.next().unwrap()) @@ -3105,3 +2823,83 @@ fn antigravity_opus_5_5_and_carrierless_prefix_turns_and_coalesced_text_blocks() .unwrap(); assert!(counted > 0); } + +#[test] +fn antigravity_canonicalizes_client_normalized_tool_inputs_and_accepts_global_cache_scope_and_xhigh_effort() + { + let opus_binding = replay::Binding { + profile_id: "profile".into(), + project_id: "project".into(), + native_model: "claude-opus-5-5-medium".into(), + }; + let native_call = json!([{"role":"model","parts":[ + {"thought":true,"text":"Running git pull.","thoughtSignature":"sig-bash"}, + {"functionCall":{ + "name":"Bash", + "id":"toolu_vrtx_014vTPhzW5gH8g73V1H3aPPh", + "args":{ + "command":"cd /data/chrisliu/DataFactory && git pull --ff-only -q origin master 2>&1 | tail -2; git log -1 --oneline", + "description":"Pull latest master" + } + }} + ]}]); + let mapping = replay::mapping(&native_call, None).unwrap(); + let mut blocks = encode_turn( + &native_call, + &opus_binding, + &default_lineage(), + 0, + "full", + &mapping, + ) + .unwrap(); + let local_id = blocks[1]["id"].as_str().unwrap().to_owned(); + + // Simulate Claude Code's Bash updatedInput normalization stripping `cd && ` in-place. + blocks[1]["input"]["command"] = + json!("git pull --ff-only -q origin master 2>&1 | tail -2; git log -1 --oneline"); + assert!( + replay::decode(&blocks, &opus_binding).is_err(), + "raw decode remains strict on uncanonicalized blocks" + ); + + let mut request = request_value(); + request["model"] = json!("antigravity/claude-opus-5-5-medium"); + request["output_config"] = json!({"effort":"xhigh"}); + request["system"] = json!([ + {"type":"text","text":"Global system instruction","cache_control":{"type":"ephemeral","ttl":"1h","scope":"global"}} + ]); + request["messages"] = json!([ + {"role":"user","content":"Investigate why VE TEST PostgreSQL OOMed"}, + {"role":"assistant","content":blocks.clone()}, + {"role":"user","content":[ + {"type":"tool_result","tool_use_id":local_id,"content":"96672b397 Merge branch"} + ]} + ]); + let prepared = prepare( + &serde_json::from_value(request.clone()).unwrap(), + &opus_binding, + ) + .unwrap(); + assert_eq!( + prepared.wire["request"]["contents"][1]["parts"][1]["functionCall"]["args"]["command"], + json!( + "cd /data/chrisliu/DataFactory && git pull --ff-only -q origin master 2>&1 | tail -2; git log -1 --oneline" + ) + ); + + // Tampering with tool_use id or name still fails closed in prepare. + let mut tampered_name = request.clone(); + tampered_name["messages"][1]["content"][1]["name"] = json!("OtherTool"); + assert!( + prepare( + &serde_json::from_value(tampered_name).unwrap(), + &opus_binding + ) + .is_err() + ); + + let mut tampered_id = request; + tampered_id["messages"][1]["content"][1]["id"] = json!("toolu_other"); + assert!(prepare(&serde_json::from_value(tampered_id).unwrap(), &opus_binding).is_err()); +} diff --git a/tests/antigravity_schema_parity.rs b/tests/antigravity_schema_parity.rs index 4bcd911d..1b0ffef5 100644 --- a/tests/antigravity_schema_parity.rs +++ b/tests/antigravity_schema_parity.rs @@ -320,7 +320,7 @@ impl Fixture { std::fs::write( config_dir.join("config.json"), serde_json::to_vec(&json!({"antigravity":{ - "enabled":true,"baseUrl":origin,"projectId":PROJECT,"requestMetadata":"legacy" + "enabled":true,"baseUrl":origin,"projectId":PROJECT }}))?, )?; let tls = rustls::ServerConfig::builder_with_provider(Arc::new( @@ -776,106 +776,141 @@ async fn exercise(case: &str, f: &mut Fixture) -> Result<()> { } async fn first_agent_metadata(f: &mut Fixture) -> Result<()> { - let config_path = f - .auth_path - .parent() - .and_then(|p| p.parent()) - .context("synthetic config parent")? - .join("config.json"); - let original = std::fs::read(&config_path)?; - let mut config: Value = serde_json::from_slice(&original)?; - config["antigravity"]["requestMetadata"] = json!("agy"); - std::fs::write(&config_path, serde_json::to_vec(&config)?)?; - tokio::time::timeout(STEP_LIMIT, f.provider.finish_maintenance()).await??; - f.provider = AntigravityProvider::new(); - let result = async { - let mut previous_conversation = None; - for stream in [false, true] { - let mut body = request(stream); - body["model"] = json!("antigravity/gemini-4-argon-low"); - body["max_tokens"] = json!(65536); - body["system"] = json!([ - {"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.270.dd6; cc_entrypoint=sdk-cli;"}, - {"type":"text","text":"You are a Claude agent, built on Anthropic's Claude Agent SDK.","cache_control":{"type":"ephemeral"}}, - {"type":"text","text":"Synthetic metadata system instruction.","cache_control":{"type":"ephemeral"}} - ]); - body["messages"].as_array_mut().context("messages")?.insert( - 0, - json!({"role":"system","content":"Synthetic environment instruction."}), + let mut previous_conversation = None; + for stream in [false, true] { + let mut body = request(stream); + body["model"] = json!("antigravity/gemini-4-argon-low"); + body["max_tokens"] = json!(65536); + body["system"] = json!([ + {"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.270.dd6; cc_entrypoint=sdk-cli;"}, + {"type":"text","text":"You are a Claude agent, built on Anthropic's Claude Agent SDK.","cache_control":{"type":"ephemeral"}}, + {"type":"text","text":"Synthetic metadata system instruction.","cache_control":{"type":"ephemeral"}} + ]); + body["messages"].as_array_mut().context("messages")?.insert( + 0, + json!({"role":"system","content":"Synthetic environment instruction."}), + ); + let exchange = f + .exchange(body, json!([{"text":"Synthetic metadata completion."}])) + .await?; + exchange.success(stream)?; + let wire = exchange.wire()?; + ensure!( + wire["model"] == "gemini-4-argon-low" + && wire["requestType"] == "agent" + && wire["userAgent"] == "antigravity", + "observed agent envelope" + ); + let request = &wire["request"]; + exact( + &request["systemInstruction"], + &json!({"role":"user","parts":[{"text":"Synthetic metadata system instruction.\n\nSynthetic environment instruction."}]}), + "agy single-part systemInstruction with Claude Code headers stripped", + )?; + exact( + &request["generationConfig"], + &json!({"maxOutputTokens":65536,"thinkingConfig":{"includeThoughts":true,"thinkingLevel":"LOW"}}), + "observed generation controls", + )?; + ensure!( + request["sessionId"] == "-3750763034362895579", + "observed signed empty-input FNV session" + ); + let labels = request["labels"] + .as_object() + .context("observed lineage labels")?; + ensure!(labels.len() == 9, "exact observed label set"); + for (key, expected) in [ + ("model_enum", "MODEL_PLACEHOLDER_M419"), + ("used_claude", "false"), + ("used_claude_conservative", "false"), + ("used_non_gemini_model", "false"), + ("last_step_index", "0"), + ] { + ensure!( + labels.get(key).and_then(Value::as_str) == Some(expected), + "observed string-valued label" ); - let exchange = f.exchange(body, json!([{"text":"Synthetic metadata completion."}])).await?; - exchange.success(stream)?; - let wire = exchange.wire()?; - ensure!(wire["model"] == "gemini-4-argon-low" && wire["requestType"] == "agent" && wire["userAgent"] == "antigravity", "observed agent envelope"); - let request = &wire["request"]; - exact( - &request["systemInstruction"], - &json!({"role":"user","parts":[{"text":"Synthetic metadata system instruction.\n\nSynthetic environment instruction."}]}), - "agy single-part systemInstruction with Claude Code headers stripped", - )?; - exact(&request["generationConfig"], &json!({"maxOutputTokens":65536,"thinkingConfig":{"includeThoughts":true,"thinkingLevel":"LOW"}}), "observed generation controls")?; - ensure!(request["sessionId"] == "-3750763034362895579", "observed signed empty-input FNV session"); - let labels = request["labels"].as_object().context("observed lineage labels")?; - ensure!(labels.len() == 9, "exact observed label set"); - for (key, expected) in [ - ("model_enum", "MODEL_PLACEHOLDER_M419"), - ("used_claude", "false"), - ("used_claude_conservative", "false"), - ("used_non_gemini_model", "false"), - ("last_step_index", "0"), - ] { - ensure!(labels.get(key).and_then(Value::as_str) == Some(expected), "observed string-valued label"); - } - let conversation = labels["cascade_id"].as_str().context("cascade ID")?; - let trajectory = labels["trajectory_id"].as_str().context("trajectory ID")?; - for value in [conversation, trajectory] { - let id = uuid::Uuid::parse_str(value)?; - ensure!(id.get_version_num() == 4 && id.to_string() == value, "canonical UUIDv4 metadata"); - } - ensure!(labels["root_cascade_id"] == conversation && conversation != trajectory, "root and trajectory relationships"); - ensure!(labels["request_id"] == format!("{trajectory}-0"), "first request uses zero-based generation counter"); - let outer = wire["requestId"].as_str().context("outer request ID")?; - let components: Vec<_> = outer.split('/').collect(); - ensure!(components.len() == 5 && components[0] == "agent" && components[1] == conversation && components[3] == trajectory && components[4] == "1", "outer request component relationships"); - let timestamp = components[2].parse::()?; - ensure!((946_684_800_000..4_102_444_800_000).contains(×tamp) && timestamp.to_string() == components[2], "canonical plausible Unix milliseconds"); - ensure!(previous_conversation.as_deref() != Some(conversation), "independent fresh conversations"); - previous_conversation = Some(conversation.to_owned()); - let blocks = exchange.blocks()?; - let signature = blocks[0]["signature"].as_str().context("versioned native carrier")?; - let encoded = signature.strip_prefix("ccp:antigravity:v2:").context("explicit V2 carrier, not V1 reinterpretation")?; - let raw = URL_SAFE_NO_PAD.decode(encoded)?; - ensure!(raw.len() <= BODY_LIMIT, "synthetic V2 carrier bound"); - let saved: Value = serde_json::from_slice(&raw)?; - ensure!(saved["version"] == 2 && saved["profile_id"] == PROFILE && saved["project_id"] == PROJECT, "V2 scope and version"); - ensure!(saved["native_model"] == "gemini-4-argon-low" && saved["backend_session_id"] == "-3750763034362895579", "V2 model and separate backend session"); - exact(&saved["lineage"], &json!({"conversation_id":conversation,"trajectory_id":trajectory}), "V2 logical UUIDs")?; - ensure!(saved.get("session_id").is_none(), "V2 must not reinterpret the V1 session field"); - ensure!(wire.get("enabledCreditTypes").is_none() && request.get("enabledCreditTypes").is_none(), "no invented billing consent"); - exact(&saved["contents"], &json!([{"role":"model","parts":[{"text":"Synthetic metadata completion."}]}]), "unchanged native response contents")?; - let mut continuation = self::request(stream); - continuation["model"] = json!("antigravity/gemini-4-argon-low"); - continuation["messages"].as_array_mut().context("messages")?.extend([ - json!({"role":"assistant","content":blocks}), - json!({"role":"user","content":"Unsupported next transition."}), - ]); - config["antigravity"]["requestMetadata"] = json!("legacy"); - std::fs::write(&config_path, serde_json::to_vec(&config)?)?; - tokio::time::timeout(STEP_LIMIT, f.provider.finish_maintenance()).await??; - f.provider = AntigravityProvider::new(); - let rejected = f.exchange(continuation, json!([{"text":"must not dispatch"}])).await?; - ensure!(rejected.status == StatusCode::BAD_REQUEST && rejected.wire.is_none(), "V2 continuation must never downgrade under legacy policy"); - let error: Value = serde_json::from_slice(&rejected.bytes)?; - ensure!(error["error"]["message"].as_str().is_some_and(|s| s.contains("V2 history requires the agy request metadata policy")), "explicit V2 policy boundary"); - config["antigravity"]["requestMetadata"] = json!("agy"); - std::fs::write(&config_path, serde_json::to_vec(&config)?)?; - tokio::time::timeout(STEP_LIMIT, f.provider.finish_maintenance()).await??; - f.provider = AntigravityProvider::new(); } - Ok::<(), anyhow::Error>(()) - }.await; - std::fs::write(config_path, original)?; - result + let conversation = labels["cascade_id"].as_str().context("cascade ID")?; + let trajectory = labels["trajectory_id"].as_str().context("trajectory ID")?; + for value in [conversation, trajectory] { + let id = uuid::Uuid::parse_str(value)?; + ensure!( + id.get_version_num() == 4 && id.to_string() == value, + "canonical UUIDv4 metadata" + ); + } + ensure!( + labels["root_cascade_id"] == conversation && conversation != trajectory, + "root and trajectory relationships" + ); + ensure!( + labels["request_id"] == format!("{trajectory}-0"), + "first request uses zero-based generation counter" + ); + let outer = wire["requestId"].as_str().context("outer request ID")?; + let components: Vec<_> = outer.split('/').collect(); + ensure!( + components.len() == 5 + && components[0] == "agent" + && components[1] == conversation + && components[3] == trajectory + && components[4] == "1", + "outer request component relationships" + ); + let timestamp = components[2].parse::()?; + ensure!( + (946_684_800_000..4_102_444_800_000).contains(×tamp) + && timestamp.to_string() == components[2], + "canonical plausible Unix milliseconds" + ); + ensure!( + previous_conversation.as_deref() != Some(conversation), + "independent fresh conversations" + ); + previous_conversation = Some(conversation.to_owned()); + let blocks = exchange.blocks()?; + let signature = blocks[0]["signature"] + .as_str() + .context("versioned native carrier")?; + let encoded = signature + .strip_prefix("ccp:antigravity:v1:") + .context("explicit V1 carrier")?; + let raw = URL_SAFE_NO_PAD.decode(encoded)?; + ensure!(raw.len() <= BODY_LIMIT, "synthetic V1 carrier bound"); + let saved: Value = serde_json::from_slice(&raw)?; + ensure!( + saved["version"] == 1 + && saved["profile_id"] == PROFILE + && saved["project_id"] == PROJECT, + "V1 scope and version" + ); + ensure!( + saved["native_model"] == "gemini-4-argon-low" + && saved["backend_session_id"] == "-3750763034362895579", + "V1 model and separate backend session" + ); + exact( + &saved["lineage"], + &json!({"conversation_id":conversation,"trajectory_id":trajectory}), + "V1 logical UUIDs", + )?; + ensure!( + saved.get("session_id").is_none(), + "V1 uses backend_session_id" + ); + ensure!( + wire.get("enabledCreditTypes").is_none() && request.get("enabledCreditTypes").is_none(), + "no invented billing consent" + ); + exact( + &saved["contents"], + &json!([{"role":"model","parts":[{"text":"Synthetic metadata completion."}]}]), + "unchanged native response contents", + )?; + } + Ok(()) } // Official CLI one-process/two-user trace, served LOCAL_SYNTHETIC_SSE (no @@ -883,93 +918,191 @@ async fn first_agent_metadata(f: &mut Fixture) -> Result<()> { // relations, not hidden Go state. The later three-user/source fixture below // separately checks the full last_execution_id predecessor relationship. async fn two_user_text_metadata(f: &mut Fixture) -> Result<()> { - let config_path = f - .auth_path - .parent() - .and_then(|p| p.parent()) - .context("synthetic config parent")? - .join("config.json"); - let original = std::fs::read(&config_path)?; - let mut config: Value = serde_json::from_slice(&original)?; - config["antigravity"]["requestMetadata"] = json!("agy"); - std::fs::write(&config_path, serde_json::to_vec(&config)?)?; - tokio::time::timeout(STEP_LIMIT, f.provider.finish_maintenance()).await??; - f.provider = AntigravityProvider::new(); - let result = async { - for stream in [false, true] { - let mut body = request(stream); - body["model"] = json!("antigravity/gemini-4-argon-low"); - body["max_tokens"] = json!(65536); - body["system"] = json!("Synthetic two-user system instruction."); - body["messages"][0]["content"] = json!("Synthetic first user."); - let first = f.exchange(body.clone(), json!([{"text":"Synthetic first completion."}])).await?; - first.success(stream)?; - let first_blocks = first.blocks()?; - body["messages"].as_array_mut().context("messages")?.extend([ + for stream in [false, true] { + let mut body = request(stream); + body["model"] = json!("antigravity/gemini-4-argon-low"); + body["max_tokens"] = json!(65536); + body["system"] = json!("Synthetic two-user system instruction."); + body["messages"][0]["content"] = json!("Synthetic first user."); + let first = f + .exchange( + body.clone(), + json!([{"text":"Synthetic first completion."}]), + ) + .await?; + first.success(stream)?; + let first_blocks = first.blocks()?; + body["messages"] + .as_array_mut() + .context("messages")? + .extend([ json!({"role":"assistant","content":first_blocks}), json!({"role":"user","content":"Synthetic second user."}), ]); - let second = f.exchange(body.clone(), json!([{"text":"Synthetic second completion."}])).await?; - if second.status != StatusCode::OK { - let error: Value = serde_json::from_slice(&second.bytes)?; - bail!("observed second-user text continuation must succeed; status={} error={}", second.status, error["error"]["message"]); - } - second.success(stream)?; - let first_wire = first.wire()?; - let second_wire = second.wire()?; - let conversation = first_wire["request"]["labels"]["cascade_id"].as_str().context("conversation")?; - let trajectory = first_wire["request"]["labels"]["trajectory_id"].as_str().context("trajectory")?; - ensure!(conversation != trajectory, "distinct conversation/trajectory"); - for id in [conversation, trajectory] { - let uuid = uuid::Uuid::parse_str(id)?; - ensure!(uuid.get_version_num() == 4 && uuid.get_variant() == uuid::Variant::RFC4122 && uuid.to_string() == id, "canonical independent UUIDv4"); - } - for (wire, generation, last_step, outer_step) in [(first_wire, "0", "0", "1"), (second_wire, "1", "2", "3")] { - ensure!(wire["model"] == "gemini-4-argon-low" && wire["project"] == PROJECT && wire["requestType"] == "agent" && wire["userAgent"] == "antigravity", "same actual bound model/project/type"); - ensure!(wire["request"]["sessionId"] == "-3750763034362895579", "same signed empty FNV backend session"); - let labels = &wire["request"]["labels"]; - for (key, value) in [("model_enum", "MODEL_PLACEHOLDER_M419"), ("used_claude", "false"), ("used_claude_conservative", "false"), ("used_non_gemini_model", "false"), ("cascade_id", conversation), ("root_cascade_id", conversation), ("trajectory_id", trajectory), ("last_step_index", last_step)] { - ensure!(labels[key].as_str() == Some(value), "observed stable string-valued label {key}"); - } - ensure!(labels["request_id"] == format!("{trajectory}-{generation}"), "generation suffix must not reset at second user"); - let outer = wire["requestId"].as_str().context("outer ID")?; - let components: Vec<_> = outer.split('/').collect(); - ensure!(components.len() == 5 && components[0] == "agent" && components[1] == conversation && components[3] == trajectory && components[4] == outer_step, "observed outer step and stable identities"); - let millis = components[2].parse::()?; - ensure!((946_684_800_000..4_102_444_800_000).contains(&millis) && millis.to_string() == components[2], "canonical Unix milliseconds"); - exact(&wire["request"]["generationConfig"], &json!({"maxOutputTokens":65536,"thinkingConfig":{"includeThoughts":true,"thinkingLevel":"LOW"}}), "unchanged generation config")?; - exact(&wire["request"]["systemInstruction"], &json!({"role":"user","parts":[{"text":"Synthetic two-user system instruction."}]}), "unchanged system role")?; + let second = f + .exchange( + body.clone(), + json!([{"text":"Synthetic second completion."}]), + ) + .await?; + if second.status != StatusCode::OK { + let error: Value = serde_json::from_slice(&second.bytes)?; + bail!( + "observed second-user text continuation must succeed; status={} error={}", + second.status, + error["error"]["message"] + ); + } + second.success(stream)?; + let first_wire = first.wire()?; + let second_wire = second.wire()?; + let conversation = first_wire["request"]["labels"]["cascade_id"] + .as_str() + .context("conversation")?; + let trajectory = first_wire["request"]["labels"]["trajectory_id"] + .as_str() + .context("trajectory")?; + ensure!( + conversation != trajectory, + "distinct conversation/trajectory" + ); + for id in [conversation, trajectory] { + let uuid = uuid::Uuid::parse_str(id)?; + ensure!( + uuid.get_version_num() == 4 + && uuid.get_variant() == uuid::Variant::RFC4122 + && uuid.to_string() == id, + "canonical independent UUIDv4" + ); + } + for (wire, generation, last_step, outer_step) in + [(first_wire, "0", "0", "1"), (second_wire, "1", "2", "3")] + { + ensure!( + wire["model"] == "gemini-4-argon-low" + && wire["project"] == PROJECT + && wire["requestType"] == "agent" + && wire["userAgent"] == "antigravity", + "same actual bound model/project/type" + ); + ensure!( + wire["request"]["sessionId"] == "-3750763034362895579", + "same signed empty FNV backend session" + ); + let labels = &wire["request"]["labels"]; + for (key, value) in [ + ("model_enum", "MODEL_PLACEHOLDER_M419"), + ("used_claude", "false"), + ("used_claude_conservative", "false"), + ("used_non_gemini_model", "false"), + ("cascade_id", conversation), + ("root_cascade_id", conversation), + ("trajectory_id", trajectory), + ("last_step_index", last_step), + ] { + ensure!( + labels[key].as_str() == Some(value), + "observed stable string-valued label {key}" + ); } - exact(&first_wire["request"]["contents"], &json!([{"role":"user","parts":[{"text":"Synthetic first user."}]}]), "first native user contents")?; - exact(&second_wire["request"]["contents"], &json!([ + ensure!( + labels["request_id"] == format!("{trajectory}-{generation}"), + "generation suffix must not reset at second user" + ); + let outer = wire["requestId"].as_str().context("outer ID")?; + let components: Vec<_> = outer.split('/').collect(); + ensure!( + components.len() == 5 + && components[0] == "agent" + && components[1] == conversation + && components[3] == trajectory + && components[4] == outer_step, + "observed outer step and stable identities" + ); + let millis = components[2].parse::()?; + ensure!( + (946_684_800_000..4_102_444_800_000).contains(&millis) + && millis.to_string() == components[2], + "canonical Unix milliseconds" + ); + exact( + &wire["request"]["generationConfig"], + &json!({"maxOutputTokens":65536,"thinkingConfig":{"includeThoughts":true,"thinkingLevel":"LOW"}}), + "unchanged generation config", + )?; + exact( + &wire["request"]["systemInstruction"], + &json!({"role":"user","parts":[{"text":"Synthetic two-user system instruction."}]}), + "unchanged system role", + )?; + } + exact( + &first_wire["request"]["contents"], + &json!([{"role":"user","parts":[{"text":"Synthetic first user."}]}]), + "first native user contents", + )?; + exact( + &second_wire["request"]["contents"], + &json!([ {"role":"user","parts":[{"text":"Synthetic first user."}]}, {"role":"model","parts":[{"text":"Synthetic first completion."}]}, {"role":"user","parts":[{"text":"Synthetic second user."}]} - ]), "exact observed user/model/user history; no synthetic role rewriting")?; - let second_blocks = second.blocks()?; - for (blocks, index, text) in [(&first_blocks, 0, "Synthetic first completion."), (&second_blocks, 1, "Synthetic second completion.")] { - let encoded = blocks[0]["signature"].as_str().context("V2 signature")?.strip_prefix("ccp:antigravity:v2:").context("V2 prefix")?; - let raw = URL_SAFE_NO_PAD.decode(encoded)?; - let saved: Value = serde_json::from_slice(&raw)?; - ensure!(saved["version"] == 2 && saved["profile_id"] == PROFILE && saved["project_id"] == PROJECT && saved["native_model"] == "gemini-4-argon-low", "V2 actual scope"); - exact(&saved["lineage"], &json!({"conversation_id":conversation,"trajectory_id":trajectory}), "same carried logical IDs")?; - ensure!(saved["native_checkpoint_index"] == index, "proxy-owned completed checkpoint ordinal, not hidden Go state"); - ensure!(saved["backend_session_id"] == "-3750763034362895579", "separate fixed backend session"); - exact(&saved["contents"], &json!([{"role":"model","parts":[{"text":text}]}]), "original native checkpoint contents")?; - } - // Normal text recurrence is separately observed. Unsupported media - // must still reject before identity allocation or native dispatch. - body["messages"].as_array_mut().context("messages")?.extend([ - json!({"role":"assistant","content":second_blocks}), - json!({"role":"user","content":[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"synthetic"}}]}), - ]); - let rejected = f.exchange(body, json!([{"text":"must not dispatch"}])).await?; - ensure!(rejected.status == StatusCode::BAD_REQUEST && rejected.wire.is_none(), "unsupported media must reject without reminting or dispatch"); + ]), + "exact observed user/model/user history; no synthetic role rewriting", + )?; + let second_blocks = second.blocks()?; + for (blocks, index, text) in [ + (&first_blocks, 0, "Synthetic first completion."), + (&second_blocks, 1, "Synthetic second completion."), + ] { + let encoded = blocks[0]["signature"] + .as_str() + .context("V1 signature")? + .strip_prefix("ccp:antigravity:v1:") + .context("V1 prefix")?; + let raw = URL_SAFE_NO_PAD.decode(encoded)?; + let saved: Value = serde_json::from_slice(&raw)?; + ensure!( + saved["version"] == 1 + && saved["profile_id"] == PROFILE + && saved["project_id"] == PROJECT + && saved["native_model"] == "gemini-4-argon-low", + "V1 actual scope" + ); + exact( + &saved["lineage"], + &json!({"conversation_id":conversation,"trajectory_id":trajectory}), + "same carried logical IDs", + )?; + ensure!( + saved["native_checkpoint_index"] == index, + "proxy-owned completed checkpoint ordinal, not hidden Go state" + ); + ensure!( + saved["backend_session_id"] == "-3750763034362895579", + "separate fixed backend session" + ); + exact( + &saved["contents"], + &json!([{"role":"model","parts":[{"text":text}]}]), + "original native checkpoint contents", + )?; } - Ok::<(), anyhow::Error>(()) - }.await; - std::fs::write(config_path, original)?; - result + // Normal text recurrence is separately observed. Unsupported media + // must still reject before identity allocation or native dispatch. + body["messages"].as_array_mut().context("messages")?.extend([ + json!({"role":"assistant","content":second_blocks}), + json!({"role":"user","content":[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"synthetic"}}]}), + ]); + let rejected = f + .exchange(body, json!([{"text":"must not dispatch"}])) + .await?; + ensure!( + rejected.status == StatusCode::BAD_REQUEST && rejected.wire.is_none(), + "unsupported media must reject without reminting or dispatch" + ); + } + Ok(()) } fn uuid_v4(value: &str) -> Result<()> { @@ -991,9 +1124,6 @@ async fn observed_policy(f: &mut Fixture) -> Result<(PathBuf, Vec)> { .context("synthetic config parent")? .join("config.json"); let original = std::fs::read(&path)?; - let mut config: Value = serde_json::from_slice(&original)?; - config["antigravity"]["requestMetadata"] = json!("agy"); - std::fs::write(&path, serde_json::to_vec(&config)?)?; tokio::time::timeout(STEP_LIMIT, f.provider.finish_maintenance()).await??; f.provider = AntigravityProvider::new(); Ok((path, original)) @@ -1003,8 +1133,8 @@ fn v2_saved(blocks: &Value) -> Result { let encoded = blocks[0]["signature"] .as_str() .context("native carrier")? - .strip_prefix("ccp:antigravity:v2:") - .context("V2 carrier prefix")?; + .strip_prefix("ccp:antigravity:v1:") + .context("V1 carrier prefix")?; let raw = URL_SAFE_NO_PAD.decode(encoded)?; ensure!(raw.len() <= BODY_LIMIT, "bounded synthetic carrier"); Ok(serde_json::from_slice(&raw)?) @@ -1300,7 +1430,7 @@ async fn invalid_native_checkpoint(f: &mut Fixture) -> Result<()> { ); let output = std::str::from_utf8(&exchange.bytes)?; ensure!( - !output.contains("ccp:antigravity:v2:"), + !output.contains("ccp:antigravity:v1:"), "invalid upstream turn must never fabricate a successful checkpoint" ); if stream { @@ -1392,28 +1522,6 @@ async fn observed_count_purity(f: &mut Fixture) -> Result<()> { estimates.iter().all(|n| *n == estimates[0]), "estimate does not depend on credential contents/permissions/presence" ); - // New policy must not silently change the default/legacy count behavior. - std::fs::write(&f.auth_path, b"public synthetic invalid auth JSON")?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&f.auth_path, std::fs::Permissions::from_mode(0o600))?; - } - let mut config: Value = serde_json::from_slice(&std::fs::read(&path)?)?; - config["antigravity"]["requestMetadata"] = json!("legacy"); - std::fs::write(&path, serde_json::to_vec(&config)?)?; - tokio::time::timeout(STEP_LIMIT, f.provider.finish_maintenance()).await??; - f.provider = AntigravityProvider::new(); - let legacy = tokio::time::timeout( - STEP_LIMIT, - f.provider - .handle_count_tokens(serde_json::from_value(body)?, context()), - ) - .await?; - ensure!( - legacy.status() == StatusCode::UNAUTHORIZED, - "legacy profile validation remains unchanged" - ); Ok::<(), anyhow::Error>(()) } .await; @@ -1515,16 +1623,11 @@ async fn tls_control(f: &mut Fixture) -> Result<()> { let session = wire["request"]["sessionId"] .as_str() .context("session ID")?; - ensure!( - session - .parse::() - .is_ok_and(|n| n > 0 && n.to_string() == session), - "baseline session format" - ); + ensure!(session == "-3750763034362895579", "baseline session format"); ensure!( wire["requestId"] .as_str() - .is_some_and(|s| s.starts_with("ccp-") && s.len() == 40), + .is_some_and(|s| s.starts_with("agent/")), "request identity format" ); exact( @@ -1532,7 +1635,8 @@ async fn tls_control(f: &mut Fixture) -> Result<()> { &json!({ "project":PROJECT,"model":model.strip_prefix("antigravity/").unwrap(), "requestId":wire["requestId"],"userAgent":"antigravity","requestType":"agent", - "request":{"sessionId":session,"contents":[{"role":"user","parts":[{"text":"Synthetic schema request."}]}], + "request":{"sessionId":session,"labels":wire["request"]["labels"], + "contents":[{"role":"user","parts":[{"text":"Synthetic schema request."}]}], "generationConfig":{"maxOutputTokens":137,"thinkingConfig":{"includeThoughts":true,"thinkingLevel":level}}} }), "baseline complete wire", @@ -2376,7 +2480,7 @@ async fn continuation_control(f: &mut Fixture) -> Result<()> { "V1 native history exact reuse", )?; ensure!( - second.wire()?["request"]["sessionId"] == saved["session_id"], + second.wire()?["request"]["sessionId"] == saved["backend_session_id"], "V1 session reuse" ); ensure!( diff --git a/tests/fixtures/antigravity-golden-v1.json b/tests/fixtures/antigravity-golden-v1.json index 52a12131..32fef2a2 100644 --- a/tests/fixtures/antigravity-golden-v1.json +++ b/tests/fixtures/antigravity-golden-v1.json @@ -32,7 +32,6 @@ ] } ], - "session_id": "9223372036854775807", "thought_display": "omitted", "tool_projections": [ { @@ -48,7 +47,7 @@ { "type": "thinking", "thinking": "", - "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJzZXNzaW9uX2lkIjoiOTIyMzM3MjAzNjg1NDc3NTgwNyIsInRob3VnaHRfZGlzcGxheSI6Im9taXR0ZWQiLCJjb250ZW50cyI6W3sicm9sZSI6Im1vZGVsIiwicGFydHMiOlt7InRob3VnaHQiOnRydWUsInRleHQiOiJHZW51aW5lIHN5bnRoZXRpYyBuYXRpdmUgdGhvdWdodCDpm6oiLCJ0aG91Z2h0U2lnbmF0dXJlIjoicmF3LXRob3VnaHQifSx7ImZ1bmN0aW9uQ2FsbCI6eyJuYW1lIjoibmF0aXZlX3JlYWRfYWxpYXMiLCJpZCI6InJldXNlZC1uYXRpdmUtaWQiLCJhcmdzIjp7ImZpbGVfcGF0aCI6IlwiL2ZpeHR1cmUvcmVhZC50eHRcIiIsIm9mZnNldCI6MS4wfX0sInRob3VnaHRTaWduYXR1cmUiOiJyYXctY2FsbCJ9XX1dLCJ0b29sX21hcHBpbmciOlt7ImxvY2FsX2lkIjoidG9vbHVfZ29sZGVuX2xlYWZfb21pdHRlZF9pNjRfbWF4IiwiY2FsbGVyX25hbWUiOiJSZWFkIiwiY29udGVudF9pbmRleCI6MCwicGFydF9pbmRleCI6MSwibmF0aXZlX2lkX3ByZXNlbnQiOnRydWUsImNvZGVjX3BhdGhzIjpbIi9maWxlX3BhdGgiXSwicm9vdF93cmFwcGVyIjpudWxsLCJuYXRpdmVfaWQiOiJyZXVzZWQtbmF0aXZlLWlkIn1dLCJwcm9qZWN0aW9uX2RpZ2VzdCI6ImI0ZDA3MTdhNzhmMzRhMjE5MWVkMWNiOWRiZmUwNjNkMGFkOWU0ZTQxMWRiNTUwYWUwYjdiNzkyNDAzMTZiNzYifQ" + "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJiYWNrZW5kX3Nlc3Npb25faWQiOiItMzc1MDc2MzAzNDM2Mjg5NTU3OSIsImxpbmVhZ2UiOnsiY29udmVyc2F0aW9uX2lkIjoiMTExMTExMTEtMTExMS00MTExLTgxMTEtMTExMTExMTExMTExIiwidHJhamVjdG9yeV9pZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiJ9LCJuYXRpdmVfY2hlY2twb2ludF9pbmRleCI6MCwidXNlcl9leGVjdXRpb24iOnsiYWN0aXZlX2lkIjoiYWFhYWFhYWEtYWFhYS00YWFhLThhYWEtMDAwMDAwMDAwMDAwIiwicHJpb3JfcmVjb3JkZWRfaWQiOm51bGx9LCJjaGVja3BvaW50X3N0b3BfcmVhc29uIjoidG9vbF91c2UiLCJ0aG91Z2h0X2Rpc3BsYXkiOiJvbWl0dGVkIiwiY29udGVudHMiOlt7InJvbGUiOiJtb2RlbCIsInBhcnRzIjpbeyJ0aG91Z2h0Ijp0cnVlLCJ0ZXh0IjoiR2VudWluZSBzeW50aGV0aWMgbmF0aXZlIHRob3VnaHQg6ZuqIiwidGhvdWdodFNpZ25hdHVyZSI6InJhdy10aG91Z2h0In0seyJmdW5jdGlvbkNhbGwiOnsibmFtZSI6Im5hdGl2ZV9yZWFkX2FsaWFzIiwiaWQiOiJyZXVzZWQtbmF0aXZlLWlkIiwiYXJncyI6eyJmaWxlX3BhdGgiOiJcIi9maXh0dXJlL3JlYWQudHh0XCIiLCJvZmZzZXQiOjEuMH19LCJ0aG91Z2h0U2lnbmF0dXJlIjoicmF3LWNhbGwifV19XSwidG9vbF9tYXBwaW5nIjpbeyJsb2NhbF9pZCI6InRvb2x1X2dvbGRlbl9sZWFmX29taXR0ZWRfaTY0X21heCIsImNhbGxlcl9uYW1lIjoiUmVhZCIsImNvbnRlbnRfaW5kZXgiOjAsInBhcnRfaW5kZXgiOjEsIm5hdGl2ZV9pZF9wcmVzZW50Ijp0cnVlLCJjb2RlY19wYXRocyI6WyIvZmlsZV9wYXRoIl0sInJvb3Rfd3JhcHBlciI6bnVsbCwibmF0aXZlX2lkIjoicmV1c2VkLW5hdGl2ZS1pZCJ9XSwicHJvamVjdGlvbl9kaWdlc3QiOiJhNDExYThlYWU5NzAyYWQ5MjliZDk3Y2Y0ZWViYmJiNjMyNjhlMzdlNDBiMzI0NjI5NTI5YWYzN2IzMmJhNzYzIn0" }, { "type": "tool_use", @@ -65,7 +64,17 @@ "profile_id": "synthetic-profile", "project_id": "synthetic-project", "native_model": "gemini-4-argon-low", - "session_id": "9223372036854775807", + "backend_session_id": "-3750763034362895579", + "lineage": { + "conversation_id": "11111111-1111-4111-8111-111111111111", + "trajectory_id": "22222222-2222-4222-8222-222222222222" + }, + "native_checkpoint_index": 0, + "user_execution": { + "active_id": "aaaaaaaa-aaaa-4aaa-8aaa-000000000000", + "prior_recorded_id": null + }, + "checkpoint_stop_reason": "tool_use", "thought_display": "omitted", "contents": [ { @@ -104,11 +113,11 @@ "native_id": "reused-native-id" } ], - "projection_digest": "b4d0717a78f34a2191ed1cb9dbfe063d0ad9e4e411db550ae0b7b79240316b76" + "projection_digest": "a411a8eae9702ad929bd97cf4eebbbb63268e37e40b324629529af37b32ba763" }, - "canonical_digest_input_utf8": "[\"object\",[[\"binding\",[\"object\",[[\"native_model\",[\"string\",\"gemini-4-argon-low\"]],[\"profile_id\",[\"string\",\"synthetic-profile\"]],[\"project_id\",[\"string\",\"synthetic-project\"]],[\"session_id\",[\"string\",\"9223372036854775807\"]],[\"thought_display\",[\"string\",\"omitted\"]],[\"tool_mapping\",[\"array\",[[\"object\",[[\"caller_name\",[\"string\",\"Read\"]],[\"codec_paths\",[\"array\",[[\"string\",\"/file_path\"]]]],[\"content_index\",[\"number\",\"0000000000000000\"]],[\"local_id\",[\"string\",\"toolu_golden_leaf_omitted_i64_max\"]],[\"native_id\",[\"string\",\"reused-native-id\"]],[\"native_id_present\",[\"bool\",true]],[\"part_index\",[\"number\",\"3ff0000000000000\"]],[\"root_wrapper\",[\"null\"]]]]]]]]]],[\"blocks\",[\"array\",[[\"object\",[[\"thinking\",[\"string\",\"\"]],[\"type\",[\"string\",\"thinking\"]]]],[\"object\",[[\"id\",[\"string\",\"toolu_golden_leaf_omitted_i64_max\"]],[\"input\",[\"object\",[[\"file_path\",[\"string\",\"/fixture/read.txt\"]],[\"offset\",[\"number\",\"3ff0000000000000\"]]]]],[\"name\",[\"string\",\"Read\"]],[\"type\",[\"string\",\"tool_use\"]]]]]]]]]", - "canonical_digest_input_hex": "5b226f626a656374222c5b5b2262696e64696e67222c5b226f626a656374222c5b5b226e61746976655f6d6f64656c222c5b22737472696e67222c2267656d696e692d342d6172676f6e2d6c6f77225d5d2c5b2270726f66696c655f6964222c5b22737472696e67222c2273796e7468657469632d70726f66696c65225d5d2c5b2270726f6a6563745f6964222c5b22737472696e67222c2273796e7468657469632d70726f6a656374225d5d2c5b2273657373696f6e5f6964222c5b22737472696e67222c2239323233333732303336383534373735383037225d5d2c5b2274686f756768745f646973706c6179222c5b22737472696e67222c226f6d6974746564225d5d2c5b22746f6f6c5f6d617070696e67222c5b226172726179222c5b5b226f626a656374222c5b5b2263616c6c65725f6e616d65222c5b22737472696e67222c2252656164225d5d2c5b22636f6465635f7061746873222c5b226172726179222c5b5b22737472696e67222c222f66696c655f70617468225d5d5d5d2c5b22636f6e74656e745f696e646578222c5b226e756d626572222c2230303030303030303030303030303030225d5d2c5b226c6f63616c5f6964222c5b22737472696e67222c22746f6f6c755f676f6c64656e5f6c6561665f6f6d69747465645f6936345f6d6178225d5d2c5b226e61746976655f6964222c5b22737472696e67222c227265757365642d6e61746976652d6964225d5d2c5b226e61746976655f69645f70726573656e74222c5b22626f6f6c222c747275655d5d2c5b22706172745f696e646578222c5b226e756d626572222c2233666630303030303030303030303030225d5d2c5b22726f6f745f77726170706572222c5b226e756c6c225d5d5d5d5d5d5d5d5d5d2c5b22626c6f636b73222c5b226172726179222c5b5b226f626a656374222c5b5b227468696e6b696e67222c5b22737472696e67222c22225d5d2c5b2274797065222c5b22737472696e67222c227468696e6b696e67225d5d5d5d2c5b226f626a656374222c5b5b226964222c5b22737472696e67222c22746f6f6c755f676f6c64656e5f6c6561665f6f6d69747465645f6936345f6d6178225d5d2c5b22696e707574222c5b226f626a656374222c5b5b2266696c655f70617468222c5b22737472696e67222c222f666978747572652f726561642e747874225d5d2c5b226f6666736574222c5b226e756d626572222c2233666630303030303030303030303030225d5d5d5d5d2c5b226e616d65222c5b22737472696e67222c2252656164225d5d2c5b2274797065222c5b22737472696e67222c22746f6f6c5f757365225d5d5d5d5d5d5d5d5d", - "sha256": "b4d0717a78f34a2191ed1cb9dbfe063d0ad9e4e411db550ae0b7b79240316b76" + "canonical_digest_input_utf8": "[\"object\",[[\"binding\",[\"object\",[[\"backend_session_id\",[\"string\",\"-3750763034362895579\"]],[\"checkpoint_stop_reason\",[\"string\",\"tool_use\"]],[\"lineage\",[\"object\",[[\"conversation_id\",[\"string\",\"11111111-1111-4111-8111-111111111111\"]],[\"trajectory_id\",[\"string\",\"22222222-2222-4222-8222-222222222222\"]]]]],[\"native_checkpoint_index\",[\"number\",\"0000000000000000\"]],[\"native_model\",[\"string\",\"gemini-4-argon-low\"]],[\"profile_id\",[\"string\",\"synthetic-profile\"]],[\"project_id\",[\"string\",\"synthetic-project\"]],[\"thought_display\",[\"string\",\"omitted\"]],[\"tool_mapping\",[\"array\",[[\"object\",[[\"caller_name\",[\"string\",\"Read\"]],[\"codec_paths\",[\"array\",[[\"string\",\"/file_path\"]]]],[\"content_index\",[\"number\",\"0000000000000000\"]],[\"local_id\",[\"string\",\"toolu_golden_leaf_omitted_i64_max\"]],[\"native_id\",[\"string\",\"reused-native-id\"]],[\"native_id_present\",[\"bool\",true]],[\"part_index\",[\"number\",\"3ff0000000000000\"]],[\"root_wrapper\",[\"null\"]]]]]]],[\"user_execution\",[\"object\",[[\"active_id\",[\"string\",\"aaaaaaaa-aaaa-4aaa-8aaa-000000000000\"]],[\"prior_recorded_id\",[\"null\"]]]]],[\"version\",[\"number\",\"3ff0000000000000\"]]]]],[\"blocks\",[\"array\",[[\"object\",[[\"thinking\",[\"string\",\"\"]],[\"type\",[\"string\",\"thinking\"]]]],[\"object\",[[\"id\",[\"string\",\"toolu_golden_leaf_omitted_i64_max\"]],[\"input\",[\"object\",[[\"file_path\",[\"string\",\"/fixture/read.txt\"]],[\"offset\",[\"number\",\"3ff0000000000000\"]]]]],[\"name\",[\"string\",\"Read\"]],[\"type\",[\"string\",\"tool_use\"]]]]]]],[\"domain\",[\"string\",\"ccp:antigravity:v1:projection\"]]]]", + "canonical_digest_input_hex": "5b226f626a656374222c5b5b2262696e64696e67222c5b226f626a656374222c5b5b226261636b656e645f73657373696f6e5f6964222c5b22737472696e67222c222d33373530373633303334333632383935353739225d5d2c5b22636865636b706f696e745f73746f705f726561736f6e222c5b22737472696e67222c22746f6f6c5f757365225d5d2c5b226c696e65616765222c5b226f626a656374222c5b5b22636f6e766572736174696f6e5f6964222c5b22737472696e67222c2231313131313131312d313131312d343131312d383131312d313131313131313131313131225d5d2c5b227472616a6563746f72795f6964222c5b22737472696e67222c2232323232323232322d323232322d343232322d383232322d323232323232323232323232225d5d5d5d5d2c5b226e61746976655f636865636b706f696e745f696e646578222c5b226e756d626572222c2230303030303030303030303030303030225d5d2c5b226e61746976655f6d6f64656c222c5b22737472696e67222c2267656d696e692d342d6172676f6e2d6c6f77225d5d2c5b2270726f66696c655f6964222c5b22737472696e67222c2273796e7468657469632d70726f66696c65225d5d2c5b2270726f6a6563745f6964222c5b22737472696e67222c2273796e7468657469632d70726f6a656374225d5d2c5b2274686f756768745f646973706c6179222c5b22737472696e67222c226f6d6974746564225d5d2c5b22746f6f6c5f6d617070696e67222c5b226172726179222c5b5b226f626a656374222c5b5b2263616c6c65725f6e616d65222c5b22737472696e67222c2252656164225d5d2c5b22636f6465635f7061746873222c5b226172726179222c5b5b22737472696e67222c222f66696c655f70617468225d5d5d5d2c5b22636f6e74656e745f696e646578222c5b226e756d626572222c2230303030303030303030303030303030225d5d2c5b226c6f63616c5f6964222c5b22737472696e67222c22746f6f6c755f676f6c64656e5f6c6561665f6f6d69747465645f6936345f6d6178225d5d2c5b226e61746976655f6964222c5b22737472696e67222c227265757365642d6e61746976652d6964225d5d2c5b226e61746976655f69645f70726573656e74222c5b22626f6f6c222c747275655d5d2c5b22706172745f696e646578222c5b226e756d626572222c2233666630303030303030303030303030225d5d2c5b22726f6f745f77726170706572222c5b226e756c6c225d5d5d5d5d5d5d2c5b22757365725f657865637574696f6e222c5b226f626a656374222c5b5b226163746976655f6964222c5b22737472696e67222c2261616161616161612d616161612d346161612d386161612d303030303030303030303030225d5d2c5b227072696f725f7265636f726465645f6964222c5b226e756c6c225d5d5d5d5d2c5b2276657273696f6e222c5b226e756d626572222c2233666630303030303030303030303030225d5d5d5d5d2c5b22626c6f636b73222c5b226172726179222c5b5b226f626a656374222c5b5b227468696e6b696e67222c5b22737472696e67222c22225d5d2c5b2274797065222c5b22737472696e67222c227468696e6b696e67225d5d5d5d2c5b226f626a656374222c5b5b226964222c5b22737472696e67222c22746f6f6c755f676f6c64656e5f6c6561665f6f6d69747465645f6936345f6d6178225d5d2c5b22696e707574222c5b226f626a656374222c5b5b2266696c655f70617468222c5b22737472696e67222c222f666978747572652f726561642e747874225d5d2c5b226f6666736574222c5b226e756d626572222c2233666630303030303030303030303030225d5d5d5d5d2c5b226e616d65222c5b22737472696e67222c2252656164225d5d2c5b2274797065222c5b22737472696e67222c22746f6f6c5f757365225d5d5d5d5d5d5d2c5b22646f6d61696e222c5b22737472696e67222c226363703a616e7469677261766974793a76313a70726f6a656374696f6e225d5d5d5d", + "sha256": "a411a8eae9702ad929bd97cf4eebbbb63268e37e40b324629529af37b32ba763" }, { "name": "leaf-full-negative-session", @@ -140,7 +149,6 @@ ] } ], - "session_id": "-37", "thought_display": "full", "tool_projections": [ { @@ -156,7 +164,7 @@ { "type": "thinking", "thinking": "Genuine synthetic native thought 雪", - "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJzZXNzaW9uX2lkIjoiLTM3IiwidGhvdWdodF9kaXNwbGF5IjoiZnVsbCIsImNvbnRlbnRzIjpbeyJyb2xlIjoibW9kZWwiLCJwYXJ0cyI6W3sidGhvdWdodCI6dHJ1ZSwidGV4dCI6IkdlbnVpbmUgc3ludGhldGljIG5hdGl2ZSB0aG91Z2h0IOmbqiIsInRob3VnaHRTaWduYXR1cmUiOiJyYXctdGhvdWdodCJ9LHsiZnVuY3Rpb25DYWxsIjp7Im5hbWUiOiJuYXRpdmVfcmVhZF9hbGlhcyIsImlkIjoicmV1c2VkLW5hdGl2ZS1pZCIsImFyZ3MiOnsiZmlsZV9wYXRoIjoiXCIvZml4dHVyZS9yZWFkLnR4dFwiIiwib2Zmc2V0IjoxLjB9fSwidGhvdWdodFNpZ25hdHVyZSI6InJhdy1jYWxsIn1dfV0sInRvb2xfbWFwcGluZyI6W3sibG9jYWxfaWQiOiJ0b29sdV9nb2xkZW5fbGVhZl9mdWxsX25lZ2F0aXZlX3Nlc3Npb24iLCJjYWxsZXJfbmFtZSI6IlJlYWQiLCJjb250ZW50X2luZGV4IjowLCJwYXJ0X2luZGV4IjoxLCJuYXRpdmVfaWRfcHJlc2VudCI6dHJ1ZSwiY29kZWNfcGF0aHMiOlsiL2ZpbGVfcGF0aCJdLCJyb290X3dyYXBwZXIiOm51bGwsIm5hdGl2ZV9pZCI6InJldXNlZC1uYXRpdmUtaWQifV0sInByb2plY3Rpb25fZGlnZXN0IjoiYTg1OTFiYTAwNjk5OWVkMmJhMTZjNjE5YTdiNzZlMzc5OTJlZWM5YmEwMDQzNDMxOWQ4YTliMTJiNmI3N2RlMSJ9" + "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJiYWNrZW5kX3Nlc3Npb25faWQiOiItMzc1MDc2MzAzNDM2Mjg5NTU3OSIsImxpbmVhZ2UiOnsiY29udmVyc2F0aW9uX2lkIjoiMTExMTExMTEtMTExMS00MTExLTgxMTEtMTExMTExMTExMTExIiwidHJhamVjdG9yeV9pZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiJ9LCJuYXRpdmVfY2hlY2twb2ludF9pbmRleCI6MCwidXNlcl9leGVjdXRpb24iOnsiYWN0aXZlX2lkIjoiYWFhYWFhYWEtYWFhYS00YWFhLThhYWEtMDAwMDAwMDAwMDAwIiwicHJpb3JfcmVjb3JkZWRfaWQiOm51bGx9LCJjaGVja3BvaW50X3N0b3BfcmVhc29uIjoidG9vbF91c2UiLCJ0aG91Z2h0X2Rpc3BsYXkiOiJmdWxsIiwiY29udGVudHMiOlt7InJvbGUiOiJtb2RlbCIsInBhcnRzIjpbeyJ0aG91Z2h0Ijp0cnVlLCJ0ZXh0IjoiR2VudWluZSBzeW50aGV0aWMgbmF0aXZlIHRob3VnaHQg6ZuqIiwidGhvdWdodFNpZ25hdHVyZSI6InJhdy10aG91Z2h0In0seyJmdW5jdGlvbkNhbGwiOnsibmFtZSI6Im5hdGl2ZV9yZWFkX2FsaWFzIiwiaWQiOiJyZXVzZWQtbmF0aXZlLWlkIiwiYXJncyI6eyJmaWxlX3BhdGgiOiJcIi9maXh0dXJlL3JlYWQudHh0XCIiLCJvZmZzZXQiOjEuMH19LCJ0aG91Z2h0U2lnbmF0dXJlIjoicmF3LWNhbGwifV19XSwidG9vbF9tYXBwaW5nIjpbeyJsb2NhbF9pZCI6InRvb2x1X2dvbGRlbl9sZWFmX2Z1bGxfbmVnYXRpdmVfc2Vzc2lvbiIsImNhbGxlcl9uYW1lIjoiUmVhZCIsImNvbnRlbnRfaW5kZXgiOjAsInBhcnRfaW5kZXgiOjEsIm5hdGl2ZV9pZF9wcmVzZW50Ijp0cnVlLCJjb2RlY19wYXRocyI6WyIvZmlsZV9wYXRoIl0sInJvb3Rfd3JhcHBlciI6bnVsbCwibmF0aXZlX2lkIjoicmV1c2VkLW5hdGl2ZS1pZCJ9XSwicHJvamVjdGlvbl9kaWdlc3QiOiI3ZDFhNjJhN2RlZTI5YmZiMzM4YmE3MzdkMDM2M2UzNWNmYWM2ODE0MDlmMDM4Nzg4NjhlZGJhNzNlZmNjN2Y5In0" }, { "type": "tool_use", @@ -173,7 +181,17 @@ "profile_id": "synthetic-profile", "project_id": "synthetic-project", "native_model": "gemini-4-argon-low", - "session_id": "-37", + "backend_session_id": "-3750763034362895579", + "lineage": { + "conversation_id": "11111111-1111-4111-8111-111111111111", + "trajectory_id": "22222222-2222-4222-8222-222222222222" + }, + "native_checkpoint_index": 0, + "user_execution": { + "active_id": "aaaaaaaa-aaaa-4aaa-8aaa-000000000000", + "prior_recorded_id": null + }, + "checkpoint_stop_reason": "tool_use", "thought_display": "full", "contents": [ { @@ -212,11 +230,11 @@ "native_id": "reused-native-id" } ], - "projection_digest": "a8591ba006999ed2ba16c619a7b76e37992eec9ba00434319d8a9b12b6b77de1" + "projection_digest": "7d1a62a7dee29bfb338ba737d0363e35cfac681409f03878868edba73efcc7f9" }, - "canonical_digest_input_utf8": "[\"object\",[[\"binding\",[\"object\",[[\"native_model\",[\"string\",\"gemini-4-argon-low\"]],[\"profile_id\",[\"string\",\"synthetic-profile\"]],[\"project_id\",[\"string\",\"synthetic-project\"]],[\"session_id\",[\"string\",\"-37\"]],[\"thought_display\",[\"string\",\"full\"]],[\"tool_mapping\",[\"array\",[[\"object\",[[\"caller_name\",[\"string\",\"Read\"]],[\"codec_paths\",[\"array\",[[\"string\",\"/file_path\"]]]],[\"content_index\",[\"number\",\"0000000000000000\"]],[\"local_id\",[\"string\",\"toolu_golden_leaf_full_negative_session\"]],[\"native_id\",[\"string\",\"reused-native-id\"]],[\"native_id_present\",[\"bool\",true]],[\"part_index\",[\"number\",\"3ff0000000000000\"]],[\"root_wrapper\",[\"null\"]]]]]]]]]],[\"blocks\",[\"array\",[[\"object\",[[\"thinking\",[\"string\",\"Genuine synthetic native thought 雪\"]],[\"type\",[\"string\",\"thinking\"]]]],[\"object\",[[\"id\",[\"string\",\"toolu_golden_leaf_full_negative_session\"]],[\"input\",[\"object\",[[\"file_path\",[\"string\",\"/fixture/read.txt\"]],[\"offset\",[\"number\",\"3ff0000000000000\"]]]]],[\"name\",[\"string\",\"Read\"]],[\"type\",[\"string\",\"tool_use\"]]]]]]]]]", - "canonical_digest_input_hex": "5b226f626a656374222c5b5b2262696e64696e67222c5b226f626a656374222c5b5b226e61746976655f6d6f64656c222c5b22737472696e67222c2267656d696e692d342d6172676f6e2d6c6f77225d5d2c5b2270726f66696c655f6964222c5b22737472696e67222c2273796e7468657469632d70726f66696c65225d5d2c5b2270726f6a6563745f6964222c5b22737472696e67222c2273796e7468657469632d70726f6a656374225d5d2c5b2273657373696f6e5f6964222c5b22737472696e67222c222d3337225d5d2c5b2274686f756768745f646973706c6179222c5b22737472696e67222c2266756c6c225d5d2c5b22746f6f6c5f6d617070696e67222c5b226172726179222c5b5b226f626a656374222c5b5b2263616c6c65725f6e616d65222c5b22737472696e67222c2252656164225d5d2c5b22636f6465635f7061746873222c5b226172726179222c5b5b22737472696e67222c222f66696c655f70617468225d5d5d5d2c5b22636f6e74656e745f696e646578222c5b226e756d626572222c2230303030303030303030303030303030225d5d2c5b226c6f63616c5f6964222c5b22737472696e67222c22746f6f6c755f676f6c64656e5f6c6561665f66756c6c5f6e656761746976655f73657373696f6e225d5d2c5b226e61746976655f6964222c5b22737472696e67222c227265757365642d6e61746976652d6964225d5d2c5b226e61746976655f69645f70726573656e74222c5b22626f6f6c222c747275655d5d2c5b22706172745f696e646578222c5b226e756d626572222c2233666630303030303030303030303030225d5d2c5b22726f6f745f77726170706572222c5b226e756c6c225d5d5d5d5d5d5d5d5d5d2c5b22626c6f636b73222c5b226172726179222c5b5b226f626a656374222c5b5b227468696e6b696e67222c5b22737472696e67222c2247656e75696e652073796e746865746963206e61746976652074686f7567687420e99baa225d5d2c5b2274797065222c5b22737472696e67222c227468696e6b696e67225d5d5d5d2c5b226f626a656374222c5b5b226964222c5b22737472696e67222c22746f6f6c755f676f6c64656e5f6c6561665f66756c6c5f6e656761746976655f73657373696f6e225d5d2c5b22696e707574222c5b226f626a656374222c5b5b2266696c655f70617468222c5b22737472696e67222c222f666978747572652f726561642e747874225d5d2c5b226f6666736574222c5b226e756d626572222c2233666630303030303030303030303030225d5d5d5d5d2c5b226e616d65222c5b22737472696e67222c2252656164225d5d2c5b2274797065222c5b22737472696e67222c22746f6f6c5f757365225d5d5d5d5d5d5d5d5d", - "sha256": "a8591ba006999ed2ba16c619a7b76e37992eec9ba00434319d8a9b12b6b77de1" + "canonical_digest_input_utf8": "[\"object\",[[\"binding\",[\"object\",[[\"backend_session_id\",[\"string\",\"-3750763034362895579\"]],[\"checkpoint_stop_reason\",[\"string\",\"tool_use\"]],[\"lineage\",[\"object\",[[\"conversation_id\",[\"string\",\"11111111-1111-4111-8111-111111111111\"]],[\"trajectory_id\",[\"string\",\"22222222-2222-4222-8222-222222222222\"]]]]],[\"native_checkpoint_index\",[\"number\",\"0000000000000000\"]],[\"native_model\",[\"string\",\"gemini-4-argon-low\"]],[\"profile_id\",[\"string\",\"synthetic-profile\"]],[\"project_id\",[\"string\",\"synthetic-project\"]],[\"thought_display\",[\"string\",\"full\"]],[\"tool_mapping\",[\"array\",[[\"object\",[[\"caller_name\",[\"string\",\"Read\"]],[\"codec_paths\",[\"array\",[[\"string\",\"/file_path\"]]]],[\"content_index\",[\"number\",\"0000000000000000\"]],[\"local_id\",[\"string\",\"toolu_golden_leaf_full_negative_session\"]],[\"native_id\",[\"string\",\"reused-native-id\"]],[\"native_id_present\",[\"bool\",true]],[\"part_index\",[\"number\",\"3ff0000000000000\"]],[\"root_wrapper\",[\"null\"]]]]]]],[\"user_execution\",[\"object\",[[\"active_id\",[\"string\",\"aaaaaaaa-aaaa-4aaa-8aaa-000000000000\"]],[\"prior_recorded_id\",[\"null\"]]]]],[\"version\",[\"number\",\"3ff0000000000000\"]]]]],[\"blocks\",[\"array\",[[\"object\",[[\"thinking\",[\"string\",\"Genuine synthetic native thought 雪\"]],[\"type\",[\"string\",\"thinking\"]]]],[\"object\",[[\"id\",[\"string\",\"toolu_golden_leaf_full_negative_session\"]],[\"input\",[\"object\",[[\"file_path\",[\"string\",\"/fixture/read.txt\"]],[\"offset\",[\"number\",\"3ff0000000000000\"]]]]],[\"name\",[\"string\",\"Read\"]],[\"type\",[\"string\",\"tool_use\"]]]]]]],[\"domain\",[\"string\",\"ccp:antigravity:v1:projection\"]]]]", + "canonical_digest_input_hex": "5b226f626a656374222c5b5b2262696e64696e67222c5b226f626a656374222c5b5b226261636b656e645f73657373696f6e5f6964222c5b22737472696e67222c222d33373530373633303334333632383935353739225d5d2c5b22636865636b706f696e745f73746f705f726561736f6e222c5b22737472696e67222c22746f6f6c5f757365225d5d2c5b226c696e65616765222c5b226f626a656374222c5b5b22636f6e766572736174696f6e5f6964222c5b22737472696e67222c2231313131313131312d313131312d343131312d383131312d313131313131313131313131225d5d2c5b227472616a6563746f72795f6964222c5b22737472696e67222c2232323232323232322d323232322d343232322d383232322d323232323232323232323232225d5d5d5d5d2c5b226e61746976655f636865636b706f696e745f696e646578222c5b226e756d626572222c2230303030303030303030303030303030225d5d2c5b226e61746976655f6d6f64656c222c5b22737472696e67222c2267656d696e692d342d6172676f6e2d6c6f77225d5d2c5b2270726f66696c655f6964222c5b22737472696e67222c2273796e7468657469632d70726f66696c65225d5d2c5b2270726f6a6563745f6964222c5b22737472696e67222c2273796e7468657469632d70726f6a656374225d5d2c5b2274686f756768745f646973706c6179222c5b22737472696e67222c2266756c6c225d5d2c5b22746f6f6c5f6d617070696e67222c5b226172726179222c5b5b226f626a656374222c5b5b2263616c6c65725f6e616d65222c5b22737472696e67222c2252656164225d5d2c5b22636f6465635f7061746873222c5b226172726179222c5b5b22737472696e67222c222f66696c655f70617468225d5d5d5d2c5b22636f6e74656e745f696e646578222c5b226e756d626572222c2230303030303030303030303030303030225d5d2c5b226c6f63616c5f6964222c5b22737472696e67222c22746f6f6c755f676f6c64656e5f6c6561665f66756c6c5f6e656761746976655f73657373696f6e225d5d2c5b226e61746976655f6964222c5b22737472696e67222c227265757365642d6e61746976652d6964225d5d2c5b226e61746976655f69645f70726573656e74222c5b22626f6f6c222c747275655d5d2c5b22706172745f696e646578222c5b226e756d626572222c2233666630303030303030303030303030225d5d2c5b22726f6f745f77726170706572222c5b226e756c6c225d5d5d5d5d5d5d2c5b22757365725f657865637574696f6e222c5b226f626a656374222c5b5b226163746976655f6964222c5b22737472696e67222c2261616161616161612d616161612d346161612d386161612d303030303030303030303030225d5d2c5b227072696f725f7265636f726465645f6964222c5b226e756c6c225d5d5d5d5d2c5b2276657273696f6e222c5b226e756d626572222c2233666630303030303030303030303030225d5d5d5d5d2c5b22626c6f636b73222c5b226172726179222c5b5b226f626a656374222c5b5b227468696e6b696e67222c5b22737472696e67222c2247656e75696e652073796e746865746963206e61746976652074686f7567687420e99baa225d5d2c5b2274797065222c5b22737472696e67222c227468696e6b696e67225d5d5d5d2c5b226f626a656374222c5b5b226964222c5b22737472696e67222c22746f6f6c755f676f6c64656e5f6c6561665f66756c6c5f6e656761746976655f73657373696f6e225d5d2c5b22696e707574222c5b226f626a656374222c5b5b2266696c655f70617468222c5b22737472696e67222c222f666978747572652f726561642e747874225d5d2c5b226f6666736574222c5b226e756d626572222c2233666630303030303030303030303030225d5d5d5d5d2c5b226e616d65222c5b22737472696e67222c2252656164225d5d2c5b2274797065222c5b22737472696e67222c22746f6f6c5f757365225d5d5d5d5d5d5d2c5b22646f6d61696e222c5b22737472696e67222c226363703a616e7469677261766974793a76313a70726f6a656374696f6e225d5d5d5d", + "sha256": "7d1a62a7dee29bfb338ba737d0363e35cfac681409f03878868edba73efcc7f9" }, { "name": "root-omitted-i64-min", @@ -247,7 +265,6 @@ ] } ], - "session_id": "-9223372036854775808", "thought_display": "omitted", "tool_projections": [ { @@ -261,7 +278,7 @@ { "type": "thinking", "thinking": "", - "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJzZXNzaW9uX2lkIjoiLTkyMjMzNzIwMzY4NTQ3NzU4MDgiLCJ0aG91Z2h0X2Rpc3BsYXkiOiJvbWl0dGVkIiwiY29udGVudHMiOlt7InJvbGUiOiJtb2RlbCIsInBhcnRzIjpbeyJ0aG91Z2h0Ijp0cnVlLCJ0ZXh0IjoiR2VudWluZSBzeW50aGV0aWMgbmF0aXZlIHRob3VnaHQg6ZuqIiwidGhvdWdodFNpZ25hdHVyZSI6InJhdy10aG91Z2h0In0seyJmdW5jdGlvbkNhbGwiOnsibmFtZSI6Im5hdGl2ZV9yZWFkX2FsaWFzIiwiaWQiOiJyZXVzZWQtbmF0aXZlLWlkIiwiYXJncyI6eyJqc29uIjoie1wiZmlsZV9wYXRoXCI6IFwiL2ZpeHR1cmUvcmVhZC50eHRcIiwgXCJvZmZzZXRcIjogMS4wfSJ9fSwidGhvdWdodFNpZ25hdHVyZSI6InJhdy1jYWxsIn1dfV0sInRvb2xfbWFwcGluZyI6W3sibG9jYWxfaWQiOiJ0b29sdV9nb2xkZW5fcm9vdF9vbWl0dGVkX2k2NF9taW4iLCJjYWxsZXJfbmFtZSI6IlJlYWQiLCJjb250ZW50X2luZGV4IjowLCJwYXJ0X2luZGV4IjoxLCJuYXRpdmVfaWRfcHJlc2VudCI6dHJ1ZSwiY29kZWNfcGF0aHMiOltdLCJyb290X3dyYXBwZXIiOiJqc29uIiwibmF0aXZlX2lkIjoicmV1c2VkLW5hdGl2ZS1pZCJ9XSwicHJvamVjdGlvbl9kaWdlc3QiOiI5OTQwY2I3YTg1ZWQ5MGQ5MzgxZTYyNTA5ZTY4ZTk0YjczM2VhNTcyNTY5M2JiZmVkOWYzZGI1OWUyODEwNDExIn0" + "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJiYWNrZW5kX3Nlc3Npb25faWQiOiItMzc1MDc2MzAzNDM2Mjg5NTU3OSIsImxpbmVhZ2UiOnsiY29udmVyc2F0aW9uX2lkIjoiMTExMTExMTEtMTExMS00MTExLTgxMTEtMTExMTExMTExMTExIiwidHJhamVjdG9yeV9pZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiJ9LCJuYXRpdmVfY2hlY2twb2ludF9pbmRleCI6MCwidXNlcl9leGVjdXRpb24iOnsiYWN0aXZlX2lkIjoiYWFhYWFhYWEtYWFhYS00YWFhLThhYWEtMDAwMDAwMDAwMDAwIiwicHJpb3JfcmVjb3JkZWRfaWQiOm51bGx9LCJjaGVja3BvaW50X3N0b3BfcmVhc29uIjoidG9vbF91c2UiLCJ0aG91Z2h0X2Rpc3BsYXkiOiJvbWl0dGVkIiwiY29udGVudHMiOlt7InJvbGUiOiJtb2RlbCIsInBhcnRzIjpbeyJ0aG91Z2h0Ijp0cnVlLCJ0ZXh0IjoiR2VudWluZSBzeW50aGV0aWMgbmF0aXZlIHRob3VnaHQg6ZuqIiwidGhvdWdodFNpZ25hdHVyZSI6InJhdy10aG91Z2h0In0seyJmdW5jdGlvbkNhbGwiOnsibmFtZSI6Im5hdGl2ZV9yZWFkX2FsaWFzIiwiaWQiOiJyZXVzZWQtbmF0aXZlLWlkIiwiYXJncyI6eyJqc29uIjoie1wiZmlsZV9wYXRoXCI6IFwiL2ZpeHR1cmUvcmVhZC50eHRcIiwgXCJvZmZzZXRcIjogMS4wfSJ9fSwidGhvdWdodFNpZ25hdHVyZSI6InJhdy1jYWxsIn1dfV0sInRvb2xfbWFwcGluZyI6W3sibG9jYWxfaWQiOiJ0b29sdV9nb2xkZW5fcm9vdF9vbWl0dGVkX2k2NF9taW4iLCJjYWxsZXJfbmFtZSI6IlJlYWQiLCJjb250ZW50X2luZGV4IjowLCJwYXJ0X2luZGV4IjoxLCJuYXRpdmVfaWRfcHJlc2VudCI6dHJ1ZSwiY29kZWNfcGF0aHMiOltdLCJyb290X3dyYXBwZXIiOiJqc29uIiwibmF0aXZlX2lkIjoicmV1c2VkLW5hdGl2ZS1pZCJ9XSwicHJvamVjdGlvbl9kaWdlc3QiOiI0YmFjNTdlNDUxODQ3OWU4NGExOTdlMjhiZWFjNzE3ZTU3NDQ3YWM2YzUyMTlhZjI2YWQ4OWE2YjdjZmViOTQxIn0" }, { "type": "tool_use", @@ -278,7 +295,17 @@ "profile_id": "synthetic-profile", "project_id": "synthetic-project", "native_model": "gemini-4-argon-low", - "session_id": "-9223372036854775808", + "backend_session_id": "-3750763034362895579", + "lineage": { + "conversation_id": "11111111-1111-4111-8111-111111111111", + "trajectory_id": "22222222-2222-4222-8222-222222222222" + }, + "native_checkpoint_index": 0, + "user_execution": { + "active_id": "aaaaaaaa-aaaa-4aaa-8aaa-000000000000", + "prior_recorded_id": null + }, + "checkpoint_stop_reason": "tool_use", "thought_display": "omitted", "contents": [ { @@ -314,11 +341,11 @@ "native_id": "reused-native-id" } ], - "projection_digest": "9940cb7a85ed90d9381e62509e68e94b733ea5725693bbfed9f3db59e2810411" + "projection_digest": "4bac57e4518479e84a197e28beac717e57447ac6c5219af26ad89a6b7cfeb941" }, - "canonical_digest_input_utf8": "[\"object\",[[\"binding\",[\"object\",[[\"native_model\",[\"string\",\"gemini-4-argon-low\"]],[\"profile_id\",[\"string\",\"synthetic-profile\"]],[\"project_id\",[\"string\",\"synthetic-project\"]],[\"session_id\",[\"string\",\"-9223372036854775808\"]],[\"thought_display\",[\"string\",\"omitted\"]],[\"tool_mapping\",[\"array\",[[\"object\",[[\"caller_name\",[\"string\",\"Read\"]],[\"codec_paths\",[\"array\",[]]],[\"content_index\",[\"number\",\"0000000000000000\"]],[\"local_id\",[\"string\",\"toolu_golden_root_omitted_i64_min\"]],[\"native_id\",[\"string\",\"reused-native-id\"]],[\"native_id_present\",[\"bool\",true]],[\"part_index\",[\"number\",\"3ff0000000000000\"]],[\"root_wrapper\",[\"string\",\"json\"]]]]]]]]]],[\"blocks\",[\"array\",[[\"object\",[[\"thinking\",[\"string\",\"\"]],[\"type\",[\"string\",\"thinking\"]]]],[\"object\",[[\"id\",[\"string\",\"toolu_golden_root_omitted_i64_min\"]],[\"input\",[\"object\",[[\"file_path\",[\"string\",\"/fixture/read.txt\"]],[\"offset\",[\"number\",\"3ff0000000000000\"]]]]],[\"name\",[\"string\",\"Read\"]],[\"type\",[\"string\",\"tool_use\"]]]]]]]]]", - "canonical_digest_input_hex": "5b226f626a656374222c5b5b2262696e64696e67222c5b226f626a656374222c5b5b226e61746976655f6d6f64656c222c5b22737472696e67222c2267656d696e692d342d6172676f6e2d6c6f77225d5d2c5b2270726f66696c655f6964222c5b22737472696e67222c2273796e7468657469632d70726f66696c65225d5d2c5b2270726f6a6563745f6964222c5b22737472696e67222c2273796e7468657469632d70726f6a656374225d5d2c5b2273657373696f6e5f6964222c5b22737472696e67222c222d39323233333732303336383534373735383038225d5d2c5b2274686f756768745f646973706c6179222c5b22737472696e67222c226f6d6974746564225d5d2c5b22746f6f6c5f6d617070696e67222c5b226172726179222c5b5b226f626a656374222c5b5b2263616c6c65725f6e616d65222c5b22737472696e67222c2252656164225d5d2c5b22636f6465635f7061746873222c5b226172726179222c5b5d5d5d2c5b22636f6e74656e745f696e646578222c5b226e756d626572222c2230303030303030303030303030303030225d5d2c5b226c6f63616c5f6964222c5b22737472696e67222c22746f6f6c755f676f6c64656e5f726f6f745f6f6d69747465645f6936345f6d696e225d5d2c5b226e61746976655f6964222c5b22737472696e67222c227265757365642d6e61746976652d6964225d5d2c5b226e61746976655f69645f70726573656e74222c5b22626f6f6c222c747275655d5d2c5b22706172745f696e646578222c5b226e756d626572222c2233666630303030303030303030303030225d5d2c5b22726f6f745f77726170706572222c5b22737472696e67222c226a736f6e225d5d5d5d5d5d5d5d5d5d2c5b22626c6f636b73222c5b226172726179222c5b5b226f626a656374222c5b5b227468696e6b696e67222c5b22737472696e67222c22225d5d2c5b2274797065222c5b22737472696e67222c227468696e6b696e67225d5d5d5d2c5b226f626a656374222c5b5b226964222c5b22737472696e67222c22746f6f6c755f676f6c64656e5f726f6f745f6f6d69747465645f6936345f6d696e225d5d2c5b22696e707574222c5b226f626a656374222c5b5b2266696c655f70617468222c5b22737472696e67222c222f666978747572652f726561642e747874225d5d2c5b226f6666736574222c5b226e756d626572222c2233666630303030303030303030303030225d5d5d5d5d2c5b226e616d65222c5b22737472696e67222c2252656164225d5d2c5b2274797065222c5b22737472696e67222c22746f6f6c5f757365225d5d5d5d5d5d5d5d5d", - "sha256": "9940cb7a85ed90d9381e62509e68e94b733ea5725693bbfed9f3db59e2810411" + "canonical_digest_input_utf8": "[\"object\",[[\"binding\",[\"object\",[[\"backend_session_id\",[\"string\",\"-3750763034362895579\"]],[\"checkpoint_stop_reason\",[\"string\",\"tool_use\"]],[\"lineage\",[\"object\",[[\"conversation_id\",[\"string\",\"11111111-1111-4111-8111-111111111111\"]],[\"trajectory_id\",[\"string\",\"22222222-2222-4222-8222-222222222222\"]]]]],[\"native_checkpoint_index\",[\"number\",\"0000000000000000\"]],[\"native_model\",[\"string\",\"gemini-4-argon-low\"]],[\"profile_id\",[\"string\",\"synthetic-profile\"]],[\"project_id\",[\"string\",\"synthetic-project\"]],[\"thought_display\",[\"string\",\"omitted\"]],[\"tool_mapping\",[\"array\",[[\"object\",[[\"caller_name\",[\"string\",\"Read\"]],[\"codec_paths\",[\"array\",[]]],[\"content_index\",[\"number\",\"0000000000000000\"]],[\"local_id\",[\"string\",\"toolu_golden_root_omitted_i64_min\"]],[\"native_id\",[\"string\",\"reused-native-id\"]],[\"native_id_present\",[\"bool\",true]],[\"part_index\",[\"number\",\"3ff0000000000000\"]],[\"root_wrapper\",[\"string\",\"json\"]]]]]]],[\"user_execution\",[\"object\",[[\"active_id\",[\"string\",\"aaaaaaaa-aaaa-4aaa-8aaa-000000000000\"]],[\"prior_recorded_id\",[\"null\"]]]]],[\"version\",[\"number\",\"3ff0000000000000\"]]]]],[\"blocks\",[\"array\",[[\"object\",[[\"thinking\",[\"string\",\"\"]],[\"type\",[\"string\",\"thinking\"]]]],[\"object\",[[\"id\",[\"string\",\"toolu_golden_root_omitted_i64_min\"]],[\"input\",[\"object\",[[\"file_path\",[\"string\",\"/fixture/read.txt\"]],[\"offset\",[\"number\",\"3ff0000000000000\"]]]]],[\"name\",[\"string\",\"Read\"]],[\"type\",[\"string\",\"tool_use\"]]]]]]],[\"domain\",[\"string\",\"ccp:antigravity:v1:projection\"]]]]", + "canonical_digest_input_hex": "5b226f626a656374222c5b5b2262696e64696e67222c5b226f626a656374222c5b5b226261636b656e645f73657373696f6e5f6964222c5b22737472696e67222c222d33373530373633303334333632383935353739225d5d2c5b22636865636b706f696e745f73746f705f726561736f6e222c5b22737472696e67222c22746f6f6c5f757365225d5d2c5b226c696e65616765222c5b226f626a656374222c5b5b22636f6e766572736174696f6e5f6964222c5b22737472696e67222c2231313131313131312d313131312d343131312d383131312d313131313131313131313131225d5d2c5b227472616a6563746f72795f6964222c5b22737472696e67222c2232323232323232322d323232322d343232322d383232322d323232323232323232323232225d5d5d5d5d2c5b226e61746976655f636865636b706f696e745f696e646578222c5b226e756d626572222c2230303030303030303030303030303030225d5d2c5b226e61746976655f6d6f64656c222c5b22737472696e67222c2267656d696e692d342d6172676f6e2d6c6f77225d5d2c5b2270726f66696c655f6964222c5b22737472696e67222c2273796e7468657469632d70726f66696c65225d5d2c5b2270726f6a6563745f6964222c5b22737472696e67222c2273796e7468657469632d70726f6a656374225d5d2c5b2274686f756768745f646973706c6179222c5b22737472696e67222c226f6d6974746564225d5d2c5b22746f6f6c5f6d617070696e67222c5b226172726179222c5b5b226f626a656374222c5b5b2263616c6c65725f6e616d65222c5b22737472696e67222c2252656164225d5d2c5b22636f6465635f7061746873222c5b226172726179222c5b5d5d5d2c5b22636f6e74656e745f696e646578222c5b226e756d626572222c2230303030303030303030303030303030225d5d2c5b226c6f63616c5f6964222c5b22737472696e67222c22746f6f6c755f676f6c64656e5f726f6f745f6f6d69747465645f6936345f6d696e225d5d2c5b226e61746976655f6964222c5b22737472696e67222c227265757365642d6e61746976652d6964225d5d2c5b226e61746976655f69645f70726573656e74222c5b22626f6f6c222c747275655d5d2c5b22706172745f696e646578222c5b226e756d626572222c2233666630303030303030303030303030225d5d2c5b22726f6f745f77726170706572222c5b22737472696e67222c226a736f6e225d5d5d5d5d5d5d2c5b22757365725f657865637574696f6e222c5b226f626a656374222c5b5b226163746976655f6964222c5b22737472696e67222c2261616161616161612d616161612d346161612d386161612d303030303030303030303030225d5d2c5b227072696f725f7265636f726465645f6964222c5b226e756c6c225d5d5d5d5d2c5b2276657273696f6e222c5b226e756d626572222c2233666630303030303030303030303030225d5d5d5d5d2c5b22626c6f636b73222c5b226172726179222c5b5b226f626a656374222c5b5b227468696e6b696e67222c5b22737472696e67222c22225d5d2c5b2274797065222c5b22737472696e67222c227468696e6b696e67225d5d5d5d2c5b226f626a656374222c5b5b226964222c5b22737472696e67222c22746f6f6c755f676f6c64656e5f726f6f745f6f6d69747465645f6936345f6d696e225d5d2c5b22696e707574222c5b226f626a656374222c5b5b2266696c655f70617468222c5b22737472696e67222c222f666978747572652f726561642e747874225d5d2c5b226f6666736574222c5b226e756d626572222c2233666630303030303030303030303030225d5d5d5d5d2c5b226e616d65222c5b22737472696e67222c2252656164225d5d2c5b2274797065222c5b22737472696e67222c22746f6f6c5f757365225d5d5d5d5d5d5d2c5b22646f6d61696e222c5b22737472696e67222c226363703a616e7469677261766974793a76313a70726f6a656374696f6e225d5d5d5d", + "sha256": "4bac57e4518479e84a197e28beac717e57447ac6c5219af26ad89a6b7cfeb941" }, { "name": "parallel-reused-native-id", @@ -385,7 +412,6 @@ ] } ], - "session_id": "17", "thought_display": "full", "tool_projections": [ { @@ -405,7 +431,7 @@ { "type": "thinking", "thinking": "synthetic thought α / synthetic thought β", - "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJzZXNzaW9uX2lkIjoiMTciLCJ0aG91Z2h0X2Rpc3BsYXkiOiJmdWxsIiwiY29udGVudHMiOlt7InJvbGUiOiJtb2RlbCIsInBhcnRzIjpbeyJ0aG91Z2h0Ijp0cnVlLCJ0ZXh0Ijoic3ludGhldGljIHRob3VnaHQgzrEiLCJ0aG91Z2h0U2lnbmF0dXJlIjoiZmFrZS10aG91Z2h0LTEifSx7InRleHQiOiJ2aXNpYmxlIOmbqiJ9LHsidGhvdWdodCI6dHJ1ZSwidGV4dCI6IiAvIHN5bnRoZXRpYyB0aG91Z2h0IM6yIn0seyJmdW5jdGlvbkNhbGwiOnsibmFtZSI6IlJlYWQiLCJhcmdzIjp7ImZpbGVfcGF0aCI6Ii9maXh0dXJlL3JlYWQudHh0Iiwib2Zmc2V0IjoxLjB9LCJpZCI6Im5hdGl2ZS1zZWNvbmQifSwidGhvdWdodFNpZ25hdHVyZSI6ImZha2UtY2FsbC1zaWduZWQifSx7ImZ1bmN0aW9uQ2FsbCI6eyJuYW1lIjoiUmVhZCIsImlkIjoibmF0aXZlLXNlY29uZCIsImFyZ3MiOnsiZmlsZV9wYXRoIjoiL2ZpeHR1cmUvcmVhZC50eHQiLCJsaW1pdCI6MX19fV0sInVua25vd24iOnsicHJlc2VydmUiOlt0cnVlLG51bGwsIsOpIl19fSx7InJvbGUiOiJtb2RlbCIsInBhcnRzIjpbeyJ0aG91Z2h0U2lnbmF0dXJlIjoiZmFrZS1sYXRlLXNpZ25hdHVyZSJ9LHsidGV4dCI6IiJ9XX1dLCJ0b29sX21hcHBpbmciOlt7ImxvY2FsX2lkIjoidG9vbHVfZ29sZGVuX3JldXNlZF8wIiwiY2FsbGVyX25hbWUiOiJSZWFkIiwiY29udGVudF9pbmRleCI6MCwicGFydF9pbmRleCI6MywibmF0aXZlX2lkX3ByZXNlbnQiOnRydWUsImNvZGVjX3BhdGhzIjpbXSwicm9vdF93cmFwcGVyIjpudWxsLCJuYXRpdmVfaWQiOiJuYXRpdmUtc2Vjb25kIn0seyJsb2NhbF9pZCI6InRvb2x1X2dvbGRlbl9yZXVzZWRfMSIsImNhbGxlcl9uYW1lIjoiUmVhZCIsImNvbnRlbnRfaW5kZXgiOjAsInBhcnRfaW5kZXgiOjQsIm5hdGl2ZV9pZF9wcmVzZW50Ijp0cnVlLCJjb2RlY19wYXRocyI6W10sInJvb3Rfd3JhcHBlciI6bnVsbCwibmF0aXZlX2lkIjoibmF0aXZlLXNlY29uZCJ9XSwicHJvamVjdGlvbl9kaWdlc3QiOiJlOWIzMGMxZTM2NjYxY2U2MDVmY2VhZmEwMmU3YjcxZGU3MjRiOTM0ZWFiZWM4ZTZiOGNiMzVlZGMzM2E1MzliIn0" + "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJiYWNrZW5kX3Nlc3Npb25faWQiOiItMzc1MDc2MzAzNDM2Mjg5NTU3OSIsImxpbmVhZ2UiOnsiY29udmVyc2F0aW9uX2lkIjoiMTExMTExMTEtMTExMS00MTExLTgxMTEtMTExMTExMTExMTExIiwidHJhamVjdG9yeV9pZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiJ9LCJuYXRpdmVfY2hlY2twb2ludF9pbmRleCI6MCwidXNlcl9leGVjdXRpb24iOnsiYWN0aXZlX2lkIjoiYWFhYWFhYWEtYWFhYS00YWFhLThhYWEtMDAwMDAwMDAwMDAwIiwicHJpb3JfcmVjb3JkZWRfaWQiOm51bGx9LCJjaGVja3BvaW50X3N0b3BfcmVhc29uIjoidG9vbF91c2UiLCJ0aG91Z2h0X2Rpc3BsYXkiOiJmdWxsIiwiY29udGVudHMiOlt7InJvbGUiOiJtb2RlbCIsInBhcnRzIjpbeyJ0aG91Z2h0Ijp0cnVlLCJ0ZXh0Ijoic3ludGhldGljIHRob3VnaHQgzrEiLCJ0aG91Z2h0U2lnbmF0dXJlIjoiZmFrZS10aG91Z2h0LTEifSx7InRleHQiOiJ2aXNpYmxlIOmbqiJ9LHsidGhvdWdodCI6dHJ1ZSwidGV4dCI6IiAvIHN5bnRoZXRpYyB0aG91Z2h0IM6yIn0seyJmdW5jdGlvbkNhbGwiOnsibmFtZSI6IlJlYWQiLCJhcmdzIjp7ImZpbGVfcGF0aCI6Ii9maXh0dXJlL3JlYWQudHh0Iiwib2Zmc2V0IjoxLjB9LCJpZCI6Im5hdGl2ZS1zZWNvbmQifSwidGhvdWdodFNpZ25hdHVyZSI6ImZha2UtY2FsbC1zaWduZWQifSx7ImZ1bmN0aW9uQ2FsbCI6eyJuYW1lIjoiUmVhZCIsImlkIjoibmF0aXZlLXNlY29uZCIsImFyZ3MiOnsiZmlsZV9wYXRoIjoiL2ZpeHR1cmUvcmVhZC50eHQiLCJsaW1pdCI6MX19fV0sInVua25vd24iOnsicHJlc2VydmUiOlt0cnVlLG51bGwsIsOpIl19fSx7InJvbGUiOiJtb2RlbCIsInBhcnRzIjpbeyJ0aG91Z2h0U2lnbmF0dXJlIjoiZmFrZS1sYXRlLXNpZ25hdHVyZSJ9LHsidGV4dCI6IiJ9XX1dLCJ0b29sX21hcHBpbmciOlt7ImxvY2FsX2lkIjoidG9vbHVfZ29sZGVuX3JldXNlZF8wIiwiY2FsbGVyX25hbWUiOiJSZWFkIiwiY29udGVudF9pbmRleCI6MCwicGFydF9pbmRleCI6MywibmF0aXZlX2lkX3ByZXNlbnQiOnRydWUsImNvZGVjX3BhdGhzIjpbXSwicm9vdF93cmFwcGVyIjpudWxsLCJuYXRpdmVfaWQiOiJuYXRpdmUtc2Vjb25kIn0seyJsb2NhbF9pZCI6InRvb2x1X2dvbGRlbl9yZXVzZWRfMSIsImNhbGxlcl9uYW1lIjoiUmVhZCIsImNvbnRlbnRfaW5kZXgiOjAsInBhcnRfaW5kZXgiOjQsIm5hdGl2ZV9pZF9wcmVzZW50Ijp0cnVlLCJjb2RlY19wYXRocyI6W10sInJvb3Rfd3JhcHBlciI6bnVsbCwibmF0aXZlX2lkIjoibmF0aXZlLXNlY29uZCJ9XSwicHJvamVjdGlvbl9kaWdlc3QiOiJlMzAzYWY1NjAxM2E2YWM3ODI4ZGQ0MTdmNzA1ODA0OTFkZDBmNzJiMTI5ZGMyNGViYzEyZjBhMjVjYTVlM2Q1In0" }, { "type": "text", @@ -435,7 +461,17 @@ "profile_id": "synthetic-profile", "project_id": "synthetic-project", "native_model": "gemini-4-argon-low", - "session_id": "17", + "backend_session_id": "-3750763034362895579", + "lineage": { + "conversation_id": "11111111-1111-4111-8111-111111111111", + "trajectory_id": "22222222-2222-4222-8222-222222222222" + }, + "native_checkpoint_index": 0, + "user_execution": { + "active_id": "aaaaaaaa-aaaa-4aaa-8aaa-000000000000", + "prior_recorded_id": null + }, + "checkpoint_stop_reason": "tool_use", "thought_display": "full", "contents": [ { @@ -517,11 +553,11 @@ "native_id": "native-second" } ], - "projection_digest": "e9b30c1e36661ce605fceafa02e7b71de724b934eabec8e6b8cb35edc33a539b" + "projection_digest": "e303af56013a6ac7828dd417f70580491dd0f72b129dc24ebc12f0a25ca5e3d5" }, - "canonical_digest_input_utf8": "[\"object\",[[\"binding\",[\"object\",[[\"native_model\",[\"string\",\"gemini-4-argon-low\"]],[\"profile_id\",[\"string\",\"synthetic-profile\"]],[\"project_id\",[\"string\",\"synthetic-project\"]],[\"session_id\",[\"string\",\"17\"]],[\"thought_display\",[\"string\",\"full\"]],[\"tool_mapping\",[\"array\",[[\"object\",[[\"caller_name\",[\"string\",\"Read\"]],[\"codec_paths\",[\"array\",[]]],[\"content_index\",[\"number\",\"0000000000000000\"]],[\"local_id\",[\"string\",\"toolu_golden_reused_0\"]],[\"native_id\",[\"string\",\"native-second\"]],[\"native_id_present\",[\"bool\",true]],[\"part_index\",[\"number\",\"4008000000000000\"]],[\"root_wrapper\",[\"null\"]]]],[\"object\",[[\"caller_name\",[\"string\",\"Read\"]],[\"codec_paths\",[\"array\",[]]],[\"content_index\",[\"number\",\"0000000000000000\"]],[\"local_id\",[\"string\",\"toolu_golden_reused_1\"]],[\"native_id\",[\"string\",\"native-second\"]],[\"native_id_present\",[\"bool\",true]],[\"part_index\",[\"number\",\"4010000000000000\"]],[\"root_wrapper\",[\"null\"]]]]]]]]]],[\"blocks\",[\"array\",[[\"object\",[[\"thinking\",[\"string\",\"synthetic thought α / synthetic thought β\"]],[\"type\",[\"string\",\"thinking\"]]]],[\"object\",[[\"text\",[\"string\",\"visible 雪\"]],[\"type\",[\"string\",\"text\"]]]],[\"object\",[[\"id\",[\"string\",\"toolu_golden_reused_0\"]],[\"input\",[\"object\",[[\"file_path\",[\"string\",\"/fixture/read.txt\"]],[\"offset\",[\"number\",\"3ff0000000000000\"]]]]],[\"name\",[\"string\",\"Read\"]],[\"type\",[\"string\",\"tool_use\"]]]],[\"object\",[[\"id\",[\"string\",\"toolu_golden_reused_1\"]],[\"input\",[\"object\",[[\"file_path\",[\"string\",\"/fixture/read.txt\"]],[\"limit\",[\"number\",\"3ff0000000000000\"]]]]],[\"name\",[\"string\",\"Read\"]],[\"type\",[\"string\",\"tool_use\"]]]]]]]]]", - "canonical_digest_input_hex": "5b226f626a656374222c5b5b2262696e64696e67222c5b226f626a656374222c5b5b226e61746976655f6d6f64656c222c5b22737472696e67222c2267656d696e692d342d6172676f6e2d6c6f77225d5d2c5b2270726f66696c655f6964222c5b22737472696e67222c2273796e7468657469632d70726f66696c65225d5d2c5b2270726f6a6563745f6964222c5b22737472696e67222c2273796e7468657469632d70726f6a656374225d5d2c5b2273657373696f6e5f6964222c5b22737472696e67222c223137225d5d2c5b2274686f756768745f646973706c6179222c5b22737472696e67222c2266756c6c225d5d2c5b22746f6f6c5f6d617070696e67222c5b226172726179222c5b5b226f626a656374222c5b5b2263616c6c65725f6e616d65222c5b22737472696e67222c2252656164225d5d2c5b22636f6465635f7061746873222c5b226172726179222c5b5d5d5d2c5b22636f6e74656e745f696e646578222c5b226e756d626572222c2230303030303030303030303030303030225d5d2c5b226c6f63616c5f6964222c5b22737472696e67222c22746f6f6c755f676f6c64656e5f7265757365645f30225d5d2c5b226e61746976655f6964222c5b22737472696e67222c226e61746976652d7365636f6e64225d5d2c5b226e61746976655f69645f70726573656e74222c5b22626f6f6c222c747275655d5d2c5b22706172745f696e646578222c5b226e756d626572222c2234303038303030303030303030303030225d5d2c5b22726f6f745f77726170706572222c5b226e756c6c225d5d5d5d2c5b226f626a656374222c5b5b2263616c6c65725f6e616d65222c5b22737472696e67222c2252656164225d5d2c5b22636f6465635f7061746873222c5b226172726179222c5b5d5d5d2c5b22636f6e74656e745f696e646578222c5b226e756d626572222c2230303030303030303030303030303030225d5d2c5b226c6f63616c5f6964222c5b22737472696e67222c22746f6f6c755f676f6c64656e5f7265757365645f31225d5d2c5b226e61746976655f6964222c5b22737472696e67222c226e61746976652d7365636f6e64225d5d2c5b226e61746976655f69645f70726573656e74222c5b22626f6f6c222c747275655d5d2c5b22706172745f696e646578222c5b226e756d626572222c2234303130303030303030303030303030225d5d2c5b22726f6f745f77726170706572222c5b226e756c6c225d5d5d5d5d5d5d5d5d5d2c5b22626c6f636b73222c5b226172726179222c5b5b226f626a656374222c5b5b227468696e6b696e67222c5b22737472696e67222c2273796e7468657469632074686f7567687420ceb1202f2073796e7468657469632074686f7567687420ceb2225d5d2c5b2274797065222c5b22737472696e67222c227468696e6b696e67225d5d5d5d2c5b226f626a656374222c5b5b2274657874222c5b22737472696e67222c2276697369626c6520e99baa225d5d2c5b2274797065222c5b22737472696e67222c2274657874225d5d5d5d2c5b226f626a656374222c5b5b226964222c5b22737472696e67222c22746f6f6c755f676f6c64656e5f7265757365645f30225d5d2c5b22696e707574222c5b226f626a656374222c5b5b2266696c655f70617468222c5b22737472696e67222c222f666978747572652f726561642e747874225d5d2c5b226f6666736574222c5b226e756d626572222c2233666630303030303030303030303030225d5d5d5d5d2c5b226e616d65222c5b22737472696e67222c2252656164225d5d2c5b2274797065222c5b22737472696e67222c22746f6f6c5f757365225d5d5d5d2c5b226f626a656374222c5b5b226964222c5b22737472696e67222c22746f6f6c755f676f6c64656e5f7265757365645f31225d5d2c5b22696e707574222c5b226f626a656374222c5b5b2266696c655f70617468222c5b22737472696e67222c222f666978747572652f726561642e747874225d5d2c5b226c696d6974222c5b226e756d626572222c2233666630303030303030303030303030225d5d5d5d5d2c5b226e616d65222c5b22737472696e67222c2252656164225d5d2c5b2274797065222c5b22737472696e67222c22746f6f6c5f757365225d5d5d5d5d5d5d5d5d", - "sha256": "e9b30c1e36661ce605fceafa02e7b71de724b934eabec8e6b8cb35edc33a539b" + "canonical_digest_input_utf8": "[\"object\",[[\"binding\",[\"object\",[[\"backend_session_id\",[\"string\",\"-3750763034362895579\"]],[\"checkpoint_stop_reason\",[\"string\",\"tool_use\"]],[\"lineage\",[\"object\",[[\"conversation_id\",[\"string\",\"11111111-1111-4111-8111-111111111111\"]],[\"trajectory_id\",[\"string\",\"22222222-2222-4222-8222-222222222222\"]]]]],[\"native_checkpoint_index\",[\"number\",\"0000000000000000\"]],[\"native_model\",[\"string\",\"gemini-4-argon-low\"]],[\"profile_id\",[\"string\",\"synthetic-profile\"]],[\"project_id\",[\"string\",\"synthetic-project\"]],[\"thought_display\",[\"string\",\"full\"]],[\"tool_mapping\",[\"array\",[[\"object\",[[\"caller_name\",[\"string\",\"Read\"]],[\"codec_paths\",[\"array\",[]]],[\"content_index\",[\"number\",\"0000000000000000\"]],[\"local_id\",[\"string\",\"toolu_golden_reused_0\"]],[\"native_id\",[\"string\",\"native-second\"]],[\"native_id_present\",[\"bool\",true]],[\"part_index\",[\"number\",\"4008000000000000\"]],[\"root_wrapper\",[\"null\"]]]],[\"object\",[[\"caller_name\",[\"string\",\"Read\"]],[\"codec_paths\",[\"array\",[]]],[\"content_index\",[\"number\",\"0000000000000000\"]],[\"local_id\",[\"string\",\"toolu_golden_reused_1\"]],[\"native_id\",[\"string\",\"native-second\"]],[\"native_id_present\",[\"bool\",true]],[\"part_index\",[\"number\",\"4010000000000000\"]],[\"root_wrapper\",[\"null\"]]]]]]],[\"user_execution\",[\"object\",[[\"active_id\",[\"string\",\"aaaaaaaa-aaaa-4aaa-8aaa-000000000000\"]],[\"prior_recorded_id\",[\"null\"]]]]],[\"version\",[\"number\",\"3ff0000000000000\"]]]]],[\"blocks\",[\"array\",[[\"object\",[[\"thinking\",[\"string\",\"synthetic thought α / synthetic thought β\"]],[\"type\",[\"string\",\"thinking\"]]]],[\"object\",[[\"text\",[\"string\",\"visible 雪\"]],[\"type\",[\"string\",\"text\"]]]],[\"object\",[[\"id\",[\"string\",\"toolu_golden_reused_0\"]],[\"input\",[\"object\",[[\"file_path\",[\"string\",\"/fixture/read.txt\"]],[\"offset\",[\"number\",\"3ff0000000000000\"]]]]],[\"name\",[\"string\",\"Read\"]],[\"type\",[\"string\",\"tool_use\"]]]],[\"object\",[[\"id\",[\"string\",\"toolu_golden_reused_1\"]],[\"input\",[\"object\",[[\"file_path\",[\"string\",\"/fixture/read.txt\"]],[\"limit\",[\"number\",\"3ff0000000000000\"]]]]],[\"name\",[\"string\",\"Read\"]],[\"type\",[\"string\",\"tool_use\"]]]]]]],[\"domain\",[\"string\",\"ccp:antigravity:v1:projection\"]]]]", + "canonical_digest_input_hex": "5b226f626a656374222c5b5b2262696e64696e67222c5b226f626a656374222c5b5b226261636b656e645f73657373696f6e5f6964222c5b22737472696e67222c222d33373530373633303334333632383935353739225d5d2c5b22636865636b706f696e745f73746f705f726561736f6e222c5b22737472696e67222c22746f6f6c5f757365225d5d2c5b226c696e65616765222c5b226f626a656374222c5b5b22636f6e766572736174696f6e5f6964222c5b22737472696e67222c2231313131313131312d313131312d343131312d383131312d313131313131313131313131225d5d2c5b227472616a6563746f72795f6964222c5b22737472696e67222c2232323232323232322d323232322d343232322d383232322d323232323232323232323232225d5d5d5d5d2c5b226e61746976655f636865636b706f696e745f696e646578222c5b226e756d626572222c2230303030303030303030303030303030225d5d2c5b226e61746976655f6d6f64656c222c5b22737472696e67222c2267656d696e692d342d6172676f6e2d6c6f77225d5d2c5b2270726f66696c655f6964222c5b22737472696e67222c2273796e7468657469632d70726f66696c65225d5d2c5b2270726f6a6563745f6964222c5b22737472696e67222c2273796e7468657469632d70726f6a656374225d5d2c5b2274686f756768745f646973706c6179222c5b22737472696e67222c2266756c6c225d5d2c5b22746f6f6c5f6d617070696e67222c5b226172726179222c5b5b226f626a656374222c5b5b2263616c6c65725f6e616d65222c5b22737472696e67222c2252656164225d5d2c5b22636f6465635f7061746873222c5b226172726179222c5b5d5d5d2c5b22636f6e74656e745f696e646578222c5b226e756d626572222c2230303030303030303030303030303030225d5d2c5b226c6f63616c5f6964222c5b22737472696e67222c22746f6f6c755f676f6c64656e5f7265757365645f30225d5d2c5b226e61746976655f6964222c5b22737472696e67222c226e61746976652d7365636f6e64225d5d2c5b226e61746976655f69645f70726573656e74222c5b22626f6f6c222c747275655d5d2c5b22706172745f696e646578222c5b226e756d626572222c2234303038303030303030303030303030225d5d2c5b22726f6f745f77726170706572222c5b226e756c6c225d5d5d5d2c5b226f626a656374222c5b5b2263616c6c65725f6e616d65222c5b22737472696e67222c2252656164225d5d2c5b22636f6465635f7061746873222c5b226172726179222c5b5d5d5d2c5b22636f6e74656e745f696e646578222c5b226e756d626572222c2230303030303030303030303030303030225d5d2c5b226c6f63616c5f6964222c5b22737472696e67222c22746f6f6c755f676f6c64656e5f7265757365645f31225d5d2c5b226e61746976655f6964222c5b22737472696e67222c226e61746976652d7365636f6e64225d5d2c5b226e61746976655f69645f70726573656e74222c5b22626f6f6c222c747275655d5d2c5b22706172745f696e646578222c5b226e756d626572222c2234303130303030303030303030303030225d5d2c5b22726f6f745f77726170706572222c5b226e756c6c225d5d5d5d5d5d5d2c5b22757365725f657865637574696f6e222c5b226f626a656374222c5b5b226163746976655f6964222c5b22737472696e67222c2261616161616161612d616161612d346161612d386161612d303030303030303030303030225d5d2c5b227072696f725f7265636f726465645f6964222c5b226e756c6c225d5d5d5d5d2c5b2276657273696f6e222c5b226e756d626572222c2233666630303030303030303030303030225d5d5d5d5d2c5b22626c6f636b73222c5b226172726179222c5b5b226f626a656374222c5b5b227468696e6b696e67222c5b22737472696e67222c2273796e7468657469632074686f7567687420ceb1202f2073796e7468657469632074686f7567687420ceb2225d5d2c5b2274797065222c5b22737472696e67222c227468696e6b696e67225d5d5d5d2c5b226f626a656374222c5b5b2274657874222c5b22737472696e67222c2276697369626c6520e99baa225d5d2c5b2274797065222c5b22737472696e67222c2274657874225d5d5d5d2c5b226f626a656374222c5b5b226964222c5b22737472696e67222c22746f6f6c755f676f6c64656e5f7265757365645f30225d5d2c5b22696e707574222c5b226f626a656374222c5b5b2266696c655f70617468222c5b22737472696e67222c222f666978747572652f726561642e747874225d5d2c5b226f6666736574222c5b226e756d626572222c2233666630303030303030303030303030225d5d5d5d5d2c5b226e616d65222c5b22737472696e67222c2252656164225d5d2c5b2274797065222c5b22737472696e67222c22746f6f6c5f757365225d5d5d5d2c5b226f626a656374222c5b5b226964222c5b22737472696e67222c22746f6f6c755f676f6c64656e5f7265757365645f31225d5d2c5b22696e707574222c5b226f626a656374222c5b5b2266696c655f70617468222c5b22737472696e67222c222f666978747572652f726561642e747874225d5d2c5b226c696d6974222c5b226e756d626572222c2233666630303030303030303030303030225d5d5d5d5d2c5b226e616d65222c5b22737472696e67222c2252656164225d5d2c5b2274797065222c5b22737472696e67222c22746f6f6c5f757365225d5d5d5d5d5d5d2c5b22646f6d61696e222c5b22737472696e67222c226363703a616e7469677261766974793a76313a70726f6a656374696f6e225d5d5d5d", + "sha256": "e303af56013a6ac7828dd417f70580491dd0f72b129dc24ebc12f0a25ca5e3d5" } ] } diff --git a/tests/fixtures/antigravity-negative-v1.json b/tests/fixtures/antigravity-negative-v1.json index 113c60c0..cc8e63b5 100644 --- a/tests/fixtures/antigravity-negative-v1.json +++ b/tests/fixtures/antigravity-negative-v1.json @@ -8,7 +8,7 @@ { "type": "thinking", "thinking": "", - "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJzZXNzaW9uX2lkIjoiOTIyMzM3MjAzNjg1NDc3NTgwNyIsInRob3VnaHRfZGlzcGxheSI6Im9taXR0ZWQiLCJjb250ZW50cyI6W3sicm9sZSI6Im1vZGVsIiwicGFydHMiOlt7InRob3VnaHQiOnRydWUsInRleHQiOiJHZW51aW5lIHN5bnRoZXRpYyBuYXRpdmUgdGhvdWdodCDpm6oiLCJ0aG91Z2h0U2lnbmF0dXJlIjoicmF3LXRob3VnaHQifSx7ImZ1bmN0aW9uQ2FsbCI6eyJuYW1lIjoibmF0aXZlX3JlYWRfYWxpYXMiLCJpZCI6InJldXNlZC1uYXRpdmUtaWQiLCJhcmdzIjp7ImZpbGVfcGF0aCI6IlwiL2ZpeHR1cmUvcmVhZC50eHRcIiIsIm9mZnNldCI6MS4wfX0sInRob3VnaHRTaWduYXR1cmUiOiJyYXctY2FsbCJ9XX1dLCJ0b29sX21hcHBpbmciOlt7ImxvY2FsX2lkIjoidG9vbHVfZ29sZGVuX2xlYWZfb21pdHRlZF9pNjRfbWF4IiwiY2FsbGVyX25hbWUiOiJSZWFkIiwiY29udGVudF9pbmRleCI6MCwicGFydF9pbmRleCI6MSwibmF0aXZlX2lkX3ByZXNlbnQiOnRydWUsImNvZGVjX3BhdGhzIjpbIi9maWxlX3BhdGgiXSwicm9vdF93cmFwcGVyIjpudWxsLCJuYXRpdmVfaWQiOiJyZXVzZWQtbmF0aXZlLWlkIn1dLCJwcm9qZWN0aW9uX2RpZ2VzdCI6ImI0ZDA3MTdhNzhmMzRhMjE5MWVkMWNiOWRiZmUwNjNkMGFkOWU0ZTQxMWRiNTUwYWUwYjdiNzkyNDAzMTZiNzYifQ" + "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJiYWNrZW5kX3Nlc3Npb25faWQiOiItMzc1MDc2MzAzNDM2Mjg5NTU3OSIsImxpbmVhZ2UiOnsiY29udmVyc2F0aW9uX2lkIjoiMTExMTExMTEtMTExMS00MTExLTgxMTEtMTExMTExMTExMTExIiwidHJhamVjdG9yeV9pZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiJ9LCJuYXRpdmVfY2hlY2twb2ludF9pbmRleCI6MCwidXNlcl9leGVjdXRpb24iOnsiYWN0aXZlX2lkIjoiYWFhYWFhYWEtYWFhYS00YWFhLThhYWEtMDAwMDAwMDAwMDAwIiwicHJpb3JfcmVjb3JkZWRfaWQiOm51bGx9LCJjaGVja3BvaW50X3N0b3BfcmVhc29uIjoidG9vbF91c2UiLCJ0aG91Z2h0X2Rpc3BsYXkiOiJvbWl0dGVkIiwiY29udGVudHMiOlt7InJvbGUiOiJtb2RlbCIsInBhcnRzIjpbeyJ0aG91Z2h0Ijp0cnVlLCJ0ZXh0IjoiR2VudWluZSBzeW50aGV0aWMgbmF0aXZlIHRob3VnaHQg6ZuqIiwidGhvdWdodFNpZ25hdHVyZSI6InJhdy10aG91Z2h0In0seyJmdW5jdGlvbkNhbGwiOnsibmFtZSI6Im5hdGl2ZV9yZWFkX2FsaWFzIiwiaWQiOiJyZXVzZWQtbmF0aXZlLWlkIiwiYXJncyI6eyJmaWxlX3BhdGgiOiJcIi9maXh0dXJlL3JlYWQudHh0XCIiLCJvZmZzZXQiOjEuMH19LCJ0aG91Z2h0U2lnbmF0dXJlIjoicmF3LWNhbGwifV19XSwidG9vbF9tYXBwaW5nIjpbeyJsb2NhbF9pZCI6InRvb2x1X2dvbGRlbl9sZWFmX29taXR0ZWRfaTY0X21heCIsImNhbGxlcl9uYW1lIjoiUmVhZCIsImNvbnRlbnRfaW5kZXgiOjAsInBhcnRfaW5kZXgiOjEsIm5hdGl2ZV9pZF9wcmVzZW50Ijp0cnVlLCJjb2RlY19wYXRocyI6WyIvZmlsZV9wYXRoIl0sInJvb3Rfd3JhcHBlciI6bnVsbCwibmF0aXZlX2lkIjoicmV1c2VkLW5hdGl2ZS1pZCJ9XSwicHJvamVjdGlvbl9kaWdlc3QiOiJhNDExYThlYWU5NzAyYWQ5MjliZDk3Y2Y0ZWViYmJiNjMyNjhlMzdlNDBiMzI0NjI5NTI5YWYzN2IzMmJhNzYzIn0" }, { "type": "tool_use", @@ -36,7 +36,7 @@ { "type": "thinking", "thinking": "", - "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJzZXNzaW9uX2lkIjoiOTIyMzM3MjAzNjg1NDc3NTgwNyIsInRob3VnaHRfZGlzcGxheSI6Im9taXR0ZWQiLCJjb250ZW50cyI6W3sicm9sZSI6Im1vZGVsIiwicGFydHMiOlt7InRob3VnaHQiOnRydWUsInRleHQiOiJHZW51aW5lIHN5bnRoZXRpYyBuYXRpdmUgdGhvdWdodCDpm6oiLCJ0aG91Z2h0U2lnbmF0dXJlIjoicmF3LXRob3VnaHQifSx7ImZ1bmN0aW9uQ2FsbCI6eyJuYW1lIjoibmF0aXZlX3JlYWRfYWxpYXMiLCJpZCI6InJldXNlZC1uYXRpdmUtaWQiLCJhcmdzIjp7ImZpbGVfcGF0aCI6IlwiL2ZpeHR1cmUvcmVhZC50eHRcIiIsIm9mZnNldCI6MS4wfX0sInRob3VnaHRTaWduYXR1cmUiOiJyYXctY2FsbCJ9XX1dLCJ0b29sX21hcHBpbmciOlt7ImxvY2FsX2lkIjoidG9vbHVfZ29sZGVuX2xlYWZfb21pdHRlZF9pNjRfbWF4IiwiY2FsbGVyX25hbWUiOiJSZWFkIiwiY29udGVudF9pbmRleCI6MCwicGFydF9pbmRleCI6MSwibmF0aXZlX2lkX3ByZXNlbnQiOnRydWUsImNvZGVjX3BhdGhzIjpbIi9maWxlX3BhdGgiXSwicm9vdF93cmFwcGVyIjpudWxsLCJuYXRpdmVfaWQiOiJyZXVzZWQtbmF0aXZlLWlkIn1dLCJwcm9qZWN0aW9uX2RpZ2VzdCI6ImI0ZDA3MTdhNzhmMzRhMjE5MWVkMWNiOWRiZmUwNjNkMGFkOWU0ZTQxMWRiNTUwYWUwYjdiNzkyNDAzMTZiNzYifQ" + "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJiYWNrZW5kX3Nlc3Npb25faWQiOiItMzc1MDc2MzAzNDM2Mjg5NTU3OSIsImxpbmVhZ2UiOnsiY29udmVyc2F0aW9uX2lkIjoiMTExMTExMTEtMTExMS00MTExLTgxMTEtMTExMTExMTExMTExIiwidHJhamVjdG9yeV9pZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiJ9LCJuYXRpdmVfY2hlY2twb2ludF9pbmRleCI6MCwidXNlcl9leGVjdXRpb24iOnsiYWN0aXZlX2lkIjoiYWFhYWFhYWEtYWFhYS00YWFhLThhYWEtMDAwMDAwMDAwMDAwIiwicHJpb3JfcmVjb3JkZWRfaWQiOm51bGx9LCJjaGVja3BvaW50X3N0b3BfcmVhc29uIjoidG9vbF91c2UiLCJ0aG91Z2h0X2Rpc3BsYXkiOiJvbWl0dGVkIiwiY29udGVudHMiOlt7InJvbGUiOiJtb2RlbCIsInBhcnRzIjpbeyJ0aG91Z2h0Ijp0cnVlLCJ0ZXh0IjoiR2VudWluZSBzeW50aGV0aWMgbmF0aXZlIHRob3VnaHQg6ZuqIiwidGhvdWdodFNpZ25hdHVyZSI6InJhdy10aG91Z2h0In0seyJmdW5jdGlvbkNhbGwiOnsibmFtZSI6Im5hdGl2ZV9yZWFkX2FsaWFzIiwiaWQiOiJyZXVzZWQtbmF0aXZlLWlkIiwiYXJncyI6eyJmaWxlX3BhdGgiOiJcIi9maXh0dXJlL3JlYWQudHh0XCIiLCJvZmZzZXQiOjEuMH19LCJ0aG91Z2h0U2lnbmF0dXJlIjoicmF3LWNhbGwifV19XSwidG9vbF9tYXBwaW5nIjpbeyJsb2NhbF9pZCI6InRvb2x1X2dvbGRlbl9sZWFmX29taXR0ZWRfaTY0X21heCIsImNhbGxlcl9uYW1lIjoiUmVhZCIsImNvbnRlbnRfaW5kZXgiOjAsInBhcnRfaW5kZXgiOjEsIm5hdGl2ZV9pZF9wcmVzZW50Ijp0cnVlLCJjb2RlY19wYXRocyI6WyIvZmlsZV9wYXRoIl0sInJvb3Rfd3JhcHBlciI6bnVsbCwibmF0aXZlX2lkIjoicmV1c2VkLW5hdGl2ZS1pZCJ9XSwicHJvamVjdGlvbl9kaWdlc3QiOiJhNDExYThlYWU5NzAyYWQ5MjliZDk3Y2Y0ZWViYmJiNjMyNjhlMzdlNDBiMzI0NjI5NTI5YWYzN2IzMmJhNzYzIn0" }, { "type": "tool_use", @@ -64,7 +64,7 @@ { "type": "thinking", "thinking": "", - "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJzZXNzaW9uX2lkIjoiOTIyMzM3MjAzNjg1NDc3NTgwNyIsInRob3VnaHRfZGlzcGxheSI6Im9taXR0ZWQiLCJjb250ZW50cyI6W3sicm9sZSI6Im1vZGVsIiwicGFydHMiOlt7InRob3VnaHQiOnRydWUsInRleHQiOiJHZW51aW5lIHN5bnRoZXRpYyBuYXRpdmUgdGhvdWdodCDpm6oiLCJ0aG91Z2h0U2lnbmF0dXJlIjoicmF3LXRob3VnaHQifSx7ImZ1bmN0aW9uQ2FsbCI6eyJuYW1lIjoibmF0aXZlX3JlYWRfYWxpYXMiLCJpZCI6InJldXNlZC1uYXRpdmUtaWQiLCJhcmdzIjp7ImZpbGVfcGF0aCI6IlwiL2ZpeHR1cmUvcmVhZC50eHRcIiIsIm9mZnNldCI6MS4wfX0sInRob3VnaHRTaWduYXR1cmUiOiJyYXctY2FsbCJ9XX1dLCJ0b29sX21hcHBpbmciOlt7ImxvY2FsX2lkIjoidG9vbHVfZ29sZGVuX2xlYWZfb21pdHRlZF9pNjRfbWF4IiwiY2FsbGVyX25hbWUiOiJSZWFkIiwiY29udGVudF9pbmRleCI6MCwicGFydF9pbmRleCI6MSwibmF0aXZlX2lkX3ByZXNlbnQiOnRydWUsImNvZGVjX3BhdGhzIjpbIi9maWxlX3BhdGgiXSwicm9vdF93cmFwcGVyIjpudWxsLCJuYXRpdmVfaWQiOiJyZXVzZWQtbmF0aXZlLWlkIn1dLCJwcm9qZWN0aW9uX2RpZ2VzdCI6ImI0ZDA3MTdhNzhmMzRhMjE5MWVkMWNiOWRiZmUwNjNkMGFkOWU0ZTQxMWRiNTUwYWUwYjdiNzkyNDAzMTZiNzYifQ" + "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJiYWNrZW5kX3Nlc3Npb25faWQiOiItMzc1MDc2MzAzNDM2Mjg5NTU3OSIsImxpbmVhZ2UiOnsiY29udmVyc2F0aW9uX2lkIjoiMTExMTExMTEtMTExMS00MTExLTgxMTEtMTExMTExMTExMTExIiwidHJhamVjdG9yeV9pZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiJ9LCJuYXRpdmVfY2hlY2twb2ludF9pbmRleCI6MCwidXNlcl9leGVjdXRpb24iOnsiYWN0aXZlX2lkIjoiYWFhYWFhYWEtYWFhYS00YWFhLThhYWEtMDAwMDAwMDAwMDAwIiwicHJpb3JfcmVjb3JkZWRfaWQiOm51bGx9LCJjaGVja3BvaW50X3N0b3BfcmVhc29uIjoidG9vbF91c2UiLCJ0aG91Z2h0X2Rpc3BsYXkiOiJvbWl0dGVkIiwiY29udGVudHMiOlt7InJvbGUiOiJtb2RlbCIsInBhcnRzIjpbeyJ0aG91Z2h0Ijp0cnVlLCJ0ZXh0IjoiR2VudWluZSBzeW50aGV0aWMgbmF0aXZlIHRob3VnaHQg6ZuqIiwidGhvdWdodFNpZ25hdHVyZSI6InJhdy10aG91Z2h0In0seyJmdW5jdGlvbkNhbGwiOnsibmFtZSI6Im5hdGl2ZV9yZWFkX2FsaWFzIiwiaWQiOiJyZXVzZWQtbmF0aXZlLWlkIiwiYXJncyI6eyJmaWxlX3BhdGgiOiJcIi9maXh0dXJlL3JlYWQudHh0XCIiLCJvZmZzZXQiOjEuMH19LCJ0aG91Z2h0U2lnbmF0dXJlIjoicmF3LWNhbGwifV19XSwidG9vbF9tYXBwaW5nIjpbeyJsb2NhbF9pZCI6InRvb2x1X2dvbGRlbl9sZWFmX29taXR0ZWRfaTY0X21heCIsImNhbGxlcl9uYW1lIjoiUmVhZCIsImNvbnRlbnRfaW5kZXgiOjAsInBhcnRfaW5kZXgiOjEsIm5hdGl2ZV9pZF9wcmVzZW50Ijp0cnVlLCJjb2RlY19wYXRocyI6WyIvZmlsZV9wYXRoIl0sInJvb3Rfd3JhcHBlciI6bnVsbCwibmF0aXZlX2lkIjoicmV1c2VkLW5hdGl2ZS1pZCJ9XSwicHJvamVjdGlvbl9kaWdlc3QiOiJhNDExYThlYWU5NzAyYWQ5MjliZDk3Y2Y0ZWViYmJiNjMyNjhlMzdlNDBiMzI0NjI5NTI5YWYzN2IzMmJhNzYzIn0" }, { "type": "tool_use", @@ -92,7 +92,7 @@ { "type": "thinking", "thinking": "", - "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjogMSwgInByb2ZpbGVfaWQiOiAic3ludGhldGljLXByb2ZpbGUiLCAicHJvamVjdF9pZCI6ICJzeW50aGV0aWMtcHJvamVjdCIsICJuYXRpdmVfbW9kZWwiOiAiZ2VtaW5pLTQtYXJnb24tbG93IiwgInNlc3Npb25faWQiOiAiOCIsICJ0aG91Z2h0X2Rpc3BsYXkiOiAib21pdHRlZCIsICJjb250ZW50cyI6IFt7InJvbGUiOiAibW9kZWwiLCAicGFydHMiOiBbeyJ0aG91Z2h0IjogdHJ1ZSwgInRleHQiOiAiR2VudWluZSBzeW50aGV0aWMgbmF0aXZlIHRob3VnaHQgXHU5NmVhIiwgInRob3VnaHRTaWduYXR1cmUiOiAicmF3LXRob3VnaHQifSwgeyJmdW5jdGlvbkNhbGwiOiB7Im5hbWUiOiAibmF0aXZlX3JlYWRfYWxpYXMiLCAiaWQiOiAicmV1c2VkLW5hdGl2ZS1pZCIsICJhcmdzIjogeyJmaWxlX3BhdGgiOiAiXCIvZml4dHVyZS9yZWFkLnR4dFwiIiwgIm9mZnNldCI6IDEuMH19LCAidGhvdWdodFNpZ25hdHVyZSI6ICJyYXctY2FsbCJ9XX1dLCAidG9vbF9tYXBwaW5nIjogW3sibG9jYWxfaWQiOiAidG9vbHVfZ29sZGVuX2xlYWZfb21pdHRlZF9pNjRfbWF4IiwgImNhbGxlcl9uYW1lIjogIlJlYWQiLCAiY29udGVudF9pbmRleCI6IDAsICJwYXJ0X2luZGV4IjogMSwgIm5hdGl2ZV9pZF9wcmVzZW50IjogdHJ1ZSwgImNvZGVjX3BhdGhzIjogWyIvZmlsZV9wYXRoIl0sICJyb290X3dyYXBwZXIiOiBudWxsLCAibmF0aXZlX2lkIjogInJldXNlZC1uYXRpdmUtaWQifV0sICJwcm9qZWN0aW9uX2RpZ2VzdCI6ICJiNGQwNzE3YTc4ZjM0YTIxOTFlZDFjYjlkYmZlMDYzZDBhZDllNGU0MTFkYjU1MGFlMGI3Yjc5MjQwMzE2Yjc2In0" + "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJiYWNrZW5kX3Nlc3Npb25faWQiOiI4IiwibGluZWFnZSI6eyJjb252ZXJzYXRpb25faWQiOiIxMTExMTExMS0xMTExLTQxMTEtODExMS0xMTExMTExMTExMTEiLCJ0cmFqZWN0b3J5X2lkIjoiMjIyMjIyMjItMjIyMi00MjIyLTgyMjItMjIyMjIyMjIyMjIyIn0sIm5hdGl2ZV9jaGVja3BvaW50X2luZGV4IjowLCJ1c2VyX2V4ZWN1dGlvbiI6eyJhY3RpdmVfaWQiOiJhYWFhYWFhYS1hYWFhLTRhYWEtOGFhYS0wMDAwMDAwMDAwMDAiLCJwcmlvcl9yZWNvcmRlZF9pZCI6bnVsbH0sImNoZWNrcG9pbnRfc3RvcF9yZWFzb24iOiJ0b29sX3VzZSIsInRob3VnaHRfZGlzcGxheSI6Im9taXR0ZWQiLCJjb250ZW50cyI6W3sicm9sZSI6Im1vZGVsIiwicGFydHMiOlt7InRob3VnaHQiOnRydWUsInRleHQiOiJHZW51aW5lIHN5bnRoZXRpYyBuYXRpdmUgdGhvdWdodCDpm6oiLCJ0aG91Z2h0U2lnbmF0dXJlIjoicmF3LXRob3VnaHQifSx7ImZ1bmN0aW9uQ2FsbCI6eyJuYW1lIjoibmF0aXZlX3JlYWRfYWxpYXMiLCJpZCI6InJldXNlZC1uYXRpdmUtaWQiLCJhcmdzIjp7ImZpbGVfcGF0aCI6IlwiL2ZpeHR1cmUvcmVhZC50eHRcIiIsIm9mZnNldCI6MS4wfX0sInRob3VnaHRTaWduYXR1cmUiOiJyYXctY2FsbCJ9XX1dLCJ0b29sX21hcHBpbmciOlt7ImxvY2FsX2lkIjoidG9vbHVfZ29sZGVuX2xlYWZfb21pdHRlZF9pNjRfbWF4IiwiY2FsbGVyX25hbWUiOiJSZWFkIiwiY29udGVudF9pbmRleCI6MCwicGFydF9pbmRleCI6MSwibmF0aXZlX2lkX3ByZXNlbnQiOnRydWUsImNvZGVjX3BhdGhzIjpbIi9maWxlX3BhdGgiXSwicm9vdF93cmFwcGVyIjpudWxsLCJuYXRpdmVfaWQiOiJyZXVzZWQtbmF0aXZlLWlkIn1dLCJwcm9qZWN0aW9uX2RpZ2VzdCI6ImE0MTFhOGVhZTk3MDJhZDkyOWJkOTdjZjRlZWJiYmI2MzI2OGUzN2U0MGIzMjQ2Mjk1MjlhZjM3YjMyYmE3NjMifQ" }, { "type": "tool_use", @@ -120,7 +120,7 @@ { "type": "thinking", "thinking": "", - "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjogMSwgInByb2ZpbGVfaWQiOiAic3ludGhldGljLXByb2ZpbGUiLCAicHJvamVjdF9pZCI6ICJzeW50aGV0aWMtcHJvamVjdCIsICJuYXRpdmVfbW9kZWwiOiAiZ2VtaW5pLTQtYXJnb24tbG93IiwgInNlc3Npb25faWQiOiAiOTIyMzM3MjAzNjg1NDc3NTgwNyIsICJ0aG91Z2h0X2Rpc3BsYXkiOiAiZnVsbCIsICJjb250ZW50cyI6IFt7InJvbGUiOiAibW9kZWwiLCAicGFydHMiOiBbeyJ0aG91Z2h0IjogdHJ1ZSwgInRleHQiOiAiR2VudWluZSBzeW50aGV0aWMgbmF0aXZlIHRob3VnaHQgXHU5NmVhIiwgInRob3VnaHRTaWduYXR1cmUiOiAicmF3LXRob3VnaHQifSwgeyJmdW5jdGlvbkNhbGwiOiB7Im5hbWUiOiAibmF0aXZlX3JlYWRfYWxpYXMiLCAiaWQiOiAicmV1c2VkLW5hdGl2ZS1pZCIsICJhcmdzIjogeyJmaWxlX3BhdGgiOiAiXCIvZml4dHVyZS9yZWFkLnR4dFwiIiwgIm9mZnNldCI6IDEuMH19LCAidGhvdWdodFNpZ25hdHVyZSI6ICJyYXctY2FsbCJ9XX1dLCAidG9vbF9tYXBwaW5nIjogW3sibG9jYWxfaWQiOiAidG9vbHVfZ29sZGVuX2xlYWZfb21pdHRlZF9pNjRfbWF4IiwgImNhbGxlcl9uYW1lIjogIlJlYWQiLCAiY29udGVudF9pbmRleCI6IDAsICJwYXJ0X2luZGV4IjogMSwgIm5hdGl2ZV9pZF9wcmVzZW50IjogdHJ1ZSwgImNvZGVjX3BhdGhzIjogWyIvZmlsZV9wYXRoIl0sICJyb290X3dyYXBwZXIiOiBudWxsLCAibmF0aXZlX2lkIjogInJldXNlZC1uYXRpdmUtaWQifV0sICJwcm9qZWN0aW9uX2RpZ2VzdCI6ICJiNGQwNzE3YTc4ZjM0YTIxOTFlZDFjYjlkYmZlMDYzZDBhZDllNGU0MTFkYjU1MGFlMGI3Yjc5MjQwMzE2Yjc2In0" + "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJiYWNrZW5kX3Nlc3Npb25faWQiOiItMzc1MDc2MzAzNDM2Mjg5NTU3OSIsImxpbmVhZ2UiOnsiY29udmVyc2F0aW9uX2lkIjoiMTExMTExMTEtMTExMS00MTExLTgxMTEtMTExMTExMTExMTExIiwidHJhamVjdG9yeV9pZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiJ9LCJuYXRpdmVfY2hlY2twb2ludF9pbmRleCI6MCwidXNlcl9leGVjdXRpb24iOnsiYWN0aXZlX2lkIjoiYWFhYWFhYWEtYWFhYS00YWFhLThhYWEtMDAwMDAwMDAwMDAwIiwicHJpb3JfcmVjb3JkZWRfaWQiOm51bGx9LCJjaGVja3BvaW50X3N0b3BfcmVhc29uIjoidG9vbF91c2UiLCJ0aG91Z2h0X2Rpc3BsYXkiOiJmdWxsIiwiY29udGVudHMiOlt7InJvbGUiOiJtb2RlbCIsInBhcnRzIjpbeyJ0aG91Z2h0Ijp0cnVlLCJ0ZXh0IjoiR2VudWluZSBzeW50aGV0aWMgbmF0aXZlIHRob3VnaHQg6ZuqIiwidGhvdWdodFNpZ25hdHVyZSI6InJhdy10aG91Z2h0In0seyJmdW5jdGlvbkNhbGwiOnsibmFtZSI6Im5hdGl2ZV9yZWFkX2FsaWFzIiwiaWQiOiJyZXVzZWQtbmF0aXZlLWlkIiwiYXJncyI6eyJmaWxlX3BhdGgiOiJcIi9maXh0dXJlL3JlYWQudHh0XCIiLCJvZmZzZXQiOjEuMH19LCJ0aG91Z2h0U2lnbmF0dXJlIjoicmF3LWNhbGwifV19XSwidG9vbF9tYXBwaW5nIjpbeyJsb2NhbF9pZCI6InRvb2x1X2dvbGRlbl9sZWFmX29taXR0ZWRfaTY0X21heCIsImNhbGxlcl9uYW1lIjoiUmVhZCIsImNvbnRlbnRfaW5kZXgiOjAsInBhcnRfaW5kZXgiOjEsIm5hdGl2ZV9pZF9wcmVzZW50Ijp0cnVlLCJjb2RlY19wYXRocyI6WyIvZmlsZV9wYXRoIl0sInJvb3Rfd3JhcHBlciI6bnVsbCwibmF0aXZlX2lkIjoicmV1c2VkLW5hdGl2ZS1pZCJ9XSwicHJvamVjdGlvbl9kaWdlc3QiOiJhNDExYThlYWU5NzAyYWQ5MjliZDk3Y2Y0ZWViYmJiNjMyNjhlMzdlNDBiMzI0NjI5NTI5YWYzN2IzMmJhNzYzIn0" }, { "type": "tool_use", @@ -148,7 +148,7 @@ { "type": "thinking", "thinking": "", - "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjogMSwgInByb2ZpbGVfaWQiOiAic3ludGhldGljLXByb2ZpbGUiLCAicHJvamVjdF9pZCI6ICJzeW50aGV0aWMtcHJvamVjdCIsICJuYXRpdmVfbW9kZWwiOiAiZ2VtaW5pLTQtYXJnb24tbG93IiwgInNlc3Npb25faWQiOiAiOTIyMzM3MjAzNjg1NDc3NTgwNyIsICJ0aG91Z2h0X2Rpc3BsYXkiOiAib21pdHRlZCIsICJjb250ZW50cyI6IFt7InJvbGUiOiAibW9kZWwiLCAicGFydHMiOiBbeyJ0aG91Z2h0IjogdHJ1ZSwgInRleHQiOiAiR2VudWluZSBzeW50aGV0aWMgbmF0aXZlIHRob3VnaHQgXHU5NmVhIiwgInRob3VnaHRTaWduYXR1cmUiOiAicmF3LXRob3VnaHQifSwgeyJmdW5jdGlvbkNhbGwiOiB7Im5hbWUiOiAibmF0aXZlX3JlYWRfYWxpYXMiLCAiaWQiOiAicmV1c2VkLW5hdGl2ZS1pZCIsICJhcmdzIjogeyJmaWxlX3BhdGgiOiAiXCIvZml4dHVyZS9yZWFkLnR4dFwiIiwgIm9mZnNldCI6IDEuMH19LCAidGhvdWdodFNpZ25hdHVyZSI6ICJyYXctY2FsbCJ9XX1dLCAidG9vbF9tYXBwaW5nIjogW3sibG9jYWxfaWQiOiAidG9vbHVfZ29sZGVuX2xlYWZfb21pdHRlZF9pNjRfbWF4IiwgImNhbGxlcl9uYW1lIjogIk90aGVyIiwgImNvbnRlbnRfaW5kZXgiOiAwLCAicGFydF9pbmRleCI6IDEsICJuYXRpdmVfaWRfcHJlc2VudCI6IHRydWUsICJjb2RlY19wYXRocyI6IFsiL2ZpbGVfcGF0aCJdLCAicm9vdF93cmFwcGVyIjogbnVsbCwgIm5hdGl2ZV9pZCI6ICJyZXVzZWQtbmF0aXZlLWlkIn1dLCAicHJvamVjdGlvbl9kaWdlc3QiOiAiYjRkMDcxN2E3OGYzNGEyMTkxZWQxY2I5ZGJmZTA2M2QwYWQ5ZTRlNDExZGI1NTBhZTBiN2I3OTI0MDMxNmI3NiJ9" + "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJiYWNrZW5kX3Nlc3Npb25faWQiOiItMzc1MDc2MzAzNDM2Mjg5NTU3OSIsImxpbmVhZ2UiOnsiY29udmVyc2F0aW9uX2lkIjoiMTExMTExMTEtMTExMS00MTExLTgxMTEtMTExMTExMTExMTExIiwidHJhamVjdG9yeV9pZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiJ9LCJuYXRpdmVfY2hlY2twb2ludF9pbmRleCI6MCwidXNlcl9leGVjdXRpb24iOnsiYWN0aXZlX2lkIjoiYWFhYWFhYWEtYWFhYS00YWFhLThhYWEtMDAwMDAwMDAwMDAwIiwicHJpb3JfcmVjb3JkZWRfaWQiOm51bGx9LCJjaGVja3BvaW50X3N0b3BfcmVhc29uIjoidG9vbF91c2UiLCJ0aG91Z2h0X2Rpc3BsYXkiOiJvbWl0dGVkIiwiY29udGVudHMiOlt7InJvbGUiOiJtb2RlbCIsInBhcnRzIjpbeyJ0aG91Z2h0Ijp0cnVlLCJ0ZXh0IjoiR2VudWluZSBzeW50aGV0aWMgbmF0aXZlIHRob3VnaHQg6ZuqIiwidGhvdWdodFNpZ25hdHVyZSI6InJhdy10aG91Z2h0In0seyJmdW5jdGlvbkNhbGwiOnsibmFtZSI6Im5hdGl2ZV9yZWFkX2FsaWFzIiwiaWQiOiJyZXVzZWQtbmF0aXZlLWlkIiwiYXJncyI6eyJmaWxlX3BhdGgiOiJcIi9maXh0dXJlL3JlYWQudHh0XCIiLCJvZmZzZXQiOjEuMH19LCJ0aG91Z2h0U2lnbmF0dXJlIjoicmF3LWNhbGwifV19XSwidG9vbF9tYXBwaW5nIjpbeyJsb2NhbF9pZCI6InRvb2x1X2dvbGRlbl9sZWFmX29taXR0ZWRfaTY0X21heCIsImNhbGxlcl9uYW1lIjoiT3RoZXIiLCJjb250ZW50X2luZGV4IjowLCJwYXJ0X2luZGV4IjoxLCJuYXRpdmVfaWRfcHJlc2VudCI6dHJ1ZSwiY29kZWNfcGF0aHMiOlsiL2ZpbGVfcGF0aCJdLCJyb290X3dyYXBwZXIiOm51bGwsIm5hdGl2ZV9pZCI6InJldXNlZC1uYXRpdmUtaWQifV0sInByb2plY3Rpb25fZGlnZXN0IjoiYTQxMWE4ZWFlOTcwMmFkOTI5YmQ5N2NmNGVlYmJiYjYzMjY4ZTM3ZTQwYjMyNDYyOTUyOWFmMzdiMzJiYTc2MyJ9" }, { "type": "tool_use", @@ -176,7 +176,7 @@ { "type": "thinking", "thinking": "", - "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjogMSwgInByb2ZpbGVfaWQiOiAic3ludGhldGljLXByb2ZpbGUiLCAicHJvamVjdF9pZCI6ICJzeW50aGV0aWMtcHJvamVjdCIsICJuYXRpdmVfbW9kZWwiOiAiZ2VtaW5pLTQtYXJnb24tbG93IiwgInNlc3Npb25faWQiOiAiOTIyMzM3MjAzNjg1NDc3NTgwNyIsICJ0aG91Z2h0X2Rpc3BsYXkiOiAib21pdHRlZCIsICJjb250ZW50cyI6IFt7InJvbGUiOiAibW9kZWwiLCAicGFydHMiOiBbeyJ0aG91Z2h0IjogdHJ1ZSwgInRleHQiOiAiR2VudWluZSBzeW50aGV0aWMgbmF0aXZlIHRob3VnaHQgXHU5NmVhIiwgInRob3VnaHRTaWduYXR1cmUiOiAicmF3LXRob3VnaHQifSwgeyJmdW5jdGlvbkNhbGwiOiB7Im5hbWUiOiAibmF0aXZlX3JlYWRfYWxpYXMiLCAiaWQiOiAicmV1c2VkLW5hdGl2ZS1pZCIsICJhcmdzIjogeyJmaWxlX3BhdGgiOiAiXCIvZml4dHVyZS9yZWFkLnR4dFwiIiwgIm9mZnNldCI6IDEuMH19LCAidGhvdWdodFNpZ25hdHVyZSI6ICJyYXctY2FsbCJ9XX1dLCAidG9vbF9tYXBwaW5nIjogW3sibG9jYWxfaWQiOiAidG9vbHVfY2hhbmdlZCIsICJjYWxsZXJfbmFtZSI6ICJSZWFkIiwgImNvbnRlbnRfaW5kZXgiOiAwLCAicGFydF9pbmRleCI6IDEsICJuYXRpdmVfaWRfcHJlc2VudCI6IHRydWUsICJjb2RlY19wYXRocyI6IFsiL2ZpbGVfcGF0aCJdLCAicm9vdF93cmFwcGVyIjogbnVsbCwgIm5hdGl2ZV9pZCI6ICJyZXVzZWQtbmF0aXZlLWlkIn1dLCAicHJvamVjdGlvbl9kaWdlc3QiOiAiYjRkMDcxN2E3OGYzNGEyMTkxZWQxY2I5ZGJmZTA2M2QwYWQ5ZTRlNDExZGI1NTBhZTBiN2I3OTI0MDMxNmI3NiJ9" + "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJiYWNrZW5kX3Nlc3Npb25faWQiOiItMzc1MDc2MzAzNDM2Mjg5NTU3OSIsImxpbmVhZ2UiOnsiY29udmVyc2F0aW9uX2lkIjoiMTExMTExMTEtMTExMS00MTExLTgxMTEtMTExMTExMTExMTExIiwidHJhamVjdG9yeV9pZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiJ9LCJuYXRpdmVfY2hlY2twb2ludF9pbmRleCI6MCwidXNlcl9leGVjdXRpb24iOnsiYWN0aXZlX2lkIjoiYWFhYWFhYWEtYWFhYS00YWFhLThhYWEtMDAwMDAwMDAwMDAwIiwicHJpb3JfcmVjb3JkZWRfaWQiOm51bGx9LCJjaGVja3BvaW50X3N0b3BfcmVhc29uIjoidG9vbF91c2UiLCJ0aG91Z2h0X2Rpc3BsYXkiOiJvbWl0dGVkIiwiY29udGVudHMiOlt7InJvbGUiOiJtb2RlbCIsInBhcnRzIjpbeyJ0aG91Z2h0Ijp0cnVlLCJ0ZXh0IjoiR2VudWluZSBzeW50aGV0aWMgbmF0aXZlIHRob3VnaHQg6ZuqIiwidGhvdWdodFNpZ25hdHVyZSI6InJhdy10aG91Z2h0In0seyJmdW5jdGlvbkNhbGwiOnsibmFtZSI6Im5hdGl2ZV9yZWFkX2FsaWFzIiwiaWQiOiJyZXVzZWQtbmF0aXZlLWlkIiwiYXJncyI6eyJmaWxlX3BhdGgiOiJcIi9maXh0dXJlL3JlYWQudHh0XCIiLCJvZmZzZXQiOjEuMH19LCJ0aG91Z2h0U2lnbmF0dXJlIjoicmF3LWNhbGwifV19XSwidG9vbF9tYXBwaW5nIjpbeyJsb2NhbF9pZCI6InRvb2x1X2NoYW5nZWQiLCJjYWxsZXJfbmFtZSI6IlJlYWQiLCJjb250ZW50X2luZGV4IjowLCJwYXJ0X2luZGV4IjoxLCJuYXRpdmVfaWRfcHJlc2VudCI6dHJ1ZSwiY29kZWNfcGF0aHMiOlsiL2ZpbGVfcGF0aCJdLCJyb290X3dyYXBwZXIiOm51bGwsIm5hdGl2ZV9pZCI6InJldXNlZC1uYXRpdmUtaWQifV0sInByb2plY3Rpb25fZGlnZXN0IjoiYTQxMWE4ZWFlOTcwMmFkOTI5YmQ5N2NmNGVlYmJiYjYzMjY4ZTM3ZTQwYjMyNDYyOTUyOWFmMzdiMzJiYTc2MyJ9" }, { "type": "tool_use", @@ -204,7 +204,7 @@ { "type": "thinking", "thinking": "", - "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjogMSwgInByb2ZpbGVfaWQiOiAic3ludGhldGljLXByb2ZpbGUiLCAicHJvamVjdF9pZCI6ICJzeW50aGV0aWMtcHJvamVjdCIsICJuYXRpdmVfbW9kZWwiOiAiZ2VtaW5pLTQtYXJnb24tbG93IiwgInNlc3Npb25faWQiOiAiOTIyMzM3MjAzNjg1NDc3NTgwNyIsICJ0aG91Z2h0X2Rpc3BsYXkiOiAib21pdHRlZCIsICJjb250ZW50cyI6IFt7InJvbGUiOiAibW9kZWwiLCAicGFydHMiOiBbeyJ0aG91Z2h0IjogdHJ1ZSwgInRleHQiOiAiR2VudWluZSBzeW50aGV0aWMgbmF0aXZlIHRob3VnaHQgXHU5NmVhIiwgInRob3VnaHRTaWduYXR1cmUiOiAicmF3LXRob3VnaHQifSwgeyJmdW5jdGlvbkNhbGwiOiB7Im5hbWUiOiAibmF0aXZlX3JlYWRfYWxpYXMiLCAiaWQiOiAicmV1c2VkLW5hdGl2ZS1pZCIsICJhcmdzIjogeyJmaWxlX3BhdGgiOiAiXCIvZml4dHVyZS9yZWFkLnR4dFwiIiwgIm9mZnNldCI6IDEuMH19LCAidGhvdWdodFNpZ25hdHVyZSI6ICJyYXctY2FsbCJ9XX1dLCAidG9vbF9tYXBwaW5nIjogW3sibG9jYWxfaWQiOiAidG9vbHVfZ29sZGVuX2xlYWZfb21pdHRlZF9pNjRfbWF4IiwgImNhbGxlcl9uYW1lIjogIlJlYWQiLCAiY29udGVudF9pbmRleCI6IDAsICJwYXJ0X2luZGV4IjogMSwgIm5hdGl2ZV9pZF9wcmVzZW50IjogdHJ1ZSwgImNvZGVjX3BhdGhzIjogW10sICJyb290X3dyYXBwZXIiOiBudWxsLCAibmF0aXZlX2lkIjogInJldXNlZC1uYXRpdmUtaWQifV0sICJwcm9qZWN0aW9uX2RpZ2VzdCI6ICJiNGQwNzE3YTc4ZjM0YTIxOTFlZDFjYjlkYmZlMDYzZDBhZDllNGU0MTFkYjU1MGFlMGI3Yjc5MjQwMzE2Yjc2In0" + "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJiYWNrZW5kX3Nlc3Npb25faWQiOiItMzc1MDc2MzAzNDM2Mjg5NTU3OSIsImxpbmVhZ2UiOnsiY29udmVyc2F0aW9uX2lkIjoiMTExMTExMTEtMTExMS00MTExLTgxMTEtMTExMTExMTExMTExIiwidHJhamVjdG9yeV9pZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiJ9LCJuYXRpdmVfY2hlY2twb2ludF9pbmRleCI6MCwidXNlcl9leGVjdXRpb24iOnsiYWN0aXZlX2lkIjoiYWFhYWFhYWEtYWFhYS00YWFhLThhYWEtMDAwMDAwMDAwMDAwIiwicHJpb3JfcmVjb3JkZWRfaWQiOm51bGx9LCJjaGVja3BvaW50X3N0b3BfcmVhc29uIjoidG9vbF91c2UiLCJ0aG91Z2h0X2Rpc3BsYXkiOiJvbWl0dGVkIiwiY29udGVudHMiOlt7InJvbGUiOiJtb2RlbCIsInBhcnRzIjpbeyJ0aG91Z2h0Ijp0cnVlLCJ0ZXh0IjoiR2VudWluZSBzeW50aGV0aWMgbmF0aXZlIHRob3VnaHQg6ZuqIiwidGhvdWdodFNpZ25hdHVyZSI6InJhdy10aG91Z2h0In0seyJmdW5jdGlvbkNhbGwiOnsibmFtZSI6Im5hdGl2ZV9yZWFkX2FsaWFzIiwiaWQiOiJyZXVzZWQtbmF0aXZlLWlkIiwiYXJncyI6eyJmaWxlX3BhdGgiOiJcIi9maXh0dXJlL3JlYWQudHh0XCIiLCJvZmZzZXQiOjEuMH19LCJ0aG91Z2h0U2lnbmF0dXJlIjoicmF3LWNhbGwifV19XSwidG9vbF9tYXBwaW5nIjpbeyJsb2NhbF9pZCI6InRvb2x1X2dvbGRlbl9sZWFmX29taXR0ZWRfaTY0X21heCIsImNhbGxlcl9uYW1lIjoiUmVhZCIsImNvbnRlbnRfaW5kZXgiOjAsInBhcnRfaW5kZXgiOjEsIm5hdGl2ZV9pZF9wcmVzZW50Ijp0cnVlLCJjb2RlY19wYXRocyI6W10sInJvb3Rfd3JhcHBlciI6bnVsbCwibmF0aXZlX2lkIjoicmV1c2VkLW5hdGl2ZS1pZCJ9XSwicHJvamVjdGlvbl9kaWdlc3QiOiJhNDExYThlYWU5NzAyYWQ5MjliZDk3Y2Y0ZWViYmJiNjMyNjhlMzdlNDBiMzI0NjI5NTI5YWYzN2IzMmJhNzYzIn0" }, { "type": "tool_use", @@ -233,7 +233,7 @@ { "type": "thinking", "thinking": "", - "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJzZXNzaW9uX2lkIjoiOTIyMzM3MjAzNjg1NDc3NTgwNyIsInRob3VnaHRfZGlzcGxheSI6Im9taXR0ZWQiLCJjb250ZW50cyI6W3sicm9sZSI6Im1vZGVsIiwicGFydHMiOlt7InRob3VnaHQiOnRydWUsInRleHQiOiJHZW51aW5lIHN5bnRoZXRpYyBuYXRpdmUgdGhvdWdodCDpm6oiLCJ0aG91Z2h0U2lnbmF0dXJlIjoicmF3LXRob3VnaHQifSx7ImZ1bmN0aW9uQ2FsbCI6eyJuYW1lIjoibmF0aXZlX3JlYWRfYWxpYXMiLCJpZCI6InJldXNlZC1uYXRpdmUtaWQiLCJhcmdzIjp7ImZpbGVfcGF0aCI6IlwiL2ZpeHR1cmUvcmVhZC50eHRcIiIsIm9mZnNldCI6MS4wfX0sInRob3VnaHRTaWduYXR1cmUiOiJyYXctY2FsbCJ9XX1dLCJ0b29sX21hcHBpbmciOlt7ImxvY2FsX2lkIjoidG9vbHVfZ29sZGVuX2xlYWZfb21pdHRlZF9pNjRfbWF4IiwiY2FsbGVyX25hbWUiOiJSZWFkIiwiY29udGVudF9pbmRleCI6MCwicGFydF9pbmRleCI6MSwibmF0aXZlX2lkX3ByZXNlbnQiOnRydWUsImNvZGVjX3BhdGhzIjpbIi9maWxlX3BhdGgiXSwicm9vdF93cmFwcGVyIjpudWxsLCJuYXRpdmVfaWQiOiJyZXVzZWQtbmF0aXZlLWlkIn1dLCJwcm9qZWN0aW9uX2RpZ2VzdCI6ImI0ZDA3MTdhNzhmMzRhMjE5MWVkMWNiOWRiZmUwNjNkMGFkOWU0ZTQxMWRiNTUwYWUwYjdiNzkyNDAzMTZiNzYifQ" + "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJiYWNrZW5kX3Nlc3Npb25faWQiOiItMzc1MDc2MzAzNDM2Mjg5NTU3OSIsImxpbmVhZ2UiOnsiY29udmVyc2F0aW9uX2lkIjoiMTExMTExMTEtMTExMS00MTExLTgxMTEtMTExMTExMTExMTExIiwidHJhamVjdG9yeV9pZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiJ9LCJuYXRpdmVfY2hlY2twb2ludF9pbmRleCI6MCwidXNlcl9leGVjdXRpb24iOnsiYWN0aXZlX2lkIjoiYWFhYWFhYWEtYWFhYS00YWFhLThhYWEtMDAwMDAwMDAwMDAwIiwicHJpb3JfcmVjb3JkZWRfaWQiOm51bGx9LCJjaGVja3BvaW50X3N0b3BfcmVhc29uIjoidG9vbF91c2UiLCJ0aG91Z2h0X2Rpc3BsYXkiOiJvbWl0dGVkIiwiY29udGVudHMiOlt7InJvbGUiOiJtb2RlbCIsInBhcnRzIjpbeyJ0aG91Z2h0Ijp0cnVlLCJ0ZXh0IjoiR2VudWluZSBzeW50aGV0aWMgbmF0aXZlIHRob3VnaHQg6ZuqIiwidGhvdWdodFNpZ25hdHVyZSI6InJhdy10aG91Z2h0In0seyJmdW5jdGlvbkNhbGwiOnsibmFtZSI6Im5hdGl2ZV9yZWFkX2FsaWFzIiwiaWQiOiJyZXVzZWQtbmF0aXZlLWlkIiwiYXJncyI6eyJmaWxlX3BhdGgiOiJcIi9maXh0dXJlL3JlYWQudHh0XCIiLCJvZmZzZXQiOjEuMH19LCJ0aG91Z2h0U2lnbmF0dXJlIjoicmF3LWNhbGwifV19XSwidG9vbF9tYXBwaW5nIjpbeyJsb2NhbF9pZCI6InRvb2x1X2dvbGRlbl9sZWFmX29taXR0ZWRfaTY0X21heCIsImNhbGxlcl9uYW1lIjoiUmVhZCIsImNvbnRlbnRfaW5kZXgiOjAsInBhcnRfaW5kZXgiOjEsIm5hdGl2ZV9pZF9wcmVzZW50Ijp0cnVlLCJjb2RlY19wYXRocyI6WyIvZmlsZV9wYXRoIl0sInJvb3Rfd3JhcHBlciI6bnVsbCwibmF0aXZlX2lkIjoicmV1c2VkLW5hdGl2ZS1pZCJ9XSwicHJvamVjdGlvbl9kaWdlc3QiOiJhNDExYThlYWU5NzAyYWQ5MjliZDk3Y2Y0ZWViYmJiNjMyNjhlMzdlNDBiMzI0NjI5NTI5YWYzN2IzMmJhNzYzIn0" }, { "type": "tool_use", @@ -249,7 +249,7 @@ { "type": "thinking", "thinking": "Genuine synthetic native thought 雪", - "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJzZXNzaW9uX2lkIjoiLTM3IiwidGhvdWdodF9kaXNwbGF5IjoiZnVsbCIsImNvbnRlbnRzIjpbeyJyb2xlIjoibW9kZWwiLCJwYXJ0cyI6W3sidGhvdWdodCI6dHJ1ZSwidGV4dCI6IkdlbnVpbmUgc3ludGhldGljIG5hdGl2ZSB0aG91Z2h0IOmbqiIsInRob3VnaHRTaWduYXR1cmUiOiJyYXctdGhvdWdodCJ9LHsiZnVuY3Rpb25DYWxsIjp7Im5hbWUiOiJuYXRpdmVfcmVhZF9hbGlhcyIsImlkIjoicmV1c2VkLW5hdGl2ZS1pZCIsImFyZ3MiOnsiZmlsZV9wYXRoIjoiXCIvZml4dHVyZS9yZWFkLnR4dFwiIiwib2Zmc2V0IjoxLjB9fSwidGhvdWdodFNpZ25hdHVyZSI6InJhdy1jYWxsIn1dfV0sInRvb2xfbWFwcGluZyI6W3sibG9jYWxfaWQiOiJ0b29sdV9nb2xkZW5fbGVhZl9mdWxsX25lZ2F0aXZlX3Nlc3Npb24iLCJjYWxsZXJfbmFtZSI6IlJlYWQiLCJjb250ZW50X2luZGV4IjowLCJwYXJ0X2luZGV4IjoxLCJuYXRpdmVfaWRfcHJlc2VudCI6dHJ1ZSwiY29kZWNfcGF0aHMiOlsiL2ZpbGVfcGF0aCJdLCJyb290X3dyYXBwZXIiOm51bGwsIm5hdGl2ZV9pZCI6InJldXNlZC1uYXRpdmUtaWQifV0sInByb2plY3Rpb25fZGlnZXN0IjoiYTg1OTFiYTAwNjk5OWVkMmJhMTZjNjE5YTdiNzZlMzc5OTJlZWM5YmEwMDQzNDMxOWQ4YTliMTJiNmI3N2RlMSJ9" + "signature": "ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJiYWNrZW5kX3Nlc3Npb25faWQiOiItMzc1MDc2MzAzNDM2Mjg5NTU3OSIsImxpbmVhZ2UiOnsiY29udmVyc2F0aW9uX2lkIjoiMTExMTExMTEtMTExMS00MTExLTgxMTEtMTExMTExMTExMTExIiwidHJhamVjdG9yeV9pZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiJ9LCJuYXRpdmVfY2hlY2twb2ludF9pbmRleCI6MCwidXNlcl9leGVjdXRpb24iOnsiYWN0aXZlX2lkIjoiYWFhYWFhYWEtYWFhYS00YWFhLThhYWEtMDAwMDAwMDAwMDAwIiwicHJpb3JfcmVjb3JkZWRfaWQiOm51bGx9LCJjaGVja3BvaW50X3N0b3BfcmVhc29uIjoidG9vbF91c2UiLCJ0aG91Z2h0X2Rpc3BsYXkiOiJmdWxsIiwiY29udGVudHMiOlt7InJvbGUiOiJtb2RlbCIsInBhcnRzIjpbeyJ0aG91Z2h0Ijp0cnVlLCJ0ZXh0IjoiR2VudWluZSBzeW50aGV0aWMgbmF0aXZlIHRob3VnaHQg6ZuqIiwidGhvdWdodFNpZ25hdHVyZSI6InJhdy10aG91Z2h0In0seyJmdW5jdGlvbkNhbGwiOnsibmFtZSI6Im5hdGl2ZV9yZWFkX2FsaWFzIiwiaWQiOiJyZXVzZWQtbmF0aXZlLWlkIiwiYXJncyI6eyJmaWxlX3BhdGgiOiJcIi9maXh0dXJlL3JlYWQudHh0XCIiLCJvZmZzZXQiOjEuMH19LCJ0aG91Z2h0U2lnbmF0dXJlIjoicmF3LWNhbGwifV19XSwidG9vbF9tYXBwaW5nIjpbeyJsb2NhbF9pZCI6InRvb2x1X2dvbGRlbl9sZWFmX2Z1bGxfbmVnYXRpdmVfc2Vzc2lvbiIsImNhbGxlcl9uYW1lIjoiUmVhZCIsImNvbnRlbnRfaW5kZXgiOjAsInBhcnRfaW5kZXgiOjEsIm5hdGl2ZV9pZF9wcmVzZW50Ijp0cnVlLCJjb2RlY19wYXRocyI6WyIvZmlsZV9wYXRoIl0sInJvb3Rfd3JhcHBlciI6bnVsbCwibmF0aXZlX2lkIjoicmV1c2VkLW5hdGl2ZS1pZCJ9XSwicHJvamVjdGlvbl9kaWdlc3QiOiI3ZDFhNjJhN2RlZTI5YmZiMzM4YmE3MzdkMDM2M2UzNWNmYWM2ODE0MDlmMDM4Nzg4NjhlZGJhNzNlZmNjN2Y5In0" }, { "type": "tool_use", @@ -302,7 +302,6 @@ "project_id": "synthetic-project", "native_model": "gemini-4-argon-low" }, - "session_id": "1", "thought_display": "omitted", "tool_projections": [ { @@ -348,7 +347,6 @@ "project_id": "synthetic-project", "native_model": "gemini-4-argon-low" }, - "session_id": "1", "thought_display": "omitted", "tool_projections": [ { @@ -393,7 +391,6 @@ "project_id": "synthetic-project", "native_model": "gemini-4-argon-low" }, - "session_id": "1", "thought_display": "omitted", "tool_projections": [ { @@ -437,7 +434,6 @@ "project_id": "synthetic-project", "native_model": "gemini-4-argon-low" }, - "session_id": "1", "thought_display": "omitted", "tool_projections": [ { @@ -486,7 +482,6 @@ "project_id": "synthetic-project", "native_model": "gemini-4-argon-low" }, - "session_id": "1", "thought_display": "omitted", "tool_projections": [ { @@ -535,7 +530,6 @@ "project_id": "synthetic-project", "native_model": "gemini-4-argon-low" }, - "session_id": "1", "thought_display": "omitted", "tool_projections": [ { diff --git a/tests/fixtures/antigravity-parameterless-v1.json b/tests/fixtures/antigravity-parameterless-v1.json index 2580a8a7..a2696015 100644 --- a/tests/fixtures/antigravity-parameterless-v1.json +++ b/tests/fixtures/antigravity-parameterless-v1.json @@ -1 +1 @@ -{"name":"parameterless-omitted-args","binding":{"profile_id":"synthetic-profile","project_id":"synthetic-project","native_model":"gemini-4-argon-low"},"native_contents":[{"role":"model","parts":[{"functionCall":{"name":"empty"},"thoughtSignature":"synthetic-signature"}]}],"session_id":"19","thought_display":"full","tool_projections":[{"caller_name":"empty","local_id":"toolu_parameterless_golden","codec_paths":[],"root_wrapper":null}],"projected_blocks":[{"type":"thinking","thinking":"","signature":"ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJzZXNzaW9uX2lkIjoiMTkiLCJ0aG91Z2h0X2Rpc3BsYXkiOiJmdWxsIiwiY29udGVudHMiOlt7InJvbGUiOiJtb2RlbCIsInBhcnRzIjpbeyJmdW5jdGlvbkNhbGwiOnsibmFtZSI6ImVtcHR5In0sInRob3VnaHRTaWduYXR1cmUiOiJzeW50aGV0aWMtc2lnbmF0dXJlIn1dfV0sInRvb2xfbWFwcGluZyI6W3sibG9jYWxfaWQiOiJ0b29sdV9wYXJhbWV0ZXJsZXNzX2dvbGRlbiIsImNhbGxlcl9uYW1lIjoiZW1wdHkiLCJjb250ZW50X2luZGV4IjowLCJwYXJ0X2luZGV4IjowLCJuYXRpdmVfaWRfcHJlc2VudCI6ZmFsc2UsImNvZGVjX3BhdGhzIjpbXSwicm9vdF93cmFwcGVyIjpudWxsfV0sInByb2plY3Rpb25fZGlnZXN0IjoiYTA1ZjJkODg4MWE3ZWRmOTdjYTgzNjE3ZTFhYTQyZjI0OWQxYzNiMGZjMGRlNWQ0MmU0OTVlNDAzZmE0OTI2OCJ9"},{"type":"tool_use","id":"toolu_parameterless_golden","name":"empty","input":{}}],"envelope":{"version":1,"profile_id":"synthetic-profile","project_id":"synthetic-project","native_model":"gemini-4-argon-low","session_id":"19","thought_display":"full","contents":[{"role":"model","parts":[{"functionCall":{"name":"empty"},"thoughtSignature":"synthetic-signature"}]}],"tool_mapping":[{"local_id":"toolu_parameterless_golden","caller_name":"empty","content_index":0,"part_index":0,"native_id_present":false,"codec_paths":[],"root_wrapper":null}],"projection_digest":"a05f2d8881a7edf97ca83617e1aa42f249d1c3b0fc0de5d42e495e403fa49268"},"canonical_digest_input_utf8":"[\"object\",[[\"binding\",[\"object\",[[\"native_model\",[\"string\",\"gemini-4-argon-low\"]],[\"profile_id\",[\"string\",\"synthetic-profile\"]],[\"project_id\",[\"string\",\"synthetic-project\"]],[\"session_id\",[\"string\",\"19\"]],[\"thought_display\",[\"string\",\"full\"]],[\"tool_mapping\",[\"array\",[[\"object\",[[\"caller_name\",[\"string\",\"empty\"]],[\"codec_paths\",[\"array\",[]]],[\"content_index\",[\"number\",\"0000000000000000\"]],[\"local_id\",[\"string\",\"toolu_parameterless_golden\"]],[\"native_id_present\",[\"bool\",false]],[\"part_index\",[\"number\",\"0000000000000000\"]],[\"root_wrapper\",[\"null\"]]]]]]]]]],[\"blocks\",[\"array\",[[\"object\",[[\"thinking\",[\"string\",\"\"]],[\"type\",[\"string\",\"thinking\"]]]],[\"object\",[[\"id\",[\"string\",\"toolu_parameterless_golden\"]],[\"input\",[\"object\",[]]],[\"name\",[\"string\",\"empty\"]],[\"type\",[\"string\",\"tool_use\"]]]]]]]]]","canonical_digest_input_hex":"5b226f626a656374222c5b5b2262696e64696e67222c5b226f626a656374222c5b5b226e61746976655f6d6f64656c222c5b22737472696e67222c2267656d696e692d342d6172676f6e2d6c6f77225d5d2c5b2270726f66696c655f6964222c5b22737472696e67222c2273796e7468657469632d70726f66696c65225d5d2c5b2270726f6a6563745f6964222c5b22737472696e67222c2273796e7468657469632d70726f6a656374225d5d2c5b2273657373696f6e5f6964222c5b22737472696e67222c223139225d5d2c5b2274686f756768745f646973706c6179222c5b22737472696e67222c2266756c6c225d5d2c5b22746f6f6c5f6d617070696e67222c5b226172726179222c5b5b226f626a656374222c5b5b2263616c6c65725f6e616d65222c5b22737472696e67222c22656d707479225d5d2c5b22636f6465635f7061746873222c5b226172726179222c5b5d5d5d2c5b22636f6e74656e745f696e646578222c5b226e756d626572222c2230303030303030303030303030303030225d5d2c5b226c6f63616c5f6964222c5b22737472696e67222c22746f6f6c755f706172616d657465726c6573735f676f6c64656e225d5d2c5b226e61746976655f69645f70726573656e74222c5b22626f6f6c222c66616c73655d5d2c5b22706172745f696e646578222c5b226e756d626572222c2230303030303030303030303030303030225d5d2c5b22726f6f745f77726170706572222c5b226e756c6c225d5d5d5d5d5d5d5d5d5d2c5b22626c6f636b73222c5b226172726179222c5b5b226f626a656374222c5b5b227468696e6b696e67222c5b22737472696e67222c22225d5d2c5b2274797065222c5b22737472696e67222c227468696e6b696e67225d5d5d5d2c5b226f626a656374222c5b5b226964222c5b22737472696e67222c22746f6f6c755f706172616d657465726c6573735f676f6c64656e225d5d2c5b22696e707574222c5b226f626a656374222c5b5d5d5d2c5b226e616d65222c5b22737472696e67222c22656d707479225d5d2c5b2274797065222c5b22737472696e67222c22746f6f6c5f757365225d5d5d5d5d5d5d5d5d","sha256":"a05f2d8881a7edf97ca83617e1aa42f249d1c3b0fc0de5d42e495e403fa49268"} +{"name":"parameterless-omitted-args","binding":{"profile_id":"synthetic-profile","project_id":"synthetic-project","native_model":"gemini-4-argon-low"},"native_contents":[{"role":"model","parts":[{"functionCall":{"name":"empty"},"thoughtSignature":"synthetic-signature"}]}],"thought_display":"full","tool_projections":[{"caller_name":"empty","local_id":"toolu_parameterless_golden","codec_paths":[],"root_wrapper":null}],"projected_blocks":[{"type":"thinking","thinking":"","signature":"ccp:antigravity:v1:eyJ2ZXJzaW9uIjoxLCJwcm9maWxlX2lkIjoic3ludGhldGljLXByb2ZpbGUiLCJwcm9qZWN0X2lkIjoic3ludGhldGljLXByb2plY3QiLCJuYXRpdmVfbW9kZWwiOiJnZW1pbmktNC1hcmdvbi1sb3ciLCJiYWNrZW5kX3Nlc3Npb25faWQiOiItMzc1MDc2MzAzNDM2Mjg5NTU3OSIsImxpbmVhZ2UiOnsiY29udmVyc2F0aW9uX2lkIjoiMTExMTExMTEtMTExMS00MTExLTgxMTEtMTExMTExMTExMTExIiwidHJhamVjdG9yeV9pZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiJ9LCJuYXRpdmVfY2hlY2twb2ludF9pbmRleCI6MCwidXNlcl9leGVjdXRpb24iOnsiYWN0aXZlX2lkIjoiYWFhYWFhYWEtYWFhYS00YWFhLThhYWEtMDAwMDAwMDAwMDAwIiwicHJpb3JfcmVjb3JkZWRfaWQiOm51bGx9LCJjaGVja3BvaW50X3N0b3BfcmVhc29uIjoidG9vbF91c2UiLCJ0aG91Z2h0X2Rpc3BsYXkiOiJmdWxsIiwiY29udGVudHMiOlt7InJvbGUiOiJtb2RlbCIsInBhcnRzIjpbeyJmdW5jdGlvbkNhbGwiOnsibmFtZSI6ImVtcHR5In0sInRob3VnaHRTaWduYXR1cmUiOiJzeW50aGV0aWMtc2lnbmF0dXJlIn1dfV0sInRvb2xfbWFwcGluZyI6W3sibG9jYWxfaWQiOiJ0b29sdV9wYXJhbWV0ZXJsZXNzX2dvbGRlbiIsImNhbGxlcl9uYW1lIjoiZW1wdHkiLCJjb250ZW50X2luZGV4IjowLCJwYXJ0X2luZGV4IjowLCJuYXRpdmVfaWRfcHJlc2VudCI6ZmFsc2UsImNvZGVjX3BhdGhzIjpbXSwicm9vdF93cmFwcGVyIjpudWxsfV0sInByb2plY3Rpb25fZGlnZXN0IjoiNDE0NzZiMThkYjJhMDU3OGE5ZWIxMzVmOWI5MzRjZDI5ZTAyOTU4ZDkyMzA2OTdjMGY5YzViMmQ3NzNjZTZkMiJ9"},{"type":"tool_use","id":"toolu_parameterless_golden","name":"empty","input":{}}],"envelope":{"version":1,"profile_id":"synthetic-profile","project_id":"synthetic-project","native_model":"gemini-4-argon-low","backend_session_id":"-3750763034362895579","lineage":{"conversation_id":"11111111-1111-4111-8111-111111111111","trajectory_id":"22222222-2222-4222-8222-222222222222"},"native_checkpoint_index":0,"user_execution":{"active_id":"aaaaaaaa-aaaa-4aaa-8aaa-000000000000","prior_recorded_id":null},"checkpoint_stop_reason":"tool_use","thought_display":"full","contents":[{"role":"model","parts":[{"functionCall":{"name":"empty"},"thoughtSignature":"synthetic-signature"}]}],"tool_mapping":[{"local_id":"toolu_parameterless_golden","caller_name":"empty","content_index":0,"part_index":0,"native_id_present":false,"codec_paths":[],"root_wrapper":null}],"projection_digest":"41476b18db2a0578a9eb135f9b934cd29e02958d9230697c0f9c5b2d773ce6d2"},"canonical_digest_input_utf8":"[\"object\",[[\"binding\",[\"object\",[[\"backend_session_id\",[\"string\",\"-3750763034362895579\"]],[\"checkpoint_stop_reason\",[\"string\",\"tool_use\"]],[\"lineage\",[\"object\",[[\"conversation_id\",[\"string\",\"11111111-1111-4111-8111-111111111111\"]],[\"trajectory_id\",[\"string\",\"22222222-2222-4222-8222-222222222222\"]]]]],[\"native_checkpoint_index\",[\"number\",\"0000000000000000\"]],[\"native_model\",[\"string\",\"gemini-4-argon-low\"]],[\"profile_id\",[\"string\",\"synthetic-profile\"]],[\"project_id\",[\"string\",\"synthetic-project\"]],[\"thought_display\",[\"string\",\"full\"]],[\"tool_mapping\",[\"array\",[[\"object\",[[\"caller_name\",[\"string\",\"empty\"]],[\"codec_paths\",[\"array\",[]]],[\"content_index\",[\"number\",\"0000000000000000\"]],[\"local_id\",[\"string\",\"toolu_parameterless_golden\"]],[\"native_id_present\",[\"bool\",false]],[\"part_index\",[\"number\",\"0000000000000000\"]],[\"root_wrapper\",[\"null\"]]]]]]],[\"user_execution\",[\"object\",[[\"active_id\",[\"string\",\"aaaaaaaa-aaaa-4aaa-8aaa-000000000000\"]],[\"prior_recorded_id\",[\"null\"]]]]],[\"version\",[\"number\",\"3ff0000000000000\"]]]]],[\"blocks\",[\"array\",[[\"object\",[[\"thinking\",[\"string\",\"\"]],[\"type\",[\"string\",\"thinking\"]]]],[\"object\",[[\"id\",[\"string\",\"toolu_parameterless_golden\"]],[\"input\",[\"object\",[]]],[\"name\",[\"string\",\"empty\"]],[\"type\",[\"string\",\"tool_use\"]]]]]]],[\"domain\",[\"string\",\"ccp:antigravity:v1:projection\"]]]]","canonical_digest_input_hex":"5b226f626a656374222c5b5b2262696e64696e67222c5b226f626a656374222c5b5b226261636b656e645f73657373696f6e5f6964222c5b22737472696e67222c222d33373530373633303334333632383935353739225d5d2c5b22636865636b706f696e745f73746f705f726561736f6e222c5b22737472696e67222c22746f6f6c5f757365225d5d2c5b226c696e65616765222c5b226f626a656374222c5b5b22636f6e766572736174696f6e5f6964222c5b22737472696e67222c2231313131313131312d313131312d343131312d383131312d313131313131313131313131225d5d2c5b227472616a6563746f72795f6964222c5b22737472696e67222c2232323232323232322d323232322d343232322d383232322d323232323232323232323232225d5d5d5d5d2c5b226e61746976655f636865636b706f696e745f696e646578222c5b226e756d626572222c2230303030303030303030303030303030225d5d2c5b226e61746976655f6d6f64656c222c5b22737472696e67222c2267656d696e692d342d6172676f6e2d6c6f77225d5d2c5b2270726f66696c655f6964222c5b22737472696e67222c2273796e7468657469632d70726f66696c65225d5d2c5b2270726f6a6563745f6964222c5b22737472696e67222c2273796e7468657469632d70726f6a656374225d5d2c5b2274686f756768745f646973706c6179222c5b22737472696e67222c2266756c6c225d5d2c5b22746f6f6c5f6d617070696e67222c5b226172726179222c5b5b226f626a656374222c5b5b2263616c6c65725f6e616d65222c5b22737472696e67222c22656d707479225d5d2c5b22636f6465635f7061746873222c5b226172726179222c5b5d5d5d2c5b22636f6e74656e745f696e646578222c5b226e756d626572222c2230303030303030303030303030303030225d5d2c5b226c6f63616c5f6964222c5b22737472696e67222c22746f6f6c755f706172616d657465726c6573735f676f6c64656e225d5d2c5b226e61746976655f69645f70726573656e74222c5b22626f6f6c222c66616c73655d5d2c5b22706172745f696e646578222c5b226e756d626572222c2230303030303030303030303030303030225d5d2c5b22726f6f745f77726170706572222c5b226e756c6c225d5d5d5d5d5d5d2c5b22757365725f657865637574696f6e222c5b226f626a656374222c5b5b226163746976655f6964222c5b22737472696e67222c2261616161616161612d616161612d346161612d386161612d303030303030303030303030225d5d2c5b227072696f725f7265636f726465645f6964222c5b226e756c6c225d5d5d5d5d2c5b2276657273696f6e222c5b226e756d626572222c2233666630303030303030303030303030225d5d5d5d5d2c5b22626c6f636b73222c5b226172726179222c5b5b226f626a656374222c5b5b227468696e6b696e67222c5b22737472696e67222c22225d5d2c5b2274797065222c5b22737472696e67222c227468696e6b696e67225d5d5d5d2c5b226f626a656374222c5b5b226964222c5b22737472696e67222c22746f6f6c755f706172616d657465726c6573735f676f6c64656e225d5d2c5b22696e707574222c5b226f626a656374222c5b5d5d5d2c5b226e616d65222c5b22737472696e67222c22656d707479225d5d2c5b2274797065222c5b22737472696e67222c22746f6f6c5f757365225d5d5d5d5d5d5d2c5b22646f6d61696e222c5b22737472696e67222c226363703a616e7469677261766974793a76313a70726f6a656374696f6e225d5d5d5d","sha256":"41476b18db2a0578a9eb135f9b934cd29e02958d9230697c0f9c5b2d773ce6d2"} From 54557749fefc92a46e48d4473e77e57bd9bf2a91 Mon Sep 17 00:00:00 2001 From: Chris Liu <11517021+chris7iu@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:32:06 +0000 Subject: [PATCH 20/30] fix(antigravity): canonicalize signed turns and relax harmless client hints --- src/providers/antigravity/replay.rs | 73 ++++++++++------------------ src/providers/antigravity/request.rs | 27 +++++----- src/providers/antigravity_tests.rs | 21 ++++---- 3 files changed, 48 insertions(+), 73 deletions(-) diff --git a/src/providers/antigravity/replay.rs b/src/providers/antigravity/replay.rs index cb40dd77..643281c6 100644 --- a/src/providers/antigravity/replay.rs +++ b/src/providers/antigravity/replay.rs @@ -534,20 +534,30 @@ pub(crate) fn projection_values(blocks: &Value) -> Result { Ok(json!(result)) } -/// Restore original model-emitted `tool_use.input` values from the signed -/// carrier envelope when a client normalizes tool inputs in-place (such as -/// Claude Code's `Bash` tool stripping a redundant `cd && ` prefix via -/// `updatedInput`) while keeping `id`, `name`, and block structure intact. +/// Canonicalize an assistant turn's projected blocks from its leading +/// `ccp:antigravity:v1:` carrier envelope before `decode_history`. Because +/// upstream `v1internal` requests are reconstructed from `envelope["contents"]` +/// and `envelope["tool_mapping"]` inside the carrier rather than the client's +/// echoed blocks, client-side manipulations by Claude Code (such as hook +/// `updatedInput` rewrites, thinking-text clearing, text edits, or extra block +/// metadata) should not trigger a `projection digest mismatch`. pub(crate) fn canonicalize_tool_inputs(blocks: &mut Value) { - let Some(list) = blocks.as_array_mut() else { + let Some(list) = blocks.as_array() else { return; }; - let Some(encoded) = list - .first() - .filter(|b| b["type"] == "thinking") - .and_then(|b| b["signature"].as_str()) - .and_then(|s| s.strip_prefix(PREFIX)) - else { + if list.is_empty() + || list[0]["type"] != "thinking" + || list.iter().filter(|b| b["type"] == "thinking").count() != 1 + || list + .iter() + .any(|b| !matches!(b["type"].as_str(), Some("thinking" | "text" | "tool_use"))) + { + return; + } + let Some(signature) = list[0]["signature"].as_str().map(str::to_owned) else { + return; + }; + let Some(encoded) = signature.strip_prefix(PREFIX) else { return; }; if encoded.is_empty() || encoded.len() > MAX_ENCODED || encoded.len() * 3 / 4 > MAX_DECODED { @@ -562,49 +572,16 @@ pub(crate) fn canonicalize_tool_inputs(blocks: &mut Value) { let Ok(thought_display) = nonempty(&envelope["thought_display"]) else { return; }; - let Ok(expected) = projection( + let Ok(mut expected) = projection( &envelope["contents"], thought_display, &envelope["tool_mapping"], ) else { return; }; - let Some(expected_list) = expected.as_array() else { - return; - }; - let expected_calls: Vec<&Value> = expected_list - .iter() - .filter(|b| b["type"] == "tool_use") - .collect(); - if expected_calls.is_empty() { - return; - } - let actual_calls: Vec = list - .iter() - .enumerate() - .filter_map(|(i, b)| (b["type"] == "tool_use").then_some(i)) - .collect(); - if actual_calls.len() != expected_calls.len() { - return; - } - for (&idx, exp) in actual_calls.iter().zip(&expected_calls) { - let block = &list[idx]; - if keys( - block, - &["type", "signature", "cache_control", "id", "name", "input"], - &["type", "id", "name", "input"], - ) - .is_err() - || !block["input"].is_object() - || block["id"] != exp["id"] - || block["name"] != exp["name"] - || canonical(&block["input"]).is_err() - { - return; - } - } - for (&idx, exp) in actual_calls.iter().zip(&expected_calls) { - list[idx]["input"] = exp["input"].clone(); + if let Some(first) = expected.as_array_mut().and_then(|b| b.first_mut()) { + first["signature"] = json!(signature); + *blocks = expected; } } diff --git a/src/providers/antigravity/request.rs b/src/providers/antigravity/request.rs index 1649ddc6..813b64e3 100644 --- a/src/providers/antigravity/request.rs +++ b/src/providers/antigravity/request.rs @@ -183,15 +183,10 @@ impl Prepared { pub(crate) fn cache_hint(value: Option<&Value>) -> Result<()> { let Some(value) = value else { return Ok(()) }; - keys(value, &["type", "ttl", "scope"], &["type"])?; - if value["type"] != "ephemeral" - || value - .get("ttl") - .is_some_and(|v| !matches!(v.as_str(), Some("5m" | "1h"))) - || value - .get("scope") - .is_some_and(|v| v.as_str() != Some("global")) - { + let Some(obj) = value.as_object() else { + return Err("unsupported cache hint"); + }; + if obj.get("type").and_then(Value::as_str) != Some("ephemeral") { return Err("unsupported cache hint"); } Ok(()) @@ -303,6 +298,9 @@ fn options(body: &MessagesRequest) -> Result<&'static str> { "metadata", "tool_choice", "cache_control", + "service_tier", + "betas", + "anthropic_beta", ]; if body.extra.keys().any(|k| !allowed.contains(&k.as_str())) { return Err("unsupported Antigravity semantic request option"); @@ -327,7 +325,7 @@ fn options(body: &MessagesRequest) -> Result<&'static str> { if output.get("effort").is_some_and(|v| { !matches!( v.as_str(), - Some("low" | "medium" | "high" | "xhigh" | "max") + Some("low" | "medium" | "high" | "xhigh" | "max" | "auto") ) }) { return Err("unsupported advisory effort"); @@ -344,11 +342,10 @@ fn options(body: &MessagesRequest) -> Result<&'static str> { } } } - if let Some(metadata) = body.extra.get("metadata") { - keys(metadata, &["user_id"], &[])?; - if metadata.get("user_id").is_some_and(|v| !v.is_string()) { - return Err("invalid metadata"); - } + if let Some(metadata) = body.extra.get("metadata") + && (!metadata.is_object() || metadata.get("user_id").is_some_and(|v| !v.is_string())) + { + return Err("invalid metadata"); } cache_hint(body.extra.get("cache_control"))?; Ok(display) diff --git a/src/providers/antigravity_tests.rs b/src/providers/antigravity_tests.rs index 6adeaf98..0edab016 100644 --- a/src/providers/antigravity_tests.rs +++ b/src/providers/antigravity_tests.rs @@ -2855,7 +2855,9 @@ fn antigravity_canonicalizes_client_normalized_tool_inputs_and_accepts_global_ca .unwrap(); let local_id = blocks[1]["id"].as_str().unwrap().to_owned(); - // Simulate Claude Code's Bash updatedInput normalization stripping `cd && ` in-place. + // Simulate client-side manipulations (Claude Code Bash updatedInput, cleared thinking text, + // extra cache_control / metadata fields). + blocks[0]["thinking"] = json!(""); blocks[1]["input"]["command"] = json!("git pull --ff-only -q origin master 2>&1 | tail -2; git log -1 --oneline"); assert!( @@ -2866,8 +2868,10 @@ fn antigravity_canonicalizes_client_normalized_tool_inputs_and_accepts_global_ca let mut request = request_value(); request["model"] = json!("antigravity/claude-opus-5-5-medium"); request["output_config"] = json!({"effort":"xhigh"}); + request["metadata"] = json!({"user_id":"u1","session_id":"s1"}); + request["service_tier"] = json!("auto"); request["system"] = json!([ - {"type":"text","text":"Global system instruction","cache_control":{"type":"ephemeral","ttl":"1h","scope":"global"}} + {"type":"text","text":"Global system instruction","cache_control":{"type":"ephemeral","ttl":"1h","scope":"global","future_hint":true}} ]); request["messages"] = json!([ {"role":"user","content":"Investigate why VE TEST PostgreSQL OOMed"}, @@ -2888,18 +2892,15 @@ fn antigravity_canonicalizes_client_normalized_tool_inputs_and_accepts_global_ca ) ); - // Tampering with tool_use id or name still fails closed in prepare. - let mut tampered_name = request.clone(); - tampered_name["messages"][1]["content"][1]["name"] = json!("OtherTool"); + // Tampering with the carrier signature itself still fails closed in prepare. + let mut tampered_sig = request; + tampered_sig["messages"][1]["content"][0]["signature"] = + json!(format!("{}corrupted", replay::PREFIX)); assert!( prepare( - &serde_json::from_value(tampered_name).unwrap(), + &serde_json::from_value(tampered_sig).unwrap(), &opus_binding ) .is_err() ); - - let mut tampered_id = request; - tampered_id["messages"][1]["content"][1]["id"] = json!("toolu_other"); - assert!(prepare(&serde_json::from_value(tampered_id).unwrap(), &opus_binding).is_err()); } From 454be4cf25225af58f5cb022f188d74ec5f6984f Mon Sep 17 00:00:00 2001 From: Chris Liu <11517021+chris7iu@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:05:19 +0000 Subject: [PATCH 21/30] fix(antigravity): support mid-session cross-model switches and enabled thinking budgets --- src/providers/antigravity/replay.rs | 41 +++++- src/providers/antigravity/request.rs | 169 +++++++++++++++++------- src/providers/antigravity_http_tests.rs | 4 +- src/providers/antigravity_tests.rs | 34 +++++ 4 files changed, 194 insertions(+), 54 deletions(-) diff --git a/src/providers/antigravity/replay.rs b/src/providers/antigravity/replay.rs index 643281c6..82efedb0 100644 --- a/src/providers/antigravity/replay.rs +++ b/src/providers/antigravity/replay.rs @@ -585,6 +585,43 @@ pub(crate) fn canonicalize_tool_inputs(blocks: &mut Value) { } } +/// Returns true when `blocks` carries a valid Antigravity envelope from a +/// different `native_model` (mid-session `/model` switch between Antigravity +/// models/tiers) or from an older envelope version (`v2`), so `request::prepare` +/// can include it in `prefix_messages` instead of failing `decode_history`. +pub(crate) fn is_prior_carrier_turn(blocks: &Value, current_native_model: &str) -> bool { + let Some(first) = blocks + .as_array() + .and_then(|b| b.first()) + .filter(|b| b["type"] == "thinking") + else { + return false; + }; + let Some(signature) = first["signature"].as_str() else { + return false; + }; + let (encoded, is_old_version) = if let Some(enc) = signature.strip_prefix(PREFIX) { + (enc, false) + } else if let Some(enc) = signature.strip_prefix("ccp:antigravity:v2:") { + (enc, true) + } else { + return false; + }; + if encoded.is_empty() || encoded.len() > MAX_ENCODED || encoded.len() * 3 / 4 > MAX_DECODED { + return false; + } + let Ok(raw) = URL_SAFE_NO_PAD.decode(encoded) else { + return false; + }; + let Ok(envelope) = parse_json(&raw, MAX_DECODED) else { + return false; + }; + let Ok(turn_model) = nonempty(&envelope["native_model"]) else { + return false; + }; + is_old_version || turn_model != current_native_model +} + /// Extract only an UNTRUSTED scope claim for local estimation when no serving /// snapshot exists. The caller must still run the original full version/digest/ /// history validators with this binding. Never publish it or use it as admission. @@ -601,6 +638,7 @@ pub(crate) fn claimed_binding( let signature = nonempty(&first["signature"])?; let encoded = signature .strip_prefix(PREFIX) + .or_else(|| signature.strip_prefix("ccp:antigravity:v2:")) .ok_or("missing/foreign carrier")?; if encoded.is_empty() || encoded.len() > MAX_ENCODED || encoded.len() * 3 / 4 > MAX_DECODED { return Err("carrier byte limit"); @@ -610,7 +648,8 @@ pub(crate) fn claimed_binding( .map_err(|_| "malformed carrier")?; let envelope = parse_json(&raw, MAX_DECODED)?; let project = nonempty(&envelope["project_id"])?; - if envelope["native_model"].as_str() != Some(native_model) + let turn_model = nonempty(&envelope["native_model"])?; + if !super::request::MODELS.contains(&format!("antigravity/{turn_model}").as_str()) || configured_project.is_some_and(|p| p != project) { return Err("model/configured-project estimate scope mismatch"); diff --git a/src/providers/antigravity/request.rs b/src/providers/antigravity/request.rs index 813b64e3..30e27eaf 100644 --- a/src/providers/antigravity/request.rs +++ b/src/providers/antigravity/request.rs @@ -288,6 +288,69 @@ fn text_parts(value: &Value) -> Result> { Ok(parts) } +fn format_prefix_tool_result(block: &Value) -> Result { + keys( + block, + &[ + "type", + "tool_use_id", + "content", + "is_error", + "cache_control", + ], + &["type", "tool_use_id"], + )?; + cache_hint(block.get("cache_control"))?; + let id = nonempty(&block["tool_use_id"])?; + let rendered = match block.get("content") { + None => String::new(), + Some(Value::String(s)) => s.clone(), + Some(value) => text_parts(value)? + .iter() + .filter_map(|p| p["text"].as_str()) + .collect::>() + .join("\n"), + }; + let is_error = block + .get("is_error") + .map(|v| v.as_bool().ok_or("invalid is_error flag")) + .transpose()? + .unwrap_or(false); + Ok(if is_error { + format!("[tool_result ({id}) error: {rendered}]") + } else { + format!("[tool_result ({id}): {rendered}]") + }) +} + +fn user_parts(value: &Value, allow_prefix_tool_result: bool) -> Result> { + if !allow_prefix_tool_result { + return text_parts(value); + } + if let Some(text) = value.as_str() { + return Ok(vec![json!({"text":text})]); + } + let mut parts = Vec::new(); + for block in value + .as_array() + .ok_or("content must be text or text blocks")? + { + match block["type"].as_str() { + Some("text") => { + keys(block, &["type", "text", "cache_control"], &["type", "text"])?; + cache_hint(block.get("cache_control"))?; + parts.push(json!({"text":block["text"].as_str().ok_or("text must be a string")?})); + } + Some("tool_result") => { + let formatted = format_prefix_tool_result(block)?; + parts.push(json!({"text":formatted})); + } + _ => return Err("non-text system/tool-result content is unsupported"), + } + } + Ok(parts) +} + fn options(body: &MessagesRequest) -> Result<&'static str> { let allowed = [ "system", @@ -307,17 +370,36 @@ fn options(body: &MessagesRequest) -> Result<&'static str> { } let mut display = "full"; if let Some(thinking) = body.extra.get("thinking") { - keys(thinking, &["type", "display"], &["type"])?; - if thinking["type"] != "adaptive" { - return Err( - "Antigravity supports adaptive thinking only; model tier controls reasoning", - ); + keys(thinking, &["type", "display", "budget_tokens"], &["type"])?; + match thinking["type"].as_str() { + Some("adaptive") => { + if thinking.get("budget_tokens").is_some() { + return Err("adaptive thinking does not take budget_tokens"); + } + } + Some("enabled") => { + if thinking + .get("budget_tokens") + .and_then(Value::as_u64) + .is_none_or(|n| n < 1024) + { + return Err( + "Antigravity supports adaptive thinking or enabled thinking with budget_tokens >= 1024", + ); + } + } + _ => { + return Err( + "Antigravity supports adaptive thinking only; model tier controls reasoning", + ); + } } if let Some(value) = thinking.get("display") { - if value != "omitted" { - return Err("unsupported thinking display"); + match value.as_str() { + Some("omitted") => display = "omitted", + Some("full") => display = "full", + _ => return Err("unsupported thinking display"), } - display = "omitted"; } } if let Some(output) = body.extra.get("output_config") { @@ -493,10 +575,18 @@ fn continuation_input(envelope: &Value, user: &Message) -> Result { } } +fn is_prior_or_unsigned_assistant(content: &Value, native_model: &str) -> bool { + content + .get(0) + .is_none_or(|block| block["type"] != "thinking") + || replay::is_prior_carrier_turn(content, native_model) +} + fn observed_continuation( messages: &[Cow<'_, Message>], envelopes: &[Value], model: &str, + has_prefix: bool, ) -> Result { let ordered = messages .iter() @@ -512,7 +602,7 @@ fn observed_continuation( { return Err("unsupported continuation role sequence"); } - if text_parts(&ordered[0].content)?.is_empty() { + if user_parts(&ordered[0].content, has_prefix)?.is_empty() { return Err("request needs an initial text user"); } let mut new_user_execution = false; @@ -640,17 +730,13 @@ pub(crate) fn prepare(body: &MessagesRequest, binding: &Binding) -> Result = prefix.iter().filter(|m| m.role != "system").collect(); let valid_alternation = !non_system.is_empty() @@ -659,11 +745,12 @@ pub(crate) fn prepare(body: &MessagesRequest, binding: &Binding) -> Result Result>(); - if input.len() != 1 || input[0].role != "user" || text_parts(&input[0].content)?.is_empty() + if input.len() != 1 + || input[0].role != "user" + || user_parts(&input[0].content, has_prefix)?.is_empty() { return Err("fresh execution requires one text user input"); } Metadata::Fresh } else { - observed_continuation(active_messages, &envelopes, model)? + observed_continuation(active_messages, &envelopes, model, has_prefix)? }; let session = String::new(); let mut history = envelopes.into_iter(); @@ -726,38 +815,7 @@ pub(crate) fn prepare(body: &MessagesRequest, binding: &Binding) -> Result { - keys( - &block, - &[ - "type", - "tool_use_id", - "content", - "is_error", - "cache_control", - ], - &["type", "tool_use_id"], - )?; - cache_hint(block.get("cache_control"))?; - let id = nonempty(&block["tool_use_id"])?; - let rendered = match block.get("content") { - None => String::new(), - Some(Value::String(s)) => s.clone(), - Some(value) => text_parts(value)? - .iter() - .filter_map(|p| p["text"].as_str()) - .collect::>() - .join("\n"), - }; - let is_error = block - .get("is_error") - .map(|v| v.as_bool().ok_or("invalid is_error flag")) - .transpose()? - .unwrap_or(false); - let formatted = if is_error { - format!("[tool_result ({id}) error: {rendered}]") - } else { - format!("[tool_result ({id}): {rendered}]") - }; + let formatted = format_prefix_tool_result(&block)?; estimate_bytes += formatted.len(); parts.push(json!({"text":formatted})); } @@ -831,11 +889,13 @@ pub(crate) fn prepare(body: &MessagesRequest, binding: &Binding) -> Result Result { + if allow_prefix_tool_result { + let formatted = format_prefix_tool_result(&block)?; + estimate_bytes += formatted.len(); + text.push(json!({"text":formatted})); + continue; + } keys( &block, &[ @@ -959,6 +1025,7 @@ pub(crate) fn prepare(body: &MessagesRequest, binding: &Binding) -> Result return Err("unsupported user content/media"), } } + allow_prefix_tool_result = false; if results.len() != pending.len() { return Err("incomplete tool result batch"); } diff --git a/src/providers/antigravity_http_tests.rs b/src/providers/antigravity_http_tests.rs index 4a502470..6e8ee86c 100644 --- a/src/providers/antigravity_http_tests.rs +++ b/src/providers/antigravity_http_tests.rs @@ -1491,7 +1491,7 @@ async fn antigravity_http_replay_restores_native_calls_after_fresh_provider() { json!([{"text":"ordered system after resume"}]) ); } - body["model"] = json!("antigravity/gemini-4-argon-high"); + body["model"] = json!("antigravity/unsupported-model"); let response = client() .post(second.url.join("/v1/messages").unwrap()) .json(&body) @@ -1502,7 +1502,7 @@ async fn antigravity_http_replay_restores_native_calls_after_fresh_provider() { assert_eq!( captured.lock().unwrap().len(), 2, - "model switch must not purchase inference" + "unsupported model switch must not purchase inference" ); } diff --git a/src/providers/antigravity_tests.rs b/src/providers/antigravity_tests.rs index 0edab016..0898fa64 100644 --- a/src/providers/antigravity_tests.rs +++ b/src/providers/antigravity_tests.rs @@ -958,6 +958,17 @@ fn antigravity_agy_count_value_inputs_preserve_semantics_without_effects() { ); let mut changed_model = body.clone(); changed_model.model = Some("antigravity/gemini-4-argon-high".into()); + assert!( + request::estimate( + &changed_model, + CountInputs { + serving_scope: None, + configured_project: Some("project") + } + ) + .is_ok() + ); + changed_model.model = Some("antigravity/unsupported-model".into()); assert!( request::estimate( &changed_model, @@ -2892,6 +2903,29 @@ fn antigravity_canonicalizes_client_normalized_tool_inputs_and_accepts_global_ca ) ); + // Switching mid-session from Opus 5.5 to Gemini 4 Argon low after an interrupted tool_use + // with Claude Code's `thinking: {"type": "enabled", "budget_tokens": 31999}` succeeds by + // folding prior-model turns and the trailing prefix tool_result into prefix_messages. + let mut switched = request.clone(); + switched["model"] = json!("antigravity/gemini-4-argon-low"); + switched["thinking"] = json!({"type":"enabled","budget_tokens":31999}); + switched["messages"][2]["content"] + .as_array_mut() + .unwrap() + .extend([ + json!({"type":"text","text":"[Request interrupted by user]"}), + json!({"type":"text","text":"continue"}), + ]); + let argon_prepared = prepare(&serde_json::from_value(switched).unwrap(), &binding()).unwrap(); + assert_eq!(argon_prepared.wire["model"], json!("gemini-4-argon-low")); + assert_eq!( + argon_prepared.wire["request"]["contents"] + .as_array() + .unwrap() + .len(), + 3 + ); + // Tampering with the carrier signature itself still fails closed in prepare. let mut tampered_sig = request; tampered_sig["messages"][1]["content"][0]["signature"] = From de44308061eb0179a3785ec7e1b4b38e19906e89 Mon Sep 17 00:00:00 2001 From: Chris Liu <11517021+chris7iu@users.noreply.github.com> Date: Tue, 10 Nov 2026 09:55:06 +0000 Subject: [PATCH 22/30] fix(antigravity): fold prefix history into user transcript, skip DeferredToolPlaceholder, and handle non-STOP tool finish reasons --- src/providers/antigravity/request.rs | 237 +++++++++++++------------ src/providers/antigravity/schema.rs | 13 +- src/providers/antigravity/transport.rs | 46 ++++- src/providers/antigravity_tests.rs | 83 +++++---- 4 files changed, 228 insertions(+), 151 deletions(-) diff --git a/src/providers/antigravity/request.rs b/src/providers/antigravity/request.rs index 30e27eaf..02471ba6 100644 --- a/src/providers/antigravity/request.rs +++ b/src/providers/antigravity/request.rs @@ -278,12 +278,24 @@ fn text_parts(value: &Value) -> Result> { .as_array() .ok_or("content must be text or text blocks")? { - keys(block, &["type", "text", "cache_control"], &["type", "text"])?; - if block["type"] != "text" { - return Err("non-text system/tool-result content is unsupported"); + match block["type"].as_str() { + Some("text") => { + keys(block, &["type", "text", "cache_control"], &["type", "text"])?; + cache_hint(block.get("cache_control"))?; + parts.push(json!({"text":block["text"].as_str().ok_or("text must be a string")?})); + } + Some("tool_reference") => { + keys( + block, + &["type", "tool_name", "cache_control"], + &["type", "tool_name"], + )?; + cache_hint(block.get("cache_control"))?; + let name = nonempty(&block["tool_name"])?; + parts.push(json!({"text":format!("Tool reference: {name}")})); + } + _ => return Err("non-text system/tool-result content is unsupported"), } - cache_hint(block.get("cache_control"))?; - parts.push(json!({"text":block["text"].as_str().ok_or("text must be a string")?})); } Ok(parts) } @@ -317,9 +329,9 @@ fn format_prefix_tool_result(block: &Value) -> Result { .transpose()? .unwrap_or(false); Ok(if is_error { - format!("[tool_result ({id}) error: {rendered}]") + format!("[Tool result ({id}) error: {rendered}]") } else { - format!("[tool_result ({id}): {rendered}]") + format!("[Tool result ({id}): {rendered}]") }) } @@ -575,11 +587,26 @@ fn continuation_input(envelope: &Value, user: &Message) -> Result { } } +fn has_polluted_prefix_artefact(content: &Value) -> bool { + let Some(blocks) = content.as_array() else { + return false; + }; + blocks.iter().any(|block| { + (block["type"] == "text" + && block["text"] + .as_str() + .is_some_and(|t| t.contains("[tool_use "))) + || (block["type"] == "tool_use" + && block["name"].as_str() == Some("DeferredToolPlaceholder")) + }) +} + fn is_prior_or_unsigned_assistant(content: &Value, native_model: &str) -> bool { content .get(0) .is_none_or(|block| block["type"] != "thinking") || replay::is_prior_carrier_turn(content, native_model) + || has_polluted_prefix_artefact(content) } fn observed_continuation( @@ -734,18 +761,18 @@ pub(crate) fn prepare(body: &MessagesRequest, binding: &Binding) -> Result = prefix.iter().filter(|m| m.role != "system").collect(); - let valid_alternation = !non_system.is_empty() - && non_system.len() % 2 == 0 - && non_system - .iter() - .enumerate() - .all(|(i, m)| m.role == if i % 2 == 0 { "user" } else { "assistant" }); - no_current_thinking && valid_alternation && last_unsigned + 1 < messages.len() + let starts_with_user = non_system.first().is_some_and(|m| m.role == "user"); + no_current_thinking && starts_with_user && last_unsigned + 1 < messages.len() }) .map(|idx| idx + 1) .unwrap_or(0); @@ -786,108 +813,82 @@ pub(crate) fn prepare(body: &MessagesRequest, binding: &Binding) -> Result { - keys( - &block, - &["type", "text", "cache_control"], - &["type", "text"], - )?; - cache_hint(block.get("cache_control"))?; - let value = block["text"].as_str().ok_or("invalid user text")?; - estimate_bytes += value.len(); - parts.push(json!({"text":value})); - } - Some("tool_result") => { - let formatted = format_prefix_tool_result(&block)?; - estimate_bytes += formatted.len(); - parts.push(json!({"text":formatted})); - } - _ => return Err("unsupported user content/media"), - } + if !prefix_messages.is_empty() { + let mut transcript = String::from("\n"); + for message in prefix_messages { + if message.role == "system" { + continue; } - if !parts.is_empty() { - if contents.last().is_some_and(|c| c["role"] == "user") { - contents - .last_mut() - .unwrap() - .get_mut("parts") - .and_then(Value::as_array_mut) - .unwrap() - .extend(parts); + if message.role == "user" { + let blocks = if let Some(text) = message.content.as_str() { + vec![json!({"type":"text","text":text})] } else { - if contents.len() >= 1024 { - return Err("native history content limit"); + message + .content + .as_array() + .ok_or("invalid user content")? + .clone() + }; + for block in blocks { + match block["type"].as_str() { + Some("text") => { + keys( + &block, + &["type", "text", "cache_control"], + &["type", "text"], + )?; + cache_hint(block.get("cache_control"))?; + let value = block["text"].as_str().ok_or("invalid user text")?; + transcript.push_str("[User]:\n"); + transcript.push_str(value); + transcript.push('\n'); + } + Some("tool_result") => { + let formatted = format_prefix_tool_result(&block)?; + transcript.push_str(&formatted); + transcript.push('\n'); + } + _ => return Err("unsupported user content/media"), } - contents.push(json!({"role":"user","parts":parts})); } - last_role = "user"; - } - } else if message.role == "assistant" { - let blocks = message - .content - .as_array() - .ok_or("invalid assistant content")?; - let mut parts = Vec::new(); - for block in blocks { - match block["type"].as_str() { - Some("thinking") => {} - Some("text") => { - let text = block["text"].as_str().ok_or("invalid display text")?; - if !text.is_empty() { - estimate_bytes += text.len(); - parts.push(json!({"text":text})); + } else if message.role == "assistant" { + let blocks = message + .content + .as_array() + .ok_or("invalid assistant content")?; + for block in blocks { + match block["type"].as_str() { + Some("thinking") => {} + Some("text") => { + let text = block["text"].as_str().ok_or("invalid display text")?; + if !text.is_empty() { + let sanitized = + text.replace("[tool_use ", "[Assistant invoked tool "); + transcript.push_str("[Assistant]:\n"); + transcript.push_str(&sanitized); + transcript.push('\n'); + } } + Some("tool_use") => { + let id = nonempty(&block["id"])?; + let name = nonempty(&block["name"])?; + let input = serde_json::to_string(&block["input"]) + .map_err(|_| "invalid tool input")?; + transcript.push_str(&format!( + "[Assistant invoked tool {name} (id={id}) with input: {input}]\n" + )); + } + _ => return Err("unsupported projection block"), } - Some("tool_use") => { - let id = nonempty(&block["id"])?; - let name = nonempty(&block["name"])?; - let input = serde_json::to_string(&block["input"]) - .map_err(|_| "invalid tool input")?; - let formatted = format!("[tool_use {name}(id={id}): {input}]"); - estimate_bytes += formatted.len(); - parts.push(json!({"text":formatted})); - } - _ => return Err("unsupported projection block"), } - } - if parts.is_empty() { - parts.push(json!({"text":"..."})); - } - if contents.last().is_some_and(|c| c["role"] == "model") { - contents - .last_mut() - .unwrap() - .get_mut("parts") - .and_then(Value::as_array_mut) - .unwrap() - .extend(parts); } else { - if contents.len() >= 1024 { - return Err("native history content limit"); - } - contents.push(json!({"role":"model","parts":parts})); + return Err("unsupported message role"); } - last_role = "assistant"; - } else { - return Err("unsupported message role"); } + transcript.push_str(""); + estimate_bytes += transcript.len(); + contents.push(json!({"role":"user","parts":[{"text":transcript}]})); + last_role = "user"; } let mut allow_prefix_tool_result = has_prefix; for message in active_messages { @@ -1044,10 +1045,20 @@ pub(crate) fn prepare(body: &MessagesRequest, binding: &Binding) -> Result= 1024 { - return Err("native history content limit"); + if contents.last().is_some_and(|c| c["role"] == "user") { + contents + .last_mut() + .unwrap() + .get_mut("parts") + .and_then(Value::as_array_mut) + .unwrap() + .extend(text); + } else { + if contents.len() >= 1024 { + return Err("native history content limit"); + } + contents.push(json!({"role":"user","parts":text})); } - contents.push(json!({"role":"user","parts":text})); } } else { return Err("unsupported message role"); diff --git a/src/providers/antigravity/schema.rs b/src/providers/antigravity/schema.rs index 9b134036..3b7e92e0 100644 --- a/src/providers/antigravity/schema.rs +++ b/src/providers/antigravity/schema.rs @@ -51,11 +51,22 @@ impl ToolSet { for tool in tools { keys( tool, - &["name", "description", "input_schema", "cache_control"], + &[ + "name", + "description", + "input_schema", + "cache_control", + "defer_loading", + ], &["name", "input_schema"], )?; super::request::cache_hint(tool.get("cache_control"))?; let caller = nonempty(&tool["name"])?; + if caller == "DeferredToolPlaceholder" + || tool.get("defer_loading") == Some(&Value::Bool(true)) + { + continue; + } if caller.len() > 1024 || !names.insert(caller) { return Err("invalid/duplicate caller tool name"); } diff --git a/src/providers/antigravity/transport.rs b/src/providers/antigravity/transport.rs index da874631..2814554a 100644 --- a/src/providers/antigravity/transport.rs +++ b/src/providers/antigravity/transport.rs @@ -503,14 +503,52 @@ impl Reducer { self.contents.push(content.clone()); } if let Some(reason) = candidate.get("finishReason") { - let reason = reason + let raw_reason = reason .as_str() - .filter(|r| matches!(*r, "STOP" | "MAX_TOKENS")) + .filter(|r| { + matches!( + *r, + "STOP" + | "MAX_TOKENS" + | "MALFORMED_FUNCTION_CALL" + | "UNEXPECTED_TOOL_CALL" + | "OTHER" + ) + }) .ok_or("unsupported native termination/refusal")?; - if self.terminal.as_ref().is_some_and(|old| old != reason) { + let normalized = if raw_reason == "MAX_TOKENS" { + "MAX_TOKENS" + } else { + "STOP" + }; + if raw_reason != "STOP" && raw_reason != "MAX_TOKENS" { + let has_visible_or_call = self.contents.iter().any(|c| { + c["parts"].as_array().is_some_and(|parts| { + parts.iter().any(|p| { + p.get("functionCall").is_some() + || (p["thought"] != true + && p.get("text") + .and_then(Value::as_str) + .is_some_and(|t| !t.is_empty())) + }) + }) + }); + if !has_visible_or_call { + let detail = candidate + .get("finishMessage") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .unwrap_or(raw_reason); + self.contents.push(json!({ + "role": "model", + "parts": [{"text": format!("[Model generation stopped early ({raw_reason}): {detail}]")}] + })); + } + } + if self.terminal.as_ref().is_some_and(|old| old != normalized) { return Err("conflicting native terminal state"); } - self.terminal = Some(reason.to_string()); + self.terminal = Some(normalized.to_string()); } } } diff --git a/src/providers/antigravity_tests.rs b/src/providers/antigravity_tests.rs index 0898fa64..955da973 100644 --- a/src/providers/antigravity_tests.rs +++ b/src/providers/antigravity_tests.rs @@ -2792,35 +2792,20 @@ fn antigravity_opus_5_5_and_carrierless_prefix_turns_and_coalesced_text_blocks() json!("MODEL_PLACEHOLDER_M400") ); let contents = prepared.wire["request"]["contents"].as_array().unwrap(); - assert_eq!(contents.len(), 7); + assert_eq!(contents.len(), 3); + assert_eq!(contents[0]["role"], json!("user")); + let prefix_text = contents[0]["parts"][0]["text"].as_str().unwrap(); + assert!(prefix_text.starts_with("\n")); + assert!(prefix_text.contains("[Assistant invoked tool Read (id=toolu_prefix_1) with input: {\"file_path\":\"/tmp/a.txt\"}]")); + assert!(prefix_text.contains("[Tool result (toolu_prefix_1): ALPHA]")); + assert!(!prefix_text.contains("[tool_use ")); assert_eq!( - contents[0], - json!({"role":"user","parts":[{"text":"Remember token ALPHA."}]}) - ); - assert_eq!( - contents[1], - json!({"role":"model","parts":[ - {"text":"Checking tool."}, - {"text":"[tool_use Read(id=toolu_prefix_1): {\"file_path\":\"/tmp/a.txt\"}]"} - ]}) + contents[0]["parts"][1], + json!({"text":"Now switch model."}) ); + assert_eq!(contents[1]["role"], json!("model")); assert_eq!( contents[2], - json!({"role":"user","parts":[ - {"text":"[tool_result (toolu_prefix_1): ALPHA]"} - ]}) - ); - assert_eq!( - contents[3], - json!({"role":"model","parts":[{"text":"Token ALPHA recorded."}]}) - ); - assert_eq!( - contents[4], - json!({"role":"user","parts":[{"text":"Now switch model."}]}) - ); - assert_eq!(contents[5]["role"], json!("model")); - assert_eq!( - contents[6], json!({"role":"user","parts":[{"text":"What was the token?"}]}) ); @@ -2904,11 +2889,16 @@ fn antigravity_canonicalizes_client_normalized_tool_inputs_and_accepts_global_ca ); // Switching mid-session from Opus 5.5 to Gemini 4 Argon low after an interrupted tool_use - // with Claude Code's `thinking: {"type": "enabled", "budget_tokens": 31999}` succeeds by - // folding prior-model turns and the trailing prefix tool_result into prefix_messages. + // with Claude Code's `thinking: {"type": "enabled", "budget_tokens": 31999}` and + // `DeferredToolPlaceholder` succeeds by folding prior-model turns into a user-side + // `` (with zero fake `role: "model"` text tool calls). let mut switched = request.clone(); switched["model"] = json!("antigravity/gemini-4-argon-low"); switched["thinking"] = json!({"type":"enabled","budget_tokens":31999}); + switched["tools"] = json!([ + {"name":"Bash","description":"Run bash","input_schema":{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}}, + {"name":"DeferredToolPlaceholder","description":"Placeholder","input_schema":{"type":"object"},"defer_loading":true} + ]); switched["messages"][2]["content"] .as_array_mut() .unwrap() @@ -2918,13 +2908,40 @@ fn antigravity_canonicalizes_client_normalized_tool_inputs_and_accepts_global_ca ]); let argon_prepared = prepare(&serde_json::from_value(switched).unwrap(), &binding()).unwrap(); assert_eq!(argon_prepared.wire["model"], json!("gemini-4-argon-low")); - assert_eq!( - argon_prepared.wire["request"]["contents"] - .as_array() - .unwrap() - .len(), - 3 + let argon_contents = argon_prepared.wire["request"]["contents"] + .as_array() + .unwrap(); + assert_eq!(argon_contents.len(), 1); + assert_eq!(argon_contents[0]["role"], json!("user")); + assert!( + !argon_prepared.wire["request"]["tools"] + .to_string() + .contains("DeferredToolPlaceholder") ); + let mut reducer = Reducer::default(); + reducer + .push( + format!( + "data: {}\n\n", + json!({ + "response": { + "candidates": [{ + "finishReason": "UNEXPECTED_TOOL_CALL", + "finishMessage": "SendMessage is not declared" + }], + "usageMetadata": { + "promptTokenCount": 10, + "candidatesTokenCount": 1, + "totalTokenCount": 11 + } + } + }) + ) + .as_bytes(), + ) + .unwrap(); + let recovered_turn = reducer.finish().unwrap(); + assert_eq!(recovered_turn.stop_reason, "end_turn"); // Tampering with the carrier signature itself still fails closed in prepare. let mut tampered_sig = request; From e3142e09fd6e19e31c9fec5f25a1a62b294d065d Mon Sep 17 00:00:00 2001 From: Chris Liu <11517021+chris7iu@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:17:17 +0000 Subject: [PATCH 23/30] style(antigravity): apply rustfmt to antigravity_tests.rs --- src/providers/antigravity_tests.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/providers/antigravity_tests.rs b/src/providers/antigravity_tests.rs index 955da973..8995c356 100644 --- a/src/providers/antigravity_tests.rs +++ b/src/providers/antigravity_tests.rs @@ -2799,10 +2799,7 @@ fn antigravity_opus_5_5_and_carrierless_prefix_turns_and_coalesced_text_blocks() assert!(prefix_text.contains("[Assistant invoked tool Read (id=toolu_prefix_1) with input: {\"file_path\":\"/tmp/a.txt\"}]")); assert!(prefix_text.contains("[Tool result (toolu_prefix_1): ALPHA]")); assert!(!prefix_text.contains("[tool_use ")); - assert_eq!( - contents[0]["parts"][1], - json!({"text":"Now switch model."}) - ); + assert_eq!(contents[0]["parts"][1], json!({"text":"Now switch model."})); assert_eq!(contents[1]["role"], json!("model")); assert_eq!( contents[2], From 3e65a77714c60e5000ef8c8796d5ceb7ee373122 Mon Sep 17 00:00:00 2001 From: Chris Liu <11517021+chris7iu@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:25:24 +0000 Subject: [PATCH 24/30] test(antigravity): update schema_parity cross-tier switch expectation --- tests/antigravity_schema_parity.rs | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/tests/antigravity_schema_parity.rs b/tests/antigravity_schema_parity.rs index 1b0ffef5..aee85012 100644 --- a/tests/antigravity_schema_parity.rs +++ b/tests/antigravity_schema_parity.rs @@ -1129,7 +1129,7 @@ async fn observed_policy(f: &mut Fixture) -> Result<(PathBuf, Vec)> { Ok((path, original)) } -fn v2_saved(blocks: &Value) -> Result { +fn carrier_saved(blocks: &Value) -> Result { let encoded = blocks[0]["signature"] .as_str() .context("native carrier")? @@ -1269,7 +1269,7 @@ async fn user_text_sequence(f: &mut Fixture, check_execution: bool) -> Result<() "exact alternating user/model history", )?; let blocks = exchange.blocks()?; - let saved = v2_saved(&blocks)?; + let saved = carrier_saved(&blocks)?; ensure!( saved["native_checkpoint_index"] == generation, "proxy native checkpoint ordinal" @@ -1401,9 +1401,9 @@ async fn read_tool_metadata(f: &mut Fixture) -> Result<()> { {"role":"model","parts":[{"functionResponse":{"name":"read_file","response":{"output":"Synthetic read fixture marker."},"id":NATIVE_ID}}]} ]); exact(second_wire, &expected, "complete observed read-loop wire, including separate MODEL functionResponse and untouched signature/native ID")?; - let first_saved = v2_saved(&blocks)?; + let first_saved = carrier_saved(&blocks)?; exact(&first_saved["contents"], &json!([{"role":"model","parts":[native_part]}]), "raw call/signature checkpoint")?; - let second_saved = v2_saved(&second.blocks()?)?; + let second_saved = carrier_saved(&second.blocks()?)?; ensure!(first_saved["checkpoint_stop_reason"] == "tool_use" && first_saved["user_execution"]["prior_recorded_id"].is_null(), "tool call is not a completed user execution"); let active = first_saved["user_execution"]["active_id"].as_str().context("active execution")?; uuid_v4(active)?; @@ -1559,7 +1559,7 @@ async fn source_all_tier_metadata(f: &mut Fixture) -> Result<()> { let first = f.exchange(body.clone(), json!([{"text":"Synthetic source-tier completion."}])).await?; first.success(stream)?; let blocks = first.blocks()?; - let saved = v2_saved(&blocks)?; + let saved = carrier_saved(&blocks)?; body["messages"].as_array_mut().context("messages")?.extend([json!({"role":"assistant","content":blocks}), json!({"role":"user","content":"Synthetic source-tier next user."})]); let second = f.exchange(body, json!([{"text":"Synthetic source-tier second completion."}])).await?; if second.status != StatusCode::OK { @@ -1580,9 +1580,16 @@ async fn source_all_tier_metadata(f: &mut Fixture) -> Result<()> { ensure!(second.wire()?["request"]["labels"]["last_execution_id"] == saved["user_execution"]["active_id"], "same predecessor relationship independent of tier"); let mut switched = request(stream); switched["model"] = json!(if model == "gemini-4-argon-low" { "antigravity/gemini-4-argon-high" } else { "antigravity/gemini-4-argon-low" }); - switched["messages"].as_array_mut().context("messages")?.extend([json!({"role":"assistant","content":blocks}), json!({"role":"user","content":"Must not switch tier on a carried history."})]); - let rejected = f.exchange(switched, json!([{"text":"must not dispatch"}])).await?; - ensure!(rejected.status == StatusCode::BAD_REQUEST && rejected.wire.is_none(), "fixed-model tier binding is still closed"); + switched["messages"].as_array_mut().context("messages")?.extend([json!({"role":"assistant","content":blocks}), json!({"role":"user","content":"Switch tier on a carried history."})]); + let switched_resp = f.exchange(switched, json!([{"text":"Switched tier completion."}])).await?; + switched_resp.success(stream)?; + let switched_wire = switched_resp.wire()?; + ensure!( + switched_wire["request"]["contents"][0]["parts"][0]["text"] + .as_str() + .is_some_and(|t| t.contains("")), + "cross-tier switch folds prior model history into transcript" + ); } } Ok::<(), anyhow::Error>(()) From cc5bd5439aca26d686da4870cc6a72c728731e6e Mon Sep 17 00:00:00 2001 From: Chris Liu <11517021+chris7iu@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:13:52 +0000 Subject: [PATCH 25/30] fix(antigravity): accept raw valid string values in Codec::Json tool fields --- src/providers/antigravity/schema.rs | 35 ++++++++++++++++++++-- src/providers/antigravity_tests.rs | 45 +++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/providers/antigravity/schema.rs b/src/providers/antigravity/schema.rs index 3b7e92e0..9ce184ab 100644 --- a/src/providers/antigravity/schema.rs +++ b/src/providers/antigravity/schema.rs @@ -167,7 +167,26 @@ impl ToolSet { if !tool.root { codec_paths(&tool.codec, raw_args, "", &mut paths)?; } - let args = replay::decode_arguments(raw_args, &json!(paths), &root)?; + let mut args = replay::decode_arguments(raw_args, &json!(paths), &root)?; + if !tool.root && !paths.is_empty() && !tool.validator.is_valid(&args) { + if tool.validator.is_valid(raw_args) { + paths.clear(); + args = raw_args.clone(); + } else { + for i in (0..paths.len()).rev() { + let mut candidate = paths.clone(); + candidate.remove(i); + if let Ok(decoded) = + replay::decode_arguments(raw_args, &json!(candidate), &root) + && tool.validator.is_valid(&decoded) + { + paths = candidate; + args = decoded; + break; + } + } + } + } if (tool.empty && args != json!({})) || !tool.validator.is_valid(&args) { return Err("upstream tool arguments violate original schema"); } @@ -676,9 +695,21 @@ fn identity(codec: &Codec) -> bool { } } +fn can_decode_json_leaf(value: &Value) -> bool { + value + .as_str() + .and_then(|raw| replay::parse_json(raw.as_bytes(), replay::MAX_ARGUMENT).ok()) + .and_then(|parsed| replay::canonical(&parsed).ok()) + .is_some() +} + fn codec_paths(codec: &Codec, args: &Value, path: &str, paths: &mut Vec) -> Result<()> { match codec { - Codec::Json => paths.push(path.to_string()), + Codec::Json => { + if can_decode_json_leaf(args) { + paths.push(path.to_string()); + } + } Codec::Object(children) => { if let Some(object) = args.as_object() { for (name, child) in children { diff --git a/src/providers/antigravity_tests.rs b/src/providers/antigravity_tests.rs index 8995c356..5361d582 100644 --- a/src/providers/antigravity_tests.rs +++ b/src/providers/antigravity_tests.rs @@ -2952,3 +2952,48 @@ fn antigravity_canonicalizes_client_normalized_tool_inputs_and_accepts_global_ca .is_err() ); } + +#[test] +fn antigravity_task_update_string_anyof_leaf_accepts_raw_or_json_encoded_string() { + let set = ToolSet::compile(&json!([{ + "name": "TaskUpdate", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "taskId": {"type": "string"}, + "status": { + "description": "New status for the task", + "anyOf": [ + {"type": "string", "enum": ["pending", "in_progress", "completed"]}, + {"type": "string", "const": "deleted"} + ] + }, + "metadata": { + "type": "object", + "propertyNames": {"type": "string"}, + "additionalProperties": {} + } + }, + "required": ["taskId"], + "additionalProperties": false + } + }])) + .unwrap(); + let direct = json!([{"role": "model", "parts": [{"functionCall": {"name": "TaskUpdate", "args": {"taskId": "1", "status": "in_progress", "metadata": "{\"step\":1}"}}, "thoughtSignature": "sig"}]}]); + let mapping = set.mapping(&direct).unwrap(); + assert_eq!(mapping[0]["codec_paths"], json!(["/metadata"])); + let blocks = encode_default(&direct, &binding(), "full", &mapping).unwrap(); + assert_eq!( + blocks[1]["input"], + json!({"taskId": "1", "status": "in_progress", "metadata": {"step": 1}}) + ); + let encoded_str = json!([{"role": "model", "parts": [{"functionCall": {"name": "TaskUpdate", "args": {"taskId": "1", "status": "\"deleted\""}}, "thoughtSignature": "sig"}]}]); + let mapping2 = set.mapping(&encoded_str).unwrap(); + assert_eq!(mapping2[0]["codec_paths"], json!(["/status"])); + let blocks2 = encode_default(&encoded_str, &binding(), "full", &mapping2).unwrap(); + assert_eq!( + blocks2[1]["input"], + json!({"taskId": "1", "status": "deleted"}) + ); +} From 135096c3f7686414d4f265737acf41d9d14d0b58 Mon Sep 17 00:00:00 2001 From: Chris Liu <11517021+chris7iu@users.noreply.github.com> Date: Wed, 11 Nov 2026 05:15:02 +0000 Subject: [PATCH 26/30] feat(antigravity): add Gemini 4 Carbon low/medium/high tier models --- src/providers/antigravity/request.rs | 28 +++++++++++++++++++++------- tests/antigravity_schema_parity.rs | 3 +++ tests/server.rs | 2 +- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/providers/antigravity/request.rs b/src/providers/antigravity/request.rs index 02471ba6..672d2153 100644 --- a/src/providers/antigravity/request.rs +++ b/src/providers/antigravity/request.rs @@ -8,6 +8,9 @@ use std::borrow::Cow; use std::collections::{BTreeMap, HashSet}; pub const MODELS: &[&str] = &[ + "antigravity/gemini-4-carbon-low", + "antigravity/gemini-4-carbon-medium", + "antigravity/gemini-4-carbon-high", "antigravity/gemini-4-argon-low", "antigravity/gemini-4-argon-medium", "antigravity/gemini-4-argon-high", @@ -18,11 +21,15 @@ pub const MODELS: &[&str] = &[ pub(crate) fn effective_tier(model: &str) -> Option<&'static str> { match model { - "antigravity/gemini-4-argon-low" | "antigravity/claude-opus-5-5-low" => Some("low"), - "antigravity/gemini-4-argon-medium" | "antigravity/claude-opus-5-5-medium" => { - Some("medium") - } - "antigravity/gemini-4-argon-high" | "antigravity/claude-opus-5-5-high" => Some("high"), + "antigravity/gemini-4-carbon-low" + | "antigravity/gemini-4-argon-low" + | "antigravity/claude-opus-5-5-low" => Some("low"), + "antigravity/gemini-4-carbon-medium" + | "antigravity/gemini-4-argon-medium" + | "antigravity/claude-opus-5-5-medium" => Some("medium"), + "antigravity/gemini-4-carbon-high" + | "antigravity/gemini-4-argon-high" + | "antigravity/claude-opus-5-5-high" => Some("high"), _ => None, } } @@ -120,6 +127,9 @@ impl Prepared { // LOW is runtime-observed. MEDIUM/HIGH enum values are compiled public // ModelSource facts, not a claim that all tiers were runtime-observed. let model_enum = match self.wire["model"].as_str() { + Some("gemini-4-carbon-low") => "MODEL_PLACEHOLDER_M438", + Some("gemini-4-carbon-medium") => "MODEL_PLACEHOLDER_M437", + Some("gemini-4-carbon-high") => "MODEL_PLACEHOLDER_M427", Some("gemini-4-argon-low") => "MODEL_PLACEHOLDER_M419", Some("gemini-4-argon-medium") => "MODEL_PLACEHOLDER_M418", Some("gemini-4-argon-high") => "MODEL_PLACEHOLDER_M417", @@ -719,8 +729,12 @@ pub(crate) fn prepare(body: &MessagesRequest, binding: &Binding) -> Result "LOW", - "antigravity/gemini-4-argon-medium" | "antigravity/claude-opus-5-5-medium" => "MEDIUM", + "antigravity/gemini-4-carbon-low" + | "antigravity/gemini-4-argon-low" + | "antigravity/claude-opus-5-5-low" => "LOW", + "antigravity/gemini-4-carbon-medium" + | "antigravity/gemini-4-argon-medium" + | "antigravity/claude-opus-5-5-medium" => "MEDIUM", _ => "HIGH", }; let cap = body.max_tokens.unwrap_or(65536); diff --git a/tests/antigravity_schema_parity.rs b/tests/antigravity_schema_parity.rs index aee85012..0eeb7929 100644 --- a/tests/antigravity_schema_parity.rs +++ b/tests/antigravity_schema_parity.rs @@ -1550,6 +1550,9 @@ async fn source_all_tier_metadata(f: &mut Fixture) -> Result<()> { let result = async { for stream in [false, true] { for (model, enumeration, level) in [ + ("gemini-4-carbon-low", "MODEL_PLACEHOLDER_M438", "LOW"), + ("gemini-4-carbon-medium", "MODEL_PLACEHOLDER_M437", "MEDIUM"), + ("gemini-4-carbon-high", "MODEL_PLACEHOLDER_M427", "HIGH"), ("gemini-4-argon-low", "MODEL_PLACEHOLDER_M419", "LOW"), ("gemini-4-argon-medium", "MODEL_PLACEHOLDER_M418", "MEDIUM"), ("gemini-4-argon-high", "MODEL_PLACEHOLDER_M417", "HIGH"), diff --git a/tests/server.rs b/tests/server.rs index a5bbf547..e4e3b853 100644 --- a/tests/server.rs +++ b/tests/server.rs @@ -2245,7 +2245,7 @@ mod antigravity_entry_tests { .unwrap(); assert_eq!( line, - "antigravity: antigravity/claude-opus-5-5-high, antigravity/claude-opus-5-5-low, antigravity/claude-opus-5-5-medium, antigravity/gemini-4-argon-high, antigravity/gemini-4-argon-low, antigravity/gemini-4-argon-medium" + "antigravity: antigravity/claude-opus-5-5-high, antigravity/claude-opus-5-5-low, antigravity/claude-opus-5-5-medium, antigravity/gemini-4-argon-high, antigravity/gemini-4-argon-low, antigravity/gemini-4-argon-medium, antigravity/gemini-4-carbon-high, antigravity/gemini-4-carbon-low, antigravity/gemini-4-carbon-medium" ); assert!( !run("0", &["codex", "auth", "status", "--json"]) From 778dde0481900fbd61f9559d286569d3b6dc824d Mon Sep 17 00:00:00 2001 From: Chris Liu <11517021+chris7iu@users.noreply.github.com> Date: Wed, 11 Nov 2026 07:15:34 +0000 Subject: [PATCH 27/30] refactor(antigravity): project tool input_schema natively like agy paramsToSchema Replace Codec::Json string-wrapping, strict keyword/format whitelisting, and post-generation jsonschema validation rejection with agy-style native v1main.Schema projection: - Inline local #/... $ref definitions with cycle protection - Fold simple allOf object compositions and unwrap nullable Optional[T] / ["T", "null"] / string enum+const unions (e.g. TaskUpdate.status) - Alias tool names outside ^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$ via ccp_ for Vertex-Anthropic and Gemini parity - Delegate semantic argument validation to downstream callers via tool_result (is_error: true) and drop the jsonschema crate dependency --- Cargo.lock | 359 +------- Cargo.toml | 3 +- src/providers/antigravity/schema.rs | 1040 +++++++++++------------ src/providers/antigravity_http_tests.rs | 15 +- src/providers/antigravity_tests.rs | 726 ++++------------ tests/antigravity_schema_parity.rs | 320 ++++--- 6 files changed, 865 insertions(+), 1598 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e5b20114..3bcd43f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,20 +8,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "getrandom 0.3.4", - "once_cell", - "serde", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.4" @@ -116,7 +102,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn", ] [[package]] @@ -193,7 +179,7 @@ checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn", ] [[package]] @@ -238,12 +224,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "borrow-or-share" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" - [[package]] name = "bstr" version = "1.12.3" @@ -261,12 +241,6 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" -[[package]] -name = "bytecount" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" - [[package]] name = "byteorder" version = "1.5.0" @@ -347,7 +321,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn", ] [[package]] @@ -379,7 +353,6 @@ dependencies = [ "hyper-rustls", "hyper-util", "jiff", - "jsonschema", "libc", "once_cell", "predicates", @@ -517,7 +490,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.118", + "syn", ] [[package]] @@ -528,7 +501,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.118", + "syn", ] [[package]] @@ -556,7 +529,7 @@ dependencies = [ "defmt-parser", "proc-macro2", "quote", - "syn 2.0.118", + "syn", ] [[package]] @@ -598,7 +571,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn", ] [[package]] @@ -607,15 +580,6 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" -[[package]] -name = "email_address" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" -dependencies = [ - "serde", -] - [[package]] name = "encoding_rs" version = "0.8.35" @@ -652,17 +616,6 @@ dependencies = [ "regex-syntax", ] -[[package]] -name = "fancy-regex" -version = "0.19.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" -dependencies = [ - "bit-set", - "regex-automata", - "regex-syntax", -] - [[package]] name = "fastrand" version = "2.4.1" @@ -694,17 +647,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "fluent-uri" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" -dependencies = [ - "borrow-or-share", - "ref-cast", - "serde", -] - [[package]] name = "fnv" version = "1.0.7" @@ -717,12 +659,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -732,16 +668,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fraction" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500" -dependencies = [ - "num", - "num-bigint", -] - [[package]] name = "futures-channel" version = "0.3.32" @@ -772,7 +698,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn", ] [[package]] @@ -878,7 +804,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.1.5", + "foldhash", ] [[package]] @@ -886,11 +812,6 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] [[package]] name = "heck" @@ -1160,7 +1081,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn 2.0.118", + "syn", ] [[package]] @@ -1222,7 +1143,7 @@ checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn", ] [[package]] @@ -1236,60 +1157,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "jsonschema" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b320ad34c177093d21622edc9215bb608294c2333ff296672c0c8265a5ee60c" -dependencies = [ - "ahash", - "bytecount", - "data-encoding", - "email_address", - "fancy-regex 0.19.2", - "fraction", - "getrandom 0.3.4", - "idna", - "itoa", - "jsonschema-regex", - "jsonschema-value", - "num-cmp", - "num-traits", - "percent-encoding", - "referencing", - "regex", - "serde", - "serde_json", - "strum 0.28.0", - "unicode-general-category", - "uuid-simd", -] - -[[package]] -name = "jsonschema-regex" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1472f7be79026f79e01a9b172263311e829d7c210d57b39cb57037abc0ce0500" -dependencies = [ - "regex-syntax", -] - -[[package]] -name = "jsonschema-value" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7550a7a84787e715ead0b47e01fa8c6bb7c3413630f91a8f2094c06c2078a" -dependencies = [ - "ahash", - "bytecount", - "fraction", - "getrandom 0.3.4", - "num-cmp", - "num-traits", - "serde_json", - "zmij", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -1371,12 +1238,6 @@ version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" -[[package]] -name = "micromap" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" - [[package]] name = "mime" version = "0.3.17" @@ -1447,81 +1308,12 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "num" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" -dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", -] - -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-cmp" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" -[[package]] -name = "num-integer" -version = "0.1.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -1549,12 +1341,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "outref" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" - [[package]] name = "parking_lot" version = "0.12.5" @@ -1694,7 +1480,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn", ] [[package]] @@ -1847,7 +1633,7 @@ dependencies = [ "itertools 0.13.0", "lru", "paste", - "strum 0.26.3", + "strum", "unicode-segmentation", "unicode-truncate", "unicode-width 0.2.0", @@ -1862,43 +1648,6 @@ dependencies = [ "bitflags 2.13.0", ] -[[package]] -name = "ref-cast" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "referencing" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "473f4dcca28ead7ee0f8e5b5d0bbae2549f4fc4c958fb20af328961b6c174be3" -dependencies = [ - "ahash", - "fluent-uri", - "getrandom 0.3.4", - "hashbrown 0.17.1", - "itoa", - "micromap", - "parking_lot", - "percent-encoding", - "serde_json", -] - [[package]] name = "regex" version = "1.12.4" @@ -2149,7 +1898,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn", ] [[package]] @@ -2314,16 +2063,7 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros 0.26.4", -] - -[[package]] -name = "strum" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" -dependencies = [ - "strum_macros 0.28.0", + "strum_macros", ] [[package]] @@ -2336,19 +2076,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.118", -] - -[[package]] -name = "strum_macros" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.118", + "syn", ] [[package]] @@ -2368,17 +2096,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "syn" -version = "3.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "sync_wrapper" version = "1.0.2" @@ -2396,7 +2113,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn", ] [[package]] @@ -2444,7 +2161,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn", ] [[package]] @@ -2455,7 +2172,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn", ] [[package]] @@ -2476,7 +2193,7 @@ dependencies = [ "anyhow", "base64", "bstr", - "fancy-regex 0.17.0", + "fancy-regex", "lazy_static", "regex", "rustc-hash", @@ -2561,7 +2278,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn", ] [[package]] @@ -2669,7 +2386,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn", ] [[package]] @@ -2762,12 +2479,6 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" -[[package]] -name = "unicode-general-category" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" - [[package]] name = "unicode-ident" version = "1.0.24" @@ -2851,16 +2562,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "uuid-simd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" -dependencies = [ - "outref", - "vsimd", -] - [[package]] name = "valuable" version = "0.1.1" @@ -2873,12 +2574,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "vsimd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" - [[package]] name = "wait-timeout" version = "0.2.1" @@ -2954,7 +2649,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.118", + "syn", "wasm-bindgen-shared", ] @@ -3224,7 +2919,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn", "synstructure", ] @@ -3245,7 +2940,7 @@ checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn", ] [[package]] @@ -3265,7 +2960,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn", "synstructure", ] @@ -3305,7 +3000,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 85024b52..fb8151ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ publish = false [dependencies] clap = { version = "4.5", features = ["derive", "env"] } serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" +serde_json = { version = "1.0", features = ["float_roundtrip"] } anyhow = "1.0" thiserror = "2.0" axum = { version = "0.8", features = ["macros", "multipart"] } @@ -48,7 +48,6 @@ ratatui = "0.29" crossterm = "0.28" jiff = { version = "0.2", default-features = false, features = ["std", "tz-system", "tzdb-zoneinfo"] } tiktoken-rs = { version = "0.12", default-features = false } -jsonschema = { version = "=0.59.0", default-features = false, features = ["idna"] } [target.'cfg(unix)'.dependencies] libc = "=0.2.186" diff --git a/src/providers/antigravity/schema.rs b/src/providers/antigravity/schema.rs index 9ce184ab..70de6031 100644 --- a/src/providers/antigravity/schema.rs +++ b/src/providers/antigravity/schema.rs @@ -1,36 +1,25 @@ -//! Source validation and honest Gemini generation projection are separate layers. +//! Antigravity tool schema bridge modeled on `agy`'s `paramsToSchema` / +//! `convertMapToSchema` (`v1main.Schema` protobuf projection). +//! +//! External MCP and Claude Code tool `input_schema` definitions are projected +//! directly into `v1main.Schema` without stringifying fields into `Codec::Json` +//! wrappers or rejecting turns post-generation on local JSON Schema validation +//! mismatches. Downstream callers (Claude Code / MCP servers) validate tool +//! arguments and return `tool_result` (`is_error: true`) so the model can +//! self-correct on the next turn. use super::replay::{self, Result, keys, nonempty}; use serde_json::{Map, Value, json}; use sha2::{Digest, Sha256}; -use std::collections::{BTreeMap, HashSet}; +use std::collections::HashSet; const MAX_SCHEMA_BYTES: usize = 256 * 1024; const MAX_SCHEMA_NODES: usize = 8192; - -struct Offline; -impl jsonschema::Retrieve for Offline { - fn retrieve( - &self, - _: &jsonschema::Uri, - ) -> std::result::Result> { - Err("external schema retrieval disabled".into()) - } -} - -enum Codec { - Identity, - Json, - Object(BTreeMap), - Array(Box), -} +const MAX_SCHEMA_DEPTH: usize = 32; +const MAX_REF_DEPTH: usize = 16; struct Tool { caller: String, native: String, - validator: jsonschema::Validator, - codec: Codec, - root: bool, - empty: bool, } pub(crate) struct ToolSet { @@ -57,9 +46,19 @@ impl ToolSet { "input_schema", "cache_control", "defer_loading", + "type", + "strict", + "eager_input_streaming", + "input_examples", ], &["name", "input_schema"], )?; + if let Some(kind) = tool.get("type") + && !kind.is_null() + && kind.as_str() != Some("custom") + { + return Err("unsupported tool type"); + } super::request::cache_hint(tool.get("cache_control"))?; let caller = nonempty(&tool["name"])?; if caller == "DeferredToolPlaceholder" @@ -86,61 +85,34 @@ impl ToolSet { { return Err("schema byte limit"); } + let root_obj = schema.as_object().ok_or("schema must be object")?; + if let Some(root_type) = root_obj.get("type") + && !root_type_allows_object(root_type) + { + return Err("tool input_schema root must be an object"); + } let mut nodes = 0; - validate_source(schema, 0, &mut nodes)?; - let draft = match schema.get("$schema").and_then(Value::as_str) { - None | Some("https://json-schema.org/draft/2020-12/schema") => { - jsonschema::Draft::Draft202012 - } - Some( - "http://json-schema.org/draft-07/schema" - | "http://json-schema.org/draft-07/schema#", - ) => jsonschema::Draft::Draft7, - _ => return Err("unsupported schema dialect"), - }; - let validator = jsonschema::options() - .with_draft(draft) - .should_validate_formats(true) - .with_retriever(Offline) - .build(schema) - .map_err(|_| "invalid original tool schema")?; - let (projected, codec) = project(schema)?; - let empty = schema["type"] == "object" - && schema["additionalProperties"] == false - && schema - .get("properties") - .is_none_or(|v| v.as_object().is_some_and(|v| v.is_empty())); - let root = matches!(codec, Codec::Json); - let mut declaration = json!({"name":native}); - if let Some(description) = tool.get("description") { + validate_tree(schema, 0, &mut nodes)?; + let mut declaration = json!({"name": native}); + if let Some(description) = tool.get("description") + && !description.is_null() + { let text = description .as_str() .filter(|v| v.len() <= 32768) .ok_or("invalid tool description")?; - declaration["description"] = json!(text); - } - let compatible = if caller == native && identity(&codec) { - compatible_projection(schema) - } else { - None - }; - if let Some(parameters) = compatible { - declaration["parameters"] = parameters; - } else if !empty { - declaration["parameters"] = if root { - json!({"type":"OBJECT","properties":{"json":projected},"required":["json"],"description":"Encode the entire original caller argument object as JSON in the single json string field; no extra wrapper keys."}) - } else { - projected - }; + if !text.is_empty() { + declaration["description"] = json!(text); + } } + let mut visited = HashSet::new(); + let mut budget = ProjectionBudget::default(); + declaration["parameters"] = + project_schema(schema, schema, &mut visited, 0, true, &mut budget); declarations.push(declaration); compiled.push(Tool { caller: caller.to_string(), native, - validator, - codec, - root, - empty, }); } Ok(Self { @@ -157,41 +129,13 @@ impl ToolSet { .iter() .find(|t| call["name"] == t.native) .ok_or("upstream returned unknown tool")?; - let mut paths = Vec::new(); - let root = if tool.root { - json!("json") - } else { - Value::Null - }; let raw_args = replay::call_arguments(call)?; - if !tool.root { - codec_paths(&tool.codec, raw_args, "", &mut paths)?; - } - let mut args = replay::decode_arguments(raw_args, &json!(paths), &root)?; - if !tool.root && !paths.is_empty() && !tool.validator.is_valid(&args) { - if tool.validator.is_valid(raw_args) { - paths.clear(); - args = raw_args.clone(); - } else { - for i in (0..paths.len()).rev() { - let mut candidate = paths.clone(); - candidate.remove(i); - if let Ok(decoded) = - replay::decode_arguments(raw_args, &json!(candidate), &root) - && tool.validator.is_valid(&decoded) - { - paths = candidate; - args = decoded; - break; - } - } - } - } - if (tool.empty && args != json!({})) || !tool.validator.is_valid(&args) { - return Err("upstream tool arguments violate original schema"); - } - recipes - .push(json!({"caller_name":tool.caller,"codec_paths":paths,"root_wrapper":root})); + replay::decode_arguments(raw_args, &json!([]), &Value::Null)?; + recipes.push(json!({ + "caller_name": tool.caller, + "codec_paths": [], + "root_wrapper": Value::Null, + })); } replay::mapping(contents, Some(&json!(recipes))) } @@ -205,329 +149,49 @@ fn native_name(name: &str) -> bool { .is_some_and(|c| c.is_ascii_alphabetic() || c == b'_') && name .bytes() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'_' | b'-' | b'.')) + .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'_' | b'-')) } -fn required_undeclared(schema: &Value) -> bool { - let properties = schema.get("properties").and_then(Value::as_object); - schema - .get("required") - .and_then(Value::as_array) - .is_some_and(|required| { - required - .iter() - .filter_map(Value::as_str) - .any(|name| properties.is_none_or(|props| !props.contains_key(name))) - }) +fn root_type_allows_object(value: &Value) -> bool { + match value { + Value::String(kind) => kind.eq_ignore_ascii_case("object"), + Value::Array(kinds) => kinds + .iter() + .filter_map(Value::as_str) + .any(|k| k.eq_ignore_ascii_case("object")), + _ => false, + } } -fn validate_source(schema: &Value, depth: usize, nodes: &mut usize) -> Result<()> { +fn validate_tree(value: &Value, depth: usize, nodes: &mut usize) -> Result<()> { *nodes += 1; - if depth > 32 || *nodes > MAX_SCHEMA_NODES { + if depth > MAX_SCHEMA_DEPTH || *nodes > MAX_SCHEMA_NODES { return Err("schema depth/node limit"); } - if schema.is_boolean() { - return Ok(()); - } - let object = schema - .as_object() - .ok_or("schema must be object or boolean")?; - if schema["type"] == "object" - && schema["additionalProperties"] == false - && required_undeclared(schema) - { - return Err( - "unsatisfiable tool contract: required undeclared property is forbidden by additionalProperties:false", - ); - } - let allowed = [ - "$schema", - "type", - "properties", - "propertyNames", - "required", - "items", - "additionalProperties", - "allOf", - "anyOf", - "oneOf", - "default", - "description", - "title", - "enum", - "const", - "minimum", - "maximum", - "exclusiveMinimum", - "exclusiveMaximum", - "minItems", - "maxItems", - "minLength", - "maxLength", - "pattern", - "format", - "multipleOf", - "uniqueItems", - "minProperties", - "maxProperties", - ]; - if object.keys().any(|key| !allowed.contains(&key.as_str())) { - return Err("unsupported schema keyword or reference"); - } - if let Some(dialect) = object.get("$schema") { - if !matches!( - dialect.as_str(), - Some( - "https://json-schema.org/draft/2020-12/schema" - | "http://json-schema.org/draft-07/schema" - | "http://json-schema.org/draft-07/schema#" - ) - ) { - return Err("unsupported schema dialect"); - } - if depth != 0 { - return Err("nested dialect is unsupported"); - } - } - if let Some(format) = object.get("format") - && !matches!( - format.as_str(), - Some( - "date-time" - | "date" - | "time" - | "duration" - | "email" - | "idn-email" - | "hostname" - | "idn-hostname" - | "ipv4" - | "ipv6" - | "uri" - | "uri-reference" - | "uuid" - | "regex" - | "json-pointer" - | "relative-json-pointer" - ) - ) - { - return Err("unsupported schema format"); - } - if let Some(properties) = object.get("properties") { - for child in properties - .as_object() - .ok_or("invalid schema properties")? - .values() - { - validate_source(child, depth + 1, nodes)?; - } - } - for key in ["items", "additionalProperties", "propertyNames"] { - if let Some(child) = object.get(key) { - validate_source(child, depth + 1, nodes)?; - } - } - for key in ["allOf", "anyOf", "oneOf"] { - if let Some(branches) = object.get(key) { - for child in branches.as_array().ok_or("invalid schema branches")? { - validate_source(child, depth + 1, nodes)?; - } - } - } - Ok(()) -} - -fn json_codec(schema: &Value) -> Result<(Value, Codec)> { - // Complete deterministic JSON, not a lossy summary: the model must know the - // caller's names/types/requirements even when the native carrier is STRING. - let original = serde_json::to_string(schema).map_err(|_| "invalid original schema guidance")?; - let description = format!( - "JSON-string transport: encode the original JSON value exactly as a JSON string. The proxy decodes and validates this original schema before returning a tool call. Original JSON Schema: {original}" - ); - if description.len() > 16384 { - return Err("complete schema guidance exceeds the 16 KiB limit"); - } - Ok(( - json!({"type":"STRING","description":description}), - Codec::Json, - )) -} - -fn project(schema: &Value) -> Result<(Value, Codec)> { - let Some(kind) = schema.get("type").and_then(Value::as_str) else { - // Only a bare union (apart from dialect metadata) can return directly. - // Otherwise sibling constraints/annotations would bypass the normal - // projection path. Use the complete bounded JSON codec, not just anyOf. - if let Some(branches) = schema.get("anyOf").and_then(Value::as_array) - && schema.as_object().is_some_and(|fields| { - fields - .keys() - .all(|key| matches!(key.as_str(), "anyOf" | "$schema")) - }) - { - let projected = branches.iter().map(project).collect::>>()?; - if projected.iter().all(|(_, c)| identity(c)) { - return Ok(( - json!({"anyOf":projected.into_iter().map(|(s,_)|s).collect::>()}), - Codec::Identity, - )); + match value { + Value::Null | Value::Bool(_) | Value::String(_) => Ok(()), + Value::Number(n) => { + if n.as_f64().is_none_or(|f| !f.is_finite()) { + return Err("invalid schema number"); } + Ok(()) } - return json_codec(schema); - }; - if schema.get("oneOf").is_some() - || !matches!( - kind, - "object" | "array" | "string" | "integer" | "number" | "boolean" - ) - { - return json_codec(schema); - } - let mut out = Map::new(); - out.insert("type".into(), json!(kind.to_ascii_uppercase())); - let mut codec = Codec::Identity; - if kind == "object" { - let props = schema.get("properties").and_then(Value::as_object); - if required_undeclared(schema) { - if schema["additionalProperties"] == false { - return Err( - "unsatisfiable tool contract: required undeclared property is forbidden by additionalProperties:false", - ); + Value::Array(items) => { + for item in items { + validate_tree(item, depth + 1, nodes)?; } - return json_codec(schema); - } - if props.is_none_or(|p| p.is_empty()) && schema["additionalProperties"] != false { - return json_codec(schema); - } - if schema - .get("additionalProperties") - .is_some_and(Value::is_object) - { - return json_codec(schema); + Ok(()) } - let mut projected = Map::new(); - let mut children = BTreeMap::new(); - for (name, child) in props.into_iter().flatten() { - let (schema, child_codec) = project(child)?; - projected.insert(name.clone(), schema); - children.insert(name.clone(), child_codec); - } - out.insert("properties".into(), Value::Object(projected)); - codec = Codec::Object(children); - if let Some(required) = schema.get("required") { - out.insert("required".into(), required.clone()); - } - } else if kind == "array" { - let (item, item_codec) = project(schema.get("items").unwrap_or(&Value::Bool(true)))?; - out.insert("items".into(), item); - codec = Codec::Array(Box::new(item_codec)); - } - for key in [ - "description", - "title", - "minimum", - "maximum", - "minItems", - "maxItems", - "minLength", - "maxLength", - "pattern", - ] { - if let Some(value) = schema.get(key) { - out.insert(key.to_string(), value.clone()); - } - } - if let Some(values) = schema - .get("enum") - .and_then(Value::as_array) - .filter(|v| v.iter().all(Value::is_string)) - { - out.insert("enum".into(), json!(values)); - } - for (exclusive, inclusive, lower) in [ - ("exclusiveMinimum", "minimum", true), - ("exclusiveMaximum", "maximum", false), - ] { - if kind == "integer" - && let Some(value) = schema.get(exclusive).and_then(Value::as_f64) - { - let bound = if lower { - value.floor() + 1.0 - } else { - value.ceil() - 1.0 - }; - if bound.abs() <= 9007199254740991.0 { - let existing = out.get(inclusive).and_then(Value::as_f64); - let bound = existing.map_or(bound, |old| { - if lower { - old.ceil().max(bound) - } else { - old.floor().min(bound) - } - }); - if bound.abs() <= 9007199254740991.0 { - out.insert(inclusive.to_string(), json!(bound as i64)); - } + Value::Object(fields) => { + for child in fields.values() { + validate_tree(child, depth + 1, nodes)?; } + Ok(()) } } - if let Some(branches) = schema.get("anyOf").and_then(Value::as_array) { - let projected = branches.iter().map(project).collect::>>()?; - if !projected.iter().all(|(_, c)| identity(c)) { - return json_codec(schema); - } - out.insert( - "anyOf".into(), - json!(projected.into_iter().map(|(v, _)| v).collect::>()), - ); - } - let local = [ - "additionalProperties", - "propertyNames", - "allOf", - "format", - "multipleOf", - "uniqueItems", - "minProperties", - "maxProperties", - "exclusiveMinimum", - "exclusiveMaximum", - "const", - ]; - let mut constraints: Map = local - .iter() - .filter_map(|k| schema.get(*k).map(|v| ((*k).to_string(), v.clone()))) - .collect(); - if let Some(values) = schema - .get("enum") - .and_then(Value::as_array) - .filter(|values| values.iter().any(|v| !v.is_string())) - { - constraints.insert("enum".to_string(), json!(values)); - } - if !constraints.is_empty() { - let hint = format!( - "Original schema constraints are validated locally after JSON decoding; invalid calls are rejected, never repaired. Constraints: {}", - Value::Object(constraints) - ); - let prior = out.get("description").and_then(Value::as_str).unwrap_or(""); - let description = format!("{prior}\n{hint}"); - if description.len() > 16384 { - return Err("complete schema guidance exceeds the 16 KiB limit"); - } - out.insert("description".into(), json!(description)); - } - Ok((Value::Object(out), codec)) -} - -fn compatible_projection(schema: &Value) -> Option { - // Check the whole tool before copying defaults or selecting any child. - (ProjectionBudget::default().visit(schema, 0) && compatible_schema(schema)) - .then(|| typed_projection(schema)) } -#[derive(Default)] +#[derive(Clone, Default)] struct ProjectionBudget { nodes: usize, strings: usize, @@ -547,7 +211,7 @@ impl ProjectionBudget { fn visit(&mut self, value: &Value, depth: usize) -> bool { self.nodes += 1; - if depth > 32 || self.nodes > 4096 { + if depth > MAX_SCHEMA_DEPTH || self.nodes > 4096 { return false; } match value { @@ -586,155 +250,469 @@ fn length_value(value: &Value) -> Option { .map(|value| value as u64) } -fn compatible_schema(schema: &Value) -> bool { - let Some(fields) = schema.as_object() else { - return false; - }; - let Some(kind) = schema.get("type").and_then(Value::as_str) else { - return false; - }; - fields - .iter() - .all(|(key, value)| match (kind, key.as_str()) { - (_, "type") => matches!( - kind, - "object" | "array" | "string" | "integer" | "number" | "boolean" - ), - (_, "description" | "title" | "$schema") => value.is_string(), - (_, "default") => true, - ("object", "properties") => value - .as_object() - .is_some_and(|properties| properties.values().all(compatible_schema)), - ("object", "required") => value.as_array().is_some_and(|required| { - required.iter().all(|name| { - name.as_str().is_some_and(|name| { - schema - .get("properties") - .and_then(Value::as_object) - .is_some_and(|properties| properties.contains_key(name)) - }) - }) - }), - ("object", "additionalProperties") => value.is_boolean(), - ("array", "items") => value.is_object() && compatible_schema(value), - ("array", "minItems" | "maxItems") | ("string", "minLength" | "maxLength") => { - length_value(value).is_some() - } - ("string", "enum") => value - .as_array() - .is_some_and(|values| values.iter().all(Value::is_string)), - ("string", "pattern" | "format") => value.is_string(), - ("integer" | "number", "minimum" | "maximum") - | ("integer", "exclusiveMinimum" | "exclusiveMaximum") => number_value(value).is_some(), - _ => false, - }) +fn genai_type(kind: &str) -> Option<&'static str> { + if kind.eq_ignore_ascii_case("string") { + Some("STRING") + } else if kind.eq_ignore_ascii_case("number") { + Some("NUMBER") + } else if kind.eq_ignore_ascii_case("integer") { + Some("INTEGER") + } else if kind.eq_ignore_ascii_case("boolean") { + Some("BOOLEAN") + } else if kind.eq_ignore_ascii_case("array") { + Some("ARRAY") + } else if kind.eq_ignore_ascii_case("object") { + Some("OBJECT") + } else if kind.eq_ignore_ascii_case("null") { + Some("NULL") + } else { + None + } } -fn typed_projection(schema: &Value) -> Value { - let mut out = Map::new(); - out.insert( - "type".into(), - json!(schema["type"].as_str().unwrap().to_ascii_uppercase()), - ); - for key in ["description", "format", "pattern"] { - if let Some(text) = schema - .get(key) +fn resolve_local_refs( + map: &Map, + root: &Value, + visited: &mut HashSet, +) -> (Map, Vec) { + let mut current = map.clone(); + let mut added = Vec::new(); + for _ in 0..MAX_REF_DEPTH { + let Some(pointer) = current + .get("$ref") .and_then(Value::as_str) - .filter(|v| !v.is_empty()) + .map(str::to_owned) + else { + break; + }; + current.remove("$ref"); + if !pointer.starts_with("#/") + || visited.len() >= MAX_REF_DEPTH + || !visited.insert(pointer.clone()) { - out.insert(key.into(), json!(text)); + break; } + added.push(pointer.clone()); + let Some(target) = root.pointer(&pointer[1..]).and_then(Value::as_object) else { + break; + }; + let mut merged = target.clone(); + for (k, v) in current { + merged.insert(k, v); + } + current = merged; } - if let Some(properties) = schema.get("properties").and_then(Value::as_object) - && !properties.is_empty() + (current, added) +} + +fn is_null_branch(branch: &Map) -> bool { + branch + .get("type") + .and_then(Value::as_str) + .is_some_and(|s| s.eq_ignore_ascii_case("null")) + || branch.get("const") == Some(&Value::Null) +} + +fn branch_string_values(branch: &Map) -> Option> { + if let Some(kind) = branch.get("type").and_then(Value::as_str) + && !kind.eq_ignore_ascii_case("string") { - out.insert( - "properties".into(), - Value::Object( - properties - .iter() - .map(|(name, child)| (name.clone(), typed_projection(child))) - .collect(), - ), - ); + return None; + } + if let Some(c) = branch.get("const").and_then(Value::as_str) { + return Some(vec![c.to_owned()]); + } + let values = branch.get("enum")?.as_array()?; + if values.is_empty() || !values.iter().all(Value::is_string) { + return None; + } + Some( + values + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(), + ) +} + +fn branch_genai_type(branch: &Map) -> Option<&'static str> { + if let Some(kind) = branch.get("type").and_then(Value::as_str) { + return genai_type(kind); + } + if let Some(c) = branch.get("const") { + return match c { + Value::String(_) => Some("STRING"), + Value::Bool(_) => Some("BOOLEAN"), + Value::Number(n) => Some(if n.is_i64() || n.is_u64() { + "INTEGER" + } else { + "NUMBER" + }), + Value::Null => Some("NULL"), + _ => None, + }; } - if let Some(items) = schema.get("items") { - out.insert("items".into(), typed_projection(items)); + if branch.get("properties").is_some_and(Value::is_object) { + return Some("OBJECT"); } - for key in ["required", "enum"] { - if let Some(values) = schema - .get(key) - .and_then(Value::as_array) - .filter(|v| !v.is_empty()) - { - out.insert(key.into(), json!(values)); - } + if branch.get("items").is_some_and(Value::is_object) { + return Some("ARRAY"); + } + None +} + +fn allowed_format(kind: &str, format: &str) -> bool { + match kind { + "STRING" => matches!( + format, + "enum" + | "date-time" + | "date" + | "time" + | "duration" + | "email" + | "idn-email" + | "hostname" + | "idn-hostname" + | "ipv4" + | "ipv6" + | "uuid" + | "uri" + | "uri-reference" + | "regex" + | "json-pointer" + | "relative-json-pointer" + ), + "INTEGER" => matches!(format, "int32" | "int64"), + "NUMBER" => matches!(format, "float" | "double"), + _ => false, } - for key in ["minimum", "maximum"] { - if let Some(value) = schema.get(key).and_then(number_value).filter(|v| *v != 0.0) { - out.insert(key.into(), json!(value)); +} + +fn project_schema( + schema: &Value, + root: &Value, + visited: &mut HashSet, + depth: usize, + is_root: bool, + budget: &mut ProjectionBudget, +) -> Value { + if depth > MAX_SCHEMA_DEPTH { + return if is_root { + json!({"type": "OBJECT"}) + } else { + json!({}) + }; + } + let Some(raw_map) = schema.as_object() else { + return if is_root { + json!({"type": "OBJECT"}) + } else { + json!({}) + }; + }; + let (mut map, added_refs) = resolve_local_refs(raw_map, root, visited); + + // Fold simple `allOf` object compositions (common in OpenAPI / MCP). + if let Some(all_of) = map.get("allOf").and_then(Value::as_array).cloned() { + for item in &all_of { + let Some(branch_raw) = item.as_object() else { + continue; + }; + let (branch, branch_refs) = resolve_local_refs(branch_raw, root, visited); + for r in branch_refs { + visited.remove(&r); + } + for key in ["type", "description", "items"] { + if !map.contains_key(key) + && let Some(val) = branch.get(key) + { + map.insert(key.to_owned(), val.clone()); + } + } + if let Some(branch_props) = branch.get("properties").and_then(Value::as_object) { + let target = map + .entry("properties") + .or_insert_with(|| Value::Object(Map::new())); + if let Some(target_props) = target.as_object_mut() { + for (k, v) in branch_props { + target_props.entry(k.clone()).or_insert_with(|| v.clone()); + } + } + } + if let Some(branch_req) = branch.get("required").and_then(Value::as_array) { + let target = map + .entry("required") + .or_insert_with(|| Value::Array(Vec::new())); + if let Some(target_req) = target.as_array_mut() { + for req in branch_req { + if req.is_string() && !target_req.contains(req) { + target_req.push(req.clone()); + } + } + } + } } } - for key in ["minLength", "maxLength", "minItems", "maxItems"] { - if let Some(value) = schema.get(key).and_then(length_value).filter(|v| *v != 0) { - out.insert(key.into(), json!(value.to_string())); + + let mut nullable = map.get("nullable") == Some(&Value::Bool(true)); + let mut inferred_union_type: Option<&'static str> = None; + + let union_branches = map + .get("anyOf") + .or_else(|| map.get("oneOf")) + .and_then(Value::as_array) + .cloned(); + if let Some(branches) = union_branches { + let mut null_count = 0usize; + let mut non_null = Vec::new(); + let mut saw_non_object = false; + for item in &branches { + if let Some(branch_raw) = item.as_object() { + let (resolved, branch_refs) = resolve_local_refs(branch_raw, root, visited); + for r in branch_refs { + visited.remove(&r); + } + if is_null_branch(&resolved) { + null_count += 1; + } else { + non_null.push(resolved); + } + } else { + saw_non_object = true; + } + } + if null_count > 0 { + nullable = true; + } + if !saw_non_object { + if non_null.len() == 1 && null_count > 0 { + // Unwrap nullable `Optional[T]` (`anyOf: [T, {"type": "null"}]`). + for (k, v) in &non_null[0] { + if !map.contains_key(k) { + map.insert(k.clone(), v.clone()); + } + } + } else if !non_null.is_empty() + && non_null.iter().all(|b| branch_string_values(b).is_some()) + { + // Flatten string enum/const unions (e.g., `TaskUpdate.status`). + let mut combined = Vec::new(); + for b in &non_null { + for s in branch_string_values(b).unwrap() { + if !combined.contains(&s) { + combined.push(s); + } + } + } + if !map.contains_key("type") { + map.insert("type".into(), json!("string")); + } + if !map.contains_key("enum") && !combined.is_empty() { + map.insert("enum".into(), json!(combined)); + } + } else if !non_null.is_empty() && !map.contains_key("type") { + let types: Option> = + non_null.iter().map(branch_genai_type).collect(); + if let Some(types) = types { + let first = types[0]; + if types.iter().all(|t| *t == first) { + inferred_union_type = Some(first); + } else if types.iter().all(|t| matches!(*t, "INTEGER" | "NUMBER")) { + inferred_union_type = Some("NUMBER"); + } + } + } } } - if let Some(value) = schema.get("default") { - out.insert("default".into(), value.clone()); + + let mut kind: Option<&'static str> = match map.get("type") { + Some(Value::String(s)) => genai_type(s), + Some(Value::Array(arr)) => { + let mut non_null_types: Vec<&'static str> = Vec::new(); + let mut saw_null = false; + let mut valid = !arr.is_empty(); + for entry in arr { + let Some(s) = entry.as_str().and_then(genai_type) else { + valid = false; + break; + }; + if s == "NULL" { + saw_null = true; + } else if !non_null_types.contains(&s) { + non_null_types.push(s); + } + } + if !valid { + None + } else { + if saw_null { + nullable = true; + } + match non_null_types.as_slice() { + [single] => Some(*single), + [a, b] if matches!((*a, *b), ("INTEGER", "NUMBER") | ("NUMBER", "INTEGER")) => { + Some("NUMBER") + } + [] if saw_null => Some("NULL"), + _ => None, + } + } + } + _ => inferred_union_type, + }; + + if let Some(c) = map.get("const") { + match c { + Value::String(s) => { + if kind.is_none() { + kind = Some("STRING"); + } + if kind == Some("STRING") && !map.contains_key("enum") { + map.insert("enum".into(), json!([s])); + } + } + Value::Bool(_) if kind.is_none() => kind = Some("BOOLEAN"), + Value::Number(n) if kind.is_none() => { + kind = Some(if n.is_i64() || n.is_u64() { + "INTEGER" + } else { + "NUMBER" + }); + } + Value::Null if kind.is_none() => kind = Some("NULL"), + _ => {} + } } - Value::Object(out) -} -fn identity(codec: &Codec) -> bool { - match codec { - Codec::Identity => true, - Codec::Json => false, - Codec::Object(c) => c.values().all(identity), - Codec::Array(c) => identity(c), + if kind.is_none() { + if map.get("properties").is_some_and(Value::is_object) { + kind = Some("OBJECT"); + } else if map.get("items").is_some_and(Value::is_object) { + kind = Some("ARRAY"); + } + } + if is_root && kind.is_none() { + kind = Some("OBJECT"); } -} -fn can_decode_json_leaf(value: &Value) -> bool { - value - .as_str() - .and_then(|raw| replay::parse_json(raw.as_bytes(), replay::MAX_ARGUMENT).ok()) - .and_then(|parsed| replay::canonical(&parsed).ok()) - .is_some() -} + let mut out = Map::new(); + if let Some(t) = kind { + out.insert("type".into(), json!(t)); + } + if nullable && !is_root && !matches!(kind, None | Some("NULL")) { + out.insert("nullable".into(), json!(true)); + } + if let Some(desc) = map + .get("description") + .and_then(Value::as_str) + .filter(|v| !v.is_empty()) + { + out.insert("description".into(), json!(desc)); + } -fn codec_paths(codec: &Codec, args: &Value, path: &str, paths: &mut Vec) -> Result<()> { - match codec { - Codec::Json => { - if can_decode_json_leaf(args) { - paths.push(path.to_string()); + match kind { + Some("STRING") => { + if let Some(fmt) = map + .get("format") + .and_then(Value::as_str) + .filter(|f| allowed_format("STRING", f)) + { + out.insert("format".into(), json!(fmt)); + } + if let Some(pat) = map + .get("pattern") + .and_then(Value::as_str) + .filter(|v| !v.is_empty()) + { + out.insert("pattern".into(), json!(pat)); + } + if let Some(values) = map.get("enum").and_then(Value::as_array) { + let strings: Vec<&str> = values.iter().filter_map(Value::as_str).collect(); + if !strings.is_empty() { + out.insert("enum".into(), json!(strings)); + } + } + for key in ["minLength", "maxLength"] { + if let Some(value) = map.get(key).and_then(length_value).filter(|v| *v != 0) { + out.insert(key.into(), json!(value.to_string())); + } } } - Codec::Object(children) => { - if let Some(object) = args.as_object() { - for (name, child) in children { - if let Some(value) = object.get(name) { - codec_paths( - child, - value, - &format!("{path}/{}", name.replace('~', "~0").replace('/', "~1")), - paths, - )?; + Some("INTEGER" | "NUMBER") => { + let t = kind.unwrap(); + if let Some(fmt) = map + .get("format") + .and_then(Value::as_str) + .filter(|f| allowed_format(t, f)) + { + out.insert("format".into(), json!(fmt)); + } + for key in ["minimum", "maximum"] { + if let Some(value) = map.get(key).and_then(number_value).filter(|v| *v != 0.0) { + out.insert(key.into(), json!(value)); + } + } + } + Some("OBJECT") => { + if let Some(properties) = map.get("properties").and_then(Value::as_object) + && !properties.is_empty() + { + let projected_props: Map = properties + .iter() + .map(|(name, child)| { + ( + name.clone(), + project_schema(child, root, visited, depth + 1, false, budget), + ) + }) + .collect(); + out.insert("properties".into(), Value::Object(projected_props)); + if let Some(required) = map.get("required").and_then(Value::as_array) { + let mut req_names: Vec<&str> = Vec::new(); + for entry in required { + if let Some(name) = entry.as_str() + && properties.contains_key(name) + && !req_names.contains(&name) + { + req_names.push(name); + } + } + if !req_names.is_empty() { + out.insert("required".into(), json!(req_names)); } } } } - Codec::Array(child) => { - if let Some(array) = args.as_array() { - for (i, value) in array.iter().enumerate() { - codec_paths(child, value, &format!("{path}/{i}"), paths)?; + Some("ARRAY") => { + if let Some(items) = map.get("items") + && items.is_object() + { + out.insert( + "items".into(), + project_schema(items, root, visited, depth + 1, false, budget), + ); + } + for key in ["minItems", "maxItems"] { + if let Some(value) = map.get(key).and_then(length_value).filter(|v| *v != 0) { + out.insert(key.into(), json!(value.to_string())); } } } - Codec::Identity => {} + _ => {} } - if paths.len() > 64 { - return Err("codec path count limit"); + + if let Some(default_val) = map.get("default") { + let mut candidate = budget.clone(); + if candidate.visit(default_val, 0) { + *budget = candidate; + out.insert("default".into(), default_val.clone()); + } + } + + for r in added_refs { + visited.remove(&r); } - Ok(()) + + Value::Object(out) } diff --git a/src/providers/antigravity_http_tests.rs b/src/providers/antigravity_http_tests.rs index 6e8ee86c..5777ca36 100644 --- a/src/providers/antigravity_http_tests.rs +++ b/src/providers/antigravity_http_tests.rs @@ -388,10 +388,19 @@ async fn antigravity_property_names_http_preflight_never_refreshes_or_generates( 0, "cap validation/counting must not refresh or generate with expired credentials" ); + body["tools"][0]["input_schema"]["$defs"] = json!({"name":{"type":"string"}}); + body["tools"][0]["input_schema"]["properties"]["metadata"]["propertyNames"] = + json!({"$ref":"#/$defs/name"}); + let response = client() + .post(proxy.url.join("/v1/messages/count_tokens").unwrap()) + .json(&body) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); for stream in [false, true] { body["stream"] = json!(stream); - body["tools"][0]["input_schema"]["properties"]["metadata"]["propertyNames"] = - json!({"$ref":"#/$defs/name"}); + body["tools"][0]["input_schema"] = json!({"type":"string"}); for path in ["/v1/messages", "/v1/messages/count_tokens"] { let response = client() .post(proxy.url.join(path).unwrap()) @@ -404,7 +413,7 @@ async fn antigravity_property_names_http_preflight_never_refreshes_or_generates( assert_eq!( response.json::().await.unwrap(), json!({"type":"error","error":{ - "type":"invalid_request_error","message":"unsupported schema keyword or reference" + "type":"invalid_request_error","message":"tool input_schema root must be an object" }}) ); assert_eq!( diff --git a/src/providers/antigravity_tests.rs b/src/providers/antigravity_tests.rs index 5361d582..b158d801 100644 --- a/src/providers/antigravity_tests.rs +++ b/src/providers/antigravity_tests.rs @@ -1282,7 +1282,7 @@ fn antigravity_absent_native_args_project_empty_without_changing_replay() { reducer.push(format!("data: {}\n\n", json!({"response":{"candidates":[{"content":contents[0],"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2}}})).as_bytes()).unwrap(); assert_eq!(reducer.finish().unwrap().contents, contents); let required = ToolSet::compile(&json!([{"name":"empty","input_schema":{"type":"object","properties":{"x":{"type":"string"}},"required":["x"]}}])).unwrap(); - assert!(required.mapping(&contents).is_err()); + assert!(required.mapping(&contents).is_ok()); for args in [Value::Null, json!([]), json!(""), json!(1)] { let mut invalid = contents.clone(); invalid[0]["parts"][0]["functionCall"]["args"] = args; @@ -1296,205 +1296,93 @@ fn antigravity_absent_native_args_project_empty_without_changing_replay() { } #[test] -fn antigravity_json_fallback_preserves_complete_bounded_schema_guidance() { - for schema in [ - json!({"type":"object","properties":{"known":{"type":"string"}},"required":["mandatory"],"additionalProperties":{"type":"integer","enum":[17,29]}}), - json!({"type":"object","properties":{"value":{"type":"string","pattern":"^prefix"}},"oneOf":[{"required":["value"]},{"required":["other"]}]}), - ] { - let set = ToolSet::compile(&json!([{"name":"wrapped","input_schema":schema}])).unwrap(); - let description = set.declarations[0]["parameters"]["properties"]["json"]["description"] - .as_str() - .unwrap(); - assert!( - description.contains(&serde_json::to_string(&schema).unwrap()), - "original constraints missing from fallback: {description}" - ); - } - let source = json!({"type":"object","additionalProperties":{"type":"string"},"description":"x".repeat(20000)}); - assert!( - ToolSet::compile(&json!([{"name":"oversized","input_schema":source}])).is_err(), - "oversized faithful guidance must reject before inference" +fn antigravity_agy_projection_inlines_local_refs_and_handles_cycles() { + let schema = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "Filter": { + "type": "object", + "properties": { + "field": {"type": "string"}, + "children": {"type": "array", "items": {"$ref": "#/$defs/Filter"}} + }, + "required": ["field"] + } + }, + "type": "object", + "properties": { + "primary": {"$ref": "#/$defs/Filter"}, + "secondary": { + "description": "Optional secondary filter", + "anyOf": [{"$ref": "#/$defs/Filter"}, {"type": "null"}] + } + }, + "required": ["primary", "undeclared_ignored"] + }); + let set = ToolSet::compile(&json!([{"name": "query", "input_schema": schema}])).unwrap(); + let params = &set.declarations[0]["parameters"]; + assert_eq!(params["type"], "OBJECT"); + assert_eq!(params["required"], json!(["primary"])); + assert_eq!( + params["properties"]["primary"]["properties"]["field"], + json!({"type": "STRING"}) ); -} - -#[test] -fn antigravity_required_undeclared_properties_use_codec_or_reject_unsatisfiable_contract() { - let schema = - json!({"type":"object","properties":{"known":{"type":"string"}},"required":["mandatory"]}); - let set = ToolSet::compile(&json!([{"name":"caller","input_schema":schema}])).unwrap(); assert_eq!( - set.declarations[0]["parameters"]["required"], - json!(["json"]) + params["properties"]["primary"]["properties"]["children"]["type"], + "ARRAY" ); - assert!(set.mapping(&json!([{"role":"model","parts":[{"functionCall":{"name":"caller","args":{"json":"{\"mandatory\":17}"}}}]}])).is_ok()); - let mut closed = schema; - closed["additionalProperties"] = json!(false); - assert!(ToolSet::compile(&json!([{"name":"caller","input_schema":closed}])).is_err()); - closed["oneOf"] = json!([{"required":["mandatory"]}]); - assert!( - ToolSet::compile(&json!([{"name":"caller","input_schema":closed}])).is_err(), - "a codec fallback must not hide the known unsatisfiable required-property contract" + assert_eq!(params["properties"]["secondary"]["type"], "OBJECT"); + assert_eq!(params["properties"]["secondary"]["nullable"], true); + assert_eq!( + params["properties"]["secondary"]["description"], + "Optional secondary filter" ); -} - -#[test] -fn antigravity_nested_freeform_guidance_keeps_required_names_and_original_validation() { - let leaf = json!({"type":"object","required":["weight"],"additionalProperties":{"type":"integer","enum":[17,29]}}); - let set = ToolSet::compile(&json!([{"name":"nested","input_schema":{"type":"object","properties":{"payload":leaf},"required":["payload"]}}])).unwrap(); - let native = &set.declarations[0]["parameters"]["properties"]["payload"]; - assert_eq!(native["type"], "STRING"); - assert!( - native["description"] - .as_str() - .unwrap() - .contains(&serde_json::to_string(&leaf).unwrap()) + assert_eq!( + params["properties"]["secondary"]["properties"]["field"], + json!({"type": "STRING"}) ); - for (raw, valid) in [ - (r#"{"weight":17}"#, true), - (r#"{"weight":18}"#, false), - ("{}", false), - ] { - let contents = json!([{"role":"model","parts":[{"functionCall":{"name":"nested","args":{"payload":raw}}}]}]); - assert_eq!(set.mapping(&contents).is_ok(), valid); - } + let contents = json!([{"role": "model", "parts": [{"functionCall": {"name": "query", "args": {"primary": {"field": "status", "children": []}}}}]}]); + let mapping = set.mapping(&contents).unwrap(); + assert_eq!(mapping[0]["codec_paths"], json!([])); + assert_eq!(mapping[0]["root_wrapper"], Value::Null); } #[test] -fn antigravity_typeless_anyof_keeps_every_sibling_in_complete_codec_guidance() { - let cases = [ - ("enum", json!({"enum":[17,29]}), json!(18)), - ("const", json!({"const":17}), json!(18)), - ( - "allOf", - json!({"allOf":[{"minimum":17},{"maximum":17}]}), - json!(18), - ), - ( - "oneOf", - json!({"oneOf":[{"const":17},{"const":29}]}), - json!(18), - ), - ( - "description", - json!({"description":"An integer or fractional number; preserve this guidance."}), - json!("not a number"), - ), - ( - "annotations", - json!({"title":"numeric choice","default":17}), - json!("not a number"), - ), - ("type array", json!({"type":["integer"]}), json!(17.5)), - ]; - let mut missing = Vec::new(); - let mut compiled = Vec::new(); - for (name, siblings, invalid) in cases { - let mut leaf = json!({"anyOf":[{"type":"integer"},{"type":"number"}]}); - leaf.as_object_mut() - .unwrap() - .extend(siblings.as_object().unwrap().clone()); - let set = ToolSet::compile(&json!([{"name":"union","input_schema":{"type":"object","properties":{"value":leaf},"required":["value"]}}])).unwrap(); - let native = &set.declarations[0]["parameters"]["properties"]["value"]; - if native["type"] != "STRING" - || !native["description"] - .as_str() - .is_some_and(|text| text.contains(&serde_json::to_string(&leaf).unwrap())) - { - missing.push(name); +fn antigravity_agy_projection_folds_allof_unions_and_nullable_types() { + let schema = json!({ + "type": "object", + "allOf": [ + {"properties": {"base": {"type": "string"}}, "required": ["base"]}, + {"properties": {"limit": {"type": "integer", "minimum": 1}}} + ], + "properties": { + "nullable_str": {"type": ["string", "null"], "description": "Optional label"}, + "number_union": {"anyOf": [{"type": "integer"}, {"type": "number"}]}, + "heterogeneous": {"oneOf": [{"type": "string"}, {"type": "boolean"}], "description": "String or flag"}, + "const_flag": {"const": "fixed"} } - compiled.push((set, invalid)); - } - assert!( - missing.is_empty(), - "typeless anyOf discarded complete sibling guidance: {missing:?}" - ); - for (set, invalid) in compiled { - let contents = json!([{"role":"model","parts":[{"functionCall":{"name":"union","args":{"value":"17"}},"thoughtSignature":"unchanged"}]}]); - let mapping = set.mapping(&contents).unwrap(); - assert_eq!(mapping[0]["codec_paths"], json!(["/value"])); - let blocks = encode_default(&contents, &binding(), "full", &mapping).unwrap(); - assert_eq!(blocks[1]["input"]["value"], 17); - assert_eq!( - replay::decode(&blocks, &binding()).unwrap()["contents"], - contents - ); - let mut invalid_contents = contents; - invalid_contents[0]["parts"][0]["functionCall"]["args"]["value"] = - json!(serde_json::to_string(&invalid).unwrap()); - assert!( - set.mapping(&invalid_contents).is_err(), - "decoded arguments must still satisfy the complete original schema" - ); - } -} - -#[test] -fn antigravity_typeless_anyof_keeps_object_siblings_without_narrowing_or_dropping_them() { - let leaf = json!({ - "anyOf":[{"type":"object","properties":{"x":{"type":"integer"}}},{"type":"object","properties":{"y":{"type":"string"}}}], - "properties":{"x":{"type":"integer"}},"required":["x"],"additionalProperties":false }); - let set = ToolSet::compile(&json!([{"name":"union","input_schema":{"type":"object","properties":{"value":leaf},"required":["value"]}}])).unwrap(); - let native = &set.declarations[0]["parameters"]["properties"]["value"]; - assert_eq!(native["type"], "STRING"); - assert!( - native["description"] - .as_str() - .unwrap() - .contains(&serde_json::to_string(&leaf).unwrap()) + let set = ToolSet::compile(&json!([{"name": "merged", "input_schema": schema}])).unwrap(); + let props = &set.declarations[0]["parameters"]["properties"]; + assert_eq!( + set.declarations[0]["parameters"]["required"], + json!(["base"]) ); - for (raw, valid) in [ - (r#"{"x":17}"#, true), - (r#"{"y":"other"}"#, false), - (r#"{"x":17,"extra":true}"#, false), - ] { - let contents = json!([{"role":"model","parts":[{"functionCall":{"name":"union","args":{"value":raw}}}]}]); - assert_eq!(set.mapping(&contents).is_ok(), valid); - } -} - -#[test] -fn antigravity_typeless_anyof_sibling_guidance_bound_and_reference_rejection_still_apply() { - let oversized = - json!({"anyOf":[{"type":"integer"},{"type":"number"}],"description":"x".repeat(20000)}); - assert!(ToolSet::compile(&json!([{"name":"union","input_schema":{"type":"object","properties":{"value":oversized}}}])).is_err(), "complete anyOf sibling guidance cannot bypass the 16 KiB bound"); - for rejected in [ - json!({"$ref":"https://example.invalid/schema"}), - json!({"$defs":{}}), - json!({"$dynamicRef":"#x"}), - ] { - let mut leaf = json!({"anyOf":[{"type":"integer"},{"type":"number"}]}); - leaf.as_object_mut() - .unwrap() - .extend(rejected.as_object().unwrap().clone()); - assert!(ToolSet::compile(&json!([{"name":"union","input_schema":{"type":"object","properties":{"value":leaf}}}])).is_err()); - } -} - -#[test] -fn antigravity_typeless_anyof_without_siblings_retains_native_alternatives() { - let set = ToolSet::compile(&json!([{"name":"union","input_schema":{"type":"object","properties":{"value":{"anyOf":[{"type":"integer"},{"type":"number"}]}}}}])).unwrap(); + assert_eq!(props["base"], json!({"type": "STRING"})); + assert_eq!(props["limit"], json!({"type": "INTEGER", "minimum": 1.0})); assert_eq!( - set.declarations[0]["parameters"]["properties"]["value"], - json!({"anyOf":[{"type":"INTEGER"},{"type":"NUMBER"}]}) + props["nullable_str"], + json!({"type": "STRING", "nullable": true, "description": "Optional label"}) ); - let contents = - json!([{"role":"model","parts":[{"functionCall":{"name":"union","args":{"value":17.5}}}]}]); - let mapping = set.mapping(&contents).unwrap(); - assert_eq!(mapping[0]["codec_paths"], json!([])); -} - -#[test] -fn antigravity_nonstring_enum_values_survive_native_guidance() { - let set = ToolSet::compile(&json!([{"name":"numbers","input_schema":{"type":"object","properties":{"value":{"type":"integer","enum":[17,29]}}}}])).unwrap(); - let description = set.declarations[0]["parameters"]["properties"]["value"]["description"] - .as_str() - .unwrap(); - assert!( - description.contains("[17,29]"), - "native enum guidance omitted accepted values: {description}" + assert_eq!(props["number_union"], json!({"type": "NUMBER"})); + assert_eq!( + props["heterogeneous"], + json!({"description": "String or flag"}) + ); + assert_eq!( + props["const_flag"], + json!({"type": "STRING", "enum": ["fixed"]}) ); - assert!(set.mapping(&json!([{"role":"model","parts":[{"functionCall":{"name":"numbers","args":{"value":18}}}]}])).is_err()); } #[test] @@ -1691,18 +1579,10 @@ fn antigravity_integer_exclusive_projection_keeps_fractional_inclusive_constrain ); let call = |args: Value| json!([{"role":"model","parts":[{"functionCall":{"name":"range","args":args}}]}]); assert!(set.mapping(&call(json!({"lower":2,"upper":-2}))).is_ok()); - for args in [ - json!({"lower":1,"upper":-2}), - json!({"lower":1.5,"upper":-2}), - json!({"lower":2,"upper":-1}), - json!({"lower":2,"upper":-1.5}), - ] { - assert!(set.mapping(&call(args)).is_err()); - } } #[test] -fn antigravity_selected_scalar_constraints_keep_proto_types_and_original_validation() { +fn antigravity_selected_scalar_constraints_keep_proto_types() { let set = ToolSet::compile(&json!([{"name":"bounded","input_schema":{ "type":"object","properties":{ "text":{"type":"string","minLength":1,"maxLength":3}, @@ -1735,67 +1615,10 @@ fn antigravity_selected_scalar_constraints_keep_proto_types_and_original_validat set.mapping(&call(json!({"text":"ok","list":[1,2],"number":2.5}))) .is_ok() ); - for args in [ - json!({"text":"","list":[1],"number":2}), - json!({"text":"long","list":[1],"number":2}), - json!({"text":"ok","list":[],"number":2}), - json!({"text":"ok","list":[1,2,3],"number":2}), - json!({"text":"ok","list":[1.5],"number":2}), - json!({"text":"ok","list":[1],"number":0.5}), - json!({"text":"ok","list":[1],"number":4.5}), - json!({"text":"ok","list":[1],"number":2,"extra":true}), - json!({"text":"ok","list":[1]}), - ] { - assert!(set.mapping(&call(args)).is_err()); - } -} - -fn property_names_tool(schema: Value) -> ToolSet { - ToolSet::compile(&json!([{"name":"record","input_schema":schema}])).unwrap() -} - -fn property_names_call(args: Value) -> Value { - json!([{"role":"model","parts":[{"functionCall":{"name":"record","args":args},"thoughtSignature":"unchanged"}]}]) -} - -#[test] -fn antigravity_property_names_records_keep_complete_codec_and_replay() { - let record = - json!({"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}); - let set = property_names_tool( - json!({"type":"object","properties":{"metadata":record},"required":["metadata"]}), - ); - let native = &set.declarations[0]["parameters"]["properties"]["metadata"]; - assert_eq!(native["type"], "STRING"); - assert!( - native["description"] - .as_str() - .unwrap() - .contains(&serde_json::to_string(&record).unwrap()) - ); - let metadata = json!({"a/b~雪":{"nested":[null,true,17,"value"]},"empty":{},"":[]}); - let contents = - property_names_call(json!({"metadata":serde_json::to_string(&metadata).unwrap()})); - let mapping = set.mapping(&contents).unwrap(); - assert_eq!(mapping[0]["codec_paths"], json!(["/metadata"])); - let blocks = encode_default(&contents, &binding(), "full", &mapping).unwrap(); - assert_eq!(blocks[1]["input"], json!({"metadata":metadata})); - assert_eq!( - replay::decode(&blocks, &binding()).unwrap()["contents"], - contents - ); - for raw in ["{", r#"{"x":1,"x":2}"#, "null", "[]"] { - assert!( - set.mapping(&property_names_call(json!({"metadata":raw}))) - .is_err() - ); - } } #[test] -fn antigravity_property_names_static_cli_record_shapes_compile_without_weakening() { - // Relevant record shapes from the pinned CLI's static tool definitions, not - // a claim that every optional tool is present in every actual-CLI request. +fn antigravity_property_names_static_cli_record_shapes_project_as_native_objects() { let metadata = json!({"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}); let answers = json!({"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}}); @@ -1807,210 +1630,32 @@ fn antigravity_property_names_static_cli_record_shapes_compile_without_weakening {"name":"TaskUpdate","input_schema":{"type":"object","properties":{"metadata":metadata},"additionalProperties":false}}, {"name":"AskUserQuestion","input_schema":{"type":"object","properties":{"answers":answers,"annotations":annotations},"additionalProperties":false}} ])).unwrap(); - for (index, property, source) in [ - (0, "metadata", &metadata), - (1, "metadata", &metadata), - (2, "answers", &answers), - (2, "annotations", &annotations), - ] { - let native = &set.declarations[index]["parameters"]["properties"][property]; - assert_eq!(native["type"], "STRING"); - assert!( - native["description"] - .as_str() - .unwrap() - .contains(&serde_json::to_string(source).unwrap()) - ); - } - for (answers, annotations, valid) in [ - ( - json!({"question":"answer"}), - json!({"question":{"preview":"preview","notes":"notes"}}), - true, - ), - (json!({}), json!({"question":{}}), true), - (json!({"question":17}), json!({}), false), - (json!({}), json!({"question":{"preview":17}}), false), - ( - json!({}), - json!({"question":{"extra":"not allowed"}}), - false, - ), - ] { - let contents = json!([{"role":"model","parts":[{"functionCall":{"name":"AskUserQuestion","args":{ - "answers":serde_json::to_string(&answers).unwrap(),"annotations":serde_json::to_string(&annotations).unwrap() - }}}]}]); - assert_eq!(set.mapping(&contents).is_ok(), valid); - } -} - -#[test] -fn antigravity_property_names_structured_guidance_validates_every_key_and_value() { - for dialect in [ - "https://json-schema.org/draft/2020-12/schema", - "http://json-schema.org/draft-07/schema#", - ] { - let names = json!({"type":"string","pattern":"^ok","minLength":3}); - let schema = json!({"$schema":dialect,"type":"object","properties":{ - "ok_known":{"type":"integer"},"bad_known":{"type":"integer"}, - "ok_default":{"type":"integer","default":17} - },"propertyNames":names}); - let set = property_names_tool(schema); - let parameters = &set.declarations[0]["parameters"]; - assert_eq!(parameters["type"], "OBJECT"); - assert!( - parameters["description"] - .as_str() - .unwrap() - .contains(&format!( - "\"propertyNames\":{}", - serde_json::to_string(&names).unwrap() - )) - ); - for args in [json!({}), json!({"ok_known":17}), json!({"ok_extra":true})] { - let contents = property_names_call(args.clone()); - let mapping = set.mapping(&contents).unwrap(); - assert_eq!(mapping[0]["codec_paths"], json!([])); - let blocks = encode_default(&contents, &binding(), "full", &mapping).unwrap(); - assert_eq!(blocks[1]["input"], args, "defaults must not be inserted"); - } - for args in [ - json!({"bad_known":17}), - json!({"bad_extra":17}), - json!({"ok":17}), - json!({"ok_known":"17"}), - ] { - assert_eq!( - set.mapping(&property_names_call(args)).err(), - Some("upstream tool arguments violate original schema") - ); - } - } -} - -#[test] -fn antigravity_property_names_standard_applicability_and_boolean_schemas() { - for (names, populated) in [ - (json!(true), true), - (json!(false), false), - (json!({"type":"integer"}), false), - ] { - let set = property_names_tool( - json!({"type":"object","properties":{"value":{"propertyNames":names}},"required":["value"]}), - ); - for (value, valid) in [ - (json!({}), true), - (json!({"x":1}), populated), - (json!(17), true), - (json!([1]), true), - (json!(null), true), - ] { - // Tool inputs remain objects; only this unconstrained nested value - // varies, so non-object applicability is tested without violating - // the existing root-codec object transport contract. - let contents = - property_names_call(json!({"value":serde_json::to_string(&value).unwrap()})); - assert_eq!( - set.mapping(&contents).is_ok(), - valid, - "legal propertyNames applicability changed for {value}" - ); - } - } - let set = property_names_tool( - json!({"type":"object","properties":{"value":{"type":"integer","propertyNames":false}},"required":["value"]}), - ); - assert!( - set.mapping(&property_names_call(json!({"value":17}))) - .is_ok() - ); - assert!( - set.mapping(&property_names_call(json!({"value":"17"}))) - .is_err() - ); - let set = property_names_tool( - json!({"type":"object","additionalProperties":false,"propertyNames":false}), - ); - assert!(set.declarations[0].get("parameters").is_none()); - assert!(set.mapping(&property_names_call(json!({}))).is_ok()); - assert!(set.mapping(&property_names_call(json!({"x":1}))).is_err()); -} - -#[test] -fn antigravity_property_names_enums_and_freeform_value_constraints_are_original() { - let schema = json!({"type":"object","propertyNames":{"enum":["allowed"]},"additionalProperties":{"type":"integer","minimum":1,"default":17}}); - let set = property_names_tool(schema.clone()); - let native = &set.declarations[0]["parameters"]["properties"]["json"]; - assert_eq!(native["type"], "STRING"); - assert!( - native["description"] - .as_str() - .unwrap() - .contains(&serde_json::to_string(&schema).unwrap()) - ); - for (value, valid) in [ - (json!({}), true), - (json!({"allowed":1}), true), - (json!({"other":1}), false), - (json!({"allowed":0}), false), - (json!({"allowed":"1"}), false), - (json!({"allowed":null}), false), - ] { - let contents = property_names_call(json!({"json":serde_json::to_string(&value).unwrap()})); - assert_eq!(set.mapping(&contents).is_ok(), valid); - if valid { - let mapping = set.mapping(&contents).unwrap(); - let blocks = encode_default(&contents, &binding(), "full", &mapping).unwrap(); - assert_eq!(blocks[1]["input"], value); - } - } -} - -#[test] -fn antigravity_property_names_traversal_rejects_unsupported_descendants_and_malformed_values() { - for child in [ - json!({"$ref":"#/$defs/name"}), - json!({"$ref":"https://example.invalid/schema"}), - json!({"$defs":{}}), - json!({"definitions":{}}), - json!({"$dynamicRef":"#name"}), - json!({"examples":[]}), - json!({"unknownConstraint":true}), - json!({"anyOf":[{"type":"string"},{"$ref":"#"}]}), + for (index, property) in [ + (0, "metadata"), + (1, "metadata"), + (2, "answers"), + (2, "annotations"), ] { assert_eq!( - ToolSet::compile(&json!([{"name":"record","input_schema":{"propertyNames":child}}])) - .err(), - Some("unsupported schema keyword or reference") + set.declarations[index]["parameters"]["properties"][property], + json!({"type":"OBJECT"}) ); } - for child in [json!(null), json!(17), json!("string"), json!([])] { - assert_eq!( - ToolSet::compile(&json!([{"name":"record","input_schema":{"propertyNames":child}}])) - .err(), - Some("schema must be object or boolean") - ); - } - for child in [json!({"type":"invented"}), json!({"minLength":-1})] { - assert_eq!( - ToolSet::compile(&json!([{"name":"record","input_schema":{"propertyNames":child}}])) - .err(), - Some("invalid original tool schema") - ); - } - let set = property_names_tool( - json!({"type":"object","properties":{"propertyNames":{"type":"string"}},"required":["propertyNames"],"additionalProperties":false}), - ); - assert!( - set.mapping(&property_names_call( - json!({"propertyNames":"ordinary domain field"}) - )) - .is_ok() + let metadata_val = json!({"a/b~雪":{"nested":[null,true,17,"value"]},"empty":{},"":[]}); + let contents = json!([{"role":"model","parts":[{"functionCall":{"name":"TaskCreate","args":{"metadata":metadata_val}},"thoughtSignature":"unchanged"}]}]); + let mapping = set.mapping(&contents).unwrap(); + assert_eq!(mapping[0]["codec_paths"], json!([])); + assert_eq!(mapping[0]["root_wrapper"], Value::Null); + let blocks = encode_default(&contents, &binding(), "full", &mapping).unwrap(); + assert_eq!(blocks[1]["input"], json!({"metadata":metadata_val})); + assert_eq!( + replay::decode(&blocks, &binding()).unwrap()["contents"], + contents ); } #[test] -fn antigravity_property_names_traversal_preserves_depth_node_byte_and_guidance_limits() { +fn antigravity_schema_traversal_preserves_depth_node_and_byte_limits() { let compile = |schema| ToolSet::compile(&json!([{"name":"record","input_schema":schema}])); let mut deep = json!(true); for _ in 0..32 { @@ -2021,7 +1666,7 @@ fn antigravity_property_names_traversal_preserves_depth_node_byte_and_guidance_l compile(json!({"propertyNames":deep})).err(), Some("schema depth/node limit") ); - let properties = (0..8190) + let properties = (0..8188) .map(|i| (format!("p{i}"), json!(true))) .collect::>(); let mut wide = json!({"type":"object","properties":properties,"propertyNames":true}); @@ -2032,97 +1677,69 @@ fn antigravity_property_names_traversal_preserves_depth_node_byte_and_guidance_l compile(json!({"propertyNames":{"description":"x".repeat(256*1024)}})).err(), Some("schema byte limit") ); - for schema in [ - json!({"propertyNames":{"description":"x".repeat(16384)}}), - json!({"type":"object","properties":{"x":{"type":"integer"}},"propertyNames":{"description":"x".repeat(16384)}}), - ] { - assert_eq!( - compile(schema).err(), - Some("complete schema guidance exceeds the 16 KiB limit") - ); - } -} - -#[test] -fn antigravity_schema_preserves_original_dialects_and_constraints() { - for dialect in [ - "https://json-schema.org/draft/2020-12/schema", - "http://json-schema.org/draft-07/schema#", + for bad_root in [ + json!(true), + json!([]), + json!({"type":"string"}), + json!({"type":["string","integer"]}), ] { - let set = ToolSet::compile(&json!([{"name":"Read", "input_schema":{ - "$schema":dialect, "type":"object", "properties":{"limit":{"type":"integer","exclusiveMinimum":0}}, - "required":["limit"], "additionalProperties":false - }}])).unwrap(); - assert_eq!( - set.declarations[0]["parameters"], - json!({"type":"OBJECT","properties":{"limit":{"type":"INTEGER"}},"required":["limit"]}) - ); - for args in [ - json!({"limit":0}), - json!({"limit":-1}), - json!({"limit":1.5}), - json!({"limit":1,"extra":true}), - ] { - assert!(set.mapping(&json!([{"role":"model","parts":[{"functionCall":{"name":"Read","args":args}}]}])).is_err()); - } - assert!(set.mapping(&json!([{"role":"model","parts":[{"functionCall":{"name":"Read","args":{"limit":1}}}]}])).is_ok()); + assert!(compile(bad_root).is_err()); } } #[test] -fn antigravity_schema_rejects_external_references_and_unknown_keywords() { +fn antigravity_schema_accepts_dialects_unknown_keywords_and_custom_formats() { for schema in [ - json!({"$ref":"https://example.invalid/schema"}), - json!({"$defs":{}}), - json!({"definitions":{}}), - json!({"$dynamicRef":"#x"}), - json!({"prefixItems":[]}), - json!({"unevaluatedProperties":false}), - json!({"$schema":"https://example.invalid/draft"}), - json!({"type":"object", "unknownConstraint":true}), + json!({"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"limit":{"type":"integer","exclusiveMinimum":0}},"required":["limit"],"additionalProperties":false}), + json!({"$schema":"https://example.invalid/draft","$defs":{"x":{"type":"string"}},"$dynamicRef":"#x","prefixItems":[],"unevaluatedProperties":false,"unknownConstraint":true,"properties":{"id":{"type":"string","format":"custom-mcp-id"}}}), + json!({"$ref":"https://example.invalid/schema","properties":{"ok":{"type":"boolean"}}}), ] { - assert!(ToolSet::compile(&json!([{"name":"tool", "input_schema":schema}])).is_err()); + assert!(ToolSet::compile(&json!([{"name":"tool","input_schema":schema}])).is_ok()); } } #[test] -fn antigravity_freeform_codec_and_mapped_names_are_lossless() { +fn antigravity_mapped_colon_and_dot_names_roundtrip_native_arguments() { let name = "caller/tool with spaces"; - let set = ToolSet::compile(&json!([{"name":name,"input_schema":{"type":"object","properties":{"args":{}},"required":["args"]}}])).unwrap(); - let native_name = &set.declarations[0]["name"]; - assert_ne!(native_name, name); - let contents = json!([{"role":"model","parts":[{"functionCall":{"name":native_name,"args":{"args":"[1, {\"x\":true}]"}},"thoughtSignature":"unchanged"}]}]); + let colon = "mcp:server:read"; + let dot = "mcp.server.read"; + let set = ToolSet::compile(&json!([ + {"name":name,"input_schema":{"type":"object","properties":{"args":{}},"required":["args"]}}, + {"name":colon,"input_schema":{"type":"object","properties":{"uri":{"type":"string","format":"uri"}}}}, + {"name":dot,"input_schema":{"type":"object","properties":{"id":{"type":"string"}}}}, + {"name":"mcp__server__read-1","input_schema":{"type":"object"}} + ])).unwrap(); + let native_name = set.declarations[0]["name"].as_str().unwrap().to_string(); + let native_colon = set.declarations[1]["name"].as_str().unwrap().to_string(); + let native_dot = set.declarations[2]["name"].as_str().unwrap().to_string(); + assert!(native_name.starts_with("ccp_")); + assert!(native_colon.starts_with("ccp_")); + assert!(native_dot.starts_with("ccp_")); + assert_eq!(set.declarations[3]["name"], "mcp__server__read-1"); + assert_eq!( + set.declarations[0]["parameters"]["properties"]["args"], + json!({}) + ); + assert_eq!( + set.declarations[1]["parameters"]["properties"]["uri"], + json!({"type":"STRING","format":"uri"}) + ); + let contents = json!([{"role":"model","parts":[ + {"functionCall":{"name":native_name,"args":{"args":[1,{"x":true}]}},"thoughtSignature":"unchanged"}, + {"functionCall":{"name":native_colon,"args":{"uri":"file:///a"}}} + ]}]); let mapping = set.mapping(&contents).unwrap(); + assert_eq!(mapping[0]["codec_paths"], json!([])); + assert_eq!(mapping[1]["caller_name"], colon); let blocks = encode_default(&contents, &binding(), "omitted", &mapping).unwrap(); assert_eq!(blocks[1]["name"], name); assert_eq!(blocks[1]["input"]["args"], json!([1,{"x":true}])); + assert_eq!(blocks[2]["name"], colon); + assert_eq!(blocks[2]["input"]["uri"], json!("file:///a")); assert_eq!( replay::decode(&blocks, &binding()).unwrap()["contents"], contents ); - let invalid = json!([{"role":"model","parts":[{"functionCall":{"name":native_name,"args":{"args":"{"}}}]}]); - assert!(set.mapping(&invalid).is_err()); -} - -#[test] -fn antigravity_original_allof_and_format_are_validated() { - let set = ToolSet::compile( - &json!([{"name":"tool","input_schema":{"type":"object","properties":{ - "to":{"type":"string","allOf":[{"pattern":"^a"},{"pattern":"z$"}]}, - "address":{"type":"string","format":"email"}},"required":["to","address"]}}]), - ) - .unwrap(); - for args in [ - json!({"to":"apple","address":"a@b.com"}), - json!({"to":"az","address":"not email"}), - ] { - assert!( - set.mapping( - &json!([{"role":"model","parts":[{"functionCall":{"name":"tool","args":args}}]}]) - ) - .is_err() - ); - } } #[test] @@ -2313,7 +1930,7 @@ fn antigravity_request_multiple_tools_have_one_declaration_per_object() { ); assert!(prepared.wire.get("labels").is_none()); let contents = json!([{"role":"model","parts":[ - {"functionCall":{"name":compiled.declarations[0]["name"],"args":{"json":"{\"payload\":[1,true]}"}}}, + {"functionCall":{"name":compiled.declarations[0]["name"],"args":{"payload":[1,true]}}}, {"functionCall":{"name":"empty","args":{}}}, {"functionCall":{"name":"limited","args":{"limit":1}}} ]}]); @@ -2327,18 +1944,12 @@ fn antigravity_request_multiple_tools_have_one_declaration_per_object() { replay::decode(&blocks, &binding()).unwrap()["contents"], contents ); - for args in [ - json!({"limit":0}), - json!({"limit":1.5}), - json!({"limit":1,"extra":true}), - ] { - assert!( - prepared - .tools - .mapping(&json!([{"role":"model","parts":[{"functionCall":{"name":"limited","args":args}}]}])) - .is_err() - ); - } + assert!( + prepared + .tools + .mapping(&json!([{"role":"model","parts":[{"functionCall":{"name":"unknown_tool","args":{}}}]}])) + .is_err() + ); } #[test] @@ -2532,27 +2143,21 @@ fn antigravity_complete_result_prefix_uses_native_order_without_synthetic_contin } #[test] -fn antigravity_schema_root_freeform_and_closed_no_argument_tools_are_explicit() { +fn antigravity_schema_root_freeform_and_closed_no_argument_tools_are_native_objects() { let set = ToolSet::compile(&json!([ {"name":"free","input_schema":{"type":"object"}}, {"name":"empty","input_schema":{"type":"object","additionalProperties":false}} ])) .unwrap(); - assert_eq!( - set.declarations[0]["parameters"]["properties"]["json"]["type"], - "STRING" - ); + assert_eq!(set.declarations[0]["parameters"], json!({"type":"OBJECT"})); assert_eq!(set.declarations[1]["parameters"], json!({"type":"OBJECT"})); - let contents = json!([{"role":"model","parts":[{"functionCall":{"name":"free","args":{"json":"{\"x\":[1,true]}"}}},{"functionCall":{"name":"empty","args":{}}}]}]); + let contents = json!([{"role":"model","parts":[{"functionCall":{"name":"free","args":{"x":[1,true]}}},{"functionCall":{"name":"empty","args":{}}}]}]); let map = set.mapping(&contents).unwrap(); + assert_eq!(map[0]["codec_paths"], json!([])); + assert_eq!(map[0]["root_wrapper"], Value::Null); let blocks = encode_default(&contents, &binding(), "full", &map).unwrap(); assert_eq!(blocks[1]["input"], json!({"x":[1,true]})); assert_eq!(blocks[2]["input"], json!({})); - let invalid = json!([{"role":"model","parts":[{"functionCall":{"name":"free","args":{"json":"{}","extra":true}}}]}]); - assert!(set.mapping(&invalid).is_err()); - let invalid_empty = - json!([{"role":"model","parts":[{"functionCall":{"name":"empty","args":{"extra":true}}}]}]); - assert!(set.mapping(&invalid_empty).is_err()); } #[test] @@ -2954,7 +2559,7 @@ fn antigravity_canonicalizes_client_normalized_tool_inputs_and_accepts_global_ca } #[test] -fn antigravity_task_update_string_anyof_leaf_accepts_raw_or_json_encoded_string() { +fn antigravity_task_update_projects_native_enum_and_object_without_codec() { let set = ToolSet::compile(&json!([{ "name": "TaskUpdate", "input_schema": { @@ -2980,20 +2585,29 @@ fn antigravity_task_update_string_anyof_leaf_accepts_raw_or_json_encoded_string( } }])) .unwrap(); - let direct = json!([{"role": "model", "parts": [{"functionCall": {"name": "TaskUpdate", "args": {"taskId": "1", "status": "in_progress", "metadata": "{\"step\":1}"}}, "thoughtSignature": "sig"}]}]); + assert_eq!( + set.declarations[0]["parameters"], + json!({ + "type": "OBJECT", + "properties": { + "taskId": {"type": "STRING"}, + "status": { + "type": "STRING", + "description": "New status for the task", + "enum": ["pending", "in_progress", "completed", "deleted"] + }, + "metadata": {"type": "OBJECT"} + }, + "required": ["taskId"] + }) + ); + let direct = json!([{"role": "model", "parts": [{"functionCall": {"name": "TaskUpdate", "args": {"taskId": "1", "status": "in_progress", "metadata": {"step": 1}}}, "thoughtSignature": "sig"}]}]); let mapping = set.mapping(&direct).unwrap(); - assert_eq!(mapping[0]["codec_paths"], json!(["/metadata"])); + assert_eq!(mapping[0]["codec_paths"], json!([])); + assert_eq!(mapping[0]["root_wrapper"], Value::Null); let blocks = encode_default(&direct, &binding(), "full", &mapping).unwrap(); assert_eq!( blocks[1]["input"], json!({"taskId": "1", "status": "in_progress", "metadata": {"step": 1}}) ); - let encoded_str = json!([{"role": "model", "parts": [{"functionCall": {"name": "TaskUpdate", "args": {"taskId": "1", "status": "\"deleted\""}}, "thoughtSignature": "sig"}]}]); - let mapping2 = set.mapping(&encoded_str).unwrap(); - assert_eq!(mapping2[0]["codec_paths"], json!(["/status"])); - let blocks2 = encode_default(&encoded_str, &binding(), "full", &mapping2).unwrap(); - assert_eq!( - blocks2[1]["input"], - json!({"taskId": "1", "status": "deleted"}) - ); } diff --git a/tests/antigravity_schema_parity.rs b/tests/antigravity_schema_parity.rs index 0eeb7929..62e279b2 100644 --- a/tests/antigravity_schema_parity.rs +++ b/tests/antigravity_schema_parity.rs @@ -482,23 +482,8 @@ fn root(node: Value) -> Value { fn selected_root(node: Value) -> Value { json!({"type":"OBJECT","properties":{"value":node},"required":["value"]}) } -fn legacy_hint(constraints: Value) -> String { - format!( - "\nOriginal schema constraints are validated locally after JSON decoding; invalid calls are rejected, never repaired. Constraints: {constraints}" - ) -} -fn legacy_root(node: Value) -> Value { - let mut result = selected_root(node); - result["description"] = json!(legacy_hint(json!({"additionalProperties":false}))); - result -} -fn json_transport(schema: &Value) -> Value { - json!({"type":"STRING","description":format!( - "JSON-string transport: encode the original JSON value exactly as a JSON string. The proxy decodes and validates this original schema before returning a tool call. Original JSON Schema: {schema}" - )}) -} -// Preserved legacy/original values use serde_json's exact integer-aware equality. +// Preserved original values use serde_json's exact integer-aware equality. // In particular, never round an unsafe integer through f64 in these assertions. fn exact(actual: &Value, expected: &Value, label: &str) -> Result<()> { ensure!( @@ -593,17 +578,17 @@ fn comparison_oracle_control() -> Result<()> { json!(9_007_199_254_740_992.0), ), ] { - let left = legacy_root(json!({"type":"NUMBER","maximum":left})); - let right = legacy_root(json!({"type":"NUMBER","maximum":right})); + let left = selected_root(json!({"type":"NUMBER","maximum":left})); + let right = selected_root(json!({"type":"NUMBER","maximum":right})); ensure!( - exact(&left, &right, "oracle unequal legacy integers").is_err(), - "legacy oracle hid integer corruption" + exact(&left, &right, "oracle unequal integers").is_err(), + "oracle hid integer corruption" ); ensure!( - exact(&right, &left, "oracle unequal legacy integers reversed").is_err(), - "legacy oracle comparison is asymmetric" + exact(&right, &left, "oracle unequal integers reversed").is_err(), + "oracle comparison is asymmetric" ); - exact(&left, &left, "oracle unchanged legacy value")?; + exact(&left, &left, "oracle unchanged value")?; } selected_exact( &selected_root(json!({"type":"NUMBER","minimum":1.0,"default":{"nested":[0.0,-0.0,1e20]}})), @@ -703,10 +688,10 @@ impl Exchange { ensure!(stopped, "missing SSE terminal event"); Ok(json!(blocks)) } - fn rejected_arguments(&self, stream: bool) -> Result<()> { + fn rejected_call(&self, stream: bool, expected_msg: &str) -> Result<()> { ensure!( self.wire.is_some(), - "argument validation must occur after actual HTTPS generation" + "call validation must occur after actual HTTPS generation" ); if stream { ensure!( @@ -715,9 +700,8 @@ impl Exchange { ); let text = std::str::from_utf8(&self.bytes)?; ensure!( - text.contains("event: error\n") - && text.contains("upstream tool arguments violate original schema"), - "missing streaming original-schema validation error" + text.contains("event: error\n") && text.contains(expected_msg), + "missing streaming structural validation error" ); ensure!( !text.contains("event: message_stop\n"), @@ -732,8 +716,8 @@ impl Exchange { ensure!( error["error"]["message"] .as_str() - .is_some_and(|s| s.contains("upstream tool arguments violate original schema")), - "wrong local validation error" + .is_some_and(|s| s.contains(expected_msg)), + "wrong structural validation error" ); } Ok(()) @@ -1686,7 +1670,7 @@ async fn validation_control(f: &mut Fixture) -> Result<()> { let mut body = request(stream); body["tools"] = json!([tool( "generic_checked", - root(json!({"type":"integer","minimum":2})) + root(json!({"type":"integer","minimum":2,"default":3})) )]); let valid = f.exchange(body.clone(), json!([{"functionCall":{"name":"generic_checked","args":{"value":2},"id":"synthetic-native-id"}}])).await?; valid.success(stream)?; @@ -1697,39 +1681,39 @@ async fn validation_control(f: &mut Fixture) -> Result<()> { .unwrap() .iter() .any(|b| b["type"] == "tool_use" && b["input"] == json!({"value":2})), - "valid original-schema tool input" + "valid tool input forwarded unchanged" ); + // Downstream callers (Claude Code / MCP servers) validate schema constraints; + // missing defaults are never synthetically injected by the proxy. + let omitted = f + .exchange( + body.clone(), + json!([{"functionCall":{"name":"generic_checked","args":{}}}]), + ) + .await?; + omitted.success(stream)?; + ensure!( + omitted + .blocks()? + .as_array() + .unwrap() + .iter() + .any(|b| b["type"] == "tool_use" && b["input"] == json!({})), + "defaults must never be synthetically inserted" + ); + // Unknown tool names and non-object `args` still fail closed structurally. f.exchange( - body, - json!([{"functionCall":{"name":"generic_checked","args":{"value":1}}}]), - ) - .await? - .rejected_arguments(stream)?; - // Draft 2020-12 admits an empty enum, but it validates no values. - // Preserve that original admission/validation rather than inventing a rejection. - let mut body = request(stream); - body["tools"] = json!([tool( - "generic_enum", - root(json!({"type":"string","enum":[]})) - )]); - f.exchange( - body, - json!([{"functionCall":{"name":"generic_enum","args":{"value":""}}}]), + body.clone(), + json!([{"functionCall":{"name":"undeclared_tool","args":{}}}]), ) .await? - .rejected_arguments(stream)?; - // A default is guidance only: missing required arguments are never repaired. - let mut body = request(stream); - body["tools"] = json!([tool( - "generic_default", - root(json!({"type":"integer","default":3})) - )]); + .rejected_call(stream, "upstream returned unknown tool")?; f.exchange( body, - json!([{"functionCall":{"name":"generic_default","args":{}}}]), + json!([{"functionCall":{"name":"generic_checked","args":[]}}]), ) .await? - .rejected_arguments(stream)?; + .rejected_call(stream, "invalid native call arguments")?; } Ok(()) } @@ -1831,19 +1815,6 @@ async fn zero_constraints(f: &mut Fixture) -> Result<()> { },"required":["text","list","number"]}), "zero constraints omit but default remains", )?; - for invalid in [ - json!({"text":"x","list":[],"number":0}), - json!({"text":"","list":[true],"number":0}), - json!({"text":"","list":[],"number":1}), - json!({"text":"","list":[],"number":-1}), - ] { - f.exchange( - body.clone(), - json!([{"functionCall":{"name":"generic_zero","args":invalid}}]), - ) - .await? - .rejected_arguments(stream)?; - } } Ok(()) } @@ -1854,10 +1825,7 @@ async fn closed_empty(f: &mut Fixture) -> Result<()> { let mut body = request(stream); body["tools"] = json!([tool("generic_empty", schema)]); let exchange = f - .exchange( - body.clone(), - json!([{"functionCall":{"name":"generic_empty"}}]), - ) + .exchange(body, json!([{"functionCall":{"name":"generic_empty"}}])) .await?; exchange.success(stream)?; exact( @@ -1890,12 +1858,6 @@ async fn closed_empty(f: &mut Fixture) -> Result<()> { envelope["tool_mapping"][0]["root_wrapper"].is_null(), "closed-empty root recipe changed" ); - f.exchange( - body, - json!([{"functionCall":{"name":"generic_empty","args":{"extra":true}}}]), - ) - .await? - .rejected_arguments(stream)?; } Ok(()) } @@ -1984,31 +1946,42 @@ async fn choice_controls(f: &mut Fixture) -> Result<()> { } async fn fallback(f: &mut Fixture) -> Result<()> { - let constrained = |kind: &str, key: &str, value: Value| { - let mut source = json!({"type":kind}); - source[key] = value.clone(); - let mut expected = json!({"type":kind.to_ascii_uppercase()}); - expected["description"] = json!(legacy_hint(json!({(key):value}))); - (source, expected) - }; - let mut cases = vec![ - constrained("string", "const", json!("x")), - constrained("number", "multipleOf", json!(2)), - constrained("number", "exclusiveMinimum", json!(1.5)), - constrained("integer", "format", json!("date")), - constrained("string", "enum", json!(["x", 7, ""])), - constrained("number", "enum", json!([1, 2])), + let cases = vec![ + ( + json!({"type":"string","const":"x"}), + json!({"type":"STRING","enum":["x"]}), + ), + ( + json!({"type":"number","multipleOf":2}), + json!({"type":"NUMBER"}), + ), + ( + json!({"type":"number","exclusiveMinimum":1.5}), + json!({"type":"NUMBER"}), + ), + ( + json!({"type":"integer","format":"date"}), + json!({"type":"INTEGER"}), + ), + ( + json!({"type":"string","enum":["x", 7, ""]}), + json!({"type":"STRING","enum":["x", ""]}), + ), + ( + json!({"type":"number","enum":[1, 2]}), + json!({"type":"NUMBER"}), + ), ( json!({"type":"string","minimum":1}), - json!({"type":"STRING","minimum":1}), + json!({"type":"STRING"}), ), ( json!({"type":"array","items":{"type":"string"},"uniqueItems":true}), - json!({"type":"ARRAY","items":{"type":"STRING"},"description":legacy_hint(json!({"uniqueItems":true}))}), + json!({"type":"ARRAY","items":{"type":"STRING"}}), ), ( json!({"type":"object","properties":{"x":{"type":"string"}},"minProperties":1}), - json!({"type":"OBJECT","properties":{"x":{"type":"STRING"}},"description":legacy_hint(json!({"minProperties":1}))}), + json!({"type":"OBJECT","properties":{"x":{"type":"STRING"}}}), ), ( json!({"type":"array","items":{"type":"string"},"properties":{"x":{"type":"string"}}}), @@ -2018,39 +1991,40 @@ async fn fallback(f: &mut Fixture) -> Result<()> { json!({"type":"string","default":9007199254740992_u64}), json!({"type":"STRING"}), ), + ( + json!({"oneOf":[{"type":"string"},{"type":"integer"}]}), + json!({}), + ), + ( + json!({"type":"object","properties":{"x":{"type":"string"}},"additionalProperties":{"type":"boolean"}}), + json!({"type":"OBJECT","properties":{"x":{"type":"STRING"}}}), + ), + (json!(true), json!({})), ]; - let union = json!({"oneOf":[{"type":"string"},{"type":"integer"}]}); - cases.push((union.clone(), json_transport(&union))); - let object_extra = json!({"type":"object","properties":{"x":{"type":"string"}},"additionalProperties":{"type":"boolean"}}); - cases.push((object_extra.clone(), json_transport(&object_extra))); - let boolean = json!(true); - cases.push((boolean.clone(), json_transport(&boolean))); for stream in [false, true] { for (source, expected) in &cases { let mut schema = root(source.clone()); - schema["title"] = json!("Synthetic legacy marker"); - let mut expected = legacy_root(expected.clone()); - expected["title"] = json!("Synthetic legacy marker"); + schema["title"] = json!("Synthetic marker"); let exchange = f .project(stream, json!([tool("generic_fallback", schema)]), None) .await?; exact( exchange.parameters(0)?, - &expected, - "whole-tool fallback, including unsupported child", + &selected_root(expected.clone()), + "agy-style permissive projection", )?; } - // Aliased names must keep their full legacy declaration and codec plan. + // Aliased names keep their hashed native declaration name and native schema projection. let name = "generic spaced name"; - let schema = root(json!({"type":"string","default":"ignored by legacy"})); + let schema = root(json!({"type":"string","default":"kept natively"})); let exchange = f.project(stream, json!([tool(name, schema)]), None).await?; let native = format!("ccp_{:x}", Sha256::digest(name.as_bytes()))[..64].to_owned(); exact( &exchange.wire()?["request"]["tools"][0], &json!({"functionDeclarations":[{ - "name":native,"description":"Synthetic function.","parameters":legacy_root(json!({"type":"STRING"})) + "name":native,"description":"Synthetic function.","parameters":selected_root(json!({"type":"STRING","default":"kept natively"})) }]}), - "aliased legacy declaration", + "aliased native declaration", )?; for schema in [ json!({"type":"object","additionalProperties":false,"allOf":[{}]}), @@ -2065,8 +2039,8 @@ async fn fallback(f: &mut Fixture) -> Result<()> { .await?; exact( &exchange.wire()?["request"]["tools"][0], - &json!({"functionDeclarations":[{"name":"generic_ineligible_empty","description":"Synthetic function."}]}), - "ineligible closed-empty omission", + &json!({"functionDeclarations":[{"name":"generic_ineligible_empty","description":"Synthetic function.","parameters":{"type":"OBJECT"}}]}), + "closed-empty object parameters retained", )?; } } @@ -2125,7 +2099,7 @@ async fn eligibility(f: &mut Fixture) -> Result<()> { )]); let valid = f .exchange( - body.clone(), + body, json!([{"functionCall":{"name":"generic_exclusive","args":{"value":1}}}]), ) .await?; @@ -2135,14 +2109,6 @@ async fn eligibility(f: &mut Fixture) -> Result<()> { &selected_root(json!({"type":"INTEGER","minimum":-5,"maximum":5})), "exclusive bounds omitted without conversion", )?; - for value in [0, 3] { - f.exchange( - body.clone(), - json!([{"functionCall":{"name":"generic_exclusive","args":{"value":value}}}]), - ) - .await? - .rejected_arguments(stream)?; - } let exchange = f .project( stream, @@ -2151,7 +2117,7 @@ async fn eligibility(f: &mut Fixture) -> Result<()> { "generic_selected", root(json!({"type":"string","default":false})) ), - tool("generic_old", root(json!({"type":"string","const":"x"}))) + tool("generic_const", root(json!({"type":"string","const":"x"}))) ]), None, ) @@ -2163,8 +2129,8 @@ async fn eligibility(f: &mut Fixture) -> Result<()> { )?; exact( exchange.parameters(1)?, - &legacy_root(json!({"type":"STRING","description":legacy_hint(json!({"const":"x"}))})), - "mixed legacy tool", + &selected_root(json!({"type":"STRING","enum":["x"]})), + "const projects natively to enum", )?; } Ok(()) @@ -2206,8 +2172,8 @@ async fn numeric_budgets(f: &mut Fixture) -> Result<()> { .await?; exact( exchange.parameters(0)?, - &legacy_root(json!({"type":"NUMBER"})), - "unsafe integer default whole-tool fallback", + &selected_root(json!({"type":"NUMBER"})), + "unsafe integer default omitted cleanly", )?; let source = root(json!({"type":"number","maximum":value})); let exchange = f @@ -2215,8 +2181,8 @@ async fn numeric_budgets(f: &mut Fixture) -> Result<()> { .await?; exact( exchange.parameters(0)?, - &legacy_root(json!({"type":"NUMBER","maximum":value})), - "unsafe integer bound whole-tool fallback", + &selected_root(json!({"type":"NUMBER"})), + "unsafe integer bound omitted cleanly", )?; } for (value, expected) in [ @@ -2256,46 +2222,49 @@ async fn numeric_budgets(f: &mut Fixture) -> Result<()> { .await?; exact( exchange.parameters(0)?, - &legacy_root(json!({"type":"STRING","minLength":value})), - "unsafe length falls back", + &selected_root(json!({"type":"STRING"})), + "unsafe length omitted cleanly", )?; } - let mut deep = json!(null); - for _ in 0..34 { - deep = json!([deep]); + let mut deep_default = json!(null); + for _ in 0..24 { + deep_default = json!([deep_default]); } - for value in [deep, json!(vec![Value::Null; 4100])] { - let exchange = f - .project( - stream, - json!([tool( - "generic_budget", - root(json!({"type":"string","default":value})) - )]), - None, - ) - .await?; - exact( - exchange.parameters(0)?, - &legacy_root(json!({"type":"STRING"})), - "default projection depth/node budget fallback", - )?; - } - // Each default is below the node limit; the whole tool is not. + let exchange = f + .project( + stream, + json!([tool( + "generic_budget", + root(json!({"type":"string","default":vec![Value::Null; 4100]})) + )]), + None, + ) + .await?; + exact( + exchange.parameters(0)?, + &selected_root(json!({"type":"STRING"})), + "default projection node budget omits oversized default", + )?; + // Each default is below the 4096-node default budget; the second exceeds the shared budget. + let half = json!(vec![Value::Null; 2300]); let schema = json!({"type":"object","properties":{ - "a":{"type":"string","default":vec![Value::Null;2300]}, - "b":{"type":"string","default":vec![Value::Null;2300]} + "a":{"type":"string","default":half}, + "b":{"type":"string","default":half} },"additionalProperties":false}); let exchange = f .project(stream, json!([tool("generic_shared_budget", schema)]), None) .await?; exact( exchange.parameters(0)?, - &json!({"type":"OBJECT","properties":{"a":{"type":"STRING"},"b":{"type":"STRING"}},"description":legacy_hint(json!({"additionalProperties":false}))}), + &json!({"type":"OBJECT","properties":{"a":{"type":"STRING","default":half},"b":{"type":"STRING"}}}), "shared whole-tool default budget", )?; - let value = - json!({"items":vec![Value::Null;32],"allOf":"ordinary data","default":{"type":false}}); + let value = json!({ + "items":vec![Value::Null;32], + "nested":deep_default, + "allOf":"ordinary data", + "default":{"type":false} + }); let exchange = f .project( stream, @@ -2316,15 +2285,18 @@ async fn numeric_budgets(f: &mut Fixture) -> Result<()> { } async fn admission(f: &mut Fixture) -> Result<()> { + let mut too_deep = json!({"type":"string"}); + for _ in 0..33 { + too_deep = json!({"properties":{"x":too_deep}}); + } for stream in [false, true] { for schema in [ - root(json!({"type":"string","minLength":-1})), - root(json!({"type":"string","minLength":1.5})), - root(json!({"type":"string","format":"unsupported-synthetic-format"})), - root(json!({"$ref":"https://example.invalid/never-retrieve"})), - json!({"type":"object","properties":{},"required":["missing"],"additionalProperties":false}), - root(json!({"type":"string","unknownSyntheticKeyword":true})), - root(json!({"type":"string","enum":7})), + json!(true), + json!([]), + json!("object"), + json!({"type":"string"}), + json!({"type":["string","integer"]}), + too_deep.clone(), root(json!({"type":"string","default":"x".repeat(256*1024)})), ] { let mut body = request(stream); @@ -2332,17 +2304,17 @@ async fn admission(f: &mut Fixture) -> Result<()> { let exchange = f.exchange(body, json!([{"text":"unreachable"}])).await?; ensure!( exchange.status == StatusCode::BAD_REQUEST && exchange.wire.is_none(), - "original schema admission weakened or deferred" + "root/bound schema admission weakened or deferred" ); } - // none must not bypass original tool admission before clearing the pool. + // none must not bypass tool admission before clearing the pool. let mut body = request(stream); - body["tools"] = json!([tool("generic_invalid", root(json!({"unsupported":true})))]); + body["tools"] = json!([tool("generic_invalid", json!({"type":"string"}))]); body["tool_choice"] = json!({"type":"none"}); let exchange = f.exchange(body, json!([{"text":"unreachable"}])).await?; ensure!( exchange.status == StatusCode::BAD_REQUEST && exchange.wire.is_none(), - "original admission-first ordering changed" + "admission-first ordering changed" ); } Ok(()) @@ -2372,11 +2344,11 @@ async fn project_admission(f: &mut Fixture) -> Result<()> { let result = async { for stream in [false,true] { let mut body = request(stream); - body["tools"] = json!([tool("generic_invalid",root(json!({"unknownSyntheticKeyword":true})))]); + body["tools"] = json!([tool("generic_invalid",json!({"type":"string"}))]); let invalid = f.exchange(body,json!([{"text":"unreachable"}])).await?; ensure!(invalid.status == StatusCode::BAD_REQUEST && invalid.wire.is_none(),"admission before project resolution"); let error: Value = serde_json::from_slice(&invalid.bytes)?; - ensure!(error["error"]["message"] == "unsupported schema keyword or reference","original admission error precedence"); + ensure!(error["error"]["message"] == "tool input_schema root must be an object","admission error precedence"); let missing = f.exchange(request(stream),json!([{"text":"unreachable"}])).await?; ensure!(missing.status == StatusCode::BAD_REQUEST && missing.wire.is_none(),"missing project must not send generation"); let error: Value = serde_json::from_slice(&missing.bytes)?; @@ -2422,8 +2394,8 @@ async fn continuation_control(f: &mut Fixture) -> Result<()> { let parts = json!([ {"text":"Synthetic thought.","thought":true,"thoughtSignature":"synthetic-opaque-signature","syntheticUnknown":{"keep":true}}, {"functionCall":{"name":"generic_empty","id":"synthetic-empty-id"}}, - {"functionCall":{"name":"generic_path","args":{"value":"null"}}}, - {"functionCall":{"name":native_alias,"id":"synthetic-alias-id","args":{"json":"{\"x\":\"y\"}"}}}, + {"functionCall":{"name":"generic_path","args":{"value":null}}}, + {"functionCall":{"name":native_alias,"id":"synthetic-alias-id","args":{"x":"y"}}}, {"functionCall":{"name":"generic_selected","args":{"value":3}}} ]); let mut body = request(stream); @@ -2447,8 +2419,8 @@ async fn continuation_control(f: &mut Fixture) -> Result<()> { ensure!(mapping.len() == 4, "V1 multiple tool mappings"); for (index, (caller, paths, wrapper)) in [ ("generic_empty", json!([]), Value::Null), - ("generic_path", json!(["/value"]), Value::Null), - (spaced, json!([]), json!("json")), + ("generic_path", json!([]), Value::Null), + (spaced, json!([]), Value::Null), ("generic_selected", json!([]), Value::Null), ] .into_iter() From 9f19647d50527d735059f961fcd9ca3104f2361a Mon Sep 17 00:00:00 2001 From: Chris Liu <11517021+chris7iu@users.noreply.github.com> Date: Wed, 11 Nov 2026 08:01:14 +0000 Subject: [PATCH 28/30] fix(antigravity): align header and stream idle timeouts with agy (300s) --- src/providers/antigravity/generation.rs | 53 ++++++++++++++++-- src/providers/antigravity/mod.rs | 54 ++++++++++++++----- src/providers/antigravity_http_tests.rs | 71 +++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 16 deletions(-) diff --git a/src/providers/antigravity/generation.rs b/src/providers/antigravity/generation.rs index 719a0d86..e66848e3 100644 --- a/src/providers/antigravity/generation.rs +++ b/src/providers/antigravity/generation.rs @@ -40,7 +40,13 @@ use tower::Service; const MAX_TURN_BYTES: usize = 8 * 1024 * 1024; const MAX_ERROR_BYTES: usize = 8 * 1024; const DECODE_INPUT_CHUNK: usize = 1024; -const READ_IDLE: Duration = Duration::from_secs(30); +// Match agy's sendModelStreamRequest / idleTimeoutReadCloser (300s) and Codex's +// HTTP/WS header + body idle timeouts (300_000ms). 1M-context prefill and deep +// reasoning pauses on Carbon/Argon/Opus 5.5 routinely exceed 30s before headers +// or between SSE chunks, while TCP+TLS handshakes stay bounded by CONNECT_TIMEOUT. +pub(super) const HEADER_TIMEOUT: Duration = Duration::from_secs(300); +pub(super) const READ_IDLE: Duration = Duration::from_secs(300); +pub(super) const TURN_DEADLINE: Duration = Duration::from_secs(3600); type BoxError = Box; type Connecting = Pin, BoxError>> + Send>>; @@ -916,14 +922,20 @@ mod tests { #[tokio::test(start_paused = true)] async fn antigravity_generation_idle_and_total_errors_remain_typed() { - for (budget, expected) in [(180, BodyError::Idle), (10, BodyError::Deadline)] { + for (budget, expected, elapsed) in [ + (3600, BodyError::Idle, READ_IDLE), + (180, BodyError::Deadline, Duration::from_secs(180)), + (10, BodyError::Deadline, Duration::from_secs(10)), + ] { + let started = Instant::now(); let mut native = NativeResponse::new( StatusCode::OK, HeaderMap::new(), futures_util::stream::pending().boxed(), - Instant::now() + Duration::from_secs(budget), + started + Duration::from_secs(budget), ); assert_eq!(native.chunk().await.unwrap_err(), expected); + assert_eq!(Instant::now() - started, elapsed); } assert_eq!(BodyError::ByteLimit.message(), "native turn byte limit"); assert_eq!(BodyError::Idle.message(), "native stream idle timeout"); @@ -933,6 +945,41 @@ mod tests { ); } + #[tokio::test(start_paused = true)] + async fn antigravity_generation_long_reasoning_pause_under_idle_budget_succeeds() { + let started = Instant::now(); + let stream = futures_util::stream::unfold(0_usize, |step| async move { + match step { + 0 => { + tokio::time::sleep(Duration::from_secs(120)).await; + Some((Ok(Bytes::from_static(b"data: first\n\n")), 1)) + } + 1 => { + tokio::time::sleep(Duration::from_secs(240)).await; + Some((Ok(Bytes::from_static(b"data: second\n\n")), 2)) + } + _ => None, + } + }) + .boxed(); + let mut native = NativeResponse::new( + StatusCode::OK, + HeaderMap::new(), + stream, + started + TURN_DEADLINE, + ); + assert_eq!( + native.chunk().await.unwrap().unwrap(), + Bytes::from_static(b"data: first\n\n") + ); + assert_eq!( + native.chunk().await.unwrap().unwrap(), + Bytes::from_static(b"data: second\n\n") + ); + assert_eq!(native.chunk().await.unwrap(), None); + assert_eq!(Instant::now() - started, Duration::from_secs(360)); + } + #[tokio::test(start_paused = true)] async fn antigravity_generation_gzip_header_progress_never_renews_total_deadline() { let started = Instant::now(); diff --git a/src/providers/antigravity/mod.rs b/src/providers/antigravity/mod.rs index 205f4821..393b9d1d 100644 --- a/src/providers/antigravity/mod.rs +++ b/src/providers/antigravity/mod.rs @@ -24,7 +24,7 @@ use axum::{ response::{IntoResponse, Response}, }; use config::Config; -use generation::GenerationClient; +use generation::{GenerationClient, HEADER_TIMEOUT, TURN_DEADLINE}; use replay::{Binding, Result}; use serde_json::{Value, json}; use std::{ @@ -156,8 +156,8 @@ impl AntigravityProvider { Err(_) => return invalid("invalid native endpoint"), }; let request_started = tokio::time::Instant::now(); - let header_deadline = request_started + Duration::from_secs(30); - let request_deadline = request_started + Duration::from_secs(180); + let header_deadline = request_started + HEADER_TIMEOUT; + let request_deadline = request_started + TURN_DEADLINE; let sent = tokio::time::timeout_at( header_deadline, generation.send( @@ -233,19 +233,47 @@ impl AntigravityProvider { } // The returned Body owns this future, including the native response and // decoder. No worker survives a downstream drop. Initial errors occurred above. + // Emit periodic SSE comment heartbeats while collecting the buffered native turn + // so standalone SSE clients (e.g. undici bodyTimeout = 300s) do not drop idle + // downstream bodies during multi-minute reasoning turns. + const DOWNSTREAM_SSE_HEARTBEAT: Duration = Duration::from_secs(15); let outcome = NativeResponseOutcome::default(); let shared = outcome.clone(); - let stream = futures_util::stream::once(async move { - let result = work.await.and_then(|message| transport::sse(&message)); - let bytes = match result { - Ok(bytes) => bytes, - Err(error) => { - shared.fail(format!("Antigravity: {error}")); - format!("event: error\ndata: {}\n\n",json!({"type":"error","error":{"type":"api_error","message":format!("Antigravity: {error}")}})).into_bytes() + let mut heartbeat = tokio::time::interval_at( + tokio::time::Instant::now() + DOWNSTREAM_SSE_HEARTBEAT, + DOWNSTREAM_SSE_HEARTBEAT, + ); + heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let stream = futures_util::stream::unfold( + Some((Box::pin(work), heartbeat, shared)), + |state| async move { + let (mut work, mut heartbeat, shared) = state?; + tokio::select! { + biased; + result = work.as_mut() => { + let bytes = match result.and_then(|message| transport::sse(&message)) { + Ok(bytes) => bytes, + Err(error) => { + shared.fail(format!("Antigravity: {error}")); + format!( + "event: error\ndata: {}\n\n", + json!({"type":"error","error":{"type":"api_error","message":format!("Antigravity: {error}")}}) + ) + .into_bytes() + } + }; + Some(( + Ok::<_, std::convert::Infallible>(bytes::Bytes::from(bytes)), + None, + )) + } + _ = heartbeat.tick() => Some(( + Ok::<_, std::convert::Infallible>(bytes::Bytes::from_static(b": ping\n\n")), + Some((work, heartbeat, shared)), + )), } - }; - Ok::<_, std::convert::Infallible>(bytes::Bytes::from(bytes)) - }); + }, + ); let mut response = Response::new(Body::from_stream(stream)); response.headers_mut().insert( "content-type", diff --git a/src/providers/antigravity_http_tests.rs b/src/providers/antigravity_http_tests.rs index 5777ca36..bcf098ae 100644 --- a/src/providers/antigravity_http_tests.rs +++ b/src/providers/antigravity_http_tests.rs @@ -2510,3 +2510,74 @@ async fn antigravity_count_estimate_is_signature_size_invariant_and_network_free assert_eq!(estimates[0], estimates[1]); assert!(captured.lock().unwrap().is_empty()); } + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn antigravity_downstream_sse_heartbeats_bridge_long_buffered_turns() { + use http_body_util::BodyExt; + use tower::ServiceExt; + + let upstream = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = url::Url::parse(&format!("http://{}", upstream.local_addr().unwrap())).unwrap(); + let sse_payload = successful(json!([{"text":"after deep thought"}])); + let (headers_observed_tx, headers_observed_rx) = tokio::sync::oneshot::channel(); + let native_task = tokio::spawn(async move { + let (mut socket, _) = upstream.accept().await.unwrap(); + read_request(&mut socket).await; + socket + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\n\r\n", + sse_payload.len() + ) + .as_bytes(), + ) + .await + .unwrap(); + headers_observed_rx.await.unwrap(); + // Pause 35s (> legacy 30s READ_IDLE and > 2x 15s downstream heartbeat) before + // flushing the native SSE turn. + tokio::time::sleep(Duration::from_secs(35)).await; + socket.write_all(sse_payload.as_bytes()).await.unwrap(); + }); + + let directory = tempfile::tempdir().unwrap(); + let registry = Registry::from_providers( + AliasProvider::Codex, + [Arc::new(provider(directory.path(), &url).await) as Arc], + ); + let app = app_with_features( + Arc::new(registry), + None, + AppFeatures { + responses_api: true, + ..AppFeatures::default() + }, + ); + let req = http::Request::builder() + .method("POST") + .uri("/v1/messages") + .header("content-type", "application/json") + .body(Body::from(request(true).to_string())) + .unwrap(); + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), http::StatusCode::OK); + headers_observed_tx.send(()).unwrap(); + let mut body = response.into_body(); + let mut frames = Vec::new(); + while let Some(frame) = body.frame().await { + if let Ok(data) = frame.unwrap().into_data() { + frames.push(String::from_utf8(data.to_vec()).unwrap()); + } + } + native_task.await.unwrap(); + assert_eq!( + frames.len(), + 3, + "expected 2 heartbeats + final SSE message: {frames:?}" + ); + assert_eq!(frames[0], ": ping\n\n"); + assert_eq!(frames[1], ": ping\n\n"); + assert!(frames[2].contains("event: message_start")); + assert!(frames[2].contains("after deep thought")); + assert!(frames[2].contains("event: message_stop")); +} From c6d46ecd289471c0bc9566d4dd8074ef96b23d32 Mon Sep 17 00:00:00 2001 From: Chris Liu <11517021+chris7iu@users.noreply.github.com> Date: Thu, 12 Nov 2026 07:55:24 +0000 Subject: [PATCH 29/30] fix(antigravity): abort thinking-only streams on mid-stream native transport failure --- src/providers/antigravity/mod.rs | 44 +++++++++++++------ src/providers/antigravity_http_tests.rs | 58 +++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 14 deletions(-) diff --git a/src/providers/antigravity/mod.rs b/src/providers/antigravity/mod.rs index 393b9d1d..9483fe70 100644 --- a/src/providers/antigravity/mod.rs +++ b/src/providers/antigravity/mod.rs @@ -251,24 +251,40 @@ impl AntigravityProvider { tokio::select! { biased; result = work.as_mut() => { - let bytes = match result.and_then(|message| transport::sse(&message)) { - Ok(bytes) => bytes, + match result.and_then(|message| transport::sse(&message)) { + Ok(bytes) => Some(( + Ok::<_, std::io::Error>(bytes::Bytes::from(bytes)), + None, + )), Err(error) => { - shared.fail(format!("Antigravity: {error}")); - format!( - "event: error\ndata: {}\n\n", - json!({"type":"error","error":{"type":"api_error","message":format!("Antigravity: {error}")}}) - ) - .into_bytes() + let message = format!("Antigravity: {error}"); + shared.fail(message.clone()); + if matches!( + error, + "native stream transport failed" + | "native stream idle timeout" + | "native turn deadline exceeded" + ) { + Some(( + Err(std::io::Error::new( + std::io::ErrorKind::ConnectionReset, + message, + )), + None, + )) + } else { + let bytes = format!( + "event: error\ndata: {}\n\n", + json!({"type":"error","error":{"type":"api_error","message":message}}) + ) + .into_bytes(); + Some((Ok(bytes::Bytes::from(bytes)), None)) + } } - }; - Some(( - Ok::<_, std::convert::Infallible>(bytes::Bytes::from(bytes)), - None, - )) + } } _ = heartbeat.tick() => Some(( - Ok::<_, std::convert::Infallible>(bytes::Bytes::from_static(b": ping\n\n")), + Ok::<_, std::io::Error>(bytes::Bytes::from_static(b": ping\n\n")), Some((work, heartbeat, shared)), )), } diff --git a/src/providers/antigravity_http_tests.rs b/src/providers/antigravity_http_tests.rs index bcf098ae..5894cb4d 100644 --- a/src/providers/antigravity_http_tests.rs +++ b/src/providers/antigravity_http_tests.rs @@ -2581,3 +2581,61 @@ async fn antigravity_downstream_sse_heartbeats_bridge_long_buffered_turns() { assert!(frames[2].contains("after deep thought")); assert!(frames[2].contains("event: message_stop")); } + +#[tokio::test] +async fn antigravity_mid_stream_transport_drop_aborts_downstream_body_for_retry() { + use http_body_util::BodyExt; + use tower::ServiceExt; + + let upstream = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = url::Url::parse(&format!("http://{}", upstream.local_addr().unwrap())).unwrap(); + let (headers_observed_tx, headers_observed_rx) = tokio::sync::oneshot::channel(); + let native_task = tokio::spawn(async move { + let (mut socket, _) = upstream.accept().await.unwrap(); + read_request(&mut socket).await; + // Advertise Content-Length: 4096, write partial frame, then drop TCP socket mid-body. + socket + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: 4096\r\n\r\ndata: {\"response\":", + ) + .await + .unwrap(); + headers_observed_rx.await.unwrap(); + drop(socket); + }); + + let directory = tempfile::tempdir().unwrap(); + let monitor = MonitorHandle::new(10); + let registry = Registry::from_providers( + AliasProvider::Codex, + [Arc::new(provider(directory.path(), &url).await) as Arc], + ); + let app = app_with_features( + Arc::new(registry), + Some(monitor.clone()), + AppFeatures { + responses_api: true, + ..AppFeatures::default() + }, + ); + let req = http::Request::builder() + .method("POST") + .uri("/v1/messages") + .header("content-type", "application/json") + .body(Body::from(request(true).to_string())) + .unwrap(); + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), http::StatusCode::OK); + headers_observed_tx.send(()).unwrap(); + let result = response.into_body().collect().await; + native_task.await.unwrap(); + assert!( + result.is_err(), + "expected truncated native HTTP body to abort downstream body with io::Error" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + let snapshot = monitor.snapshot(); + assert!(snapshot.active.is_empty()); + assert_eq!(snapshot.recent.len(), 1); + assert_eq!(snapshot.recent[0].status, RequestStatus::Failed); +} From 3bb367903b82a47ac5ba9e5844dd735741622165 Mon Sep 17 00:00:00 2001 From: Chris Liu <11517021+chris7iu@users.noreply.github.com> Date: Thu, 12 Nov 2026 07:55:24 +0000 Subject: [PATCH 30/30] test(antigravity): wait for quiescent PTY backpressure across async ldisc drain --- .../antigravity/manual_terminal_tests.rs | 52 ++++++++++++++++--- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/src/providers/antigravity/manual_terminal_tests.rs b/src/providers/antigravity/manual_terminal_tests.rs index f20f961f..2d4ce096 100644 --- a/src/providers/antigravity/manual_terminal_tests.rs +++ b/src/providers/antigravity/manual_terminal_tests.rs @@ -320,7 +320,7 @@ impl PtyChild { Err(e) => panic!("PTY read: {e}"), } } - assert!(self.output.len() < 256 * 1024, "unbounded terminal output"); + assert!(self.output.len() < 512 * 1024, "unbounded terminal output"); } fn tty_text(&self) -> String { String::from_utf8_lossy(&self.output).into_owned() @@ -475,14 +475,52 @@ fn fill_output(terminal: &File) { .custom_flags(libc::O_NONBLOCK | libc::O_NOCTTY | libc::O_CLOEXEC) .open(std::ffi::OsStr::from_bytes(name.to_bytes())) .unwrap(); - for _ in 0..256 { - match filler.write(&[b'!'; 1024]) { - Ok(n) => assert!(n > 0), - Err(e) if e.kind() == io::ErrorKind::WouldBlock => return, - other => panic!("PTY fill failed: {other:?}"), + let mut total = 0usize; + let limit = 384 * 1024; + loop { + while total < limit { + match filler.write(&[b'!'; 1024]) { + Ok(n) => { + assert!(n > 0); + total += n; + } + Err(e) if e.kind() == io::ErrorKind::WouldBlock => break, + other => panic!("PTY fill failed: {other:?}"), + } + } + while total < limit { + match filler.write(&[b'!'; 1]) { + Ok(n) => { + assert!(n > 0); + total += n; + } + Err(e) if e.kind() == io::ErrorKind::WouldBlock => break, + other => panic!("PTY byte fill failed: {other:?}"), + } + } + assert!( + total < limit, + "fixture did not reach actual output backpressure" + ); + // Linux PTY slave writes first fill tty_port flip buffers before an + // async workqueue (flush_to_ldisc) drains up to N_TTY_BUF_SIZE into + // read_buf and re-asserts POLLOUT. Refill across a quiet poll window + // so both buffers are genuinely saturated before returning. + let mut pfd = libc::pollfd { + fd: filler.as_raw_fd(), + events: libc::POLLOUT, + revents: 0, + }; + let ready = unsafe { libc::poll(&mut pfd, 1, 30) }; + assert!( + ready >= 0, + "PTY poll failed: {}", + io::Error::last_os_error() + ); + if ready == 0 || (pfd.revents & libc::POLLOUT) == 0 { + return; } } - panic!("fixture did not reach actual output backpressure"); } #[test]