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 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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 [