diff --git a/Cargo.lock b/Cargo.lock index 7e1e9ce9..3bcd43f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -349,8 +349,11 @@ dependencies = [ "hostname", "http", "http-body-util", + "hyper", + "hyper-rustls", "hyper-util", "jiff", + "libc", "once_cell", "predicates", "prost", @@ -1659,9 +1662,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", @@ -3002,6 +3005,6 @@ dependencies = [ [[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 267820da..fb8151ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ publish = false [dependencies] clap = { version = "4.5", features = ["derive", "env"] } serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" +serde_json = { version = "1.0", features = ["float_roundtrip"] } anyhow = "1.0" thiserror = "2.0" axum = { version = "0.8", features = ["macros", "multipart"] } @@ -35,10 +35,12 @@ once_cell = "1.20" regex-lite = "0.1" tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] } tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } -rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } rustls-native-certs = "0.8" webpki-roots = "1" -hyper-util = { version = "0.1", features = ["client-proxy"] } +hyper = { version = "1.10.1", default-features = false, features = ["client", "http1", "http2"] } +hyper-rustls = { version = "0.27.9", default-features = false, features = ["http1", "http2", "ring", "tls12"] } +hyper-util = { version = "0.1", features = ["client-legacy", "client-proxy", "http1", "http2", "tokio"] } url = "2" prost = "0.13" flate2 = "1.0" @@ -47,7 +49,11 @@ crossterm = "0.28" jiff = { version = "0.2", default-features = false, features = ["std", "tz-system", "tzdb-zoneinfo"] } tiktoken-rs = { version = "0.12", default-features = false } +[target.'cfg(unix)'.dependencies] +libc = "=0.2.186" + [dev-dependencies] +hyper = { version = "1.10.1", default-features = false, features = ["server", "http1", "http2"] } tokio = { version = "1", features = ["test-util"] } tempfile = "3.14" assert_cmd = "2" diff --git a/README.md b/README.md index 30ab8260..22173f3f 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,40 @@ 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 adapter** supports +`antigravity/gemini-4-argon-low`, `antigravity/gemini-4-argon-medium`, +`antigravity/gemini-4-argon-high`, `antigravity/claude-opus-5-5-low`, +`antigravity/claude-opus-5-5-medium`, and `antigravity/claude-opus-5-5-high`. It does not run or import credentials from `agy`. +Its default HTTP User-Agent is `claude-code-proxy/`. An explicit +provider-specific override affects only native generation/project discovery, +not OAuth; it is compatibility metadata, not verified official-client identity. +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. + +Auto-review classifiers route by their own requested model, not the main conversation's +model. Recognized Claude aliases follow Codex/Kimi session affinity or `aliasProvider`; +Grok does not establish alias affinity. Explicit Antigravity requests ignore +cross-provider auto-review overrides. Same-provider overrides still undergo native +replay/model validation; signed history cannot silently change tiers. + +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 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. +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 eb1c7fd4..608939e0 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) | Browser PKCE; opt-in Unix `--manual` paste-back | Unsupported | Safe local readiness, optional `--json` | Delete only Antigravity credential | Examples: @@ -80,7 +81,54 @@ 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 login --manual +claude-code-proxy antigravity auth logout +``` + +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 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. + +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 +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..0ac23d89 100644 --- a/docs/src/content/docs/reference/compatibility-and-limitations.md +++ b/docs/src/content/docs/reference/compatibility-and-limitations.md @@ -82,6 +82,323 @@ 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 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 +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 | +| 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 | +| 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. + +### Consumer endpoint evidence + +The 2026-10-23–24 investigation observed an `agy` 1.4.6 consumer request using +`https://daily-cloudcode-pa.googleapis.com`; static analysis also found that its +consumer endpoint selector chooses daily. A controlled native comparison held the +profile, model and request settings fixed and changed only the native API origin: +production returned HTTP 429, daily returned HTTP 200 with a complete Anthropic +text SSE turn, expected marker and valid usage, then production returned HTTP 429 +again. This supports the daily default for this adapter's supported consumer OAuth +flow in that profile/time window, not a claim that every account or Google Cloud +tier must use daily. HTTP 429 alone does not establish exhausted subscription quota. + +Both project discovery and generation use the selected native origin. Explicit +`CCP_ANTIGRAVITY_BASE_URL` / `antigravity.baseUrl` overrides remain authoritative, +including production and custom trusted HTTPS origins. GCP/ADC/service-account +flows and account-tier endpoint selection are not implemented. No automatic +endpoint fallback, hostname rewrite, OAuth endpoint change or replay-format change +is introduced. Synthetic tests cover the complete nonstream assistant-content +carrier and tool-result round trip; neither those tests nor the live low-tier text +result establish all-tier or live tool/replay acceptance. + +### Evidence and remaining gates + +Synthetic Rust HTTP/OAuth tests and an isolated real-client replay fixture do not +prove Google acceptance, entitlement, billing or native model limits. Browser +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 +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. + +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 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 +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 +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 +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, device/session fingerprint fabrication 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 08514b10..683bf97f 100644 --- a/docs/src/content/docs/reference/configuration.md +++ b/docs/src/content/docs/reference/configuration.md @@ -23,12 +23,11 @@ These settings configure the proxy process. Claude Code client settings such as "reasoningSummary": "auto", "serviceTier": "fast", "baseUrl": "https://chatgpt.com/backend-api/codex/responses", - "transport": "auto", + "transport": "websocket", "headerTimeoutMs": 300000, "previousResponseId": false, "serverCompaction": false, "responsesApi": false, - "fullLane": false, "imagesApi": false, "imagesBaseUrl": "https://chatgpt.com/backend-api/codex" }, @@ -39,7 +38,7 @@ These settings configure the proxy process. Claude Code client settings such as }, "grok": { "baseUrl": "https://cli-chat-proxy.grok.com/v1", - "clientVersion": "1.0.13" + "clientVersion": "0.2.93" }, "opencode": { "apiKey": "YOUR_OPENCODE_GO_API_KEY", @@ -57,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 @@ -75,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. -Auto-review routing resolves the classifier's requested model first, not the main conversation's model. Recognized Claude aliases follow Codex/Kimi session affinity or `aliasProvider`; Grok and other providers do not establish alias affinity. 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 @@ -109,12 +108,11 @@ Proxy URLs may use `http`, `https`, `socks4`, `socks4a`, `socks5`, or `socks5h`. | `CCP_CODEX_REASONING_SUMMARY` | `codex.reasoningSummary` | unset | Overrides summary mode. `off` and `none` suppress summaries. | | `CCP_CODEX_SERVICE_TIER` | `codex.serviceTier` | unset | Forces `fast`, `priority`, `ultrafast`, or `flex` on `/v1/messages` requests. Fast is sent as `priority`. Ultrafast is sent as `priority` to models that do not offer it. | | `CCP_CODEX_BASE_URL` | `codex.baseUrl` | ChatGPT Codex Responses URL | Changes the Codex endpoint. | -| `CCP_CODEX_TRANSPORT` | `codex.transport` | `auto` | Selects `websocket`, `http`, or `auto`. `auto` falls back to HTTP SSE only when WebSocket setup fails before a request is sent. | +| `CCP_CODEX_TRANSPORT` | `codex.transport` | `websocket` | Selects `websocket`, `http`, or `auto`. | | `CCP_CODEX_HEADER_TIMEOUT_MS` | `codex.headerTimeoutMs` | `300000` | Sets how long an HTTP-transport request waits for the Codex response headers. Values below `1000` are ignored. | | `CCP_CODEX_PREVIOUS_RESPONSE_ID` | `codex.previousResponseId` | `false` | Enables append-only WebSocket continuation for `1`, `true`, or `yes`. | | `CCP_CODEX_SERVER_COMPACTION` | `codex.serverCompaction` | `false` | Enables or disables native compaction for standard boolean words. | | `CCP_CODEX_RESPONSES_API` | `codex.responsesApi` | `false` | Enables `/v1/responses` and `/v1/chat/completions` for every registered provider. Accepts `1`, `true`, or `yes`. | -| `CCP_CODEX_FULL_LANE` | `codex.fullLane` | `false` | Enables parallel tool calls on the six [supported full-lane models](/providers/codex/#responses-lanes-and-parallel-tools). Luna and catalog-only models keep their default lane. Accepts `1`, `true`, or `yes`. | | `CCP_CODEX_IMAGES_API` | `codex.imagesApi` | `false` | Enables `/v1/images/generations` and `/v1/images/edits` for `1`, `true`, or `yes`. | | `CCP_CODEX_IMAGES_BASE_URL` | `codex.imagesBaseUrl` | `https://chatgpt.com/backend-api/codex` | Sets the trusted Codex Images API root; production use is restricted to HTTPS `chatgpt.com/backend-api/codex`. | | `CCP_CODEX_ORIGINATOR` | `codex.originator` | `claude-code-proxy` | Changes the Codex `originator` header. | @@ -135,7 +133,7 @@ Proxy URLs may use `http`, `https`, `socks4`, `socks4a`, `socks5`, or `socks5h`. | Environment | Config key | Default | Purpose | | --- | --- | --- | --- | | `CCP_GROK_BASE_URL` | `grok.baseUrl` | `https://cli-chat-proxy.grok.com/v1` | Changes the Responses API base URL. | -| `CCP_GROK_CLIENT_VERSION` | `grok.clientVersion` | `1.0.13` | Changes the Grok client version header. | +| `CCP_GROK_CLIENT_VERSION` | `grok.clientVersion` | `0.2.93` | Changes the Grok client version header. | | `CCP_GROK_TOOL_IMAGE` | none | `omit` | Selects `omit`, `reattach`, `inline`, or `reject` image handling. | | `CCP_GROK_HOSTED_SEARCH` | none | off | Set to `1`, `on`, or `true` to let hosted search tools replace the caller's own search tools and force them on an explicit search turn. | | `CCP_GROK_SEARCH_BLOCKS` | none | `text` | Selects how a hosted search is reported: `text` for a text block, `native` for `server_tool_use` plus a `*_tool_result` block. | @@ -157,6 +155,238 @@ 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://daily-cloudcode-pa.googleapis.com` | Consumer OAuth native API origin. Explicit trusted HTTPS origin override; no userinfo, query, fragment, or path. Never automatically falls back to another endpoint. | +| `CCP_ANTIGRAVITY_USER_AGENT` | `antigravity.userAgent` | `claude-code-proxy/` | Full HTTP User-Agent override for native generation and `loadCodeAssist` only. OAuth token exchange/refresh always retain the default proxy identity. | + +The built-in daily origin serves both `loadCodeAssist` project discovery and +native generation for this adapter's supported consumer OAuth flow. Base URL +precedence remains environment, config file, then default. Explicit production or +custom trusted HTTPS origins remain unchanged; there is no hostname-based rewrite. +The adapter does not implement GCP/ADC/service-account authentication or account-tier +endpoint selection. This default is not a general Google Cloud routing policy; see +[endpoint evidence](/reference/compatibility-and-limitations/#consumer-endpoint-evidence). + +Remote endpoints require HTTPS, redirects are never followed, and OAuth endpoints +are fixed to `https://accounts.google.com/o/oauth2/v2/auth` and +`https://oauth2.googleapis.com/token`. Plain loopback HTTP endpoints exist only as +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 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 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 +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 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 +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 +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 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 +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. + +Refresh alone permits at most **two application HTTP send attempts**, separated +by 200 ms, under that same lock and original 30-second network deadline. The +second attempt requires at least 16 seconds remaining after backoff (the existing +15-second connection timeout plus a one-second response reserve). It is allowed +only when the pinned Reqwest/Hyper error graph proves the OAuth POST was not sent +and its first underlying I/O error is `ConnectionRefused` or `TimedOut`. The +complete, bounded graph includes wrapped I/O causes; TLS verification/protocol +errors, proxy authentication failures, unknown DNS errors and other unallowlisted +causes are not retried. Reqwest's private connector-deadline error is also not on +this I/O allowlist. A transport I/O timeout during TLS can qualify; there is no +blanket timeout or TLS-phase retry rule. Redirects and Reqwest's own retry policy +remain disabled. Hyper can internally reconnect an **unstarted** pooled request, +so two send attempts do not imply a maximum of two socket connections. + +If every failed attempt is proven pre-send, credentials are retained without +claiming an unknown rotation. A 30-second cooldown, scoped to the same profile, +lock epoch and refresh token, prevents further foreground refreshes in this +manager; after it expires, the next foreground request may start another bounded +flight. No background retry is scheduled. Metadata-only edits do not reset the +cooldown; a changed scope or successful persistence clears it. This bound is +manager-local, not cross-process or crash-durable. Safe connection failures, +cooldown and shutdown before a retry return a fixed, redacted HTTP 503 `api_error` +without `Retry-After`, before streaming or nonstreaming generation headers. A +shutdown detected before retry prevents the second send. + +An unresolved send, consumed request followed by connection loss, response/body +failure, or timeout while a request might have been sent remains ambiguous and +is **not retried**, including after a safe first failure. Those errors retain the +existing HTTP 401 `authentication_error` and obsolete-token protection. No model +generation or authorization-code exchange retries are added. These are this +proxy's source-audited guarantees, not a claim about the official `agy` client's +internal retry count or backoff. + +If persistence fails, the manager retains the replacement in memory, scoped to +the profile, lock epoch and superseded refresh token, and reports a typed pending- +persistence error. Subsequent requests retry the local write, not the obsolete +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/config.rs b/src/config.rs index ee1ff80a..8e7c425b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -246,7 +246,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()); @@ -394,7 +394,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(); } @@ -407,7 +407,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(); } @@ -578,17 +578,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 } @@ -597,7 +597,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(); } @@ -612,7 +612,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(); } @@ -627,7 +627,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(); } @@ -649,7 +649,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(); } @@ -667,7 +667,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(); } @@ -682,7 +682,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(); } @@ -700,7 +700,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"); } @@ -715,7 +715,7 @@ pub fn codex_previous_response_id() -> bool { } pub fn codex_request_compression() -> bool { - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); if let Some(raw) = env.get("CCP_CODEX_REQUEST_COMPRESSION") { match raw.trim().to_ascii_lowercase().as_str() { "1" | "true" | "yes" | "on" => return true, @@ -734,7 +734,7 @@ pub fn codex_request_compression() -> 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, @@ -753,7 +753,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"); } @@ -768,7 +768,7 @@ pub fn codex_responses_api() -> bool { } pub fn codex_full_lane() -> bool { - let env: HashMap<_, _> = std::env::vars().collect(); + let env: HashMap<_, _> = crate::environment::legacy_vars().collect(); if let Some(raw) = env.get("CCP_CODEX_FULL_LANE") { return matches!(raw.to_ascii_lowercase().as_str(), "1" | "true" | "yes"); } @@ -783,7 +783,7 @@ pub fn codex_full_lane() -> 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"); } @@ -798,7 +798,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"); } @@ -813,7 +813,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(); } @@ -828,7 +828,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()); } @@ -842,7 +842,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()); } @@ -856,7 +856,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()) @@ -874,7 +874,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()); } @@ -888,7 +888,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()) @@ -931,7 +931,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) { @@ -964,7 +964,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()) @@ -989,7 +989,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(); } @@ -1004,7 +1004,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(); } @@ -1039,7 +1039,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/main.rs b/src/main.rs index 1899dbba..29a62d7c 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,33 @@ enum Commands { }, } +#[derive(Debug, Subcommand)] +enum AntigravityGroup { + Auth { + #[command(subcommand)] + command: AntigravityAuth, + }, +} + +#[derive(Debug, Subcommand)] +enum AntigravityAuth { + Login { + /// 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)] + manual: bool, + }, + Device, + Status { + #[arg(long)] + json: bool, + }, + Logout, +} + #[derive(Debug, Subcommand)] enum ProviderGroup { Auth { @@ -186,6 +218,27 @@ 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 { 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 } => { + 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 +381,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; }; @@ -411,6 +464,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/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/provider.rs b/src/provider.rs index 2894f449..bf2262f2 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -27,6 +27,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; @@ -42,6 +47,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..9883e6c4 --- /dev/null +++ b/src/providers/antigravity/auth.rs @@ -0,0 +1,603 @@ +//! 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) 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, + 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 + } + #[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}"); + open_browser(authorization.as_str()); + Ok(()) + }).await + } + pub(crate) async fn login_with_opener( + &self, + opener: impl FnOnce(&url::Url) -> Result<()>, + ) -> Result<()> { + let attempt = self.begin_login().await?; + 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 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, &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()? != 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", &attempt.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) + .headers(self.config.native_headers()) + .json(&load_code_assist_payload()) + .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)?; + Ok(()) + } +} + +pub(crate) fn load_code_assist_payload() -> Value { + json!({"metadata":{"ideType":"ANTIGRAVITY"}}) +} + +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..9a562333 --- /dev/null +++ b/src/providers/antigravity/config.rs @@ -0,0 +1,454 @@ +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::{ffi::OsString, path::Path}; +use url::Url; + +pub(super) const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); + +#[derive(Clone)] +pub(crate) struct Config { + pub enabled: bool, + pub base_url: Url, + pub token_url: Url, + pub authorization_url: Url, + 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 { + fn default() -> Self { + Self { + enabled: false, + base_url: Url::parse("https://daily-cloudcode-pa.googleapis.com").unwrap(), + token_url: Url::parse("https://oauth2.googleapis.com/token").unwrap(), + authorization_url: Url::parse("https://accounts.google.com/o/oauth2/v2/auth").unwrap(), + client_id: None, + client_secret: None, + project: None, + native_user_agent: parse_user_agent(&default_user_agent()) + .expect("built-in proxy identity is a valid header"), + } + } +} + +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_os(key) + }) + } + + 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")? + } + 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", + "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")?; + } + 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 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(CONNECT_TIMEOUT) + .timeout(std::time::Duration::from_secs(180)) + .user_agent(default_user_agent()) + } + + pub fn client(&self) -> Result { + self.client_builder() + .build() + .map_err(|_| "Antigravity HTTP client initialization failed") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn antigravity_consumer_default_origin_preserves_oauth_and_identity() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.json"); + let mut configurations = vec![Config::default(), Config::read(&path, |_| None).unwrap()]; + std::fs::write(&path, br#"{"antigravity":{}}"#).unwrap(); + configurations.push(Config::read(&path, |_| None).unwrap()); + for config in configurations { + assert_eq!( + config.base_url.as_str(), + "https://daily-cloudcode-pa.googleapis.com/" + ); + assert_eq!( + config.token_url.as_str(), + "https://oauth2.googleapis.com/token" + ); + assert_eq!( + config.authorization_url.as_str(), + "https://accounts.google.com/o/oauth2/v2/auth" + ); + assert_eq!(config.native_headers()["user-agent"], default_user_agent()); + assert!(!config.enabled); + } + } + + #[test] + fn antigravity_consumer_base_url_precedence_preserves_explicit_origins() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.json"); + let daily = "https://daily-cloudcode-pa.googleapis.com"; + let production = "https://cloudcode-pa.googleapis.com"; + let custom = "https://cloudcode-pa.googleapis.com.example.invalid:8443"; + for (file, environment, expected) in [ + (None, None, daily), + (Some(production), None, production), + (Some(custom), None, custom), + (Some(daily), Some(production), production), + (Some(production), Some(custom), custom), + (Some(custom), Some(daily), daily), + ] { + let mut section = json!({}); + if let Some(origin) = file { + section["baseUrl"] = json!(origin); + } + std::fs::write(&path, json!({"antigravity":section}).to_string()).unwrap(); + let config = Config::read(&path, |key| { + if key == "CCP_ANTIGRAVITY_BASE_URL" { + environment.map(OsString::from) + } else { + None + } + }) + .unwrap(); + assert_eq!(config.base_url, Url::parse(expected).unwrap()); + } + } + + #[test] + fn antigravity_consumer_invalid_selected_origin_never_falls_back() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.json"); + for invalid in [ + "", + "http://example.invalid", + "http://127.0.0.1:1234", + "https://user:secret@example.invalid", + "https://example.invalid/native", + "https://example.invalid/?query=value", + "https://example.invalid/#fragment", + ] { + std::fs::write( + &path, + br#"{"antigravity":{"baseUrl":"https://cloudcode-pa.googleapis.com"}}"#, + ) + .unwrap(); + assert!( + Config::read(&path, |key| { + (key == "CCP_ANTIGRAVITY_BASE_URL").then(|| invalid.into()) + }) + .is_err() + ); + std::fs::write( + &path, + json!({"antigravity":{"baseUrl":invalid}}).to_string(), + ) + .unwrap(); + assert!(Config::read(&path, |_| None).is_err()); + } + std::fs::write(&path, br#"{"antigravity":{"authMethod":"gcp"}}"#).unwrap(); + assert!(Config::read(&path, |_| None).is_err()); + } + + #[test] + fn antigravity_user_agent_file_setting_is_accepted_without_enabling_provider() { + let directory = tempfile::tempdir().unwrap(); + 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/generation.rs b/src/providers/antigravity/generation.rs new file mode 100644 index 00000000..e66848e3 --- /dev/null +++ b/src/providers/antigravity/generation.rs @@ -0,0 +1,1517 @@ +//! Generation-only native HTTP. OAuth and refresh keep their separate reqwest client. + +use super::{ + config::{CONNECT_TIMEOUT, Config}, + replay::Result, +}; +use bytes::Bytes; +use futures_util::{StreamExt, stream::BoxStream}; +use http::{HeaderMap, HeaderValue, StatusCode, Uri, header}; +use http_body_util::BodyExt; +use hyper::rt::{Read, ReadBufCursor, Write as HttpWrite}; +use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder, MaybeHttpsStream}; +use hyper_util::{ + client::{ + legacy::{ + Client, + connect::{ + Connected, Connection, HttpConnector, + proxy::{SocksV4, SocksV5, Tunnel}, + }, + }, + proxy::matcher::Matcher, + }, + rt::{TokioExecutor, TokioIo, TokioTimer}, +}; +use serde_json::Value; +use std::{ + convert::Infallible, + error::Error, + future::Future, + io::{self, IoSlice, Write}, + pin::Pin, + sync::Arc, + task::{Context, Poll}, + time::Duration, +}; +use tokio::{net::TcpStream, time::Instant}; +use tower::Service; + +const MAX_TURN_BYTES: usize = 8 * 1024 * 1024; +const MAX_ERROR_BYTES: usize = 8 * 1024; +const DECODE_INPUT_CHUNK: usize = 1024; +// Match agy's sendModelStreamRequest / idleTimeoutReadCloser (300s) and Codex's +// HTTP/WS header + body idle timeouts (300_000ms). 1M-context prefill and deep +// reasoning pauses on Carbon/Argon/Opus 5.5 routinely exceed 30s before headers +// or between SSE chunks, while TCP+TLS handshakes stay bounded by CONNECT_TIMEOUT. +pub(super) const HEADER_TIMEOUT: Duration = Duration::from_secs(300); +pub(super) const READ_IDLE: Duration = Duration::from_secs(300); +pub(super) const TURN_DEADLINE: Duration = Duration::from_secs(3600); +type BoxError = Box; +type Connecting = + Pin, BoxError>> + Send>>; +type TcpIo = TokioIo; + +pub(super) struct GenerationClient { + client: Client, + native_headers: HeaderMap, +} + +impl GenerationClient { + pub(super) fn new(config: &Config) -> Result { + Config::validate_endpoint(config.base_url.as_str(), cfg!(test))?; + if config.base_url.path() != "/" { + return Err("invalid native origin"); + } + let connector = generation_connector()?; + let builder = generation_client_builder(TokioExecutor::new()); + Ok(Self { + client: builder.build(connector), + native_headers: config.native_headers(), + }) + } + + pub(super) async fn send( + &self, + endpoint: url::Url, + access: &str, + wire: &Value, + header_deadline: Instant, + request_deadline: Instant, + ) -> Result { + if endpoint.scheme() != "https" && !test_loopback_http(&endpoint) { + return Err("invalid native endpoint"); + } + // Preserve the existing prepared JSON/caller semantics; its source, + // schema and replay structures were bounded by request::prepare. + let bytes = serde_json::to_vec(wire).map_err(|_| "invalid native request JSON")?; + let uri = endpoint + .as_str() + .parse::() + .map_err(|_| "invalid native endpoint")?; + let body = reqwest::Body::wrap_stream(futures_util::stream::once(async move { + Ok::<_, Infallible>(Bytes::from(bytes)) + })); + let mut request = http::Request::builder() + .method("POST") + .uri(uri) + .body(body) + .map_err(|_| "invalid native request")?; + *request.headers_mut() = self.native_headers.clone(); + let mut authorization = HeaderValue::from_str(&format!("Bearer {access}")) + .map_err(|_| "invalid native authorization")?; + authorization.set_sensitive(true); + request + .headers_mut() + .insert(header::AUTHORIZATION, authorization); + request.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + request + .headers_mut() + .insert(header::ACCEPT_ENCODING, HeaderValue::from_static("gzip")); + // No reqwest client execution/default headers and no known byte SizeHint. + // HTTP/1.1 chunking or HTTP/2 DATA is chosen by the negotiated protocol. + if Instant::now() >= header_deadline || Instant::now() >= request_deadline { + return Err("native headers deadline exceeded"); + } + let response = self + .client + .request(request) + .await + .map_err(|_| "native headers transport failed")?; + let (parts, body) = response.into_parts(); + let stream = body + .into_data_stream() + .map(|chunk| chunk.map_err(|_| BodyError::Transport)) + .boxed(); + Ok(NativeResponse::new( + parts.status, + parts.headers, + stream, + request_deadline, + )) + } +} + +// Keep production and the owned-driver cancellation fixture on one policy. +// The executor is Hyper's existing boundary, not a transport/config preference. +fn generation_client_builder(executor: E) -> hyper_util::client::legacy::Builder +where + E: hyper::rt::Executor + Send>>> + + Send + + Sync + + Clone + + 'static, +{ + let mut builder = Client::builder(executor); + builder + .timer(TokioTimer::new()) + .pool_timer(TokioTimer::new()) + .retry_canceled_requests(false) + .pool_max_idle_per_host(8) + .pool_idle_timeout(Duration::from_secs(90)); + builder +} + +fn generation_connector() -> Result { + let tls = tls_configuration()?; + let mut http = HttpConnector::new(); + http.enforce_http(false); + http.set_nodelay(true); + http.set_connect_timeout(Some(CONNECT_TIMEOUT)); + http.set_keepalive(Some(Duration::from_secs(15))); + http.set_keepalive_interval(Some(Duration::from_secs(15))); + http.set_keepalive_retries(Some(3)); + let proxy_tls = HttpsConnectorBuilder::new() + .with_tls_config(tls.clone()) + .https_only() + .enable_http1() + .wrap_connector(http.clone()); + let mut proxy_headers = HeaderMap::new(); + // Baseline reqwest CONNECT uses its client-builder identity, not the + // per-request native UA override that belongs inside origin TLS. + proxy_headers.insert( + header::USER_AGENT, + HeaderValue::from_static(concat!("claude-code-proxy/", env!("CARGO_PKG_VERSION"))), + ); + let route = RouteConnector { + http: http.clone(), + proxy_tls, + proxy_headers, + matcher: Arc::new(Matcher::from_system()), + }; + let https = HttpsConnectorBuilder::new() + .with_tls_config(tls) + .https_only() + .enable_http1() + .enable_http2() + .wrap_connector(route); + Ok(Connector { + https, + #[cfg(test)] + test_http: http, + }) +} + +fn tls_configuration() -> Result { + let mut roots = rustls::RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + let native = rustls_native_certs::load_native_certs(); + let (valid, invalid) = roots.add_parsable_certificates(native.certs); + // Same acceptance policy as the existing reqwest client: tolerate partial + // native-store errors, but do not hide an entirely invalid native cert set. + if valid == 0 && invalid > 0 { + return Err("native TLS roots initialization failed"); + } + let provider = Arc::new(rustls::crypto::ring::default_provider()); + Ok(rustls::ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .map_err(|_| "native TLS versions initialization failed")? + .with_root_certificates(roots) + .with_no_client_auth()) +} + +fn test_loopback_http(endpoint: &url::Url) -> bool { + #[cfg(test)] + { + endpoint.scheme() == "http" + && endpoint.host().is_some_and(|host| match host { + url::Host::Ipv4(ip) => ip.is_loopback(), + url::Host::Ipv6(ip) => ip.is_loopback(), + _ => false, + }) + } + #[cfg(not(test))] + { + let _ = endpoint; + false + } +} + +#[derive(Clone)] +struct Connector { + https: HttpsConnector, + #[cfg(test)] + test_http: HttpConnector, +} + +impl Service for Connector { + type Response = MaybeHttpsStream; + type Error = BoxError; + type Future = Connecting; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.https.poll_ready(cx) + } + + fn call(&mut self, uri: Uri) -> Connecting { + #[cfg(test)] + if uri.scheme_str() == Some("http") { + let valid = uri + .host() + .and_then(|host| { + host.trim_matches(['[', ']']) + .parse::() + .ok() + }) + .is_some_and(|ip| ip.is_loopback()); + if valid { + // Existing unit fixtures only. Never proxy cleartext Bearer; + // normal-library integration tests use verified HTTPS instead. + let connecting = self.test_http.call(uri); + return Box::pin(connect_with_timeout(async move { + Ok(MaybeHttpsStream::Http(RouteIo::Plain(Box::new( + connecting.await?, + )))) + })); + } + } + let connecting = self.https.call(uri); + Box::pin(connect_with_timeout(connecting)) + } +} + +async fn connect_with_timeout(future: F) -> std::result::Result +where + F: Future>, +{ + tokio::time::timeout(CONNECT_TIMEOUT, future) + .await + .map_err(|_| { + Box::new(io::Error::new( + io::ErrorKind::TimedOut, + "native connect timeout", + )) as BoxError + })? +} + +#[derive(Clone)] +struct RouteConnector { + http: HttpConnector, + proxy_tls: HttpsConnector, + proxy_headers: HeaderMap, + matcher: Arc, +} + +impl Service for RouteConnector { + type Response = RouteIo; + type Error = BoxError; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, destination: Uri) -> Self::Future { + let mut http = self.http.clone(); + let proxy_tls = self.proxy_tls.clone(); + let proxy_headers = self.proxy_headers.clone(); + let intercepted = self.matcher.intercept(&destination); + Box::pin(async move { + let Some(proxy) = intercepted else { + return Ok(RouteIo::Plain(Box::new(http.call(destination).await?))); + }; + let scheme = proxy + .uri() + .scheme_str() + .ok_or_else(|| io::Error::other("invalid native proxy scheme"))?; + match scheme { + "http" => { + let mut tunnel = + Tunnel::new(tcp_proxy_uri(proxy.uri())?, http).with_headers(proxy_headers); + if let Some(auth) = proxy.basic_auth() { + tunnel = tunnel.with_auth(auth.clone()); + } + Ok(RouteIo::Plain(Box::new(tunnel.call(destination).await?))) + } + "https" => { + // The proxy TLS connector is HTTP/1 only for CONNECT. The + // independent outer HttpsConnector negotiates origin h1/h2. + let mut tunnel = + Tunnel::new(proxy.uri().clone(), proxy_tls).with_headers(proxy_headers); + if let Some(auth) = proxy.basic_auth() { + tunnel = tunnel.with_auth(auth.clone()); + } + Ok(RouteIo::ProxyTls(Box::new(tunnel.call(destination).await?))) + } + "socks4" | "socks4a" => { + // Keep the locked legacy helper's wire behavior. Its extra + // NUL after an empty USERID is incompatible with strict RFC + // SOCKS4/4a peers; negative baseline-matching tests document + // that inherited limitation, not positive support. + let target = socks_destination(&destination, scheme).await?; + let mut socks = SocksV4::new(tcp_proxy_uri(proxy.uri())?, http); + Ok(RouteIo::Plain(Box::new(socks.call(target).await?))) + } + "socks5" | "socks5h" => { + let target = socks_destination(&destination, scheme).await?; + let mut socks = SocksV5::new(tcp_proxy_uri(proxy.uri())?, http); + if let Some((user, password)) = proxy.raw_auth() { + socks = socks.with_auth(user.to_owned(), password.to_owned()); + } + Ok(RouteIo::Plain(Box::new(socks.call(target).await?))) + } + _ => Err(io::Error::other("unsupported native proxy scheme").into()), + } + }) + } +} + +async fn socks_destination(destination: &Uri, scheme: &str) -> std::result::Result { + if matches!(scheme, "socks4a" | "socks5h") { + return Ok(destination.clone()); + } + let host = destination + .host() + .ok_or_else(|| io::Error::other("native SOCKS destination missing"))? + .trim_matches(['[', ']']); + let port = destination.port_u16().unwrap_or(443); + // The cached helpers' local_dns switch performs blocking getaddrinfo. Resolve + // before their handshake so DNS is owned by the complete connect deadline. + // Their Uri interface cannot encode an IPv6 literal as an IP address (host() + // includes brackets); do not silently turn local DNS into proxy DNS. + let address = tokio::net::lookup_host((host, port)) + .await? + .find(|address| address.is_ipv4()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::Unsupported, + "native SOCKS local address unsupported", + ) + })?; + let mut parts = destination.clone().into_parts(); + parts.authority = Some(address.to_string().parse()?); + Ok(Uri::from_parts(parts)?) +} + +fn tcp_proxy_uri(proxy: &Uri) -> std::result::Result { + let default_port = match proxy.scheme_str() { + Some("http") => 80, + Some("https") => 443, + Some("socks4" | "socks4a" | "socks5" | "socks5h") => 1080, + _ => return Err(io::Error::other("unsupported native proxy scheme").into()), + }; + let host = proxy + .host() + .ok_or_else(|| io::Error::other("native proxy host missing"))?; + let host = if host.contains(':') && !host.starts_with('[') { + format!("[{host}]") + } else { + host.to_owned() + }; + Ok(Uri::builder() + .scheme("http") + .authority(format!( + "{host}:{}", + proxy.port_u16().unwrap_or(default_port) + )) + .path_and_query("/") + .build()?) +} + +enum RouteIo { + Plain(Box), + ProxyTls(Box>), +} + +impl Connection for RouteIo { + fn connected(&self) -> Connected { + match self { + Self::Plain(io) => io.connected(), + Self::ProxyTls(io) => io.connected(), + } + } +} + +impl Read for RouteIo { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buffer: ReadBufCursor<'_>, + ) -> Poll> { + match self.get_mut() { + Self::Plain(io) => Pin::new(io.as_mut()).poll_read(cx, buffer), + Self::ProxyTls(io) => Pin::new(io.as_mut()).poll_read(cx, buffer), + } + } +} + +impl HttpWrite for RouteIo { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + bytes: &[u8], + ) -> Poll> { + match self.get_mut() { + Self::Plain(io) => Pin::new(io.as_mut()).poll_write(cx, bytes), + Self::ProxyTls(io) => Pin::new(io.as_mut()).poll_write(cx, bytes), + } + } + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + Self::Plain(io) => Pin::new(io.as_mut()).poll_flush(cx), + Self::ProxyTls(io) => Pin::new(io.as_mut()).poll_flush(cx), + } + } + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + Self::Plain(io) => Pin::new(io.as_mut()).poll_shutdown(cx), + Self::ProxyTls(io) => Pin::new(io.as_mut()).poll_shutdown(cx), + } + } + fn is_write_vectored(&self) -> bool { + match self { + Self::Plain(io) => io.is_write_vectored(), + Self::ProxyTls(io) => io.is_write_vectored(), + } + } + fn poll_write_vectored( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + bytes: &[IoSlice<'_>], + ) -> Poll> { + match self.get_mut() { + Self::Plain(io) => Pin::new(io.as_mut()).poll_write_vectored(cx, bytes), + Self::ProxyTls(io) => Pin::new(io.as_mut()).poll_write_vectored(cx, bytes), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum BodyError { + ByteLimit, + Idle, + Deadline, + Transport, + Compression, + Encoding, +} +impl BodyError { + pub(super) fn message(self) -> &'static str { + match self { + Self::ByteLimit => "native turn byte limit", + Self::Idle => "native stream idle timeout", + Self::Deadline => "native turn deadline exceeded", + Self::Transport => "native stream transport failed", + Self::Compression => "invalid native gzip body", + Self::Encoding => "unsupported native content encoding", + } + } +} + +struct BoundedWriter { + bytes: Vec, + total: usize, + limit: usize, + exceeded: bool, +} +impl BoundedWriter { + fn new(limit: usize) -> Self { + Self { + bytes: Vec::new(), + total: 0, + limit, + exceeded: false, + } + } + fn take(&mut self) -> Bytes { + Bytes::from(std::mem::take(&mut self.bytes)) + } +} +impl Write for BoundedWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + let Some(total) = self + .total + .checked_add(bytes.len()) + .filter(|total| *total <= self.limit) + else { + self.exceeded = true; + return Err(io::Error::other("native decoded byte limit")); + }; + self.total = total; + self.bytes.extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +enum Decoder { + Identity, + Gzip(Box>), +} + +/// Original headers/hint describe ENCODED bytes. The typed reader yields only +/// validated decoded bytes and never erases byte/idle/deadline failures into a +/// reqwest::Error. Status/content-type remain available before any body work. +pub(crate) struct NativeResponse { + status: StatusCode, + headers: HeaderMap, + encoded_hint: Option, + body: BoxStream<'static, std::result::Result>, + remaining: Bytes, + encoded: usize, + limit: usize, + decoder: Decoder, + initial_error: Option, + deadline: Instant, + finished: bool, +} + +impl NativeResponse { + fn new( + status: StatusCode, + headers: HeaderMap, + body: BoxStream<'static, std::result::Result>, + deadline: Instant, + ) -> Self { + let limit = if status.is_success() { + MAX_TURN_BYTES + } else { + MAX_ERROR_BYTES + }; + let hint = encoded_hint(&headers); + let encoding = response_encoding(&headers); + let initial_error = match (hint.as_ref(), encoding.as_ref()) { + (Ok(Some(length)), _) if *length > limit as u64 => Some(BodyError::ByteLimit), + (Err(error), _) | (_, Err(error)) => Some(*error), + _ => None, + }; + let decoder = if encoding == Ok(true) { + Decoder::Gzip(Box::new(flate2::write::MultiGzDecoder::new( + BoundedWriter::new(limit), + ))) + } else { + Decoder::Identity + }; + Self { + status, + headers, + encoded_hint: hint.ok().flatten(), + body, + remaining: Bytes::new(), + encoded: 0, + limit, + decoder, + initial_error, + deadline, + finished: false, + } + } + pub(super) fn status(&self) -> StatusCode { + self.status + } + pub(super) fn headers(&self) -> &HeaderMap { + &self.headers + } + pub(super) fn encoded_length_hint(&self) -> Option { + self.encoded_hint + } + + fn fail(&mut self, error: BodyError) -> std::result::Result, BodyError> { + self.finished = true; + self.remaining = Bytes::new(); + self.body = futures_util::stream::empty().boxed(); + Err(error) + } + + pub(super) async fn chunk(&mut self) -> std::result::Result, BodyError> { + if let Some(error) = self.initial_error.take() { + return self.fail(error); + } + if self.finished { + return Ok(None); + } + let mut work = 0; + loop { + if Instant::now() >= self.deadline { + return self.fail(BodyError::Deadline); + } + if self.remaining.is_empty() { + let idle = (Instant::now() + READ_IDLE).min(self.deadline); + let next = match tokio::time::timeout_at(idle, self.body.next()).await { + Ok(Some(Ok(chunk))) => Some(chunk), + Ok(Some(Err(error))) => return self.fail(error), + Ok(None) => None, + Err(_) => { + return self.fail(if Instant::now() >= self.deadline { + BodyError::Deadline + } else { + BodyError::Idle + }); + } + }; + let Some(chunk) = next else { + self.finished = true; + return match &mut self.decoder { + Decoder::Identity => Ok(None), + Decoder::Gzip(decoder) => { + if decoder.header().is_none() || decoder.try_finish().is_err() { + Err(if decoder.get_ref().exceeded { + BodyError::ByteLimit + } else { + BodyError::Compression + }) + } else { + let bytes = decoder.get_mut().take(); + Ok((!bytes.is_empty()).then_some(bytes)) + } + } + }; + }; + let Some(total) = self + .encoded + .checked_add(chunk.len()) + .filter(|size| *size <= self.limit) + else { + return self.fail(BodyError::ByteLimit); + }; + self.encoded = total; + self.remaining = chunk; + if self.remaining.is_empty() { + work += 1; + if work == 32 { + tokio::task::yield_now().await; + work = 0; + } + continue; + } + } + let chunk = self + .remaining + .split_to(self.remaining.len().min(DECODE_INPUT_CHUNK)); + let output = match &mut self.decoder { + Decoder::Identity => chunk, + Decoder::Gzip(decoder) => { + if decoder.write_all(&chunk).is_err() { + let error = if decoder.get_ref().exceeded { + BodyError::ByteLimit + } else { + BodyError::Compression + }; + return self.fail(error); + } + decoder.get_mut().take() + } + }; + if !output.is_empty() { + return Ok(Some(output)); + } + work += 1; + if work == 32 { + tokio::task::yield_now().await; + work = 0; + } + } + } +} + +fn encoded_hint(headers: &HeaderMap) -> std::result::Result, BodyError> { + let mut length = None; + for value in headers.get_all(header::CONTENT_LENGTH).iter() { + for value in value.to_str().map_err(|_| BodyError::Transport)?.split(',') { + let value = value.trim(); + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(BodyError::Transport); + } + let parsed = value.parse::().map_err(|_| BodyError::Transport)?; + if length.is_some_and(|old| old != parsed) { + return Err(BodyError::Transport); + } + length = Some(parsed); + } + } + Ok(length) +} + +fn response_encoding(headers: &HeaderMap) -> std::result::Result { + let mut values = headers.get_all(header::CONTENT_ENCODING).iter(); + let Some(value) = values.next() else { + return Ok(false); + }; + if values.next().is_some() { + return Err(BodyError::Encoding); + } + let value = value.to_str().map_err(|_| BodyError::Encoding)?.trim(); + if value.eq_ignore_ascii_case("identity") { + Ok(false) + } else if value.eq_ignore_ascii_case("gzip") { + Ok(true) + } else { + Err(BodyError::Encoding) + } +} + +#[cfg(test)] +impl From for NativeResponse { + fn from(response: reqwest::Response) -> Self { + let status = response.status(); + let headers = response.headers().clone(); + let stream = response + .bytes_stream() + .map(|chunk| chunk.map_err(|_| BodyError::Transport)) + .boxed(); + Self::new( + status, + headers, + stream, + Instant::now() + Duration::from_secs(180), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use std::sync::atomic::{AtomicUsize, Ordering}; + + fn gzip(bytes: &[u8]) -> Vec { + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(bytes).unwrap(); + encoder.finish().unwrap() + } + + fn response(status: u16, encoding: Option<&str>, bytes: &[u8], chunk: usize) -> NativeResponse { + let mut headers = HeaderMap::new(); + if let Some(encoding) = encoding { + headers.insert("content-encoding", HeaderValue::from_str(encoding).unwrap()); + } + let chunks = bytes + .chunks(chunk) + .map(Bytes::copy_from_slice) + .map(Ok) + .collect::>(); + NativeResponse::new( + StatusCode::from_u16(status).unwrap(), + headers, + futures_util::stream::iter(chunks).boxed(), + Instant::now() + Duration::from_secs(180), + ) + } + + async fn decoded(mut response: NativeResponse) -> std::result::Result, BodyError> { + let mut result = Vec::new(); + while let Some(chunk) = response.chunk().await? { + result.extend_from_slice(&chunk); + } + Ok(result) + } + + #[tokio::test] + async fn antigravity_generation_gzip_fragments_require_complete_members() { + let first = gzip(b"first"); + let mut concatenated = first.clone(); + concatenated.extend(gzip(b"second")); + for chunk in [1, 3, 16, 4096] { + assert_eq!( + decoded(response(200, Some("GZip"), &concatenated, chunk)) + .await + .unwrap(), + b"firstsecond" + ); + let mut crc = first.clone(); + let index = crc.len() - 8; + crc[index] ^= 1; + let mut truncated = first.clone(); + truncated.pop(); + let mut junk = first.clone(); + junk.extend_from_slice(b"trailing"); + let mut partial = first.clone(); + partial.extend_from_slice(&[0x1f, 0x8b]); + for bytes in [Vec::new(), crc, truncated, junk, partial] { + assert_eq!( + decoded(response(200, Some("gzip"), &bytes, chunk)) + .await + .unwrap_err(), + BodyError::Compression + ); + } + } + } + + #[tokio::test] + async fn antigravity_generation_error_decode_limit_is_not_transport_error() { + let encoded = gzip(&vec![b'x'; MAX_ERROR_BYTES + 1]); + assert!(encoded.len() < MAX_ERROR_BYTES); + assert_eq!( + decoded(response(429, Some("gzip"), &encoded, 1)) + .await + .unwrap_err(), + BodyError::ByteLimit + ); + assert_eq!( + decoded(response(200, Some("identity"), b"plain", 1)) + .await + .unwrap(), + b"plain" + ); + } + + #[test] + fn antigravity_generation_bounded_sink_rejects_before_allocation() { + let mut sink = BoundedWriter::new(8); + assert!(sink.write_all(b"123456789").is_err()); + assert!(sink.bytes.is_empty()); + assert!(sink.exceeded); + let mut sink = BoundedWriter::new(8); + sink.write_all(b"12345678").unwrap(); + assert!(sink.write_all(b"9").is_err()); + assert_eq!(sink.bytes.len(), 8); + } + + #[tokio::test] + async fn antigravity_generation_original_encoded_hint_prevents_polling() { + for (status, limit) in [ + (StatusCode::OK, MAX_TURN_BYTES), + (StatusCode::TOO_MANY_REQUESTS, MAX_ERROR_BYTES), + ] { + let polls = Arc::new(AtomicUsize::new(0)); + let observed = polls.clone(); + let stream = futures_util::stream::poll_fn(move |_| { + observed.fetch_add(1, Ordering::SeqCst); + Poll::Ready(None) + }) + .boxed(); + let mut headers = HeaderMap::new(); + headers.insert( + "content-length", + HeaderValue::from_str(&(limit + 1).to_string()).unwrap(), + ); + headers.insert("content-encoding", HeaderValue::from_static("gzip")); + headers.insert("retry-after", HeaderValue::from_static("7")); + let mut native = NativeResponse::new( + status, + headers, + stream, + Instant::now() + Duration::from_secs(180), + ); + assert_eq!(native.encoded_length_hint(), Some((limit + 1) as u64)); + assert_eq!(native.status(), status); + assert_eq!(native.headers()["retry-after"], "7"); + assert_eq!(native.chunk().await.unwrap_err(), BodyError::ByteLimit); + assert_eq!(polls.load(Ordering::SeqCst), 0); + } + } + + #[tokio::test] + async fn antigravity_generation_all_content_encoding_values_are_validated() { + for values in [ + vec!["gzip", "gzip"], + vec!["identity", "gzip"], + vec!["gzip, identity"], + vec!["br"], + vec![""], + ] { + let mut headers = HeaderMap::new(); + for value in values { + headers.append("content-encoding", HeaderValue::from_str(value).unwrap()); + } + let mut native = NativeResponse::new( + StatusCode::OK, + headers, + futures_util::stream::empty().boxed(), + Instant::now() + Duration::from_secs(180), + ); + assert_eq!(native.chunk().await.unwrap_err(), BodyError::Encoding); + } + } + + #[tokio::test(start_paused = true)] + async fn antigravity_generation_idle_and_total_errors_remain_typed() { + for (budget, expected, elapsed) in [ + (3600, BodyError::Idle, READ_IDLE), + (180, BodyError::Deadline, Duration::from_secs(180)), + (10, BodyError::Deadline, Duration::from_secs(10)), + ] { + let started = Instant::now(); + let mut native = NativeResponse::new( + StatusCode::OK, + HeaderMap::new(), + futures_util::stream::pending().boxed(), + started + Duration::from_secs(budget), + ); + assert_eq!(native.chunk().await.unwrap_err(), expected); + assert_eq!(Instant::now() - started, elapsed); + } + assert_eq!(BodyError::ByteLimit.message(), "native turn byte limit"); + assert_eq!(BodyError::Idle.message(), "native stream idle timeout"); + assert_eq!( + BodyError::Deadline.message(), + "native turn deadline exceeded" + ); + } + + #[tokio::test(start_paused = true)] + async fn antigravity_generation_long_reasoning_pause_under_idle_budget_succeeds() { + let started = Instant::now(); + let stream = futures_util::stream::unfold(0_usize, |step| async move { + match step { + 0 => { + tokio::time::sleep(Duration::from_secs(120)).await; + Some((Ok(Bytes::from_static(b"data: first\n\n")), 1)) + } + 1 => { + tokio::time::sleep(Duration::from_secs(240)).await; + Some((Ok(Bytes::from_static(b"data: second\n\n")), 2)) + } + _ => None, + } + }) + .boxed(); + let mut native = NativeResponse::new( + StatusCode::OK, + HeaderMap::new(), + stream, + started + TURN_DEADLINE, + ); + assert_eq!( + native.chunk().await.unwrap().unwrap(), + Bytes::from_static(b"data: first\n\n") + ); + assert_eq!( + native.chunk().await.unwrap().unwrap(), + Bytes::from_static(b"data: second\n\n") + ); + assert_eq!(native.chunk().await.unwrap(), None); + assert_eq!(Instant::now() - started, Duration::from_secs(360)); + } + + #[tokio::test(start_paused = true)] + async fn antigravity_generation_gzip_header_progress_never_renews_total_deadline() { + let started = Instant::now(); + let stream = futures_util::stream::unfold(0_usize, |index| async move { + tokio::time::sleep(Duration::from_secs(5)).await; + Some(( + Ok(Bytes::from_static(if index == 0 { + &[0x1f] + } else { + &[0x8b] + })), + index + 1, + )) + }) + .boxed(); + let mut headers = HeaderMap::new(); + headers.insert("content-encoding", HeaderValue::from_static("gzip")); + let mut native = NativeResponse::new( + StatusCode::OK, + headers, + stream, + started + Duration::from_secs(12), + ); + assert_eq!(native.chunk().await.unwrap_err(), BodyError::Deadline); + assert_eq!(Instant::now() - started, Duration::from_secs(12)); + } + + #[tokio::test(start_paused = true)] + async fn antigravity_generation_connect_budget_covers_whole_connector_future() { + let started = Instant::now(); + let result = connect_with_timeout(futures_util::future::pending::< + std::result::Result<(), BoxError>, + >()) + .await; + assert!(result.is_err()); + assert_eq!(Instant::now() - started, CONNECT_TIMEOUT); + } + + #[tokio::test] + async fn antigravity_generation_local_socks_dns_is_resolved_before_handshake() { + let destination: Uri = "https://localhost:443/".parse().unwrap(); + for local in ["socks4", "socks5"] { + let resolved = socks_destination(&destination, local).await.unwrap(); + assert!( + resolved + .host() + .unwrap() + .parse::() + .unwrap() + .is_loopback() + ); + } + for remote in ["socks4a", "socks5h"] { + assert_eq!( + socks_destination(&destination, remote).await.unwrap(), + destination + ); + } + } + + #[tokio::test] + async fn antigravity_generation_drop_cancels_partial_gzip_success_and_error_readers() { + struct DropGuard(Arc); + impl Drop for DropGuard { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + for status in [StatusCode::OK, StatusCode::TOO_MANY_REQUESTS] { + let dropped = Arc::new(AtomicUsize::new(0)); + let guard = DropGuard(dropped.clone()); + let mut first = true; + let body = futures_util::stream::poll_fn(move |_| { + let _owned = &guard; + if first { + first = false; + Poll::Ready(Some(Ok(Bytes::from_static(&[0x1f])))) + } else { + Poll::Pending + } + }) + .boxed(); + let mut headers = HeaderMap::new(); + headers.insert("content-encoding", HeaderValue::from_static("gzip")); + let mut native = NativeResponse::new( + status, + headers, + body, + Instant::now() + Duration::from_secs(180), + ); + let mut reader = Box::pin(async move { native.chunk().await }); + assert!(futures_util::poll!(reader.as_mut()).is_pending()); + assert_eq!(dropped.load(Ordering::SeqCst), 0); + drop(reader); + assert_eq!( + dropped.load(Ordering::SeqCst), + 1, + "partial gzip state and owned upstream must drop without a detached collector" + ); + } + } + + #[tokio::test] + async fn antigravity_generation_empty_frames_yield_before_unbounded_work() { + let polls = Arc::new(AtomicUsize::new(0)); + let observed = polls.clone(); + let stream = futures_util::stream::poll_fn(move |_| { + let count = observed.fetch_add(1, Ordering::SeqCst); + if count < 4096 { + Poll::Ready(Some(Ok(Bytes::new()))) + } else { + Poll::Pending + } + }) + .boxed(); + let mut native = NativeResponse::new( + StatusCode::OK, + HeaderMap::new(), + stream, + Instant::now() + Duration::from_secs(180), + ); + let future = native.chunk(); + tokio::pin!(future); + assert!(futures_util::poll!(future.as_mut()).is_pending()); + assert!( + polls.load(Ordering::SeqCst) <= 32, + "empty encoded frames must yield within bounded work" + ); + } + + #[tokio::test] + async fn antigravity_generation_idle_socket_closes_without_a_followup_request() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = + url::Url::parse(&format!("http://{}", listener.local_addr().unwrap())).unwrap(); + let (closed_tx, closed_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let service = hyper::service::service_fn( + |request: http::Request| async move { + let mut body = request.into_body(); + let mut count = 0; + while let Some(frame) = body.frame().await { + if let Ok(bytes) = frame.unwrap().into_data() { + count += bytes.len(); + assert!(count <= 4096); + } + } + Ok::<_, Infallible>( + http::Response::builder() + .status(200) + .header("content-type", "text/event-stream") + .body(http_body_util::Full::new(Bytes::from_static(b"idle"))) + .unwrap(), + ) + }, + ); + let closed = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(socket), service) + .await + .is_ok(); + let _ = closed_tx.send(closed); + }); + let client = GenerationClient::new(&Config { + base_url: origin.clone(), + ..Config::default() + }) + .unwrap(); + let started = Instant::now(); + let mut native = client + .send( + origin, + "public-test-access", + &serde_json::json!({"synthetic":"idle"}), + started + Duration::from_secs(30), + started + Duration::from_secs(180), + ) + .await + .unwrap(); + while native.chunk().await.unwrap().is_some() {} + drop(native); + // Register the actual idle entry/task before shifting only this runtime's + // clock. Keep the client alive and issue NO second request. + tokio::time::sleep(Duration::from_millis(20)).await; + tokio::time::pause(); + tokio::time::advance(Duration::from_secs(91)).await; + tokio::task::yield_now().await; + tokio::time::resume(); + let closed = matches!( + tokio::time::timeout(Duration::from_secs(1), closed_rx).await, + Ok(Ok(true)) + ); + if !closed { + server.abort(); + } + assert!( + closed, + "idle pool must close its socket without a followup request" + ); + server.await.unwrap(); + drop(client); + } + + // These executor futures are owned and polled by the test, never detached. + // In the cached legacy client the first execute() is the H1 connection + // driver; later futures are pool readiness/idle work. Bound all of them. + type Driver = Pin + Send>>; + const MAX_QUEUE_TEST_TASKS: usize = 8; + + #[derive(Default)] + struct QueueDriverWake { + parent: futures_util::task::AtomicWaker, + wakes: AtomicUsize, + } + + impl futures_util::task::ArcWake for QueueDriverWake { + fn wake_by_ref(wake: &Arc) { + wake.wakes.fetch_add(1, Ordering::SeqCst); + wake.parent.wake(); + } + } + + #[derive(Clone, Default)] + struct QueueExecutor { + pending: Arc>>, + scheduled: Arc, + } + + impl hyper::rt::Executor for QueueExecutor + where + F: Future + Send + 'static, + { + fn execute(&self, future: F) { + let count = self.scheduled.fetch_add(1, Ordering::SeqCst); + assert!(count < MAX_QUEUE_TEST_TASKS, "bounded queue fixture tasks"); + self.pending.lock().unwrap().push(Box::pin(future)); + } + } + + struct QueueDrivers { + executor: QueueExecutor, + tasks: Vec>, + wake: Arc, + driver_polls: usize, + paused: bool, + } + + impl QueueDrivers { + fn new(executor: QueueExecutor) -> Self { + Self { + executor, + tasks: Vec::new(), + wake: Arc::default(), + driver_polls: 0, + paused: false, + } + } + + fn poll_tasks(&mut self, cx: &mut Context<'_>) { + self.wake.parent.register(cx.waker()); + // Include helper futures scheduled while polling, without holding + // the executor mutex across a poll or polling any task twice. + for index in 0..MAX_QUEUE_TEST_TASKS { + self.tasks + .extend(self.executor.pending.lock().unwrap().drain(..).map(Some)); + let Some(slot) = self.tasks.get_mut(index) else { + break; + }; + let Some(task) = slot.as_mut() else { + continue; + }; + let result = if index == 0 { + if self.paused { + continue; + } + self.driver_polls += 1; + let wake = futures_util::task::waker_ref(&self.wake); + task.as_mut().poll(&mut Context::from_waker(&wake)) + } else { + task.as_mut().poll(cx) + }; + if result.is_ready() { + *slot = None; + } + } + } + + async fn run(&mut self, future: F) -> F::Output { + tokio::pin!(future); + futures_util::future::poll_fn(|cx| { + let result = future.as_mut().poll(cx); + // Also settle the driver/readiness helpers when the operation + // completes, so a fully consumed warmup can enter the pool. + self.poll_tasks(cx); + result + }) + .await + } + + fn pause_ack(&mut self) -> (usize, usize) { + assert!(self.tasks.first().is_some_and(Option::is_some)); + self.paused = true; + (self.driver_polls, self.wake.wakes.load(Ordering::SeqCst)) + } + + fn cancel_paused_driver(&mut self) { + assert!(self.paused); + // Dropping Hyper's queued Envelope returns the untouched request + // with Error::Canceled; do not poll/write this driver after pause. + drop(self.tasks[0].take().expect("owned paused H1 driver")); + } + + async fn finish(mut self) { + self.paused = false; + let finished = tokio::time::timeout( + Duration::from_secs(1), + futures_util::future::poll_fn(|cx| { + self.poll_tasks(cx); + if self.tasks.iter().all(Option::is_none) + && self.executor.pending.lock().unwrap().is_empty() + { + Poll::Ready(()) + } else { + Poll::Pending + } + }), + ) + .await; + // Every future is still owned here even on timeout; dropping this + // set cancels it synchronously. No spawned driver can outlive it. + assert!(finished.is_ok(), "owned client drivers must finish"); + } + } + + struct QueueOriginTask(Option>); + + impl Drop for QueueOriginTask { + fn drop(&mut self) { + if let Some(task) = &self.0 { + task.abort(); + } + } + } + + async fn reused_canceled_queue_case(retry_counterfactual: bool) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin: Uri = format!("http://{}/native", listener.local_addr().unwrap()) + .parse() + .unwrap(); + let tcp = Arc::new(AtomicUsize::new(0)); + let native = Arc::new(AtomicUsize::new(0)); + let (stop_tx, mut stop_rx) = tokio::sync::oneshot::channel(); + let observed_tcp = tcp.clone(); + let observed_native = native.clone(); + let mut server = QueueOriginTask(Some(tokio::spawn(async move { + let mut connections = tokio::task::JoinSet::new(); + loop { + let socket = tokio::select! { + _ = &mut stop_rx => break, + accepted = listener.accept() => accepted.unwrap().0, + }; + assert!(observed_tcp.fetch_add(1, Ordering::SeqCst) < 2); + let native = observed_native.clone(); + connections.spawn(async move { + let service = hyper::service::service_fn( + move |request: http::Request| { + let native = native.clone(); + async move { + assert_eq!(request.method(), http::Method::POST); + assert_eq!(request.uri().path(), "/native"); + // Count native HEADERS, separately from TCP and + // before consuming the bounded synthetic body. + assert!(native.fetch_add(1, Ordering::SeqCst) < 2); + let mut body = request.into_body(); + let mut bytes = Vec::new(); + let mut frames = 0; + while let Some(frame) = body.frame().await { + frames += 1; + assert!(frames <= 8); + if let Ok(data) = frame.unwrap().into_data() { + assert!(bytes.len() + data.len() <= 4096); + bytes.extend_from_slice(&data); + } + } + assert_eq!(bytes, b"{\"synthetic\":\"queue\"}"); + Ok::<_, Infallible>(http::Response::new(http_body_util::Full::new( + Bytes::from_static(b"ok"), + ))) + } + }, + ); + // Driver cancellation may close the first idle socket. + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(socket), service) + .await; + }); + } + connections.abort_all(); + while let Some(joined) = connections.join_next().await { + assert!(joined.is_ok() || joined.unwrap_err().is_cancelled()); + } + }))); + let executor = QueueExecutor::default(); + let mut drivers = QueueDrivers::new(executor.clone()); + let mut builder = generation_client_builder(executor); + if retry_counterfactual { + // Only this synthetic counterfactual differs from production policy. + builder.retry_canceled_requests(true); + } + let client: Client<_, reqwest::Body> = builder.build(generation_connector().unwrap()); + let request = || { + http::Request::builder() + .method("POST") + .uri(origin.clone()) + .body(reqwest::Body::wrap_stream(futures_util::stream::once( + async { Ok::<_, Infallible>(Bytes::from_static(b"{\"synthetic\":\"queue\"}")) }, + ))) + .unwrap() + }; + tokio::time::timeout( + Duration::from_secs(2), + drivers.run(async { + let response = client.request(request()).await.unwrap(); + assert_eq!(response.version(), http::Version::HTTP_11); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.into_body().collect().await.unwrap().to_bytes(), + "ok" + ); + }), + ) + .await + .expect("bounded actual H1 warmup"); + assert_eq!(tcp.load(Ordering::SeqCst), 1); + assert_eq!(native.load(Ordering::SeqCst), 1); + let (paused_polls, before_enqueue_wakes) = drivers.pause_ack(); + let mut second = Box::pin(client.request(request())); + // No await/yield between this snapshot, one request poll, and the wake + // observation. On this current-thread runtime no timer, socket reactor, + // or helper task can produce an unrelated wake in this interval. + let request_wake = futures_util::task::noop_waker(); + let mut request_cx = Context::from_waker(&request_wake); + assert!(second.as_mut().poll(&mut request_cx).is_pending()); + assert!(drivers.wake.wakes.load(Ordering::SeqCst) > before_enqueue_wakes); + assert_eq!(drivers.driver_polls, paused_polls); + assert_eq!(tcp.load(Ordering::SeqCst), 1, "no second TCP while gated"); + assert_eq!( + native.load(Ordering::SeqCst), + 1, + "second HEADERS not dispatched" + ); + drivers.cancel_paused_driver(); + let result = tokio::time::timeout(Duration::from_secs(2), drivers.run(second.as_mut())) + .await + .expect("bounded canceled-queue outcome"); + if retry_counterfactual { + let response = result.expect("retry=true must recover the untouched reused request"); + assert_eq!(response.status(), StatusCode::OK); + let body = tokio::time::timeout( + Duration::from_secs(2), + drivers.run(response.into_body().collect()), + ) + .await + .unwrap() + .unwrap() + .to_bytes(); + assert_eq!(body, "ok"); + } else { + let error = result.expect_err("production retry=false must be terminal"); + assert!(!error.is_connect(), "not a cold connect failure"); + assert!(error.connect_info().is_some()); + assert!( + error.source().is_some_and(|source| { + source + .downcast_ref::() + .is_some_and(hyper::Error::is_canceled) + }), + "queued request must produce Hyper cancellation" + ); + } + let expected_total = if retry_counterfactual { 2 } else { 1 }; + // Totals INCLUDE the one successful warmup. Second-request-only counts + // are respectively zero with production policy and one with retry=true. + assert_eq!(tcp.load(Ordering::SeqCst), expected_total); + assert_eq!(native.load(Ordering::SeqCst), expected_total); + assert_eq!( + tcp.load(Ordering::SeqCst) - 1, + usize::from(retry_counterfactual) + ); + assert_eq!( + native.load(Ordering::SeqCst) - 1, + usize::from(retry_counterfactual) + ); + drop(second); + drop(client); + drivers.finish().await; + stop_tx.send(()).unwrap(); + let mut server_task = server.0.take().unwrap(); + let stopped = tokio::time::timeout(Duration::from_secs(1), &mut server_task).await; + if stopped.is_err() { + server_task.abort(); + let _ = server_task.await; + panic!("bounded owned origin shutdown"); + } + stopped.unwrap().unwrap(); + } + + #[tokio::test(flavor = "current_thread")] + async fn antigravity_generation_reused_canceled_queue_is_not_retried() { + reused_canceled_queue_case(false).await; + } + + #[tokio::test(flavor = "current_thread")] + async fn antigravity_generation_reused_canceled_queue_retry_counterfactual() { + reused_canceled_queue_case(true).await; + } + + #[test] + fn antigravity_generation_proxy_tcp_authorities_and_default_ports() { + for (uri, expected) in [ + ("socks4://localhost/", "http://localhost:1080/"), + ("socks4a://localhost:123/", "http://localhost:123/"), + ("socks5://127.0.0.1/", "http://127.0.0.1:1080/"), + ("socks5h://[::1]/", "http://[::1]:1080/"), + ("http://localhost/", "http://localhost:80/"), + ("https://localhost/", "http://localhost:443/"), + ] { + assert_eq!( + tcp_proxy_uri(&uri.parse().unwrap()).unwrap().to_string(), + expected + ); + } + assert!(tcp_proxy_uri(&"ftp://localhost/".parse().unwrap()).is_err()); + } +} diff --git a/src/providers/antigravity/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..725b212c --- /dev/null +++ b/src/providers/antigravity/manual_terminal.rs @@ -0,0 +1,608 @@ +//! 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 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 (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, + 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!("\r\nError: {message}\r\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 manage_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.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?; + 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, None).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(()) + } + 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 { + 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. + } + } + } + } + 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); + } + } + } + } + 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. + tokio::time::sleep_until((Instant::now() + Duration::from_millis(2)).min(deadline)) + .await; + if Instant::now() >= deadline { + 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, + 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(descriptor.revents); + } + } + } +} +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()); + } + } +} + +// 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, + 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 cancels(byte) { + 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..2d4ce096 --- /dev/null +++ b/src/providers/antigravity/manual_terminal_tests.rs @@ -0,0 +1,1012 @@ +//! 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.manage_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 if mode == "echo-deadline" { + Duration::from_millis(800) + } 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 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"], 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(); + 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.manage_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 { + Self::with_columns(mode, 80) + } + fn with_columns(mode: &str, columns: u16) -> 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) }; + set_columns(&slave, columns); + 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; + 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 + ); + 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())) + // 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 { + 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() < 512 * 1024, "unbounded terminal output"); + } + 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 { + 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()))); + assert!( + !self.logs().contains(CODE), + "stdout/stderr/log disclosed input" + ); + for secret in [ + "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 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) }, + 0 + ); + } +} + +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(); + let mut total = 0usize; + let limit = 384 * 1024; + loop { + while total < limit { + match filler.write(&[b'!'; 1024]) { + Ok(n) => { + assert!(n > 0); + total += n; + } + Err(e) if e.kind() == io::ErrorKind::WouldBlock => break, + other => panic!("PTY fill failed: {other:?}"), + } + } + while total < limit { + match filler.write(&[b'!'; 1]) { + Ok(n) => { + assert!(n > 0); + total += n; + } + Err(e) if e.kind() == io::ErrorKind::WouldBlock => break, + other => panic!("PTY byte fill failed: {other:?}"), + } + } + assert!( + total < limit, + "fixture did not reach actual output backpressure" + ); + // Linux PTY slave writes first fill tty_port flip buffers before an + // async workqueue (flush_to_ldisc) drains up to N_TTY_BUF_SIZE into + // read_buf and re-asserts POLLOUT. Refill across a quiet poll window + // so both buffers are genuinely saturated before returning. + let mut pfd = libc::pollfd { + fd: filler.as_raw_fd(), + events: libc::POLLOUT, + revents: 0, + }; + let ready = unsafe { libc::poll(&mut pfd, 1, 30) }; + assert!( + ready >= 0, + "PTY poll failed: {}", + io::Error::last_os_error() + ); + if ready == 0 || (pfd.revents & libc::POLLOUT) == 0 { + return; + } + } +} + +#[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_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(), + 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 new file mode 100644 index 00000000..9483fe70 --- /dev/null +++ b/src/providers/antigravity/mod.rs @@ -0,0 +1,476 @@ +//! Experimental, opt-in native Argon subscription transport. +pub(crate) mod auth; +pub(crate) mod config; +pub(crate) mod generation; +#[cfg(any(unix, test))] +mod manual; +mod refresh; +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 generation::{GenerationClient, HEADER_TIMEOUT, TURN_DEADLINE}; +use replay::{Binding, Result}; +use serde_json::{Value, json}; +use std::{ + sync::{Arc, RwLock}, + time::Duration, +}; + +pub struct AntigravityProvider { + auth: Result>, + generation: Result, + // Advisory nonsecret VALUE snapshot only. Not an owner/trajectory store and + // never populated from count requests or untrusted transcript scope claims. + serving_scope: Arc>>, +} +impl Default for AntigravityProvider { + fn default() -> Self { + Self::new() + } +} +impl AntigravityProvider { + pub fn new() -> Self { + #[cfg(test)] + request::effects::note(request::effects::Kind::Factory); + let config = Config::load(); + let generation = config + .as_ref() + .map_err(|error| *error) + .and_then(GenerationClient::new); + let auth = config + .and_then(|config| Auth::new(config, Store::configured())) + .map(Arc::new); + Self { + auth, + generation, + serving_scope: Arc::new(RwLock::new(None)), + } + } + #[cfg(test)] + pub(crate) fn with_auth(auth: Auth) -> Self { + request::effects::note(request::effects::Kind::Factory); + let generation = GenerationClient::new(&auth.config); + Self { + auth: Ok(Arc::new(auth)), + generation, + serving_scope: Arc::new(RwLock::new(None)), + } + } + + /// Effect-free clone of an immutable nonsecret serving value. In particular, + /// do not substitute local_capability()/Store getters, which read profiles. + /// Poisoned/unavailable snapshot stays unknown; count never repairs it from + /// a caller claim. It is an estimate input, never generation authorization. + pub(crate) fn count_scope_snapshot(&self) -> Option { + self.serving_scope + .read() + .ok() + .and_then(|scope| scope.clone()) + } + + async fn generate(&self, body: MessagesRequest, ctx: RequestContext) -> Response { + let auth = match &self.auth { + Ok(auth) => auth, + Err(_) => return invalid("Antigravity configuration is invalid"), + }; + #[cfg(test)] + request::effects::note(request::effects::Kind::CredentialLoad); + let stored = match auth.store.load() { + Ok(value) => value, + Err(_) => return authentication("Antigravity credentials are invalid or unreadable"), + }; + let binding = binding(auth, &body, stored.as_ref()); + let mut prepared = match request::prepare(&body, &binding) { + Ok(p) => p, + Err(e) => return invalid(e), + }; + let generation = match &self.generation { + Ok(client) => client, + Err(_) => { + return upstream_error("Antigravity generation transport initialization failed"); + } + }; + #[cfg(test)] + request::effects::note(request::effects::Kind::Access); + let credential = match auth.access().await { + Ok(c) => c, + Err(error) => { + return match error { + auth::AccessError::PendingPersistence + | auth::AccessError::ConnectFailure + | auth::AccessError::ConnectCooldown + | auth::AccessError::StoppedBeforeSend + | auth::AccessError::Lock(_) + | auth::AccessError::Closed => json_error( + StatusCode::SERVICE_UNAVAILABLE, + "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", + ); + } + // Fresh UUIDs are allocated only after access/discovery and actual + // scope validation. Observed continuations keep carried IDs. + let metadata = request::metadata_millis() + .and_then(|millis| prepared.finalize_metadata(millis, request::metadata_uuid)); + if let Err(error) = metadata { + return upstream_error(error); + } + if let Some(monitor) = ctx.monitor.as_ref() { + monitor.model_resolved(&ctx.req_id, body.model.as_deref().unwrap_or_default()); + } + let endpoint = match auth + .config + .base_url + .join("/v1internal:streamGenerateContent?alt=sse") + { + Ok(v) => v, + Err(_) => return invalid("invalid native endpoint"), + }; + let request_started = tokio::time::Instant::now(); + let header_deadline = request_started + HEADER_TIMEOUT; + let request_deadline = request_started + TURN_DEADLINE; + let sent = tokio::time::timeout_at( + header_deadline, + generation.send( + endpoint, + &credential.access_token, + &prepared.wire, + header_deadline, + request_deadline, + ), + ) + .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() { + return transport::http_error(upstream, header_deadline).await; + } + 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 serving_scope = self.serving_scope.clone(); + let work = async move { + let turn = tokio::time::timeout_at(request_deadline, transport::collect(upstream)) + .await + .map_err(|_| "native turn deadline exceeded")??; + if let Some(monitor) = ctx.monitor.as_ref() { + 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 = match &prepared.metadata { + request::Metadata::Ready(state) => replay::encode( + &turn.contents, + &binding, + state, + &turn.stop_reason, + &prepared.display, + &mapping, + )?, + _ => return Err("native metadata was not finalized"), + }; + // Only actual successful access/binding plus a fully validated native + // turn/projection may publish these nonsecret count inputs. Errors or + // cancellation before this point publish nothing; no carrier claim is + // authoritative and this snapshot never authorizes generation. + if let Ok(mut published) = serving_scope.write() { + *published = Some(request::ServingScope { + profile_id: binding.profile_id.clone(), + project_id: binding.project_id.clone(), + }); + } + Ok::<_, &'static str>(transport::message(blocks, &model, &turn)) + }; + if !body.stream { + return match work.await { + Ok(message) => Json(message).into_response(), + Err(e) => upstream_error(e), + }; + } + // The returned Body owns this future, including the native response and + // decoder. No worker survives a downstream drop. Initial errors occurred above. + // Emit periodic SSE comment heartbeats while collecting the buffered native turn + // so standalone SSE clients (e.g. undici bodyTimeout = 300s) do not drop idle + // downstream bodies during multi-minute reasoning turns. + const DOWNSTREAM_SSE_HEARTBEAT: Duration = Duration::from_secs(15); + let outcome = NativeResponseOutcome::default(); + let shared = outcome.clone(); + let mut heartbeat = tokio::time::interval_at( + tokio::time::Instant::now() + DOWNSTREAM_SSE_HEARTBEAT, + DOWNSTREAM_SSE_HEARTBEAT, + ); + heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let stream = futures_util::stream::unfold( + Some((Box::pin(work), heartbeat, shared)), + |state| async move { + let (mut work, mut heartbeat, shared) = state?; + tokio::select! { + biased; + result = work.as_mut() => { + match result.and_then(|message| transport::sse(&message)) { + Ok(bytes) => Some(( + Ok::<_, std::io::Error>(bytes::Bytes::from(bytes)), + None, + )), + Err(error) => { + let message = format!("Antigravity: {error}"); + shared.fail(message.clone()); + if matches!( + error, + "native stream transport failed" + | "native stream idle timeout" + | "native turn deadline exceeded" + ) { + Some(( + Err(std::io::Error::new( + std::io::ErrorKind::ConnectionReset, + message, + )), + None, + )) + } else { + let bytes = format!( + "event: error\ndata: {}\n\n", + json!({"type":"error","error":{"type":"api_error","message":message}}) + ) + .into_bytes(); + Some((Ok(bytes::Bytes::from(bytes)), None)) + } + } + } + } + _ = heartbeat.tick() => Some(( + Ok::<_, std::io::Error>(bytes::Bytes::from_static(b": ping\n\n")), + Some((work, heartbeat, shared)), + )), + } + }, + ); + 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 scope = self.count_scope_snapshot(); + match request::estimate( + &body, + request::CountInputs { + serving_scope: scope.as_ref(), + configured_project: auth.config.project.as_deref(), + }, + ) { + Ok(estimate) => Json(json!({"input_tokens":estimate})).into_response(), + Err(error) => invalid(error), + } + } + async fn finish_maintenance(&self) -> anyhow::Result<()> { + 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 +} + +/// 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<()> { + 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..c8600761 --- /dev/null +++ b/src/providers/antigravity/refresh.rs @@ -0,0 +1,1385 @@ +//! 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::{CONNECT_TIMEOUT, Config}, +}; +use std::{ + error::Error as StdError, + io, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; +use tokio::{sync::watch, task::JoinHandle, time::Instant}; + +const RETRY_BACKOFF: Duration = Duration::from_millis(200); +const CONNECT_COOLDOWN: Duration = Duration::from_secs(30); +const RETRY_RESPONSE_RESERVE: Duration = Duration::from_secs(1); +const MAX_ERROR_NODES: usize = 16; + +type ErrorRef<'a> = &'a (dyn StdError + 'static); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SendFailure { + Ambiguous, + PreSendTransientRetryable, + PreSendNonRetryableSafe, +} + +// Complete both source() and io.get_ref() paths before granting pre-send proof. +// The latter is essential: io::Error::source can skip its contained error. +// Distinguish an active-path cycle from a shared child in the bounded graph. +fn error_graph<'a>(root: ErrorRef<'a>) -> Option>> { + fn visit<'a>( + error: ErrorRef<'a>, + nodes: &mut Vec>, + active: &mut Vec>, + ) -> Option<()> { + if active.iter().any(|seen| std::ptr::eq(*seen, error)) { + return None; + } + if nodes.iter().any(|seen| std::ptr::eq(*seen, error)) { + return Some(()); + } + if nodes.len() == MAX_ERROR_NODES { + return None; + } + nodes.push(error); + active.push(error); + if let Some(source) = error.source() { + visit(source, nodes, active)?; + } + if let Some(inner) = error + .downcast_ref::() + .and_then(io::Error::get_ref) + { + visit(inner, nodes, active)?; + } + active.pop(); + Some(()) + } + let mut nodes = Vec::with_capacity(MAX_ERROR_NODES); + visit(root, &mut nodes, &mut Vec::with_capacity(MAX_ERROR_NODES))?; + Some(nodes) +} + +fn classify_connect_graph(root: ErrorRef<'_>) -> SendFailure { + let Some(nodes) = error_graph(root) else { + return SendFailure::Ambiguous; + }; + let Some((index, first_hyper)) = nodes.iter().enumerate().find_map(|(index, error)| { + error + .downcast_ref::() + .map(|error| (index, error)) + }) else { + return SendFailure::Ambiguous; + }; + if !first_hyper.is_connect() || nodes[..index].iter().any(|error| error.is::()) { + return SendFailure::Ambiguous; + } + if nodes.iter().any(|error| error.is::()) { + return SendFailure::PreSendNonRetryableSafe; + } + let first_io = nodes[index + 1..] + .iter() + .find_map(|error| error.downcast_ref::()); + if first_io.is_some_and(|error| { + matches!( + error.kind(), + io::ErrorKind::ConnectionRefused | io::ErrorKind::TimedOut + ) + }) { + SendFailure::PreSendTransientRetryable + } else { + SendFailure::PreSendNonRetryableSafe + } +} + +// Pinned reqwest 0.12.28 / hyper-util 0.1.20: Connect is constructed before +// request dispatch, including proxy CONNECT and TLS. This proof is specific to +// our buffered form, disabled redirects and disabled reqwest retry policy. +// Hyper may reconnect an unstarted pooled request internally: two .send() calls +// are not a limit of two sockets. Sent requests are never returned for that retry. +fn classify_send_error(error: &reqwest::Error) -> SendFailure { + let classification = classify_connect_graph(error); + // Only call reqwest's unbounded source walker after a complete acyclic graph. + if classification != SendFailure::Ambiguous && error.is_connect() { + classification + } else { + SendFailure::Ambiguous + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum NetworkPhase { + NotSent, + InFlight, + ProvenPreSend, +} + +fn retry_budget(remaining: Duration, before_backoff: bool) -> bool { + remaining + >= CONNECT_TIMEOUT + + RETRY_RESPONSE_RESERVE + + if before_backoff { + RETRY_BACKOFF + } else { + Duration::ZERO + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum AccessError { + Storage(&'static str), + Lock(&'static str), + Missing, + LoginRequired, + ClientConfiguration, + ConnectFailure, + ConnectCooldown, + StoppedBeforeSend, + 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::ConnectFailure => { + "Antigravity refresh connection failed before token submission; credentials retained" + } + Self::ConnectCooldown => { + "Antigravity refresh connection retry temporarily deferred; credentials retained" + } + Self::StoppedBeforeSend => { + "Antigravity refresh stopped before token submission; credentials retained" + } + Self::Network => { + "Antigravity refresh failed; its outcome may be unknown, no obsolete-token retry attempted" + } + 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<()>, +} +struct Cooldown { + scope: Scope, + until: Instant, +} +#[derive(Default)] +struct State { + flight: Option, + pending: Option, + uncertain: Option, + cooldown: 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, + #[cfg(test)] + first_failure_barrier: Option>, + #[cfg(test)] + send_attempts: AtomicUsize, +} +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)] + first_failure_barrier: None, + #[cfg(test)] + send_attempts: AtomicUsize::new(0), + }) + } + #[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; + state.cooldown = 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)?; + if state.closed { + return Err(AccessError::Closed); + } + if state.cooldown.as_ref().is_some_and(|cooldown| { + cooldown.scope.matches(&scope.epoch, &credential) && Instant::now() < cooldown.until + }) { + return Err(AccessError::ConnectCooldown); + } + state.cooldown = None; + } + let mut form = vec![ + ("grant_type", "refresh_token"), + ("refresh_token", refresh), + ("client_id", client_id), + ]; + if let Some(secret) = self.client_secret.as_deref() { + form.push(("client_secret", secret)); + } + let deadline = Instant::now() + self.network_timeout; + let mut phase = NetworkPhase::NotSent; + let result = tokio::time::timeout_at(deadline, async { + for attempt in 0..2 { + { + let mut state = self.state.lock().map_err(|_| AccessError::Closed)?; + if state.closed { + return Err(AccessError::StoppedBeforeSend); + } + if attempt > 0 + && !retry_budget(deadline.saturating_duration_since(Instant::now()), false) + { + return Err(AccessError::ConnectFailure); + } + // No cancellation point between declaring uncertainty and + // starting this attempt. An unresolved send/body stays unknown. + state.uncertain = Some(scope.clone()); + phase = NetworkPhase::InFlight; + } + #[cfg(test)] + self.send_attempts.fetch_add(1, Ordering::SeqCst); + let sent = self + .client + .post(self.token_url.clone()) + .timeout(deadline.saturating_duration_since(Instant::now())) + .form(&form) + .send() + .await; + let response = match sent { + Ok(response) => response, + Err(error) => { + let classification = classify_send_error(&error); + if classification == SendFailure::Ambiguous { + return Err(AccessError::Network); + } + phase = NetworkPhase::ProvenPreSend; + { + let mut state = self.state.lock().map_err(|_| AccessError::Closed)?; + state.uncertain = None; + if state.closed { + return Err(AccessError::StoppedBeforeSend); + } + } + if attempt == 1 || classification == SendFailure::PreSendNonRetryableSafe { + return Err(AccessError::ConnectFailure); + } + #[cfg(test)] + if let Some(barrier) = &self.first_failure_barrier { + barrier.pause().await; + } + if self.state.lock().map_err(|_| AccessError::Closed)?.closed { + return Err(AccessError::StoppedBeforeSend); + } + if !retry_budget(deadline.saturating_duration_since(Instant::now()), true) { + return Err(AccessError::ConnectFailure); + } + tokio::time::sleep(RETRY_BACKOFF).await; + // Loop entry rechecks shutdown and the 16s reserve AFTER + // backoff; scheduler delay cannot renew or steal the budget. + continue; + } + }; + let token = bounded_json(response) + .await + .map_err(|_| AccessError::InvalidResponse)?; + let mut replacement = credential.clone(); + update_tokens(&mut replacement, &token) + .map_err(|_| AccessError::InvalidResponse)?; + return Ok(replacement); + } + Err(AccessError::ConnectFailure) + }) + .await; + let result = match result { + Ok(result) => result, + Err(_) if phase == NetworkPhase::InFlight => Err(AccessError::NetworkTimeout), + Err(_) => Err(AccessError::ConnectFailure), + }; + let replacement = match result { + Err(AccessError::ConnectFailure) => { + let mut state = self.state.lock().map_err(|_| AccessError::Closed)?; + if state.closed { + return Err(AccessError::StoppedBeforeSend); + } + // Only complete pre-send proof (or never starting a send) reaches + // this cooldown. An ambiguous second attempt must never clear or + // hide uncertainty. This rate bound is manager-local, not durable. + state.cooldown = Some(Cooldown { + scope: scope.clone(), + until: Instant::now() + CONNECT_COOLDOWN, + }); + return Err(AccessError::ConnectFailure); + } + other => other?, + }; + // The manager's network deadline ended above. A disconnected waiter or + // elapsed network timer cannot discard the now validated replacement. + self.persist( + &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(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{error::Error, io, net::SocketAddr}; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + sync::Notify, + }; + + #[derive(Default)] + pub(super) struct RetryBarrier { + entered: Notify, + release: Notify, + } + impl RetryBarrier { + pub(super) async fn pause(&self) { + self.entered.notify_one(); + self.release.notified().await; + } + async fn wait(&self) { + tokio::time::timeout(Duration::from_secs(3), self.entered.notified()) + .await + .expect("first refusal reached retry barrier"); + } + } + + fn credential() -> Credential { + Credential { + profile_id: "synthetic-profile".into(), + access_token: "synthetic-expired-access".into(), + refresh_token: Some("synthetic-refresh".into()), + project_id: Some("synthetic-project".into()), + expires_at: 0, + } + } + + async fn fixture( + url: url::Url, + client: Option, + barrier: Option>, + budget: Duration, + ) -> (tempfile::TempDir, Arc) { + let directory = tempfile::tempdir().unwrap(); + let store = Store::new(directory.path().join("auth.json")); + let lock = store.lock(Duration::from_secs(1)).await.unwrap(); + store.save(&lock, &credential()).unwrap(); + drop(lock); + let config = Config { + token_url: url, + client_id: Some("synthetic-client".into()), + ..Config::default() + }; + let client = client.unwrap_or_else(|| config.client_builder().no_proxy().build().unwrap()); + let mut manager = RefreshManager::new(store, client, &config) + .with_timeouts(Duration::from_secs(1), budget); + Arc::get_mut(&mut manager).unwrap().first_failure_barrier = barrier; + (directory, manager) + } + + async fn closed_address() -> SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + listener.local_addr().unwrap() + } + fn token_url(address: SocketAddr) -> url::Url { + format!("http://{address}/token").parse().unwrap() + } + fn attempts(manager: &RefreshManager) -> usize { + manager.send_attempts.load(Ordering::SeqCst) + } + async fn failure(manager: &Arc) -> AccessError { + manager.access().await.err().expect("expected fixed error") + } + + async fn read_request(stream: &mut S) -> Vec { + let mut raw = Vec::new(); + let header_end = loop { + let mut byte = [0u8]; + stream.read_exact(&mut byte).await.unwrap(); + raw.push(byte[0]); + assert!(raw.len() < 8192); + if raw.ends_with(b"\r\n\r\n") { + break raw.len(); + } + }; + let headers = std::str::from_utf8(&raw).unwrap(); + let length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().unwrap()) + }) + .unwrap_or(0); + assert!(length < 8192); + raw.resize(header_end + length, 0); + stream.read_exact(&mut raw[header_end..]).await.unwrap(); + raw + } + + async fn successful_server(listener: TcpListener, calls: Arc) { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_request(&mut stream).await; + assert!(request.starts_with(b"POST /token HTTP/1.1\r\n")); + assert!( + request + .windows(b"refresh_token=synthetic-refresh".len()) + .any(|part| part == b"refresh_token=synthetic-refresh") + ); + calls.fetch_add(1, Ordering::SeqCst); + let body = r#"{"access_token":"rotated-access","refresh_token":"rotated-refresh","expires_in":3600,"token_type":"Bearer"}"#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).await.unwrap(); + } + + #[tokio::test] + async fn refresh_connect_real_refusal_then_success_coalesces_and_survives_cancelled_waiter() { + let address = closed_address().await; + let client = Config::default() + .client_builder() + .no_proxy() + .build() + .unwrap(); + let error = client + .post(token_url(address)) + .form(&[("refresh_token", "synthetic")]) + .send() + .await + .err() + .unwrap(); + // Positive transport guard: never widen this if dependency wrapping changes. + assert_eq!( + classify_send_error(&error), + SendFailure::PreSendTransientRetryable + ); + let barrier = Arc::new(RetryBarrier::default()); + let (_directory, manager) = fixture( + token_url(address), + Some(client), + Some(barrier.clone()), + Duration::from_secs(30), + ) + .await; + let leader = { + let manager = manager.clone(); + tokio::spawn(async move { manager.access().await }) + }; + barrier.wait().await; + let mut followers = Vec::new(); + for _ in 0..8 { + let manager = manager.clone(); + followers.push(tokio::spawn(async move { manager.access().await })); + } + tokio::time::timeout(Duration::from_secs(3), async { + loop { + if manager + .state + .lock() + .unwrap() + .flight + .as_ref() + .unwrap() + .waiters + .load(Ordering::Acquire) + == 9 + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + leader.abort(); + let _ = leader.await; + let listener = TcpListener::bind(address).await.unwrap(); + let calls = Arc::new(AtomicUsize::new(0)); + let server = tokio::spawn(successful_server(listener, calls.clone())); + barrier.release.notify_one(); + for follower in followers { + let result = follower.await.unwrap().unwrap(); + assert_eq!(result.refresh_token.as_deref(), Some("rotated-refresh")); + } + server.await.unwrap(); + assert_eq!(attempts(&manager), 2); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!( + manager + .store + .load() + .unwrap() + .unwrap() + .refresh_token + .as_deref(), + Some("rotated-refresh") + ); + assert!(manager.state.lock().unwrap().cooldown.is_none()); + manager.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn refresh_connect_two_refusals_cooldown_expiry_and_scope_rules() { + let (_directory, manager) = fixture( + token_url(closed_address().await), + None, + None, + Duration::from_secs(30), + ) + .await; + assert_eq!(failure(&manager).await, AccessError::ConnectFailure); + assert_eq!(attempts(&manager), 2); + for _ in 0..8 { + assert_eq!(failure(&manager).await, AccessError::ConnectCooldown); + } + assert_eq!(attempts(&manager), 2); + assert!(manager.state.lock().unwrap().uncertain.is_none()); + let mut same_scope = manager.store.load().unwrap().unwrap(); + same_scope.expires_at = 1; + same_scope.access_token = "incidental-access".into(); + same_scope.project_id = Some("incidental-project".into()); + let lock = manager.store.lock(Duration::from_secs(1)).await.unwrap(); + manager.store.save(&lock, &same_scope).unwrap(); + drop(lock); + assert_eq!(failure(&manager).await, AccessError::ConnectCooldown); + // Expire only cooldown, without pausing Tokio's real-socket clock. + manager + .state + .lock() + .unwrap() + .cooldown + .as_mut() + .unwrap() + .until = tokio::time::Instant::now(); + assert_eq!(failure(&manager).await, AccessError::ConnectFailure); + assert_eq!(attempts(&manager), 4); + for change in 0..3 { + let mut lock = manager.store.lock(Duration::from_secs(1)).await.unwrap(); + match change { + 0 => { + lock.advance().unwrap(); + } + 1 => { + same_scope.profile_id = "new-profile".into(); + } + _ => { + same_scope.refresh_token = Some("new-refresh".into()); + } + } + manager.store.save(&lock, &same_scope).unwrap(); + drop(lock); + assert_eq!(failure(&manager).await, AccessError::ConnectFailure); + assert_eq!(attempts(&manager), 6 + change * 2); + } + manager.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn refresh_connect_insufficient_budget_and_shutdown_before_retry_are_safe() { + let (_directory, manager) = fixture( + token_url(closed_address().await), + None, + None, + Duration::from_secs(2), + ) + .await; + assert_eq!(failure(&manager).await, AccessError::ConnectFailure); + assert_eq!(attempts(&manager), 1); + assert!(manager.state.lock().unwrap().uncertain.is_none()); + manager.shutdown().await.unwrap(); + + let barrier = Arc::new(RetryBarrier::default()); + let (_directory, manager) = fixture( + token_url(closed_address().await), + None, + Some(barrier.clone()), + Duration::from_secs(30), + ) + .await; + let waiter = { + let manager = manager.clone(); + tokio::spawn(async move { manager.access().await }) + }; + barrier.wait().await; + let closing = { + let manager = manager.clone(); + tokio::spawn(async move { manager.shutdown().await }) + }; + tokio::time::timeout(Duration::from_secs(3), async { + while !manager.state.lock().unwrap().closed { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + barrier.release.notify_one(); + assert_eq!( + waiter.await.unwrap().err(), + Some(AccessError::StoppedBeforeSend) + ); + closing.await.unwrap().unwrap(); + assert_eq!(attempts(&manager), 1); + assert!(manager.state.lock().unwrap().uncertain.is_none()); + assert_eq!(failure(&manager).await, AccessError::Closed); + } + + #[tokio::test] + async fn refresh_connect_deadline_during_proven_presend_pause_is_not_uncertain() { + let barrier = Arc::new(RetryBarrier::default()); + let (_directory, manager) = fixture( + token_url(closed_address().await), + None, + Some(barrier.clone()), + Duration::from_millis(150), + ) + .await; + let waiter = { + let manager = manager.clone(); + tokio::spawn(async move { manager.access().await }) + }; + barrier.wait().await; + assert_eq!( + waiter.await.unwrap().err(), + Some(AccessError::ConnectFailure) + ); + assert_eq!(attempts(&manager), 1); + assert!(manager.state.lock().unwrap().uncertain.is_none()); + manager.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn refresh_connect_sent_body_drop_partial_response_timeout_and_status_never_retry() { + for mode in 0..4 { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = token_url(listener.local_addr().unwrap()); + let received = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let server = { + let received = received.clone(); + let release = release.clone(); + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_request(&mut stream).await; + assert!(request.starts_with(b"POST ")); + received.notify_one(); + match mode { + 1 => { + stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 1000\r\n\r\n{\"access_token\":").await.unwrap(); + } + 2 => { + release.notified().await; + } + 3 => { + stream + .write_all(b"HTTP/1.1 503 Unavailable\r\nContent-Length: 0\r\n\r\n") + .await + .unwrap(); + } + _ => {} + } + }) + }; + let (_directory, manager) = fixture(url, None, None, Duration::from_millis(500)).await; + let result = failure(&manager).await; + assert!(matches!( + result, + AccessError::Network | AccessError::NetworkTimeout | AccessError::InvalidResponse + )); + received.notified().await; + assert_eq!(attempts(&manager), 1); + assert_eq!(failure(&manager).await, AccessError::UncertainRotation); + assert!(manager.state.lock().unwrap().cooldown.is_none()); + release.notify_one(); + server.await.unwrap(); + assert_eq!( + manager.shutdown().await, + Err(AccessError::UncertainRotation) + ); + } + } + + #[tokio::test] + async fn refresh_connect_safe_first_then_ambiguous_second_never_reuses_token() { + let address = closed_address().await; + let barrier = Arc::new(RetryBarrier::default()); + let (_directory, manager) = fixture( + token_url(address), + None, + Some(barrier.clone()), + Duration::from_secs(30), + ) + .await; + let waiter = { + let manager = manager.clone(); + tokio::spawn(async move { manager.access().await }) + }; + barrier.wait().await; + let listener = TcpListener::bind(address).await.unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + assert!(read_request(&mut stream).await.starts_with(b"POST ")); + }); + barrier.release.notify_one(); + assert_eq!(waiter.await.unwrap().err(), Some(AccessError::Network)); + server.await.unwrap(); + assert_eq!(attempts(&manager), 2); + assert_eq!(failure(&manager).await, AccessError::UncertainRotation); + assert_eq!(attempts(&manager), 2); + assert!(manager.state.lock().unwrap().cooldown.is_none()); + } + + #[tokio::test] + async fn refresh_connect_shutdown_during_second_send_joins_and_commits() { + let address = closed_address().await; + let barrier = Arc::new(RetryBarrier::default()); + let (_directory, manager) = fixture( + token_url(address), + None, + Some(barrier.clone()), + Duration::from_secs(30), + ) + .await; + let waiter = { + let manager = manager.clone(); + tokio::spawn(async move { manager.access().await }) + }; + barrier.wait().await; + let listener = TcpListener::bind(address).await.unwrap(); + let issued = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let server = { + let issued = issued.clone(); + let release = release.clone(); + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + assert!(read_request(&mut stream).await.starts_with(b"POST ")); + issued.notify_one(); + release.notified().await; + let body = r#"{"access_token":"rotated-access","refresh_token":"rotated-refresh","expires_in":3600}"#; + stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap(); + }) + }; + barrier.release.notify_one(); + tokio::time::timeout(Duration::from_secs(3), issued.notified()) + .await + .unwrap(); + waiter.abort(); + let _ = waiter.await; + let closing = { + let manager = manager.clone(); + tokio::spawn(async move { manager.shutdown().await }) + }; + tokio::time::timeout(Duration::from_secs(3), async { + while !manager.state.lock().unwrap().closed { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert!(!closing.is_finished()); + release.notify_one(); + closing.await.unwrap().unwrap(); + server.await.unwrap(); + assert_eq!(attempts(&manager), 2); + assert_eq!( + manager + .store + .load() + .unwrap() + .unwrap() + .refresh_token + .as_deref(), + Some("rotated-refresh") + ); + assert!(manager.state.lock().unwrap().uncertain.is_none()); + } + + #[derive(Debug)] + struct UnknownDns; + impl reqwest::dns::Resolve for UnknownDns { + fn resolve(&self, _: reqwest::dns::Name) -> reqwest::dns::Resolving { + Box::pin(async { + Err(Box::new(io::Error::other("synthetic-private-DNS")) + as Box) + }) + } + } + + #[tokio::test] + async fn refresh_connect_private_connector_timeout_and_unknown_dns_are_not_retried() { + let client = Config::default() + .client_builder() + .no_proxy() + .dns_resolver(Arc::new(UnknownDns)) + .build() + .unwrap(); + let (_directory, manager) = fixture( + "https://synthetic.invalid/token".parse().unwrap(), + Some(client), + None, + Duration::from_secs(30), + ) + .await; + assert_eq!(failure(&manager).await, AccessError::ConnectFailure); + assert_eq!(attempts(&manager), 1); + assert_eq!(failure(&manager).await, AccessError::ConnectCooldown); + manager.shutdown().await.unwrap(); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy = format!("http://{}", listener.local_addr().unwrap()); + let release = Arc::new(Notify::new()); + let server = { + let release = release.clone(); + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + assert!(read_request(&mut stream).await.starts_with(b"CONNECT ")); + release.notified().await; + }) + }; + // The shortened connector deadline is a test client setting only. It + // yields reqwest's private TimedOut, NOT an allowlisted io::TimedOut. + let client = Config::default() + .client_builder() + .no_proxy() + .proxy(reqwest::Proxy::https(proxy).unwrap()) + .connect_timeout(Duration::from_millis(500)) + .build() + .unwrap(); + let (_directory, manager) = fixture( + "https://localhost/token".parse().unwrap(), + Some(client), + None, + Duration::from_secs(30), + ) + .await; + assert_eq!(failure(&manager).await, AccessError::ConnectFailure); + assert_eq!(attempts(&manager), 1); + assert_eq!(failure(&manager).await, AccessError::ConnectCooldown); + release.notify_one(); + server.await.unwrap(); + manager.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn refresh_connect_nested_connect_under_send_request_is_ambiguous() { + let nested = hyper_connect_error(io::Error::from(io::ErrorKind::ConnectionRefused)).await; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = token_url(listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut bytes = Vec::new(); + let _ = + tokio::time::timeout(Duration::from_secs(2), stream.read_to_end(&mut bytes)).await; + }); + let body = + reqwest::Body::wrap_stream(futures_util::stream::iter([Err::( + nested, + )])); + let error = Config::default() + .client_builder() + .no_proxy() + .build() + .unwrap() + .post(url) + .body(body) + .send() + .await + .err() + .unwrap(); + assert!( + error.is_connect(), + "test must exercise incidental nested Connect" + ); + assert_eq!(classify_send_error(&error), SendFailure::Ambiguous); + server.await.unwrap(); + } + + // Public synthetic localhost fixtures generated offline for these tests only. + // This key is NOT a production credential. CA/leaf validity ends in 2046; + // only this test client's additional root trusts the CA, never production. + const TEST_CA: &str = "MIIBezCCASGgAwIBAgIUGLhYg9q/Al/VHS8Zw/knkCtNSYwwCgYIKoZIzj0EAwIwKzEpMCcGA1UEAwwgUHVibGljIHN5bnRoZXRpYyByZWZyZXNoIHRlc3QgQ0EwHhcNMjYxMDIxMTA1NzM5WhcNNDYxMDE2MTA1NzM5WjArMSkwJwYDVQQDDCBQdWJsaWMgc3ludGhldGljIHJlZnJlc2ggdGVzdCBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABKPFbXyxtoHecqg9QFPmnUaRwqMlr51ZGT7lWJrjU6wdiAm9QgN6bhod1c6l3jgBhd9f7tSV5aGPCrAF5fTIyH2jIzAhMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMCA0gAMEUCIQCNSOmNAj8vypFDV9QCxq0MKvqkp0vQ9DuGerTmk3ltwwIgTVWj2Zw4fWKWVsttU3GMe2L6s8+DtYcDgsfQmKlU8MI="; + const TEST_CERT: &str = "MIIBkjCCATigAwIBAgIUbFZU4Ue3etDnol7vW2OIhfdiZxowCgYIKoZIzj0EAwIwKzEpMCcGA1UEAwwgUHVibGljIHN5bnRoZXRpYyByZWZyZXNoIHRlc3QgQ0EwHhcNMjYxMDIxMTA1NzM5WhcNNDYxMDE2MTA1NzM5WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQPJVIZ/jqVeUIxkgg7aKln+59Edhs2wSlDuR8VfCgrD3Vw30c6XzGO6ztD29559ZoSCF6RQIYqPLQfNcY/pIOUo1EwTzAaBgNVHREEEzARgglsb2NhbGhvc3SHBH8AAAEwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwEwCgYIKoZIzj0EAwIDSAAwRQIhAK+Bm7mGxkegBKkFnOCn3fsQP405jmiH5rQ7kkmvamiQAiBRIjQG9siBjZLJk8eidWK49EkXb8fYXJtzZn5vnAUdGg=="; + const TEST_KEY: &str = "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgrHg+Obl2CHfYNy0VWcSYpRdGNktCYRTDWsnD/6tegyChRANCAAQPJVIZ/jqVeUIxkgg7aKln+59Edhs2wSlDuR8VfCgrD3Vw30c6XzGO6ztD29559ZoSCF6RQIYqPLQfNcY/pIOU"; + fn der(value: &str) -> Vec { + use base64::Engine; + base64::engine::general_purpose::STANDARD + .decode(value) + .unwrap() + } + fn tls_acceptor() -> tokio_rustls::TlsAcceptor { + let config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert( + vec![rustls::pki_types::CertificateDer::from(der(TEST_CERT))], + rustls::pki_types::PrivatePkcs8KeyDer::from(der(TEST_KEY)).into(), + ) + .unwrap(); + tokio_rustls::TlsAcceptor::from(Arc::new(config)) + } + + #[tokio::test] + async fn refresh_connect_proxy_auth_tls_certificate_and_tunneled_post_drop() { + // 407, bad certificate, then trusted tunnel which consumes the OAuth body. + for mode in 0..3 { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy = format!("http://{}", listener.local_addr().unwrap()); + let bodies = Arc::new(AtomicUsize::new(0)); + let seen = bodies.clone(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let connect = read_request(&mut stream).await; + assert!(connect.starts_with(b"CONNECT localhost:443 HTTP/1.1\r\n")); + assert!( + !connect + .windows(b"synthetic-refresh".len()) + .any(|p| p == b"synthetic-refresh") + ); + if mode == 0 { + stream.write_all(b"HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n").await.unwrap(); + return; + } + stream + .write_all(b"HTTP/1.1 200 Connection established\r\n\r\n") + .await + .unwrap(); + match tls_acceptor().accept(stream).await { + Ok(mut tls) => { + assert_eq!(mode, 2, "untrusted certificate unexpectedly accepted"); + let request = read_request(&mut tls).await; + assert!(request.starts_with(b"POST /token HTTP/1.1\r\n")); + seen.fetch_add(1, Ordering::SeqCst); + // Full OAuth body received; drop without a response. + } + Err(_) => { + assert_eq!(mode, 1); + } + } + }); + let mut builder = Config::default() + .client_builder() + .no_proxy() + .proxy(reqwest::Proxy::https(proxy).unwrap()); + if mode == 2 { + builder = builder + .add_root_certificate(reqwest::Certificate::from_der(&der(TEST_CA)).unwrap()); + } + let (_directory, manager) = fixture( + "https://localhost/token".parse().unwrap(), + Some(builder.build().unwrap()), + None, + Duration::from_secs(30), + ) + .await; + let result = failure(&manager).await; + server.await.unwrap(); + assert_eq!(attempts(&manager), 1); + if mode == 2 { + assert_eq!(result, AccessError::Network); + assert_eq!(bodies.load(Ordering::SeqCst), 1); + assert_eq!(failure(&manager).await, AccessError::UncertainRotation); + } else { + assert_eq!(result, AccessError::ConnectFailure); + assert_eq!(bodies.load(Ordering::SeqCst), 0); + assert_eq!(failure(&manager).await, AccessError::ConnectCooldown); + assert!(manager.state.lock().unwrap().uncertain.is_none()); + manager.shutdown().await.unwrap(); + } + } + } + + #[derive(Debug)] + struct Cause(Option>); + impl std::fmt::Display for Cause { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("synthetic-private-error") + } + } + impl Error for Cause { + fn source(&self) -> Option<&(dyn Error + 'static)> { + self.0.as_deref().map(|e| e as _) + } + } + #[derive(Debug)] + struct Cycle; + impl std::fmt::Display for Cycle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("synthetic-cycle") + } + } + impl Error for Cycle { + fn source(&self) -> Option<&(dyn Error + 'static)> { + Some(self) + } + } + + async fn hyper_connect_error(error: io::Error) -> hyper_util::client::legacy::Error { + let error = Arc::new(Mutex::new(Some(error))); + let connector = tower::service_fn(move |_: http::Uri| { + let error = error.lock().unwrap().take().unwrap(); + std::future::ready(Err::, _>( + error, + )) + }); + let client: hyper_util::client::legacy::Client<_, http_body_util::Empty> = + hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new()) + .build(connector); + client + .get("http://localhost/".parse().unwrap()) + .await + .err() + .unwrap() + } + + #[test] + fn refresh_connect_retry_budget_reserves_full_connect_window_after_backoff() { + assert_eq!(CONNECT_TIMEOUT, Duration::from_secs(15)); + assert!(!retry_budget(Duration::from_millis(16_199), true)); + assert!(retry_budget(Duration::from_millis(16_200), true)); + assert!(!retry_budget(Duration::from_millis(15_999), false)); + assert!(retry_budget(Duration::from_secs(16), false)); + for error in [ + AccessError::ConnectFailure, + AccessError::ConnectCooldown, + AccessError::StoppedBeforeSend, + ] { + assert!(!format!("{error} {error:?}").contains("synthetic-private")); + } + } + + #[tokio::test] + async fn refresh_connect_classifier_complete_graph_tls_deny_and_outer_io() { + for kind in [io::ErrorKind::ConnectionRefused, io::ErrorKind::TimedOut] { + let error = hyper_connect_error(io::Error::from(kind)).await; + assert_eq!( + classify_connect_graph(&error), + SendFailure::PreSendTransientRetryable + ); + } + let error = + hyper_connect_error(io::Error::other(io::Error::from(io::ErrorKind::TimedOut))).await; + assert_eq!( + classify_connect_graph(&error), + SendFailure::PreSendNonRetryableSafe + ); + let error = hyper_connect_error(io::Error::new( + io::ErrorKind::TimedOut, + rustls::Error::General("synthetic-private-TLS-error".into()), + )) + .await; + assert_eq!( + classify_connect_graph(&error), + SendFailure::PreSendNonRetryableSafe + ); + let error = io::Error::other( + hyper_connect_error(io::Error::from(io::ErrorKind::ConnectionRefused)).await, + ); + assert_eq!(classify_connect_graph(&error), SendFailure::Ambiguous); + assert_eq!(classify_connect_graph(&Cycle), SendFailure::Ambiguous); + let mut error: Box = + Box::new(hyper_connect_error(io::Error::from(io::ErrorKind::ConnectionRefused)).await); + // Hyper + IO consumes two of the 16 distinct-node allowance. + for _ in 0..14 { + error = Box::new(Cause(Some(error))); + } + assert_eq!( + classify_connect_graph(&*error), + SendFailure::PreSendTransientRetryable + ); + error = Box::new(Cause(Some(error))); + assert_eq!(classify_connect_graph(&*error), SendFailure::Ambiguous); + // IO.source and IO.get_ref reconverge on the same inner source. It must + // not be mistaken for a cycle or consume another distinct-node slot. + let inner = Cause(Some(Box::new(io::Error::from(io::ErrorKind::TimedOut)))); + let error = hyper_connect_error(io::Error::new(io::ErrorKind::TimedOut, inner)).await; + assert_eq!( + classify_connect_graph(&error), + SendFailure::PreSendTransientRetryable + ); + } +} diff --git a/src/providers/antigravity/replay.rs b/src/providers/antigravity/replay.rs new file mode 100644 index 00000000..82efedb0 --- /dev/null +++ b/src/providers/antigravity/replay.rs @@ -0,0 +1,965 @@ +//! 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(crate) const BACKEND_SESSION: &str = "-3750763034362895579"; +pub(crate) const MAX_CHECKPOINTS: u64 = 512; +pub const MAX_DECODED: usize = 8 * 1024 * 1024; +pub const MAX_ENCODED: usize = 12 * 1024 * 1024; +pub const MAX_ARGUMENT: usize = 1024 * 1024; +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") +} + +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)) +} + +/// Canonicalize an assistant turn's projected blocks from its leading +/// `ccp:antigravity:v1:` carrier envelope before `decode_history`. Because +/// upstream `v1internal` requests are reconstructed from `envelope["contents"]` +/// and `envelope["tool_mapping"]` inside the carrier rather than the client's +/// echoed blocks, client-side manipulations by Claude Code (such as hook +/// `updatedInput` rewrites, thinking-text clearing, text edits, or extra block +/// metadata) should not trigger a `projection digest mismatch`. +pub(crate) fn canonicalize_tool_inputs(blocks: &mut Value) { + let Some(list) = blocks.as_array() else { + return; + }; + if list.is_empty() + || list[0]["type"] != "thinking" + || list.iter().filter(|b| b["type"] == "thinking").count() != 1 + || list + .iter() + .any(|b| !matches!(b["type"].as_str(), Some("thinking" | "text" | "tool_use"))) + { + return; + } + let Some(signature) = list[0]["signature"].as_str().map(str::to_owned) else { + return; + }; + let Some(encoded) = signature.strip_prefix(PREFIX) else { + return; + }; + if encoded.is_empty() || encoded.len() > MAX_ENCODED || encoded.len() * 3 / 4 > MAX_DECODED { + return; + } + let Ok(raw) = URL_SAFE_NO_PAD.decode(encoded) else { + return; + }; + let Ok(envelope) = parse_json(&raw, MAX_DECODED) else { + return; + }; + let Ok(thought_display) = nonempty(&envelope["thought_display"]) else { + return; + }; + let Ok(mut expected) = projection( + &envelope["contents"], + thought_display, + &envelope["tool_mapping"], + ) else { + return; + }; + if let Some(first) = expected.as_array_mut().and_then(|b| b.first_mut()) { + first["signature"] = json!(signature); + *blocks = expected; + } +} + +/// Returns true when `blocks` carries a valid Antigravity envelope from a +/// different `native_model` (mid-session `/model` switch between Antigravity +/// models/tiers) or from an older envelope version (`v2`), so `request::prepare` +/// can include it in `prefix_messages` instead of failing `decode_history`. +pub(crate) fn is_prior_carrier_turn(blocks: &Value, current_native_model: &str) -> bool { + let Some(first) = blocks + .as_array() + .and_then(|b| b.first()) + .filter(|b| b["type"] == "thinking") + else { + return false; + }; + let Some(signature) = first["signature"].as_str() else { + return false; + }; + let (encoded, is_old_version) = if let Some(enc) = signature.strip_prefix(PREFIX) { + (enc, false) + } else if let Some(enc) = signature.strip_prefix("ccp:antigravity:v2:") { + (enc, true) + } else { + return false; + }; + if encoded.is_empty() || encoded.len() > MAX_ENCODED || encoded.len() * 3 / 4 > MAX_DECODED { + return false; + } + let Ok(raw) = URL_SAFE_NO_PAD.decode(encoded) else { + return false; + }; + let Ok(envelope) = parse_json(&raw, MAX_DECODED) else { + return false; + }; + let Ok(turn_model) = nonempty(&envelope["native_model"]) else { + return false; + }; + is_old_version || turn_model != current_native_model +} + +/// Extract only an UNTRUSTED scope claim for local estimation when no serving +/// snapshot exists. The caller must still run the original full version/digest/ +/// history validators with this binding. Never publish it or use it as admission. +pub(crate) fn claimed_binding( + blocks: &Value, + native_model: &str, + configured_project: Option<&str>, +) -> Result { + let first = blocks + .as_array() + .and_then(|b| b.first()) + .filter(|b| b["type"] == "thinking") + .ok_or("missing carrier")?; + let signature = nonempty(&first["signature"])?; + let encoded = signature + .strip_prefix(PREFIX) + .or_else(|| signature.strip_prefix("ccp:antigravity:v2:")) + .ok_or("missing/foreign carrier")?; + if encoded.is_empty() || encoded.len() > MAX_ENCODED || encoded.len() * 3 / 4 > MAX_DECODED { + return Err("carrier byte limit"); + } + let raw = URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| "malformed carrier")?; + let envelope = parse_json(&raw, MAX_DECODED)?; + let project = nonempty(&envelope["project_id"])?; + let turn_model = nonempty(&envelope["native_model"])?; + if !super::request::MODELS.contains(&format!("antigravity/{turn_model}").as_str()) + || configured_project.is_some_and(|p| p != project) + { + return Err("model/configured-project estimate scope mismatch"); + } + Ok(Binding { + profile_id: nonempty(&envelope["profile_id"])?.to_owned(), + project_id: project.to_owned(), + native_model: native_model.to_owned(), + }) +} + +fn uuid_v4(value: &str) -> Result<()> { + let id = uuid::Uuid::parse_str(value).map_err(|_| "invalid logical UUID")?; + if id.get_version_num() != 4 + || id.get_variant() != uuid::Variant::RFC4122 + || id.to_string() != value + { + return Err("invalid logical UUID"); + } + Ok(()) +} + +#[derive(Clone, Eq, PartialEq, serde::Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Lineage { + pub conversation_id: String, + pub trajectory_id: String, +} + +impl Lineage { + pub(crate) fn new(conversation_id: String, trajectory_id: String) -> Result { + let value = Self { + conversation_id, + trajectory_id, + }; + value.validate()?; + Ok(value) + } + + pub(crate) fn validate(&self) -> Result<()> { + for value in [&self.conversation_id, &self.trajectory_id] { + uuid_v4(value)?; + } + if self.conversation_id == self.trajectory_id { + return Err("logical UUID collision"); + } + Ok(()) + } +} + +#[derive(Clone, serde::Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct UserExecution { + pub active_id: String, + /// Last recorded predecessor when THIS user execution began. Immutable + /// through its tool loop; never minted while rendering the label. + pub prior_recorded_id: Option, +} + +#[derive(Clone, serde::Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct State { + pub lineage: Lineage, + /// Our native assistant-checkpoint ordinal, not a hidden Go counter. + pub native_checkpoint_index: u64, + pub user_execution: UserExecution, +} + +impl State { + pub(crate) fn validate(&self) -> Result<()> { + self.lineage.validate()?; + if self.native_checkpoint_index >= MAX_CHECKPOINTS { + return Err("checkpoint limit"); + } + let execution = &self.user_execution; + uuid_v4(&execution.active_id)?; + if let Some(prior) = &execution.prior_recorded_id { + uuid_v4(prior)?; + if self.native_checkpoint_index == 0 || prior == &execution.active_id { + return Err("invalid execution predecessor"); + } + } + for id in std::iter::once(&execution.active_id).chain(execution.prior_recorded_id.iter()) { + if id == &self.lineage.conversation_id || id == &self.lineage.trajectory_id { + return Err("execution/lineage UUID collision"); + } + } + Ok(()) + } + + /// Read only after bounded envelope validation. No allocation sources, + /// clock, auth/profile access or publication of authoritative scope. + pub(crate) fn from_envelope(envelope: &Value) -> Result { + keys( + &envelope["user_execution"], + &["active_id", "prior_recorded_id"], + &["active_id", "prior_recorded_id"], + )?; + let state: Self = serde_json::from_value(json!({ + "lineage":envelope["lineage"], + "native_checkpoint_index":envelope["native_checkpoint_index"], + "user_execution":envelope["user_execution"] + })) + .map_err(|_| "invalid state")?; + state.validate()?; + Ok(state) + } + + /// Successful final completion records active for the NEXT user. + pub(crate) fn recorded_after(&self, stop: &str) -> Result> { + match stop { + "end_turn" => Ok(Some(&self.user_execution.active_id)), + "tool_use" | "max_tokens" => Ok(self.user_execution.prior_recorded_id.as_deref()), + _ => Err("unsupported checkpoint stop reason"), + } + } +} + +fn stop_reason(stop: &str, mapping: &Value) -> Result<()> { + let has_calls = !mapping.as_array().ok_or("invalid tool mapping")?.is_empty(); + match stop { + "end_turn" if !has_calls => Ok(()), + "tool_use" if has_calls => Ok(()), + "max_tokens" => Ok(()), + _ => Err("inconsistent checkpoint stop reason"), + } +} + +pub(crate) fn digest_input(blocks: &Value, envelope: &Value) -> Result> { + let mut binding = Map::new(); + for key in [ + "version", + "profile_id", + "project_id", + "native_model", + "backend_session_id", + "lineage", + "native_checkpoint_index", + "user_execution", + "checkpoint_stop_reason", + "thought_display", + "tool_mapping", + ] { + binding.insert( + key.to_owned(), + envelope.get(key).ok_or("missing binding")?.clone(), + ); + } + canonical(&json!({ + "domain":"ccp:antigravity:v1:projection", "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, + state: &State, + checkpoint_stop_reason: &str, + thought_display: &str, + mapping: &Value, +) -> Result { + state.validate()?; + let mut blocks = projection(contents, thought_display, mapping)?; + stop_reason(checkpoint_stop_reason, mapping)?; + let mut envelope = serde_json::to_value(binding).map_err(|_| "invalid binding")?; + let fields = json!({ + "version":1, "backend_session_id":BACKEND_SESSION, "lineage":state.lineage, + "native_checkpoint_index":state.native_checkpoint_index, + "user_execution":state.user_execution, + "checkpoint_stop_reason":checkpoint_stop_reason, + "thought_display":thought_display, "contents":contents, "tool_mapping":mapping + }); + envelope + .as_object_mut() + .unwrap() + .extend(fields.as_object().unwrap().clone()); + envelope["projection_digest"] = json!(digest(&blocks, &envelope)?); + let raw = serde_json::to_vec(&envelope).map_err(|_| "invalid envelope")?; + parse_json(&raw, MAX_DECODED)?; + let encoded = URL_SAFE_NO_PAD.encode(raw); + if encoded.len() > MAX_ENCODED { + return Err("encoded byte limit"); + } + blocks[0]["signature"] = json!(format!("{PREFIX}{encoded}")); + decode(&blocks, binding)?; + Ok(blocks) +} + +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", + "backend_session_id", + "lineage", + "native_checkpoint_index", + "user_execution", + "checkpoint_stop_reason", + "thought_display", + "contents", + "tool_mapping", + "projection_digest", + ]; + keys(&envelope, &fields, &fields)?; + if envelope["version"].as_u64() != Some(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"); + } + } + if envelope["backend_session_id"] != BACKEND_SESSION { + return Err("invalid backend session"); + } + State::from_envelope(&envelope)?; + let expected = projection( + &envelope["contents"], + nonempty(&envelope["thought_display"])?, + &envelope["tool_mapping"], + )?; + stop_reason( + nonempty(&envelope["checkpoint_stop_reason"])?, + &envelope["tool_mapping"], + )?; + let hash = digest(&expected, &envelope)?; + if envelope["projection_digest"] != hash || digest(blocks, &envelope)? != hash { + return Err("projection digest mismatch"); + } + Ok(envelope) +} + +pub(crate) fn decode_history(turns: &Value, binding: &Binding) -> Result> { + let turns = turns + .as_array() + .filter(|t| t.len() <= MAX_CHECKPOINTS as usize) + .ok_or("history limit/type")?; + let mut result = Vec::new(); + let mut previous: Option<(State, String)> = None; + let mut executions = HashSet::new(); + let mut ids = HashSet::new(); + for (index, turn) in turns.iter().enumerate() { + let envelope = decode(turn, binding)?; + let state = State::from_envelope(&envelope)?; + if state.native_checkpoint_index != index as u64 { + return Err("conflicting checkpoint order"); + } + if let Some((prior, stop)) = &previous { + if prior.lineage != state.lineage { + return Err("conflicting logical conversation IDs"); + } + if state.user_execution.prior_recorded_id.as_deref() != prior.recorded_after(stop)? { + return Err("conflicting recorded execution predecessor"); + } + match stop.as_str() { + "end_turn" if executions.contains(&state.user_execution.active_id) => { + return Err("reused USER execution identity"); + } + "tool_use" if state.user_execution.active_id != prior.user_execution.active_id => { + return Err("tool loop changed active execution"); + } + "max_tokens" => { + return Err("unqualified continuation after incomplete checkpoint"); + } + _ => {} + } + } + executions.insert(state.user_execution.active_id.clone()); + previous = Some(( + state, + nonempty(&envelope["checkpoint_stop_reason"])?.to_owned(), + )); + for entry in envelope["tool_mapping"].as_array().unwrap() { + if !ids.insert(entry["local_id"].as_str().unwrap().to_owned()) { + return Err("duplicate local ID across history"); + } + } + 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..672d2153 --- /dev/null +++ b/src/providers/antigravity/request.rs @@ -0,0 +1,1121 @@ +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-carbon-low", + "antigravity/gemini-4-carbon-medium", + "antigravity/gemini-4-carbon-high", + "antigravity/gemini-4-argon-low", + "antigravity/gemini-4-argon-medium", + "antigravity/gemini-4-argon-high", + "antigravity/claude-opus-5-5-low", + "antigravity/claude-opus-5-5-medium", + "antigravity/claude-opus-5-5-high", +]; + +pub(crate) fn effective_tier(model: &str) -> Option<&'static str> { + match model { + "antigravity/gemini-4-carbon-low" + | "antigravity/gemini-4-argon-low" + | "antigravity/claude-opus-5-5-low" => Some("low"), + "antigravity/gemini-4-carbon-medium" + | "antigravity/gemini-4-argon-medium" + | "antigravity/claude-opus-5-5-medium" => Some("medium"), + "antigravity/gemini-4-carbon-high" + | "antigravity/gemini-4-argon-high" + | "antigravity/claude-opus-5-5-high" => Some("high"), + _ => None, + } +} + +#[cfg(test)] +pub(crate) mod effects { + use std::cell::Cell; + #[derive(Clone, Copy)] + pub(crate) enum Kind { + CredentialLoad, + Access, + Factory, + Clock, + NativeId, + } + thread_local! { static COUNTS: Cell<[usize;5]> = const { Cell::new([0;5]) }; } + pub(crate) fn note(kind: Kind) { + COUNTS.with(|cell| { + let mut counts = cell.get(); + counts[kind as usize] += 1; + cell.set(counts); + }); + } + pub(crate) fn reset() { + COUNTS.with(|cell| cell.set([0; 5])); + } + pub(crate) fn snapshot() -> [usize; 5] { + COUNTS.with(Cell::get) + } +} + +pub(crate) fn metadata_millis() -> Result { + #[cfg(test)] + effects::note(effects::Kind::Clock); + let millis = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|_| "invalid metadata clock")? + .as_millis(); + i64::try_from(millis).map_err(|_| "invalid metadata clock") +} + +pub(crate) fn metadata_uuid() -> String { + #[cfg(test)] + effects::note(effects::Kind::NativeId); + uuid::Uuid::new_v4().to_string() +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ServingScope { + pub profile_id: String, + pub project_id: String, +} + +/// Immutable, nonsecret VALUE inputs only. No Auth, Store, client, clock or ID +/// source is available to the estimate function. A missing serving scope does +/// not authorize generation: carrier scope is then an untrusted consistency +/// claim checked against the known model/configured project, never published. +#[derive(Clone, Copy)] +pub(crate) struct CountInputs<'a> { + pub serving_scope: Option<&'a ServingScope>, + pub configured_project: Option<&'a str>, +} + +pub(crate) enum Metadata { + Fresh, + Continuation { + state: replay::State, + new_user_execution: bool, + execution_ids: Vec, + }, + Ready(replay::State), +} + +pub(crate) struct Prepared { + pub wire: Value, + pub session: String, + pub display: String, + pub tools: ToolSet, + pub estimate: u64, + pub metadata: Metadata, +} + +impl Prepared { + /// Only called after unchanged access and actual binding validation. The ID + /// source is used at fresh/new-USER execution boundaries, NEVER to fabricate + /// last_execution_id at render time. Tool generations retain active/prior. + pub(crate) fn finalize_metadata( + &mut self, + unix_millis: i64, + mut uuid: impl FnMut() -> String, + ) -> Result<()> { + if unix_millis < 0 { + return Err("invalid metadata timestamp"); + } + // LOW is runtime-observed. MEDIUM/HIGH enum values are compiled public + // ModelSource facts, not a claim that all tiers were runtime-observed. + let model_enum = match self.wire["model"].as_str() { + Some("gemini-4-carbon-low") => "MODEL_PLACEHOLDER_M438", + Some("gemini-4-carbon-medium") => "MODEL_PLACEHOLDER_M437", + Some("gemini-4-carbon-high") => "MODEL_PLACEHOLDER_M427", + Some("gemini-4-argon-low") => "MODEL_PLACEHOLDER_M419", + Some("gemini-4-argon-medium") => "MODEL_PLACEHOLDER_M418", + Some("gemini-4-argon-high") => "MODEL_PLACEHOLDER_M417", + Some("claude-opus-5-5-low") => "MODEL_PLACEHOLDER_M400", + Some("claude-opus-5-5-medium") => "MODEL_PLACEHOLDER_M401", + Some("claude-opus-5-5-high") => "MODEL_PLACEHOLDER_M402", + _ => return Err("unsupported metadata model"), + }; + let state = match &self.metadata { + Metadata::Fresh => replay::State { + lineage: replay::Lineage::new(uuid(), uuid())?, + native_checkpoint_index: 0, + user_execution: replay::UserExecution { + active_id: uuid(), + prior_recorded_id: None, + }, + }, + Metadata::Continuation { + state, + new_user_execution, + execution_ids, + } => { + let mut state = state.clone(); + if *new_user_execution { + let active = uuid(); + if execution_ids.contains(&active) { + return Err("USER execution UUID collision"); + } + state.user_execution.active_id = active; + } + state + } + _ => return Err("metadata requires a fresh or validated continuation"), + }; + state.validate()?; + let native_checkpoint_index = state.native_checkpoint_index; + let last_step = native_checkpoint_index + .checked_mul(2) + .ok_or("step overflow")?; + let outer_step = last_step.checked_add(1).ok_or("step overflow")?; + let lineage = &state.lineage; + self.wire["request"]["labels"] = json!({ + "model_enum":model_enum, "used_claude":"false", "used_claude_conservative":"false", + "used_non_gemini_model":"false", "last_step_index":last_step.to_string(), + "cascade_id":lineage.conversation_id, "root_cascade_id":lineage.conversation_id, + "trajectory_id":lineage.trajectory_id, "request_id":format!("{}-{native_checkpoint_index}", lineage.trajectory_id) + }); + if let Some(prior) = &state.user_execution.prior_recorded_id { + self.wire["request"]["labels"]["last_execution_id"] = json!(prior); + } + self.wire["requestId"] = json!(format!( + "agent/{}/{unix_millis}/{}/{outer_step}", + lineage.conversation_id, lineage.trajectory_id + )); + self.session = replay::BACKEND_SESSION.to_owned(); + self.wire["request"]["sessionId"] = json!(self.session); + self.metadata = Metadata::Ready(state); + Ok(()) + } +} + +pub(crate) fn cache_hint(value: Option<&Value>) -> Result<()> { + let Some(value) = value else { return Ok(()) }; + let Some(obj) = value.as_object() else { + return Err("unsupported cache hint"); + }; + if obj.get("type").and_then(Value::as_str) != Some("ephemeral") { + return Err("unsupported cache hint"); + } + Ok(()) +} + +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(()) +} + +const CLAUDE_CODE_SYSTEM_HEADERS: [&str; 3] = [ + "You are Claude Code, Anthropic's official CLI for Claude.", + "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK.", + "You are a Claude agent, built on Anthropic's Claude Agent SDK.", +]; + +fn strip_claude_code_system_header(mut text: &str) -> Option<&str> { + let mut stripped_any = false; + loop { + let trimmed = text.trim_start_matches([' ', '\t', '\r', '\n']); + if let Some(rest) = trimmed.strip_prefix("x-anthropic-billing-header:") { + stripped_any = true; + let after_line = rest.split_once('\n').map_or("", |(_, tail)| tail); + text = after_line + .strip_prefix("\r\n") + .or_else(|| after_line.strip_prefix('\n')) + .unwrap_or(after_line); + continue; + } + if let Some(after_header) = CLAUDE_CODE_SYSTEM_HEADERS + .iter() + .find_map(|header| trimmed.strip_prefix(header)) + { + let after_ws = after_header.trim_start_matches([' ', '\t']); + if after_ws.is_empty() || after_ws.starts_with('\n') || after_ws.starts_with("\r\n") { + stripped_any = true; + let after_line = after_ws + .strip_prefix("\r\n") + .or_else(|| after_ws.strip_prefix('\n')) + .unwrap_or(after_ws); + text = after_line + .strip_prefix("\r\n") + .or_else(|| after_line.strip_prefix('\n')) + .unwrap_or(after_line); + continue; + } + } + break; + } + if stripped_any && text.trim().is_empty() { + None + } else { + Some(text) + } +} + +fn sanitize_system_parts(parts: Vec) -> Vec { + let filtered: Vec = parts + .into_iter() + .filter_map(|part| { + let text = part["text"].as_str()?; + strip_claude_code_system_header(text).map(|cleaned| json!({"text": cleaned})) + }) + .collect(); + if filtered.len() > 1 { + let joined = filtered + .iter() + .filter_map(|part| part["text"].as_str()) + .collect::>() + .join("\n\n"); + vec![json!({"text": joined})] + } else { + filtered + } +} + +fn text_parts(value: &Value) -> Result> { + if let Some(text) = value.as_str() { + return Ok(vec![json!({"text":text})]); + } + let mut parts = Vec::new(); + for block in value + .as_array() + .ok_or("content must be text or text blocks")? + { + match block["type"].as_str() { + Some("text") => { + keys(block, &["type", "text", "cache_control"], &["type", "text"])?; + cache_hint(block.get("cache_control"))?; + parts.push(json!({"text":block["text"].as_str().ok_or("text must be a string")?})); + } + Some("tool_reference") => { + keys( + block, + &["type", "tool_name", "cache_control"], + &["type", "tool_name"], + )?; + cache_hint(block.get("cache_control"))?; + let name = nonempty(&block["tool_name"])?; + parts.push(json!({"text":format!("Tool reference: {name}")})); + } + _ => return Err("non-text system/tool-result content is unsupported"), + } + } + Ok(parts) +} + +fn format_prefix_tool_result(block: &Value) -> Result { + keys( + block, + &[ + "type", + "tool_use_id", + "content", + "is_error", + "cache_control", + ], + &["type", "tool_use_id"], + )?; + cache_hint(block.get("cache_control"))?; + let id = nonempty(&block["tool_use_id"])?; + let rendered = match block.get("content") { + None => String::new(), + Some(Value::String(s)) => s.clone(), + Some(value) => text_parts(value)? + .iter() + .filter_map(|p| p["text"].as_str()) + .collect::>() + .join("\n"), + }; + let is_error = block + .get("is_error") + .map(|v| v.as_bool().ok_or("invalid is_error flag")) + .transpose()? + .unwrap_or(false); + Ok(if is_error { + format!("[Tool result ({id}) error: {rendered}]") + } else { + format!("[Tool result ({id}): {rendered}]") + }) +} + +fn user_parts(value: &Value, allow_prefix_tool_result: bool) -> Result> { + if !allow_prefix_tool_result { + return text_parts(value); + } + if let Some(text) = value.as_str() { + return Ok(vec![json!({"text":text})]); + } + let mut parts = Vec::new(); + for block in value + .as_array() + .ok_or("content must be text or text blocks")? + { + match block["type"].as_str() { + Some("text") => { + keys(block, &["type", "text", "cache_control"], &["type", "text"])?; + cache_hint(block.get("cache_control"))?; + parts.push(json!({"text":block["text"].as_str().ok_or("text must be a string")?})); + } + Some("tool_result") => { + let formatted = format_prefix_tool_result(block)?; + parts.push(json!({"text":formatted})); + } + _ => return Err("non-text system/tool-result content is unsupported"), + } + } + Ok(parts) +} + +fn options(body: &MessagesRequest) -> Result<&'static str> { + let allowed = [ + "system", + "tools", + "thinking", + "output_config", + "context_management", + "metadata", + "tool_choice", + "cache_control", + "service_tier", + "betas", + "anthropic_beta", + ]; + if body.extra.keys().any(|k| !allowed.contains(&k.as_str())) { + return Err("unsupported Antigravity semantic request option"); + } + let mut display = "full"; + if let Some(thinking) = body.extra.get("thinking") { + keys(thinking, &["type", "display", "budget_tokens"], &["type"])?; + match thinking["type"].as_str() { + Some("adaptive") => { + if thinking.get("budget_tokens").is_some() { + return Err("adaptive thinking does not take budget_tokens"); + } + } + Some("enabled") => { + if thinking + .get("budget_tokens") + .and_then(Value::as_u64) + .is_none_or(|n| n < 1024) + { + return Err( + "Antigravity supports adaptive thinking or enabled thinking with budget_tokens >= 1024", + ); + } + } + _ => { + return Err( + "Antigravity supports adaptive thinking only; model tier controls reasoning", + ); + } + } + if let Some(value) = thinking.get("display") { + match value.as_str() { + Some("omitted") => display = "omitted", + Some("full") => display = "full", + _ => return Err("unsupported thinking display"), + } + } + } + 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" | "xhigh" | "max" | "auto") + ) + }) { + 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") + && (!metadata.is_object() || metadata.get("user_id").is_some_and(|v| !v.is_string())) + { + return Err("invalid metadata"); + } + cache_hint(body.extra.get("cache_control"))?; + Ok(display) +} + +/// 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) +} + +fn coalesce_claude_native_turn(native: &[Value]) -> Vec { + let mut merged_parts: Vec = Vec::new(); + for content in native { + let Some(parts) = content["parts"].as_array() else { + continue; + }; + for part in parts { + if part["thought"] == true { + let chunk = part["text"].as_str().unwrap_or(""); + let sig = part.get("thoughtSignature").cloned(); + if let Some(last) = merged_parts.last_mut().filter(|p| p["thought"] == true) { + if let Some(Value::String(prev)) = last.get_mut("text") { + prev.push_str(chunk); + } + if let Some(sig) = sig { + last["thoughtSignature"] = sig; + } + } else { + let mut item = json!({"thought": true, "text": chunk}); + if let Some(sig) = sig { + item["thoughtSignature"] = sig; + } + merged_parts.push(item); + } + } else if part.get("functionCall").is_some() { + merged_parts.push(part.clone()); + } else if let Some(text) = part.get("text").and_then(Value::as_str) { + if text.is_empty() && part.get("thoughtSignature").is_none() { + continue; + } + if part.get("thoughtSignature").is_none() + && let Some(last) = merged_parts.last_mut().filter(|p| { + p.get("text").is_some() + && p.get("thought").is_none() + && p.get("thoughtSignature").is_none() + }) + { + if let Some(Value::String(prev)) = last.get_mut("text") { + prev.push_str(text); + } + } else { + merged_parts.push(part.clone()); + } + } else { + merged_parts.push(part.clone()); + } + } + } + merged_parts.retain(|p| p["thought"] != true || p.get("thoughtSignature").is_some()); + if merged_parts.is_empty() { + Vec::new() + } else { + vec![json!({"role": "model", "parts": merged_parts})] + } +} + +fn continuation_input(envelope: &Value, user: &Message) -> Result { + match nonempty(&envelope["checkpoint_stop_reason"])? { + "end_turn" => { + if text_parts(&user.content)?.is_empty() { + return Err("continuation requires ordinary text"); + } + Ok(true) + } + "tool_use" => Ok(false), + _ => Err("unqualified continuation after incomplete checkpoint"), + } +} + +fn has_polluted_prefix_artefact(content: &Value) -> bool { + let Some(blocks) = content.as_array() else { + return false; + }; + blocks.iter().any(|block| { + (block["type"] == "text" + && block["text"] + .as_str() + .is_some_and(|t| t.contains("[tool_use "))) + || (block["type"] == "tool_use" + && block["name"].as_str() == Some("DeferredToolPlaceholder")) + }) +} + +fn is_prior_or_unsigned_assistant(content: &Value, native_model: &str) -> bool { + content + .get(0) + .is_none_or(|block| block["type"] != "thinking") + || replay::is_prior_carrier_turn(content, native_model) + || has_polluted_prefix_artefact(content) +} + +fn observed_continuation( + messages: &[Cow<'_, Message>], + envelopes: &[Value], + model: &str, + has_prefix: bool, +) -> Result { + let ordered = messages + .iter() + .filter(|m| m.role != "system") + .collect::>(); + if !MODELS.contains(&model) + || envelopes.is_empty() + || ordered.len() != envelopes.len().saturating_mul(2).saturating_add(1) + || ordered + .iter() + .enumerate() + .any(|(i, m)| m.role != if i % 2 == 0 { "user" } else { "assistant" }) + { + return Err("unsupported continuation role sequence"); + } + if user_parts(&ordered[0].content, has_prefix)?.is_empty() { + return Err("request needs an initial text user"); + } + let mut new_user_execution = false; + for (index, envelope) in envelopes.iter().enumerate() { + new_user_execution = continuation_input(envelope, ordered[(index + 1) * 2])?; + } + let last = envelopes.last().unwrap(); + let mut state = replay::State::from_envelope(last)?; + state.native_checkpoint_index = state + .native_checkpoint_index + .checked_add(1) + .filter(|n| *n < replay::MAX_CHECKPOINTS) + .ok_or("checkpoint limit")?; + if new_user_execution { + state.user_execution.prior_recorded_id = state + .recorded_after(nonempty(&last["checkpoint_stop_reason"])?)? + .map(str::to_owned); + // Active is replaced only at the post-access USER execution boundary. + } + let execution_ids = envelopes + .iter() + .map(|e| { + e["user_execution"]["active_id"] + .as_str() + .unwrap() + .to_owned() + }) + .collect(); + Ok(Metadata::Continuation { + state, + new_user_execution, + execution_ids, + }) +} + +/// Local semantic estimate only, never generation admission. Counts have no +/// owner/profile reads, refresh/discovery, clock/ID allocation, lineage +/// mutation or scope publication. Failed/unknown scope is not filled from a +/// caller claim; successful serving is the sole publisher of trusted inputs. +pub(crate) fn estimate(body: &MessagesRequest, inputs: CountInputs<'_>) -> Result { + let model = body.model.as_deref().ok_or("missing model")?; + if !MODELS.contains(&model) { + return Err("unsupported Antigravity model"); + } + let native_model = model.strip_prefix("antigravity/").unwrap(); + let binding = if let Some(scope) = inputs.serving_scope { + if scope.profile_id.is_empty() + || scope.project_id.is_empty() + || inputs + .configured_project + .is_some_and(|p| p != scope.project_id.as_str()) + { + return Err("inconsistent published Antigravity scope"); + } + Binding { + profile_id: scope.profile_id.clone(), + project_id: scope.project_id.clone(), + native_model: native_model.to_owned(), + } + } else { + let messages = logical_messages(&body.messages)?; + if let Some(turn) = messages.iter().rev().find(|m| { + m.role == "assistant" + && m.content + .get(0) + .is_some_and(|block| block["type"] == "thinking") + }) { + replay::claimed_binding(&turn.content, native_model, inputs.configured_project)? + } else { + Binding { + profile_id: "unconfigured".into(), + project_id: inputs + .configured_project + .unwrap_or("unconfigured") + .to_owned(), + native_model: native_model.to_owned(), + } + } + }; + prepare(body, &binding).map(|p| p.estimate) +} + +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-carbon-low" + | "antigravity/gemini-4-argon-low" + | "antigravity/claude-opus-5-5-low" => "LOW", + "antigravity/gemini-4-carbon-medium" + | "antigravity/gemini-4-argon-medium" + | "antigravity/claude-opus-5-5-medium" => "MEDIUM", + _ => "HIGH", + }; + let cap = body.max_tokens.unwrap_or(65536); + 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 system = sanitize_system_parts(system); + let start_idx = messages + .iter() + .rposition(|m| { + m.role == "assistant" + && is_prior_or_unsigned_assistant(&m.content, &binding.native_model) + }) + .filter(|&last_unsigned| { + let prefix = &messages[..=last_unsigned]; + let last_has_carrier = messages[last_unsigned] + .content + .get(0) + .is_some_and(|block| block["type"] == "thinking"); + let no_current_thinking = last_has_carrier + || prefix.iter().all(|m| { + m.role != "assistant" + || is_prior_or_unsigned_assistant(&m.content, &binding.native_model) + }); + let non_system: Vec<_> = prefix.iter().filter(|m| m.role != "system").collect(); + let starts_with_user = non_system.first().is_some_and(|m| m.role == "user"); + no_current_thinking && starts_with_user && last_unsigned + 1 < messages.len() + }) + .map(|idx| idx + 1) + .unwrap_or(0); + let (prefix_messages, active_messages) = messages.split_at(start_idx); + let has_prefix = !prefix_messages.is_empty(); + let mut turns = active_messages + .iter() + .filter(|m| m.role == "assistant") + .map(|m| m.content.clone()) + .collect::>(); + for turn in &mut turns { + replay::canonicalize_tool_inputs(turn); + } + let envelopes = replay::decode_history(&json!(turns), binding)?; + let metadata = if turns.is_empty() { + let input = active_messages + .iter() + .filter(|m| m.role != "system") + .collect::>(); + if input.len() != 1 + || input[0].role != "user" + || user_parts(&input[0].content, has_prefix)?.is_empty() + { + return Err("fresh execution requires one text user input"); + } + Metadata::Fresh + } else { + observed_continuation(active_messages, &envelopes, model, has_prefix)? + }; + let session = String::new(); + let mut history = envelopes.into_iter(); + let mut pending: Vec<(Value, Value)> = Vec::new(); + let mut contents: Vec = Vec::new(); + let mut last_role = ""; + let mut estimate_bytes = serde_json::to_vec(&system) + .map_err(|_| "invalid system")? + .len(); + estimate_bytes += serde_json::to_vec(&tools.declarations) + .map_err(|_| "invalid tools")? + .len(); + if !prefix_messages.is_empty() { + let mut transcript = String::from("\n"); + for message in prefix_messages { + if message.role == "system" { + continue; + } + if message.role == "user" { + let blocks = if let Some(text) = message.content.as_str() { + vec![json!({"type":"text","text":text})] + } else { + message + .content + .as_array() + .ok_or("invalid user content")? + .clone() + }; + for block in blocks { + match block["type"].as_str() { + Some("text") => { + keys( + &block, + &["type", "text", "cache_control"], + &["type", "text"], + )?; + cache_hint(block.get("cache_control"))?; + let value = block["text"].as_str().ok_or("invalid user text")?; + transcript.push_str("[User]:\n"); + transcript.push_str(value); + transcript.push('\n'); + } + Some("tool_result") => { + let formatted = format_prefix_tool_result(&block)?; + transcript.push_str(&formatted); + transcript.push('\n'); + } + _ => return Err("unsupported user content/media"), + } + } + } else if message.role == "assistant" { + let blocks = message + .content + .as_array() + .ok_or("invalid assistant content")?; + for block in blocks { + match block["type"].as_str() { + Some("thinking") => {} + Some("text") => { + let text = block["text"].as_str().ok_or("invalid display text")?; + if !text.is_empty() { + let sanitized = + text.replace("[tool_use ", "[Assistant invoked tool "); + transcript.push_str("[Assistant]:\n"); + transcript.push_str(&sanitized); + transcript.push('\n'); + } + } + Some("tool_use") => { + let id = nonempty(&block["id"])?; + let name = nonempty(&block["name"])?; + let input = serde_json::to_string(&block["input"]) + .map_err(|_| "invalid tool input")?; + transcript.push_str(&format!( + "[Assistant invoked tool {name} (id={id}) with input: {input}]\n" + )); + } + _ => return Err("unsupported projection block"), + } + } + } else { + return Err("unsupported message role"); + } + } + transcript.push_str(""); + estimate_bytes += transcript.len(); + contents.push(json!({"role":"user","parts":[{"text":transcript}]})); + last_role = "user"; + } + let mut allow_prefix_tool_result = has_prefix; + for message in active_messages { + if message.role == "system" { + continue; + } + if message.role == "assistant" { + allow_prefix_tool_result = false; + 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 binding.native_model.starts_with("claude-") { + let coalesced = coalesce_claude_native_turn(native); + if contents.len().saturating_add(coalesced.len()) > 1024 { + return Err("native history content limit"); + } + contents.extend(coalesced); + } else { + if contents.len().saturating_add(native.len()) > 1024 { + return Err("native history content limit"); + } + contents.extend(native.iter().cloned()); + } + } 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") => { + if allow_prefix_tool_result { + let formatted = format_prefix_tool_result(&block)?; + estimate_bytes += formatted.len(); + text.push(json!({"text":formatted})); + continue; + } + 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"), + } + } + allow_prefix_tool_result = false; + 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.last().is_some_and(|c| c["role"] == "user") { + contents + .last_mut() + .unwrap() + .get_mut("parts") + .and_then(Value::as_array_mut) + .unwrap() + .extend(text); + } else { + if contents.len() >= 1024 { + return Err("native history content limit"); + } + contents.push(json!({"role":"user","parts":text})); + } + } + } 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!({"role":"user","parts":system}); + } + if !tools.declarations.as_array().unwrap().is_empty() { + request["tools"] = json!( + tools + .declarations + .as_array() + .unwrap() + .iter() + .map(|declaration| json!({"functionDeclarations":[declaration]})) + .collect::>() + ); + if body + .extra + .get("tool_choice") + .is_some_and(|choice| choice["type"] == "auto") + { + request["toolConfig"] = json!({"functionCallingConfig":{"mode":"VALIDATED"}}); + } + } + let wire = json!({"project":binding.project_id,"model":binding.native_model,"request":request, + "requestId":"","userAgent":"antigravity","requestType":"agent"}); + Ok(Prepared { + wire, + session, + display, + tools, + estimate: ((estimate_bytes as u64).div_ceil(3)).max(1), + metadata, + }) +} diff --git a/src/providers/antigravity/schema.rs b/src/providers/antigravity/schema.rs new file mode 100644 index 00000000..70de6031 --- /dev/null +++ b/src/providers/antigravity/schema.rs @@ -0,0 +1,718 @@ +//! Antigravity tool schema bridge modeled on `agy`'s `paramsToSchema` / +//! `convertMapToSchema` (`v1main.Schema` protobuf projection). +//! +//! External MCP and Claude Code tool `input_schema` definitions are projected +//! directly into `v1main.Schema` without stringifying fields into `Codec::Json` +//! wrappers or rejecting turns post-generation on local JSON Schema validation +//! mismatches. Downstream callers (Claude Code / MCP servers) validate tool +//! arguments and return `tool_result` (`is_error: true`) so the model can +//! self-correct on the next turn. +use super::replay::{self, Result, keys, nonempty}; +use serde_json::{Map, Value, json}; +use sha2::{Digest, Sha256}; +use std::collections::HashSet; + +const MAX_SCHEMA_BYTES: usize = 256 * 1024; +const MAX_SCHEMA_NODES: usize = 8192; +const MAX_SCHEMA_DEPTH: usize = 32; +const MAX_REF_DEPTH: usize = 16; + +struct Tool { + caller: String, + native: String, +} + +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", + "defer_loading", + "type", + "strict", + "eager_input_streaming", + "input_examples", + ], + &["name", "input_schema"], + )?; + if let Some(kind) = tool.get("type") + && !kind.is_null() + && kind.as_str() != Some("custom") + { + return Err("unsupported tool type"); + } + super::request::cache_hint(tool.get("cache_control"))?; + let caller = nonempty(&tool["name"])?; + if caller == "DeferredToolPlaceholder" + || tool.get("defer_loading") == Some(&Value::Bool(true)) + { + continue; + } + 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 root_obj = schema.as_object().ok_or("schema must be object")?; + if let Some(root_type) = root_obj.get("type") + && !root_type_allows_object(root_type) + { + return Err("tool input_schema root must be an object"); + } + let mut nodes = 0; + validate_tree(schema, 0, &mut nodes)?; + let mut declaration = json!({"name": native}); + if let Some(description) = tool.get("description") + && !description.is_null() + { + let text = description + .as_str() + .filter(|v| v.len() <= 32768) + .ok_or("invalid tool description")?; + if !text.is_empty() { + declaration["description"] = json!(text); + } + } + let mut visited = HashSet::new(); + let mut budget = ProjectionBudget::default(); + declaration["parameters"] = + project_schema(schema, schema, &mut visited, 0, true, &mut budget); + declarations.push(declaration); + compiled.push(Tool { + caller: caller.to_string(), + native, + }); + } + 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 raw_args = replay::call_arguments(call)?; + replay::decode_arguments(raw_args, &json!([]), &Value::Null)?; + recipes.push(json!({ + "caller_name": tool.caller, + "codec_paths": [], + "root_wrapper": Value::Null, + })); + } + replay::mapping(contents, Some(&json!(recipes))) + } +} + +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'-')) +} + +fn root_type_allows_object(value: &Value) -> bool { + match value { + Value::String(kind) => kind.eq_ignore_ascii_case("object"), + Value::Array(kinds) => kinds + .iter() + .filter_map(Value::as_str) + .any(|k| k.eq_ignore_ascii_case("object")), + _ => false, + } +} + +fn validate_tree(value: &Value, depth: usize, nodes: &mut usize) -> Result<()> { + *nodes += 1; + if depth > MAX_SCHEMA_DEPTH || *nodes > MAX_SCHEMA_NODES { + return Err("schema depth/node limit"); + } + match value { + Value::Null | Value::Bool(_) | Value::String(_) => Ok(()), + Value::Number(n) => { + if n.as_f64().is_none_or(|f| !f.is_finite()) { + return Err("invalid schema number"); + } + Ok(()) + } + Value::Array(items) => { + for item in items { + validate_tree(item, depth + 1, nodes)?; + } + Ok(()) + } + Value::Object(fields) => { + for child in fields.values() { + validate_tree(child, depth + 1, nodes)?; + } + Ok(()) + } + } +} + +#[derive(Clone, Default)] +struct ProjectionBudget { + nodes: usize, + strings: usize, +} + +impl ProjectionBudget { + fn string(&mut self, value: &str) -> bool { + let Some(total) = self.strings.checked_add(value.len()) else { + return false; + }; + if total > MAX_SCHEMA_BYTES { + return false; + } + self.strings = total; + true + } + + fn visit(&mut self, value: &Value, depth: usize) -> bool { + self.nodes += 1; + if depth > MAX_SCHEMA_DEPTH || self.nodes > 4096 { + return false; + } + match value { + Value::Null | Value::Bool(_) => true, + Value::Number(_) => number_value(value).is_some(), + Value::String(text) => self.string(text), + Value::Array(values) => values.iter().all(|v| self.visit(v, depth + 1)), + Value::Object(values) => values + .iter() + .all(|(key, value)| self.string(key) && self.visit(value, depth + 1)), + } + } +} + +fn number_value(value: &Value) -> Option { + const SAFE: i64 = 9_007_199_254_740_991; + let number = value.as_number()?; + if let Some(value) = number.as_i64() { + if !(-SAFE..=SAFE).contains(&value) { + return None; + } + return Some(value as f64); + } + if let Some(value) = number.as_u64() { + if value > SAFE as u64 { + return None; + } + return Some(value as f64); + } + number.as_f64().filter(|value| value.is_finite()) +} + +fn length_value(value: &Value) -> Option { + number_value(value) + .filter(|value| *value >= 0.0 && *value <= 9_007_199_254_740_991.0 && value.fract() == 0.0) + .map(|value| value as u64) +} + +fn genai_type(kind: &str) -> Option<&'static str> { + if kind.eq_ignore_ascii_case("string") { + Some("STRING") + } else if kind.eq_ignore_ascii_case("number") { + Some("NUMBER") + } else if kind.eq_ignore_ascii_case("integer") { + Some("INTEGER") + } else if kind.eq_ignore_ascii_case("boolean") { + Some("BOOLEAN") + } else if kind.eq_ignore_ascii_case("array") { + Some("ARRAY") + } else if kind.eq_ignore_ascii_case("object") { + Some("OBJECT") + } else if kind.eq_ignore_ascii_case("null") { + Some("NULL") + } else { + None + } +} + +fn resolve_local_refs( + map: &Map, + root: &Value, + visited: &mut HashSet, +) -> (Map, Vec) { + let mut current = map.clone(); + let mut added = Vec::new(); + for _ in 0..MAX_REF_DEPTH { + let Some(pointer) = current + .get("$ref") + .and_then(Value::as_str) + .map(str::to_owned) + else { + break; + }; + current.remove("$ref"); + if !pointer.starts_with("#/") + || visited.len() >= MAX_REF_DEPTH + || !visited.insert(pointer.clone()) + { + break; + } + added.push(pointer.clone()); + let Some(target) = root.pointer(&pointer[1..]).and_then(Value::as_object) else { + break; + }; + let mut merged = target.clone(); + for (k, v) in current { + merged.insert(k, v); + } + current = merged; + } + (current, added) +} + +fn is_null_branch(branch: &Map) -> bool { + branch + .get("type") + .and_then(Value::as_str) + .is_some_and(|s| s.eq_ignore_ascii_case("null")) + || branch.get("const") == Some(&Value::Null) +} + +fn branch_string_values(branch: &Map) -> Option> { + if let Some(kind) = branch.get("type").and_then(Value::as_str) + && !kind.eq_ignore_ascii_case("string") + { + return None; + } + if let Some(c) = branch.get("const").and_then(Value::as_str) { + return Some(vec![c.to_owned()]); + } + let values = branch.get("enum")?.as_array()?; + if values.is_empty() || !values.iter().all(Value::is_string) { + return None; + } + Some( + values + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(), + ) +} + +fn branch_genai_type(branch: &Map) -> Option<&'static str> { + if let Some(kind) = branch.get("type").and_then(Value::as_str) { + return genai_type(kind); + } + if let Some(c) = branch.get("const") { + return match c { + Value::String(_) => Some("STRING"), + Value::Bool(_) => Some("BOOLEAN"), + Value::Number(n) => Some(if n.is_i64() || n.is_u64() { + "INTEGER" + } else { + "NUMBER" + }), + Value::Null => Some("NULL"), + _ => None, + }; + } + if branch.get("properties").is_some_and(Value::is_object) { + return Some("OBJECT"); + } + if branch.get("items").is_some_and(Value::is_object) { + return Some("ARRAY"); + } + None +} + +fn allowed_format(kind: &str, format: &str) -> bool { + match kind { + "STRING" => matches!( + format, + "enum" + | "date-time" + | "date" + | "time" + | "duration" + | "email" + | "idn-email" + | "hostname" + | "idn-hostname" + | "ipv4" + | "ipv6" + | "uuid" + | "uri" + | "uri-reference" + | "regex" + | "json-pointer" + | "relative-json-pointer" + ), + "INTEGER" => matches!(format, "int32" | "int64"), + "NUMBER" => matches!(format, "float" | "double"), + _ => false, + } +} + +fn project_schema( + schema: &Value, + root: &Value, + visited: &mut HashSet, + depth: usize, + is_root: bool, + budget: &mut ProjectionBudget, +) -> Value { + if depth > MAX_SCHEMA_DEPTH { + return if is_root { + json!({"type": "OBJECT"}) + } else { + json!({}) + }; + } + let Some(raw_map) = schema.as_object() else { + return if is_root { + json!({"type": "OBJECT"}) + } else { + json!({}) + }; + }; + let (mut map, added_refs) = resolve_local_refs(raw_map, root, visited); + + // Fold simple `allOf` object compositions (common in OpenAPI / MCP). + if let Some(all_of) = map.get("allOf").and_then(Value::as_array).cloned() { + for item in &all_of { + let Some(branch_raw) = item.as_object() else { + continue; + }; + let (branch, branch_refs) = resolve_local_refs(branch_raw, root, visited); + for r in branch_refs { + visited.remove(&r); + } + for key in ["type", "description", "items"] { + if !map.contains_key(key) + && let Some(val) = branch.get(key) + { + map.insert(key.to_owned(), val.clone()); + } + } + if let Some(branch_props) = branch.get("properties").and_then(Value::as_object) { + let target = map + .entry("properties") + .or_insert_with(|| Value::Object(Map::new())); + if let Some(target_props) = target.as_object_mut() { + for (k, v) in branch_props { + target_props.entry(k.clone()).or_insert_with(|| v.clone()); + } + } + } + if let Some(branch_req) = branch.get("required").and_then(Value::as_array) { + let target = map + .entry("required") + .or_insert_with(|| Value::Array(Vec::new())); + if let Some(target_req) = target.as_array_mut() { + for req in branch_req { + if req.is_string() && !target_req.contains(req) { + target_req.push(req.clone()); + } + } + } + } + } + } + + let mut nullable = map.get("nullable") == Some(&Value::Bool(true)); + let mut inferred_union_type: Option<&'static str> = None; + + let union_branches = map + .get("anyOf") + .or_else(|| map.get("oneOf")) + .and_then(Value::as_array) + .cloned(); + if let Some(branches) = union_branches { + let mut null_count = 0usize; + let mut non_null = Vec::new(); + let mut saw_non_object = false; + for item in &branches { + if let Some(branch_raw) = item.as_object() { + let (resolved, branch_refs) = resolve_local_refs(branch_raw, root, visited); + for r in branch_refs { + visited.remove(&r); + } + if is_null_branch(&resolved) { + null_count += 1; + } else { + non_null.push(resolved); + } + } else { + saw_non_object = true; + } + } + if null_count > 0 { + nullable = true; + } + if !saw_non_object { + if non_null.len() == 1 && null_count > 0 { + // Unwrap nullable `Optional[T]` (`anyOf: [T, {"type": "null"}]`). + for (k, v) in &non_null[0] { + if !map.contains_key(k) { + map.insert(k.clone(), v.clone()); + } + } + } else if !non_null.is_empty() + && non_null.iter().all(|b| branch_string_values(b).is_some()) + { + // Flatten string enum/const unions (e.g., `TaskUpdate.status`). + let mut combined = Vec::new(); + for b in &non_null { + for s in branch_string_values(b).unwrap() { + if !combined.contains(&s) { + combined.push(s); + } + } + } + if !map.contains_key("type") { + map.insert("type".into(), json!("string")); + } + if !map.contains_key("enum") && !combined.is_empty() { + map.insert("enum".into(), json!(combined)); + } + } else if !non_null.is_empty() && !map.contains_key("type") { + let types: Option> = + non_null.iter().map(branch_genai_type).collect(); + if let Some(types) = types { + let first = types[0]; + if types.iter().all(|t| *t == first) { + inferred_union_type = Some(first); + } else if types.iter().all(|t| matches!(*t, "INTEGER" | "NUMBER")) { + inferred_union_type = Some("NUMBER"); + } + } + } + } + } + + let mut kind: Option<&'static str> = match map.get("type") { + Some(Value::String(s)) => genai_type(s), + Some(Value::Array(arr)) => { + let mut non_null_types: Vec<&'static str> = Vec::new(); + let mut saw_null = false; + let mut valid = !arr.is_empty(); + for entry in arr { + let Some(s) = entry.as_str().and_then(genai_type) else { + valid = false; + break; + }; + if s == "NULL" { + saw_null = true; + } else if !non_null_types.contains(&s) { + non_null_types.push(s); + } + } + if !valid { + None + } else { + if saw_null { + nullable = true; + } + match non_null_types.as_slice() { + [single] => Some(*single), + [a, b] if matches!((*a, *b), ("INTEGER", "NUMBER") | ("NUMBER", "INTEGER")) => { + Some("NUMBER") + } + [] if saw_null => Some("NULL"), + _ => None, + } + } + } + _ => inferred_union_type, + }; + + if let Some(c) = map.get("const") { + match c { + Value::String(s) => { + if kind.is_none() { + kind = Some("STRING"); + } + if kind == Some("STRING") && !map.contains_key("enum") { + map.insert("enum".into(), json!([s])); + } + } + Value::Bool(_) if kind.is_none() => kind = Some("BOOLEAN"), + Value::Number(n) if kind.is_none() => { + kind = Some(if n.is_i64() || n.is_u64() { + "INTEGER" + } else { + "NUMBER" + }); + } + Value::Null if kind.is_none() => kind = Some("NULL"), + _ => {} + } + } + + if kind.is_none() { + if map.get("properties").is_some_and(Value::is_object) { + kind = Some("OBJECT"); + } else if map.get("items").is_some_and(Value::is_object) { + kind = Some("ARRAY"); + } + } + if is_root && kind.is_none() { + kind = Some("OBJECT"); + } + + let mut out = Map::new(); + if let Some(t) = kind { + out.insert("type".into(), json!(t)); + } + if nullable && !is_root && !matches!(kind, None | Some("NULL")) { + out.insert("nullable".into(), json!(true)); + } + if let Some(desc) = map + .get("description") + .and_then(Value::as_str) + .filter(|v| !v.is_empty()) + { + out.insert("description".into(), json!(desc)); + } + + match kind { + Some("STRING") => { + if let Some(fmt) = map + .get("format") + .and_then(Value::as_str) + .filter(|f| allowed_format("STRING", f)) + { + out.insert("format".into(), json!(fmt)); + } + if let Some(pat) = map + .get("pattern") + .and_then(Value::as_str) + .filter(|v| !v.is_empty()) + { + out.insert("pattern".into(), json!(pat)); + } + if let Some(values) = map.get("enum").and_then(Value::as_array) { + let strings: Vec<&str> = values.iter().filter_map(Value::as_str).collect(); + if !strings.is_empty() { + out.insert("enum".into(), json!(strings)); + } + } + for key in ["minLength", "maxLength"] { + if let Some(value) = map.get(key).and_then(length_value).filter(|v| *v != 0) { + out.insert(key.into(), json!(value.to_string())); + } + } + } + Some("INTEGER" | "NUMBER") => { + let t = kind.unwrap(); + if let Some(fmt) = map + .get("format") + .and_then(Value::as_str) + .filter(|f| allowed_format(t, f)) + { + out.insert("format".into(), json!(fmt)); + } + for key in ["minimum", "maximum"] { + if let Some(value) = map.get(key).and_then(number_value).filter(|v| *v != 0.0) { + out.insert(key.into(), json!(value)); + } + } + } + Some("OBJECT") => { + if let Some(properties) = map.get("properties").and_then(Value::as_object) + && !properties.is_empty() + { + let projected_props: Map = properties + .iter() + .map(|(name, child)| { + ( + name.clone(), + project_schema(child, root, visited, depth + 1, false, budget), + ) + }) + .collect(); + out.insert("properties".into(), Value::Object(projected_props)); + if let Some(required) = map.get("required").and_then(Value::as_array) { + let mut req_names: Vec<&str> = Vec::new(); + for entry in required { + if let Some(name) = entry.as_str() + && properties.contains_key(name) + && !req_names.contains(&name) + { + req_names.push(name); + } + } + if !req_names.is_empty() { + out.insert("required".into(), json!(req_names)); + } + } + } + } + Some("ARRAY") => { + if let Some(items) = map.get("items") + && items.is_object() + { + out.insert( + "items".into(), + project_schema(items, root, visited, depth + 1, false, budget), + ); + } + for key in ["minItems", "maxItems"] { + if let Some(value) = map.get(key).and_then(length_value).filter(|v| *v != 0) { + out.insert(key.into(), json!(value.to_string())); + } + } + } + _ => {} + } + + if let Some(default_val) = map.get("default") { + let mut candidate = budget.clone(); + if candidate.visit(default_val, 0) { + *budget = candidate; + out.insert("default".into(), default_val.clone()); + } + } + + for r in added_refs { + visited.remove(&r); + } + + Value::Object(out) +} diff --git a/src/providers/antigravity/transport.rs b/src/providers/antigravity/transport.rs new file mode 100644 index 00000000..2814554a --- /dev/null +++ b/src/providers/antigravity/transport.rs @@ -0,0 +1,844 @@ +//! Bounded native HTTP error diagnostics and SSE collection. No generation retry. +use super::{ + generation::NativeResponse, + 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", + "daily-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: NativeResponse, deadline: Instant) -> Option> { + if upstream + .encoded_length_hint() + .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: impl Into, + header_deadline: Instant, +) -> Response { + let upstream = upstream.into(); + let deadline = header_deadline.min(Instant::now() + Duration::from_secs(1)); + let status = upstream.status(); + let retry = RetryHeader::parse(upstream.headers(), time::OffsetDateTime::now_utc()); + 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; + +#[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 raw_reason = reason + .as_str() + .filter(|r| { + matches!( + *r, + "STOP" + | "MAX_TOKENS" + | "MALFORMED_FUNCTION_CALL" + | "UNEXPECTED_TOOL_CALL" + | "OTHER" + ) + }) + .ok_or("unsupported native termination/refusal")?; + let normalized = if raw_reason == "MAX_TOKENS" { + "MAX_TOKENS" + } else { + "STOP" + }; + if raw_reason != "STOP" && raw_reason != "MAX_TOKENS" { + let has_visible_or_call = self.contents.iter().any(|c| { + c["parts"].as_array().is_some_and(|parts| { + parts.iter().any(|p| { + p.get("functionCall").is_some() + || (p["thought"] != true + && p.get("text") + .and_then(Value::as_str) + .is_some_and(|t| !t.is_empty())) + }) + }) + }); + if !has_visible_or_call { + let detail = candidate + .get("finishMessage") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .unwrap_or(raw_reason); + self.contents.push(json!({ + "role": "model", + "parts": [{"text": format!("[Model generation stopped early ({raw_reason}): {detail}]")}] + })); + } + } + if self.terminal.as_ref().is_some_and(|old| old != normalized) { + return Err("conflicting native terminal state"); + } + self.terminal = Some(normalized.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: NativeResponse) -> Result { + let mut reducer = Reducer::default(); + while let Some(chunk) = response.chunk().await.map_err(|error| error.message())? { + reducer.push(&chunk)?; + } + reducer.finish() +} + +pub(crate) fn message(blocks: Value, model: &str, turn: &Turn) -> Value { + let content = match blocks { + Value::Array(items) => { + let mut coalesced: Vec = Vec::with_capacity(items.len()); + for item in items { + if item["type"] == "text" + && let Some(text) = item["text"].as_str() + && coalesced.last().is_some_and(|b| b["type"] == "text") + { + if let Some(Value::String(prev)) = + coalesced.last_mut().and_then(|b| b.get_mut("text")) + { + prev.push_str(text); + } + } else { + coalesced.push(item); + } + } + Value::Array(coalesced) + } + other => other, + }; + json!({"id":format!("msg_{}",uuid::Uuid::new_v4().simple()),"type":"message","role":"assistant","model":model, + "content":content,"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()) +} + +#[cfg(test)] +mod error_info_tests { + use super::*; + + const DAILY: &str = "daily-cloudcode-pa.googleapis.com"; + const PRIVATE: &str = "DO_NOT_REFLECT_private_project_consumer_email"; + + fn info(reason: Value, domain: Value) -> Value { + json!({"@type":"type.googleapis.com/google.rpc.ErrorInfo", "reason":reason, + "domain":domain, "message":PRIVATE, "metadata":{"project":PRIVATE,"consumer":PRIVATE}}) + } + + fn labels(details: Value) -> [String; 6] { + let body = + json!({"error":{"status":"RESOURCE_EXHAUSTED","message":PRIVATE,"details":details}}); + let parsed = ErrorDiagnostics::parse(&serde_json::to_vec(&body).unwrap()).unwrap(); + let labels = [ + parsed.status.into(), + parsed.reason.into(), + parsed.domain.into(), + parsed.quota.into(), + parsed.retry.label().into(), + parsed.retry.seconds(), + ]; + assert!(labels.iter().all(|label| !label.contains(PRIVATE))); + labels + } + + #[test] + fn daily_domain_preserves_known_reason_and_only_sanitized_labels() { + assert_eq!( + labels(json!([info(json!("RATE_LIMIT_EXCEEDED"), json!(DAILY))])), + [ + "RESOURCE_EXHAUSTED", + "RATE_LIMIT_EXCEEDED", + DAILY, + "absent", + "absent", + "none" + ] + ); + assert_eq!( + labels(json!([info(json!(PRIVATE), json!(DAILY))])), + [ + "RESOURCE_EXHAUSTED", + "unclassified", + DAILY, + "absent", + "absent", + "none" + ] + ); + } + + #[test] + fn daily_domain_is_exact_not_a_suffix_url_or_normalized_alias() { + for domain in [ + json!("daily-cloudcode-pa.googleapis.com.example.test"), + json!("tenant.daily-cloudcode-pa.googleapis.com"), + json!("notdaily-cloudcode-pa.googleapis.com"), + json!("DAILY-cloudcode-pa.googleapis.com"), + json!("https://daily-cloudcode-pa.googleapis.com"), + json!("daily-cloudcode-pa.googleapis.com:443"), + json!("daily-cloudcode-pa.googleapis.com."), + json!(" daily-cloudcode-pa.googleapis.com"), + json!("daily-cloudcode-pa.googleapis.com\n"), + json!("daily-cloudcode-pa.googléapis.com"), + json!("another.googleapis.com"), + json!(null), + json!([DAILY]), + ] { + assert_eq!( + labels(json!([info(json!("RATE_LIMIT_EXCEEDED"), domain)])), + [ + "RESOURCE_EXHAUSTED", + "unclassified", + "unclassified", + "absent", + "absent", + "none" + ] + ); + } + } + + #[test] + fn daily_malformed_or_multiple_error_info_keeps_quota_and_retry_independent() { + for infos in [ + vec![info(json!(42), json!(DAILY))], + vec![info(json!(null), json!(DAILY))], + vec![json!({"@type":"type.googleapis.com/google.rpc.ErrorInfo","domain":DAILY})], + vec![info(json!("RATE_LIMIT_EXCEEDED"), json!(null))], + vec![ + info(json!("RATE_LIMIT_EXCEEDED"), json!(DAILY)), + info(json!("RATE_LIMIT_EXCEEDED"), json!(DAILY)), + ], + ] { + let mut details = infos; + details.push( + json!({"@type":"type.googleapis.com/google.rpc.QuotaFailure", + "violations":[{"subject":PRIVATE,"description":PRIVATE}]}), + ); + details.push( + json!({"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":"7s"}), + ); + assert_eq!( + labels(json!(details)), + [ + "RESOURCE_EXHAUSTED", + "unclassified", + "unclassified", + "reported", + "valid", + "7" + ] + ); + } + assert_eq!( + labels(json!([ + info(json!(PRIVATE), json!(DAILY)), + {"@type":"type.googleapis.com/google.rpc.QuotaFailure","violations":[{"subject":PRIVATE}]}, + {"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":"7s"} + ])), + [ + "RESOURCE_EXHAUSTED", + "unclassified", + DAILY, + "reported", + "valid", + "7" + ] + ); + } + + #[test] + fn daily_error_info_does_not_bypass_body_or_detail_limits() { + let detail = info(json!("RATE_LIMIT_EXCEEDED"), json!(DAILY)); + let body = json!({"error":{"details":vec![detail; 17]}}); + assert!(ErrorDiagnostics::parse(&serde_json::to_vec(&body).unwrap()).is_none()); + let body = json!({"error":{"message":"x".repeat(MAX_ERROR_BODY), + "details":[info(json!("RATE_LIMIT_EXCEEDED"), json!(DAILY))]}}); + assert!(ErrorDiagnostics::parse(&serde_json::to_vec(&body).unwrap()).is_none()); + } +} diff --git a/src/providers/antigravity_auth_tests.rs b/src/providers/antigravity_auth_tests.rs new file mode 100644 index 00000000..b0d2e79c --- /dev/null +++ b/src/providers/antigravity_auth_tests.rs @@ -0,0 +1,1269 @@ +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_connect_failure_retains_session_and_cools_down() { + let directory = tempfile::tempdir().unwrap(); + let store = Store::new(directory.path().join("auth.json")); + saved(&store, &expired()).await; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + drop(listener); + let config = Config { + token_url: format!("http://{address}/token").parse().unwrap(), + client_id: Some("synthetic-client".into()), + ..Config::default() + }; + // A short budget cannot accommodate the second connection's 16s reserve. + let auth = Auth::new(config, store) + .unwrap() + .with_refresh_timeouts(Duration::from_secs(1), Duration::from_secs(2)); + let error = auth.access().await.err().expect("closed port must fail"); + assert_eq!( + error.to_string(), + "Antigravity refresh connection failed before token submission; credentials retained" + ); + assert_eq!( + auth.access() + .await + .err() + .expect("cooldown must fail") + .to_string(), + "Antigravity refresh connection retry temporarily deferred; credentials retained" + ); + assert_eq!( + auth.store.load().unwrap().unwrap().refresh_token, + expired().refresh_token + ); + auth.finish_maintenance().await.unwrap(); +} + +#[tokio::test] +async fn antigravity_refresh_connect_http_errors_are_redacted_503_before_stream_headers() { + let directory = tempfile::tempdir().unwrap(); + let store = Store::new(directory.path().join("auth.json")); + saved(&store, &expired()).await; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + drop(listener); + let generations = Arc::new(AtomicUsize::new(0)); + let counter = generations.clone(); + let upstream = Server::start(Router::new().fallback(move || { + let counter = counter.clone(); + async move { + counter.fetch_add(1, Ordering::SeqCst); + "must not generate" + } + })) + .await; + let auth = Auth::new( + Config { + base_url: upstream.url.clone(), + token_url: format!("http://{address}/private-token-url") + .parse() + .unwrap(), + client_id: Some("private-client-id".into()), + client_secret: Some("private-client-secret".into()), + ..Config::default() + }, + store.clone(), + ) + .unwrap() + .with_refresh_timeouts(Duration::from_secs(1), Duration::from_secs(2)); + let registry = crate::registry::Registry::from_providers( + crate::config::AliasProvider::Codex, + [ + Arc::new(super::antigravity::AntigravityProvider::with_auth(auth)) + as Arc, + ], + ); + let proxy = Server::start(crate::server::app_with_features( + Arc::new(registry), + None, + crate::server::AppFeatures::default(), + )) + .await; + for stream in [false, true] { + let response = reqwest::Client::builder().no_proxy().build().unwrap() + .post(proxy.url.join("/v1/messages").unwrap()) + .json(&json!({"model":"antigravity/gemini-4-argon-low", "max_tokens":32, "stream":stream, "messages":[{"role":"user", "content":"hello"}]})) + .send().await.unwrap(); + assert_eq!(response.status(), 503); + assert!(!response.headers().contains_key("retry-after")); + assert!( + response.headers()["content-type"] + .to_str() + .unwrap() + .starts_with("application/json") + ); + let error: Value = response.json().await.unwrap(); + assert_eq!(error["error"]["type"], "api_error"); + let rendered = error.to_string(); + for sentinel in [ + "private-token-url", + "private-client-id", + "private-client-secret", + "original-refresh", + "expired-access", + "127.0.0.1", + ] { + assert!(!rendered.contains(sentinel)); + } + } + assert_eq!(generations.load(Ordering::SeqCst), 0); + assert_eq!( + store.load().unwrap().unwrap().refresh_token, + expired().refresh_token + ); +} + +#[tokio::test] +async fn antigravity_refresh_is_single_flight_and_preserves_profile_and_rotation() { + for rotate in [false, true] { + 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_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 [ + 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_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; + 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_load_code_assist_payload_matches_exact_bootstrap_contract() { + assert_eq!( + auth::load_code_assist_payload(), + json!({"metadata":{"ideType":"ANTIGRAVITY"}}), + "bootstrap must not add pluginType, nulls, or other root/metadata fields" + ); +} + +#[test] +fn antigravity_pkce_state_and_project_shapes_are_explicit() { + let url = auth::authorization_url( + &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::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()); + assert_eq!(hits.load(Ordering::SeqCst), 0); + let response = auth + .client + .post(redirect.url.clone()) + .bearer_auth("synthetic-access") + .headers(auth.config.native_headers()) + .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..5894cb4d --- /dev/null +++ b/src/providers/antigravity_http_tests.rs @@ -0,0 +1,2641 @@ +//! 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)>, + headers: http::HeaderMap, +} +async fn backend(State(state): State>, request: Request) -> Response { + assert_eq!( + request.uri().path_and_query().unwrap().as_str(), + "/v1internal:streamGenerateContent?alt=sse" + ); + let headers = request.headers().clone(); + let bytes = axum::body::to_bytes(request.into_body(), 32 * 1024 * 1024) + .await + .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()); + let mut response = Response::builder() + .status(*status) + .body(Body::from(body.clone())) + .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() + .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}}}), + ) +} +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 + .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::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()) +} +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() +} + +async fn assert_generation_cap_ingress(stream: bool) { + let (upstream, captured) = + native(vec![(200, successful(json!([{"text":"cap response"}])))]).await; + let directory = tempfile::tempdir().unwrap(); + let proxy = proxy(provider(directory.path(), &upstream.url).await, None).await; + let cases = [ + (None, 65536), + (Some(Value::Null), 65536), + (Some(json!(1)), 1), + (Some(json!(512)), 512), + (Some(json!(65536)), 65536), + ]; + for (cap, _) in &cases { + let mut body = request(stream); + body.as_object_mut().unwrap().remove("max_tokens"); + if let Some(cap) = cap { + body["max_tokens"] = cap.clone(); + } + body["system"] = json!([]); + let response = client() + .post(proxy.url.join("/v1/messages").unwrap()) + .header("accept", "application/x-hostile-frontend") + .header("authorization", "Bearer frontend-credential") + .header("user-agent", "hostile-frontend-UA") + .header("cookie", "private-frontend-cookie") + .header("x-goog-api-client", "frontend-metadata") + .json(&body) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + assert_eq!(response.headers()["x-antigravity-reasoning-tier"], "low"); + assert_eq!( + response.headers()["content-type"], + if stream { + "text/event-stream" + } else { + "application/json" + } + ); + let text = response.text().await.unwrap(); + assert!(text.contains("cap response")); + if stream { + assert!(text.contains("event: message_stop")); + assert!(!text.contains("event: error")); + } else { + let message: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(message["stop_reason"], "end_turn"); + } + } + let captured = captured.lock().unwrap(); + assert_eq!(captured.len(), cases.len()); + for ((headers, wire), (_, cap)) in captured.iter().zip(&cases) { + assert!(!headers.contains_key("accept")); + assert_eq!(headers["accept-encoding"], "gzip"); + assert!(!headers.contains_key("content-length")); + assert_eq!(headers["transfer-encoding"], "chunked"); + assert_eq!(headers["content-type"], "application/json"); + assert_eq!(headers["authorization"], "Bearer proxy-owned-access"); + assert_eq!(headers["user-agent"], expected_native_user_agent()); + assert!(!headers.contains_key("cookie")); + assert!(!headers.contains_key("x-goog-api-client")); + assert_eq!(wire.as_object().unwrap().len(), 6); + assert_eq!(wire["model"], "gemini-4-argon-low"); + assert_eq!(wire["userAgent"], "antigravity"); + assert_eq!(wire["requestType"], "agent"); + assert_eq!(wire["request"].as_object().unwrap().len(), 4); + assert_eq!( + wire["request"]["generationConfig"]["thinkingConfig"], + json!({"includeThoughts":true,"thinkingLevel":"LOW"}) + ); + assert_eq!(wire["request"]["generationConfig"]["maxOutputTokens"], *cap); + } +} + +#[tokio::test] +async fn antigravity_http_nonstream_omitted_cap_uses_agent_default() { + assert_generation_cap_ingress(false).await; +} + +#[tokio::test] +async fn antigravity_http_stream_omitted_cap_uses_agent_default() { + assert_generation_cap_ingress(true).await; +} + +#[tokio::test] +async fn antigravity_property_names_http_preflight_never_refreshes_or_generates() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let upstream_calls = Arc::new(AtomicUsize::new(0)); + let observed = upstream_calls.clone(); + let upstream = Server::start(Router::new().fallback(move || { + let observed = observed.clone(); + async move { + observed.fetch_add(1, Ordering::SeqCst); + http::StatusCode::INTERNAL_SERVER_ERROR + } + })) + .await; + let directory = tempfile::tempdir().unwrap(); + let store = Store::new(directory.path().join("antigravity/auth.json")); + let lock = store.lock(Duration::from_secs(1)).await.unwrap(); + store + .save( + &lock, + &Credential { + profile_id: "profile".into(), + project_id: Some("project".into()), + access_token: "synthetic-expired-access".into(), + refresh_token: Some("synthetic-refresh".into()), + expires_at: 1, + }, + ) + .unwrap(); + drop(lock); + let config = Config { + base_url: upstream.url.clone(), + token_url: upstream.url.join("token").unwrap(), + client_id: Some("synthetic-client".into()), + ..Config::read(&directory.path().join("config.json"), |_| None).unwrap() + }; + let proxy = proxy( + AntigravityProvider::with_auth(Auth::new(config, store).unwrap()), + None, + ) + .await; + let mut body = request(false); + body["tools"] = json!([{"name":"TaskCreate","input_schema":{"type":"object","properties":{ + "metadata":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}} + },"additionalProperties":false}}]); + let response = client() + .post(proxy.url.join("/v1/messages/count_tokens").unwrap()) + .json(&body) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + assert!( + response.json::().await.unwrap()["input_tokens"] + .as_u64() + .unwrap() + > 0 + ); + assert_eq!( + upstream_calls.load(Ordering::SeqCst), + 0, + "count_tokens must not access expired credentials" + ); + let mut estimates = Vec::new(); + for cap in [ + None, + Some(Value::Null), + Some(json!(1)), + Some(json!(512)), + Some(json!(65536)), + ] { + let mut counted = body.clone(); + counted.as_object_mut().unwrap().remove("max_tokens"); + if let Some(cap) = cap { + counted["max_tokens"] = cap; + } + let response = client() + .post(proxy.url.join("/v1/messages/count_tokens").unwrap()) + .json(&counted) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + estimates.push(response.json::().await.unwrap()["input_tokens"].clone()); + } + assert!(estimates.iter().all(|estimate| *estimate == estimates[0])); + for stream in [false, true] { + for cap in [ + json!(0), + json!(65537), + json!(-1), + json!(1.5), + json!("512"), + json!(true), + json!([]), + json!({}), + ] { + let mut invalid = body.clone(); + invalid["stream"] = json!(stream); + invalid["max_tokens"] = cap; + for path in ["/v1/messages", "/v1/messages/count_tokens"] { + let response = client() + .post(proxy.url.join(path).unwrap()) + .json(&invalid) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 400); + } + } + } + assert_eq!( + upstream_calls.load(Ordering::SeqCst), + 0, + "cap validation/counting must not refresh or generate with expired credentials" + ); + body["tools"][0]["input_schema"]["$defs"] = json!({"name":{"type":"string"}}); + body["tools"][0]["input_schema"]["properties"]["metadata"]["propertyNames"] = + json!({"$ref":"#/$defs/name"}); + let response = client() + .post(proxy.url.join("/v1/messages/count_tokens").unwrap()) + .json(&body) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + for stream in [false, true] { + body["stream"] = json!(stream); + body["tools"][0]["input_schema"] = json!({"type":"string"}); + for path in ["/v1/messages", "/v1/messages/count_tokens"] { + let response = client() + .post(proxy.url.join(path).unwrap()) + .json(&body) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 400); + assert_eq!(response.headers()["content-type"], "application/json"); + assert_eq!( + response.json::().await.unwrap(), + json!({"type":"error","error":{ + "type":"invalid_request_error","message":"tool input_schema root must be an object" + }}) + ); + assert_eq!( + upstream_calls.load(Ordering::SeqCst), + 0, + "schema rejection must precede refresh and generation" + ); + } + } +} + +#[tokio::test] +async fn antigravity_generation_init_error_is_separate_from_auth_and_counting() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let attempts = Arc::new(AtomicUsize::new(0)); + let observed = attempts.clone(); + let token = Server::start(Router::new().fallback(move || { + let observed = observed.clone(); + async move { + observed.fetch_add(1, Ordering::SeqCst); + http::StatusCode::INTERNAL_SERVER_ERROR + } + })) + .await; + let directory = tempfile::tempdir().unwrap(); + let store = Store::new(directory.path().join("antigravity/auth.json")); + let lock = store.lock(Duration::from_secs(1)).await.unwrap(); + store + .save( + &lock, + &Credential { + profile_id: "profile".into(), + project_id: Some("project".into()), + access_token: "expired-synthetic".into(), + refresh_token: Some("synthetic-refresh".into()), + expires_at: 1, + }, + ) + .unwrap(); + drop(lock); + // A private constructor injection isolates generation init failure without + // weakening the release Config loader or changing the auth client's policy. + let config = Config { + base_url: url::Url::parse("http://outside.invalid").unwrap(), + token_url: token.url.join("token").unwrap(), + client_id: Some("synthetic-client".into()), + ..Config::default() + }; + let provider = AntigravityProvider::with_auth(Auth::new(config, store).unwrap()); + assert_eq!( + provider.local_capability().unwrap()["status"]["configuration"], + "valid" + ); + let proxy = proxy(provider, None).await; + let counted = client() + .post(proxy.url.join("/v1/messages/count_tokens").unwrap()) + .json(&request(false)) + .send() + .await + .unwrap(); + assert_eq!(counted.status(), 200); + for stream in [false, true] { + let response = client() + .post(proxy.url.join("/v1/messages").unwrap()) + .json(&request(stream)) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 502); + let value: Value = response.json().await.unwrap(); + assert_eq!(value["error"]["type"], "api_error"); + assert!( + value["error"]["message"] + .as_str() + .unwrap() + .contains("generation transport initialization failed") + ); + } + let mut invalid = request(false); + invalid["temperature"] = json!(0.5); + let response = client() + .post(proxy.url.join("/v1/messages").unwrap()) + .json(&invalid) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 400); + assert_eq!( + attempts.load(Ordering::SeqCst), + 0, + "transport init failure/counting/preflight must never refresh" + ); +} + +#[tokio::test] +async fn antigravity_consumer_selected_origin_serves_discovery_and_generation() { + for stream in [false, true] { + let operations = Arc::new(Mutex::new(Vec::new())); + let token_operations = operations.clone(); + let oauth = Server::start(Router::new().route("/token", post(move |axum::extract::Form(form): axum::extract::Form>| { + let operations = token_operations.clone(); + async move { + assert_eq!(form["grant_type"], "authorization_code"); + operations.lock().unwrap().push("token"); + axum::Json(json!({"access_token":"synthetic-access","refresh_token":"synthetic-refresh","expires_in":3600})) + } + }))).await; + let captured: Captured = Arc::new(Mutex::new(Vec::new())); + let state = Arc::new(Backend { + captured: captured.clone(), + replies: vec![(200, successful(json!([{"text":"selected origin"}])))], + headers: http::HeaderMap::from_iter([( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("text/event-stream"), + )]), + }); + let discovery_operations = operations.clone(); + let generation_operations = operations.clone(); + let chosen = Server::start( + Router::new() + .route( + "/v1internal:loadCodeAssist", + post(move |headers: http::HeaderMap| { + let operations = discovery_operations.clone(); + async move { + assert_eq!(headers["authorization"], "Bearer synthetic-access"); + operations.lock().unwrap().push("discovery"); + axum::Json(json!({"cloudaicompanionProject":"synthetic-project"})) + } + }), + ) + .route( + "/v1internal:streamGenerateContent", + post(move |request: Request| { + let state = state.clone(); + let operations = generation_operations.clone(); + async move { + assert_eq!( + request.uri().path_and_query().unwrap().as_str(), + "/v1internal:streamGenerateContent?alt=sse" + ); + operations.lock().unwrap().push("generation"); + backend(State(state), request).await + } + }), + ), + ) + .await; + let directory = tempfile::tempdir().unwrap(); + // Loopback injection tests shared-origin plumbing, not live Google access. + let auth = Auth::new( + Config { + base_url: chosen.url.clone(), + token_url: oauth.url.join("/token").unwrap(), + client_id: Some("synthetic-client".into()), + ..Config::default() + }, + Store::new(directory.path().join("auth.json")), + ) + .unwrap(); + let mut callback = None; + auth.login_with_opener(|url| { + let pairs = url + .query_pairs() + .collect::>(); + let mut redirect = url::Url::parse(&pairs["redirect_uri"]).unwrap(); + redirect.query_pairs_mut().extend_pairs([ + ("state", pairs["state"].as_ref()), + ("code", "synthetic-code"), + ]); + callback = Some(tokio::spawn(async move { + client().get(redirect).send().await.unwrap() + })); + Ok(()) + }) + .await + .unwrap(); + assert_eq!(callback.unwrap().await.unwrap().status(), 200); + let proxy = proxy(AntigravityProvider::with_auth(auth), None).await; + let response = client() + .post(proxy.url.join("/v1/messages").unwrap()) + .json(&request(stream)) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + assert!(response.text().await.unwrap().contains("selected origin")); + assert_eq!( + *operations.lock().unwrap(), + ["token", "discovery", "generation"] + ); + let captured = captured.lock().unwrap(); + assert_eq!(captured.len(), 1); + assert_eq!(captured[0].0["authorization"], "Bearer synthetic-access"); + assert_eq!(captured[0].1["project"], "synthetic-project"); + } +} + +#[tokio::test] +async fn antigravity_consumer_three_stage_sse_and_nonstream_tool_replay() { + // Entirely synthetic: no captured provider signatures, prompts or credentials. + let thought = json!({"role":"model","parts":[{"thought":true,"text":"synthetic hidden thought","thoughtSignature":"synthetic-thought-signature"}]}); + let call = json!({"role":"model","parts":[{"functionCall":{"id":"synthetic-native-call","name":"endpoint_canary_echo","args":{"value":"endpoint-canary-public-nonce"}},"thoughtSignature":"synthetic-call-signature"}]}); + let tool_reply = format!( + "{}{}", + frame(json!({"response":{"candidates":[{"content":thought.clone()}]}})), + successful(call["parts"].clone()) + ); + let (upstream, captured) = native(vec![ + (200, successful(json!([{"text":"SSE_"},{"text":"TEXT_OK"}]))), + (200, tool_reply), + ( + 200, + successful(json!([{"text":"TOOL_"},{"text":"ROUNDTRIP_OK"}])), + ), + ]) + .await; + let directory = tempfile::tempdir().unwrap(); + let proxy = proxy(provider(directory.path(), &upstream.url).await, None).await; + let endpoint = proxy.url.join("/v1/messages").unwrap(); + let mut text_request = request(true); + text_request["max_tokens"] = json!(4096); + let response = client() + .post(endpoint.clone()) + .json(&text_request) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + assert_eq!(response.headers()["content-type"], "text/event-stream"); + let events: Vec = response + .text() + .await + .unwrap() + .lines() + .filter_map(|line| line.strip_prefix("data: ")) + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(events.first().unwrap()["type"], "message_start"); + assert_eq!(events.last().unwrap()["type"], "message_stop"); + assert_eq!( + events + .iter() + .filter(|event| event["type"] == "message_stop") + .count(), + 1 + ); + assert!( + events + .iter() + .all(|event| event["type"] != "error" && event["content_block"]["type"] != "tool_use") + ); + let text: String = events + .iter() + .filter(|event| event["delta"]["type"] == "text_delta") + .map(|event| event["delta"]["text"].as_str().unwrap()) + .collect(); + assert_eq!(text, "SSE_TEXT_OK"); + let terminal = events + .iter() + .find(|event| event["type"] == "message_delta") + .unwrap(); + assert_eq!(terminal["delta"]["stop_reason"], "end_turn"); + assert_eq!(terminal["usage"]["input_tokens"], 10); + assert_eq!(terminal["usage"]["output_tokens"], 3); + + let mut tool_request = request(false); + tool_request["max_tokens"] = json!(4096); + tool_request["messages"] = json!([{"role":"user","content":"Call endpoint_canary_echo with endpoint-canary-public-nonce, then wait for its result."}]); + tool_request["tools"] = json!([{"name":"endpoint_canary_echo","input_schema":{"type":"object","properties":{"value":{"type":"string","enum":["endpoint-canary-public-nonce"]}},"required":["value"],"additionalProperties":false}}]); + tool_request["tool_choice"] = json!({"type":"auto"}); + let response = client() + .post(endpoint.clone()) + .json(&tool_request) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + assert_eq!(response.headers()["content-type"], "application/json"); + let message: Value = response.json().await.unwrap(); + assert_eq!(message["stop_reason"], "tool_use"); + let content = message["content"].as_array().unwrap(); + assert_eq!(content.len(), 2); + assert_eq!(content[0]["type"], "thinking"); + assert_eq!(content[0]["thinking"], ""); + assert!( + content[0]["signature"] + .as_str() + .unwrap() + .starts_with(replay::PREFIX) + ); + assert_eq!(content[1]["type"], "tool_use"); + assert_eq!(content[1]["name"], "endpoint_canary_echo"); + assert_eq!( + content[1]["input"], + json!({"value":"endpoint-canary-public-nonce"}) + ); + let tool_id = content[1]["id"] + .as_str() + .filter(|id| !id.is_empty()) + .unwrap(); + // Carry the full nonstream aggregate unchanged, including its leading carrier. + tool_request["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":message["content"].clone()}), + json!({"role":"user","content":[{"type":"tool_result","tool_use_id":tool_id,"content":"endpoint-canary-public-nonce"}]}), + ]); + let response = client() + .post(endpoint) + .json(&tool_request) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + let final_message: Value = response.json().await.unwrap(); + assert_eq!(final_message["stop_reason"], "end_turn"); + let final_content = final_message["content"].as_array().unwrap(); + assert!( + final_content + .iter() + .all(|block| block["type"] != "tool_use") + ); + let text: String = final_content + .iter() + .filter(|block| block["type"] == "text") + .map(|block| block["text"].as_str().unwrap()) + .collect(); + assert_eq!(text, "TOOL_ROUNDTRIP_OK"); + assert_eq!(final_message["usage"]["input_tokens"], 10); + assert_eq!(final_message["usage"]["output_tokens"], 3); + let captured = captured.lock().unwrap(); + assert_eq!(captured.len(), 3); + for (_, wire) in captured.iter() { + assert_eq!(wire["model"], "gemini-4-argon-low"); + assert_eq!(wire["project"], "project"); + assert_eq!(wire["request"]["generationConfig"]["maxOutputTokens"], 4096); + } + let replayed = &captured[2].1["request"]; + assert_eq!(replayed["sessionId"], captured[1].1["request"]["sessionId"]); + assert_eq!(replayed["contents"][1], thought); + assert_eq!(replayed["contents"][2], call); + assert_eq!( + replayed["contents"][3], + json!({"role":"model","parts":[{"functionResponse":{"id":"synthetic-native-call","name":"endpoint_canary_echo","response":{"output":"endpoint-canary-public-nonce"}}}]}) + ); +} + +#[tokio::test] +async fn antigravity_user_agent_generation_wire_matrix() { + for override_value in [ + 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(); + 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 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() + .build() + .unwrap(); + assert!( + client + .post(format!("http://{address}/v1internal:streamGenerateContent")) + .headers(config.native_headers()) + .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_legacy_reqwest_socks5_ipv6_literal_is_a_known_wire_gap() { + // Exercise the UNCHANGED OAuth/native-baseline reqwest builder, not the new + // generation connector. No origin exists or is contacted: the synthetic + // SOCKS proxy rejects the observed bracketed domain before any TLS/HTTP. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let observed = tokio::spawn(async move { + let (mut socket, _) = tokio::time::timeout(Duration::from_secs(3), listener.accept()) + .await + .unwrap() + .unwrap(); + let mut greeting = [0; 2]; + socket.read_exact(&mut greeting).await.unwrap(); + assert_eq!(greeting[0], 5); + let mut methods = vec![0; greeting[1] as usize]; + socket.read_exact(&mut methods).await.unwrap(); + socket.write_all(&[5, 0]).await.unwrap(); + let mut request = [0; 4]; + socket.read_exact(&mut request).await.unwrap(); + assert_eq!(&request[..3], &[5, 1, 0]); + let mut host = Vec::new(); + match request[3] { + 1 => { + host.resize(4, 0); + } + 4 => { + host.resize(16, 0); + } + 3 => { + let mut length = [0]; + socket.read_exact(&mut length).await.unwrap(); + host.resize(length[0] as usize, 0); + } + _ => panic!("unexpected synthetic SOCKS address type"), + } + socket.read_exact(&mut host).await.unwrap(); + let mut port = [0; 2]; + socket.read_exact(&mut port).await.unwrap(); + socket + .write_all(&[5, 4, 0, 1, 0, 0, 0, 0, 0, 0]) + .await + .unwrap(); + (request[3], host) + }); + let config = Config::default(); + let legacy = config + .client_builder() + .proxy(reqwest::Proxy::all(format!("socks5h://{address}")).unwrap()) + .timeout(Duration::from_secs(3)) + .build() + .unwrap(); + assert!( + legacy + .post("https://[::1]:443/v1internal:streamGenerateContent?alt=sse") + .headers(config.native_headers()) + .json(&json!({"synthetic":"baseline-proxy-probe"})) + .send() + .await + .is_err() + ); + let (kind, host) = observed.await.unwrap(); + assert_eq!( + kind, 3, + "baseline hypothesis: cached helper cannot emit literal IPv6 ATYP4 through Uri" + ); + assert_eq!( + host, b"[::1]", + "baseline hypothesis must be measured, never assumed as candidate parity" + ); +} + +#[tokio::test] +async fn antigravity_legacy_reqwest_local_socks5_ipv6_and_aaaa_only_are_measured_separately() { + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + for (case, endpoint, override_name, override_ip) in [ + ( + "ipv4-control", + "https://aaaa-only.fixture.invalid:443/", + Some("aaaa-only.fixture.invalid"), + Some(IpAddr::V4(Ipv4Addr::LOCALHOST)), + ), + ("literal-default", "https://[::1]:443/", None, None), + ( + "literal-controlled", + "https://[::1]:443/", + Some("[::1]"), + Some(IpAddr::V6(Ipv6Addr::LOCALHOST)), + ), + ( + "aaaa-only", + "https://aaaa-only.fixture.invalid:443/", + Some("aaaa-only.fixture.invalid"), + Some(IpAddr::V6(Ipv6Addr::LOCALHOST)), + ), + ] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (stop_tx, stop_rx) = tokio::sync::oneshot::channel(); + let peer = tokio::spawn(async move { + let accepted = tokio::select! { biased; + accepted = listener.accept() => Some(accepted.unwrap().0), + _ = stop_rx => None, + }; + let mut socket = accepted?; + let mut greeting = [0; 2]; + socket.read_exact(&mut greeting).await.unwrap(); + assert_eq!(greeting[0], 5); + let mut methods = vec![0; greeting[1] as usize]; + socket.read_exact(&mut methods).await.unwrap(); + socket.write_all(&[5, 0]).await.unwrap(); + let mut request = [0; 4]; + socket.read_exact(&mut request).await.unwrap(); + assert_eq!(&request[..3], &[5, 1, 0]); + let mut host = Vec::new(); + match request[3] { + 1 => host.resize(4, 0), + 4 => host.resize(16, 0), + 3 => { + let mut length = [0]; + socket.read_exact(&mut length).await.unwrap(); + host.resize(length[0] as usize, 0); + } + _ => panic!("unexpected synthetic SOCKS address type"), + } + socket.read_exact(&mut host).await.unwrap(); + let mut port = [0; 2]; + socket.read_exact(&mut port).await.unwrap(); + socket + .write_all(&[5, 4, 0, 1, 0, 0, 0, 0, 0, 0]) + .await + .unwrap(); + Some((request[3], host)) + }); + // Public locked reqwest DNS override isolates AAAA-only resolution; + // no real DNS origin/profile/network or candidate connector is involved. + let config = Config::default(); + let mut builder = config + .client_builder() + .proxy(reqwest::Proxy::all(format!("socks5://{address}")).unwrap()) + .timeout(Duration::from_secs(3)); + if let (Some(name), Some(ip)) = (override_name, override_ip) { + builder = builder.resolve(name, SocketAddr::new(ip, 443)); + } + let legacy = builder.build().unwrap(); + assert!( + legacy + .post(endpoint) + .json(&json!({"synthetic":"legacy-local-dns"})) + .send() + .await + .is_err() + ); + let _ = stop_tx.send(()); + let result = peer.await.unwrap(); + match case { + "ipv4-control" => assert_eq!( + result, + Some((1, vec![127, 0, 0, 1])), + "public override/proxy fixture must actually work" + ), + "literal-default" => match result { + None => eprintln!("BASELINE_LOCAL_SOCKS5_LITERAL_IPV6_PREHANDSHAKE_FAILURE"), + Some((3, host)) if host == b"[::1]" => { + eprintln!("BASELINE_LOCAL_SOCKS5_LITERAL_IPV6_BRACKETED_DOMAIN") + } + _ => panic!( + "baseline local literal IPv6 may work; candidate compatibility requires adjudication" + ), + }, + _ => { + assert_eq!( + result, + Some((3, b"[::1]".to_vec())), + "baseline AAAA-only hypothesis must be actual wire evidence" + ); + eprintln!("BASELINE_LOCAL_SOCKS5_AAAA_RESOLVED_TO_BRACKETED_DOMAIN"); + } + } + } +} + +#[tokio::test] +async fn antigravity_legacy_reqwest_connect_ua_is_the_client_default() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let observed = tokio::spawn(async move { + let (mut socket, _) = tokio::time::timeout(Duration::from_secs(3), listener.accept()) + .await + .unwrap() + .unwrap(); + let mut raw = Vec::new(); + let mut buffer = [0; 512]; + while !raw.windows(4).any(|bytes| bytes == b"\r\n\r\n") { + let count = socket.read(&mut buffer).await.unwrap(); + assert!(count > 0 && raw.len() + count <= 8192); + raw.extend_from_slice(&buffer[..count]); + } + let headers = std::str::from_utf8(&raw).unwrap(); + assert!(headers.starts_with("CONNECT ")); + assert!(!headers.lines().any(|line| { + line.split_once(':') + .is_some_and(|(name, _)| name.eq_ignore_ascii_case("authorization")) + })); + let user_agent = headers.lines().find_map(|line| { + line.split_once(':') + .filter(|(name, _)| name.eq_ignore_ascii_case("user-agent")) + .map(|(_, value)| value.trim().to_owned()) + }); + socket.write_all(b"HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await.unwrap(); + user_agent + }); + let directory = tempfile::tempdir().unwrap(); + let config = Config::read(&directory.path().join("config.json"), |key| { + (key == "CCP_ANTIGRAVITY_USER_AGENT").then(|| "public-native-override".into()) + }) + .unwrap(); + let legacy = config + .client_builder() + .proxy(reqwest::Proxy::all(format!("http://user:pass@{address}")).unwrap()) + .timeout(Duration::from_secs(3)) + .build() + .unwrap(); + assert!( + legacy + .post("https://127.0.0.1:443/v1internal:streamGenerateContent?alt=sse") + .bearer_auth("public-test-access") + .headers(config.native_headers()) + .json(&json!({"synthetic":"baseline-connect-ua"})) + .send() + .await + .is_err() + ); + assert_eq!( + observed.await.unwrap().as_deref(), + Some(expected_native_user_agent().as_str()), + "baseline CONNECT uses client builder UA, not per-request native override" + ); +} + +#[tokio::test] +async fn antigravity_legacy_https_proxy_connect_omits_alpn_and_speaks_http1() { + use rustls::pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject}; + let certificate = include_bytes!("../../tests/fixtures/antigravity-transport/server.pem"); + let key = include_bytes!("../../tests/fixtures/antigravity-transport/server-key.pem"); + let ca = include_bytes!("../../tests/fixtures/antigravity-transport/ca.pem"); + let mut tls = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert( + CertificateDer::pem_slice_iter(certificate) + .collect::, _>>() + .unwrap(), + PrivateKeyDer::from_pem_slice(key).unwrap(), + ) + .unwrap(); + tls.alpn_protocols = vec![b"http/1.1".to_vec(), b"h2".to_vec()]; + let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let peer = tokio::spawn(async move { + let (socket, _) = tokio::time::timeout(Duration::from_secs(3), listener.accept()) + .await + .unwrap() + .unwrap(); + let mut socket = tokio::time::timeout(Duration::from_secs(3), acceptor.accept(socket)) + .await + .unwrap() + .unwrap(); + let alpn = socket + .get_ref() + .1 + .alpn_protocol() + .map(|value| value.to_vec()); + let mut headers = Vec::new(); + while !headers.ends_with(b"\r\n\r\n") { + assert!(headers.len() < 8192); + headers.push(socket.read_u8().await.unwrap()); + } + let http1_connect = headers.starts_with(b"CONNECT ") + && headers.windows(10).any(|value| value == b" HTTP/1.1\r"); + socket.write_all(b"HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await.unwrap(); + (alpn, http1_connect) + }); + let config = Config::default(); + let legacy = config + .client_builder() + .add_root_certificate(reqwest::Certificate::from_pem(ca).unwrap()) + .proxy(reqwest::Proxy::all(format!("https://{address}")).unwrap()) + .timeout(Duration::from_secs(3)) + .build() + .unwrap(); + assert!( + legacy + .post("https://127.0.0.1:443/") + .json(&json!({"synthetic":"legacy-proxy-alpn"})) + .send() + .await + .is_err() + ); + let (alpn, http1) = peer.await.unwrap(); + assert!( + alpn.is_none(), + "measure legacy proxy ALPN rather than assume h1 advertisement" + ); + assert!( + http1, + "absence of ALPN must still carry explicit HTTP/1.1 CONNECT" + ); + eprintln!("BASELINE_HTTPS_PROXY_ALPN_ABSENT_HTTP1_CONNECT_OBSERVED"); +} + +#[tokio::test] +async fn antigravity_legacy_reqwest_socks4_empty_userid_has_a_strict_rfc_wire_gap() { + async fn terminated(socket: &mut tokio::net::TcpStream) -> Vec { + let mut value = Vec::new(); + for _ in 0..256 { + let byte = socket.read_u8().await.unwrap(); + if byte == 0 { + return value; + } + value.push(byte); + } + panic!("bounded synthetic SOCKS string exceeded"); + } + for (scheme, endpoint) in [ + ("socks4", "https://127.0.0.1:443/"), + ("socks4a", "https://fixture.invalid:443/"), + ] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let peer = tokio::spawn(async move { + let (mut socket, _) = tokio::time::timeout(Duration::from_secs(3), listener.accept()) + .await + .unwrap() + .unwrap(); + let mut header = [0; 8]; + socket.read_exact(&mut header).await.unwrap(); + assert_eq!(&header[..2], &[4, 1]); + let userid = terminated(&mut socket).await; + // Strict RFC: this is already the socket request end, or the first + // byte of the SOCKS4a hostname. Do not consume/forgive a second NUL. + let next = tokio::time::timeout(Duration::from_millis(200), socket.read_u8()).await; + let extra = next.ok().and_then(Result::ok); + let remaining_domain = if scheme == "socks4a" && extra == Some(0) { + Some(terminated(&mut socket).await) + } else { + None + }; + socket.write_all(&[0, 91, 0, 0, 0, 0, 0, 0]).await.unwrap(); + (userid, extra, remaining_domain) + }); + let config = Config::default(); + let legacy = config + .client_builder() + .proxy(reqwest::Proxy::all(format!("{scheme}://{address}")).unwrap()) + .timeout(Duration::from_secs(3)) + .build() + .unwrap(); + assert!( + legacy + .post(endpoint) + .json(&json!({"synthetic":"strict-legacy-socks4"})) + .send() + .await + .is_err() + ); + let (userid, extra, domain) = peer.await.unwrap(); + assert!(userid.is_empty()); + assert_eq!( + extra, + Some(0), + "legacy extra-NUL hypothesis must be measured, not worked around in the proxy" + ); + if scheme == "socks4a" { + assert_eq!(domain.as_deref(), Some(b"fixture.invalid".as_slice())); + } + eprintln!("BASELINE_STRICT_SOCKS4_EMPTY_USERID_EXTRA_NUL_OBSERVED"); + } +} + +#[test] +fn antigravity_legacy_matcher_wildcard_domain_and_literal_ip_are_distinct() { + use hyper_util::client::proxy::matcher::Matcher; + let proxy = "http://127.0.0.1:9"; + let ip = "https://127.0.0.1:443/".parse().unwrap(); + let domain = "https://localhost:443/".parse().unwrap(); + let wildcard = Matcher::builder().https(proxy).no("*").build(); + assert!(wildcard.intercept(&domain).is_none()); + assert!( + wildcard.intercept(&ip).is_some(), + "measure cached baseline wildcard/IP behavior without changing privacy policy" + ); + for bypass in ["127.0.0.1", "127.0.0.0/8"] { + assert!( + Matcher::builder() + .https(proxy) + .no(bypass) + .build() + .intercept(&ip) + .is_none() + ); + } + eprintln!("BASELINE_NO_PROXY_WILDCARD_DOMAIN_ONLY_LITERAL_IP_REMAINS_PROXIED"); +} + +#[tokio::test] +async fn antigravity_serving_capability_uses_its_own_auth_and_configuration() { + let (upstream, captured) = native(vec![(200, successful(json!([{"text":"unused"}])))]).await; + 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 lineage = replay::Lineage::new( + "11111111-1111-4111-8111-111111111111".into(), + "22222222-2222-4222-8222-222222222222".into(), + ) + .unwrap(); + let state = replay::State { + lineage, + native_checkpoint_index: 0, + user_execution: replay::UserExecution { + active_id: "aaaaaaaa-aaaa-4aaa-8aaa-000000000000".into(), + prior_recorded_id: None, + }, + }; + let native_contents = json!([{"role":"model","parts":[{"text":"same projection","thoughtSignature":"original-native-signature"}]}]); + let mut blocks = replay::encode( + &native_contents, + &binding, + &state, + "end_turn", + "full", + &json!([]), + ) + .unwrap(); + let original = replay::decode(&blocks, &binding).unwrap(); + let mut mutated = original.clone(); + mutated["contents"][0]["parts"][0]["thoughtSignature"] = json!("corrupt-native-signature"); + 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_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") + .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"); + 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")); + assert_eq!(captured[0].0.get_all("user-agent").iter().count(), 1); + assert_eq!(captured[0].0["user-agent"], "sentinel-no-retry-UA"); + } +} + +#[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/unsupported-model"); + 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, + "unsupported 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) { + const MAX_REQUEST: usize = 64 * 1024 * 1024; + const MAX_HEADERS: usize = 64 * 1024; + fn complete(raw: &[u8]) -> bool { + let Some(end) = raw.windows(4).position(|bytes| bytes == b"\r\n\r\n") else { + assert!(raw.len() <= MAX_HEADERS); + return false; + }; + assert!(end <= MAX_HEADERS); + let headers = std::str::from_utf8(&raw[..end]).unwrap(); + let length = headers.lines().find_map(|line| { + line.split_once(':') + .filter(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .map(|(_, value)| value.trim().parse::().unwrap()) + }); + if let Some(length) = length { + assert!(length <= MAX_REQUEST - end - 4); + return raw.len() >= end + 4 + length; + } + assert!( + headers + .lines() + .any(|line| line.split_once(':').is_some_and(|(name, value)| { + name.eq_ignore_ascii_case("transfer-encoding") + && value.trim().eq_ignore_ascii_case("chunked") + })), + "fixture request must have explicit HTTP/1.1 framing" + ); + let mut offset = end + 4; + loop { + let Some(line) = raw[offset..].windows(2).position(|bytes| bytes == b"\r\n") else { + return false; + }; + assert!(line <= MAX_HEADERS); + let value = std::str::from_utf8(&raw[offset..offset + line]) + .unwrap() + .split(';') + .next() + .unwrap() + .trim(); + let length = usize::from_str_radix(value, 16).unwrap(); + offset += line + 2; + if length == 0 { + let trailers = &raw[offset..]; + if trailers.starts_with(b"\r\n") { + return true; + } + assert!(trailers.len() <= MAX_HEADERS); + return trailers.windows(4).any(|bytes| bytes == b"\r\n\r\n"); + } + let needed = offset + .checked_add(length) + .and_then(|end| end.checked_add(2)) + .unwrap(); + assert!(needed <= MAX_REQUEST); + if raw.len() < needed { + return false; + } + assert_eq!(&raw[needed - 2..needed], b"\r\n"); + offset = needed; + } + } + let mut raw = Vec::new(); + let mut buffer = [0u8; 1024]; + loop { + let count = stream.read(&mut buffer).await.unwrap(); + assert_ne!(count, 0); + assert!(raw.len() + count <= MAX_REQUEST); + raw.extend_from_slice(&buffer[..count]); + if complete(&raw) { + return; + } + } +} + +#[tokio::test] +async fn antigravity_raw_fixture_reads_known_and_chunked_request_bodies() { + for body in [ + "Content-Length: 3\r\n\r\nabc", + "Transfer-Encoding: chunked\r\n\r\n3\r\nabc\r\n0\r\n\r\n", + "Transfer-Encoding: chunked\r\n\r\n1;x=y\r\na\r\n2\r\nbc\r\n0\r\nx-fixture: okay\r\n\r\n", + ] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let request = format!("POST / HTTP/1.1\r\nHost: localhost\r\n{body}"); + let sender = tokio::spawn(async move { + let mut socket = tokio::net::TcpStream::connect(address).await.unwrap(); + for chunk in request.as_bytes().chunks(2) { + socket.write_all(chunk).await.unwrap(); + } + socket.shutdown().await.unwrap(); + }); + let (mut socket, _) = listener.accept().await.unwrap(); + tokio::time::timeout(Duration::from_secs(1), read_request(&mut socket)) + .await + .unwrap(); + sender.await.unwrap(); + } +} + +#[tokio::test] +async fn antigravity_real_disconnect_cancels_preheader_nonstream_and_stream_collection() { + for (headers_sent, streaming) in [(false, false), (false, true), (true, false), (true, true)] { + 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()); + } +} + +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; + 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 lineage = replay::Lineage::new( + "11111111-1111-4111-8111-111111111111".into(), + "22222222-2222-4222-8222-222222222222".into(), + ) + .unwrap(); + let state = replay::State { + lineage, + native_checkpoint_index: 0, + user_execution: replay::UserExecution { + active_id: "aaaaaaaa-aaaa-4aaa-8aaa-000000000000".into(), + prior_recorded_id: None, + }, + }; + let mut estimates = Vec::new(); + for signature in ["x".to_string(), "x".repeat(100000)] { + let contents = + json!([{"role":"model","parts":[{"text":"same text","thoughtSignature":signature}]}]); + let blocks = replay::encode( + &contents, + &binding, + &state, + "end_turn", + "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()); +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn antigravity_downstream_sse_heartbeats_bridge_long_buffered_turns() { + use http_body_util::BodyExt; + use tower::ServiceExt; + + let upstream = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = url::Url::parse(&format!("http://{}", upstream.local_addr().unwrap())).unwrap(); + let sse_payload = successful(json!([{"text":"after deep thought"}])); + let (headers_observed_tx, headers_observed_rx) = tokio::sync::oneshot::channel(); + let native_task = tokio::spawn(async move { + let (mut socket, _) = upstream.accept().await.unwrap(); + read_request(&mut socket).await; + socket + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\n\r\n", + sse_payload.len() + ) + .as_bytes(), + ) + .await + .unwrap(); + headers_observed_rx.await.unwrap(); + // Pause 35s (> legacy 30s READ_IDLE and > 2x 15s downstream heartbeat) before + // flushing the native SSE turn. + tokio::time::sleep(Duration::from_secs(35)).await; + socket.write_all(sse_payload.as_bytes()).await.unwrap(); + }); + + let directory = tempfile::tempdir().unwrap(); + let registry = Registry::from_providers( + AliasProvider::Codex, + [Arc::new(provider(directory.path(), &url).await) as Arc], + ); + let app = app_with_features( + Arc::new(registry), + None, + AppFeatures { + responses_api: true, + ..AppFeatures::default() + }, + ); + let req = http::Request::builder() + .method("POST") + .uri("/v1/messages") + .header("content-type", "application/json") + .body(Body::from(request(true).to_string())) + .unwrap(); + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), http::StatusCode::OK); + headers_observed_tx.send(()).unwrap(); + let mut body = response.into_body(); + let mut frames = Vec::new(); + while let Some(frame) = body.frame().await { + if let Ok(data) = frame.unwrap().into_data() { + frames.push(String::from_utf8(data.to_vec()).unwrap()); + } + } + native_task.await.unwrap(); + assert_eq!( + frames.len(), + 3, + "expected 2 heartbeats + final SSE message: {frames:?}" + ); + assert_eq!(frames[0], ": ping\n\n"); + assert_eq!(frames[1], ": ping\n\n"); + assert!(frames[2].contains("event: message_start")); + assert!(frames[2].contains("after deep thought")); + assert!(frames[2].contains("event: message_stop")); +} + +#[tokio::test] +async fn antigravity_mid_stream_transport_drop_aborts_downstream_body_for_retry() { + use http_body_util::BodyExt; + use tower::ServiceExt; + + let upstream = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = url::Url::parse(&format!("http://{}", upstream.local_addr().unwrap())).unwrap(); + let (headers_observed_tx, headers_observed_rx) = tokio::sync::oneshot::channel(); + let native_task = tokio::spawn(async move { + let (mut socket, _) = upstream.accept().await.unwrap(); + read_request(&mut socket).await; + // Advertise Content-Length: 4096, write partial frame, then drop TCP socket mid-body. + socket + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: 4096\r\n\r\ndata: {\"response\":", + ) + .await + .unwrap(); + headers_observed_rx.await.unwrap(); + drop(socket); + }); + + let directory = tempfile::tempdir().unwrap(); + let monitor = MonitorHandle::new(10); + let registry = Registry::from_providers( + AliasProvider::Codex, + [Arc::new(provider(directory.path(), &url).await) as Arc], + ); + let app = app_with_features( + Arc::new(registry), + Some(monitor.clone()), + AppFeatures { + responses_api: true, + ..AppFeatures::default() + }, + ); + let req = http::Request::builder() + .method("POST") + .uri("/v1/messages") + .header("content-type", "application/json") + .body(Body::from(request(true).to_string())) + .unwrap(); + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), http::StatusCode::OK); + headers_observed_tx.send(()).unwrap(); + let result = response.into_body().collect().await; + native_task.await.unwrap(); + assert!( + result.is_err(), + "expected truncated native HTTP body to abort downstream body with io::Error" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + let snapshot = monitor.snapshot(); + assert!(snapshot.active.is_empty()); + assert_eq!(snapshot.recent.len(), 1); + assert_eq!(snapshot.recent[0].status, RequestStatus::Failed); +} diff --git a/src/providers/antigravity_observed_schema_tests.rs b/src/providers/antigravity_observed_schema_tests.rs new file mode 100644 index 00000000..7a333dc1 --- /dev/null +++ b/src/providers/antigravity_observed_schema_tests.rs @@ -0,0 +1,110 @@ +//! Observed tool-schema shapes with synthetic names and text, not private input replay. +use super::antigravity::{replay::Binding, request::prepare}; +use serde_json::{Value, json}; + +fn synthetic_caller_schema(native: &Value) -> Value { + let source = native.as_object().unwrap(); + assert!(source.keys().all(|key| matches!( + key.as_str(), + "type" + | "description" + | "properties" + | "items" + | "required" + | "enum" + | "default" + | "format" + | "pattern" + | "minimum" + | "maximum" + | "minLength" + | "maxLength" + | "minItems" + | "maxItems" + ))); + let kind = source["type"].as_str().unwrap(); + assert!(matches!( + kind, + "OBJECT" | "ARRAY" | "STRING" | "INTEGER" | "NUMBER" | "BOOLEAN" + )); + let mut caller = source.clone(); + caller.insert("type".into(), json!(kind.to_ascii_lowercase())); + if let Some(properties) = source.get("properties") { + caller.insert( + "properties".into(), + Value::Object( + properties + .as_object() + .unwrap() + .iter() + .map(|(name, schema)| (name.clone(), synthetic_caller_schema(schema))) + .collect(), + ), + ); + } + if let Some(items) = source.get("items") { + caller.insert("items".into(), synthetic_caller_schema(items)); + } + for key in ["minLength", "maxLength", "minItems", "maxItems"] { + if let Some(value) = source.get(key) { + let limit = value.as_str().unwrap().parse::().unwrap(); + assert!(limit <= 9_007_199_254_740_991); + caller.insert(key.into(), json!(limit)); + } + } + if kind == "OBJECT" + && source + .get("properties") + .and_then(Value::as_object) + .is_none_or(|properties| properties.is_empty()) + { + caller.insert("additionalProperties".into(), json!(false)); + } + Value::Object(caller) +} + +#[test] +fn antigravity_observed_tools_match_complete_synthetic_groups() { + let expected: Value = serde_json::from_str(include_str!( + "../../tests/fixtures/antigravity-observed-tool-schemas.json" + )) + .unwrap(); + let groups = expected.as_array().unwrap(); + assert_eq!(groups.len(), 12); + let tools: Vec = groups + .iter() + .map(|group| { + assert_eq!(group.as_object().unwrap().len(), 1); + let declarations = group["functionDeclarations"].as_array().unwrap(); + assert_eq!(declarations.len(), 1); + let mut tool = declarations[0].as_object().unwrap().clone(); + assert!( + tool.keys() + .all(|key| matches!(key.as_str(), "name" | "description" | "parameters")) + ); + let parameters = tool.remove("parameters").unwrap(); + tool.insert("input_schema".into(), synthetic_caller_schema(¶meters)); + Value::Object(tool) + }) + .collect(); + let request = serde_json::from_value(json!({ + "model":"antigravity/gemini-4-argon-low", + "max_tokens":512, + "messages":[{"role":"user","content":"synthetic captured-schema request"}], + "tools":tools + })) + .unwrap(); + let binding = Binding { + profile_id: "synthetic-profile".into(), + project_id: "synthetic-project".into(), + native_model: "gemini-4-argon-low".into(), + }; + let prepared = prepare(&request, &binding).unwrap(); + let actual = &prepared.wire["request"]["tools"]; + assert_eq!( + actual[3]["functionDeclarations"][0]["parameters"]["properties"]["field_0001"]["minLength"], + expected[3]["functionDeclarations"][0]["parameters"]["properties"]["field_0001"]["minLength"], + "observed int64 schema limits require quoted serialization" + ); + assert_eq!(actual, &expected, "complete synthetic tool groups differ"); +} diff --git a/src/providers/antigravity_tests.rs b/src/providers/antigravity_tests.rs new file mode 100644 index 00000000..b158d801 --- /dev/null +++ b/src/providers/antigravity_tests.rs @@ -0,0 +1,2613 @@ +//! 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"}]}) +} + +fn execution_id(index: u64) -> String { + format!("aaaaaaaa-aaaa-4aaa-8aaa-{index:012x}") +} + +fn default_lineage() -> replay::Lineage { + replay::Lineage::new( + "11111111-1111-4111-8111-111111111111".into(), + "22222222-2222-4222-8222-222222222222".into(), + ) + .unwrap() +} + +fn state_at(lineage: &replay::Lineage, index: u64) -> replay::State { + replay::State { + lineage: lineage.clone(), + native_checkpoint_index: index, + user_execution: replay::UserExecution { + active_id: execution_id(index), + prior_recorded_id: index.checked_sub(1).map(execution_id), + }, + } +} + +fn encode_turn( + contents: &Value, + binding: &replay::Binding, + lineage: &replay::Lineage, + index: u64, + display: &str, + mapping: &Value, +) -> replay::Result { + let stop = if mapping.as_array().is_some_and(|m| !m.is_empty()) { + "tool_use" + } else { + "end_turn" + }; + replay::encode( + contents, + binding, + &state_at(lineage, index), + stop, + display, + mapping, + ) +} + +fn encode_default( + contents: &Value, + binding: &replay::Binding, + display: &str, + mapping: &Value, +) -> replay::Result { + encode_turn(contents, binding, &default_lineage(), 0, display, mapping) +} + +#[test] +fn antigravity_first_checkpoint_is_bounded_and_scope_bound() { + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + let lineage = default_lineage(); + let other_lineage = replay::Lineage::new( + "33333333-3333-4333-8333-333333333333".into(), + "44444444-4444-4444-8444-444444444444".into(), + ) + .unwrap(); + let contents = json!([{"role":"model","parts":[ + {"text":"private thought","thought":true,"thoughtSignature":"opaque-original"}, + {"functionCall":{"name":"native_name","args":{"payload":"{\"x\":17}"}},"thoughtSignature":"opaque-call"}, + {"text":"complete"} + ]}]); + let recipes = + json!([{"local_id":"toolu_fixed","caller_name":"caller_name","codec_paths":["/payload"]}]); + let mapping = replay::mapping(&contents, Some(&recipes)).unwrap(); + for display in ["full", "omitted"] { + let other = + encode_turn(&contents, &binding(), &other_lineage, 0, display, &mapping).unwrap(); + let blocks = encode_turn(&contents, &binding(), &lineage, 0, display, &mapping).unwrap(); + assert_eq!( + replay::projection_values(&other).unwrap(), + replay::projection_values(&blocks).unwrap() + ); + assert!( + blocks[0]["signature"] + .as_str() + .unwrap() + .starts_with(replay::PREFIX) + ); + let envelope = replay::decode(&blocks, &binding()).unwrap(); + assert_eq!(envelope["contents"], contents); + assert_eq!(envelope["tool_mapping"], mapping); + assert_eq!(envelope["version"], 1); + assert_eq!(envelope["backend_session_id"], "-3750763034362895579"); + assert_eq!( + envelope["lineage"]["conversation_id"], + lineage.conversation_id + ); + assert_eq!(envelope["lineage"]["trajectory_id"], lineage.trajectory_id); + assert_ne!( + envelope["projection_digest"], + replay::decode(&other, &binding()).unwrap()["projection_digest"] + ); + for key in ["profile_id", "project_id", "native_model"] { + let mut scope = serde_json::to_value(binding()).unwrap(); + scope[key] = json!("different"); + let scope = serde_json::from_value(scope).unwrap(); + assert!(replay::decode(&blocks, &scope).is_err()); + } + for (key, value) in [ + ("version", json!(2)), + ("version", json!(3)), + ("backend_session_id", json!("19")), + ("native_checkpoint_index", json!(1)), + ("native_checkpoint_index", json!(2)), + ( + "lineage", + json!({"conversation_id":lineage.conversation_id,"trajectory_id":lineage.conversation_id}), + ), + ( + "lineage", + json!({"conversation_id":"33333333-3333-4333-8333-333333333333","trajectory_id":lineage.trajectory_id}), + ), + ( + "projection_digest", + replay::decode(&other, &binding()).unwrap()["projection_digest"].clone(), + ), + ("extra", json!(true)), + ] { + let mut tampered = envelope.clone(); + tampered[key] = value; + let mut candidate = blocks.clone(); + candidate[0]["signature"] = json!(format!( + "{}{}", + replay::PREFIX, + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&tampered).unwrap()) + )); + assert!( + replay::decode(&candidate, &binding()).is_err(), + "accepted {key}" + ); + } + let mut modified = blocks.clone(); + modified[1]["input"]["payload"]["x"] = json!(18); + assert!(replay::decode(&modified, &binding()).is_err()); + let mut foreign = blocks.clone(); + foreign[0]["signature"] = json!("ccp:antigravity:v3:unknown"); + assert!(replay::decode(&foreign, &binding()).is_err()); + foreign[0]["signature"] = json!(format!("{}!", replay::PREFIX)); + assert!(replay::decode(&foreign, &binding()).is_err()); + } + for id in [ + "not-a-uuid", + "11111111111141118111111111111111", + "11111111-1111-1111-8111-111111111111", + "11111111-1111-4111-0111-111111111111", + "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA", + ] { + assert!(replay::Lineage::new(id.into(), lineage.trajectory_id.clone()).is_err()); + } + let oversized = json!([{"role":"model","parts":[{"text":"x".repeat(replay::MAX_DECODED)}]}]); + assert_eq!( + encode_turn(&oversized, &binding(), &lineage, 0, "full", &json!([])).err(), + Some("JSON byte limit") + ); + let too_many = json!(vec![json!({"role":"model","parts":[]}); 513]); + assert!(encode_turn(&too_many, &binding(), &lineage, 0, "full", &json!([])).is_err()); + assert!( + replay::Lineage::new(lineage.conversation_id.clone(), lineage.conversation_id).is_err() + ); +} + +#[test] +fn antigravity_metadata_first_request_and_history_continuation() { + use super::antigravity::request::Metadata; + let body = serde_json::from_value(request_value()).unwrap(); + let mut fresh = prepare(&body, &binding()).unwrap(); + assert!( + matches!(fresh.metadata, Metadata::Fresh), + "preparation must not allocate Go identifiers" + ); + assert_eq!(fresh.wire["requestId"], ""); + assert_eq!(fresh.wire["request"]["sessionId"], ""); + let lineage = default_lineage(); + assert!( + fresh + .finalize_metadata(-1, || panic!("invalid clock must not allocate")) + .is_err() + ); + let mut allocated = 0; + let mut ids = [ + lineage.conversation_id.clone(), + lineage.trajectory_id.clone(), + execution_id(0), + ] + .into_iter(); + fresh + .finalize_metadata(1_799_999_999_123, || { + allocated += 1; + ids.next().unwrap() + }) + .unwrap(); + assert_eq!( + allocated, 3, + "two logical IDs and one proxy user execution at admission" + ); + assert_eq!( + fresh.wire["requestId"], + "agent/11111111-1111-4111-8111-111111111111/1799999999123/22222222-2222-4222-8222-222222222222/1" + ); + assert_eq!(fresh.wire["request"]["sessionId"], "-3750763034362895579"); + assert_eq!( + fresh.wire["request"]["labels"], + json!({ + "model_enum":"MODEL_PLACEHOLDER_M419","used_claude":"false","used_claude_conservative":"false", + "used_non_gemini_model":"false","last_step_index":"0","cascade_id":lineage.conversation_id, + "root_cascade_id":lineage.conversation_id,"trajectory_id":lineage.trajectory_id, + "request_id":format!("{}-0", lineage.trajectory_id) + }) + ); + assert!( + fresh + .finalize_metadata(1_799_999_999_123, || panic!( + "double finalization must not allocate" + )) + .is_err() + ); + let contents = json!([{"role":"model","parts":[{"text":"previous"}]}]); + let turn = encode_turn(&contents, &binding(), &lineage, 0, "full", &json!([])).unwrap(); + let mut history = request_value(); + history["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":turn}), + json!({"role":"user","content":"next"}), + ]); + // Ignored noncarrier signatures on text blocks must not invalidate a valid signed turn. + history["messages"][1]["content"][1]["signature"] = + json!("ccp:antigravity:v1:ignored-text-signature"); + let resumed = prepare(&serde_json::from_value(history).unwrap(), &binding()).unwrap(); + assert!(matches!( + resumed.metadata, + Metadata::Continuation { + new_user_execution: true, + .. + } + )); +} + +#[test] +fn antigravity_two_user_text_checkpoint_progress_and_history_consistency() { + use super::antigravity::request::Metadata; + let lineage = default_lineage(); + let first_contents = + json!([{"role":"model","parts":[{"text":"first","thoughtSignature":"original-opaque"}]}]); + let second_contents = json!([{"role":"model","parts":[{"text":"second"}]}]); + let first = encode_turn(&first_contents, &binding(), &lineage, 0, "full", &json!([])).unwrap(); + let second = encode_turn( + &second_contents, + &binding(), + &lineage, + 1, + "full", + &json!([]), + ) + .unwrap(); + let decoded = replay::decode_history(&json!([first, second]), &binding()).unwrap(); + assert_eq!(decoded[0]["native_checkpoint_index"], 0); + assert_eq!(decoded[1]["native_checkpoint_index"], 1); + assert_eq!(decoded[0]["contents"], first_contents); + assert!( + encode_turn( + &second_contents, + &binding(), + &lineage, + 512, + "full", + &json!([]) + ) + .is_err() + ); + for history in [ + json!([second]), + json!([second, first]), + json!([first, first]), + ] { + assert!( + replay::decode_history(&history, &binding()).is_err(), + "checkpoint omission/reorder/duplicate" + ); + } + let foreign = replay::Lineage::new( + "33333333-3333-4333-8333-333333333333".into(), + "44444444-4444-4444-8444-444444444444".into(), + ) + .unwrap(); + let other_second = encode_turn( + &second_contents, + &binding(), + &foreign, + 1, + "full", + &json!([]), + ) + .unwrap(); + assert!(replay::decode_history(&json!([first, other_second]), &binding()).is_err()); + let mut body = request_value(); + body["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":first}), + json!({"role":"user","content":"next"}), + ]); + let mut prepared = prepare(&serde_json::from_value(body.clone()).unwrap(), &binding()).unwrap(); + let Metadata::Continuation { + state, + new_user_execution: true, + .. + } = &prepared.metadata + else { + panic!("expected carried lineage, no fresh allocation yet") + }; + assert_eq!(state.lineage.conversation_id, lineage.conversation_id); + assert_eq!(state.lineage.trajectory_id, lineage.trajectory_id); + assert_eq!(prepared.wire["requestId"], ""); + assert_eq!(prepared.wire["request"]["sessionId"], ""); + assert!( + prepared + .finalize_metadata(1_799_999_999_456, || lineage.conversation_id.clone()) + .is_err(), + "reject detected user-execution/lineage collision without reminting" + ); + let mut allocations = 0; + prepared + .finalize_metadata(1_799_999_999_456, || { + allocations += 1; + execution_id(1) + }) + .unwrap(); + assert_eq!( + allocations, 1, + "ordinary new user allocates only its active execution" + ); + assert!( + matches!(prepared.metadata, Metadata::Ready(ref state) if state.native_checkpoint_index == 1) + ); + assert_eq!( + prepared.wire["request"]["labels"]["last_execution_id"], + execution_id(0) + ); + assert_eq!( + prepared.wire["request"]["labels"]["request_id"], + format!("{}-1", lineage.trajectory_id) + ); + assert_eq!(prepared.wire["request"]["labels"]["last_step_index"], "2"); + assert_eq!( + prepared.wire["requestId"], + format!( + "agent/{}/1799999999456/{}/3", + lineage.conversation_id, lineage.trajectory_id + ) + ); + assert_eq!( + prepared.wire["request"]["contents"], + json!([ + {"role":"user","parts":[{"text":"hello"}]}, first_contents[0], {"role":"user","parts":[{"text":"next"}]} + ]) + ); + // A carried native assistant checkpoint counts once; no hidden Go operation + // counters are inferred from the number of native SSE fragments/parts. + let fragmented = json!([ + {"role":"model","parts":[{"text":"part one","thoughtSignature":"opaque-one"}]}, + {"role":"model","parts":[{"text":"part two","thoughtSignature":"opaque-two"}]} + ]); + let fragmented_blocks = + encode_turn(&fragmented, &binding(), &lineage, 0, "full", &json!([])).unwrap(); + let mut split_body = body.clone(); + split_body["messages"][1]["content"] = fragmented_blocks; + let mut split = prepare(&serde_json::from_value(split_body).unwrap(), &binding()).unwrap(); + split + .finalize_metadata(1_799_999_999_456, || execution_id(1)) + .unwrap(); + assert_eq!(split.wire["request"]["labels"]["last_step_index"], "2"); + assert_eq!(split.wire["request"]["contents"][1], fragmented[0]); + assert_eq!(split.wire["request"]["contents"][2], fragmented[1]); + body["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":second}), + json!({"role":"user","content":"unobserved third user"}), + ]); + let mut third = prepare(&serde_json::from_value(body).unwrap(), &binding()).unwrap(); + assert!( + third + .finalize_metadata(1_799_999_999_789, || execution_id(0)) + .is_err(), + "new USER execution must not reuse an older carried identity" + ); + third + .finalize_metadata(1_799_999_999_789, || execution_id(2)) + .unwrap(); + assert_eq!( + third.wire["request"]["labels"]["request_id"], + format!("{}-2", lineage.trajectory_id) + ); + assert_eq!(third.wire["request"]["labels"]["last_step_index"], "4"); + assert_eq!( + third.wire["request"]["labels"]["last_execution_id"], + execution_id(1) + ); + assert!(third.wire["requestId"].as_str().unwrap().ends_with("/5")); +} + +#[test] +fn antigravity_unqualified_tool_and_noncanonical_text_transitions_reject() { + let lineage = default_lineage(); + let contents = json!([{"role":"model","parts":[{"functionCall":{"name":"tool","args":{}}}]}]); + let mapping = replay::mapping(&contents, None).unwrap(); + let tool_turn = encode_turn(&contents, &binding(), &lineage, 0, "full", &mapping).unwrap(); + let mut body = request_value(); + body["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":tool_turn}), + json!({"role":"user","content":"ordinary text without tool_result"}), + ]); + assert!(prepare(&serde_json::from_value(body).unwrap(), &binding(),).is_err()); + let text = encode_turn( + &json!([{"role":"model","parts":[{"text":"first"}]}]), + &binding(), + &lineage, + 0, + "full", + &json!([]), + ) + .unwrap(); + let mut body = request_value(); + body["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":text}), + json!({"role":"user","content":"next"}), + ]); + for signature in [ + "ccp:antigravity:v1:!", + "ccp:antigravity:v2:unknown", + "ccp:antigravity:v3:unknown", + "foreign", + ] { + let mut invalid = body.clone(); + invalid["messages"][1]["content"][0]["signature"] = json!(signature); + assert!(prepare(&serde_json::from_value(invalid).unwrap(), &binding(),).is_err()); + } + let mut changed_scope = binding(); + changed_scope.profile_id = "other".into(); + assert!( + prepare( + &serde_json::from_value(body.clone()).unwrap(), + &changed_scope, + ) + .is_err() + ); + body["messages"] + .as_array_mut() + .unwrap() + .push(json!({"role":"user","content":"additional unobserved role"})); + assert!(prepare(&serde_json::from_value(body).unwrap(), &binding(),).is_err()); +} + +#[test] +fn antigravity_user_execution_survives_read_loop_then_records_for_next_user() { + use super::antigravity::request::Metadata; + let lineage = default_lineage(); + let initial = state_at(&lineage, 0); + let call = json!([{"role":"model","parts":[{"functionCall":{"name":"read_file","args":{"Path":"/synthetic/owned"},"id":"synthetic-read-call-1"},"thoughtSignature":"c3ludGhldGljLXNpZ25hdHVyZQ=="}]}]); + let mapping = replay::mapping(&call, None).unwrap(); + let first = replay::encode(&call, &binding(), &initial, "tool_use", "full", &mapping).unwrap(); + let saved = replay::decode(&first, &binding()).unwrap(); + assert_eq!(saved["checkpoint_stop_reason"], "tool_use"); + assert!(saved["user_execution"]["prior_recorded_id"].is_null()); + let mut body = request_value(); + body["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":first}), + json!({"role":"user","content":[{"type":"tool_result","tool_use_id":first[1]["id"],"content":"read marker"}]}) + ]); + let mut tool = prepare(&serde_json::from_value(body.clone()).unwrap(), &binding()).unwrap(); + assert!(matches!( + tool.metadata, + Metadata::Continuation { + new_user_execution: false, + .. + } + )); + tool.finalize_metadata(1_799_999_999_456, || { + panic!("tool loop must not mint any IDs") + }) + .unwrap(); + let Metadata::Ready(ref running) = tool.metadata else { + panic!("metadata ready") + }; + assert_eq!(running.native_checkpoint_index, 1); + assert_eq!(running.user_execution.active_id, execution_id(0)); + assert_eq!(running.user_execution.prior_recorded_id, None); + assert!( + tool.wire["request"]["labels"] + .get("last_execution_id") + .is_none() + ); + assert_eq!(tool.wire["request"]["labels"]["last_step_index"], "2"); + assert_eq!(tool.wire["request"]["contents"][1], call[0]); + assert_eq!( + tool.wire["request"]["contents"][2], + json!({"role":"model","parts":[{"functionResponse":{"name":"read_file","response":{"output":"read marker"},"id":"synthetic-read-call-1"}}]}) + ); + let completion = json!([{"role":"model","parts":[{"text":"done"}]}]); + let second = replay::encode( + &completion, + &binding(), + running, + "end_turn", + "full", + &json!([]), + ) + .unwrap(); + let decoded = replay::decode(&second, &binding()).unwrap(); + let complete = replay::State::from_envelope(&decoded).unwrap(); + assert_eq!( + complete.recorded_after("end_turn").unwrap(), + Some(execution_id(0).as_str()) + ); + assert_eq!( + complete.user_execution.active_id, + initial.user_execution.active_id + ); + body["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":second}), + json!({"role":"user","content":"next ordinary user"}), + ]); + let mut next = prepare(&serde_json::from_value(body).unwrap(), &binding()).unwrap(); + let mut calls = 0; + next.finalize_metadata(1_799_999_999_789, || { + calls += 1; + execution_id(1) + }) + .unwrap(); + assert_eq!(calls, 1); + let Metadata::Ready(ref next_state) = next.metadata else { + panic!("ready") + }; + assert_eq!( + next_state.native_checkpoint_index, 2, + "generation ordinal is separate from USER execution identity" + ); + assert_eq!(next_state.user_execution.active_id, execution_id(1)); + assert_eq!( + next_state.user_execution.prior_recorded_id, + Some(execution_id(0)) + ); + assert_eq!( + next.wire["request"]["labels"]["last_execution_id"], + execution_id(0) + ); + assert_eq!( + next.wire["request"]["labels"]["request_id"], + format!("{}-2", lineage.trajectory_id) + ); + let third = replay::encode( + &completion, + &binding(), + next_state, + "end_turn", + "full", + &json!([]), + ) + .unwrap(); + assert!(replay::decode_history(&json!([first, second, third]), &binding()).is_ok()); +} + +#[test] +fn antigravity_incomplete_or_invalid_terminal_state_never_records_success() { + let lineage = default_lineage(); + let state = state_at(&lineage, 0); + let text = json!([{"role":"model","parts":[{"text":"limited"}]}]); + let limited = + replay::encode(&text, &binding(), &state, "max_tokens", "full", &json!([])).unwrap(); + let saved = replay::decode(&limited, &binding()).unwrap(); + let incomplete = replay::State::from_envelope(&saved).unwrap(); + assert_eq!(incomplete.recorded_after("max_tokens").unwrap(), None); + let mut body = request_value(); + body["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":limited}), + json!({"role":"user","content":"not a proven continuation"}), + ]); + assert!(prepare(&serde_json::from_value(body).unwrap(), &binding(),).is_err()); + assert!(replay::encode(&text, &binding(), &state, "tool_use", "full", &json!([])).is_err()); + for stop in ["STOP", "", "error", "cancelled"] { + assert!(replay::encode(&text, &binding(), &state, stop, "full", &json!([])).is_err()); + } + let call = json!([{"role":"model","parts":[{"functionCall":{"name":"tool","id":"native","args":{}}}]}]); + let map = replay::mapping(&call, None).unwrap(); + assert!(replay::encode(&call, &binding(), &state, "end_turn", "full", &map).is_err()); + for id in [ + lineage.conversation_id.clone(), + lineage.trajectory_id.clone(), + "bad".into(), + ] { + let mut invalid = state.clone(); + invalid.user_execution.active_id = id; + assert!( + replay::encode(&text, &binding(), &invalid, "end_turn", "full", &json!([])).is_err() + ); + } + let mut invalid = state; + invalid.user_execution.prior_recorded_id = Some(execution_id(7)); + assert!( + replay::encode(&text, &binding(), &invalid, "end_turn", "full", &json!([])).is_err(), + "first checkpoint has no previous execution" + ); +} + +#[test] +fn antigravity_execution_predecessors_and_termination_are_digest_bound() { + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + let lineage = default_lineage(); + let text = json!([{"role":"model","parts":[{"text":"complete"}]}]); + let first = encode_turn(&text, &binding(), &lineage, 0, "full", &json!([])).unwrap(); + let second = encode_turn(&text, &binding(), &lineage, 1, "full", &json!([])).unwrap(); + let envelope = replay::decode(&first, &binding()).unwrap(); + for (key, value) in [ + ( + "user_execution", + json!({"active_id":execution_id(7),"prior_recorded_id":null}), + ), + ( + "user_execution", + json!({"active_id":execution_id(0),"prior_recorded_id":execution_id(9)}), + ), + ("user_execution", json!({"active_id":execution_id(0)})), + ( + "user_execution", + json!({"active_id":execution_id(0),"prior_recorded_id":null,"extra":true}), + ), + ("checkpoint_stop_reason", json!("max_tokens")), + ] { + let mut modified = envelope.clone(); + modified[key] = value; + let mut blocks = first.clone(); + blocks[0]["signature"] = json!(format!( + "{}{}", + replay::PREFIX, + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&modified).unwrap()) + )); + assert!(replay::decode(&blocks, &binding()).is_err()); + } + let mut wrong_predecessor = state_at(&lineage, 1); + wrong_predecessor.user_execution.prior_recorded_id = Some(execution_id(7)); + let wrong = replay::encode( + &text, + &binding(), + &wrong_predecessor, + "end_turn", + "full", + &json!([]), + ) + .unwrap(); + assert!(replay::decode_history(&json!([first, wrong]), &binding()).is_err()); + let mut reused = state_at(&lineage, 2); + reused.user_execution.active_id = execution_id(0); + let wrong = replay::encode(&text, &binding(), &reused, "end_turn", "full", &json!([])).unwrap(); + assert!(replay::decode_history(&json!([first, second, wrong]), &binding()).is_err()); + let call = json!([{"role":"model","parts":[{"functionCall":{"name":"read_file","id":"native","args":{}},"thoughtSignature":"opaque"}]}]); + let mapping = replay::mapping(&call, None).unwrap(); + let tool_first = replay::encode( + &call, + &binding(), + &state_at(&lineage, 0), + "tool_use", + "full", + &mapping, + ) + .unwrap(); + let mut reset = state_at(&lineage, 1); + reset.user_execution.prior_recorded_id = None; + let changed_active = + replay::encode(&text, &binding(), &reset, "end_turn", "full", &json!([])).unwrap(); + assert!( + replay::decode_history(&json!([tool_first, changed_active]), &binding()).is_err(), + "tool continuation cannot reset active execution" + ); +} + +fn metadata_count_context() -> crate::provider::RequestContext { + crate::provider::RequestContext { + req_id: "synthetic-pure-count".into(), + session_id: None, + session_seq: None, + provider: "antigravity".into(), + traffic: None, + monitor: None, + } +} + +async fn counting_provider( + directory: &std::path::Path, + url: &url::Url, + project: Option<&str>, + expires: u64, +) -> (super::antigravity::AntigravityProvider, Store) { + let store = Store::new(directory.join("antigravity/auth.json")); + let lock = store.lock(std::time::Duration::from_secs(1)).await.unwrap(); + store + .save( + &lock, + &Credential { + profile_id: "profile".into(), + project_id: project.map(str::to_owned), + access_token: "public-synthetic-access".into(), + refresh_token: Some("public-synthetic-refresh".into()), + expires_at: expires, + }, + ) + .unwrap(); + drop(lock); + let config = Config { + base_url: url.clone(), + token_url: url.join("/token").unwrap(), + client_id: Some("public-synthetic-client".into()), + project: project.map(str::to_owned), + ..Config::default() + }; + ( + super::antigravity::AntigravityProvider::with_auth( + super::antigravity::auth::Auth::new(config, store.clone()).unwrap(), + ), + store, + ) +} + +async fn pure_count_response( + provider: &super::antigravity::AntigravityProvider, + body: &crate::anthropic::schema::MessagesRequest, +) -> (http::StatusCode, Value) { + use super::antigravity::request::effects; + use crate::provider::Provider; + let before = provider.count_scope_snapshot(); + effects::reset(); + let response = provider + .handle_count_tokens(body.clone(), metadata_count_context()) + .await; + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024) + .await + .unwrap(); + let value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!( + effects::snapshot(), + [0; 5], + "no credential/profile load, access, factory, clock or native identity source" + ); + assert_eq!( + provider.count_scope_snapshot(), + before, + "count cannot publish or mutate scope" + ); + (status, value) +} + +#[test] +fn antigravity_agy_count_value_inputs_preserve_semantics_without_effects() { + use super::antigravity::request::{self, CountInputs, ServingScope, effects}; + let lineage = default_lineage(); + let contents = + json!([{"role":"model","parts":[{"text":"prior","thoughtSignature":"untouched"}]}]); + let full = encode_turn(&contents, &binding(), &lineage, 0, "full", &json!([])).unwrap(); + let omitted = encode_turn(&contents, &binding(), &lineage, 0, "omitted", &json!([])).unwrap(); + let known = ServingScope { + profile_id: "profile".into(), + project_id: "project".into(), + }; + let wrong = ServingScope { + profile_id: "not-the-serving-profile".into(), + project_id: "project".into(), + }; + let mut expected = None; + for blocks in [full, omitted] { + let mut value = request_value(); + value["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":blocks}), + json!({"role":"user","content":"next"}), + ]); + let body: crate::anthropic::schema::MessagesRequest = + serde_json::from_value(value).unwrap(); + let before = serde_json::to_vec(&body).unwrap(); + for scope in [None, Some(&known)] { + effects::reset(); + let inputs = CountInputs { + serving_scope: scope, + configured_project: Some("project"), + }; + let count = request::estimate(&body, inputs).unwrap(); + assert_eq!(request::estimate(&body, inputs).unwrap(), count); + assert!(count > 0); + if let Some(expected) = expected { + assert_eq!( + count, expected, + "opaque/version/state bytes are not semantic token input" + ); + } + expected = Some(count); + assert_eq!( + serde_json::to_vec(&body).unwrap(), + before, + "count never mutates caller history/lineage" + ); + assert_eq!(effects::snapshot(), [0; 5]); + } + effects::reset(); + assert!( + request::estimate( + &body, + CountInputs { + serving_scope: Some(&wrong), + configured_project: Some("project") + } + ) + .is_err() + ); + assert!( + request::estimate( + &body, + CountInputs { + serving_scope: None, + configured_project: Some("other-project") + } + ) + .is_err() + ); + let mut changed_model = body.clone(); + changed_model.model = Some("antigravity/gemini-4-argon-high".into()); + assert!( + request::estimate( + &changed_model, + CountInputs { + serving_scope: None, + configured_project: Some("project") + } + ) + .is_ok() + ); + changed_model.model = Some("antigravity/unsupported-model".into()); + assert!( + request::estimate( + &changed_model, + CountInputs { + serving_scope: None, + configured_project: Some("project") + } + ) + .is_err() + ); + assert_eq!(effects::snapshot(), [0; 5]); + } + effects::reset(); + let fresh = serde_json::from_value(request_value()).unwrap(); + assert!( + request::estimate( + &fresh, + CountInputs { + serving_scope: None, + configured_project: None + } + ) + .unwrap() + > 0, + "unknown scope is estimate-only, not admission" + ); + assert_eq!(effects::snapshot(), [0; 5]); +} + +#[tokio::test(flavor = "current_thread")] +async fn antigravity_agy_count_never_loads_credentials_accesses_or_constructs_factories() { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + let calls = Arc::new(AtomicUsize::new(0)); + let observed = calls.clone(); + let server = + super::antigravity_http_tests::Server::start(axum::Router::new().fallback(move || { + let observed = observed.clone(); + async move { + observed.fetch_add(1, Ordering::SeqCst); + http::StatusCode::INTERNAL_SERVER_ERROR + } + })) + .await; + let directory = tempfile::tempdir().unwrap(); + let (provider, _) = + counting_provider(directory.path(), &server.url, Some("project"), u64::MAX).await; + let path = directory.path().join("antigravity/auth.json"); + let body = serde_json::from_value(request_value()).unwrap(); + for kind in ["invalid", "unreadable", "missing"] { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if path.exists() { + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + } + } + match kind { + "invalid" => std::fs::write(&path, b"public invalid synthetic auth").unwrap(), + "unreadable" => { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)) + .unwrap(); + } + } + "missing" => std::fs::remove_file(&path).unwrap(), + _ => unreachable!(), + } + let (status, counted) = pure_count_response(&provider, &body).await; + assert_eq!(status, 200, "{kind}"); + assert!(counted["input_tokens"].as_u64().unwrap() > 0); + if kind == "missing" { + assert!(!path.exists()); + } + assert!(provider.count_scope_snapshot().is_none()); + } + let (expired, _) = counting_provider(directory.path(), &server.url, Some("project"), 1).await; + assert_eq!(pure_count_response(&expired, &body).await.0, 200); + let (undiscovered, _) = counting_provider(directory.path(), &server.url, None, u64::MAX).await; + assert_eq!(pure_count_response(&undiscovered, &body).await.0, 200); + assert_eq!( + calls.load(Ordering::SeqCst), + 0, + "no refresh/discovery/generation transport calls" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn antigravity_agy_count_claims_are_estimates_never_authoritative_scope() { + use crate::provider::Provider; + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + let calls = Arc::new(AtomicUsize::new(0)); + let observed = calls.clone(); + let server = + super::antigravity_http_tests::Server::start(axum::Router::new().fallback(move || { + let observed = observed.clone(); + async move { + observed.fetch_add(1, Ordering::SeqCst); + http::StatusCode::INTERNAL_SERVER_ERROR + } + })) + .await; + let directory = tempfile::tempdir().unwrap(); + let (provider, _) = + counting_provider(directory.path(), &server.url, Some("project"), u64::MAX).await; + let lineage = default_lineage(); + let mut claim = binding(); + claim.profile_id = "untrusted-carrier-claim".into(); + let blocks = encode_turn( + &json!([{"role":"model","parts":[{"text":"prior"}]}]), + &claim, + &lineage, + 0, + "full", + &json!([]), + ) + .unwrap(); + let mut body = request_value(); + body["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":blocks}), + json!({"role":"user","content":"next"}), + ]); + let body = serde_json::from_value(body).unwrap(); + let (status, counted) = pure_count_response(&provider, &body).await; + assert_eq!(status, 200); + assert!(counted["input_tokens"].as_u64().unwrap() > 0); + assert!( + provider.count_scope_snapshot().is_none(), + "untrusted carrier cannot bootstrap authoritative scope" + ); + let rejected = provider + .handle_messages(body, metadata_count_context()) + .await; + assert_eq!( + rejected.status(), + 400, + "generation still validates actual native owner/profile binding" + ); + assert!(provider.count_scope_snapshot().is_none()); + assert_eq!(calls.load(Ordering::SeqCst), 0); +} + +#[tokio::test(flavor = "current_thread")] +async fn antigravity_agy_count_scope_is_published_only_by_validated_serving() { + use super::antigravity::request::ServingScope; + use crate::provider::Provider; + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + let calls = Arc::new(AtomicUsize::new(0)); + let observed = calls.clone(); + let frame = format!( + "data: {}\n\n", + json!({"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"completed"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":2,"totalTokenCount":5}}}) + ); + let server = + super::antigravity_http_tests::Server::start(axum::Router::new().fallback(move || { + let observed = observed.clone(); + let frame = frame.clone(); + async move { + observed.fetch_add(1, Ordering::SeqCst); + ([("content-type", "text/event-stream")], frame) + } + })) + .await; + let directory = tempfile::tempdir().unwrap(); + let (provider, _) = + counting_provider(directory.path(), &server.url, Some("project"), u64::MAX).await; + assert!(provider.count_scope_snapshot().is_none()); + let generated = provider + .handle_messages( + serde_json::from_value(request_value()).unwrap(), + metadata_count_context(), + ) + .await; + assert_eq!(generated.status(), 200); + let generated: Value = serde_json::from_slice( + &axum::body::to_bytes(generated.into_body(), 1024 * 1024) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!( + provider.count_scope_snapshot(), + Some(ServingScope { + profile_id: "profile".into(), + project_id: "project".into() + }) + ); + std::fs::write( + directory.path().join("antigravity/auth.json"), + b"public invalid synthetic auth after successful serving", + ) + .unwrap(); + let mut body = request_value(); + body["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":generated["content"]}), + json!({"role":"user","content":"next"}), + ]); + let body = serde_json::from_value(body).unwrap(); + assert_eq!(pure_count_response(&provider, &body).await.0, 200); + let mut foreign_scope = binding(); + foreign_scope.profile_id = "untrusted-carrier-claim".into(); + let lineage = default_lineage(); + let foreign = encode_turn( + &json!([{"role":"model","parts":[{"text":"foreign"}]}]), + &foreign_scope, + &lineage, + 0, + "full", + &json!([]), + ) + .unwrap(); + let mut foreign_body = request_value(); + foreign_body["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":foreign}), + json!({"role":"user","content":"next"}), + ]); + assert_eq!( + pure_count_response(&provider, &serde_json::from_value(foreign_body).unwrap()) + .await + .0, + 400, + "a carrier cannot replace known serving scope" + ); + let rejected = provider + .handle_messages(body, metadata_count_context()) + .await; + assert_eq!( + rejected.status(), + 401, + "count snapshot never authorizes generation" + ); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "count and failed auth do not call upstream" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn antigravity_agy_failed_and_cancelled_turns_do_not_publish_count_scope() { + use crate::provider::Provider; + use futures_util::StreamExt; + for cancel in [false, true] { + let server=super::antigravity_http_tests::Server::start(axum::Router::new().fallback(move || async move { + let body=if cancel { + let stream=futures_util::stream::once(async { Ok::<_,std::convert::Infallible>(bytes::Bytes::from_static(b"data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"incomplete\"}]}}]}}\n\n")) }).chain(futures_util::stream::pending()); + axum::body::Body::from_stream(stream) + } else { axum::body::Body::from("data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":false}}]}}\n\n") }; + axum::response::Response::builder().header("content-type","text/event-stream").body(body).unwrap() + })).await; + let directory = tempfile::tempdir().unwrap(); + let (provider, _) = + counting_provider(directory.path(), &server.url, Some("project"), u64::MAX).await; + let mut body = request_value(); + body["stream"] = json!(cancel); + let response = provider + .handle_messages( + serde_json::from_value(body).unwrap(), + metadata_count_context(), + ) + .await; + assert_eq!(response.status(), if cancel { 200 } else { 502 }); + drop(response); + tokio::task::yield_now().await; + assert!( + provider.count_scope_snapshot().is_none(), + "no successful native checkpoint, no authoritative count scope" + ); + } +} + +#[test] +fn antigravity_absent_native_args_project_empty_without_changing_replay() { + let contents = json!([{"role":"model","parts":[{"functionCall":{"name":"empty"},"thoughtSignature":"untouched"}]}]); + let set = ToolSet::compile( + &json!([{"name":"empty","input_schema":{"type":"object","additionalProperties":false}}]), + ) + .unwrap(); + let mapping = set.mapping(&contents).unwrap(); + let blocks = encode_default(&contents, &binding(), "full", &mapping).unwrap(); + assert_eq!(blocks[1]["input"], json!({})); + let decoded = replay::decode(&blocks, &binding()).unwrap(); + assert_eq!(decoded["contents"], contents); + 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_ok()); + 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_agy_projection_inlines_local_refs_and_handles_cycles() { + let schema = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "Filter": { + "type": "object", + "properties": { + "field": {"type": "string"}, + "children": {"type": "array", "items": {"$ref": "#/$defs/Filter"}} + }, + "required": ["field"] + } + }, + "type": "object", + "properties": { + "primary": {"$ref": "#/$defs/Filter"}, + "secondary": { + "description": "Optional secondary filter", + "anyOf": [{"$ref": "#/$defs/Filter"}, {"type": "null"}] + } + }, + "required": ["primary", "undeclared_ignored"] + }); + let set = ToolSet::compile(&json!([{"name": "query", "input_schema": schema}])).unwrap(); + let params = &set.declarations[0]["parameters"]; + assert_eq!(params["type"], "OBJECT"); + assert_eq!(params["required"], json!(["primary"])); + assert_eq!( + params["properties"]["primary"]["properties"]["field"], + json!({"type": "STRING"}) + ); + assert_eq!( + params["properties"]["primary"]["properties"]["children"]["type"], + "ARRAY" + ); + assert_eq!(params["properties"]["secondary"]["type"], "OBJECT"); + assert_eq!(params["properties"]["secondary"]["nullable"], true); + assert_eq!( + params["properties"]["secondary"]["description"], + "Optional secondary filter" + ); + assert_eq!( + params["properties"]["secondary"]["properties"]["field"], + json!({"type": "STRING"}) + ); + let contents = json!([{"role": "model", "parts": [{"functionCall": {"name": "query", "args": {"primary": {"field": "status", "children": []}}}}]}]); + let mapping = set.mapping(&contents).unwrap(); + assert_eq!(mapping[0]["codec_paths"], json!([])); + assert_eq!(mapping[0]["root_wrapper"], Value::Null); +} + +#[test] +fn antigravity_agy_projection_folds_allof_unions_and_nullable_types() { + let schema = json!({ + "type": "object", + "allOf": [ + {"properties": {"base": {"type": "string"}}, "required": ["base"]}, + {"properties": {"limit": {"type": "integer", "minimum": 1}}} + ], + "properties": { + "nullable_str": {"type": ["string", "null"], "description": "Optional label"}, + "number_union": {"anyOf": [{"type": "integer"}, {"type": "number"}]}, + "heterogeneous": {"oneOf": [{"type": "string"}, {"type": "boolean"}], "description": "String or flag"}, + "const_flag": {"const": "fixed"} + } + }); + let set = ToolSet::compile(&json!([{"name": "merged", "input_schema": schema}])).unwrap(); + let props = &set.declarations[0]["parameters"]["properties"]; + assert_eq!( + set.declarations[0]["parameters"]["required"], + json!(["base"]) + ); + assert_eq!(props["base"], json!({"type": "STRING"})); + assert_eq!(props["limit"], json!({"type": "INTEGER", "minimum": 1.0})); + assert_eq!( + props["nullable_str"], + json!({"type": "STRING", "nullable": true, "description": "Optional label"}) + ); + assert_eq!(props["number_union"], json!({"type": "NUMBER"})); + assert_eq!( + props["heterogeneous"], + json!({"description": "String or flag"}) + ); + assert_eq!( + props["const_flag"], + json!({"type": "STRING", "enum": ["fixed"]}) + ); +} + +#[test] +fn antigravity_adjacent_assistant_fragments_preserve_original_system_boundaries() { + let contents = + json!([{"role":"model","parts":[{"thought":true,"text":"thought"},{"text":"answer"}]}]); + let blocks = encode_default(&contents, &binding(), "full", &json!([])).unwrap(); + let mut request = request_value(); + request["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":[blocks[0]]}), + 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":"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(), + 3 + ); + assert_eq!( + prepared.wire["request"]["systemInstruction"]["parts"], + json!([{"text":"privileged"}]) + ); + assert_eq!(prepared.wire["request"]["contents"][2]["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 = encode_default(&contents, &binding(), 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 = encode_default(&contents, &binding(), display, &json!([])).unwrap(); + let mut request = request_value(); + request["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":[blocks[0]]}), + 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 = encode_default( + &native, + &scope, + 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 = encode_default(&contents, &binding(), "full", &mapping).unwrap(); + let envelope = replay::decode(&blocks, &binding()).unwrap(); + let merged = json!([blocks[0], {"type":"text","text":text.repeat(4096)}]); + let start = std::time::Instant::now(); + 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, + &json!({ + "lower":{"type":"INTEGER","minimum":1.5}, + "upper":{"type":"INTEGER","maximum":-1.5} + }) + ); + let call = |args: Value| json!([{"role":"model","parts":[{"functionCall":{"name":"range","args":args}}]}]); + assert!(set.mapping(&call(json!({"lower":2,"upper":-2}))).is_ok()); +} + +#[test] +fn antigravity_selected_scalar_constraints_keep_proto_types() { + let set = ToolSet::compile(&json!([{"name":"bounded","input_schema":{ + "type":"object","properties":{ + "text":{"type":"string","minLength":1,"maxLength":3}, + "list":{"type":"array","items":{"type":"integer"},"minItems":1,"maxItems":2}, + "number":{"type":"number","minimum":1,"maximum":4.0} + },"required":["text","list","number"],"additionalProperties":false + }}])) + .unwrap(); + let parameters = &set.declarations[0]["parameters"]; + assert_eq!(parameters.as_object().unwrap().len(), 3); + assert_eq!(parameters["type"], "OBJECT"); + assert_eq!(parameters["required"], json!(["text", "list", "number"])); + let properties = ¶meters["properties"]; + assert_eq!(properties.as_object().unwrap().len(), 3); + assert_eq!( + properties["text"], + json!({"type":"STRING","minLength":"1","maxLength":"3"}) + ); + assert_eq!( + properties["list"], + json!({"type":"ARRAY","items":{"type":"INTEGER"},"minItems":"1","maxItems":"2"}) + ); + let number = properties["number"].as_object().unwrap(); + assert_eq!(number.len(), 3); + assert_eq!(number["type"], "NUMBER"); + assert_eq!(number["minimum"].as_f64(), Some(1.0)); + assert_eq!(number["maximum"].as_f64(), Some(4.0)); + let call = |args: Value| json!([{"role":"model","parts":[{"functionCall":{"name":"bounded","args":args}}]}]); + assert!( + set.mapping(&call(json!({"text":"ok","list":[1,2],"number":2.5}))) + .is_ok() + ); +} + +#[test] +fn antigravity_property_names_static_cli_record_shapes_project_as_native_objects() { + let metadata = + json!({"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}); + let answers = json!({"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}}); + let annotations = json!({"type":"object","propertyNames":{"type":"string"},"additionalProperties":{ + "type":"object","properties":{"preview":{"type":"string"},"notes":{"type":"string"}},"additionalProperties":false + }}); + let set = ToolSet::compile(&json!([ + {"name":"TaskCreate","input_schema":{"type":"object","properties":{"metadata":metadata},"additionalProperties":false}}, + {"name":"TaskUpdate","input_schema":{"type":"object","properties":{"metadata":metadata},"additionalProperties":false}}, + {"name":"AskUserQuestion","input_schema":{"type":"object","properties":{"answers":answers,"annotations":annotations},"additionalProperties":false}} + ])).unwrap(); + for (index, property) in [ + (0, "metadata"), + (1, "metadata"), + (2, "answers"), + (2, "annotations"), + ] { + assert_eq!( + set.declarations[index]["parameters"]["properties"][property], + json!({"type":"OBJECT"}) + ); + } + let metadata_val = json!({"a/b~雪":{"nested":[null,true,17,"value"]},"empty":{},"":[]}); + let contents = json!([{"role":"model","parts":[{"functionCall":{"name":"TaskCreate","args":{"metadata":metadata_val}},"thoughtSignature":"unchanged"}]}]); + let mapping = set.mapping(&contents).unwrap(); + assert_eq!(mapping[0]["codec_paths"], json!([])); + assert_eq!(mapping[0]["root_wrapper"], Value::Null); + let blocks = encode_default(&contents, &binding(), "full", &mapping).unwrap(); + assert_eq!(blocks[1]["input"], json!({"metadata":metadata_val})); + assert_eq!( + replay::decode(&blocks, &binding()).unwrap()["contents"], + contents + ); +} + +#[test] +fn antigravity_schema_traversal_preserves_depth_node_and_byte_limits() { + let compile = |schema| ToolSet::compile(&json!([{"name":"record","input_schema":schema}])); + let mut deep = json!(true); + for _ in 0..32 { + deep = json!({"propertyNames":deep}); + } + assert!(compile(deep.clone()).is_ok()); + assert_eq!( + compile(json!({"propertyNames":deep})).err(), + Some("schema depth/node limit") + ); + let properties = (0..8188) + .map(|i| (format!("p{i}"), json!(true))) + .collect::>(); + let mut wide = json!({"type":"object","properties":properties,"propertyNames":true}); + assert!(compile(wide.clone()).is_ok()); + wide["properties"]["extra"] = json!(true); + assert_eq!(compile(wide).err(), Some("schema depth/node limit")); + assert_eq!( + compile(json!({"propertyNames":{"description":"x".repeat(256*1024)}})).err(), + Some("schema byte limit") + ); + for bad_root in [ + json!(true), + json!([]), + json!({"type":"string"}), + json!({"type":["string","integer"]}), + ] { + assert!(compile(bad_root).is_err()); + } +} + +#[test] +fn antigravity_schema_accepts_dialects_unknown_keywords_and_custom_formats() { + for schema in [ + json!({"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"limit":{"type":"integer","exclusiveMinimum":0}},"required":["limit"],"additionalProperties":false}), + json!({"$schema":"https://example.invalid/draft","$defs":{"x":{"type":"string"}},"$dynamicRef":"#x","prefixItems":[],"unevaluatedProperties":false,"unknownConstraint":true,"properties":{"id":{"type":"string","format":"custom-mcp-id"}}}), + json!({"$ref":"https://example.invalid/schema","properties":{"ok":{"type":"boolean"}}}), + ] { + assert!(ToolSet::compile(&json!([{"name":"tool","input_schema":schema}])).is_ok()); + } +} + +#[test] +fn antigravity_mapped_colon_and_dot_names_roundtrip_native_arguments() { + let name = "caller/tool with spaces"; + let colon = "mcp:server:read"; + let dot = "mcp.server.read"; + let set = ToolSet::compile(&json!([ + {"name":name,"input_schema":{"type":"object","properties":{"args":{}},"required":["args"]}}, + {"name":colon,"input_schema":{"type":"object","properties":{"uri":{"type":"string","format":"uri"}}}}, + {"name":dot,"input_schema":{"type":"object","properties":{"id":{"type":"string"}}}}, + {"name":"mcp__server__read-1","input_schema":{"type":"object"}} + ])).unwrap(); + let native_name = set.declarations[0]["name"].as_str().unwrap().to_string(); + let native_colon = set.declarations[1]["name"].as_str().unwrap().to_string(); + let native_dot = set.declarations[2]["name"].as_str().unwrap().to_string(); + assert!(native_name.starts_with("ccp_")); + assert!(native_colon.starts_with("ccp_")); + assert!(native_dot.starts_with("ccp_")); + assert_eq!(set.declarations[3]["name"], "mcp__server__read-1"); + assert_eq!( + set.declarations[0]["parameters"]["properties"]["args"], + json!({}) + ); + assert_eq!( + set.declarations[1]["parameters"]["properties"]["uri"], + json!({"type":"STRING","format":"uri"}) + ); + let contents = json!([{"role":"model","parts":[ + {"functionCall":{"name":native_name,"args":{"args":[1,{"x":true}]}},"thoughtSignature":"unchanged"}, + {"functionCall":{"name":native_colon,"args":{"uri":"file:///a"}}} + ]}]); + let mapping = set.mapping(&contents).unwrap(); + assert_eq!(mapping[0]["codec_paths"], json!([])); + assert_eq!(mapping[1]["caller_name"], colon); + let blocks = encode_default(&contents, &binding(), "omitted", &mapping).unwrap(); + assert_eq!(blocks[1]["name"], name); + assert_eq!(blocks[1]["input"]["args"], json!([1,{"x":true}])); + assert_eq!(blocks[2]["name"], colon); + assert_eq!(blocks[2]["input"]["uri"], json!("file:///a")); + assert_eq!( + replay::decode(&blocks, &binding()).unwrap()["contents"], + contents + ); +} + +#[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()); + assert_eq!(prepared.wire["request"]["sessionId"], ""); +} + +#[test] +fn antigravity_request_system_role_user_preserves_parts_and_order() { + let mut request = request_value(); + request["system"] = json!([{"type":"text","text":"top"}]); + request["messages"] = json!([ + {"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"], + json!({"role":"user","parts":[{"text":"top\n\nfirst\n\nsecond"}]}) + ); + 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_absent_cap_defaults_to_65536() { + for model in super::antigravity::request::MODELS { + let mut scope = binding(); + scope.native_model = model.strip_prefix("antigravity/").unwrap().into(); + for null in [false, true] { + let mut request = request_value(); + request["model"] = json!(model); + request.as_object_mut().unwrap().remove("max_tokens"); + if null { + request["max_tokens"] = Value::Null; + } + let prepared = prepare(&serde_json::from_value(request).unwrap(), &scope).unwrap(); + assert_eq!( + prepared.wire["request"]["generationConfig"]["maxOutputTokens"], 65536, + "model={model}, null={null}" + ); + } + } +} + +#[test] +fn antigravity_request_explicit_caps_are_exact_and_invalid_caps_reject() { + let mut estimates = Vec::new(); + for stream in [false, true] { + for cap in [1, 512, 65536] { + let mut request = request_value(); + request["max_tokens"] = json!(cap); + request["stream"] = json!(stream); + request["output_config"] = json!({"effort":"high"}); + let prepared = prepare(&serde_json::from_value(request).unwrap(), &binding()).unwrap(); + assert_eq!( + prepared.wire["request"]["generationConfig"]["maxOutputTokens"], + cap + ); + assert_eq!( + prepared.wire["request"]["generationConfig"]["thinkingConfig"], + json!({"includeThoughts":true,"thinkingLevel":"LOW"}) + ); + estimates.push(prepared.estimate); + } + } + assert!(estimates.iter().all(|estimate| *estimate == estimates[0])); + for cap in [0, 65537] { + let mut request = request_value(); + request["max_tokens"] = json!(cap); + assert_eq!( + prepare(&serde_json::from_value(request).unwrap(), &binding()).err(), + Some("max_tokens must be between 1 and 65536") + ); + } + for cap in [ + json!(-1), + json!(1.5), + json!("512"), + json!(true), + json!([]), + json!({}), + ] { + let mut request = request_value(); + request["max_tokens"] = cap; + assert!( + serde_json::from_value::(request).is_err() + ); + } +} + +#[test] +fn antigravity_request_absent_and_empty_system_keep_exact_envelope() { + for system in [None, Some(json!([]))] { + let mut request = request_value(); + if let Some(system) = system { + request["system"] = system; + } + let prepared = prepare(&serde_json::from_value(request).unwrap(), &binding()).unwrap(); + assert_eq!( + prepared + .wire + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect::>(), + std::collections::BTreeSet::from([ + "project", + "model", + "request", + "requestId", + "userAgent", + "requestType" + ]) + ); + assert_eq!( + prepared.wire["request"] + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect::>(), + std::collections::BTreeSet::from(["contents", "sessionId", "generationConfig"]) + ); + assert_eq!( + prepared.wire["request"]["contents"], + json!([{"role":"user","parts":[{"text":"hello"}]}]) + ); + assert_eq!(prepared.wire["requestType"], "agent"); + assert_eq!(prepared.wire["userAgent"], "antigravity"); + } +} + +#[test] +fn antigravity_request_multiple_tools_have_one_declaration_per_object() { + let source = json!([ + {"name":"caller/tool with spaces","description":"free-form input","input_schema":{"type":"object"}}, + {"name":"empty","input_schema":{"type":"object","additionalProperties":false}}, + {"name":"limited","input_schema":{"type":"object","properties":{"limit":{"type":"integer","minimum":1}},"required":["limit"],"additionalProperties":false}} + ]); + let compiled = ToolSet::compile(&source).unwrap(); + let mut request = request_value(); + request["tools"] = source; + request["tool_choice"] = json!({"type":"auto"}); + let prepared = prepare(&serde_json::from_value(request).unwrap(), &binding()).unwrap(); + assert_eq!(prepared.tools.declarations, compiled.declarations); + let expected: Vec = compiled + .declarations + .as_array() + .unwrap() + .iter() + .map(|declaration| json!({"functionDeclarations":[declaration]})) + .collect(); + assert_eq!(prepared.wire["request"]["tools"], json!(expected)); + assert_eq!(prepared.wire["request"].as_object().unwrap().len(), 5); + assert_eq!(prepared.wire.as_object().unwrap().len(), 6); + assert_eq!( + prepared.wire["request"]["toolConfig"], + json!({"functionCallingConfig":{"mode":"VALIDATED"}}) + ); + assert!(prepared.wire.get("labels").is_none()); + let contents = json!([{"role":"model","parts":[ + {"functionCall":{"name":compiled.declarations[0]["name"],"args":{"payload":[1,true]}}}, + {"functionCall":{"name":"empty","args":{}}}, + {"functionCall":{"name":"limited","args":{"limit":1}}} + ]}]); + let mapping = prepared.tools.mapping(&contents).unwrap(); + let blocks = encode_default(&contents, &binding(), "full", &mapping).unwrap(); + assert_eq!(blocks[1]["name"], "caller/tool with spaces"); + assert_eq!(blocks[1]["input"], json!({"payload":[1,true]})); + assert_eq!(blocks[2]["input"], json!({})); + assert_eq!(blocks[3]["input"], json!({"limit":1})); + assert_eq!( + replay::decode(&blocks, &binding()).unwrap()["contents"], + contents + ); + assert!( + prepared + .tools + .mapping(&json!([{"role":"model","parts":[{"functionCall":{"name":"unknown_tool","args":{}}}]}])) + .is_err() + ); +} + +#[test] +fn antigravity_request_zero_one_tools_and_choice_none_preserve_omission() { + let tool = + json!({"name":"empty","input_schema":{"type":"object","additionalProperties":false}}); + for (source, choice, expected) in [ + (json!([]), None, None), + (json!([]), Some(json!({"type":"auto"})), None), + ( + json!([tool]), + None, + Some( + json!([{"functionDeclarations":[{"name":"empty","parameters":{"type":"OBJECT"}}]}]), + ), + ), + (json!([tool]), Some(json!({"type":"none"})), None), + ] { + let mut request = request_value(); + request["tools"] = source; + if let Some(choice) = choice { + request["tool_choice"] = choice; + } + let prepared = prepare(&serde_json::from_value(request).unwrap(), &binding()).unwrap(); + assert_eq!(prepared.wire["request"].get("tools"), expected.as_ref()); + assert_eq!( + prepared.tools.declarations.as_array().unwrap().len(), + usize::from(expected.is_some()) + ); + assert!(prepared.wire["request"].get("toolConfig").is_none()); + assert!(prepared.wire["request"].get("labels").is_none()); + assert_eq!(prepared.wire.as_object().unwrap().len(), 6); + } +} + +#[test] +fn antigravity_request_rejects_semantic_options_media_and_lost_history() { + for (key, value) in [ + ("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() { + 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 = encode_default(&contents, &binding(), "full", &mapping).unwrap(); + let first = + json!({"type":"tool_result","tool_use_id":mapping[0]["local_id"],"content":"first"}); + let second = json!({"type":"tool_result","tool_use_id":mapping[1]["local_id"],"content":[{"type":"text","text":"second"}],"is_error":true}); + 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_native_objects() { + let set = ToolSet::compile(&json!([ + {"name":"free","input_schema":{"type":"object"}}, + {"name":"empty","input_schema":{"type":"object","additionalProperties":false}} + ])) + .unwrap(); + assert_eq!(set.declarations[0]["parameters"], json!({"type":"OBJECT"})); + assert_eq!(set.declarations[1]["parameters"], json!({"type":"OBJECT"})); + let contents = json!([{"role":"model","parts":[{"functionCall":{"name":"free","args":{"x":[1,true]}}},{"functionCall":{"name":"empty","args":{}}}]}]); + let map = set.mapping(&contents).unwrap(); + assert_eq!(map[0]["codec_paths"], json!([])); + assert_eq!(map[0]["root_wrapper"], Value::Null); + let blocks = encode_default(&contents, &binding(), "full", &map).unwrap(); + assert_eq!(blocks[1]["input"], json!({"x":[1,true]})); + assert_eq!(blocks[2]["input"], json!({})); +} + +#[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 state = replay::State::from_envelope(&envelope).unwrap(); + let blocks = replay::encode( + &vector["native_contents"], + &binding, + &state, + envelope["checkpoint_stop_reason"].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| { + encode_default( + &input["contents"], + &binding, + 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"]); + } +} + +#[test] +fn antigravity_strips_claude_code_billing_and_sdk_headers_and_consolidates_agy_system_parts() { + for sdk_header in [ + "You are Claude Code, Anthropic's official CLI for Claude.", + "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK.", + "You are a Claude agent, built on Anthropic's Claude Agent SDK.", + ] { + let mut request = request_value(); + request["system"] = json!([ + {"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.270.dd6; cc_entrypoint=sdk-cli;"}, + {"type":"text","text":sdk_header,"cache_control":{"type":"ephemeral"}}, + {"type":"text","text":"\nYou are an interactive agent.","cache_control":{"type":"ephemeral"}} + ]); + request["messages"] = json!([ + {"role":"system","content":"# Environment\n - Primary working directory: /workspace"}, + {"role":"user","content":"Reply with OK."} + ]); + + let prepared = prepare(&serde_json::from_value(request).unwrap(), &binding()).unwrap(); + assert_eq!( + prepared.wire["request"]["systemInstruction"], + json!({ + "role":"user", + "parts":[{"text":"\nYou are an interactive agent.\n\n# Environment\n - Primary working directory: /workspace"}] + }) + ); + + // Pre-flattened single-string system with leading billing + SDK headers. + let mut flattened = request_value(); + flattened["system"] = json!(format!( + "x-anthropic-billing-header: cc_version=2.1.270.dd6\n\n{sdk_header}\n\n\nYou are an interactive agent." + )); + let flattened_prepared = + prepare(&serde_json::from_value(flattened).unwrap(), &binding()).unwrap(); + assert_eq!( + flattened_prepared.wire["request"]["systemInstruction"], + json!({ + "role":"user", + "parts":[{"text":"\nYou are an interactive agent."}] + }) + ); + + // System consisting solely of billing + SDK headers omits systemInstruction entirely. + let mut headers_only = request_value(); + headers_only["system"] = json!([ + {"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.270.dd6"}, + {"type":"text","text":sdk_header} + ]); + let omitted = prepare(&serde_json::from_value(headers_only).unwrap(), &binding()).unwrap(); + assert!(omitted.wire["request"].get("systemInstruction").is_none()); + } +} + +#[test] +fn antigravity_opus_5_5_and_carrierless_prefix_turns_and_coalesced_text_blocks() { + use super::antigravity::request::{CountInputs, estimate}; + use crate::MessagesRequest; + + let opus_binding = replay::Binding { + profile_id: "profile".into(), + project_id: "project".into(), + native_model: "claude-opus-5-5-low".into(), + }; + let lineage = default_lineage(); + let signed_turn = encode_turn( + &json!([{"role":"model","parts":[{"thought":true,"text":"thinking","thoughtSignature":"sig-1"},{"text":"Turn 2 reply."}]}]), + &opus_binding, + &lineage, + 0, + "full", + &json!([]), + ) + .unwrap(); + + // Carrier-less prefix (from mid-session /model switch) + active signed turn + let mut switched = request_value(); + switched["model"] = json!("antigravity/claude-opus-5-5-low"); + switched["messages"] = json!([ + {"role":"user","content":"Remember token ALPHA."}, + {"role":"assistant","content":[ + {"type":"text","text":"Checking tool."}, + {"type":"tool_use","id":"toolu_prefix_1","name":"Read","input":{"file_path":"/tmp/a.txt"}} + ]}, + {"role":"user","content":[ + {"type":"tool_result","tool_use_id":"toolu_prefix_1","content":"ALPHA"} + ]}, + {"role":"assistant","content":[ + {"type":"text","text":"Token ALPHA recorded."} + ]}, + {"role":"user","content":"Now switch model."}, + {"role":"assistant","content":signed_turn}, + {"role":"user","content":"What was the token?"} + ]); + + let body: MessagesRequest = serde_json::from_value(switched).unwrap(); + let mut prepared = prepare(&body, &opus_binding).unwrap(); + let mut ids = [execution_id(1)].into_iter(); + prepared + .finalize_metadata(1_799_999_999_456, || ids.next().unwrap()) + .unwrap(); + assert_eq!( + prepared.wire["request"]["labels"]["model_enum"], + json!("MODEL_PLACEHOLDER_M400") + ); + let contents = prepared.wire["request"]["contents"].as_array().unwrap(); + assert_eq!(contents.len(), 3); + assert_eq!(contents[0]["role"], json!("user")); + let prefix_text = contents[0]["parts"][0]["text"].as_str().unwrap(); + assert!(prefix_text.starts_with("\n")); + assert!(prefix_text.contains("[Assistant invoked tool Read (id=toolu_prefix_1) with input: {\"file_path\":\"/tmp/a.txt\"}]")); + assert!(prefix_text.contains("[Tool result (toolu_prefix_1): ALPHA]")); + assert!(!prefix_text.contains("[tool_use ")); + assert_eq!(contents[0]["parts"][1], json!({"text":"Now switch model."})); + assert_eq!(contents[1]["role"], json!("model")); + assert_eq!( + contents[2], + json!({"role":"user","parts":[{"text":"What was the token?"}]}) + ); + + let counted = estimate( + &body, + CountInputs { + serving_scope: None, + configured_project: Some("project"), + }, + ) + .unwrap(); + assert!(counted > 0); +} + +#[test] +fn antigravity_canonicalizes_client_normalized_tool_inputs_and_accepts_global_cache_scope_and_xhigh_effort() + { + let opus_binding = replay::Binding { + profile_id: "profile".into(), + project_id: "project".into(), + native_model: "claude-opus-5-5-medium".into(), + }; + let native_call = json!([{"role":"model","parts":[ + {"thought":true,"text":"Running git pull.","thoughtSignature":"sig-bash"}, + {"functionCall":{ + "name":"Bash", + "id":"toolu_vrtx_014vTPhzW5gH8g73V1H3aPPh", + "args":{ + "command":"cd /data/chrisliu/DataFactory && git pull --ff-only -q origin master 2>&1 | tail -2; git log -1 --oneline", + "description":"Pull latest master" + } + }} + ]}]); + let mapping = replay::mapping(&native_call, None).unwrap(); + let mut blocks = encode_turn( + &native_call, + &opus_binding, + &default_lineage(), + 0, + "full", + &mapping, + ) + .unwrap(); + let local_id = blocks[1]["id"].as_str().unwrap().to_owned(); + + // Simulate client-side manipulations (Claude Code Bash updatedInput, cleared thinking text, + // extra cache_control / metadata fields). + blocks[0]["thinking"] = json!(""); + blocks[1]["input"]["command"] = + json!("git pull --ff-only -q origin master 2>&1 | tail -2; git log -1 --oneline"); + assert!( + replay::decode(&blocks, &opus_binding).is_err(), + "raw decode remains strict on uncanonicalized blocks" + ); + + let mut request = request_value(); + request["model"] = json!("antigravity/claude-opus-5-5-medium"); + request["output_config"] = json!({"effort":"xhigh"}); + request["metadata"] = json!({"user_id":"u1","session_id":"s1"}); + request["service_tier"] = json!("auto"); + request["system"] = json!([ + {"type":"text","text":"Global system instruction","cache_control":{"type":"ephemeral","ttl":"1h","scope":"global","future_hint":true}} + ]); + request["messages"] = json!([ + {"role":"user","content":"Investigate why VE TEST PostgreSQL OOMed"}, + {"role":"assistant","content":blocks.clone()}, + {"role":"user","content":[ + {"type":"tool_result","tool_use_id":local_id,"content":"96672b397 Merge branch"} + ]} + ]); + let prepared = prepare( + &serde_json::from_value(request.clone()).unwrap(), + &opus_binding, + ) + .unwrap(); + assert_eq!( + prepared.wire["request"]["contents"][1]["parts"][1]["functionCall"]["args"]["command"], + json!( + "cd /data/chrisliu/DataFactory && git pull --ff-only -q origin master 2>&1 | tail -2; git log -1 --oneline" + ) + ); + + // Switching mid-session from Opus 5.5 to Gemini 4 Argon low after an interrupted tool_use + // with Claude Code's `thinking: {"type": "enabled", "budget_tokens": 31999}` and + // `DeferredToolPlaceholder` succeeds by folding prior-model turns into a user-side + // `` (with zero fake `role: "model"` text tool calls). + let mut switched = request.clone(); + switched["model"] = json!("antigravity/gemini-4-argon-low"); + switched["thinking"] = json!({"type":"enabled","budget_tokens":31999}); + switched["tools"] = json!([ + {"name":"Bash","description":"Run bash","input_schema":{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}}, + {"name":"DeferredToolPlaceholder","description":"Placeholder","input_schema":{"type":"object"},"defer_loading":true} + ]); + switched["messages"][2]["content"] + .as_array_mut() + .unwrap() + .extend([ + json!({"type":"text","text":"[Request interrupted by user]"}), + json!({"type":"text","text":"continue"}), + ]); + let argon_prepared = prepare(&serde_json::from_value(switched).unwrap(), &binding()).unwrap(); + assert_eq!(argon_prepared.wire["model"], json!("gemini-4-argon-low")); + let argon_contents = argon_prepared.wire["request"]["contents"] + .as_array() + .unwrap(); + assert_eq!(argon_contents.len(), 1); + assert_eq!(argon_contents[0]["role"], json!("user")); + assert!( + !argon_prepared.wire["request"]["tools"] + .to_string() + .contains("DeferredToolPlaceholder") + ); + let mut reducer = Reducer::default(); + reducer + .push( + format!( + "data: {}\n\n", + json!({ + "response": { + "candidates": [{ + "finishReason": "UNEXPECTED_TOOL_CALL", + "finishMessage": "SendMessage is not declared" + }], + "usageMetadata": { + "promptTokenCount": 10, + "candidatesTokenCount": 1, + "totalTokenCount": 11 + } + } + }) + ) + .as_bytes(), + ) + .unwrap(); + let recovered_turn = reducer.finish().unwrap(); + assert_eq!(recovered_turn.stop_reason, "end_turn"); + + // Tampering with the carrier signature itself still fails closed in prepare. + let mut tampered_sig = request; + tampered_sig["messages"][1]["content"][0]["signature"] = + json!(format!("{}corrupted", replay::PREFIX)); + assert!( + prepare( + &serde_json::from_value(tampered_sig).unwrap(), + &opus_binding + ) + .is_err() + ); +} + +#[test] +fn antigravity_task_update_projects_native_enum_and_object_without_codec() { + let set = ToolSet::compile(&json!([{ + "name": "TaskUpdate", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "taskId": {"type": "string"}, + "status": { + "description": "New status for the task", + "anyOf": [ + {"type": "string", "enum": ["pending", "in_progress", "completed"]}, + {"type": "string", "const": "deleted"} + ] + }, + "metadata": { + "type": "object", + "propertyNames": {"type": "string"}, + "additionalProperties": {} + } + }, + "required": ["taskId"], + "additionalProperties": false + } + }])) + .unwrap(); + assert_eq!( + set.declarations[0]["parameters"], + json!({ + "type": "OBJECT", + "properties": { + "taskId": {"type": "STRING"}, + "status": { + "type": "STRING", + "description": "New status for the task", + "enum": ["pending", "in_progress", "completed", "deleted"] + }, + "metadata": {"type": "OBJECT"} + }, + "required": ["taskId"] + }) + ); + let direct = json!([{"role": "model", "parts": [{"functionCall": {"name": "TaskUpdate", "args": {"taskId": "1", "status": "in_progress", "metadata": {"step": 1}}}, "thoughtSignature": "sig"}]}]); + let mapping = set.mapping(&direct).unwrap(); + assert_eq!(mapping[0]["codec_paths"], json!([])); + assert_eq!(mapping[0]["root_wrapper"], Value::Null); + let blocks = encode_default(&direct, &binding(), "full", &mapping).unwrap(); + assert_eq!( + blocks[1]["input"], + json!({"taskId": "1", "status": "in_progress", "metadata": {"step": 1}}) + ); +} diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 8c4f82fe..12248fb8 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -1,6 +1,16 @@ +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_observed_schema_tests; +#[cfg(test)] +mod antigravity_tests; diff --git a/src/registry.rs b/src/registry.rs index 2dad8e5c..bac6ddca 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -88,9 +88,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()), @@ -138,6 +150,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() } @@ -374,6 +393,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 a12db0c8..55be250b 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,14 @@ fn log_native_request_completed( ); } +fn antigravity_context_error( + _model: &str, + _original_model: &str, + _headers: &http::HeaderMap, +) -> Option<&'static str> { + None +} + async fn dispatch_request( state: Arc, req: Request, @@ -1630,6 +1659,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() { @@ -1653,6 +1691,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()); } @@ -1676,10 +1724,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, @@ -1717,6 +1769,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() { @@ -1739,10 +1800,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(), @@ -1994,7 +2060,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!({ @@ -2096,7 +2162,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)); @@ -2104,6 +2175,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, } } @@ -2404,6 +2478,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 grok_session_classifier_routes_by_requested_model_or_explicit_override() { use crate::config::AliasProvider; diff --git a/src/traffic.rs b/src/traffic.rs index 4d7936e3..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 { @@ -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/antigravity_schema_parity.rs b/tests/antigravity_schema_parity.rs new file mode 100644 index 00000000..62e279b2 --- /dev/null +++ b/tests/antigravity_schema_parity.rs @@ -0,0 +1,2489 @@ +//! Public, synthetic production-library contract tests. The library dependency is +//! built without cfg(test); TLS verification and configuration use its normal APIs. +//! The bundled private key is a public test asset, never an account credential. +use anyhow::{Context, Result, bail, ensure}; +use axum::{ + Router, + body::to_bytes, + extract::{Request, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{get, post}, +}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use claude_code_proxy::{ + anthropic::schema::MessagesRequest, + provider::{Provider, RequestContext}, + providers::antigravity::AntigravityProvider, +}; +use futures_util::FutureExt; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::{ + collections::VecDeque, + io::Read, + net::SocketAddr, + panic::AssertUnwindSafe, + path::PathBuf, + process::{Command, Stdio}, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; +use tokio::{net::TcpListener, sync::oneshot, task::JoinHandle}; +use tokio_rustls::{TlsAcceptor, server::TlsStream}; + +const MODEL: &str = "antigravity/gemini-4-argon-high"; +const PROFILE: &str = "synthetic-schema-profile"; +const PROJECT: &str = "synthetic-schema-project"; +const TOKEN: &str = "public-synthetic-token-not-a-credential"; +const BODY_LIMIT: usize = 1024 * 1024; +const STEP_LIMIT: Duration = Duration::from_secs(8); +const CA: &[u8] = include_bytes!("fixtures/antigravity-schema-parity/ca.pem"); +const CERT: &[u8] = include_bytes!("fixtures/antigravity-schema-parity/server.der"); +const KEY: &[u8] = include_bytes!("fixtures/antigravity-schema-parity/server-key.der"); + +// Child environment changes never mutate the test runner's process environment. +// Only this very executable and this exact ignored entrypoint can be launched. +fn isolated(case: &str) { + let directory = tempfile::tempdir().expect("synthetic child directory"); + for name in ["home", "config", "state", "tmp", "empty-certs"] { + std::fs::create_dir(directory.path().join(name)).expect("synthetic directory"); + } + let root = directory.path().join("root.pem"); + std::fs::write(&root, if case == "untrusted" { &[][..] } else { CA }) + .expect("synthetic root file"); + let mut command = Command::new(std::env::current_exe().expect("integration executable")); + command + .args([ + "--ignored", + "--exact", + "schema_parity_child_entry", + "--nocapture", + ]) + .env_clear() + .env("HOME", directory.path().join("home")) + .env("XDG_CONFIG_HOME", directory.path().join("config")) + .env("XDG_STATE_HOME", directory.path().join("state")) + .env("CCP_CONFIG_DIR", directory.path().join("config")) + .env("TMPDIR", directory.path().join("tmp")) + .env("SSL_CERT_FILE", &root) + .env("SSL_CERT_DIR", directory.path().join("empty-certs")) + .env("SCHEMA_PARITY_CASE", case) + .env("RUST_TEST_THREADS", "1") + .current_dir(directory.path()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command.spawn().expect("owned integration child"); + fn reader(pipe: impl Read + Send + 'static) -> std::thread::JoinHandle> { + std::thread::spawn(move || { + let mut output = Vec::new(); + // Closing on overflow also prevents an unbounded writer/reader pair. + pipe.take(16 * 1024 + 1) + .read_to_end(&mut output) + .expect("child pipe"); + output + }) + } + let stdout = reader(child.stdout.take().expect("owned stdout")); + let stderr = reader(child.stderr.take().expect("owned stderr")); + let deadline = Instant::now() + Duration::from_secs(120); + let mut expired = false; + let status = loop { + if let Some(status) = child.try_wait().expect("owned child status") { + break status; + } + if Instant::now() >= deadline { + expired = true; + child + .kill() + .expect("terminate only the owned fixture child"); + break child.wait().expect("reap owned fixture child"); + } + std::thread::sleep(Duration::from_millis(20)); + }; + let stdout = stdout.join().expect("join stdout reader"); + let stderr = stderr.join().expect("join stderr reader"); + assert!(!expired, "synthetic child exceeded its finite deadline"); + assert!( + stdout.len() <= 16 * 1024 && stderr.len() <= 16 * 1024, + "child output bound" + ); + assert!( + status.success(), + "synthetic case {case} failed ({status})\n{}\n{}", + String::from_utf8_lossy(&stdout), + String::from_utf8_lossy(&stderr) + ); + assert!( + String::from_utf8_lossy(&stdout).contains("1 passed"), + "child test did not run" + ); +} + +macro_rules! cases { + ($($name:ident => $case:literal),+ $(,)?) => {$( + #[test] + fn $name() { isolated($case); } + )+}; +} +cases! { + schema_parity_verified_tls_control => "tls", + schema_parity_tls_rejects_untrusted_root => "untrusted", + schema_parity_baseline_validation_control => "validation", + schema_parity_v1_continuation_control => "continuation", + schema_parity_quoted_int64_limits => "limits", + schema_parity_presence_sensitive_defaults => "defaults", + schema_parity_zero_constraint_omission => "zeros", + schema_parity_closed_empty_parameters => "closed-empty", + schema_parity_explicit_auto_validated => "auto", + schema_parity_empty_pool_choice_controls => "choice-controls", + schema_parity_whole_tool_legacy_fallback => "fallback", + schema_parity_eligibility_selection => "eligibility", + schema_parity_numeric_variants_and_budgets => "numeric-budgets", + schema_parity_original_admission_controls => "admission", + schema_parity_project_admission_control => "project-admission", + schema_parity_observed_first_agent_metadata => "first-agent", + schema_parity_observed_two_user_text_metadata => "two-user-text", + schema_parity_observed_three_user_text_recurrence => "three-user-text", + schema_parity_observed_last_recorded_execution => "last-recorded-execution", + schema_parity_observed_read_tool_metadata => "read-tool-metadata", + schema_parity_observed_invalid_turn_never_checkpoints => "invalid-native-checkpoint", + schema_parity_observed_count_purity => "observed-count-purity", + schema_parity_source_all_tier_metadata => "source-all-tier-metadata", +} + +#[test] +#[ignore = "fixed subprocess entrypoint; invoked only with isolated synthetic environment"] +fn schema_parity_child_entry() { + let case = std::env::var("SCHEMA_PARITY_CASE").expect("fixed synthetic case selector"); + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .expect("fixture runtime"); + runtime.block_on(async { + let mut fixture = Fixture::start() + .await + .expect("start verified HTTPS fixture"); + // Even a failed assertion/panic must first close and join fixture work. + let result = AssertUnwindSafe(tokio::time::timeout( + Duration::from_secs(100), + exercise(&case, &mut fixture), + )) + .catch_unwind() + .await; + let closed = fixture.close().await; + closed.expect("fixture closure and synthetic store preservation"); + match result { + Ok(Ok(result)) => result.expect("synthetic contract"), + Ok(Err(_)) => panic!("synthetic case deadline"), + Err(panic) => std::panic::resume_unwind(panic), + } + }); +} + +// axum's public Listener API avoids either a new Hyper dependency or a +// production-only test transport hook. Handshakes are verified by the client. +struct HttpsListener { + tcp: TcpListener, + tls: TlsAcceptor, +} +impl axum::serve::Listener for HttpsListener { + type Io = TlsStream; + type Addr = SocketAddr; + + async fn accept(&mut self) -> (Self::Io, Self::Addr) { + loop { + match self.tcp.accept().await { + Ok((stream, address)) => { + if let Ok(Ok(stream)) = + tokio::time::timeout(Duration::from_secs(3), self.tls.accept(stream)).await + { + return (stream, address); + } + } + Err(_) => tokio::time::sleep(Duration::from_millis(10)).await, + } + } + } + + fn local_addr(&self) -> std::io::Result { + self.tcp.local_addr() + } +} + +#[derive(Default)] +struct Script { + seen: Vec, + replies: VecDeque, + failures: Vec<&'static str>, +} +type Shared = Arc>; + +async fn generation(State(state): State, request: Request) -> Response { + let valid_headers = request.method() == "POST" + && request.uri().path() == "/v1internal:streamGenerateContent" + && request.uri().query() == Some("alt=sse") + && request + .headers() + .get("authorization") + .is_some_and(|v| v.as_bytes() == format!("Bearer {TOKEN}").as_bytes()) + && !request.headers().contains_key("accept") + && request + .headers() + .get("content-type") + .is_some_and(|v| v == "application/json") + && request + .headers() + .get("accept-encoding") + .is_some_and(|v| v == "gzip"); + if !valid_headers { + state + .lock() + .unwrap() + .failures + .push("unexpected generation headers or URL"); + return StatusCode::BAD_REQUEST.into_response(); + } + let raw = match to_bytes(request.into_body(), BODY_LIMIT).await { + Ok(raw) => raw, + Err(_) => return StatusCode::PAYLOAD_TOO_LARGE.into_response(), + }; + let wire: Value = match serde_json::from_slice(&raw) { + Ok(wire) => wire, + Err(_) => return StatusCode::BAD_REQUEST.into_response(), + }; + let mut script = state.lock().unwrap(); + if script.seen.len() >= 256 { + script.failures.push("generation count bound"); + return StatusCode::TOO_MANY_REQUESTS.into_response(); + } + // Keep the complete request. Do not delete unknown fields or normalize it. + script.seen.push(wire); + let Some(parts) = script.replies.pop_front() else { + script.failures.push("unscripted generation"); + return StatusCode::BAD_REQUEST.into_response(); + }; + let frame = json!({"response":{ + "candidates":[{"content":{"role":"model","parts":parts},"finishReason":"STOP"}], + "usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":2,"totalTokenCount":5} + }}); + ( + [("content-type", "text/event-stream")], + format!("data: {frame}\n\ndata: [DONE]\n\n"), + ) + .into_response() +} + +struct Fixture { + provider: AntigravityProvider, + state: Shared, + origin: String, + stop: Option>, + server: JoinHandle>, + auth_path: PathBuf, + auth_bytes: Vec, +} +struct Exchange { + status: StatusCode, + stream: bool, + content_type: String, + bytes: Vec, + wire: Option, +} +impl Fixture { + async fn start() -> Result { + let config_dir = + PathBuf::from(std::env::var_os("CCP_CONFIG_DIR").context("isolated config")?); + let auth_dir = config_dir.join("antigravity"); + std::fs::create_dir(&auth_dir)?; + let auth_path = auth_dir.join("auth.json"); + let expires = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs() + + 7 * 24 * 60 * 60; + let auth_bytes = serde_json::to_vec(&json!({ + "profile_id":PROFILE,"project_id":PROJECT,"access_token":TOKEN, + "refresh_token":null,"expires_at":expires + }))?; + std::fs::write(&auth_path, &auth_bytes)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&auth_dir, std::fs::Permissions::from_mode(0o700))?; + std::fs::set_permissions(&auth_path, std::fs::Permissions::from_mode(0o600))?; + } + let tcp = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).await?; + let origin = format!("https://{}", tcp.local_addr()?); + std::fs::write( + config_dir.join("config.json"), + serde_json::to_vec(&json!({"antigravity":{ + "enabled":true,"baseUrl":origin,"projectId":PROJECT + }}))?, + )?; + let tls = rustls::ServerConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions()? + .with_no_client_auth() + .with_single_cert( + vec![CertificateDer::from(CERT.to_vec())], + PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(KEY.to_vec())), + )?; + let listener = HttpsListener { + tcp, + tls: TlsAcceptor::from(Arc::new(tls)), + }; + let state: Shared = Arc::default(); + let app = Router::new() + .route("/control", get(|| async { "synthetic verified TLS" })) + .route("/v1internal:streamGenerateContent", post(generation)) + .fallback({ + let state = state.clone(); + move || { + let state = state.clone(); + async move { + state.lock().unwrap().failures.push("unexpected endpoint"); + StatusCode::NOT_FOUND + } + } + }) + .with_state(state.clone()); + let (stop, stopped) = oneshot::channel(); + let server = tokio::spawn(async move { + axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = stopped.await; + }) + .await + }); + Ok(Self { + provider: AntigravityProvider::new(), + state, + origin, + stop: Some(stop), + server, + auth_path, + auth_bytes, + }) + } + + async fn close(mut self) -> Result<()> { + let maintenance = + tokio::time::timeout(STEP_LIMIT, self.provider.finish_maintenance()).await; + if let Some(stop) = self.stop.take() { + let _ = stop.send(()); + } + let joined = tokio::time::timeout(Duration::from_secs(3), &mut self.server).await; + match joined { + Ok(result) => result??, + Err(_) => { + self.server.abort(); + let _ = self.server.await; + bail!("HTTPS server required cancellation after graceful deadline"); + } + } + maintenance.context("maintenance deadline")??; + ensure!( + self.state.lock().unwrap().failures.is_empty(), + "unexpected fixture endpoint or request" + ); + ensure!( + std::fs::read(&self.auth_path)? == self.auth_bytes, + "synthetic auth store changed" + ); + Ok(()) + } + + async fn exchange(&mut self, body: Value, parts: Value) -> Result { + let before = { + let mut script = self.state.lock().unwrap(); + ensure!(script.replies.is_empty(), "unconsumed fixture reply"); + script.replies.push_back(parts); + script.seen.len() + }; + let body: MessagesRequest = serde_json::from_value(body)?; + let response = + tokio::time::timeout(STEP_LIMIT, self.provider.handle_messages(body, context())) + .await + .context("provider response deadline")?; + let status = response.status(); + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_owned(); + let bytes = tokio::time::timeout(STEP_LIMIT, to_bytes(response.into_body(), BODY_LIMIT)) + .await + .context("downstream body deadline")?? + .to_vec(); + let mut script = self.state.lock().unwrap(); + ensure!( + script.seen.len() <= before + 1, + "unexpected generation retry" + ); + let wire = script.seen.get(before).cloned(); + if wire.is_none() { + script.replies.pop_front(); + } + ensure!(script.replies.is_empty(), "reply ownership"); + let stream = content_type.starts_with("text/event-stream"); + Ok(Exchange { + status, + stream, + content_type, + bytes, + wire, + }) + } + + async fn project( + &mut self, + stream: bool, + tools: Value, + choice: Option, + ) -> Result { + let mut body = request(stream); + body["tools"] = tools; + if let Some(choice) = choice { + body["tool_choice"] = choice; + } + let exchange = self + .exchange(body, json!([{"text":"synthetic complete"}])) + .await?; + exchange.success(stream)?; + Ok(exchange) + } +} + +fn context() -> RequestContext { + RequestContext { + req_id: "synthetic-schema-request".into(), + session_id: None, + session_seq: None, + provider: "antigravity".into(), + traffic: None, + monitor: None, + } +} +fn request(stream: bool) -> Value { + json!({"model":MODEL,"max_tokens":137,"stream":stream, + "messages":[{"role":"user","content":"Synthetic schema request."}]}) +} +fn tool(name: &str, schema: Value) -> Value { + json!({"name":name,"description":"Synthetic function.","input_schema":schema}) +} +fn root(node: Value) -> Value { + json!({"type":"object","properties":{"value":node},"required":["value"],"additionalProperties":false}) +} +fn selected_root(node: Value) -> Value { + json!({"type":"OBJECT","properties":{"value":node},"required":["value"]}) +} + +// Preserved original values use serde_json's exact integer-aware equality. +// In particular, never round an unsafe integer through f64 in these assertions. +fn exact(actual: &Value, expected: &Value, label: &str) -> Result<()> { + ensure!( + actual == expected, + "{label}: complete structural value differs" + ); + Ok(()) +} + +// Only the selected projection's NumberValue domains permit integer/float +// spelling differences. Unsafe integer variants are not in that selected domain. +fn number_value_equivalent(actual: &Value, expected: &Value) -> bool { + fn number(value: &serde_json::Number) -> Option { + const SAFE: i64 = 9_007_199_254_740_991; + if let Some(value) = value.as_i64() { + return if (-SAFE..=SAFE).contains(&value) { + Some(value as f64) + } else { + None + }; + } + if let Some(value) = value.as_u64() { + return if value <= SAFE as u64 { + Some(value as f64) + } else { + None + }; + } + value.as_f64().filter(|value| value.is_finite()) + } + match (actual, expected) { + (Value::Number(a), Value::Number(b)) => { + matches!((number(a), number(b)), (Some(a), Some(b)) if a == b) + } + (Value::Array(a), Value::Array(b)) => { + a.len() == b.len() && a.iter().zip(b).all(|(a, b)| number_value_equivalent(a, b)) + } + (Value::Object(a), Value::Object(b)) => { + a.len() == b.len() + && a.iter() + .all(|(key, a)| b.get(key).is_some_and(|b| number_value_equivalent(a, b))) + } + _ => actual == expected, + } +} +fn selected_equivalent(actual: &Value, expected: &Value) -> bool { + let (Some(actual), Some(expected)) = (actual.as_object(), expected.as_object()) else { + return false; + }; + actual.len() == expected.len() + && actual.iter().all(|(key, actual)| { + expected + .get(key) + .is_some_and(|expected| match key.as_str() { + "default" | "minimum" | "maximum" => number_value_equivalent(actual, expected), + "items" => selected_equivalent(actual, expected), + "properties" => { + let (Some(actual), Some(expected)) = + (actual.as_object(), expected.as_object()) + else { + return false; + }; + actual.len() == expected.len() + && actual.iter().all(|(name, actual)| { + expected + .get(name) + .is_some_and(|expected| selected_equivalent(actual, expected)) + }) + } + // Includes enum/required and quoted int64 limits: no numeric coercion. + _ => actual == expected, + }) + }) +} +fn selected_exact(actual: &Value, expected: &Value, label: &str) -> Result<()> { + ensure!( + selected_equivalent(actual, expected), + "{label}: selected structural value differs" + ); + Ok(()) +} +fn comparison_oracle_control() -> Result<()> { + for (left, right) in [ + ( + json!(9_007_199_254_740_992_u64), + json!(9_007_199_254_740_993_u64), + ), + (json!(u64::MAX), json!(u64::MAX - 1)), + (json!(i64::MIN), json!(i64::MIN + 1)), + ( + json!(9_007_199_254_740_992_u64), + json!(9_007_199_254_740_992.0), + ), + ] { + let left = selected_root(json!({"type":"NUMBER","maximum":left})); + let right = selected_root(json!({"type":"NUMBER","maximum":right})); + ensure!( + exact(&left, &right, "oracle unequal integers").is_err(), + "oracle hid integer corruption" + ); + ensure!( + exact(&right, &left, "oracle unequal integers reversed").is_err(), + "oracle comparison is asymmetric" + ); + exact(&left, &left, "oracle unchanged value")?; + } + selected_exact( + &selected_root(json!({"type":"NUMBER","minimum":1.0,"default":{"nested":[0.0,-0.0,1e20]}})), + &selected_root(json!({"type":"NUMBER","minimum":1,"default":{"nested":[0,0,1e20]}})), + "selected NumberValue spelling equivalence", + )?; + ensure!( + !selected_equivalent( + &selected_root(json!({"type":"STRING","minLength":1})), + &selected_root(json!({"type":"STRING","minLength":"1"})), + ), + "selected oracle coerced quoted int64 domain" + ); + ensure!( + !selected_equivalent( + &selected_root(json!({"type":"NUMBER","enum":[1]})), + &selected_root(json!({"type":"NUMBER","enum":[1.0]})), + ), + "NumberValue semantics escaped into enum domain" + ); + ensure!( + !number_value_equivalent(&json!(u64::MAX), &json!(u64::MAX - 1)), + "unsafe integers entered selected NumberValue comparison" + ); + Ok(()) +} +impl Exchange { + fn wire(&self) -> Result<&Value> { + self.wire + .as_ref() + .context("expected one production HTTPS generation") + } + fn parameters(&self, index: usize) -> Result<&Value> { + self.wire()? + .pointer(&format!( + "/request/tools/{index}/functionDeclarations/0/parameters" + )) + .context("present function parameters") + } + fn success(&self, stream: bool) -> Result<()> { + ensure!( + self.status == StatusCode::OK, + "expected successful downstream HTTP status" + ); + ensure!(self.stream == stream, "downstream mode changed"); + let blocks = self.blocks()?; + ensure!( + blocks.as_array().is_some_and(|b| !b.is_empty()), + "missing output blocks" + ); + Ok(()) + } + fn blocks(&self) -> Result { + if !self.stream { + return Ok(serde_json::from_slice::(&self.bytes)?["content"].clone()); + } + let mut blocks: Vec = Vec::new(); + let mut stopped = false; + for frame in std::str::from_utf8(&self.bytes)? + .split("\n\n") + .filter(|s| !s.is_empty()) + { + let data = frame + .lines() + .find_map(|line| line.strip_prefix("data: ")) + .context("SSE data")?; + let event: Value = serde_json::from_str(data)?; + match event["type"].as_str() { + Some("content_block_start") => { + ensure!( + event["index"].as_u64() == Some(blocks.len() as u64), + "SSE block order" + ); + blocks.push(event["content_block"].clone()); + } + Some("content_block_delta") => { + let index = event["index"].as_u64().context("SSE index")? as usize; + let block = blocks.get_mut(index).context("SSE delta block")?; + let delta = &event["delta"]; + match delta["type"].as_str() { + Some("thinking_delta") => append(block, "thinking", &delta["thinking"])?, + Some("signature_delta") => append(block, "signature", &delta["signature"])?, + Some("text_delta") => append(block, "text", &delta["text"])?, + Some("input_json_delta") => { + block["input"] = serde_json::from_str( + delta["partial_json"].as_str().context("SSE input JSON")?, + )?; + } + _ => bail!("unexpected SSE delta"), + } + } + Some("message_start" | "content_block_stop" | "message_delta") => {} + Some("message_stop") => stopped = true, + _ => bail!("unexpected downstream SSE event or error"), + } + } + ensure!(stopped, "missing SSE terminal event"); + Ok(json!(blocks)) + } + fn rejected_call(&self, stream: bool, expected_msg: &str) -> Result<()> { + ensure!( + self.wire.is_some(), + "call validation must occur after actual HTTPS generation" + ); + if stream { + ensure!( + self.status == StatusCode::OK && self.stream, + "streaming validation response mode" + ); + let text = std::str::from_utf8(&self.bytes)?; + ensure!( + text.contains("event: error\n") && text.contains(expected_msg), + "missing streaming structural validation error" + ); + ensure!( + !text.contains("event: message_stop\n"), + "invalid generated call was emitted" + ); + } else { + ensure!( + self.status == StatusCode::BAD_GATEWAY, + "invalid generated call must fail" + ); + let error: Value = serde_json::from_slice(&self.bytes)?; + ensure!( + error["error"]["message"] + .as_str() + .is_some_and(|s| s.contains(expected_msg)), + "wrong structural validation error" + ); + } + Ok(()) + } +} +fn append(block: &mut Value, key: &str, value: &Value) -> Result<()> { + let suffix = value.as_str().context("SSE string delta")?; + let current = block[key].as_str().context("SSE initial string")?; + block[key] = json!(format!("{current}{suffix}")); + Ok(()) +} + +async fn exercise(case: &str, f: &mut Fixture) -> Result<()> { + match case { + "tls" => tls_control(f).await, + "untrusted" => untrusted_control(f).await, + "validation" => validation_control(f).await, + "continuation" => continuation_control(f).await, + "limits" => quoted_limits(f).await, + "defaults" => defaults(f).await, + "zeros" => zero_constraints(f).await, + "closed-empty" => closed_empty(f).await, + "auto" => auto_choice(f).await, + "choice-controls" => choice_controls(f).await, + "fallback" => fallback(f).await, + "eligibility" => eligibility(f).await, + "numeric-budgets" => numeric_budgets(f).await, + "admission" => admission(f).await, + "project-admission" => project_admission(f).await, + "first-agent" => first_agent_metadata(f).await, + "two-user-text" => two_user_text_metadata(f).await, + "three-user-text" => user_text_sequence(f, false).await, + "last-recorded-execution" => user_text_sequence(f, true).await, + "read-tool-metadata" => read_tool_metadata(f).await, + "invalid-native-checkpoint" => invalid_native_checkpoint(f).await, + "observed-count-purity" => observed_count_purity(f).await, + "source-all-tier-metadata" => source_all_tier_metadata(f).await, + _ => bail!("unknown fixed fixture case"), + } +} + +async fn first_agent_metadata(f: &mut Fixture) -> Result<()> { + let mut previous_conversation = None; + for stream in [false, true] { + let mut body = request(stream); + body["model"] = json!("antigravity/gemini-4-argon-low"); + body["max_tokens"] = json!(65536); + body["system"] = json!([ + {"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.270.dd6; cc_entrypoint=sdk-cli;"}, + {"type":"text","text":"You are a Claude agent, built on Anthropic's Claude Agent SDK.","cache_control":{"type":"ephemeral"}}, + {"type":"text","text":"Synthetic metadata system instruction.","cache_control":{"type":"ephemeral"}} + ]); + body["messages"].as_array_mut().context("messages")?.insert( + 0, + json!({"role":"system","content":"Synthetic environment instruction."}), + ); + let exchange = f + .exchange(body, json!([{"text":"Synthetic metadata completion."}])) + .await?; + exchange.success(stream)?; + let wire = exchange.wire()?; + ensure!( + wire["model"] == "gemini-4-argon-low" + && wire["requestType"] == "agent" + && wire["userAgent"] == "antigravity", + "observed agent envelope" + ); + let request = &wire["request"]; + exact( + &request["systemInstruction"], + &json!({"role":"user","parts":[{"text":"Synthetic metadata system instruction.\n\nSynthetic environment instruction."}]}), + "agy single-part systemInstruction with Claude Code headers stripped", + )?; + exact( + &request["generationConfig"], + &json!({"maxOutputTokens":65536,"thinkingConfig":{"includeThoughts":true,"thinkingLevel":"LOW"}}), + "observed generation controls", + )?; + ensure!( + request["sessionId"] == "-3750763034362895579", + "observed signed empty-input FNV session" + ); + let labels = request["labels"] + .as_object() + .context("observed lineage labels")?; + ensure!(labels.len() == 9, "exact observed label set"); + for (key, expected) in [ + ("model_enum", "MODEL_PLACEHOLDER_M419"), + ("used_claude", "false"), + ("used_claude_conservative", "false"), + ("used_non_gemini_model", "false"), + ("last_step_index", "0"), + ] { + ensure!( + labels.get(key).and_then(Value::as_str) == Some(expected), + "observed string-valued label" + ); + } + let conversation = labels["cascade_id"].as_str().context("cascade ID")?; + let trajectory = labels["trajectory_id"].as_str().context("trajectory ID")?; + for value in [conversation, trajectory] { + let id = uuid::Uuid::parse_str(value)?; + ensure!( + id.get_version_num() == 4 && id.to_string() == value, + "canonical UUIDv4 metadata" + ); + } + ensure!( + labels["root_cascade_id"] == conversation && conversation != trajectory, + "root and trajectory relationships" + ); + ensure!( + labels["request_id"] == format!("{trajectory}-0"), + "first request uses zero-based generation counter" + ); + let outer = wire["requestId"].as_str().context("outer request ID")?; + let components: Vec<_> = outer.split('/').collect(); + ensure!( + components.len() == 5 + && components[0] == "agent" + && components[1] == conversation + && components[3] == trajectory + && components[4] == "1", + "outer request component relationships" + ); + let timestamp = components[2].parse::()?; + ensure!( + (946_684_800_000..4_102_444_800_000).contains(×tamp) + && timestamp.to_string() == components[2], + "canonical plausible Unix milliseconds" + ); + ensure!( + previous_conversation.as_deref() != Some(conversation), + "independent fresh conversations" + ); + previous_conversation = Some(conversation.to_owned()); + let blocks = exchange.blocks()?; + let signature = blocks[0]["signature"] + .as_str() + .context("versioned native carrier")?; + let encoded = signature + .strip_prefix("ccp:antigravity:v1:") + .context("explicit V1 carrier")?; + let raw = URL_SAFE_NO_PAD.decode(encoded)?; + ensure!(raw.len() <= BODY_LIMIT, "synthetic V1 carrier bound"); + let saved: Value = serde_json::from_slice(&raw)?; + ensure!( + saved["version"] == 1 + && saved["profile_id"] == PROFILE + && saved["project_id"] == PROJECT, + "V1 scope and version" + ); + ensure!( + saved["native_model"] == "gemini-4-argon-low" + && saved["backend_session_id"] == "-3750763034362895579", + "V1 model and separate backend session" + ); + exact( + &saved["lineage"], + &json!({"conversation_id":conversation,"trajectory_id":trajectory}), + "V1 logical UUIDs", + )?; + ensure!( + saved.get("session_id").is_none(), + "V1 uses backend_session_id" + ); + ensure!( + wire.get("enabledCreditTypes").is_none() && request.get("enabledCreditTypes").is_none(), + "no invented billing consent" + ); + exact( + &saved["contents"], + &json!([{"role":"model","parts":[{"text":"Synthetic metadata completion."}]}]), + "unchanged native response contents", + )?; + } + Ok(()) +} + +// Official CLI one-process/two-user trace, served LOCAL_SYNTHETIC_SSE (no +// Google-origin generation). This checks its exact known ID/counter/content +// relations, not hidden Go state. The later three-user/source fixture below +// separately checks the full last_execution_id predecessor relationship. +async fn two_user_text_metadata(f: &mut Fixture) -> Result<()> { + for stream in [false, true] { + let mut body = request(stream); + body["model"] = json!("antigravity/gemini-4-argon-low"); + body["max_tokens"] = json!(65536); + body["system"] = json!("Synthetic two-user system instruction."); + body["messages"][0]["content"] = json!("Synthetic first user."); + let first = f + .exchange( + body.clone(), + json!([{"text":"Synthetic first completion."}]), + ) + .await?; + first.success(stream)?; + let first_blocks = first.blocks()?; + body["messages"] + .as_array_mut() + .context("messages")? + .extend([ + json!({"role":"assistant","content":first_blocks}), + json!({"role":"user","content":"Synthetic second user."}), + ]); + let second = f + .exchange( + body.clone(), + json!([{"text":"Synthetic second completion."}]), + ) + .await?; + if second.status != StatusCode::OK { + let error: Value = serde_json::from_slice(&second.bytes)?; + bail!( + "observed second-user text continuation must succeed; status={} error={}", + second.status, + error["error"]["message"] + ); + } + second.success(stream)?; + let first_wire = first.wire()?; + let second_wire = second.wire()?; + let conversation = first_wire["request"]["labels"]["cascade_id"] + .as_str() + .context("conversation")?; + let trajectory = first_wire["request"]["labels"]["trajectory_id"] + .as_str() + .context("trajectory")?; + ensure!( + conversation != trajectory, + "distinct conversation/trajectory" + ); + for id in [conversation, trajectory] { + let uuid = uuid::Uuid::parse_str(id)?; + ensure!( + uuid.get_version_num() == 4 + && uuid.get_variant() == uuid::Variant::RFC4122 + && uuid.to_string() == id, + "canonical independent UUIDv4" + ); + } + for (wire, generation, last_step, outer_step) in + [(first_wire, "0", "0", "1"), (second_wire, "1", "2", "3")] + { + ensure!( + wire["model"] == "gemini-4-argon-low" + && wire["project"] == PROJECT + && wire["requestType"] == "agent" + && wire["userAgent"] == "antigravity", + "same actual bound model/project/type" + ); + ensure!( + wire["request"]["sessionId"] == "-3750763034362895579", + "same signed empty FNV backend session" + ); + let labels = &wire["request"]["labels"]; + for (key, value) in [ + ("model_enum", "MODEL_PLACEHOLDER_M419"), + ("used_claude", "false"), + ("used_claude_conservative", "false"), + ("used_non_gemini_model", "false"), + ("cascade_id", conversation), + ("root_cascade_id", conversation), + ("trajectory_id", trajectory), + ("last_step_index", last_step), + ] { + ensure!( + labels[key].as_str() == Some(value), + "observed stable string-valued label {key}" + ); + } + ensure!( + labels["request_id"] == format!("{trajectory}-{generation}"), + "generation suffix must not reset at second user" + ); + let outer = wire["requestId"].as_str().context("outer ID")?; + let components: Vec<_> = outer.split('/').collect(); + ensure!( + components.len() == 5 + && components[0] == "agent" + && components[1] == conversation + && components[3] == trajectory + && components[4] == outer_step, + "observed outer step and stable identities" + ); + let millis = components[2].parse::()?; + ensure!( + (946_684_800_000..4_102_444_800_000).contains(&millis) + && millis.to_string() == components[2], + "canonical Unix milliseconds" + ); + exact( + &wire["request"]["generationConfig"], + &json!({"maxOutputTokens":65536,"thinkingConfig":{"includeThoughts":true,"thinkingLevel":"LOW"}}), + "unchanged generation config", + )?; + exact( + &wire["request"]["systemInstruction"], + &json!({"role":"user","parts":[{"text":"Synthetic two-user system instruction."}]}), + "unchanged system role", + )?; + } + exact( + &first_wire["request"]["contents"], + &json!([{"role":"user","parts":[{"text":"Synthetic first user."}]}]), + "first native user contents", + )?; + exact( + &second_wire["request"]["contents"], + &json!([ + {"role":"user","parts":[{"text":"Synthetic first user."}]}, + {"role":"model","parts":[{"text":"Synthetic first completion."}]}, + {"role":"user","parts":[{"text":"Synthetic second user."}]} + ]), + "exact observed user/model/user history; no synthetic role rewriting", + )?; + let second_blocks = second.blocks()?; + for (blocks, index, text) in [ + (&first_blocks, 0, "Synthetic first completion."), + (&second_blocks, 1, "Synthetic second completion."), + ] { + let encoded = blocks[0]["signature"] + .as_str() + .context("V1 signature")? + .strip_prefix("ccp:antigravity:v1:") + .context("V1 prefix")?; + let raw = URL_SAFE_NO_PAD.decode(encoded)?; + let saved: Value = serde_json::from_slice(&raw)?; + ensure!( + saved["version"] == 1 + && saved["profile_id"] == PROFILE + && saved["project_id"] == PROJECT + && saved["native_model"] == "gemini-4-argon-low", + "V1 actual scope" + ); + exact( + &saved["lineage"], + &json!({"conversation_id":conversation,"trajectory_id":trajectory}), + "same carried logical IDs", + )?; + ensure!( + saved["native_checkpoint_index"] == index, + "proxy-owned completed checkpoint ordinal, not hidden Go state" + ); + ensure!( + saved["backend_session_id"] == "-3750763034362895579", + "separate fixed backend session" + ); + exact( + &saved["contents"], + &json!([{"role":"model","parts":[{"text":text}]}]), + "original native checkpoint contents", + )?; + } + // Normal text recurrence is separately observed. Unsupported media + // must still reject before identity allocation or native dispatch. + body["messages"].as_array_mut().context("messages")?.extend([ + json!({"role":"assistant","content":second_blocks}), + json!({"role":"user","content":[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"synthetic"}}]}), + ]); + let rejected = f + .exchange(body, json!([{"text":"must not dispatch"}])) + .await?; + ensure!( + rejected.status == StatusCode::BAD_REQUEST && rejected.wire.is_none(), + "unsupported media must reject without reminting or dispatch" + ); + } + Ok(()) +} + +fn uuid_v4(value: &str) -> Result<()> { + let id = uuid::Uuid::parse_str(value)?; + ensure!( + id.get_version_num() == 4 + && id.get_variant() == uuid::Variant::RFC4122 + && id.to_string() == value, + "canonical lowercase RFC4122 UUIDv4" + ); + Ok(()) +} + +async fn observed_policy(f: &mut Fixture) -> Result<(PathBuf, Vec)> { + let path = f + .auth_path + .parent() + .and_then(|p| p.parent()) + .context("synthetic config parent")? + .join("config.json"); + let original = std::fs::read(&path)?; + tokio::time::timeout(STEP_LIMIT, f.provider.finish_maintenance()).await??; + f.provider = AntigravityProvider::new(); + Ok((path, original)) +} + +fn carrier_saved(blocks: &Value) -> Result { + let encoded = blocks[0]["signature"] + .as_str() + .context("native carrier")? + .strip_prefix("ccp:antigravity:v1:") + .context("V1 carrier prefix")?; + let raw = URL_SAFE_NO_PAD.decode(encoded)?; + ensure!(raw.len() <= BODY_LIMIT, "bounded synthetic carrier"); + Ok(serde_json::from_slice(&raw)?) +} + +fn observed_metadata( + wire: &Value, + conversation: &str, + trajectory: &str, + generation: u64, +) -> Result<()> { + let (suffix, last_step, outer_step) = match generation { + 0 => ("0", "0", "1"), + 1 => ("1", "2", "3"), + 2 => ("2", "4", "5"), + _ => bail!("fixture only asserts independently observed progression"), + }; + ensure!( + wire["model"] == "gemini-4-argon-low" + && wire["project"] == PROJECT + && wire["requestType"] == "agent" + && wire["userAgent"] == "antigravity", + "bound native agent envelope" + ); + ensure!( + wire["request"]["sessionId"] == "-3750763034362895579", + "signed empty FNV session" + ); + let labels = &wire["request"]["labels"]; + for (key, value) in [ + ("model_enum", "MODEL_PLACEHOLDER_M419"), + ("used_claude", "false"), + ("used_claude_conservative", "false"), + ("used_non_gemini_model", "false"), + ("cascade_id", conversation), + ("root_cascade_id", conversation), + ("trajectory_id", trajectory), + ("last_step_index", last_step), + ] { + ensure!( + labels[key].as_str() == Some(value), + "observed string-valued label {key}" + ); + } + ensure!( + labels["request_id"] == format!("{trajectory}-{suffix}"), + "observed generation suffix" + ); + let components: Vec<_> = wire["requestId"] + .as_str() + .context("outer request ID")? + .split('/') + .collect(); + ensure!( + components.len() == 5 + && components[0] == "agent" + && components[1] == conversation + && components[3] == trajectory + && components[4] == outer_step, + "observed outer request relationships" + ); + let millis = components[2].parse::()?; + ensure!( + (946_684_800_000..4_102_444_800_000).contains(&millis) + && millis.to_string() == components[2], + "canonical Unix milliseconds" + ); + exact( + &wire["request"]["generationConfig"], + &json!({"maxOutputTokens":65536,"thinkingConfig":{"includeThoughts":true,"thinkingLevel":"LOW"}}), + "observed LOW generation config", + )?; + Ok(()) +} + +// Three successful ordinary-user executions and the public latest-recorded +// executor-metadata accessor establish these relations. IDs here are our +// bounded proxy-owned execution representation, not copied private native IDs +// or a claim about native allocation/error/cancellation timing. +async fn user_text_sequence(f: &mut Fixture, check_execution: bool) -> Result<()> { + let (path, original) = observed_policy(f).await?; + let result = async { + for stream in [false, true] { + let mut body = request(stream); + body["model"] = json!("antigravity/gemini-4-argon-low"); + body["max_tokens"] = json!(65536); + body["messages"][0]["content"] = json!("Synthetic user 0."); + let mut expected_contents = + vec![json!({"role":"user","parts":[{"text":"Synthetic user 0."}]})]; + let mut conversation = None; + let mut trajectory = None; + let mut saved_turns: Vec = Vec::new(); + for generation in 0..3_u64 { + let text = format!("Synthetic completion {generation}."); + let exchange = f.exchange(body.clone(), json!([{"text":text}])).await?; + if exchange.status != StatusCode::OK { + let error: Value = serde_json::from_slice(&exchange.bytes)?; + bail!( + "observed user generation {generation} must succeed; status={} error={}", + exchange.status, + error["error"]["message"] + ); + } + exchange.success(stream)?; + let wire = exchange.wire()?; + if generation == 0 { + conversation = Some( + wire["request"]["labels"]["cascade_id"] + .as_str() + .context("conversation")? + .to_owned(), + ); + trajectory = Some( + wire["request"]["labels"]["trajectory_id"] + .as_str() + .context("trajectory")? + .to_owned(), + ); + uuid_v4(conversation.as_deref().unwrap())?; + uuid_v4(trajectory.as_deref().unwrap())?; + ensure!(conversation != trajectory, "independent lineage UUIDs"); + } + observed_metadata( + wire, + conversation.as_deref().unwrap(), + trajectory.as_deref().unwrap(), + generation, + )?; + exact( + &wire["request"]["contents"], + &json!(expected_contents), + "exact alternating user/model history", + )?; + let blocks = exchange.blocks()?; + let saved = carrier_saved(&blocks)?; + ensure!( + saved["native_checkpoint_index"] == generation, + "proxy native checkpoint ordinal" + ); + exact( + &saved["contents"], + &json!([{"role":"model","parts":[{"text":text}]}]), + "original native contents", + )?; + if check_execution { + let labels = wire["request"]["labels"].as_object().context("labels")?; + if generation == 0 { + ensure!( + labels.len() == 9 && !labels.contains_key("last_execution_id"), + "first request has no recorded execution" + ); + } else { + // Reach the actual wire failure before inspecting new + // checkpoint fields, so pre-implementation RED is causal. + let last = labels + .get("last_execution_id") + .and_then(Value::as_str) + .context("observed last recorded user execution label")?; + uuid_v4(last)?; + ensure!(labels.len() == 10, "exact observed later-user label set"); + let previous = + saved_turns.last().context("previous completed execution")?; + ensure!( + previous["user_execution"]["active_id"] == last + && previous["checkpoint_stop_reason"] == "end_turn", + "label reuses previous recorded completion, never a renderer-time UUID" + ); + ensure!( + saved["user_execution"]["active_id"] != last, + "new ordinary user execution is distinct" + ); + } + } + saved_turns.push(saved); + if generation < 2 { + let next = format!("Synthetic user {}.", generation + 1); + body["messages"] + .as_array_mut() + .context("messages")? + .extend([ + json!({"role":"assistant","content":blocks}), + json!({"role":"user","content":next}), + ]); + expected_contents.push(json!({"role":"model","parts":[{"text":text}]})); + expected_contents.push(json!({"role":"user","parts":[{"text":next}]})); + } + } + if check_execution { + let mut seen = std::collections::HashSet::new(); + let mut recorded: Option = None; + for saved in saved_turns { + let active = saved["user_execution"]["active_id"] + .as_str() + .context("proxy-owned user execution ID")?; + uuid_v4(active)?; + ensure!( + Some(active) != conversation.as_deref() + && Some(active) != trajectory.as_deref() + && seen.insert(active.to_owned()), + "distinct execution identities" + ); + ensure!( + saved["checkpoint_stop_reason"] == "end_turn" + && saved["user_execution"]["prior_recorded_id"].as_str() == recorded.as_deref(), + "checkpoint binds the predecessor; validated completion records active for the NEXT user" + ); + recorded = Some(active.to_owned()); + } + } + } + Ok::<(), anyhow::Error>(()) + } + .await; + std::fs::write(path, original)?; + result +} + +// Genuine one-user READ loop: local scripted SSE, zero origin generations. +// Both requests omit last_execution_id, despite model generation 0 -> 1. +async fn read_tool_metadata(f: &mut Fixture) -> Result<()> { + const SIGNATURE: &str = "c3ludGhldGljLXNpZ25hdHVyZQ=="; + const NATIVE_ID: &str = "synthetic-read-call-1"; + let (path, original) = observed_policy(f).await?; + let result = async { + for stream in [false, true] { + let mut body = request(stream); + body["model"] = json!("antigravity/gemini-4-argon-low"); + body["max_tokens"] = json!(65536); + body["tools"] = json!([tool("read_file", json!({"type":"object","properties":{"Path":{"type":"string"}},"required":["Path"],"additionalProperties":false}))]); + let native_part = json!({"functionCall":{"name":"read_file","args":{"Path":"/synthetic/owned-fixture.txt"},"id":NATIVE_ID},"thoughtSignature":SIGNATURE}); + let first = f.exchange(body.clone(), json!([native_part])).await?; + first.success(stream)?; + let blocks = first.blocks()?; + let local = blocks[1]["id"].as_str().context("local tool ID")?; + ensure!(blocks[1]["type"] == "tool_use" && local != NATIVE_ID, "preserve original native/local tool mapping"); + exact(&blocks[1]["input"], &json!({"Path":"/synthetic/owned-fixture.txt"}), "original caller arguments")?; + body["messages"].as_array_mut().context("messages")?.extend([ + json!({"role":"assistant","content":blocks}), + json!({"role":"user","content":[{"type":"tool_result","tool_use_id":local,"content":"Synthetic read fixture marker."}]}) + ]); + let second = f.exchange(body, json!([{"text":"Synthetic read complete."}])).await?; + if second.status != StatusCode::OK { + let error: Value = serde_json::from_slice(&second.bytes)?; + bail!("observed read-tool continuation must succeed; status={} error={}", second.status, error["error"]["message"]); + } + second.success(stream)?; + let first_wire = first.wire()?; + let second_wire = second.wire()?; + let conversation = first_wire["request"]["labels"]["cascade_id"].as_str().context("conversation")?; + let trajectory = first_wire["request"]["labels"]["trajectory_id"].as_str().context("trajectory")?; + for (wire, generation) in [(first_wire, 0), (second_wire, 1)] { + observed_metadata(wire, conversation, trajectory, generation)?; + let labels = wire["request"]["labels"].as_object().context("labels")?; + ensure!(labels.len() == 9 && !labels.contains_key("last_execution_id"), "no recorded execution within first user's tool loop"); + } + let mut expected = first_wire.clone(); + let components: Vec<_> = second_wire["requestId"].as_str().context("second request ID")?.split('/').collect(); + expected["requestId"] = json!(format!("agent/{conversation}/{}/{trajectory}/3", components[2])); + expected["request"]["labels"]["request_id"] = json!(format!("{trajectory}-1")); + expected["request"]["labels"]["last_step_index"] = json!("2"); + expected["request"]["contents"] = json!([ + {"role":"user","parts":[{"text":"Synthetic schema request."}]}, + {"role":"model","parts":[native_part]}, + {"role":"model","parts":[{"functionResponse":{"name":"read_file","response":{"output":"Synthetic read fixture marker."},"id":NATIVE_ID}}]} + ]); + exact(second_wire, &expected, "complete observed read-loop wire, including separate MODEL functionResponse and untouched signature/native ID")?; + let first_saved = carrier_saved(&blocks)?; + exact(&first_saved["contents"], &json!([{"role":"model","parts":[native_part]}]), "raw call/signature checkpoint")?; + let second_saved = carrier_saved(&second.blocks()?)?; + ensure!(first_saved["checkpoint_stop_reason"] == "tool_use" && first_saved["user_execution"]["prior_recorded_id"].is_null(), "tool call is not a completed user execution"); + let active = first_saved["user_execution"]["active_id"].as_str().context("active execution")?; + uuid_v4(active)?; + ensure!(second_saved["user_execution"]["active_id"] == active && second_saved["user_execution"]["prior_recorded_id"].is_null() && second_saved["checkpoint_stop_reason"] == "end_turn", "same execution survives tool loop and records only after validated final completion"); + } + Ok::<(), anyhow::Error>(()) + }.await; + std::fs::write(path, original)?; + result +} + +async fn invalid_native_checkpoint(f: &mut Fixture) -> Result<()> { + let (path, original) = observed_policy(f).await?; + let result = async { + for stream in [false, true] { + let mut body = request(stream); + body["model"] = json!("antigravity/gemini-4-argon-low"); + let exchange = f + .exchange(body, json!({"synthetic":"invalid native parts type"})) + .await?; + ensure!( + exchange.wire.is_some(), + "actual native request occurred exactly once" + ); + let output = std::str::from_utf8(&exchange.bytes)?; + ensure!( + !output.contains("ccp:antigravity:v1:"), + "invalid upstream turn must never fabricate a successful checkpoint" + ); + if stream { + ensure!( + exchange.status == StatusCode::OK + && output.contains("event: error\n") + && !output.contains("event: message_stop\n"), + "streaming invalid turn is terminal error only" + ); + } else { + ensure!( + exchange.status == StatusCode::BAD_GATEWAY, + "invalid turn must fail" + ); + let error: Value = serde_json::from_slice(&exchange.bytes)?; + ensure!( + error["error"]["message"] + .as_str() + .is_some_and(|s| s.contains("invalid native parts")), + "bounded native validation error" + ); + } + } + Ok::<(), anyhow::Error>(()) + } + .await; + std::fs::write(path, original)?; + result +} + +async fn observed_count_purity(f: &mut Fixture) -> Result<()> { + let (path, original) = observed_policy(f).await?; + let result = async { + let mut body = request(false); + body["model"] = json!("antigravity/gemini-4-argon-low"); + let mut estimates = Vec::new(); + for kind in ["invalid", "unreadable", "missing"] { + if f.auth_path.exists() { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&f.auth_path, std::fs::Permissions::from_mode(0o600))?; + } + } + match kind { + "invalid" => std::fs::write(&f.auth_path, b"public synthetic invalid auth JSON")?, + "unreadable" => { + std::fs::write(&f.auth_path, &f.auth_bytes)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + &f.auth_path, + std::fs::Permissions::from_mode(0o000), + )?; + } + } + "missing" => std::fs::remove_file(&f.auth_path)?, + _ => unreachable!(), + } + let response = tokio::time::timeout( + STEP_LIMIT, + f.provider + .handle_count_tokens(serde_json::from_value(body.clone())?, context()), + ) + .await?; + let status = response.status(); + let raw = to_bytes(response.into_body(), BODY_LIMIT).await?; + let counted: Value = serde_json::from_slice(&raw)?; + ensure!( + status == StatusCode::OK, + "selected agy count must ignore {kind} synthetic auth; status={status} error={}", + counted["error"]["message"] + ); + ensure!( + counted["input_tokens"].as_u64().is_some_and(|n| n > 0), + "positive local estimate" + ); + estimates.push(counted["input_tokens"].clone()); + ensure!( + f.state.lock().unwrap().seen.is_empty(), + "count must not dispatch, discover or refresh" + ); + if kind == "missing" { + ensure!(!f.auth_path.exists(), "count must not create an auth store"); + } + } + ensure!( + estimates.iter().all(|n| *n == estimates[0]), + "estimate does not depend on credential contents/permissions/presence" + ); + Ok::<(), anyhow::Error>(()) + } + .await; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if f.auth_path.exists() { + std::fs::set_permissions(&f.auth_path, std::fs::Permissions::from_mode(0o600))?; + } + } + std::fs::write(&f.auth_path, &f.auth_bytes)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&f.auth_path, std::fs::Permissions::from_mode(0o600))?; + } + std::fs::write(path, original)?; + result +} + +// Synthetic coverage of existing selectors and compiled public enum facts. +// This is NOT genuine HIGH/MEDIUM runtime/entitlement evidence; the official +// runtime reference remains LOW-only. Each tier starts an independent history. +async fn source_all_tier_metadata(f: &mut Fixture) -> Result<()> { + let (path, original) = observed_policy(f).await?; + let result = async { + for stream in [false, true] { + for (model, enumeration, level) in [ + ("gemini-4-carbon-low", "MODEL_PLACEHOLDER_M438", "LOW"), + ("gemini-4-carbon-medium", "MODEL_PLACEHOLDER_M437", "MEDIUM"), + ("gemini-4-carbon-high", "MODEL_PLACEHOLDER_M427", "HIGH"), + ("gemini-4-argon-low", "MODEL_PLACEHOLDER_M419", "LOW"), + ("gemini-4-argon-medium", "MODEL_PLACEHOLDER_M418", "MEDIUM"), + ("gemini-4-argon-high", "MODEL_PLACEHOLDER_M417", "HIGH"), + ] { + let mut body = request(stream); + body["model"] = json!(format!("antigravity/{model}")); + let first = f.exchange(body.clone(), json!([{"text":"Synthetic source-tier completion."}])).await?; + first.success(stream)?; + let blocks = first.blocks()?; + let saved = carrier_saved(&blocks)?; + body["messages"].as_array_mut().context("messages")?.extend([json!({"role":"assistant","content":blocks}), json!({"role":"user","content":"Synthetic source-tier next user."})]); + let second = f.exchange(body, json!([{"text":"Synthetic source-tier second completion."}])).await?; + if second.status != StatusCode::OK { + let error: Value = serde_json::from_slice(&second.bytes)?; + bail!("supported selector {model} must share continuation logic; status={} error={}", second.status, error["error"]["message"]); + } + second.success(stream)?; + for (exchange, generation) in [(&first, "0"), (&second, "1")] { + let wire = exchange.wire()?; + let labels = &wire["request"]["labels"]; + ensure!(wire["model"] == model && saved["native_model"] == model && labels["model_enum"] == enumeration, "exact fixed-tier model and source enum mapping"); + ensure!(labels["cascade_id"] == first.wire()?["request"]["labels"]["cascade_id"] && labels["trajectory_id"] == first.wire()?["request"]["labels"]["trajectory_id"], "fixed tier history retains lineage"); + let trajectory = labels["trajectory_id"].as_str().context("trajectory")?; + ensure!(labels["request_id"] == format!("{trajectory}-{generation}"), "shared generation progression"); + ensure!(wire["request"]["sessionId"] == "-3750763034362895579", "fixed signed backend session"); + exact(&wire["request"]["generationConfig"], &json!({"maxOutputTokens":137,"thinkingConfig":{"includeThoughts":true,"thinkingLevel":level}}), "existing cap and thinking tiers unchanged")?; + } + ensure!(second.wire()?["request"]["labels"]["last_execution_id"] == saved["user_execution"]["active_id"], "same predecessor relationship independent of tier"); + let mut switched = request(stream); + switched["model"] = json!(if model == "gemini-4-argon-low" { "antigravity/gemini-4-argon-high" } else { "antigravity/gemini-4-argon-low" }); + switched["messages"].as_array_mut().context("messages")?.extend([json!({"role":"assistant","content":blocks}), json!({"role":"user","content":"Switch tier on a carried history."})]); + let switched_resp = f.exchange(switched, json!([{"text":"Switched tier completion."}])).await?; + switched_resp.success(stream)?; + let switched_wire = switched_resp.wire()?; + ensure!( + switched_wire["request"]["contents"][0]["parts"][0]["text"] + .as_str() + .is_some_and(|t| t.contains("")), + "cross-tier switch folds prior model history into transcript" + ); + } + } + Ok::<(), anyhow::Error>(()) + }.await; + std::fs::write(path, original)?; + result +} + +async fn tls_control(f: &mut Fixture) -> Result<()> { + comparison_oracle_control()?; + let response = reqwest::Client::builder() + .build()? + .get(format!("{}/control", f.origin)) + .timeout(STEP_LIMIT) + .send() + .await?; + ensure!( + response.status() == StatusCode::OK, + "plain verified HTTPS control" + ); + ensure!( + response.text().await? == "synthetic verified TLS", + "TLS control response" + ); + for stream in [false, true] { + for (model, level) in [ + ("antigravity/gemini-4-argon-low", "LOW"), + ("antigravity/gemini-4-argon-medium", "MEDIUM"), + (MODEL, "HIGH"), + ] { + let mut body = request(stream); + body["model"] = json!(model); + let exchange = f + .exchange(body, json!([{"text":"synthetic complete"}])) + .await?; + exchange.success(stream)?; + let wire = exchange.wire()?; + let session = wire["request"]["sessionId"] + .as_str() + .context("session ID")?; + ensure!(session == "-3750763034362895579", "baseline session format"); + ensure!( + wire["requestId"] + .as_str() + .is_some_and(|s| s.starts_with("agent/")), + "request identity format" + ); + exact( + wire, + &json!({ + "project":PROJECT,"model":model.strip_prefix("antigravity/").unwrap(), + "requestId":wire["requestId"],"userAgent":"antigravity","requestType":"agent", + "request":{"sessionId":session,"labels":wire["request"]["labels"], + "contents":[{"role":"user","parts":[{"text":"Synthetic schema request."}]}], + "generationConfig":{"maxOutputTokens":137,"thinkingConfig":{"includeThoughts":true,"thinkingLevel":level}}} + }), + "baseline complete wire", + )?; + } + } + Ok(()) +} +async fn untrusted_control(f: &mut Fixture) -> Result<()> { + let plain = reqwest::Client::builder() + .build()? + .get(format!("{}/control", f.origin)) + .timeout(STEP_LIMIT) + .send() + .await; + ensure!( + plain.is_err(), + "synthetic root must be necessary, never bypass TLS verification" + ); + for stream in [false, true] { + let exchange = f + .exchange(request(stream), json!([{"text":"unreachable"}])) + .await?; + ensure!( + exchange.status == StatusCode::BAD_GATEWAY && exchange.wire.is_none(), + "production client accepted untrusted TLS" + ); + ensure!( + !exchange.stream && exchange.content_type.starts_with("application/json"), + "TLS header error mode" + ); + } + Ok(()) +} +async fn validation_control(f: &mut Fixture) -> Result<()> { + for stream in [false, true] { + let mut body = request(stream); + body["tools"] = json!([tool( + "generic_checked", + root(json!({"type":"integer","minimum":2,"default":3})) + )]); + let valid = f.exchange(body.clone(), json!([{"functionCall":{"name":"generic_checked","args":{"value":2},"id":"synthetic-native-id"}}])).await?; + valid.success(stream)?; + ensure!( + valid + .blocks()? + .as_array() + .unwrap() + .iter() + .any(|b| b["type"] == "tool_use" && b["input"] == json!({"value":2})), + "valid tool input forwarded unchanged" + ); + // Downstream callers (Claude Code / MCP servers) validate schema constraints; + // missing defaults are never synthetically injected by the proxy. + let omitted = f + .exchange( + body.clone(), + json!([{"functionCall":{"name":"generic_checked","args":{}}}]), + ) + .await?; + omitted.success(stream)?; + ensure!( + omitted + .blocks()? + .as_array() + .unwrap() + .iter() + .any(|b| b["type"] == "tool_use" && b["input"] == json!({})), + "defaults must never be synthetically inserted" + ); + // Unknown tool names and non-object `args` still fail closed structurally. + f.exchange( + body.clone(), + json!([{"functionCall":{"name":"undeclared_tool","args":{}}}]), + ) + .await? + .rejected_call(stream, "upstream returned unknown tool")?; + f.exchange( + body, + json!([{"functionCall":{"name":"generic_checked","args":[]}}]), + ) + .await? + .rejected_call(stream, "invalid native call arguments")?; + } + Ok(()) +} + +async fn quoted_limits(f: &mut Fixture) -> Result<()> { + for stream in [false, true] { + let schema = root(json!({"type":"array","minItems":1,"maxItems":4, + "items":{"type":"string","minLength":2,"maxLength":9}})); + let exchange = f + .project(stream, json!([tool("generic_limits", schema)]), None) + .await?; + exact( + &exchange.parameters(0)?["properties"]["value"]["minItems"], + &json!("1"), + "minItems must be a quoted int64", + )?; + exact( + exchange.parameters(0)?, + &selected_root(json!({"type":"ARRAY","minItems":"1","maxItems":"4", + "items":{"type":"STRING","minLength":"2","maxLength":"9"}})), + "quoted int64 limits", + )?; + } + Ok(()) +} +async fn defaults(f: &mut Fixture) -> Result<()> { + let values = [ + json!(false), + json!(0), + json!(0.0), + json!(-0.0), + json!(""), + Value::Null, + json!({}), + json!([]), + json!({"items":{"allOf":[null,false,0,""]},"type":"ordinary data","default":{"required":[]}}), + ]; + for stream in [false, true] { + for value in &values { + let schema = root(json!({"type":"string","default":value})); + let exchange = f + .project(stream, json!([tool("generic_defaults", schema)]), None) + .await?; + ensure!( + exchange.parameters(0)?["properties"]["value"] + .get("default") + .is_some(), + "explicit default must retain presence" + ); + selected_exact( + exchange.parameters(0)?, + &selected_root(json!({"type":"STRING","default":value})), + "explicit default presence", + )?; + } + let exchange = f + .project( + stream, + json!([tool("generic_defaults", root(json!({"type":"string"})))]), + None, + ) + .await?; + exact( + exchange.parameters(0)?, + &selected_root(json!({"type":"STRING"})), + "absent default stays absent", + )?; + } + Ok(()) +} +async fn zero_constraints(f: &mut Fixture) -> Result<()> { + for stream in [false, true] { + let schema = json!({"type":"object","properties":{ + "text":{"type":"string","minLength":0,"maxLength":0}, + "list":{"type":"array","items":{"type":"boolean"},"minItems":0,"maxItems":0}, + "number":{"type":"number","minimum":-0.0,"maximum":0.0,"default":0} + },"required":["text","list","number"],"additionalProperties":false}); + let mut body = request(stream); + body["tools"] = json!([tool("generic_zero", schema)]); + let valid_args = json!({"text":"","list":[],"number":0}); + let valid = f + .exchange( + body.clone(), + json!([{"functionCall":{"name":"generic_zero","args":valid_args}}]), + ) + .await?; + valid.success(stream)?; + ensure!( + valid.parameters(0)?["properties"]["text"] + .get("maxLength") + .is_none(), + "zero maxLength must omit on wire" + ); + selected_exact( + valid.parameters(0)?, + &json!({"type":"OBJECT","properties":{ + "text":{"type":"STRING"},"list":{"type":"ARRAY","items":{"type":"BOOLEAN"}}, + "number":{"type":"NUMBER","default":0} + },"required":["text","list","number"]}), + "zero constraints omit but default remains", + )?; + } + Ok(()) +} +async fn closed_empty(f: &mut Fixture) -> Result<()> { + for stream in [false, true] { + let schema = + json!({"type":"object","properties":{},"required":[],"additionalProperties":false}); + let mut body = request(stream); + body["tools"] = json!([tool("generic_empty", schema)]); + let exchange = f + .exchange(body, json!([{"functionCall":{"name":"generic_empty"}}])) + .await?; + exchange.success(stream)?; + exact( + exchange.parameters(0)?, + &json!({"type":"OBJECT"}), + "closed-empty parameters must be present", + )?; + ensure!( + exchange + .blocks()? + .as_array() + .unwrap() + .iter() + .any(|b| b["type"] == "tool_use" && b["input"] == json!({})), + "omitted args must remain empty" + ); + let envelope = envelope(&exchange.blocks()?)?; + ensure!( + envelope["contents"][0]["parts"][0]["functionCall"] + .get("args") + .is_none(), + "saved omitted args were repaired" + ); + exact( + &envelope["tool_mapping"][0]["codec_paths"], + &json!([]), + "closed-empty codec paths", + )?; + ensure!( + envelope["tool_mapping"][0]["root_wrapper"].is_null(), + "closed-empty root recipe changed" + ); + } + Ok(()) +} +async fn auto_choice(f: &mut Fixture) -> Result<()> { + for stream in [false, true] { + let eligible = tool("generic_auto", root(json!({"type":"string"}))); + let legacy = tool("generic_legacy", root(json!({"type":"string","const":"x"}))); + let empty = tool( + "generic_empty", + json!({"type":"object","additionalProperties":false}), + ); + for tools in [ + json!([eligible.clone()]), + json!([legacy.clone()]), + json!([eligible, legacy]), + json!([empty]), + ] { + let exchange = f + .project(stream, tools, Some(json!({"type":"auto"}))) + .await?; + exact( + &exchange.wire()?["request"]["toolConfig"], + &json!({"functionCallingConfig":{"mode":"VALIDATED"}}), + "explicit auto mode", + )?; + } + } + Ok(()) +} +async fn choice_controls(f: &mut Fixture) -> Result<()> { + for stream in [false, true] { + for tools in [None, Some(json!([]))] { + for choice in [ + None, + Some(json!({"type":"auto"})), + Some(json!({"type":"none"})), + ] { + let mut body = request(stream); + if let Some(tools) = &tools { + body["tools"] = tools.clone(); + } + if let Some(choice) = choice { + body["tool_choice"] = choice; + } + let exchange = f + .exchange(body, json!([{"text":"synthetic complete"}])) + .await?; + exchange.success(stream)?; + let wire = &exchange.wire()?["request"]; + ensure!( + wire.get("tools").is_none() && wire.get("toolConfig").is_none(), + "empty tool pool must omit declarations and config" + ); + } + } + let tools = json!([tool("generic_choice", root(json!({"type":"string"})))]); + for choice in [None, Some(json!({"type":"none"}))] { + let exchange = f.project(stream, tools.clone(), choice.clone()).await?; + let wire = &exchange.wire()?["request"]; + ensure!( + wire.get("toolConfig").is_none(), + "absent/none tool config changed" + ); + ensure!( + wire.get("tools").is_some() == choice.is_none(), + "none tool declarations changed" + ); + } + for choice in [ + json!({"type":"any"}), + json!({"type":"required"}), + json!({"type":"tool","name":"generic_choice"}), + json!({"type":"auto","disable_parallel_tool_use":true}), + ] { + let mut body = request(stream); + body["tools"] = tools.clone(); + body["tool_choice"] = choice; + let exchange = f.exchange(body, json!([{"text":"unreachable"}])).await?; + ensure!( + exchange.status == StatusCode::BAD_REQUEST && exchange.wire.is_none(), + "unsupported choice admission changed" + ); + } + } + Ok(()) +} + +async fn fallback(f: &mut Fixture) -> Result<()> { + let cases = vec![ + ( + json!({"type":"string","const":"x"}), + json!({"type":"STRING","enum":["x"]}), + ), + ( + json!({"type":"number","multipleOf":2}), + json!({"type":"NUMBER"}), + ), + ( + json!({"type":"number","exclusiveMinimum":1.5}), + json!({"type":"NUMBER"}), + ), + ( + json!({"type":"integer","format":"date"}), + json!({"type":"INTEGER"}), + ), + ( + json!({"type":"string","enum":["x", 7, ""]}), + json!({"type":"STRING","enum":["x", ""]}), + ), + ( + json!({"type":"number","enum":[1, 2]}), + json!({"type":"NUMBER"}), + ), + ( + json!({"type":"string","minimum":1}), + json!({"type":"STRING"}), + ), + ( + json!({"type":"array","items":{"type":"string"},"uniqueItems":true}), + json!({"type":"ARRAY","items":{"type":"STRING"}}), + ), + ( + json!({"type":"object","properties":{"x":{"type":"string"}},"minProperties":1}), + json!({"type":"OBJECT","properties":{"x":{"type":"STRING"}}}), + ), + ( + json!({"type":"array","items":{"type":"string"},"properties":{"x":{"type":"string"}}}), + json!({"type":"ARRAY","items":{"type":"STRING"}}), + ), + ( + json!({"type":"string","default":9007199254740992_u64}), + json!({"type":"STRING"}), + ), + ( + json!({"oneOf":[{"type":"string"},{"type":"integer"}]}), + json!({}), + ), + ( + json!({"type":"object","properties":{"x":{"type":"string"}},"additionalProperties":{"type":"boolean"}}), + json!({"type":"OBJECT","properties":{"x":{"type":"STRING"}}}), + ), + (json!(true), json!({})), + ]; + for stream in [false, true] { + for (source, expected) in &cases { + let mut schema = root(source.clone()); + schema["title"] = json!("Synthetic marker"); + let exchange = f + .project(stream, json!([tool("generic_fallback", schema)]), None) + .await?; + exact( + exchange.parameters(0)?, + &selected_root(expected.clone()), + "agy-style permissive projection", + )?; + } + // Aliased names keep their hashed native declaration name and native schema projection. + let name = "generic spaced name"; + let schema = root(json!({"type":"string","default":"kept natively"})); + let exchange = f.project(stream, json!([tool(name, schema)]), None).await?; + let native = format!("ccp_{:x}", Sha256::digest(name.as_bytes()))[..64].to_owned(); + exact( + &exchange.wire()?["request"]["tools"][0], + &json!({"functionDeclarations":[{ + "name":native,"description":"Synthetic function.","parameters":selected_root(json!({"type":"STRING","default":"kept natively"})) + }]}), + "aliased native declaration", + )?; + for schema in [ + json!({"type":"object","additionalProperties":false,"allOf":[{}]}), + json!({"type":"object","additionalProperties":false,"default":9007199254740992_u64}), + ] { + let exchange = f + .project( + stream, + json!([tool("generic_ineligible_empty", schema)]), + None, + ) + .await?; + exact( + &exchange.wire()?["request"]["tools"][0], + &json!({"functionDeclarations":[{"name":"generic_ineligible_empty","description":"Synthetic function.","parameters":{"type":"OBJECT"}}]}), + "closed-empty object parameters retained", + )?; + } + } + Ok(()) +} + +async fn eligibility(f: &mut Fixture) -> Result<()> { + for stream in [false, true] { + // A schema-looking property name is data, not an unsupported keyword. + let schema = json!({"type":"object","properties":{"allOf":{"type":"string"},"items":{"type":"boolean"}},"additionalProperties":false}); + let exchange = f + .project(stream, json!([tool("generic_keys", schema)]), None) + .await?; + exact( + exchange.parameters(0)?, + &json!({"type":"OBJECT","properties":{"allOf":{"type":"STRING"},"items":{"type":"BOOLEAN"}}}), + "property names are not keywords", + )?; + let mut schema = root( + json!({"type":"string","description":"Synthetic caller description.", + "title":"Synthetic annotation","enum":["","x"],"format":"email","pattern":".*"}), + ); + schema["title"] = json!("Synthetic root annotation"); + schema["$schema"] = json!("https://json-schema.org/draft/2020-12/schema"); + let exchange = f + .project(stream, json!([tool("generic_annotations", schema)]), None) + .await?; + exact( + exchange.parameters(0)?, + &selected_root( + json!({"type":"STRING","description":"Synthetic caller description.", + "enum":["","x"],"format":"email","pattern":".*"}), + ), + "selected annotations and real empty-string enum choice", + )?; + let exchange = f + .project( + stream, + json!([tool( + "generic_empty_scalars", + root(json!({"type":"string","description":"","pattern":""})) + )]), + None, + ) + .await?; + exact( + exchange.parameters(0)?, + &selected_root(json!({"type":"STRING"})), + "empty scalar strings omit", + )?; + let mut body = request(stream); + body["tools"] = json!([tool( + "generic_exclusive", + root(json!({"type":"integer","minimum":-5,"maximum":5, + "exclusiveMinimum":0,"exclusiveMaximum":3})) + )]); + let valid = f + .exchange( + body, + json!([{"functionCall":{"name":"generic_exclusive","args":{"value":1}}}]), + ) + .await?; + valid.success(stream)?; + selected_exact( + valid.parameters(0)?, + &selected_root(json!({"type":"INTEGER","minimum":-5,"maximum":5})), + "exclusive bounds omitted without conversion", + )?; + let exchange = f + .project( + stream, + json!([ + tool( + "generic_selected", + root(json!({"type":"string","default":false})) + ), + tool("generic_const", root(json!({"type":"string","const":"x"}))) + ]), + None, + ) + .await?; + exact( + exchange.parameters(0)?, + &selected_root(json!({"type":"STRING","default":false})), + "mixed selected tool", + )?; + exact( + exchange.parameters(1)?, + &selected_root(json!({"type":"STRING","enum":["x"]})), + "const projects natively to enum", + )?; + } + Ok(()) +} + +async fn numeric_budgets(f: &mut Fixture) -> Result<()> { + for stream in [false, true] { + for value in [ + json!(9007199254740991_i64), + json!(-9007199254740991_i64), + json!(1e20), + json!(1.0), + json!(-0.0), + ] { + let source = root(json!({"type":"number","default":value,"minimum":value})); + let mut expected = json!({"type":"NUMBER","default":value}); + if value.as_f64().unwrap() != 0.0 { + expected["minimum"] = value; + } + let exchange = f + .project(stream, json!([tool("generic_number", source)]), None) + .await?; + selected_exact( + exchange.parameters(0)?, + &selected_root(expected), + "safe integer or finite float variant", + )?; + } + for value in [ + json!(9007199254740992_u64), + json!(-9007199254740992_i64), + json!(i64::MIN), + json!(i64::MAX), + json!(u64::MAX), + ] { + let source = root(json!({"type":"number","default":value})); + let exchange = f + .project(stream, json!([tool("generic_unsafe", source)]), None) + .await?; + exact( + exchange.parameters(0)?, + &selected_root(json!({"type":"NUMBER"})), + "unsafe integer default omitted cleanly", + )?; + let source = root(json!({"type":"number","maximum":value})); + let exchange = f + .project(stream, json!([tool("generic_unsafe", source)]), None) + .await?; + exact( + exchange.parameters(0)?, + &selected_root(json!({"type":"NUMBER"})), + "unsafe integer bound omitted cleanly", + )?; + } + for (value, expected) in [ + (json!(1.0), json!({"type":"STRING","minLength":"1"})), + (json!(-0.0), json!({"type":"STRING"})), + ( + json!(9007199254740991_u64), + json!({"type":"STRING","minLength":"9007199254740991"}), + ), + ] { + let exchange = f + .project( + stream, + json!([tool( + "generic_length", + root(json!({"type":"string","minLength":value})) + )]), + None, + ) + .await?; + exact( + exchange.parameters(0)?, + &selected_root(expected), + "integral length numeric variant", + )?; + } + for value in [json!(9007199254740992_u64), json!(u64::MAX), json!(1e20)] { + let exchange = f + .project( + stream, + json!([tool( + "generic_length", + root(json!({"type":"string","minLength":value})) + )]), + None, + ) + .await?; + exact( + exchange.parameters(0)?, + &selected_root(json!({"type":"STRING"})), + "unsafe length omitted cleanly", + )?; + } + let mut deep_default = json!(null); + for _ in 0..24 { + deep_default = json!([deep_default]); + } + let exchange = f + .project( + stream, + json!([tool( + "generic_budget", + root(json!({"type":"string","default":vec![Value::Null; 4100]})) + )]), + None, + ) + .await?; + exact( + exchange.parameters(0)?, + &selected_root(json!({"type":"STRING"})), + "default projection node budget omits oversized default", + )?; + // Each default is below the 4096-node default budget; the second exceeds the shared budget. + let half = json!(vec![Value::Null; 2300]); + let schema = json!({"type":"object","properties":{ + "a":{"type":"string","default":half}, + "b":{"type":"string","default":half} + },"additionalProperties":false}); + let exchange = f + .project(stream, json!([tool("generic_shared_budget", schema)]), None) + .await?; + exact( + exchange.parameters(0)?, + &json!({"type":"OBJECT","properties":{"a":{"type":"STRING","default":half},"b":{"type":"STRING"}}}), + "shared whole-tool default budget", + )?; + let value = json!({ + "items":vec![Value::Null;32], + "nested":deep_default, + "allOf":"ordinary data", + "default":{"type":false} + }); + let exchange = f + .project( + stream, + json!([tool( + "generic_small_budget", + root(json!({"type":"string","default":value})) + )]), + None, + ) + .await?; + exact( + exchange.parameters(0)?, + &selected_root(json!({"type":"STRING","default":value})), + "bounded default data remains selected", + )?; + } + Ok(()) +} + +async fn admission(f: &mut Fixture) -> Result<()> { + let mut too_deep = json!({"type":"string"}); + for _ in 0..33 { + too_deep = json!({"properties":{"x":too_deep}}); + } + for stream in [false, true] { + for schema in [ + json!(true), + json!([]), + json!("object"), + json!({"type":"string"}), + json!({"type":["string","integer"]}), + too_deep.clone(), + root(json!({"type":"string","default":"x".repeat(256*1024)})), + ] { + let mut body = request(stream); + body["tools"] = json!([tool("generic_invalid", schema)]); + let exchange = f.exchange(body, json!([{"text":"unreachable"}])).await?; + ensure!( + exchange.status == StatusCode::BAD_REQUEST && exchange.wire.is_none(), + "root/bound schema admission weakened or deferred" + ); + } + // none must not bypass tool admission before clearing the pool. + let mut body = request(stream); + body["tools"] = json!([tool("generic_invalid", json!({"type":"string"}))]); + body["tool_choice"] = json!({"type":"none"}); + let exchange = f.exchange(body, json!([{"text":"unreachable"}])).await?; + ensure!( + exchange.status == StatusCode::BAD_REQUEST && exchange.wire.is_none(), + "admission-first ordering changed" + ); + } + Ok(()) +} + +async fn project_admission(f: &mut Fixture) -> Result<()> { + // Change only this fixture's synthetic files, never a global environment. + // Restore them before propagating any assertion error to the common closer. + let config_path = f + .auth_path + .parent() + .and_then(|p| p.parent()) + .context("synthetic config parent")? + .join("config.json"); + let config_bytes = std::fs::read(&config_path)?; + let mut credential: Value = serde_json::from_slice(&f.auth_bytes)?; + credential["project_id"] = Value::Null; + std::fs::write(&f.auth_path, serde_json::to_vec(&credential)?)?; + std::fs::write( + &config_path, + serde_json::to_vec(&json!({"antigravity":{ + "enabled":true,"baseUrl":f.origin + }}))?, + )?; + tokio::time::timeout(STEP_LIMIT, f.provider.finish_maintenance()).await??; + f.provider = AntigravityProvider::new(); + let result = async { + for stream in [false,true] { + let mut body = request(stream); + body["tools"] = json!([tool("generic_invalid",json!({"type":"string"}))]); + let invalid = f.exchange(body,json!([{"text":"unreachable"}])).await?; + ensure!(invalid.status == StatusCode::BAD_REQUEST && invalid.wire.is_none(),"admission before project resolution"); + let error: Value = serde_json::from_slice(&invalid.bytes)?; + ensure!(error["error"]["message"] == "tool input_schema root must be an object","admission error precedence"); + let missing = f.exchange(request(stream),json!([{"text":"unreachable"}])).await?; + ensure!(missing.status == StatusCode::BAD_REQUEST && missing.wire.is_none(),"missing project must not send generation"); + let error: Value = serde_json::from_slice(&missing.bytes)?; + ensure!(error["error"]["message"] == "Antigravity project missing; run auth login or configure your authorized project","legacy missing-project error changed"); + } + Ok::<(),anyhow::Error>(()) + }.await; + std::fs::write(&f.auth_path, &f.auth_bytes)?; + std::fs::write(config_path, config_bytes)?; + result +} + +fn envelope(blocks: &Value) -> Result { + let signature = blocks[0]["signature"] + .as_str() + .context("V1 carrier signature")?; + let encoded = signature + .strip_prefix("ccp:antigravity:v1:") + .context("V1 prefix unchanged")?; + let raw = URL_SAFE_NO_PAD.decode(encoded)?; + ensure!(raw.len() <= BODY_LIMIT, "synthetic V1 envelope bound"); + Ok(serde_json::from_slice(&raw)?) +} +async fn continuation_control(f: &mut Fixture) -> Result<()> { + for stream in [false, true] { + let spaced = "generic alias name"; + let native_alias = format!("ccp_{:x}", Sha256::digest(spaced.as_bytes()))[..64].to_owned(); + let tools = json!([ + tool( + "generic_empty", + json!({"type":"object","additionalProperties":false}) + ), + tool("generic_path", root(json!({"type":["string","null"]}))), + tool( + spaced, + json!({"type":"object","additionalProperties":{"type":"string"}}) + ), + tool( + "generic_selected", + root(json!({"type":"integer","default":3})) + ) + ]); + let parts = json!([ + {"text":"Synthetic thought.","thought":true,"thoughtSignature":"synthetic-opaque-signature","syntheticUnknown":{"keep":true}}, + {"functionCall":{"name":"generic_empty","id":"synthetic-empty-id"}}, + {"functionCall":{"name":"generic_path","args":{"value":null}}}, + {"functionCall":{"name":native_alias,"id":"synthetic-alias-id","args":{"x":"y"}}}, + {"functionCall":{"name":"generic_selected","args":{"value":3}}} + ]); + let mut body = request(stream); + body["tools"] = tools.clone(); + let first = f.exchange(body.clone(), parts.clone()).await?; + first.success(stream)?; + let blocks = first.blocks()?; + let saved = envelope(&blocks)?; + exact( + &saved["contents"], + &json!([{"role":"model","parts":parts}]), + "V1 unknown native contents preserved", + )?; + ensure!( + saved["version"] == 1 + && saved["profile_id"] == PROFILE + && saved["project_id"] == PROJECT, + "V1 scope/version" + ); + let mapping = saved["tool_mapping"].as_array().context("V1 recipes")?; + ensure!(mapping.len() == 4, "V1 multiple tool mappings"); + for (index, (caller, paths, wrapper)) in [ + ("generic_empty", json!([]), Value::Null), + ("generic_path", json!([]), Value::Null), + (spaced, json!([]), Value::Null), + ("generic_selected", json!([]), Value::Null), + ] + .into_iter() + .enumerate() + { + ensure!( + mapping[index]["caller_name"] == caller, + "V1 caller-name recipe" + ); + exact(&mapping[index]["codec_paths"], &paths, "V1 path recipe")?; + exact(&mapping[index]["root_wrapper"], &wrapper, "V1 root recipe")?; + } + let mut results = Vec::new(); + for block in blocks + .as_array() + .unwrap() + .iter() + .filter(|b| b["type"] == "tool_use") + { + results.push(json!({"type":"tool_result","tool_use_id":block["id"],"content":"Synthetic result."})); + } + body["messages"].as_array_mut().unwrap().extend([ + json!({"role":"assistant","content":blocks}), + json!({"role":"user","content":results}), + ]); + // Current declarations can change, but saved codecs and carrier must not. + body["tools"] = json!([tool( + "generic_selected", + root(json!({"type":"integer","default":4})) + )]); + let second = f + .exchange(body, json!([{"text":"Synthetic continuation."}])) + .await?; + second.success(stream)?; + let contents = &second.wire()?["request"]["contents"]; + exact( + &contents[1], + &saved["contents"][0], + "V1 native history exact reuse", + )?; + ensure!( + second.wire()?["request"]["sessionId"] == saved["backend_session_id"], + "V1 session reuse" + ); + ensure!( + contents[2]["parts"] + .as_array() + .is_some_and(|p| p.len() == 4), + "V1 complete result batch" + ); + for (index, call) in saved["contents"][0]["parts"] + .as_array() + .unwrap() + .iter() + .filter_map(|p| p.get("functionCall")) + .enumerate() + { + let response = &contents[2]["parts"][index]["functionResponse"]; + ensure!( + response["name"] == call["name"] && response.get("id") == call.get("id"), + "V1 native result identity" + ); + } + } + Ok(()) +} diff --git a/tests/antigravity_transport.rs b/tests/antigravity_transport.rs new file mode 100644 index 00000000..4c69b925 --- /dev/null +++ b/tests/antigravity_transport.rs @@ -0,0 +1,2498 @@ +//! Production-library transport fixtures: synthetic credentials and verified loopback TLS only. +//! Every environment-sensitive provider runs in its own child; the library is cfg(test)=false. +use axum::body::to_bytes; +use bytes::Bytes; +use claude_code_proxy::{ + MessagesRequest, Provider, RequestContext, providers::antigravity::AntigravityProvider, +}; +use http::{HeaderMap, Response, Version}; +use http_body_util::{BodyExt, Full}; +use hyper::{body::Incoming, service::service_fn}; +use hyper_util::rt::{TokioExecutor, TokioIo}; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject}; +use serde_json::{Value, json}; +use std::{ + convert::Infallible, + io::Write, + path::Path, + sync::{Arc, Mutex}, + time::{SystemTime, UNIX_EPOCH}, +}; +use tokio::task::{JoinHandle, JoinSet}; + +// Intentionally public, inert test credentials; never use this CA/key outside fixtures. +const CA: &[u8] = include_bytes!("fixtures/antigravity-transport/ca.pem"); +const CERT: &[u8] = include_bytes!("fixtures/antigravity-transport/server.pem"); +const KEY: &[u8] = include_bytes!("fixtures/antigravity-transport/server-key.pem"); +const CHILD_CASE: &str = "CCP_TRANSPORT_FIXTURE_CASE"; +const CHILD_STREAM: &str = "CCP_TRANSPORT_FIXTURE_STREAM"; + +struct Observed { + headers: HeaderMap, + version: Version, + wire: Value, +} + +struct TlsServer { + url: String, + observed: Arc>>, + task: JoinHandle<()>, +} + +impl Drop for TlsServer { + fn drop(&mut self) { + self.task.abort(); + } +} + +fn success() -> Vec { + format!( + "data: {}\n\n", + json!({"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"TRANSPORT_OK"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":2,"totalTokenCount":12}}}) + ) + .into_bytes() +} + +fn gzip(bytes: &[u8]) -> Vec { + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(bytes).unwrap(); + encoder.finish().unwrap() +} + +impl TlsServer { + async fn start(protocol: Version, status: u16, encoding: Option<&str>, body: Vec) -> Self { + let mut headers = HeaderMap::new(); + headers.insert( + "content-type", + http::HeaderValue::from_static(if status == 200 { + "text/event-stream" + } else { + "application/json" + }), + ); + headers.insert("retry-after", http::HeaderValue::from_static("7")); + if let Some(encoding) = encoding { + headers.insert( + "content-encoding", + http::HeaderValue::from_str(encoding).unwrap(), + ); + } + Self::start_with_headers(protocol, status, headers, body).await + } + + async fn start_with_headers( + protocol: Version, + status: u16, + headers: HeaderMap, + body: Vec, + ) -> Self { + let certificates = CertificateDer::pem_slice_iter(CERT) + .collect::, _>>() + .unwrap(); + let key = PrivateKeyDer::from_pem_slice(KEY).unwrap(); + let mut config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certificates, key) + .unwrap(); + config.alpn_protocols = vec![if protocol == Version::HTTP_2 { + b"h2".to_vec() + } else { + b"http/1.1".to_vec() + }]; + let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(config)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("https://{}", listener.local_addr().unwrap()); + let observed = Arc::new(Mutex::new(Vec::new())); + let captures = observed.clone(); + let task = tokio::spawn(async move { + let mut connections = JoinSet::new(); + loop { + tokio::select! { + accepted = listener.accept() => { + let (socket, _) = accepted.unwrap(); + let acceptor = acceptor.clone(); + let captures = captures.clone(); + let headers = headers.clone(); + let body = body.clone(); + connections.spawn(async move { + let Ok(tls) = acceptor.accept(socket).await else { return }; + let service = service_fn(move |request: http::Request| { + let captures = captures.clone(); + let headers = headers.clone(); + let body = body.clone(); + async move { + assert_eq!(request.method(), http::Method::POST); + assert_eq!(request.uri().path_and_query().unwrap().as_str(), "/v1internal:streamGenerateContent?alt=sse"); + let (parts, mut incoming) = request.into_parts(); + let mut bytes = Vec::new(); + while let Some(frame) = incoming.frame().await { + if let Ok(data) = frame.unwrap().into_data() { + assert!(bytes.len() + data.len() <= 65536); + bytes.extend_from_slice(&data); + } + } + captures.lock().unwrap().push(Observed { + headers: parts.headers, + version: parts.version, + wire: serde_json::from_slice(&bytes).unwrap(), + }); + let mut response = Response::builder().status(status) + .body(Full::new(Bytes::from(body))).unwrap(); + *response.headers_mut() = headers; + Ok::<_, Infallible>(response) + } + }); + if protocol == Version::HTTP_2 { + let _ = hyper::server::conn::http2::Builder::new(TokioExecutor::new()) + .serve_connection(TokioIo::new(tls), service).await; + } else { + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(tls), service).await; + } + }); + } + Some(result) = connections.join_next(), if !connections.is_empty() => { + result.unwrap(); + } + } + } + }); + Self { + url, + observed, + task, + } + } +} + +fn write_synthetic_store(root: &Path) { + use std::fs; + let provider = root.join("config/antigravity"); + fs::create_dir_all(&provider).unwrap(); + let expires_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + + 3600; + let path = provider.join("auth.json"); + fs::write(&path, json!({"profile_id":"public-test-profile","project_id":"public-test-project","access_token":"public-test-access","refresh_token":null,"expires_at":expires_at}).to_string()).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&provider, fs::Permissions::from_mode(0o700)).unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap(); + } + fs::write(root.join("ca.pem"), CA).unwrap(); + for directory in [ + "empty-certs", + "home", + "tmp", + "state", + "cache", + "data", + "run", + ] { + fs::create_dir_all(root.join(directory)).unwrap(); + } +} + +async fn child(server: &TlsServer, case: &'static str, stream: bool) { + child_configured(server, case, stream, &server.url, &[]).await; +} + +async fn child_configured( + server: &TlsServer, + case: &'static str, + stream: bool, + base_url: &str, + proxy_env: &[(&str, &str)], +) { + let _ = server; + let directory = tempfile::tempdir().unwrap(); + write_synthetic_store(directory.path()); + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command + .env_clear() + .args(["--exact", "antigravity_transport_child", "--nocapture"]) + .env(CHILD_CASE, case) + .env(CHILD_STREAM, if stream { "1" } else { "0" }) + .env("PATH", "/usr/bin:/bin") + .env("LANG", "C.UTF-8") + .env("RUST_BACKTRACE", "0") + .env("HOME", directory.path().join("home")) + .env("TMPDIR", directory.path().join("tmp")) + .env("XDG_CONFIG_HOME", directory.path().join("config")) + .env("XDG_CACHE_HOME", directory.path().join("cache")) + .env("XDG_DATA_HOME", directory.path().join("data")) + .env("XDG_STATE_HOME", directory.path().join("state")) + .env("XDG_RUNTIME_DIR", directory.path().join("run")) + .env("CCP_CONFIG_DIR", directory.path().join("config")) + .env("CCP_ANTIGRAVITY_ENABLED", "1") + .env("CCP_ANTIGRAVITY_BASE_URL", base_url) + .env("CCP_ANTIGRAVITY_PROJECT_ID", "public-test-project") + .env("SSL_CERT_FILE", directory.path().join("ca.pem")) + .env("SSL_CERT_DIR", directory.path().join("empty-certs")) + .env("NO_PROXY", "*") + .env("no_proxy", "*"); + if case == "native-ua-override" { + command.env("CCP_ANTIGRAVITY_USER_AGENT", "public-native-override"); + } + if case == "untrusted-ca" { + let empty_ca = directory.path().join("empty-ca.pem"); + std::fs::write(&empty_ca, b"").unwrap(); + command.env("SSL_CERT_FILE", empty_ca); + } + for (key, value) in proxy_env { + assert!(matches!( + *key, + "HTTPS_PROXY" + | "https_proxy" + | "HTTP_PROXY" + | "http_proxy" + | "ALL_PROXY" + | "all_proxy" + | "NO_PROXY" + | "no_proxy" + | "REQUEST_METHOD" + )); + command.env(key, value); + } + let output = tokio::task::spawn_blocking(move || command.output().unwrap()) + .await + .unwrap(); + assert!( + output.stdout.len() + output.stderr.len() < 32768, + "bounded synthetic child output" + ); + assert!( + output.status.success(), + "production child assertion failed: {} {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed")); +} + +fn synthetic_client_setup_class() -> &'static str { + let native = rustls_native_certs::load_native_certs(); + let mut roots = rustls::RootCertStore::empty(); + let (valid, invalid) = roots.add_parsable_certificates(native.certs); + let root_class = match (valid > 0, invalid > 0) { + (true, false) => "valid", + (true, true) => "partial_invalid", + (false, true) => "only_invalid", + (false, false) => "empty", + }; + eprintln!("FIXTURE_ROOT_CLASS {root_class}"); + // Exact public builder operations from baseline Config::client_builder(). + // Construction only: no endpoint, request, send, credentials or network. + let result = reqwest::Client::builder() + .retry(reqwest::retry::never()) + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(std::time::Duration::from_secs(15)) + .timeout(std::time::Duration::from_secs(180)) + .user_agent(format!("claude-code-proxy/{}", env!("CARGO_PKG_VERSION"))) + .build(); + let Err(error) = result else { + return "replica_client_ok"; + }; + let mut cause: Option<&(dyn std::error::Error + 'static)> = Some(&error); + for _ in 0..8 { + let Some(current) = cause else { break }; + // Inspect in memory; never print arbitrary error objects/strings or paths. + match current.to_string().as_str() { + "zero valid certificates found in native root store" => return "native_roots_invalid", + "empty supported tls versions" => return "tls_versions_empty", + "invalid TLS versions" => return "tls_versions_invalid", + "invalid TLS verification settings" => return "tls_verifier_invalid", + "invalid certificate encoding" => return "certificate_encoding_invalid", + "incompatible TLS identity type" => return "tls_identity_invalid", + "Unknown TLS backend passed to `use_preconfigured_tls`" => return "tls_backend_invalid", + "invalid HTTP header value" => return "client_header_invalid", + _ => {} + } + if current.downcast_ref::().is_some() { + return "tls_configuration_error"; + } + if let Some(error) = current.downcast_ref::() { + return match error.kind() { + std::io::ErrorKind::NotFound => "client_setup_file_missing", + std::io::ErrorKind::PermissionDenied => "client_setup_permission_denied", + _ => "client_setup_io_error", + }; + } + cause = current.source(); + } + "client_setup_unclassified" +} + +fn synthetic_error_class(status: http::StatusCode, text: &str) -> &'static str { + let value: Value = serde_json::from_str(text).unwrap_or(Value::Null); + let message = value["error"]["message"].as_str().unwrap_or(""); + match message { + "Antigravity configuration is invalid" => { + // Public local status does not construct Auth's HTTP client. Comparing + // its finite validity label distinguishes settings from client setup. + let status = claude_code_proxy::providers::antigravity::status_json(); + if status["status"]["configuration"] == "valid" { + eprintln!( + "FIXTURE_CLIENT_SETUP_CLASS {}", + synthetic_client_setup_class() + ); + "auth_http_client_init_invalid" + } else { + "config_invalid" + } + } + "Antigravity credentials are invalid or unreadable" => "synthetic_store_invalid", + "unsupported Antigravity model or replay scope" | "missing model" => { + "request_model_invalid" + } + "unsupported Antigravity semantic request option" | "unsupported or missing fields" => { + "request_option_invalid" + } + "Antigravity profile/project changed during request; start a fresh session" => { + "profile_binding_changed" + } + "Antigravity project missing; run auth login or configure your authorized project" => { + "project_missing" + } + "request needs a final user turn with complete tool results" => "request_turn_invalid", + "Antigravity: Antigravity native headers timed out or transport failed" => { + "native_transport_failed" + } + _ if status == http::StatusCode::BAD_REQUEST => "request_invalid_unclassified", + _ if status == http::StatusCode::UNAUTHORIZED => "auth_invalid_unclassified", + _ => "unclassified", + } +} + +#[tokio::test] +async fn antigravity_transport_child() { + let Ok(case) = std::env::var(CHILD_CASE) else { + return; + }; + let stream = std::env::var(CHILD_STREAM).unwrap() == "1"; + let provider = AntigravityProvider::new(); + let request: MessagesRequest = serde_json::from_value(json!({"model":"antigravity/gemini-4-argon-low","max_tokens":512,"stream":stream,"messages":[{"role":"user","content":"public synthetic transport fixture"}],"thinking":{"type":"adaptive","display":"omitted"}})).unwrap(); + let response = provider + .handle_messages( + request.clone(), + RequestContext { + req_id: "public-test-request".into(), + session_id: None, + session_seq: None, + provider: "antigravity".into(), + traffic: None, + monitor: None, + }, + ) + .await; + let status = response.status(); + let retry_after = response + .headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let bytes = to_bytes(response.into_body(), 65536).await.unwrap(); + let text = std::str::from_utf8(&bytes).unwrap(); + for prohibited in [ + "public-test-access", + "DO_NOT_REFLECT", + "user:pass", + "dXNlcjpwYXNz", + ] { + assert!( + !text.contains(prohibited), + "native response must redact synthetic origin/proxy credentials" + ); + } + let expected_status = match case.as_str() { + "gzip-error" | "rejected-error" => 429, + "redirect" => 302, + "wrong-content-type" | "preheader-failure" | "untrusted-ca" => 502, + "rejected-success" if !stream => 502, + _ => 200, + }; + if status.as_u16() != expected_status { + eprintln!( + "FIXTURE_ERROR_CLASS {}", + synthetic_error_class(status, text) + ); + } + if case == "rejected-success" { + assert_eq!(status.as_u16(), expected_status); + assert!(text.contains(if stream { "event: error" } else { "api_error" })); + for prohibited in [ + "message_start", + "message_stop", + "TRANSPORT_OK", + "DO_NOT_REFLECT", + ] { + assert!( + !text.contains(prohibited), + "invalid native transport must not publish success or raw content" + ); + } + } else if case == "redirect" { + assert_eq!( + status, 302, + "native redirect is an upstream error response, never followed" + ); + assert!(text.contains("api_error")); + assert!(!text.contains("message_start") && !text.contains("message_stop")); + } else if case == "rejected-error" { + assert_eq!(status, 429); + assert_eq!(retry_after.as_deref(), Some("7")); + assert!( + text.contains("google_status=unclassified; reason=unclassified; domain=unclassified") + ); + assert!(!text.contains("DO_NOT_REFLECT")); + } else if case == "preheader-failure" || case == "untrusted-ca" { + assert_eq!(status, 502); + assert!(text.contains("api_error")); + for prohibited in [ + "message_start", + "message_stop", + "TRANSPORT_OK", + "public-test-access", + "DO_NOT_REFLECT", + ] { + assert!(!text.contains(prohibited)); + } + } else if case == "wrong-content-type" { + assert_eq!(status, 502); + assert!( + text.contains("unexpected content type"), + "content type check must precede body decoding" + ); + } else if case == "gzip-error" { + assert_eq!( + status, 429, + "original upstream status must survive compression" + ); + assert!(text.contains("google_status=RESOURCE_EXHAUSTED; reason=RATE_LIMIT_EXCEEDED; domain=googleapis.com"), "gzip error diagnostics must decode"); + assert!( + !text.contains("DO_NOT_REFLECT"), + "error details must remain redacted" + ); + } else { + assert_eq!(status, 200, "valid native transport must succeed"); + assert!( + text.contains("TRANSPORT_OK"), + "validated native text must survive transport" + ); + if stream { + assert!(text.contains("event: message_stop")); + assert!(!text.contains("event: error")); + } + } + if case == "pooled-failure" { + let response = provider + .handle_messages( + request, + RequestContext { + req_id: "public-second-request".into(), + session_id: None, + session_seq: None, + provider: "antigravity".into(), + traffic: None, + monitor: None, + }, + ) + .await; + assert_eq!( + response.status(), + 502, + "second pooled generation failure must not retry" + ); + let bytes = to_bytes(response.into_body(), 65536).await.unwrap(); + let text = std::str::from_utf8(&bytes).unwrap(); + assert!(text.contains("api_error")); + for prohibited in [ + "message_start", + "message_stop", + "TRANSPORT_OK", + "public-test-access", + "user:pass", + "dXNlcjpwYXNz", + ] { + assert!(!text.contains(prohibited)); + } + } +} + +#[tokio::test] +async fn antigravity_transport_control_plain_verified_tls() { + for protocol in [Version::HTTP_11, Version::HTTP_2] { + let server = TlsServer::start(protocol, 200, None, success()).await; + for stream in [false, true] { + child(&server, "plain", stream).await; + } + let observed = server.observed.lock().unwrap(); + assert_eq!(observed.len(), 2); + for request in observed.iter() { + assert_eq!(request.version, protocol); + assert_eq!( + request.headers["authorization"], + "Bearer public-test-access" + ); + assert_eq!( + request.headers["user-agent"], + format!("claude-code-proxy/{}", env!("CARGO_PKG_VERSION")) + ); + assert_eq!( + request.wire["request"]["generationConfig"]["maxOutputTokens"], + 512 + ); + } + } +} + +#[test] +fn antigravity_transport_control_cached_multigzip_contract() { + fn decode(input: &[u8], chunk_size: usize) -> std::io::Result> { + let mut decoder = flate2::write::MultiGzDecoder::new(Vec::new()); + for chunk in input.chunks(chunk_size) { + decoder.write_all(chunk)?; + } + decoder.finish() + } + let first = gzip(b"first"); + let mut concatenated = first.clone(); + concatenated.extend(gzip(b"second")); + for chunk_size in [1, 3, 16, 4096] { + assert_eq!(decode(&concatenated, chunk_size).unwrap(), b"firstsecond"); + let mut corrupt = first.clone(); + let crc = corrupt.len() - 8; + corrupt[crc] ^= 1; + let mut truncated = first.clone(); + truncated.pop(); + let mut junk = first.clone(); + junk.extend_from_slice(b"not gzip"); + let mut partial_member = first.clone(); + partial_member.extend_from_slice(&[0x1f, 0x8b]); + for invalid in [Vec::new(), corrupt, truncated, junk, partial_member] { + assert!( + decode(&invalid, chunk_size).is_err(), + "gzip must consume and validate every member through EOF" + ); + } + } +} + +#[tokio::test] +async fn antigravity_transport_red_accept_is_absent_at_runtime() { + let server = TlsServer::start(Version::HTTP_11, 200, None, success()).await; + child(&server, "plain", false).await; + let observed = server.observed.lock().unwrap(); + assert_eq!(observed.len(), 1); + assert!( + !observed[0].headers.contains_key("accept"), + "generation Accept must be truly absent" + ); +} + +#[tokio::test] +async fn antigravity_transport_red_gzip_is_advertised_at_runtime() { + let server = TlsServer::start(Version::HTTP_11, 200, None, success()).await; + child(&server, "plain", false).await; + let observed = server.observed.lock().unwrap(); + assert_eq!(observed.len(), 1); + let values: Vec<_> = observed[0] + .headers + .get_all("accept-encoding") + .iter() + .collect(); + assert_eq!( + values.len(), + 1, + "generation must advertise exactly one gzip value" + ); + assert_eq!(values[0], "gzip"); +} + +#[tokio::test] +async fn antigravity_transport_red_unknown_length_http1() { + let server = TlsServer::start(Version::HTTP_11, 200, None, success()).await; + child(&server, "plain", false).await; + let observed = server.observed.lock().unwrap(); + assert_eq!(observed.len(), 1); + assert_eq!(observed[0].version, Version::HTTP_11); + assert!( + !observed[0].headers.contains_key("content-length"), + "unknown-length HTTP/1.1 must not emit Content-Length" + ); + assert_eq!(observed[0].headers["transfer-encoding"], "chunked"); +} + +#[tokio::test] +async fn antigravity_transport_red_unknown_length_http2() { + let server = TlsServer::start(Version::HTTP_2, 200, None, success()).await; + child(&server, "plain", false).await; + let observed = server.observed.lock().unwrap(); + assert_eq!(observed.len(), 1); + assert_eq!(observed[0].version, Version::HTTP_2); + assert!( + !observed[0].headers.contains_key("content-length"), + "unknown-length HTTP/2 must not emit Content-Length" + ); + assert!(!observed[0].headers.contains_key("transfer-encoding")); +} + +#[tokio::test] +async fn antigravity_transport_red_gzip_sse_decodes() { + let server = TlsServer::start(Version::HTTP_11, 200, Some("gzip"), gzip(&success())).await; + for stream in [false, true] { + child(&server, "gzip-success", stream).await; + } +} + +#[tokio::test] +async fn antigravity_transport_red_gzip_error_preserves_diagnostics() { + let error = json!({"error":{"status":"RESOURCE_EXHAUSTED","message":"DO_NOT_REFLECT", "details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"RATE_LIMIT_EXCEEDED","domain":"googleapis.com","metadata":{"private":"DO_NOT_REFLECT"}}]}}).to_string(); + let server = + TlsServer::start(Version::HTTP_11, 429, Some("gzip"), gzip(error.as_bytes())).await; + for stream in [false, true] { + child(&server, "gzip-error", stream).await; + } +} + +#[tokio::test] +async fn antigravity_transport_gzip_concatenated_members_and_identity_are_supported() { + let native = success(); + let split = native.len() / 2; + let mut concatenated = gzip(&native[..split]); + concatenated.extend(gzip(&native[split..])); + let mut empty_first = gzip(b""); + empty_first.extend(gzip(&native)); + for protocol in [Version::HTTP_11, Version::HTTP_2] { + for (encoding, body) in [ + ("identity", native.clone()), + ("GZip", gzip(&native)), + ("gzip", concatenated.clone()), + ("gzip", empty_first.clone()), + ] { + let server = TlsServer::start(protocol, 200, Some(encoding), body).await; + for stream in [false, true] { + child(&server, "plain", stream).await; + } + } + } +} + +#[tokio::test] +async fn antigravity_transport_gzip_crc_eof_and_trailing_fail_closed() { + let valid = gzip(&success()); + let mut crc = valid.clone(); + let offset = crc.len() - 8; + crc[offset] ^= 1; + let mut truncated = valid.clone(); + truncated.pop(); + let mut junk = valid.clone(); + junk.extend_from_slice(b"DO_NOT_REFLECT"); + let mut partial = valid.clone(); + partial.extend_from_slice(&[0x1f, 0x8b]); + let mut zero = valid.clone(); + zero.push(0); + for body in [Vec::new(), vec![0x1f], crc, truncated, junk, partial, zero] { + let server = TlsServer::start(Version::HTTP_11, 200, Some("gzip"), body).await; + for stream in [false, true] { + child(&server, "rejected-success", stream).await; + } + } +} + +#[tokio::test] +async fn antigravity_transport_all_encoding_headers_are_checked() { + for encodings in [ + vec!["br"], + vec!["deflate"], + vec![""], + vec!["gzip, identity"], + vec!["gzip", "gzip"], + vec!["identity", "gzip"], + ] { + for status in [200, 429] { + let mut headers = HeaderMap::new(); + headers.insert( + "content-type", + http::HeaderValue::from_static(if status == 200 { + "text/event-stream" + } else { + "application/json" + }), + ); + headers.insert("retry-after", http::HeaderValue::from_static("7")); + for encoding in &encodings { + headers.append( + "content-encoding", + http::HeaderValue::from_str(encoding).unwrap(), + ); + } + let server = + TlsServer::start_with_headers(Version::HTTP_11, status, headers, gzip(&success())) + .await; + for stream in [false, true] { + child( + &server, + if status == 200 { + "rejected-success" + } else { + "rejected-error" + }, + stream, + ) + .await; + } + } + } +} + +#[tokio::test] +async fn antigravity_transport_invalid_gzip_errors_preserve_status_and_retry() { + for body in [ + Vec::new(), + b"DO_NOT_REFLECT".to_vec(), + gzip(&vec![b'x'; 8193]), + ] { + let server = TlsServer::start(Version::HTTP_11, 429, Some("gzip"), body).await; + for stream in [false, true] { + child(&server, "rejected-error", stream).await; + } + } +} + +#[tokio::test] +async fn antigravity_transport_encoded_and_decoded_bodies_are_bounded() { + let part = format!( + "data: {}\n\n", + json!({"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"x".repeat(128 * 1024)}]}}]}}) + ); + let mut oversized_decoded = part.repeat(65).into_bytes(); + oversized_decoded.extend(success()); + for (encoding, body) in [ + (None, vec![b'x'; 8 * 1024 * 1024 + 1]), + (Some("gzip"), gzip(&oversized_decoded)), + (Some("gzip"), gzip(&vec![b'x'; 1024 * 1024 + 1])), + ] { + let server = TlsServer::start(Version::HTTP_11, 200, encoding, body).await; + for stream in [false, true] { + child(&server, "rejected-success", stream).await; + } + } + let server = TlsServer::start(Version::HTTP_11, 429, Some("gzip"), vec![0; 8193]).await; + child(&server, "rejected-error", false).await; +} + +#[tokio::test] +async fn antigravity_transport_content_type_precedes_success_decoding() { + let mut headers = HeaderMap::new(); + headers.insert( + "content-type", + http::HeaderValue::from_static("application/json"), + ); + headers.insert("content-encoding", http::HeaderValue::from_static("br")); + let server = + TlsServer::start_with_headers(Version::HTTP_11, 200, headers, b"DO_NOT_REFLECT".to_vec()) + .await; + for stream in [false, true] { + child(&server, "wrong-content-type", stream).await; + } +} + +#[tokio::test] +async fn antigravity_transport_redirect_is_not_followed_or_forwarded_credentials() { + let target = TlsServer::start(Version::HTTP_11, 200, None, success()).await; + let mut headers = HeaderMap::new(); + headers.insert( + "content-type", + http::HeaderValue::from_static("application/json"), + ); + headers.insert( + "location", + http::HeaderValue::from_str(&target.url).unwrap(), + ); + let origin = + TlsServer::start_with_headers(Version::HTTP_11, 302, headers, b"DO_NOT_REFLECT".to_vec()) + .await; + for stream in [false, true] { + child(&origin, "redirect", stream).await; + } + assert_eq!(origin.observed.lock().unwrap().len(), 2); + assert!( + target.observed.lock().unwrap().is_empty(), + "redirect target must receive neither a second generation nor Authorization" + ); +} + +mod retry_tests { + use super::*; + use std::{ + io, + sync::atomic::{AtomicUsize, Ordering}, + time::Duration, + }; + use tokio::{ + io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}, + sync::oneshot, + }; + + const MAX_CONNECTIONS: usize = 4; + const CONNECTION_TIMEOUT: Duration = Duration::from_secs(10); + + #[derive(Default)] + struct RetryCounts { + tcp: AtomicUsize, + tls: AtomicUsize, + requests: AtomicUsize, + h2_prefaces: AtomicUsize, + settings_acknowledged: AtomicUsize, + refused_streams: AtomicUsize, + errors: AtomicUsize, + } + + struct RetryFixture { + server: TlsServer, + counts: Arc, + stop: Option>, + drained: oneshot::Receiver<()>, + } + + fn fixture_error() -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, "synthetic retry fixture failed") + } + + fn retry_acceptor(protocol: Version) -> tokio_rustls::TlsAcceptor { + let certificates = CertificateDer::pem_slice_iter(CERT) + .collect::, _>>() + .unwrap(); + let key = PrivateKeyDer::from_pem_slice(KEY).unwrap(); + let mut config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certificates, key) + .unwrap(); + config.alpn_protocols = vec![if protocol == Version::HTTP_2 { + b"h2".to_vec() + } else { + b"http/1.1".to_vec() + }]; + tokio_rustls::TlsAcceptor::from(Arc::new(config)) + } + + impl RetryFixture { + async fn start(protocol: Version) -> Self { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("https://{}", listener.local_addr().unwrap()); + let acceptor = retry_acceptor(protocol); + let observed = Arc::new(Mutex::new(Vec::new())); + let records = observed.clone(); + let counts = Arc::new(RetryCounts::default()); + let captures = counts.clone(); + let (stop, mut stopping) = oneshot::channel(); + let (done, drained) = oneshot::channel(); + let task = tokio::spawn(async move { + let mut connections = JoinSet::new(); + loop { + tokio::select! { + // Drain already-queued TCP attempts before honoring the + // post-child stop barrier, rather than hiding retries. + biased; + accepted = listener.accept() => { + let Ok((socket, _)) = accepted else { + captures.errors.fetch_add(1, Ordering::SeqCst); + break; + }; + if captures.tcp.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS { + captures.errors.fetch_add(1, Ordering::SeqCst); + drop(socket); + break; + } + let acceptor = acceptor.clone(); + let captures = captures.clone(); + let records = records.clone(); + connections.spawn(async move { + let connection = async { + let tls = acceptor.accept(socket).await?; + captures.tls.fetch_add(1, Ordering::SeqCst); + let expected_alpn: &[u8] = if protocol == Version::HTTP_2 { + b"h2" + } else { + b"http/1.1" + }; + if tls.get_ref().1.alpn_protocol() != Some(expected_alpn) { + return Err(fixture_error()); + } + if protocol == Version::HTTP_2 { + refuse_h2_streams(tls, &captures).await + } else { + pooled_http1(tls, captures.clone(), records).await + } + }; + if !matches!(tokio::time::timeout(CONNECTION_TIMEOUT, connection).await, Ok(Ok(()))) { + captures.errors.fetch_add(1, Ordering::SeqCst); + } + }); + } + Some(result) = connections.join_next(), if !connections.is_empty() => { + if result.is_err() { + captures.errors.fetch_add(1, Ordering::SeqCst); + } + } + _ = &mut stopping => break, + } + } + drop(listener); + let complete = tokio::time::timeout(Duration::from_secs(2), async { + while let Some(result) = connections.join_next().await { + if result.is_err() { + captures.errors.fetch_add(1, Ordering::SeqCst); + } + } + }) + .await; + if complete.is_err() { + captures.errors.fetch_add(1, Ordering::SeqCst); + } + // JoinSet aborts anything still pending on timeout or when the + // enclosing TlsServer's Drop aborts this owner task. + drop(connections); + let _ = done.send(()); + }); + Self { + server: TlsServer { + url, + observed, + task, + }, + counts, + stop: Some(stop), + drained, + } + } + + async fn finish(&mut self) { + self.stop.take().unwrap().send(()).unwrap(); + tokio::time::timeout(Duration::from_secs(3), &mut self.drained) + .await + .expect("retry fixture shutdown must be bounded") + .expect("retry fixture owner must complete"); + assert_eq!( + self.counts.errors.load(Ordering::SeqCst), + 0, + "local retry fixture must complete without parsing/task errors" + ); + } + } + + async fn h2_frame( + stream: &mut S, + kind: u8, + flags: u8, + stream_id: u32, + payload: &[u8], + ) -> io::Result<()> { + if payload.len() > 65536 || stream_id & 0x8000_0000 != 0 { + return Err(fixture_error()); + } + let length = payload.len(); + let id = stream_id.to_be_bytes(); + stream + .write_all(&[ + (length >> 16) as u8, + (length >> 8) as u8, + length as u8, + kind, + flags, + id[0], + id[1], + id[2], + id[3], + ]) + .await?; + stream.write_all(payload).await?; + stream.flush().await + } + + async fn refuse_h2_streams( + mut stream: S, + counts: &RetryCounts, + ) -> io::Result<()> { + let mut preface = [0; 24]; + stream.read_exact(&mut preface).await?; + if &preface != b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" { + return Err(fixture_error()); + } + counts.h2_prefaces.fetch_add(1, Ordering::SeqCst); + h2_frame(&mut stream, 4, 0, 0, &[]).await?; // Initial server SETTINGS. + let mut total_payload = 0usize; + let mut refused = false; + for _ in 0..128 { + let mut header = [0; 9]; + if let Err(error) = stream.read_exact(&mut header).await { + // The isolated child closes its connection after the terminal + // 502. No raw TLS/error/header/body data is retained or printed. + return if refused { Ok(()) } else { Err(error) }; + } + let length = (usize::from(header[0]) << 16) + | (usize::from(header[1]) << 8) + | usize::from(header[2]); + if length > 65536 || total_payload + length > 256 * 1024 { + return Err(fixture_error()); + } + total_payload += length; + let stream_id = u32::from_be_bytes(header[5..9].try_into().unwrap()) & 0x7fff_ffff; + let mut payload = vec![0; length]; + stream.read_exact(&mut payload).await?; + match header[3] { + 4 => { + if stream_id != 0 || (header[4] & 1 != 0 && length != 0) || length % 6 != 0 { + return Err(fixture_error()); + } + if header[4] & 1 == 0 { + h2_frame(&mut stream, 4, 1, 0, &[]).await?; + counts.settings_acknowledged.fetch_add(1, Ordering::SeqCst); + } + } + 1 => { + if stream_id == 0 || stream_id % 2 == 0 { + return Err(fixture_error()); + } + if counts.requests.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS { + return Err(fixture_error()); + } + // RFC 7540 REFUSED_STREAM (7). Keep the connection alive so + // a retransmission on this connection is counted as well. + h2_frame(&mut stream, 3, 0, stream_id, &7u32.to_be_bytes()).await?; + counts.refused_streams.fetch_add(1, Ordering::SeqCst); + refused = true; + } + 6 => { + if stream_id != 0 || length != 8 { + return Err(fixture_error()); + } + if header[4] & 1 == 0 { + h2_frame(&mut stream, 6, 1, 0, &payload).await?; + } + } + 7 => { + return if refused { + Ok(()) + } else { + Err(fixture_error()) + }; + } + // DATA, CONTINUATION and flow-control payloads are bounded and + // discarded. This fixture deliberately does not decode HPACK. + _ => {} + } + } + Err(fixture_error()) + } + + async fn pooled_http1( + tls: tokio_rustls::server::TlsStream, + counts: Arc, + observed: Arc>>, + ) -> io::Result<()> { + let captures = counts.clone(); + let service = service_fn(move |request: http::Request| { + let counts = captures.clone(); + let observed = observed.clone(); + async move { + let ordinal = counts.requests.fetch_add(1, Ordering::SeqCst); + if ordinal >= MAX_CONNECTIONS + || request.method() != http::Method::POST + || request.version() != Version::HTTP_11 + || request.uri().path_and_query().map(|value| value.as_str()) + != Some("/v1internal:streamGenerateContent?alt=sse") + { + counts.errors.fetch_add(1, Ordering::SeqCst); + return Err::>, _>(fixture_error()); + } + let (parts, mut incoming) = request.into_parts(); + let mut bytes = Vec::new(); + while let Some(frame) = incoming.frame().await { + let frame = frame.map_err(|_| fixture_error())?; + if let Ok(data) = frame.into_data() { + if bytes.len() + data.len() > 65536 { + counts.errors.fetch_add(1, Ordering::SeqCst); + return Err(fixture_error()); + } + bytes.extend_from_slice(&data); + } + } + let wire = serde_json::from_slice(&bytes).map_err(|_| fixture_error())?; + observed.lock().unwrap().push(Observed { + headers: parts.headers, + version: parts.version, + wire, + }); + if ordinal == 0 { + Ok(Response::builder() + .status(200) + .header("content-type", "text/event-stream") + .body(Full::new(Bytes::from(success()))) + .unwrap()) + } else { + // The request reached the reused connection. Returning a + // service error closes it before any response headers. + Err(io::Error::other("synthetic pooled connection failure")) + } + } + }); + let result = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(tls), service) + .await; + if result.is_err() && counts.requests.load(Ordering::SeqCst) < 2 { + return Err(fixture_error()); + } + Ok(()) + } + + #[tokio::test] + async fn antigravity_transport_retry_h2_refused_stream_is_one_native_attempt() { + for stream in [false, true] { + let mut fixture = RetryFixture::start(Version::HTTP_2).await; + child(&fixture.server, "preheader-failure", stream).await; + fixture.finish().await; + assert_eq!( + fixture.counts.tcp.load(Ordering::SeqCst), + 1, + "REFUSED_STREAM must not open a second TCP connection" + ); + assert_eq!(fixture.counts.tls.load(Ordering::SeqCst), 1); + assert_eq!(fixture.counts.h2_prefaces.load(Ordering::SeqCst), 1); + assert!(fixture.counts.settings_acknowledged.load(Ordering::SeqCst) >= 1); + assert_eq!( + fixture.counts.requests.load(Ordering::SeqCst), + 1, + "REFUSED_STREAM must not retransmit generation HEADERS on any stream" + ); + assert_eq!(fixture.counts.refused_streams.load(Ordering::SeqCst), 1); + } + } + + #[tokio::test] + async fn antigravity_transport_retry_reused_http1_service_failure_is_not_retransmitted() { + let mut fixture = RetryFixture::start(Version::HTTP_11).await; + // The child consumes one successful turn, then sends another using the + // SAME public provider. Its second request must return a terminal 502. + child(&fixture.server, "pooled-failure", false).await; + fixture.finish().await; + assert_eq!( + fixture.counts.tcp.load(Ordering::SeqCst), + 1, + "both turns must use one pooled TCP connection; no reconnect retry" + ); + assert_eq!(fixture.counts.tls.load(Ordering::SeqCst), 1); + assert_eq!( + fixture.counts.requests.load(Ordering::SeqCst), + 2, + "one successful turn plus one failed generation, with no retransmission" + ); + let observed = fixture.server.observed.lock().unwrap(); + assert_eq!(observed.len(), 2); + for request in observed.iter() { + assert_eq!(request.version, Version::HTTP_11); + assert_eq!(request.headers.get_all("authorization").iter().count(), 1); + assert_eq!( + request.headers["authorization"], + "Bearer public-test-access" + ); + assert!(!request.headers.contains_key("proxy-authorization")); + assert_eq!( + request.wire["request"]["generationConfig"]["maxOutputTokens"], + 512 + ); + } + } +} + +mod proxy_tests { + use super::*; + use std::{ + io, + net::{Ipv4Addr, Ipv6Addr, SocketAddr}, + sync::atomic::{AtomicUsize, Ordering}, + time::Duration, + }; + use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + + const BASIC: &str = "Basic dXNlcjpwYXNz"; + const FIXTURE_TIMEOUT: Duration = Duration::from_secs(10); + const MAX_CONNECTIONS: usize = 8; + + #[derive(Clone, Copy)] + enum ConnectReply { + Tunnel, + AuthenticationRequired, + Refused, + Truncated, + } + + #[derive(Clone, Copy)] + enum SocksAuth { + None, + Password, + RejectPassword, + } + + #[derive(Clone, Copy)] + enum ProxyKind { + Connect(ConnectReply), + Socks4, + Socks5 { auth: SocksAuth, refuse: bool }, + Close, + } + + struct ConnectObserved { + authority: String, + proxy_authorizations: usize, + expected_basic: bool, + client_user_agents: usize, + expected_client_user_agent: bool, + origin_authorizations: usize, + origin_bearer_seen: bool, + } + + #[derive(Debug, PartialEq)] + enum SocksAddress { + Ipv4(Ipv4Addr), + Ipv6(Ipv6Addr), + Hostname(String), + } + + struct SocksObserved { + version: u8, + address: Option, + port: Option, + authenticated: bool, + expected_credentials: bool, + origin_bearer_seen: bool, + } + + #[derive(Default)] + struct ProxyObserved { + tcp_connections: AtomicUsize, + tls_connections: AtomicUsize, + no_proxy_alpn: AtomicUsize, + http1_proxy_alpn: AtomicUsize, + fixture_errors: AtomicUsize, + connect: Mutex>, + socks: Mutex>, + } + + // The fixture never connects to the requested hostname/address. Its only + // upstream socket is this already-owned origin, including for .invalid names. + struct FakeProxy { + address: SocketAddr, + observed: Arc, + task: JoinHandle<()>, + } + + impl Drop for FakeProxy { + fn drop(&mut self) { + // Dropping the accept task also drops its JoinSet and aborts every + // handshake/forwarder. No collector or tunnel task is detached. + self.task.abort(); + } + } + + trait ProxyIo: AsyncRead + AsyncWrite + Unpin + Send {} + impl ProxyIo for T {} + + fn invalid_handshake() -> io::Error { + io::Error::new( + io::ErrorKind::InvalidData, + "invalid synthetic proxy handshake", + ) + } + + fn origin_address(origin: &TlsServer) -> SocketAddr { + let uri: http::Uri = origin.url.parse().unwrap(); + SocketAddr::new( + uri.host().unwrap().parse().unwrap(), + uri.port_u16().unwrap(), + ) + } + + fn origin_url(origin: &TlsServer, hostname: &str) -> String { + format!("https://{hostname}:{}", origin_address(origin).port()) + } + + fn proxy_tls_acceptor() -> tokio_rustls::TlsAcceptor { + let certificates = CertificateDer::pem_slice_iter(CERT) + .collect::, _>>() + .unwrap(); + let key = PrivateKeyDer::from_pem_slice(KEY).unwrap(); + let mut config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certificates, key) + .unwrap(); + // The outer CONNECT carrier deliberately cannot negotiate h2. Origin + // TLS, inside this channel, must still independently negotiate h1/h2. + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + tokio_rustls::TlsAcceptor::from(Arc::new(config)) + } + + impl FakeProxy { + async fn start( + origin: &TlsServer, + bind: &str, + tls: bool, + kind: ProxyKind, + ) -> io::Result { + let target = origin_address(origin); + assert!(target.ip().is_loopback()); + let listener = tokio::net::TcpListener::bind(bind).await?; + let address = listener.local_addr()?; + assert!(address.ip().is_loopback()); + let acceptor = tls.then(proxy_tls_acceptor); + let observed = Arc::new(ProxyObserved::default()); + let captures = observed.clone(); + let task = tokio::spawn(async move { + let mut connections = JoinSet::new(); + loop { + tokio::select! { + accepted = listener.accept() => { + let Ok((socket, _)) = accepted else { + captures.fixture_errors.fetch_add(1, Ordering::SeqCst); + break; + }; + let number = captures.tcp_connections.fetch_add(1, Ordering::SeqCst); + if number >= MAX_CONNECTIONS { + captures.fixture_errors.fetch_add(1, Ordering::SeqCst); + drop(socket); + break; + } + let captures = captures.clone(); + let acceptor = acceptor.clone(); + connections.spawn(async move { + let connection = async { + if matches!(kind, ProxyKind::Close) { + drop(socket); + return Ok::<_, io::Error>(()); + } + let mut stream: Box = if let Some(acceptor) = acceptor { + // Certificate rejection by the client is an expected + // outcome in negative proxy-TLS cases. + let Ok(tls) = acceptor.accept(socket).await else { + return Ok(()); + }; + captures.tls_connections.fetch_add(1, Ordering::SeqCst); + if tls.get_ref().1.alpn_protocol().is_none() { + captures.no_proxy_alpn.fetch_add(1, Ordering::SeqCst); + } + if tls.get_ref().1.alpn_protocol() == Some(b"http/1.1".as_slice()) { + captures.http1_proxy_alpn.fetch_add(1, Ordering::SeqCst); + } + Box::new(tls) + } else { + Box::new(socket) + }; + let forward = match kind { + ProxyKind::Connect(reply) => { + connect_handshake(&mut stream, &captures, reply).await? + } + ProxyKind::Socks4 => { + socks4_handshake(&mut stream, &captures, target.port()).await? + } + ProxyKind::Socks5 { auth, refuse } => { + socks5_handshake(&mut stream, &captures, target.port(), auth, refuse).await? + } + ProxyKind::Close => unreachable!(), + }; + if forward { + let mut upstream = tokio::net::TcpStream::connect(target).await?; + // Closure/reset after failed origin TLS is expected. + let _ = tokio::io::copy_bidirectional(&mut stream, &mut upstream).await; + } else { + stream.shutdown().await?; + } + Ok(()) + }; + if !matches!(tokio::time::timeout(FIXTURE_TIMEOUT, connection).await, Ok(Ok(()))) { + captures.fixture_errors.fetch_add(1, Ordering::SeqCst); + } + }); + } + Some(result) = connections.join_next(), if !connections.is_empty() => { + if result.is_err() { + captures.fixture_errors.fetch_add(1, Ordering::SeqCst); + } + } + } + } + }); + Ok(Self { + address, + observed, + task, + }) + } + + fn url(&self, scheme: &str, credentials: bool) -> String { + format!( + "{scheme}://{}{address}", + if credentials { "user:pass@" } else { "" }, + address = self.address + ) + } + + fn assert_connections(&self, expected: usize) { + assert_eq!( + self.observed.tcp_connections.load(Ordering::SeqCst), + expected, + "one selected-proxy connection per generation; no handshake retry" + ); + assert_eq!( + self.observed.fixture_errors.load(Ordering::SeqCst), + 0, + "bounded fixture parsing/forwarding must complete without errors" + ); + } + + fn assert_connect(&self, authority: &str, count: usize, credentials: bool) { + self.assert_connections(count); + let observed = self.observed.connect.lock().unwrap(); + assert_eq!(observed.len(), count); + for request in observed.iter() { + assert_eq!(request.authority, authority); + assert_eq!(request.proxy_authorizations, usize::from(credentials)); + assert_eq!(request.expected_basic, credentials); + assert_eq!( + request.client_user_agents, 1, + "CONNECT preserves the measured legacy client UA" + ); + assert!( + request.expected_client_user_agent, + "CONNECT must not use the native per-request override" + ); + assert_eq!( + request.origin_authorizations, 0, + "origin Authorization must never appear on outer CONNECT" + ); + assert!(!request.origin_bearer_seen); + } + } + } + + async fn read_nul_string( + stream: &mut S, + limit: usize, + ) -> io::Result> { + let mut value = Vec::new(); + for _ in 0..=limit { + let byte = stream.read_u8().await?; + if byte == 0 { + return Ok(value); + } + if value.len() == limit { + return Err(invalid_handshake()); + } + value.push(byte); + } + Err(invalid_handshake()) + } + + async fn connect_handshake( + stream: &mut S, + captures: &ProxyObserved, + reply: ConnectReply, + ) -> io::Result { + let mut bytes = Vec::new(); + // Read exactly through the terminator, never consuming inner TLS bytes. + while bytes.len() < 8192 && !bytes.ends_with(b"\r\n\r\n") { + bytes.push(stream.read_u8().await?); + } + if !bytes.ends_with(b"\r\n\r\n") { + return Err(invalid_handshake()); + } + let text = std::str::from_utf8(&bytes).map_err(|_| invalid_handshake())?; + let mut lines = text.split("\r\n"); + let mut request_line = lines + .next() + .ok_or_else(invalid_handshake)? + .split_whitespace(); + if request_line.next() != Some("CONNECT") { + return Err(invalid_handshake()); + } + let authority = request_line.next().ok_or_else(invalid_handshake)?; + if authority.len() > 256 + || request_line.next() != Some("HTTP/1.1") + || request_line.next().is_some() + { + return Err(invalid_handshake()); + } + let mut request = ConnectObserved { + authority: authority.to_owned(), + proxy_authorizations: 0, + expected_basic: false, + client_user_agents: 0, + expected_client_user_agent: false, + origin_authorizations: 0, + origin_bearer_seen: text.contains("public-test-access"), + }; + for line in lines.filter(|line| !line.is_empty()) { + let (name, value) = line.split_once(':').ok_or_else(invalid_handshake)?; + if name.eq_ignore_ascii_case("proxy-authorization") { + request.proxy_authorizations += 1; + request.expected_basic = value.trim() == BASIC; + } + if name.eq_ignore_ascii_case("user-agent") { + request.client_user_agents += 1; + request.expected_client_user_agent = + value.trim() == concat!("claude-code-proxy/", env!("CARGO_PKG_VERSION")); + } + if name.eq_ignore_ascii_case("authorization") { + request.origin_authorizations += 1; + } + } + let legacy_ua = request.client_user_agents == 1 && request.expected_client_user_agent; + { + let mut observed = captures.connect.lock().unwrap(); + if observed.len() >= MAX_CONNECTIONS { + return Err(invalid_handshake()); + } + observed.push(request); + } + let response: &[u8] = match reply { + // A strict proxy policy justified by the unchanged legacy-builder + // runtime probe, not an invented official client fingerprint. + ConnectReply::Tunnel if legacy_ua => b"HTTP/1.1 200 Connection Established\r\n\r\n", + ConnectReply::Tunnel => b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n", + ConnectReply::AuthenticationRequired => b"HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"fixture\"\r\nContent-Length: 14\r\n\r\nDO_NOT_REFLECT", + ConnectReply::Refused => b"HTTP/1.1 403 Forbidden\r\nContent-Length: 14\r\n\r\nDO_NOT_REFLECT", + ConnectReply::Truncated => b"HTTP/1.1 200 Connection Established\r\nX-Fixture: DO_NOT_REFLECT", + }; + stream.write_all(response).await?; + Ok(matches!(reply, ConnectReply::Tunnel) && legacy_ua) + } + + async fn socks4_handshake( + stream: &mut S, + captures: &ProxyObserved, + target_port: u16, + ) -> io::Result { + let mut header = [0; 8]; + stream.read_exact(&mut header).await?; + if header[0] != 4 || header[1] != 1 { + return Err(invalid_handshake()); + } + let port = u16::from_be_bytes([header[2], header[3]]); + let user = read_nul_string(stream, 255).await?; + let address = if header[4..7] == [0, 0, 0] && header[7] != 0 { + let hostname = read_nul_string(stream, 255).await?; + SocksAddress::Hostname(String::from_utf8(hostname).map_err(|_| invalid_handshake())?) + } else { + SocksAddress::Ipv4(Ipv4Addr::new(header[4], header[5], header[6], header[7])) + }; + if port != target_port { + return Err(invalid_handshake()); + } + let valid_domain = + !matches!(&address, SocksAddress::Hostname(hostname) if hostname.is_empty()); + captures.socks.lock().unwrap().push(SocksObserved { + version: 4, + address: Some(address), + port: Some(port), + authenticated: !user.is_empty(), + expected_credentials: false, + origin_bearer_seen: user + .windows(b"public-test-access".len()) + .any(|window| window == b"public-test-access"), + }); + stream + .write_all(&[ + 0, + if valid_domain { 90 } else { 91 }, + header[2], + header[3], + 127, + 0, + 0, + 1, + ]) + .await?; + Ok(valid_domain) + } + + async fn socks5_handshake( + stream: &mut S, + captures: &ProxyObserved, + target_port: u16, + auth: SocksAuth, + refuse: bool, + ) -> io::Result { + let version = stream.read_u8().await?; + let count = usize::from(stream.read_u8().await?); + if version != 5 || count == 0 || count > 16 { + return Err(invalid_handshake()); + } + let mut methods = vec![0; count]; + stream.read_exact(&mut methods).await?; + let authenticated = !matches!(auth, SocksAuth::None); + let method = if authenticated { 2 } else { 0 }; + if !methods.contains(&method) { + return Err(invalid_handshake()); + } + stream.write_all(&[5, method]).await?; + let mut expected_credentials = false; + let mut origin_bearer_seen = false; + if authenticated { + if stream.read_u8().await? != 1 { + return Err(invalid_handshake()); + } + let length = usize::from(stream.read_u8().await?); + let mut user = vec![0; length]; + stream.read_exact(&mut user).await?; + let length = usize::from(stream.read_u8().await?); + let mut password = vec![0; length]; + stream.read_exact(&mut password).await?; + expected_credentials = user == b"user" && password == b"pass"; + origin_bearer_seen = [&user, &password].iter().any(|value| { + value + .windows(b"public-test-access".len()) + .any(|window| window == b"public-test-access") + }); + let reject = matches!(auth, SocksAuth::RejectPassword); + stream.write_all(&[1, u8::from(reject)]).await?; + if reject { + captures.socks.lock().unwrap().push(SocksObserved { + version: 5, + address: None, + port: None, + authenticated, + expected_credentials, + origin_bearer_seen, + }); + return Ok(false); + } + } + let mut header = [0; 4]; + stream.read_exact(&mut header).await?; + if header[..3] != [5, 1, 0] { + return Err(invalid_handshake()); + } + let address = match header[3] { + 1 => { + let mut address = [0; 4]; + stream.read_exact(&mut address).await?; + SocksAddress::Ipv4(Ipv4Addr::from(address)) + } + 3 => { + let length = usize::from(stream.read_u8().await?); + if length == 0 { + return Err(invalid_handshake()); + } + let mut hostname = vec![0; length]; + stream.read_exact(&mut hostname).await?; + SocksAddress::Hostname( + String::from_utf8(hostname).map_err(|_| invalid_handshake())?, + ) + } + 4 => { + let mut address = [0; 16]; + stream.read_exact(&mut address).await?; + SocksAddress::Ipv6(Ipv6Addr::from(address)) + } + _ => return Err(invalid_handshake()), + }; + let port = stream.read_u16().await?; + if port != target_port { + return Err(invalid_handshake()); + } + captures.socks.lock().unwrap().push(SocksObserved { + version: 5, + address: Some(address), + port: Some(port), + authenticated, + expected_credentials, + origin_bearer_seen, + }); + stream + .write_all(&[5, if refuse { 5 } else { 0 }, 0, 1, 127, 0, 0, 1, 0, 0]) + .await?; + Ok(!refuse) + } + + async fn proxied_child( + origin: &TlsServer, + proxy_url: &str, + case: &'static str, + stream: bool, + base_url: &str, + ) { + child_configured( + origin, + case, + stream, + base_url, + &[ + ("HTTPS_PROXY", proxy_url), + ("NO_PROXY", ""), + ("no_proxy", ""), + ], + ) + .await; + } + + fn assert_origin(origin: &TlsServer, protocol: Version, expected: usize) { + let observed = origin.observed.lock().unwrap(); + assert_eq!( + observed.len(), + expected, + "no extra generation requests or direct fallback" + ); + for request in observed.iter() { + assert_eq!(request.version, protocol); + assert_eq!(request.headers.get_all("authorization").iter().count(), 1); + assert_eq!( + request.headers["authorization"], + "Bearer public-test-access" + ); + assert!( + !request.headers.contains_key("proxy-authorization"), + "proxy credentials must stay outside origin TLS" + ); + assert!(!request.headers.contains_key("proxy-connection")); + assert!(!request.headers.values().any(|value| { + let value = value.to_str().unwrap_or(""); + value.contains("dXNlcjpwYXNz") || value.contains("user:pass") + })); + } + } + + async fn connect_matrix(protocol: Version, tls: bool) { + let origin = TlsServer::start(protocol, 200, None, success()).await; + let proxy = FakeProxy::start( + &origin, + "127.0.0.1:0", + tls, + ProxyKind::Connect(ConnectReply::Tunnel), + ) + .await + .unwrap(); + let base_url = origin_url(&origin, "fixture.invalid"); + let proxy_url = proxy.url(if tls { "https" } else { "http" }, true); + for stream in [false, true] { + proxied_child(&origin, &proxy_url, "native-ua-override", stream, &base_url).await; + } + proxy.assert_connect( + &format!("fixture.invalid:{}", origin_address(&origin).port()), + 2, + true, + ); + if tls { + assert_eq!(proxy.observed.tls_connections.load(Ordering::SeqCst), 2); + assert_eq!( + proxy.observed.no_proxy_alpn.load(Ordering::SeqCst), + 2, + "baseline clears proxy ALPN; successful parsed CONNECT still proves HTTP/1.1" + ); + assert_eq!(proxy.observed.http1_proxy_alpn.load(Ordering::SeqCst), 0); + } + assert_origin(&origin, protocol, 2); + for request in origin.observed.lock().unwrap().iter() { + assert_eq!(request.headers["user-agent"], "public-native-override"); + } + } + + #[tokio::test] + async fn antigravity_transport_proxy_http_connect_origin_http1() { + connect_matrix(Version::HTTP_11, false).await; + } + + #[tokio::test] + async fn antigravity_transport_proxy_http_connect_origin_http2() { + connect_matrix(Version::HTTP_2, false).await; + } + + #[tokio::test] + async fn antigravity_transport_proxy_https_connect_origin_http1() { + connect_matrix(Version::HTTP_11, true).await; + } + + #[tokio::test] + async fn antigravity_transport_proxy_https_connect_origin_http2() { + connect_matrix(Version::HTTP_2, true).await; + } + + #[tokio::test] + async fn antigravity_transport_proxy_connect_rejections_do_not_retry_or_fall_back() { + for tls in [false, true] { + for reply in [ + ConnectReply::AuthenticationRequired, + ConnectReply::Refused, + ConnectReply::Truncated, + ] { + let origin = TlsServer::start(Version::HTTP_11, 200, None, success()).await; + let proxy = + FakeProxy::start(&origin, "127.0.0.1:0", tls, ProxyKind::Connect(reply)) + .await + .unwrap(); + let proxy_url = proxy.url(if tls { "https" } else { "http" }, true); + for stream in [false, true] { + proxied_child( + &origin, + &proxy_url, + "preheader-failure", + stream, + &origin.url, + ) + .await; + } + proxy.assert_connect(&origin_address(&origin).to_string(), 2, true); + assert_origin(&origin, Version::HTTP_11, 0); + } + } + } + + #[tokio::test] + async fn antigravity_transport_tls_untrusted_origin_ca_is_rejected() { + let origin = TlsServer::start(Version::HTTP_11, 200, None, success()).await; + for stream in [false, true] { + child(&origin, "untrusted-ca", stream).await; + } + assert_origin(&origin, Version::HTTP_11, 0); + } + + #[tokio::test] + async fn antigravity_transport_proxy_connect_verifies_origin_hostname_and_ca() { + for (wrong_hostname, proxy_tls) in [(false, false), (true, false), (true, true)] { + let origin = TlsServer::start(Version::HTTP_2, 200, None, success()).await; + let proxy = FakeProxy::start( + &origin, + "127.0.0.1:0", + proxy_tls, + ProxyKind::Connect(ConnectReply::Tunnel), + ) + .await + .unwrap(); + let hostname = if wrong_hostname { + "wrong-san.invalid" + } else { + "fixture.invalid" + }; + let base_url = origin_url(&origin, hostname); + let proxy_url = proxy.url(if proxy_tls { "https" } else { "http" }, true); + for stream in [false, true] { + proxied_child( + &origin, + &proxy_url, + if wrong_hostname { + "preheader-failure" + } else { + "untrusted-ca" + }, + stream, + &base_url, + ) + .await; + } + proxy.assert_connect( + &format!("{hostname}:{}", origin_address(&origin).port()), + 2, + true, + ); + if proxy_tls { + assert_eq!(proxy.observed.tls_connections.load(Ordering::SeqCst), 2); + } + assert_origin(&origin, Version::HTTP_2, 0); + } + } + + #[tokio::test] + async fn antigravity_transport_proxy_https_rejects_untrusted_ca_before_connect() { + let origin = TlsServer::start(Version::HTTP_2, 200, None, success()).await; + let proxy = FakeProxy::start( + &origin, + "127.0.0.1:0", + true, + ProxyKind::Connect(ConnectReply::Tunnel), + ) + .await + .unwrap(); + let proxy_url = proxy.url("https", true); + for stream in [false, true] { + proxied_child(&origin, &proxy_url, "untrusted-ca", stream, &origin.url).await; + } + proxy.assert_connections(2); + assert!(proxy.observed.connect.lock().unwrap().is_empty()); + assert_eq!(proxy.observed.tls_connections.load(Ordering::SeqCst), 0); + assert_origin(&origin, Version::HTTP_2, 0); + } + + #[tokio::test] + async fn antigravity_transport_proxy_https_rejects_wrong_proxy_san_before_connect() { + let origin = TlsServer::start(Version::HTTP_11, 200, None, success()).await; + let proxy = match FakeProxy::start( + &origin, + "127.0.0.2:0", + true, + ProxyKind::Connect(ConnectReply::Tunnel), + ) + .await + { + Ok(proxy) => proxy, + Err(error) + if matches!( + error.kind(), + io::ErrorKind::AddrNotAvailable | io::ErrorKind::Unsupported + ) => + { + eprintln!("SKIP_IPV4_LOOPBACK_ALIAS_UNAVAILABLE"); + return; + } + Err(_) => panic!("synthetic IPv4 loopback alias fixture failed"), + }; + let proxy_url = proxy.url("https", true); + for stream in [false, true] { + proxied_child( + &origin, + &proxy_url, + "preheader-failure", + stream, + &origin.url, + ) + .await; + } + proxy.assert_connections(2); + assert!(proxy.observed.connect.lock().unwrap().is_empty()); + assert_eq!(proxy.observed.tls_connections.load(Ordering::SeqCst), 0); + assert_origin(&origin, Version::HTTP_11, 0); + } + + #[tokio::test] + async fn antigravity_transport_proxy_https_failed_handshake_does_not_fall_back() { + let origin = TlsServer::start(Version::HTTP_11, 200, None, success()).await; + let proxy = FakeProxy::start(&origin, "127.0.0.1:0", false, ProxyKind::Close) + .await + .unwrap(); + let proxy_url = proxy.url("https", true); + for stream in [false, true] { + proxied_child( + &origin, + &proxy_url, + "preheader-failure", + stream, + &origin.url, + ) + .await; + } + proxy.assert_connections(2); + assert!(proxy.observed.connect.lock().unwrap().is_empty()); + assert_origin(&origin, Version::HTTP_11, 0); + } + + async fn positive_socks5_matrix(scheme: &str, remote_dns: bool, authenticated: bool) { + for protocol in [Version::HTTP_11, Version::HTTP_2] { + let origin = TlsServer::start(protocol, 200, None, success()).await; + let kind = ProxyKind::Socks5 { + auth: if authenticated { + SocksAuth::Password + } else { + SocksAuth::None + }, + refuse: false, + }; + let proxy = FakeProxy::start(&origin, "127.0.0.1:0", false, kind) + .await + .unwrap(); + // localhost must actually resolve before the local-DNS handshake; + // fixture.invalid cannot resolve and must remain a remote hostname. + let base_url = origin_url( + &origin, + if remote_dns { + "fixture.invalid" + } else { + "localhost" + }, + ); + let proxy_url = proxy.url(scheme, authenticated); + for stream in [false, true] { + proxied_child(&origin, &proxy_url, "plain", stream, &base_url).await; + } + proxy.assert_connections(2); + let observed = proxy.observed.socks.lock().unwrap(); + assert_eq!(observed.len(), 2); + for request in observed.iter() { + assert_eq!(request.version, 5); + assert_eq!(request.port, Some(origin_address(&origin).port())); + let expected = if remote_dns { + SocksAddress::Hostname("fixture.invalid".into()) + } else { + // Only a representable local IPv4 result is supported by + // the cached Uri-based helper; literal IPv6 is separate. + SocksAddress::Ipv4(Ipv4Addr::LOCALHOST) + }; + assert_eq!( + request.address.as_ref(), + Some(&expected), + "assert actual local-IP versus remote-hostname address form" + ); + assert_eq!(request.authenticated, authenticated); + assert_eq!(request.expected_credentials, authenticated); + assert!(!request.origin_bearer_seen); + } + assert_origin(&origin, protocol, 2); + } + } + + async fn strict_socks4_legacy_failure(scheme: &str, remote_dns: bool) { + // NEGATIVE compatibility rows only. The unchanged legacy builder probe + // proved the cached helper's extra NUL; never forgive/drop that byte in + // this strict proxy or advertise working strict SOCKS4/4a support. + for protocol in [Version::HTTP_11, Version::HTTP_2] { + let origin = TlsServer::start(protocol, 200, None, success()).await; + let proxy = FakeProxy::start(&origin, "127.0.0.1:0", false, ProxyKind::Socks4) + .await + .unwrap(); + let base_url = origin_url( + &origin, + if remote_dns { + "fixture.invalid" + } else { + "localhost" + }, + ); + let proxy_url = proxy.url(scheme, false); + for stream in [false, true] { + proxied_child(&origin, &proxy_url, "preheader-failure", stream, &base_url).await; + } + proxy.assert_connections(2); + let observed = proxy.observed.socks.lock().unwrap(); + assert_eq!(observed.len(), 2); + for request in observed.iter() { + assert_eq!(request.version, 4); + assert_eq!(request.port, Some(origin_address(&origin).port())); + assert!( + !request.authenticated + && !request.expected_credentials + && !request.origin_bearer_seen + ); + let expected = if remote_dns { + SocksAddress::Hostname(String::new()) + } else { + SocksAddress::Ipv4(Ipv4Addr::LOCALHOST) + }; + assert_eq!( + request.address.as_ref(), + Some(&expected), + "preserve observed legacy framing failure, not a repaired protocol" + ); + } + assert_origin(&origin, protocol, 0); + eprintln!("INHERITED_STRICT_SOCKS4_INCOMPATIBILITY_NOT_POSITIVE_SUPPORT"); + } + } + + #[tokio::test] + async fn antigravity_transport_proxy_socks4_strict_rfc_failure_preserves_legacy_framing() { + strict_socks4_legacy_failure("socks4", false).await; + } + + #[tokio::test] + async fn antigravity_transport_proxy_socks4a_strict_rfc_failure_preserves_legacy_framing() { + strict_socks4_legacy_failure("socks4a", true).await; + } + + #[tokio::test] + async fn antigravity_transport_proxy_socks5_local_dns_and_password_auth() { + positive_socks5_matrix("socks5", false, true).await; + } + + #[tokio::test] + async fn antigravity_transport_proxy_socks5h_remote_dns_and_password_auth() { + positive_socks5_matrix("socks5h", true, true).await; + } + + #[tokio::test] + async fn antigravity_transport_proxy_socks5h_no_auth_is_supported() { + positive_socks5_matrix("socks5h", true, false).await; + } + + #[tokio::test] + async fn antigravity_transport_proxy_socks5_auth_and_connect_rejections_do_not_fall_back() { + for (auth, refuse) in [ + (SocksAuth::RejectPassword, false), + (SocksAuth::Password, true), + ] { + let origin = TlsServer::start(Version::HTTP_11, 200, None, success()).await; + let proxy = FakeProxy::start( + &origin, + "127.0.0.1:0", + false, + ProxyKind::Socks5 { auth, refuse }, + ) + .await + .unwrap(); + let proxy_url = proxy.url("socks5h", true); + let base_url = origin_url(&origin, "fixture.invalid"); + for stream in [false, true] { + proxied_child(&origin, &proxy_url, "preheader-failure", stream, &base_url).await; + } + proxy.assert_connections(2); + let observed = proxy.observed.socks.lock().unwrap(); + assert_eq!(observed.len(), 2); + for request in observed.iter() { + assert!(request.authenticated); + assert!(request.expected_credentials); + assert!(!request.origin_bearer_seen); + if refuse { + assert_eq!( + request.address, + Some(SocksAddress::Hostname("fixture.invalid".into())) + ); + assert_eq!(request.port, Some(origin_address(&origin).port())); + } else { + assert!( + request.address.is_none(), + "rejected password must prevent CONNECT" + ); + assert!(request.port.is_none()); + } + } + assert_origin(&origin, Version::HTTP_11, 0); + } + } + + #[tokio::test] + async fn antigravity_transport_proxy_ipv6_tcp_loopback_is_separate_from_destination_support() { + for (scheme, kind) in [ + ("http", ProxyKind::Connect(ConnectReply::Tunnel)), + // Strict SOCKS4a is an explicit negative legacy-compatibility row, + // not part of positive IPv6 proxy-TCP support. + ( + "socks5h", + ProxyKind::Socks5 { + auth: SocksAuth::Password, + refuse: false, + }, + ), + ] { + let origin = TlsServer::start(Version::HTTP_2, 200, None, success()).await; + let proxy = match FakeProxy::start(&origin, "[::1]:0", false, kind).await { + Ok(proxy) => proxy, + Err(error) + if matches!( + error.kind(), + io::ErrorKind::AddrNotAvailable | io::ErrorKind::Unsupported + ) => + { + // A finite, explicit coverage skip, never universal IPv6 PASS. + eprintln!("SKIP_IPV6_LOOPBACK_UNAVAILABLE"); + return; + } + Err(_) => panic!("synthetic IPv6 proxy loopback fixture failed"), + }; + let credentials = true; + let proxy_url = proxy.url(scheme, credentials); + assert!(proxy_url.contains("[::1]:")); + let base_url = origin_url(&origin, "fixture.invalid"); + for stream in [false, true] { + proxied_child(&origin, &proxy_url, "plain", stream, &base_url).await; + } + proxy.assert_connections(2); + if scheme == "http" { + proxy.assert_connect( + &format!("fixture.invalid:{}", origin_address(&origin).port()), + 2, + true, + ); + } else { + let observed = proxy.observed.socks.lock().unwrap(); + assert_eq!(observed.len(), 2); + for request in observed.iter() { + assert_eq!( + request.address, + Some(SocksAddress::Hostname("fixture.invalid".into())) + ); + assert_eq!(request.port, Some(origin_address(&origin).port())); + assert_eq!(request.authenticated, credentials); + assert_eq!(request.expected_credentials, credentials); + assert!(!request.origin_bearer_seen); + } + } + assert_origin(&origin, Version::HTTP_2, 2); + } + } + + #[tokio::test] + async fn antigravity_transport_proxy_no_proxy_wildcard_ip_and_cidr_bypass() { + // Cached baseline wildcard is a domain matcher, not an IP matcher. + // Exercise domain wildcard separately from literal-IP and CIDR bypass. + for (bypass, hostname) in [ + ("*", "localhost"), + ("127.0.0.1", "127.0.0.1"), + ("127.0.0.0/8", "127.0.0.1"), + ] { + let origin = TlsServer::start(Version::HTTP_11, 200, None, success()).await; + let rejected = FakeProxy::start( + &origin, + "127.0.0.1:0", + false, + ProxyKind::Connect(ConnectReply::AuthenticationRequired), + ) + .await + .unwrap(); + let proxy_url = rejected.url("http", true); + let base_url = origin_url(&origin, hostname); + for stream in [false, true] { + child_configured( + &origin, + "plain", + stream, + &base_url, + &[ + ("HTTPS_PROXY", &proxy_url), + ("NO_PROXY", bypass), + ("no_proxy", bypass), + ], + ) + .await; + } + rejected.assert_connections(0); + assert_origin(&origin, Version::HTTP_11, 2); + } + } + + #[tokio::test] + async fn antigravity_transport_proxy_wildcard_ip_literal_preserves_cached_baseline() { + // Do not silently broaden the inherited matcher into a direct-route + // privacy change: '*' currently matches domains, not IP literals. + let origin = TlsServer::start(Version::HTTP_11, 200, None, success()).await; + let proxy = FakeProxy::start( + &origin, + "127.0.0.1:0", + false, + ProxyKind::Connect(ConnectReply::Tunnel), + ) + .await + .unwrap(); + let proxy_url = proxy.url("http", true); + for stream in [false, true] { + child_configured( + &origin, + "plain", + stream, + &origin.url, + &[ + ("HTTPS_PROXY", &proxy_url), + ("NO_PROXY", "*"), + ("no_proxy", "*"), + ], + ) + .await; + } + proxy.assert_connect(&origin_address(&origin).to_string(), 2, true); + assert_origin(&origin, Version::HTTP_11, 2); + } + + #[tokio::test] + async fn antigravity_transport_proxy_uppercase_https_overrides_lowercase() { + let origin = TlsServer::start(Version::HTTP_2, 200, None, success()).await; + let selected = FakeProxy::start( + &origin, + "127.0.0.1:0", + false, + ProxyKind::Connect(ConnectReply::Tunnel), + ) + .await + .unwrap(); + let rejected = FakeProxy::start( + &origin, + "127.0.0.1:0", + false, + ProxyKind::Connect(ConnectReply::AuthenticationRequired), + ) + .await + .unwrap(); + let selected_url = selected.url("http", true); + let rejected_url = rejected.url("http", true); + for stream in [false, true] { + child_configured( + &origin, + "plain", + stream, + &origin.url, + &[ + ("HTTPS_PROXY", &selected_url), + ("https_proxy", &rejected_url), + ("NO_PROXY", ""), + ("no_proxy", ""), + ], + ) + .await; + } + selected.assert_connect(&origin_address(&origin).to_string(), 2, true); + rejected.assert_connections(0); + assert_origin(&origin, Version::HTTP_2, 2); + } + + #[tokio::test] + async fn antigravity_transport_proxy_https_specific_precedes_all_proxy() { + let origin = TlsServer::start(Version::HTTP_11, 200, None, success()).await; + let selected = FakeProxy::start( + &origin, + "127.0.0.1:0", + false, + ProxyKind::Connect(ConnectReply::Tunnel), + ) + .await + .unwrap(); + let rejected = FakeProxy::start( + &origin, + "127.0.0.1:0", + false, + ProxyKind::Connect(ConnectReply::AuthenticationRequired), + ) + .await + .unwrap(); + let selected_url = selected.url("http", true); + let rejected_url = rejected.url("http", true); + for stream in [false, true] { + child_configured( + &origin, + "plain", + stream, + &origin.url, + &[ + ("HTTPS_PROXY", &selected_url), + ("ALL_PROXY", &rejected_url), + ("NO_PROXY", ""), + ("no_proxy", ""), + ], + ) + .await; + } + selected.assert_connect(&origin_address(&origin).to_string(), 2, true); + rejected.assert_connections(0); + assert_origin(&origin, Version::HTTP_11, 2); + } + + #[tokio::test] + async fn antigravity_transport_proxy_uppercase_no_proxy_even_empty_precedes_lowercase() { + // Cached Matcher::get_first_env returns the first present variable, + // including an explicitly empty uppercase value. + for (uppercase, lowercase, proxy_requests) in [("", "*", 2), ("*", "", 0)] { + let origin = TlsServer::start(Version::HTTP_11, 200, None, success()).await; + let selected = FakeProxy::start( + &origin, + "127.0.0.1:0", + false, + ProxyKind::Connect(ConnectReply::Tunnel), + ) + .await + .unwrap(); + let selected_url = selected.url("http", true); + let base_url = origin_url(&origin, "localhost"); + for stream in [false, true] { + child_configured( + &origin, + "plain", + stream, + &base_url, + &[ + ("HTTPS_PROXY", &selected_url), + ("NO_PROXY", uppercase), + ("no_proxy", lowercase), + ], + ) + .await; + } + selected.assert_connect( + &format!("localhost:{}", origin_address(&origin).port()), + proxy_requests, + true, + ); + assert_origin(&origin, Version::HTTP_11, 2); + } + } + + #[tokio::test] + async fn antigravity_transport_proxy_uppercase_all_precedence_and_cgi_bypass() { + for cgi in [false, true] { + for https_specific in [false, true] { + let origin = TlsServer::start(Version::HTTP_11, 200, None, success()).await; + let selected = FakeProxy::start( + &origin, + "127.0.0.1:0", + false, + ProxyKind::Connect(ConnectReply::Tunnel), + ) + .await + .unwrap(); + let rejected = FakeProxy::start( + &origin, + "127.0.0.1:0", + false, + ProxyKind::Connect(ConnectReply::AuthenticationRequired), + ) + .await + .unwrap(); + let selected_url = selected.url("http", true); + let rejected_url = rejected.url("http", true); + let mut env = vec![ + ("HTTP_PROXY", rejected_url.as_str()), + ("ALL_PROXY", selected_url.as_str()), + ("all_proxy", rejected_url.as_str()), + ("NO_PROXY", ""), + ("no_proxy", ""), + ]; + if https_specific { + env.push(("HTTPS_PROXY", selected_url.as_str())); + } + if cgi { + env.push(("REQUEST_METHOD", "GET")); + } + for stream in [false, true] { + child_configured(&origin, "plain", stream, &origin.url, &env).await; + } + // Cached Linux Matcher::from_system disables every proxy in + // CGI, not just HTTP_PROXY. This is source-backed compatibility, + // not an assumption about Go or other proxy implementations. + selected.assert_connect( + &origin_address(&origin).to_string(), + if cgi { 0 } else { 2 }, + true, + ); + rejected.assert_connections(0); + assert_origin(&origin, Version::HTTP_11, 2); + } + } + } + + #[tokio::test] + async fn antigravity_transport_proxy_http_environment_is_not_an_https_fallback_even_in_cgi() { + // The release public API accepts only HTTPS origins: this exercises CGI + // handling without claiming coverage of cleartext HTTP CGI destinations. + for cgi in [false, true] { + let origin = TlsServer::start(Version::HTTP_11, 200, None, success()).await; + let rejected = FakeProxy::start( + &origin, + "127.0.0.1:0", + false, + ProxyKind::Connect(ConnectReply::AuthenticationRequired), + ) + .await + .unwrap(); + let rejected_url = rejected.url("http", true); + let mut env = vec![ + ("HTTP_PROXY", rejected_url.as_str()), + ("http_proxy", rejected_url.as_str()), + ("NO_PROXY", ""), + ("no_proxy", ""), + ]; + if cgi { + env.push(("REQUEST_METHOD", "GET")); + } + for stream in [false, true] { + child_configured(&origin, "plain", stream, &origin.url, &env).await; + } + rejected.assert_connections(0); + assert_origin(&origin, Version::HTTP_11, 2); + } + } +} diff --git a/tests/cli.rs b/tests/cli.rs index 6f5f69af..22b2360b 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -100,6 +100,858 @@ fn provider_logout_without_auth_is_success() -> Result<(), Box 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 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, + 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) }; + 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 + ); + } + // 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_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!( + self.child.0.try_wait().unwrap().is_none(), + "CLI exited before establishing terminal session" + ); + assert!( + Instant::now() < deadline, + "CLI did not enter application-managed 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, + ) + } + + // 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_managed_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_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)); + } + + #[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) }; + 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 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")?; @@ -125,31 +977,199 @@ impl Drop for ChildGuard { } } +#[cfg(unix)] +struct ServiceStderr { + file: tempfile::NamedTempFile, + bytes: Vec, +} + +#[cfg(unix)] +impl ServiceStderr { + fn new() -> std::io::Result { + Ok(Self { + file: tempfile::NamedTempFile::new()?, + bytes: Vec::new(), + }) + } + + fn stdio(&self) -> std::io::Result { + // Reopen rather than clone: reading must not move the child's write cursor. + Ok(Stdio::from(self.file.reopen()?)) + } + + fn read(&mut self) -> std::io::Result<()> { + const LIMIT: usize = 64 * 1024; + self.file + .as_file_mut() + .take((LIMIT.saturating_sub(self.bytes.len()) + 1) as u64) + .read_to_end(&mut self.bytes)?; + if self.bytes.len() > LIMIT { + return Err(std::io::Error::other( + "synthetic service stderr exceeded limit", + )); + } + Ok(()) + } + + fn text(&self) -> std::borrow::Cow<'_, str> { + String::from_utf8_lossy(&self.bytes) + } +} + #[cfg(unix)] fn wait_for_service( child: &mut ChildGuard, - port: u16, + stderr: &mut ServiceStderr, ) -> Result> { let deadline = Instant::now() + Duration::from_secs(15); + let mut address = None; loop { - match TcpStream::connect(("127.0.0.1", port)) { - Ok(stream) => return Ok(stream), - Err(error) if Instant::now() < deadline => { - if let Some(status) = child.0.try_wait()? { - let mut stderr = String::new(); - if let Some(mut pipe) = child.0.stderr.take() { - pipe.read_to_string(&mut stderr)?; + stderr.read()?; + if let Some(status) = child.0.try_wait()? { + return Err(format!("service exited with {status}: {}", stderr.text()).into()); + } + if address.is_none() { + // Only this child's post-bind log is evidence of its listener. A TCP + // probe on a released ephemeral port can reach an unrelated fixture. + for line in stderr.bytes.split(|byte| *byte == b'\n') { + let Ok(entry) = serde_json::from_slice::(line) else { + continue; + }; + if entry["service"] != "server" || entry["msg"] != "server listening" { + continue; + } + let ip = entry["fields"]["bindAddress"] + .as_str() + .ok_or("service startup omitted bound address")? + .parse::()?; + if ip != std::net::Ipv4Addr::LOCALHOST { + return Err("synthetic service must bind explicit IPv4 loopback".into()); + } + let port = entry["fields"]["port"] + .as_u64() + .and_then(|port| u16::try_from(port).ok()) + .filter(|port| *port != 0) + .ok_or("service startup omitted actual bound port")?; + address = Some(std::net::SocketAddr::new(ip, port)); + break; + } + } + if let Some(address) = address { + match TcpStream::connect(address) { + Ok(stream) => { + if let Some(status) = child.0.try_wait()? { + stderr.read()?; + return Err( + format!("service exited with {status}: {}", stderr.text()).into() + ); } - return Err(format!("service exited with {status}: {stderr}").into()); + return Ok(stream); } - let _ = error; - thread::sleep(Duration::from_millis(20)); + Err(error) if Instant::now() >= deadline => return Err(error.into()), + Err(_) => {} } - Err(error) => return Err(error.into()), } + if Instant::now() >= deadline { + return Err(format!( + "timed out waiting for owned service startup: {}", + stderr.text() + ) + .into()); + } + thread::sleep(Duration::from_millis(20)); } } +#[cfg(unix)] +#[test] +fn service_readiness_rejects_exited_child_on_foreign_listener() +-> Result<(), Box> { + let foreign = TcpListener::bind("127.0.0.1:0")?; + let mut stderr = ServiceStderr::new()?; + let child = std::process::Command::new("/bin/sh") + .args([ + "-c", + "printf synthetic-service-startup-failure >&2; exit 23", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(stderr.stdio()?) + .spawn()?; + let mut child = ChildGuard(child); + assert_eq!(child.0.wait()?.code(), Some(23)); + + let result = wait_for_service(&mut child, &mut stderr); + assert!( + result.is_err(), + "an unrelated listener must not make an exited service ready" + ); + let error = result.unwrap_err().to_string(); + assert!(error.contains("23"), "{error}"); + assert!( + error.contains("synthetic-service-startup-failure"), + "{error}" + ); + foreign.set_nonblocking(true)?; + assert!( + matches!(foreign.accept(), Err(error) if error.kind() == std::io::ErrorKind::WouldBlock) + ); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn service_readiness_discovers_delayed_owned_ephemeral_listener() +-> Result<(), Box> { + let directory = TempDir::new()?; + let foreign = TcpListener::bind("127.0.0.1:0")?; + let mut stderr = ServiceStderr::new()?; + // Delay only this synthetic child's startup, not the readiness deadline. + let child = std::process::Command::new("/bin/sh") + .args([ + "-c", + "/bin/sleep 0.1; exec \"$@\"", + "synthetic-delayed-service", + env!("CARGO_BIN_EXE_claude-code-proxy"), + "serve", + "--no-monitor", + "--port", + "0", + ]) + .env_clear() + .env("HOME", directory.path()) + .env("CCP_CONFIG_DIR", directory.path()) + .env("XDG_STATE_HOME", directory.path().join("state")) + .env("CCP_BIND_ADDRESS", "127.0.0.1") + .env("CCP_LOG_STDERR", "1") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(stderr.stdio()?) + .spawn()?; + let mut child = ChildGuard(child); + let connection = wait_for_service(&mut child, &mut stderr) + .expect("readiness must discover the delayed child's actual bound port"); + let address = connection.peer_addr()?; + assert!(address.ip().is_loopback()); + assert_ne!(address.port(), 0); + assert_ne!(address.port(), foreign.local_addr()?.port()); + drop(connection); + send_signal(&child, "-TERM")?; + assert!(wait_for_exit(&mut child, Duration::from_secs(5))?.success()); + stderr.read()?; + assert!( + stderr + .text() + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .any(|entry| { + entry["service"] == "server" + && entry["msg"] == "server listening" + && entry["fields"]["port"] == address.port() + }) + ); + Ok(()) +} + #[cfg(unix)] fn send_signal(child: &ChildGuard, signal: &str) -> Result<(), Box> { let status = std::process::Command::new("kill") @@ -178,6 +1198,125 @@ 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 mut stderr = ServiceStderr::new()?; + let child = std::process::Command::new(env!("CARGO_BIN_EXE_claude-code-proxy")) + .args(["serve", "--no-monitor", "--port", "0"]) + .env("CCP_BIND_ADDRESS", "127.0.0.1") + .env("CCP_CONFIG_DIR", directory.path()) + .env("XDG_STATE_HOME", directory.path().join("state")) + .env("CCP_ANTIGRAVITY_ENABLED", enabled) + .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(stderr.stdio()?) + .spawn()?; + let mut child = ChildGuard(child); + let port = wait_for_service(&mut child, &mut stderr)? + .peer_addr()? + .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 + .map_err(|error| { + let capture = stderr.read(); + format!( + "models request failed: {error}; child: {:?}; stderr capture: {capture:?}; stderr: {}", + child.0.try_wait(), + stderr.text() + ) + })?; + assert_eq!(response.status(), 200); + let models: serde_json::Value = response.json().await?; + assert!(!models["data"].as_array().unwrap().iter().any(|entry| { + 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()); + stderr.read()?; + assert!(!stderr.text().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")?; @@ -198,19 +1337,21 @@ fn plain_service_exits_on_second_signal(signal: &str) -> Result<(), Box>>; + + // 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() + } + + #[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_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_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_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_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::<&str>::new(), + ), + ( + raw( + "gpt-6-luna", + r#"{"role":"user","content":[{"type":"text","text":"first","text":"second"}]}"#, + ), + Vec::<&str>::new(), + ), + ]; + 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/claude-opus-5-5-high, antigravity/claude-opus-5-5-low, antigravity/claude-opus-5-5-medium, antigravity/gemini-4-argon-high, antigravity/gemini-4-argon-low, antigravity/gemini-4-argon-medium, antigravity/gemini-4-carbon-high, antigravity/gemini-4-carbon-low, antigravity/gemini-4-carbon-medium" + ); + assert!( + !run("0", &["codex", "auth", "status", "--json"]) + .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); + } + } + } +}