Files
zerogravity/src/proto.rs
Nikketryhard d4de436856 feat: MITM interception for standalone LS with UID isolation
- Spawn standalone LS as dedicated 'antigravity-ls' user via sudo
- UID-scoped iptables redirect (port 443 → MITM proxy) via mitm-redirect.sh
- Combined CA bundle (system CAs + MITM CA) for Go TLS trust
- Transparent TLS interception with chunked response detection
- Google SSE parser for streamGenerateContent usage extraction
- Timeouts on all MITM operations (TLS handshake, upstream, idle)
- Forward response data immediately (no buffering)
- Per-model token usage capture (input, output, thinking)
- Update docs and known issues to reflect resolved TLS blocker
2026-02-14 17:50:12 -06:00

284 lines
9.8 KiB
Rust

//! Protobuf wire-format encoder — byte-exact match to the real Antigravity webview.
//!
//! This is a minimal, hand-rolled encoder. We do NOT use prost or any codegen
//! because we need precise control over field ordering and encoding to produce
//! byte-identical output to the captured webview traffic.
//!
//! The LS communicates with Google via gRPC over H2, using proto messages from
//! the `exa.*_pb` package family. Init metadata (field 34 of the init request)
//! carries the `detect_and_use_proxy` enum, model selection, and version info.
//! See `docs/ls-binary-analysis.md` for the full proto schema reverse engineering.
use crate::constants::{client_version, CLIENT_NAME};
// ─── Wire primitives ────────────────────────────────────────────────────────
/// Encode a varint (base-128, little-endian, MSB continuation).
pub fn varint(mut val: u64) -> Vec<u8> {
if val == 0 {
return vec![0x00];
}
let mut out = Vec::with_capacity(10);
while val > 0x7F {
out.push(((val & 0x7F) | 0x80) as u8);
val >>= 7;
}
out.push((val & 0x7F) as u8);
out
}
/// Encode a field tag (field_number << 3 | wire_type).
pub fn tag(field: u32, wire: u8) -> Vec<u8> {
varint(((field as u64) << 3) | (wire as u64))
}
/// Wire type 2: length-delimited string/bytes field.
pub fn proto_string(field: u32, val: &[u8]) -> Vec<u8> {
let mut out = tag(field, 2);
out.extend(varint(val.len() as u64));
out.extend_from_slice(val);
out
}
/// Wire type 2: length-delimited sub-message field.
pub fn proto_message(field: u32, inner: &[u8]) -> Vec<u8> {
let mut out = tag(field, 2);
out.extend(varint(inner.len() as u64));
out.extend_from_slice(inner);
out
}
/// Wire type 0: boolean field (varint 0 or 1).
pub fn bool_field(field: u32, val: bool) -> Vec<u8> {
let mut out = tag(field, 0);
out.extend(varint(if val { 1 } else { 0 }));
out
}
/// Wire type 0: varint field.
pub fn varint_field(field: u32, val: u64) -> Vec<u8> {
let mut out = tag(field, 0);
out.extend(varint(val));
out
}
// ─── Init metadata builder (for standalone LS stdin) ─────────────────────────
/// Build the init metadata protobuf that the LS expects on stdin at startup.
///
/// This replaces the Python snippet in `standalone-ls.sh` with proper Rust encoding.
/// Fields match what the real Antigravity extension sends to the LS.
///
/// Field layout (from binary analysis):
/// 1: api_key (string) — unique session key
/// 3: ide_name (string) — "antigravity"
/// 4: antigravity_version (string) — e.g. "1.107.0"
/// 5: ide_version (string) — e.g. "1.16.5"
/// 6: locale (string) — "en_US"
/// 10: session_id (string) — unique session identifier
/// 11: editor_name (string) — "antigravity"
/// 34: detect_and_use_proxy (varint enum) — 1 = ENABLED
pub fn build_init_metadata(
api_key: &str,
antigravity_version: &str,
ide_version: &str,
session_id: &str,
detect_and_use_proxy: u64,
) -> Vec<u8> {
let mut buf = Vec::with_capacity(128);
// Field 1: api_key
buf.extend(proto_string(1, api_key.as_bytes()));
// Field 3: ide_name
buf.extend(proto_string(3, CLIENT_NAME.as_bytes()));
// Field 4: antigravity version
buf.extend(proto_string(4, antigravity_version.as_bytes()));
// Field 5: IDE/client version
buf.extend(proto_string(5, ide_version.as_bytes()));
// Field 6: locale
buf.extend(proto_string(6, b"en_US"));
// Field 10: session_id
buf.extend(proto_string(10, session_id.as_bytes()));
// Field 11: editor_name
buf.extend(proto_string(11, CLIENT_NAME.as_bytes()));
// Field 34: detect_and_use_proxy enum (1 = ENABLED)
buf.extend(varint_field(34, detect_and_use_proxy));
buf
}
// ─── SendUserCascadeMessageRequest builder ───────────────────────────────────
/// Build the `SendUserCascadeMessageRequest` protobuf binary.
///
/// Produces a byte-exact match to real Antigravity webview traffic.
/// Verified against Chrome DevTools network capture 2026-02-12.
///
/// Field layout:
/// 1: cascade_id (string)
/// 2: { 1: text } (message)
/// 3: metadata { 1: client_name, 3: oauth_token, 4: "en", 7: version, 12: client_name }
/// 5: PlannerConfig { 1: inner_config, 7: { 1: 1 } }
/// inner_config contains: f2 (conv mode), f13 (tool config), f15 (model), f21 (ephemeral), f32 (knowledge)
/// 11: conversation_history = true
pub fn build_request(cascade_id: &str, text: &str, oauth_token: &str, model_enum: u32) -> Vec<u8> {
let mut msg = Vec::with_capacity(256);
// Field 1: cascade_id
msg.extend(proto_string(1, cascade_id.as_bytes()));
// Field 2: { field 1: text }
msg.extend(proto_message(2, &proto_string(1, text.as_bytes())));
// Field 3: Metadata (Auth + Client ID)
let mut meta = Vec::new();
meta.extend(proto_string(1, CLIENT_NAME.as_bytes()));
meta.extend(proto_string(3, oauth_token.as_bytes()));
meta.extend(proto_string(4, b"en"));
meta.extend(proto_string(7, client_version().as_bytes()));
meta.extend(proto_string(12, CLIENT_NAME.as_bytes()));
msg.extend(proto_message(3, &meta));
// Field 5: PlannerConfig
let mut inner = Vec::new();
// field 2: conversational mode { f4: 1, f14: 0 }
let conv_mode = [varint_field(4, 1), varint_field(14, 0)].concat();
inner.extend(proto_message(2, &conv_mode));
// field 13: toolConfig
// field 8 (runCommand): field 3 (autoCommandConfig) -> field 6 (policy) = 3 (EAGER)
// field 33 (artifactReviewPolicy): field 1 = 2 (TURBO)
let run_cmd = proto_message(3, &varint_field(6, 3));
let tool_config = [
proto_message(8, &run_cmd),
proto_message(33, &varint_field(1, 2)),
]
.concat();
inner.extend(proto_message(13, &tool_config));
// field 15: requested model { f1: model_enum }
inner.extend(proto_message(15, &varint_field(1, model_enum as u64)));
// field 21: ephemeral messages config { f1: 1 }
inner.extend(proto_message(21, &varint_field(1, 1)));
// field 32: knowledge config { f1: true }
inner.extend(proto_message(32, &bool_field(1, true)));
// Field 5 wraps: field 1 (inner config) + field 7 { f1: 1 }
let f5_payload = [
proto_message(1, &inner),
proto_message(7, &varint_field(1, 1)),
]
.concat();
msg.extend(proto_message(5, &f5_payload));
// Field 11: conversation history flag
msg.extend(bool_field(11, true));
msg
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_varint_zero() {
assert_eq!(varint(0), vec![0x00]);
}
#[test]
fn test_varint_small() {
assert_eq!(varint(1), vec![0x01]);
assert_eq!(varint(127), vec![0x7F]);
}
#[test]
fn test_varint_multibyte() {
assert_eq!(varint(128), vec![0x80, 0x01]);
assert_eq!(varint(300), vec![0xAC, 0x02]);
}
#[test]
fn test_varint_1026() {
// model_enum 1026 = 0x402 → varint [0x82, 0x08]
assert_eq!(varint(1026), vec![0x82, 0x08]);
}
#[test]
fn test_tag() {
// field 1, wire type 2 (LEN) = (1 << 3) | 2 = 0x0A
assert_eq!(tag(1, 2), vec![0x0A]);
// field 3, wire type 0 (VARINT) = (3 << 3) | 0 = 0x18
assert_eq!(tag(3, 0), vec![0x18]);
// field 33, wire type 2 = (33 << 3) | 2 = 266 → varint [0x8A, 0x02]
assert_eq!(tag(33, 2), vec![0x8A, 0x02]);
}
#[test]
fn test_proto_string() {
let result = proto_string(1, b"hi");
// tag(1,2) = 0x0A, len=2, 'h'=0x68, 'i'=0x69
assert_eq!(result, vec![0x0A, 0x02, 0x68, 0x69]);
}
#[test]
fn test_build_request_deterministic() {
let a = build_request("cid", "hello", "ya29.tok", 1026);
let b = build_request("cid", "hello", "ya29.tok", 1026);
assert_eq!(a, b, "build_request must be deterministic");
}
#[test]
fn test_build_request_structure() {
let msg = build_request("test-cascade-id", "hello", "ya29.test-token", 1026);
assert_eq!(msg[0], 0x0A, "first byte must be field 1 tag");
let cascade_bytes = b"test-cascade-id";
assert!(
msg.windows(cascade_bytes.len())
.any(|w| w == cascade_bytes),
"cascade_id must appear in output"
);
assert!(
msg.windows(5).any(|w| w == b"hello"),
"text must appear in output"
);
let token_bytes = b"ya29.test-token";
assert!(
msg.windows(token_bytes.len()).any(|w| w == token_bytes),
"oauth token must appear in output"
);
// model enum 1026 varint [0x82, 0x08]
assert!(
msg.windows(2).any(|w| w == [0x82, 0x08]),
"model enum 1026 varint must appear in output"
);
// field 11 bool true at end: tag(11,0)=0x58, varint(1)=0x01
let len = msg.len();
assert_eq!(msg[len - 2], 0x58);
assert_eq!(msg[len - 1], 0x01);
}
/// Cross-verified against Python output: 127/127 bytes identical.
#[test]
fn test_byte_exact_match_with_python() {
let msg = build_request("test-cascade-id", "hello", "ya29.test-token", 1026);
let hex: String = msg.iter().map(|b| format!("{:02x}", b)).collect();
let expected = "0a0f746573742d636173636164652d696412070a0568656c6c6f\
1a370a0b616e7469677261766974791a0f796132392e746573742d746f6b656e\
2202656e3a06312e31362e35620b616e7469677261766974792a280a22120420\
0170006a0b42041a0230038a020208027a03088208aa010208018202020801\
3a0208015801";
assert_eq!(hex, expected, "must be byte-exact match with Python");
assert_eq!(msg.len(), 127);
}
}