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/5] 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/5] 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/5] 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/5] 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/5] 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;