fix: cross-platform support + auto token from state.vscdb

- User-Agent now matches actual OS (macOS/Windows/Linux)
- grep -oP replaced with grep -oE for macOS BSD compat
- Port-killer gated with cfg(unix)/cfg(windows)
- zg binary: macOS uses launchctl, Windows uses schtasks
- Data dir mismatch fixed in mitm-redirect.sh
- Windows setup-windows.ps1 ProjectDir fixed
- README: token path, prerequisites updated
- setup-linux.sh: pre-flight dependency checks
- OAuth token auto-read from Antigravity state.vscdb
- Version bump to 1.0.1
This commit is contained in:
Nikketryhard
2026-02-18 04:09:41 -06:00
parent efdb98e6f0
commit 134126358f
12 changed files with 397 additions and 70 deletions

2
Cargo.lock generated
View File

@@ -2361,7 +2361,7 @@ dependencies = [
[[package]]
name = "zerogravity"
version = "1.0.0"
version = "1.0.1"
dependencies = [
"async-stream",
"axum",

View File

@@ -1,6 +1,6 @@
[package]
name = "zerogravity"
version = "1.0.0"
version = "1.0.1"
edition = "2021"
license = "MIT"
description = "OpenAI-compatible proxy for Google Antigravity"

View File

@@ -47,8 +47,8 @@ Explain to the user what this project unlocks — not what it _is_, but what bec
- **Linux:** `./scripts/setup-linux.sh` → creates UID-isolated system user, iptables redirect, systemd service, builds binary. Then `sudo ./scripts/mitm-redirect.sh install` for MITM interception. Start with `zg start`.
- **macOS:** `./scripts/setup-macos.sh` → launchd plist, config dirs, builds binary. Start with `zg start`.
- **Windows:** `powershell -ExecutionPolicy Bypass -File scripts\setup-windows.ps1` (as Admin) → scheduled task, config dirs, builds binary. Start with `schtasks /run /tn "ZeroGravity Proxy"`.
- **Prerequisites:** Rust toolchain (`curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh`). Linux also needs `iptables`.
- **Auth:** They need an OAuth token from Antigravity. If the app is installed on the same machine, auto-refresh works via `state.vscdb`. Otherwise: `ZEROGRAVITY_TOKEN=ya29.xxx` env var, or `~/.config/zerogravity-token` file, or runtime `POST /v1/token`.
- **Prerequisites:** Rust toolchain (`curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh`). Linux also needs `iptables`, `gcc`, `jq`, and `curl`.
- **Auth:** They need an OAuth token from Antigravity. If the app is installed on the same machine, auto-refresh works via `state.vscdb`. Otherwise: `ZEROGRAVITY_TOKEN=ya29.xxx` env var, or `~/.config/zerogravity/token` file, or runtime `POST /v1/token`.
- **Test it:** `zg test "say hi"` or `curl http://localhost:8741/v1/chat/completions -H "Content-Type: application/json" -d '{"model":"gemini-3-flash","messages":[{"role":"user","content":"hi"}]}'`
</details>
@@ -102,7 +102,7 @@ zg start
The proxy needs an OAuth token:
1. **Env var**: `ZEROGRAVITY_TOKEN=ya29.xxx`
2. **Token file**: `~/.config/zerogravity-token`
2. **Token file**: `~/.config/zerogravity/token`
3. **Runtime**: `curl -X POST http://localhost:8741/v1/token -d '{"token":"ya29.xxx"}'`
<details>
@@ -130,7 +130,7 @@ Extract the OAuth token (starts with ya29.) from this cURL command and give me j
### Linux
```bash
# Prerequisites: Rust toolchain, iptables
# Prerequisites: Rust toolchain, iptables, gcc, jq, curl
./scripts/setup-linux.sh
```

View File

@@ -25,7 +25,7 @@ set -euo pipefail
MITM_PORT="${2:-8742}"
LS_USER="zerogravity-ls"
DATA_DIR="/tmp/antigravity-standalone"
DATA_DIR="/tmp/zerogravity-standalone"
LS_BINARY="/usr/share/antigravity/resources/app/extensions/antigravity/bin/language_server_linux_x64"
SUDOERS_FILE="/etc/sudoers.d/zerogravity-ls"

View File

@@ -7,6 +7,17 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
# ── 0. Dependency check ──
MISSING=()
for cmd in cargo curl jq gcc sudo iptables; do
command -v "$cmd" &>/dev/null || MISSING+=("$cmd")
done
if [ ${#MISSING[@]} -gt 0 ]; then
echo "✗ Missing dependencies: ${MISSING[*]}"
echo " Install them first, then re-run this script."
exit 1
fi
# ── 1. System user for UID isolation ──
echo "→ Creating zerogravity-ls system user…"
if id -u zerogravity-ls &>/dev/null; then

View File

@@ -4,7 +4,7 @@
# Run as: powershell -ExecutionPolicy Bypass -File scripts\setup-windows.ps1
$ErrorActionPreference = "Stop"
$ProjectDir = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
$ProjectDir = Split-Path -Parent $PSScriptRoot
if (-not $ProjectDir) { $ProjectDir = (Get-Location).Path }
# ── 1. Config directory ──

View File

@@ -137,7 +137,7 @@ impl Backend {
/// Get current OAuth token.
///
/// Priority: token file > env var > cached value.
/// Priority: token file > env var > state.vscdb > cached value.
/// Uses async I/O for file reads. Single write-lock acquisition
/// eliminates the TOCTOU race of read-check-then-write.
pub async fn oauth_token(&self) -> String {
@@ -168,6 +168,22 @@ impl Backend {
}
}
// Then state.vscdb (blocking I/O — run on spawn_blocking)
if let Ok(Some(token)) = tokio::task::spawn_blocking(|| {
crate::standalone::read_oauth_from_state_db().map(|(t, _)| t)
})
.await
{
if !token.is_empty() && token.starts_with("ya29.") {
let mut guard = self.inner.write().await;
if guard.oauth_token != token {
info!("Token updated from state.vscdb");
guard.oauth_token = token.clone();
}
return token;
}
}
self.inner.read().await.oauth_token.clone()
}
@@ -616,6 +632,12 @@ fn discover() -> Result<BackendInner, String> {
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
})
.or_else(|| {
// Fallback: read from Antigravity's state.vscdb
crate::standalone::read_oauth_from_state_db()
.map(|(token, _)| token)
.filter(|t| !t.is_empty() && t.starts_with("ya29."))
})
.unwrap_or_default();
Ok(BackendInner {

View File

@@ -1,12 +1,18 @@
//! `zg` — ZeroGravity daemon manager.
//!
//! All commands exit immediately (safe for agent use via fast-bash MCP).
//! Platform-aware: uses systemd on Linux, launchctl on macOS, and direct
//! process management on Windows.
use std::process::{Command, Stdio};
const SERVICE: &str = "zerogravity";
const PORT: u16 = 8741;
// macOS plist identifier
#[cfg(target_os = "macos")]
const PLIST_LABEL: &str = "com.zerogravity.proxy";
// ANSI colors
const RED: &str = "\x1b[0;31m";
const GREEN: &str = "\x1b[0;32m";
@@ -70,6 +76,19 @@ fn project_dir() -> String {
.to_string()
}
#[allow(dead_code)]
fn binary_path() -> String {
let dir = project_dir();
#[cfg(windows)]
{
format!("{dir}\\target\\release\\zerogravity.exe")
}
#[cfg(not(windows))]
{
format!("{dir}/target/release/zerogravity")
}
}
fn base_url() -> String {
let port = std::env::var("PROXY_PORT")
.ok()
@@ -78,6 +97,63 @@ fn base_url() -> String {
format!("http://localhost:{port}")
}
// ── Platform service management ──
#[cfg(target_os = "linux")]
fn svc_start() -> bool {
systemctl(&["daemon-reload"]);
systemctl(&["start", SERVICE])
}
#[cfg(target_os = "linux")]
fn svc_stop() -> bool {
systemctl(&["stop", SERVICE])
}
#[cfg(target_os = "linux")]
fn svc_status() {
let output = Command::new("systemctl")
.args(["--user", "status", SERVICE, "--no-pager"])
.output();
match output {
Ok(o) => {
let text = String::from_utf8_lossy(&o.stdout);
for (i, line) in text.lines().enumerate() {
if i >= 6 {
break;
}
println!("{line}");
}
}
Err(_) => println!("{RED}Not running{NC}"),
}
}
#[cfg(target_os = "linux")]
fn svc_logs(n: &str, follow: bool) {
let mut args = vec!["--user", "-u", SERVICE, "--no-pager", "-n", n];
if follow {
args.push("-f");
}
let _ = Command::new("journalctl")
.args(&args)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status();
}
#[cfg(target_os = "linux")]
fn svc_logs_all() {
let _ = Command::new("journalctl")
.args(["--user", "-u", SERVICE, "--no-pager"])
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status();
}
#[cfg(target_os = "linux")]
fn systemctl(args: &[&str]) -> bool {
Command::new("systemctl")
.arg("--user")
@@ -87,6 +163,202 @@ fn systemctl(args: &[&str]) -> bool {
.unwrap_or(false)
}
#[cfg(target_os = "linux")]
fn svc_show_fail_logs() {
let _ = Command::new("journalctl")
.args(["--user", "-u", SERVICE, "--no-pager", "-n", "20"])
.status();
}
// ── macOS: launchctl ──
#[cfg(target_os = "macos")]
fn plist_path() -> String {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
format!("{home}/Library/LaunchAgents/{PLIST_LABEL}.plist")
}
#[cfg(target_os = "macos")]
fn svc_start() -> bool {
let plist = plist_path();
if !std::path::Path::new(&plist).exists() {
eprintln!("{RED}Plist not found at {plist}{NC}");
eprintln!(" Run ./scripts/setup-macos.sh first");
return false;
}
// bootout first to ensure clean state (ignore errors)
let uid = Command::new("id")
.arg("-u")
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|| "501".into());
let _ = Command::new("launchctl")
.args(["bootout", &format!("gui/{uid}"), &plist])
.status();
Command::new("launchctl")
.args(["load", &plist])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[cfg(target_os = "macos")]
fn svc_stop() -> bool {
let plist = plist_path();
Command::new("launchctl")
.args(["unload", &plist])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[cfg(target_os = "macos")]
fn svc_status() {
let output = Command::new("launchctl")
.args(["list", PLIST_LABEL])
.output();
match output {
Ok(o) if o.status.success() => {
let text = String::from_utf8_lossy(&o.stdout);
println!("{text}");
}
_ => println!("{RED}Not loaded{NC}"),
}
}
#[cfg(target_os = "macos")]
fn svc_logs(n: &str, follow: bool) {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
let log = format!("{home}/Library/Logs/zerogravity.log");
if !std::path::Path::new(&log).exists() {
println!("{DIM}(no log file yet){NC}");
return;
}
let mut args = vec!["-n", n];
if follow {
args.push("-f");
}
args.push(&log);
let _ = Command::new("tail")
.args(&args)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status();
}
#[cfg(target_os = "macos")]
fn svc_logs_all() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
let log = format!("{home}/Library/Logs/zerogravity.log");
if std::path::Path::new(&log).exists() {
let _ = Command::new("cat")
.arg(&log)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status();
} else {
println!("{DIM}(no log file yet){NC}");
}
}
#[cfg(target_os = "macos")]
fn svc_show_fail_logs() {
svc_logs("20", false);
}
// ── Windows / other: direct process management ──
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn svc_start() -> bool {
let bin = binary_path();
if !std::path::Path::new(&bin).exists() {
eprintln!("{RED}Binary not found: {bin}{NC}");
eprintln!(" Run `cargo build --release` first");
return false;
}
// Try starting via scheduled task on Windows
#[cfg(windows)]
{
let result = Command::new("schtasks")
.args(["/run", "/tn", "ZeroGravity Proxy"])
.status()
.map(|s| s.success())
.unwrap_or(false);
if result {
return true;
}
// Fallback: start directly (detached)
eprintln!("{YELLOW}Scheduled task not found, starting directly...{NC}");
}
// Fallback for all non-linux/macos: spawn detached
match Command::new(&bin)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
Ok(_) => true,
Err(e) => {
eprintln!("{RED}Failed to start: {e}{NC}");
false
}
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn svc_stop() -> bool {
#[cfg(windows)]
{
let _ = Command::new("schtasks")
.args(["/end", "/tn", "ZeroGravity Proxy"])
.status();
}
// Also try killing by process name
#[cfg(windows)]
{
let _ = Command::new("taskkill")
.args(["/F", "/IM", "zerogravity.exe"])
.status();
}
#[cfg(not(windows))]
{
let _ = Command::new("pkill")
.args(["-f", "zerogravity"])
.status();
}
true
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn svc_status() {
if health_ok() {
println!("{GREEN}Running{NC} (responding on port {PORT})");
} else {
println!("{RED}Not responding on port {PORT}{NC}");
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn svc_logs(_n: &str, _follow: bool) {
println!("{DIM}Logs not available via zg on this platform.{NC}");
println!(" Check the console output where zerogravity was started.");
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn svc_logs_all() {
svc_logs("0", false);
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn svc_show_fail_logs() {
println!("{DIM}Check the console output where zerogravity was started.{NC}");
}
// ── Shared helpers ──
fn curl_get(path: &str) -> Option<String> {
let url = format!("{}{}", base_url(), path);
Command::new("curl")
@@ -119,17 +391,30 @@ fn health_ok() -> bool {
}
fn jq_print(json: &str) {
let mut child = Command::new("jq")
// Try jq first, fall back to raw JSON
match Command::new("jq")
.arg(".")
.stdin(Stdio::piped())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()
.expect("jq not found");
if let Some(stdin) = child.stdin.as_mut() {
{
Ok(mut child) => {
// Drop stdin before wait so jq sees EOF and doesn't hang
{
use std::io::Write;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(json.as_bytes());
}
}
let _ = child.wait();
}
Err(_) => {
// jq not installed — print raw
println!("{json}");
}
}
}
// ── Commands ──
@@ -154,8 +439,10 @@ fn do_build() {
}
fn do_start() {
systemctl(&["daemon-reload"]);
systemctl(&["start", SERVICE]);
if !svc_start() {
eprintln!("{RED}Failed to start service.{NC}");
std::process::exit(1);
}
println!("{GREEN}Started.{NC} Waiting for ready...");
for _ in 0..20 {
@@ -167,14 +454,12 @@ fn do_start() {
}
eprintln!("{RED}Proxy didn't become healthy in 10s. Check logs:{NC}");
let _ = Command::new("journalctl")
.args(["--user", "-u", SERVICE, "--no-pager", "-n", "20"])
.status();
svc_show_fail_logs();
std::process::exit(1);
}
fn do_stop() {
let _ = systemctl(&["stop", SERVICE]);
let _ = svc_stop();
println!("{YELLOW}Stopped.{NC}");
}
@@ -187,22 +472,7 @@ fn do_restart() {
fn do_status() {
println!("{BOLD}── Service ──{NC}");
let output = Command::new("systemctl")
.args(["--user", "status", SERVICE, "--no-pager"])
.output();
match output {
Ok(o) => {
let text = String::from_utf8_lossy(&o.stdout);
// Print first 6 lines
for (i, line) in text.lines().enumerate() {
if i >= 6 {
break;
}
println!("{line}");
}
}
Err(_) => println!("{RED}Not running{NC}"),
}
svc_status();
println!();
if !health_ok() {
@@ -232,32 +502,23 @@ fn do_status() {
}
fn do_logs(n: &str, follow: bool) {
let mut args = vec!["--user", "-u", SERVICE, "--no-pager", "-n", n];
if follow {
args.push("-f");
}
let _ = Command::new("journalctl")
.args(&args)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status();
svc_logs(n, follow);
}
fn do_logs_all() {
let _ = Command::new("journalctl")
.args(["--user", "-u", SERVICE, "--no-pager"])
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status();
svc_logs_all();
}
fn do_test(msg: &str) {
println!("{CYAN}Testing:{NC} {msg}");
let escaped = msg
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t");
let body = format!(
r#"{{"model":"gemini-3-flash","input":"{}","stream":false,"timeout":30}}"#,
msg.replace('"', r#"\""#)
r#"{{"model":"gemini-3-flash","input":"{escaped}","stream":false,"timeout":30}}"#
);
match curl_post("/v1/responses", &body) {
Some(json) => jq_print(&json),

View File

@@ -104,12 +104,12 @@ fn extract_binary_versions(install_dir: &str) -> (Option<String>, Option<String>
return (None, None);
}
// Use grep -oP on the binary to avoid loading the whole thing into memory
// Use grep -oE on the binary to avoid loading the whole thing into memory
let chrome = Command::new("sh")
.args([
"-c",
&format!(
"strings '{}' | grep -oP 'Chrome/[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+' | head -1",
"strings '{}' | grep -oE 'Chrome/[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+' | head -1",
binary
),
])
@@ -124,7 +124,7 @@ fn extract_binary_versions(install_dir: &str) -> (Option<String>, Option<String>
.args([
"-c",
&format!(
"strings '{}' | grep -oP 'Electron/[0-9]+\\.[0-9]+\\.[0-9]+' | head -1",
"strings '{}' | grep -oE 'Electron/[0-9]+\\.[0-9]+\\.[0-9]+' | head -1",
binary
),
])
@@ -234,8 +234,9 @@ pub fn token_file_path() -> String {
/// User-Agent string matching the Electron webview — computed once.
pub static USER_AGENT: LazyLock<String> = LazyLock::new(|| {
let os_part = user_agent_os_part();
format!(
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 \
"Mozilla/5.0 ({os_part}) AppleWebKit/537.36 \
(KHTML, like Gecko) Antigravity/{} \
Chrome/{} Electron/{} Safari/537.36",
antigravity_version(),
@@ -244,6 +245,22 @@ pub static USER_AGENT: LazyLock<String> = LazyLock::new(|| {
)
});
/// Returns the OS portion of the User-Agent string matching real Electron/Chrome.
fn user_agent_os_part() -> &'static str {
#[cfg(target_os = "macos")]
{
"Macintosh; Intel Mac OS X 10_15_7"
}
#[cfg(target_os = "windows")]
{
"Windows NT 10.0; Win64; x64"
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
"X11; Linux x86_64"
}
}
/// Chrome major version for sec-ch-ua header — computed once.
pub static CHROME_MAJOR: LazyLock<String> = LazyLock::new(|| {
chrome_version()

View File

@@ -113,16 +113,31 @@ async fn main() {
Err(_) => {
// Port in use — try to kill whatever's holding it
eprintln!(" Port {} in use, killing stale process...", cli.port);
#[cfg(unix)]
{
let _ = std::process::Command::new("sh")
.args([
"-c",
&format!("kill $(lsof -ti:{}) 2>/dev/null; sleep 0.3", cli.port),
])
.status();
// Also kill any leftover standalone LS processes
let _ = std::process::Command::new("pkill")
.args(["-f", "language_server.*antigravity-standalone"])
.status();
}
#[cfg(windows)]
{
// Windows: find PID via netstat and kill it
let _ = std::process::Command::new("cmd")
.args([
"/C",
&format!(
"for /f \"tokens=5\" %a in ('netstat -aon ^| findstr :{} ^| findstr LISTENING') do taskkill /PID %a /F",
cli.port
),
])
.status();
}
// Retry once
match tokio::net::TcpListener::bind(&addr).await {
Ok(l) => l,

View File

@@ -254,7 +254,7 @@ pub(super) fn cleanup_orphaned_ls() {
/// The DB stores the exact Topic proto bytes under key `antigravityUnifiedStateSync.oauthToken`.
/// This includes access_token + refresh_token + expiry, allowing the LS to auto-refresh.
/// Returns (access_token, topic_proto_bytes) or None if unavailable.
pub(super) fn read_oauth_from_state_db() -> Option<(String, Vec<u8>)> {
pub fn read_oauth_from_state_db() -> Option<(String, Vec<u8>)> {
use base64::Engine;
let db_path = paths().state_db_path;

View File

@@ -15,6 +15,7 @@ use tracing::info;
use uuid::Uuid;
// Re-export public API
pub use discovery::read_oauth_from_state_db;
pub use spawn::StandaloneLS;
/// Source for the DNS redirect preload library (compiled at runtime, Linux only).