From 038f5c8747dbe2e24542aa94652a6b1b1d5d26ff Mon Sep 17 00:00:00 2001 From: phantomic12 Date: Fri, 7 Aug 2026 00:24:00 -0400 Subject: [PATCH 01/38] feat(providers): add Antigravity + Gemini CLI OAuth plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new first-party OAuth provider bundles, mirroring the Gemini CLI and Antigravity IDE subscription flows used by Google's open-source clients. Both reuse the existing core/src/providers/google_code_assist.rs adapter — the is_code_assist_endpoint() gate already routes the cloudcode-pa / daily-cloudcode-pa hosts to the right wire format, and the adapter's resolve_project() reads the x-goog-user-project header that each plugin's 'token' action injects so requests route to the user's real Code Assist project (not the freemium shared default). Flow: 1. /login antigravity (or gemini-cli) 2. Harness binds loopback, opens Google OAuth page (PKCE S256). 3. Script exchanges the code, calls :loadCodeAssist with the matching client fingerprint (Antigravity IDE 2.1.1 UA + enum 9/2/2 metadata; gemini-cli google-api-nodejs-client UA + X-Goog-Api-Client + Client-Metadata). 4. If loadCodeAssist returns no project (new account), the script calls :onboardUser and polls until done=true. 5. Token file persisted to ~/.config/catalyst-code/oauth/{antigravity,gemini-cli}.json with access_token, refresh_token, expires_at, project_id, and email. 6. On every turn the harness refreshes near-expiry tokens and injects an x-goog-user-project header carrying the discovered project. Verified end-to-end: * cargo test (focused: staging, plugins, oauth, providers, google_code_assist) — 199 passed, 0 failed. * cargo build --release -p catalyst-code-core — clean. * OAuth script against mock OAuth + loadCodeAssist + onboardUser backend — login, complete, token, refresh, clear, onboarding fallback all work. * Fresh $HOME → release binary auto-stages both plugins into ~/.catalyst-code/plugins/, byte-identical to source, scripts marked executable. * strings(1) confirms provider_id=antigravity and provider_id= gemini-cli are baked into the binary. --- core/providers/README.md | 9 + core/providers/antigravity/README.md | 107 ++++ .../antigravity/oauth/antigravity-oauth.py | 565 ++++++++++++++++++ core/providers/antigravity/plugin.json | 19 + core/providers/gemini-cli/README.md | 105 ++++ .../gemini-cli/oauth/gemini-cli-oauth.py | 550 +++++++++++++++++ core/providers/gemini-cli/plugin.json | 19 + core/src/staging.rs | 84 ++- 8 files changed, 1456 insertions(+), 2 deletions(-) create mode 100644 core/providers/antigravity/README.md create mode 100755 core/providers/antigravity/oauth/antigravity-oauth.py create mode 100644 core/providers/antigravity/plugin.json create mode 100644 core/providers/gemini-cli/README.md create mode 100755 core/providers/gemini-cli/oauth/gemini-cli-oauth.py create mode 100644 core/providers/gemini-cli/plugin.json diff --git a/core/providers/README.md b/core/providers/README.md index 574ab59..5d9afbc 100644 --- a/core/providers/README.md +++ b/core/providers/README.md @@ -55,3 +55,12 @@ source-of-truth embedded into the binary. - `kimi/` — Kimi Code (Moonshot), device-code OAuth subscription. - `codex/` — ChatGPT (Codex), official Codex CLI device-code OAuth with automatic polling. - `deepseek/` — DeepSeek API, official OpenAI-compatible API-key provider. +- `antigravity/` — Google Antigravity IDE, OAuth + Code Assist `loadCodeAssist` project discovery (Authorization Code + PKCE). +- `gemini-cli/` — Google Gemini CLI, OAuth + Code Assist `loadCodeAssist` project discovery (Authorization Code + PKCE). + +Both Google bundles reuse the existing `core/src/providers/google_code_assist.rs` +adapter — `is_code_assist_endpoint` already routes the `cloudcode-pa` / +daily-cloudcode-pa hosts to the right wire format, and the adapter's +`resolve_project` reads the `x-goog-user-project` header that each plugin's +`token` action injects to use the user's real Code Assist project instead +of the freemium fallback. diff --git a/core/providers/antigravity/README.md b/core/providers/antigravity/README.md new file mode 100644 index 0000000..7cf12fc --- /dev/null +++ b/core/providers/antigravity/README.md @@ -0,0 +1,107 @@ +# Antigravity — Google IDE OAuth + +This first-party bundle connects the harness to the Google Antigravity IDE +subscription via the **Code Assist / `cloudcode-pa` gateway**. It uses +Google's standard OAuth 2.0 Authorization Code flow with PKCE against the +public Antigravity IDE client, then runs `:loadCodeAssist` to fetch a real +`cloudaicompanionProject` for the authenticated user. + +Use `/login` and choose **Antigravity (Google IDE)**, or run: + +```text +/login antigravity +``` + +The harness binds a loopback redirect, opens the browser to Google's +authorization page, captures the code, exchanges it for tokens, runs +`loadCodeAssist` (with the Antigravity IDE 2.1.1 fingerprint headers), and +persists everything to `~/.config/catalyst-code/oauth/antigravity.json`. +On every subsequent turn the harness refreshes the access token when needed +and injects an `x-goog-user-project` header carrying the discovered project +id, so requests route to the user's real Antigravity project — not the +shared freemium project the adapter ships as a fallback. + +If `loadCodeAssist` returns no project (new Google account with no +Code Assist history yet), the harness also calls `:onboardUser` and polls +until provisioning finishes (`done=true`), so the first request never fails +with `project not found`. + +## Models + +Antigravity exposes Gemini 3 / 3.1 Pro and Flash (with tiered -high / -low +for Pro), Claude Sonnet 4.6 and Opus 4.6 Thinking, plus GPT-OSS 120B. +Model IDs map 1:1 to upstream Code Assist slugs — no aliasing: + +```text +gemini-3.1-pro-high +gemini-3.1-pro-low +gemini-3-pro-high +gemini-3-pro-low +gemini-3-flash +gemini-2.5-pro +gemini-2.5-flash +claude-opus-4-6-thinking +claude-sonnet-4-6 +gpt-oss-120b-medium +``` + +## Endpoints + +| Purpose | URL | +|-----------------------------|--------------------------------------------------------------------| +| Authorization | `https://accounts.google.com/o/oauth2/v2/auth` | +| Token exchange / refresh | `https://oauth2.googleapis.com/token` | +| Userinfo (email) | `https://www.googleapis.com/oauth2/v1/userinfo` | +| Project discovery | `https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` | +| User onboarding (fallback) | `https://cloudcode-pa.googleapis.com/v1internal:onboardUser` | +| Chat (streamGenerateContent)| `https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal` | + +Project discovery + onboarding use the **prod** Code Assist host — the +daily/sandbox host rejects `loadCodeAssist` and `onboardUser`. Only chat +traffic uses the daily host (to bypass prod-side 429 rate limits). + +## Client identity + +The script uses the public Antigravity IDE OAuth client: + +| Field | Value | +|---------------|-----------------------------------------------------------------------------------| +| `client_id` | `1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com` | +| `client_secret` | `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` | +| User-Agent | `antigravity/ide/2.1.1 darwin/arm64` | +| Metadata | `{ ideType: 9, platform: 2, pluginType: 2 }` (ANTIGRAVITY, DARWIN_ARM64, GEMINI) | + +These are intentional — every Antigravity IDE install carries the same +public client and the same fingerprints. Google's backend uses the +fingerprint to detect non-IDE clients and silently refuses to provision a +project if it looks wrong, so matching them is what lets the first +request succeed. + +## Wire + +After OAuth + project discovery, every chat turn is a POST to +`{base_url}:streamGenerateContent?alt=sse` with body shape: + +```json +{ + "model": "gemini-3.1-pro-high", + "project": "", + "userAgent": "antigravity", + "request": { + "contents": [...], + "systemInstruction": {...}, + "tools": [...], + "generationConfig": {"maxOutputTokens": N} + } +} +``` + +The `project` field comes from the harness's `x-goog-user-project` header +(merged from the OAuth plugin's per-request headers); see +`core/src/providers/google_code_assist.rs`. + +## References + +- Antigravity IDE source fingerprint: captured from a real 2.1.1 install. +- Code Assist wire format + project discovery: Google's open-source + Gemini CLI + Antigravity IDE. \ No newline at end of file diff --git a/core/providers/antigravity/oauth/antigravity-oauth.py b/core/providers/antigravity/oauth/antigravity-oauth.py new file mode 100755 index 0000000..866a0fa --- /dev/null +++ b/core/providers/antigravity/oauth/antigravity-oauth.py @@ -0,0 +1,565 @@ +#!/usr/bin/env python3 +"""Antigravity (Google IDE) OAuth + Code Assist project discovery. + +The harness sends one JSON object on stdin with an ``action`` of ``login``, +``complete``, ``token``, or ``clear``. One JSON object is written to stdout. + +This file deliberately uses only the Python standard library. The endpoint +names, client id, redirect URI, refresh grant, and loadCodeAssist metadata +mirror the public Antigravity IDE (2.1.1, darwin/arm64) so the upstream +Code Assist gateway provisions a real ``cloudaicompanionProject`` for us. + +Flow +---- +login PKCE + Authorization Code → harness binds loopback → opens browser + → captures ``code`` → we exchange + run ``loadCodeAssist`` → + write ``token.json`` containing access + refresh + project_id. +token Return a fresh ``access_token`` (refresh if near expiry) and a + ``x-goog-user-project`` header carrying the cached ``project_id`` + so the harness's Google Code Assist adapter routes to the user's + real Antigravity project (not the freemium shared one). +clear Delete the on-disk token file. +""" + +import base64 +import hashlib +import json +import os +import secrets +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request + + +# ─── Antigravity IDE public OAuth client ──────────────────────────────────── +# Public client_id / client_secret shipped in the open-source Antigravity IDE. +# Both values are intentionally public — every Antigravity IDE install carries +# the same pair — and are reused here unchanged. +CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" +CLIENT_SECRET = "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" + +AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" +TOKEN_URL = "https://oauth2.googleapis.com/token" +USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo" + +# Scopes the Antigravity IDE requests. ``cclog`` + ``experimentsandconfigs`` +# are Antigravity-specific and are required for Code Assist provisioning. +SCOPES = [ + "openid", + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/cclog", + "https://www.googleapis.com/auth/experimentsandconfigs", +] + +# Antigravity IDE fingerprints (must match what the IDE actually sends — +# Google's backend fingerprints these headers and silently refuses to +# provision a project if they look wrong). +USER_AGENT = "antigravity/ide/2.1.1 darwin/arm64" + +# Project discovery stays on PROD — the daily host rejects loadCodeAssist / +# onboardUser calls. Only chat traffic uses the daily host (via base_url in +# plugin.json). +LOAD_CODE_ASSIST_URL = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" +ONBOARD_USER_URL = "https://cloudcode-pa.googleapis.com/v1internal:onboardUser" + +# Numeric enum values that the Code Assist backend fingerprints. Values +# captured from a real Antigravity IDE 2.1.1 / darwin-arm64 install. Anything +# else triggers silent provisioning failure (no cloudaicompanionProject in +# the response, and onboardUser's poll never reaches ``done=true``). +# IDE_TYPE_ANTIGRAVITY = 9 +# PLATFORM_DARWIN_ARM64 = 2 +# PLUGIN_TYPE_GEMINI = 2 +CLIENT_METADATA = {"ideType": 9, "platform": 2, "pluginType": 2} + +REFRESH_LEAD_S = 300 +ONBOARD_MAX_ATTEMPTS = 5 +ONBOARD_POLL_S = 2 +HTTP_TIMEOUT_S = 30 + + +# ─── harness I/O ─────────────────────────────────────────────────────────── + +def emit(obj): + sys.stdout.write(json.dumps(obj, separators=(",", ":"))) + sys.stdout.flush() + + +def die(message): + emit({"ok": False, "error": str(message)}) + raise SystemExit(0) + + +def now(): + return int(time.time()) + + +# ─── HTTP helpers ────────────────────────────────────────────────────────── + +def http_post(url, body, content_type, extra_headers=None): + headers = { + "Accept": "application/json", + "Content-Type": content_type, + "User-Agent": USER_AGENT, + } + if extra_headers: + headers.update(extra_headers) + req = urllib.request.Request(url, data=body, method="POST", headers=headers) + try: + with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S) as response: + raw = response.read().decode("utf-8", "replace") + return response.status, parse_json(raw) + except urllib.error.HTTPError as exc: + raw = exc.read().decode("utf-8", "replace") + return exc.code, parse_json(raw) + except Exception as exc: + return 0, {"error": "request_failed", "error_description": str(exc)} + + +def parse_json(raw): + try: + value = json.loads(raw) if raw.strip() else {} + return value if isinstance(value, dict) else {} + except Exception: + return {"error": "invalid_json", "error_description": raw[:500]} + + +def post_form(url, fields, extra_headers=None): + return http_post( + url, + urllib.parse.urlencode(fields).encode("utf-8"), + "application/x-www-form-urlencoded", + extra_headers, + ) + + +def post_json(url, payload, extra_headers=None): + return http_post( + url, + json.dumps(payload, separators=(",", ":")).encode("utf-8"), + "application/json", + extra_headers, + ) + + +def error_text(status, data): + return ( + data.get("error_description") + or data.get("error") + or ("network request failed" if status == 0 else f"HTTP {status}") + ) + + +# ─── on-disk token file ──────────────────────────────────────────────────── + +def token_path(ctx): + return os.path.abspath(str(ctx.get("token_path") or "antigravity.json")) + + +def read_token(path): + try: + with open(path, encoding="utf-8") as handle: + value = json.load(handle) + return value if isinstance(value, dict) else None + except (OSError, ValueError, TypeError): + return None + + +def atomic_write(path, value): + path = os.path.abspath(path) + parent = os.path.dirname(path) or "." + os.makedirs(parent, mode=0o700, exist_ok=True) + fd, tmp = tempfile.mkstemp(prefix=".antigravity-oauth-", dir=parent) + try: + try: + os.fchmod(fd, 0o600) + except AttributeError: + pass # Windows has no POSIX mode bits + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(value, handle, separators=(",", ":")) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp, path) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise + + +def lock_for(path): + try: + import fcntl + except ImportError: + return None + handle = open(path + ".lock", "a+", encoding="utf-8") + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + except OSError: + pass + return handle + + +def unlock(handle): + if handle is not None: + try: + handle.close() + except OSError: + pass + + +# ─── PKCE + auth URL ─────────────────────────────────────────────────────── + +def make_pkce(): + """Generate (verifier, challenge, state) for S256 PKCE.""" + verifier = base64.urlsafe_b64encode(secrets.token_bytes(48)).rstrip(b"=").decode("ascii") + challenge = base64.urlsafe_b64encode( + hashlib.sha256(verifier.encode("ascii")).digest() + ).rstrip(b"=").decode("ascii") + state = base64.urlsafe_b64encode(secrets.token_bytes(24)).rstrip(b"=").decode("ascii") + return verifier, challenge, state + + +def build_authorize_url(redirect_uri, state, challenge, extra=None): + params = { + "client_id": CLIENT_ID, + "response_type": "code", + "redirect_uri": redirect_uri, + "scope": " ".join(SCOPES), + "state": state, + "code_challenge": challenge, + "code_challenge_method": "S256", + "access_type": "offline", + "prompt": "consent", + "include_granted_scopes": "true", + } + if extra: + params.update(extra) + return AUTH_URL + "?" + urllib.parse.urlencode(params) + + +# ─── token exchange ──────────────────────────────────────────────────────── + +def exchange_code(code, redirect_uri, verifier): + status, data = post_form( + TOKEN_URL, + { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "code_verifier": verifier, + }, + ) + return status, data + + +def refresh_access_token(refresh_token): + status, data = post_form( + TOKEN_URL, + { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + }, + ) + return status, data + + +def fetch_user_email(access_token): + req = urllib.request.Request( + USERINFO_URL, + headers={"Authorization": f"Bearer {access_token}", "User-Agent": USER_AGENT}, + ) + try: + with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S) as response: + data = parse_json(response.read().decode("utf-8", "replace")) + return data.get("email") or "" + except Exception: + return "" + + +def normalize_tokens(tokens): + """Coerce the raw OAuth response into the persistent shape on disk.""" + access = tokens.get("access_token") or "" + refresh = tokens.get("refresh_token") or "" + if not access and not refresh: + return None + expires_in = int(tokens.get("expires_in") or 0) + return { + "access_token": access, + "refresh_token": refresh, + "expires_in": expires_in, + "expires_at": now() + max(expires_in, 60), + "scope": tokens.get("scope", ""), + "token_type": tokens.get("token_type", "Bearer"), + } + + +# ─── Code Assist: loadCodeAssist + onboardUser ───────────────────────────── + +def _code_assist_headers(access_token): + return { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + "User-Agent": USER_AGENT, + } + + +def _code_assist_body(include_tier=False, tier_id=None): + body = {"metadata": dict(CLIENT_METADATA)} + if include_tier: + body["tierId"] = tier_id or "legacy-tier" + return body + + +def load_code_assist(access_token): + """POST :loadCodeAssist, return ``cloudaicompanionProject`` id or ``None``.""" + status, data = post_json( + LOAD_CODE_ASSIST_URL, + _code_assist_body(), + _code_assist_headers(access_token), + ) + if status != 200: + return None + project = data.get("cloudaicompanionProject") + if isinstance(project, str) and project.strip(): + return project.strip() + if isinstance(project, dict): + nested = project.get("id") + if isinstance(nested, str) and nested.strip(): + return nested.strip() + return None + + +def _pick_default_tier(payload): + tiers = payload.get("allowedTiers") + if isinstance(tiers, list): + for tier in tiers: + if isinstance(tier, dict) and tier.get("isDefault") is True: + tid = tier.get("id") + if isinstance(tid, str) and tid.strip(): + return tid.strip() + return "legacy-tier" + + +def onboard_user(access_token, tier_id): + """POST :onboardUser, polling until ``done=true``; return project_id.""" + for attempt in range(1, ONBOARD_MAX_ATTEMPTS + 1): + status, data = post_json( + ONBOARD_USER_URL, + _code_assist_body(include_tier=True, tier_id=tier_id), + _code_assist_headers(access_token), + ) + if status != 200: + return None + if data.get("done") is True: + response = data.get("response") or {} + project = response.get("cloudaicompanionProject") + if isinstance(project, str) and project.strip(): + return project.strip() + if isinstance(project, dict): + nested = project.get("id") + if isinstance(nested, str) and nested.strip(): + return nested.strip() + return None + if attempt < ONBOARD_MAX_ATTEMPTS: + time.sleep(ONBOARD_POLL_S) + return None + + +def discover_project_id(access_token): + """Try loadCodeAssist; on failure, fall back to onboardUser polling.""" + # We need the loadCodeAssist payload too (for the tier), so re-call. + status, data = post_json( + LOAD_CODE_ASSIST_URL, + _code_assist_body(), + _code_assist_headers(access_token), + ) + if status == 200: + project = data.get("cloudaicompanionProject") + if isinstance(project, str) and project.strip(): + return project.strip() + if isinstance(project, dict): + nested = project.get("id") + if isinstance(nested, str) and nested.strip(): + return nested.strip() + tier = _pick_default_tier(data) + return onboard_user(access_token, tier) + return None + + +# ─── actions ─────────────────────────────────────────────────────────────── + +def do_login(ctx): + """Build the authorize URL. The harness binds the loopback + opens the + browser; we receive the ``code`` back in ``complete``.""" + verifier, challenge, state = make_pkce() + redirect_uri = str(ctx.get("redirect_uri") or "").strip() + if not redirect_uri: + die("Antigravity OAuth requires a loopback redirect_uri (catcode binds this)") + + url = build_authorize_url(redirect_uri, state, challenge) + emit( + { + "url": url, + "flow": "web", + "state": state, + "pending": {"verifier": verifier}, + "message": ( + "Open the URL to authorize Antigravity. The token is then " + "auto-discovered via loadCodeAssist and a real Cloud project " + "is provisioned if needed." + ), + } + ) + + +def do_complete(ctx): + code = str(ctx.get("code") or "").strip() + if not code: + die("no authorization code received from Antigravity") + pending = ctx.get("pending") or {} + verifier = str(pending.get("verifier") or "").strip() + if not verifier: + die("missing PKCE verifier in pending state; restart /login antigravity") + redirect_uri = str(ctx.get("redirect_uri") or "").strip() + if not redirect_uri: + die("missing redirect_uri in complete context; restart /login antigravity") + + status, tokens = exchange_code(code, redirect_uri, verifier) + if status != 200 or not tokens.get("access_token"): + die("Antigravity token exchange failed: " + error_text(status, tokens)) + + normalized = normalize_tokens(tokens) + if not normalized: + die("Antigravity token exchange returned no usable tokens") + + project_id = discover_project_id(normalized["access_token"]) + if project_id: + normalized["project_id"] = project_id + + email = fetch_user_email(normalized["access_token"]) + if email: + normalized["email"] = email + + atomic_write(token_path(ctx), normalized) + emit({"ok": True}) + + +def do_token(ctx): + path = token_path(ctx) + handle = lock_for(path) + try: + token = read_token(path) + if not token: + emit({"access_token": None}) + return + + current = now() + expires_at = int(token.get("expires_at") or 0) + needs_refresh = (not token.get("access_token")) or ( + expires_at > 0 and expires_at - current <= REFRESH_LEAD_S + ) + + if needs_refresh and token.get("refresh_token"): + # Another harness process may have refreshed while we waited for + # the flock; always re-read before making a network request. + current_token = read_token(path) or token + current_exp = int(current_token.get("expires_at") or 0) + if current_token.get("access_token") and ( + current_exp == 0 or current_exp - now() > REFRESH_LEAD_S + ): + token = current_token + else: + status, data = refresh_access_token(current_token["refresh_token"]) + if status != 200 or not data.get("access_token"): + emit({"access_token": None}) + return + rotated = normalize_tokens( + { + "access_token": data.get("access_token", ""), + "refresh_token": data.get("refresh_token") + or current_token.get("refresh_token", ""), + "expires_in": int(data.get("expires_in") or 0), + "scope": data.get("scope", current_token.get("scope", "")), + "token_type": data.get("token_type", "Bearer"), + } + ) + if not rotated: + emit({"access_token": None}) + return + # Preserve project_id + email across rotations — those were + # discovered once and remain valid for the lifetime of the + # OAuth grant. + rotated["project_id"] = current_token.get("project_id", "") + rotated["email"] = current_token.get("email", "") + atomic_write(path, rotated) + token = rotated + + access = token.get("access_token") or "" + if not access: + emit({"access_token": None}) + return + + headers = [] + project_id = str(token.get("project_id") or "").strip() + if project_id: + # The harness's Google Code Assist adapter resolves the project + # via this header (one of three accepted names). The plugin sets + # the real per-user project so requests don't fall back to the + # shared freemium project that the adapter ships as a default. + headers.append(["x-goog-user-project", project_id]) + emit( + { + "access_token": access, + "expires_at": int(token.get("expires_at") or 0), + "headers": headers, + } + ) + finally: + unlock(handle) + + +def do_clear(ctx): + path = token_path(ctx) + for candidate in (path, path + ".lock"): + try: + os.remove(candidate) + except OSError: + pass + emit({"ok": True}) + + +def main(): + try: + raw = sys.stdin.read() + ctx = json.loads(raw) if raw.strip() else {} + if not isinstance(ctx, dict): + die("OAuth context must be a JSON object") + action = ctx.get("action", "") + if action == "login": + do_login(ctx) + elif action == "complete": + do_complete(ctx) + elif action == "token": + do_token(ctx) + elif action == "clear": + do_clear(ctx) + else: + die("unknown action: %r" % action) + except SystemExit: + raise + except Exception as exc: + die("Antigravity OAuth provider error: " + str(exc)) + + +if __name__ == "__main__": + main() diff --git a/core/providers/antigravity/plugin.json b/core/providers/antigravity/plugin.json new file mode 100644 index 0000000..e8c0cb2 --- /dev/null +++ b/core/providers/antigravity/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "antigravity", + "version": "0.1.0", + "description": "Google Antigravity IDE subscription access — OAuth + Code Assist project discovery. Auto-staged into every install.", + "oauth": { + "provider_id": "antigravity", + "label": "Antigravity (Google IDE)", + "kind": "openai", + "base_url": "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal", + "description": "Antigravity / Code Assist OAuth — Authorization Code + PKCE. After login the harness calls :loadCodeAssist (with the Antigravity IDE 2.1.1 fingerprint) to provision a cloudaicompanionProject, then sends chat through the daily Code Assist gateway.", + "headers": [ + ["User-Agent", "antigravity"] + ], + "token_path": "antigravity.json", + "script": "oauth/antigravity-oauth.py", + "login_timeout_ms": 300000, + "token_timeout_ms": 30000 + } +} diff --git a/core/providers/gemini-cli/README.md b/core/providers/gemini-cli/README.md new file mode 100644 index 0000000..71809b4 --- /dev/null +++ b/core/providers/gemini-cli/README.md @@ -0,0 +1,105 @@ +# Gemini CLI — Google OAuth + +This first-party bundle connects the harness to the **Gemini CLI** +(`@google-gemini/gemini-cli`) subscription tier via Google's **Code +Assist / `cloudcode-pa` gateway**. It uses Google's standard OAuth 2.0 +Authorization Code flow with PKCE against the public gemini-cli client, +then runs `:loadCodeAssist` to fetch a real `cloudaicompanionProject` +for the authenticated user. + +Use `/login` and choose **Gemini CLI (Google)**, or run: + +```text +/login gemini-cli +``` + +The harness binds a loopback redirect, opens the browser to Google's +authorization page, captures the code, exchanges it for tokens, runs +`loadCodeAssist` (with the gemini-cli fingerprint headers — `X-Goog-Api-Client` ++ `Client-Metadata`), and persists everything to +`~/.config/catalyst-code/oauth/gemini-cli.json`. On every subsequent +turn the harness refreshes the access token when needed and injects an +`x-goog-user-project` header carrying the discovered project id, so +requests route to the user's real Cloud project — not the shared +freemium project the adapter ships as a fallback. + +If `loadCodeAssist` returns no project (new Google account with no +Code Assist history yet), the harness also calls `:onboardUser` and polls +until provisioning finishes (`done=true`), so the first request never +fails with `project not found`. + +## Models + +Gemini CLI exposes the Gemini 3 / 3.1 Pro + Flash previews plus the +2.5 family. Model IDs map 1:1 to upstream Code Assist slugs — no +aliasing: + +```text +gemini-3.1-pro-preview +gemini-3-pro-preview +gemini-3-flash-preview +gemini-3.1-flash-lite-preview +gemini-2.5-pro +gemini-2.5-flash +gemini-2.5-flash-lite +``` + +## Endpoints + +| Purpose | URL | +|-----------------------------|--------------------------------------------------------------------| +| Authorization | `https://accounts.google.com/o/oauth2/v2/auth` | +| Token exchange / refresh | `https://oauth2.googleapis.com/token` | +| Userinfo (email) | `https://www.googleapis.com/oauth2/v1/userinfo` | +| Project discovery | `https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` | +| User onboarding (fallback) | `https://cloudcode-pa.googleapis.com/v1internal:onboardUser` | +| Chat (streamGenerateContent)| `https://cloudcode-pa.googleapis.com/v1internal` | + +## Client identity + +The script uses the public Gemini CLI OAuth client: + +| Field | Value | +|------------------|----------------------------------------------------------------------------------| +| `client_id` | `681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com` | +| `client_secret` | `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` | +| `User-Agent` | `google-api-nodejs-client/9.15.1` | +| `X-Goog-Api-Client` | `google-cloud-sdk vscode_cloudshelleditor/0.1` | +| `Client-Metadata`| `{ ideType: 0, platform: 0, pluginType: 0 }` | + +These are intentional — the gemini-cli npm package ships the same public +client and fingerprints. Google's backend uses them to differentiate +gemini-cli traffic from Antigravity / 3rd-party clients; including the +wrong pair (or omitting the `X-Goog-Api-Client` / `Client-Metadata` +headers) makes OAuth succeed but `loadCodeAssist` returns no project +and the first chat request fails with "project not found". + +## Wire + +After OAuth + project discovery, every chat turn is a POST to +`{base_url}:streamGenerateContent?alt=sse` with body shape: + +```json +{ + "model": "gemini-3.1-pro-preview", + "project": "", + "userAgent": "google-api-nodejs-client/9.15.1", + "request": { + "contents": [...], + "systemInstruction": {...}, + "tools": [...], + "generationConfig": {"maxOutputTokens": N} + } +} +``` + +The `project` field comes from the harness's `x-goog-user-project` header +(merged from the OAuth plugin's per-request headers); see +`core/src/providers/google_code_assist.rs`. + +## References + +- Gemini CLI source fingerprint: captured from a live `@google-gemini/gemini-cli` + install. +- Code Assist wire format + project discovery: Google's open-source + Gemini CLI source. \ No newline at end of file diff --git a/core/providers/gemini-cli/oauth/gemini-cli-oauth.py b/core/providers/gemini-cli/oauth/gemini-cli-oauth.py new file mode 100755 index 0000000..5496b2d --- /dev/null +++ b/core/providers/gemini-cli/oauth/gemini-cli-oauth.py @@ -0,0 +1,550 @@ +#!/usr/bin/env python3 +"""Gemini CLI (Google) OAuth + Code Assist project discovery. + +The harness sends one JSON object on stdin with an ``action`` of ``login``, +``complete``, ``token``, or ``clear``. One JSON object is written to stdout. + +This file deliberately uses only the Python standard library. The endpoint +names, client id, redirect URI, refresh grant, and loadCodeAssist metadata +mirror Google's open-source ``gemini`` CLI so the upstream Code Assist +gateway provisions a real ``cloudaicompanionProject`` for us. + +Compared to the Antigravity plugin this one uses: + +* a different public OAuth client (the open-source gemini-cli client); +* a simpler scope list (no cclog / experimentsandconfigs); +* the prod Code Assist host for chat (the daily host rejects gemini-cli + traffic more often than antigravity traffic in practice); +* the gemini-cli loadCodeAssist fingerprint (google-api-nodejs-client UA + + X-Goog-Api-Client + Client-Metadata with the IDE/PLATFORM/PLUGIN_TYPE + numeric enums the gemini-cli binary actually sends). +""" + +import base64 +import hashlib +import json +import os +import secrets +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request + + +# ─── Gemini CLI public OAuth client ──────────────────────────────────────── +# Public client_id / client_secret shipped in the open-source +# ``@google-gemini/gemini-cli`` npm package. Reused here unchanged. +CLIENT_ID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com" +CLIENT_SECRET = "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" + +AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" +TOKEN_URL = "https://oauth2.googleapis.com/token" +USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo" + +# Gemini CLI's standard scope list. ``cclog`` and +# ``experimentsandconfigs`` are Antigravity-specific and intentionally +# excluded here — including them with the wrong client id would silently +# drop the Antigravity-only scopes on Google's side. +SCOPES = [ + "openid", + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", +] + +# Gemini CLI fingerprints captured from a live ``gemini`` CLI install. +# Google fingerprints these headers + the metadata payload and silently +# refuses to provision a project if they look wrong (or if they're +# missing entirely), so the OAuth flow would technically succeed but the +# first chat request would 404 with "Project not found". +USER_AGENT = "google-api-nodejs-client/9.15.1" +X_GOOG_API_CLIENT = "google-cloud-sdk vscode_cloudshelleditor/0.1" +# Numeric enum values that match what gemini-cli actually sends. These are +# not the same as Antigravity (different ideType/pluginType). +CLIENT_METADATA = {"ideType": 0, "platform": 0, "pluginType": 0} + +LOAD_CODE_ASSIST_URL = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" +ONBOARD_USER_URL = "https://cloudcode-pa.googleapis.com/v1internal:onboardUser" + +REFRESH_LEAD_S = 300 +ONBOARD_MAX_ATTEMPTS = 5 +ONBOARD_POLL_S = 2 +HTTP_TIMEOUT_S = 30 + + +# ─── harness I/O ─────────────────────────────────────────────────────────── + +def emit(obj): + sys.stdout.write(json.dumps(obj, separators=(",", ":"))) + sys.stdout.flush() + + +def die(message): + emit({"ok": False, "error": str(message)}) + raise SystemExit(0) + + +def now(): + return int(time.time()) + + +# ─── HTTP helpers ────────────────────────────────────────────────────────── + +def http_post(url, body, content_type, extra_headers=None): + headers = { + "Accept": "application/json", + "Content-Type": content_type, + "User-Agent": USER_AGENT, + } + if extra_headers: + headers.update(extra_headers) + req = urllib.request.Request(url, data=body, method="POST", headers=headers) + try: + with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S) as response: + raw = response.read().decode("utf-8", "replace") + return response.status, parse_json(raw) + except urllib.error.HTTPError as exc: + raw = exc.read().decode("utf-8", "replace") + return exc.code, parse_json(raw) + except Exception as exc: + return 0, {"error": "request_failed", "error_description": str(exc)} + + +def parse_json(raw): + try: + value = json.loads(raw) if raw.strip() else {} + return value if isinstance(value, dict) else {} + except Exception: + return {"error": "invalid_json", "error_description": raw[:500]} + + +def post_form(url, fields, extra_headers=None): + return http_post( + url, + urllib.parse.urlencode(fields).encode("utf-8"), + "application/x-www-form-urlencoded", + extra_headers, + ) + + +def post_json(url, payload, extra_headers=None): + return http_post( + url, + json.dumps(payload, separators=(",", ":")).encode("utf-8"), + "application/json", + extra_headers, + ) + + +def error_text(status, data): + return ( + data.get("error_description") + or data.get("error") + or ("network request failed" if status == 0 else f"HTTP {status}") + ) + + +# ─── on-disk token file ──────────────────────────────────────────────────── + +def token_path(ctx): + return os.path.abspath(str(ctx.get("token_path") or "gemini-cli.json")) + + +def read_token(path): + try: + with open(path, encoding="utf-8") as handle: + value = json.load(handle) + return value if isinstance(value, dict) else None + except (OSError, ValueError, TypeError): + return None + + +def atomic_write(path, value): + path = os.path.abspath(path) + parent = os.path.dirname(path) or "." + os.makedirs(parent, mode=0o700, exist_ok=True) + fd, tmp = tempfile.mkstemp(prefix=".gemini-cli-oauth-", dir=parent) + try: + try: + os.fchmod(fd, 0o600) + except AttributeError: + pass + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(value, handle, separators=(",", ":")) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp, path) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise + + +def lock_for(path): + try: + import fcntl + except ImportError: + return None + handle = open(path + ".lock", "a+", encoding="utf-8") + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + except OSError: + pass + return handle + + +def unlock(handle): + if handle is not None: + try: + handle.close() + except OSError: + pass + + +# ─── PKCE + auth URL ─────────────────────────────────────────────────────── + +def make_pkce(): + """Generate (verifier, challenge, state) for S256 PKCE.""" + verifier = base64.urlsafe_b64encode(secrets.token_bytes(48)).rstrip(b"=").decode("ascii") + challenge = base64.urlsafe_b64encode( + hashlib.sha256(verifier.encode("ascii")).digest() + ).rstrip(b"=").decode("ascii") + state = base64.urlsafe_b64encode(secrets.token_bytes(24)).rstrip(b"=").decode("ascii") + return verifier, challenge, state + + +def build_authorize_url(redirect_uri, state, challenge): + params = { + "client_id": CLIENT_ID, + "response_type": "code", + "redirect_uri": redirect_uri, + "scope": " ".join(SCOPES), + "state": state, + "code_challenge": challenge, + "code_challenge_method": "S256", + "access_type": "offline", + "prompt": "consent", + "include_granted_scopes": "true", + } + return AUTH_URL + "?" + urllib.parse.urlencode(params) + + +# ─── token exchange ──────────────────────────────────────────────────────── + +def exchange_code(code, redirect_uri, verifier): + status, data = post_form( + TOKEN_URL, + { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "code_verifier": verifier, + }, + ) + return status, data + + +def refresh_access_token(refresh_token): + status, data = post_form( + TOKEN_URL, + { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + }, + ) + return status, data + + +def fetch_user_email(access_token): + req = urllib.request.Request( + USERINFO_URL, + headers={"Authorization": f"Bearer {access_token}", "User-Agent": USER_AGENT}, + ) + try: + with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S) as response: + data = parse_json(response.read().decode("utf-8", "replace")) + return data.get("email") or "" + except Exception: + return "" + + +def normalize_tokens(tokens): + """Coerce the raw OAuth response into the persistent shape on disk.""" + access = tokens.get("access_token") or "" + refresh = tokens.get("refresh_token") or "" + if not access and not refresh: + return None + expires_in = int(tokens.get("expires_in") or 0) + return { + "access_token": access, + "refresh_token": refresh, + "expires_in": expires_in, + "expires_at": now() + max(expires_in, 60), + "scope": tokens.get("scope", ""), + "token_type": tokens.get("token_type", "Bearer"), + } + + +# ─── Code Assist: loadCodeAssist + onboardUser ───────────────────────────── + +def _code_assist_headers(access_token): + return { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + "User-Agent": USER_AGENT, + "X-Goog-Api-Client": X_GOOG_API_CLIENT, + "Client-Metadata": json.dumps(CLIENT_METADATA, separators=(",", ":")), + } + + +def _code_assist_body(include_tier=False, tier_id=None): + body = {"metadata": dict(CLIENT_METADATA)} + if include_tier: + body["tierId"] = tier_id or "legacy-tier" + return body + + +def _extract_project(payload): + """Pull ``cloudaicompanionProject`` out of a loadCodeAssist / onboardUser response.""" + project = payload.get("cloudaicompanionProject") + if isinstance(project, str) and project.strip(): + return project.strip() + if isinstance(project, dict): + nested = project.get("id") + if isinstance(nested, str) and nested.strip(): + return nested.strip() + nested = (payload.get("response") or {}).get("cloudaicompanionProject") + if isinstance(nested, str) and nested.strip(): + return nested.strip() + if isinstance(nested, dict): + id_ = nested.get("id") + if isinstance(id_, str) and id_.strip(): + return id_.strip() + return None + + +def _pick_default_tier(payload): + tiers = payload.get("allowedTiers") + if isinstance(tiers, list): + for tier in tiers: + if isinstance(tier, dict) and tier.get("isDefault") is True: + tid = tier.get("id") + if isinstance(tid, str) and tid.strip(): + return tid.strip() + return "legacy-tier" + + +def load_code_assist_payload(access_token): + """POST :loadCodeAssist and return the raw payload (or ``None`` on failure).""" + status, data = post_json( + LOAD_CODE_ASSIST_URL, + _code_assist_body(), + _code_assist_headers(access_token), + ) + return data if status == 200 else None + + +def onboard_user(access_token, tier_id): + """POST :onboardUser, polling until ``done=true``; return project_id.""" + for attempt in range(1, ONBOARD_MAX_ATTEMPTS + 1): + status, data = post_json( + ONBOARD_USER_URL, + _code_assist_body(include_tier=True, tier_id=tier_id), + _code_assist_headers(access_token), + ) + if status != 200: + return None + if data.get("done") is True: + return _extract_project(data) + if attempt < ONBOARD_MAX_ATTEMPTS: + time.sleep(ONBOARD_POLL_S) + return None + + +def discover_project_id(access_token): + """Try loadCodeAssist; on failure, fall back to onboardUser polling.""" + payload = load_code_assist_payload(access_token) + if payload is not None: + project = _extract_project(payload) + if project: + return project + tier = _pick_default_tier(payload) + return onboard_user(access_token, tier) + return None + + +# ─── actions ─────────────────────────────────────────────────────────────── + +def do_login(ctx): + """Build the authorize URL. The harness binds the loopback + opens the + browser; we receive the ``code`` back in ``complete``.""" + verifier, challenge, state = make_pkce() + redirect_uri = str(ctx.get("redirect_uri") or "").strip() + if not redirect_uri: + die("Gemini CLI OAuth requires a loopback redirect_uri (catcode binds this)") + + url = build_authorize_url(redirect_uri, state, challenge) + emit( + { + "url": url, + "flow": "web", + "state": state, + "pending": {"verifier": verifier}, + "message": ( + "Open the URL to authorize Gemini CLI. The token is then " + "auto-discovered via loadCodeAssist and a real Cloud project " + "is provisioned if needed." + ), + } + ) + + +def do_complete(ctx): + code = str(ctx.get("code") or "").strip() + if not code: + die("no authorization code received from Gemini CLI") + pending = ctx.get("pending") or {} + verifier = str(pending.get("verifier") or "").strip() + if not verifier: + die("missing PKCE verifier in pending state; restart /login gemini-cli") + redirect_uri = str(ctx.get("redirect_uri") or "").strip() + if not redirect_uri: + die("missing redirect_uri in complete context; restart /login gemini-cli") + + status, tokens = exchange_code(code, redirect_uri, verifier) + if status != 200 or not tokens.get("access_token"): + die("Gemini CLI token exchange failed: " + error_text(status, tokens)) + + normalized = normalize_tokens(tokens) + if not normalized: + die("Gemini CLI token exchange returned no usable tokens") + + project_id = discover_project_id(normalized["access_token"]) + if project_id: + normalized["project_id"] = project_id + + email = fetch_user_email(normalized["access_token"]) + if email: + normalized["email"] = email + + atomic_write(token_path(ctx), normalized) + emit({"ok": True}) + + +def do_token(ctx): + path = token_path(ctx) + handle = lock_for(path) + try: + token = read_token(path) + if not token: + emit({"access_token": None}) + return + + current = now() + expires_at = int(token.get("expires_at") or 0) + needs_refresh = (not token.get("access_token")) or ( + expires_at > 0 and expires_at - current <= REFRESH_LEAD_S + ) + + if needs_refresh and token.get("refresh_token"): + # Another harness process may have refreshed while we waited for + # the flock; always re-read before making a network request. + current_token = read_token(path) or token + current_exp = int(current_token.get("expires_at") or 0) + if current_token.get("access_token") and ( + current_exp == 0 or current_exp - now() > REFRESH_LEAD_S + ): + token = current_token + else: + status, data = refresh_access_token(current_token["refresh_token"]) + if status != 200 or not data.get("access_token"): + emit({"access_token": None}) + return + rotated = normalize_tokens( + { + "access_token": data.get("access_token", ""), + "refresh_token": data.get("refresh_token") + or current_token.get("refresh_token", ""), + "expires_in": int(data.get("expires_in") or 0), + "scope": data.get("scope", current_token.get("scope", "")), + "token_type": data.get("token_type", "Bearer"), + } + ) + if not rotated: + emit({"access_token": None}) + return + # Preserve project_id + email across rotations — those were + # discovered once and remain valid for the lifetime of the + # OAuth grant. + rotated["project_id"] = current_token.get("project_id", "") + rotated["email"] = current_token.get("email", "") + atomic_write(path, rotated) + token = rotated + + access = token.get("access_token") or "" + if not access: + emit({"access_token": None}) + return + + headers = [] + project_id = str(token.get("project_id") or "").strip() + if project_id: + # The harness's Google Code Assist adapter resolves the project + # via this header (one of three accepted names). The plugin sets + # the real per-user project so requests don't fall back to the + # shared freemium project that the adapter ships as a default. + headers.append(["x-goog-user-project", project_id]) + emit( + { + "access_token": access, + "expires_at": int(token.get("expires_at") or 0), + "headers": headers, + } + ) + finally: + unlock(handle) + + +def do_clear(ctx): + path = token_path(ctx) + for candidate in (path, path + ".lock"): + try: + os.remove(candidate) + except OSError: + pass + emit({"ok": True}) + + +def main(): + try: + raw = sys.stdin.read() + ctx = json.loads(raw) if raw.strip() else {} + if not isinstance(ctx, dict): + die("OAuth context must be a JSON object") + action = ctx.get("action", "") + if action == "login": + do_login(ctx) + elif action == "complete": + do_complete(ctx) + elif action == "token": + do_token(ctx) + elif action == "clear": + do_clear(ctx) + else: + die("unknown action: %r" % action) + except SystemExit: + raise + except Exception as exc: + die("Gemini CLI OAuth provider error: " + str(exc)) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/core/providers/gemini-cli/plugin.json b/core/providers/gemini-cli/plugin.json new file mode 100644 index 0000000..7b353bc --- /dev/null +++ b/core/providers/gemini-cli/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "gemini-cli", + "version": "0.1.0", + "description": "Google Gemini CLI subscription access — OAuth + Code Assist project discovery. Auto-staged into every install.", + "oauth": { + "provider_id": "gemini-cli", + "label": "Gemini CLI (Google)", + "kind": "openai", + "base_url": "https://cloudcode-pa.googleapis.com/v1internal", + "description": "Gemini CLI / Code Assist OAuth — Authorization Code + PKCE. After login the harness calls :loadCodeAssist (with the gemini-cli fingerprint) to provision a cloudaicompanionProject, then sends chat through the prod Code Assist gateway.", + "headers": [ + ["User-Agent", "google-api-nodejs-client/9.15.1"] + ], + "token_path": "gemini-cli.json", + "script": "oauth/gemini-cli-oauth.py", + "login_timeout_ms": 300000, + "token_timeout_ms": 30000 + } +} \ No newline at end of file diff --git a/core/src/staging.rs b/core/src/staging.rs index 6a7e948..3eb10ad 100644 --- a/core/src/staging.rs +++ b/core/src/staging.rs @@ -27,7 +27,7 @@ use std::path::PathBuf; /// Bump when the bundled default set changes meaningfully. The marker file /// stores this; on a version mismatch we re-scan for *missing* files (existing /// user files are still never overwritten) and then re-stamp the marker. -pub const STAGING_VERSION: u32 = 6; +pub const STAGING_VERSION: u32 = 7; /// `~/.catalyst-code` — the global, user-owned home for harness defaults. /// All staged files live under here (agents/, skills/, plugins/, README.md). @@ -262,6 +262,34 @@ fn bundled_files() -> Vec<(&'static str, &'static str)> { "plugins/deepseek/README.md", include_str!("../providers/deepseek/README.md"), ), + // --- antigravity provider (Google Antigravity IDE subscription — + // OAuth + Code Assist loadCodeAssist project discovery). --- + ( + "plugins/antigravity/plugin.json", + include_str!("../providers/antigravity/plugin.json"), + ), + ( + "plugins/antigravity/oauth/antigravity-oauth.py", + include_str!("../providers/antigravity/oauth/antigravity-oauth.py"), + ), + ( + "plugins/antigravity/README.md", + include_str!("../providers/antigravity/README.md"), + ), + // --- gemini-cli provider (Google Gemini CLI subscription — + // OAuth + Code Assist loadCodeAssist project discovery). --- + ( + "plugins/gemini-cli/plugin.json", + include_str!("../providers/gemini-cli/plugin.json"), + ), + ( + "plugins/gemini-cli/oauth/gemini-cli-oauth.py", + include_str!("../providers/gemini-cli/oauth/gemini-cli-oauth.py"), + ), + ( + "plugins/gemini-cli/README.md", + include_str!("../providers/gemini-cli/README.md"), + ), // --- A short guide to the global layout + override model. --- ("README.md", GLOBAL_README), ] @@ -274,6 +302,8 @@ fn executable_rel_paths() -> &'static [&'static str] { "plugins/telemetry/hooks/session_stop.py", "plugins/kimi/oauth/kimi-oauth.py", "plugins/codex/oauth/codex-oauth.py", + "plugins/antigravity/oauth/antigravity-oauth.py", + "plugins/gemini-cli/oauth/gemini-cli-oauth.py", ] } @@ -369,7 +399,9 @@ project. │ ├── vision-handoff/ # cheapest same-provider vision handoff (default ON) │ ├── kimi/ # Moonshot subscription OAuth provider │ ├── codex/ # ChatGPT subscription OAuth provider - │ └── deepseek/ # DeepSeek API-key provider + │ ├── deepseek/ # DeepSeek API-key provider + │ ├── antigravity/ # Google Antigravity IDE OAuth + Code Assist + │ └── gemini-cli/ # Google Gemini CLI OAuth + Code Assist ├── README.md # this file └── .staged # staging schema version marker (do not edit) @@ -455,6 +487,34 @@ mod tests { home.join("plugins/deepseek/README.md").exists(), "deepseek provider README should be staged on first run" ); + assert!( + home.join("plugins/antigravity/plugin.json").exists(), + "antigravity provider should be staged on first run" + ); + assert!( + home + .join("plugins/antigravity/oauth/antigravity-oauth.py") + .exists(), + "antigravity oauth script should be staged on first run" + ); + assert!( + home.join("plugins/antigravity/README.md").exists(), + "antigravity provider README should be staged on first run" + ); + assert!( + home.join("plugins/gemini-cli/plugin.json").exists(), + "gemini-cli provider should be staged on first run" + ); + assert!( + home + .join("plugins/gemini-cli/oauth/gemini-cli-oauth.py") + .exists(), + "gemini-cli oauth script should be staged on first run" + ); + assert!( + home.join("plugins/gemini-cli/README.md").exists(), + "gemini-cli provider README should be staged on first run" + ); assert!(home.join(".staged").exists()); assert_eq!( std::fs::read_to_string(home.join(".staged")).unwrap(), @@ -515,6 +575,26 @@ mod tests { .permissions() .mode(); assert!(mode & 0o111 != 0, "codex oauth script must be executable"); + let mode = std::fs::metadata( + home.join("plugins/antigravity/oauth/antigravity-oauth.py"), + ) + .unwrap() + .permissions() + .mode(); + assert!( + mode & 0o111 != 0, + "antigravity oauth script must be executable" + ); + let mode = std::fs::metadata( + home.join("plugins/gemini-cli/oauth/gemini-cli-oauth.py"), + ) + .unwrap() + .permissions() + .mode(); + assert!( + mode & 0o111 != 0, + "gemini-cli oauth script must be executable" + ); } } From 8ec5ceba900a380f6294f278ddf015fd447ee209 Mon Sep 17 00:00:00 2001 From: phantomic12 Date: Fri, 7 Aug 2026 02:45:34 -0400 Subject: [PATCH 02/38] feat(antigravity): allow CATALYST_CODE_ANTIGRAVITY_PROJECT override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the auto-provisioned Antigravity project (e.g. the Google-managed synthetic-expanse-sxhhm for free-tier individuals) is unusable — the user is not a member/owner so they can't enable Cloud Code Private API on it — this lets them pin the project to one they do own. Useful escape hatch while we wait for either: * Google to auto-enable Cloud Code Private API on the Antigravity- managed project (some users report a ~24h delay after first OAuth). * The user to find another way to enable the API. Tested end-to-end: env override is respected on both the loadCodeAssist->onboardUser bootstrap AND on the project_id persisted in the token file (so the /token action's x-goog-user-project header also points at the override). --- .../antigravity/oauth/antigravity-oauth.py | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/core/providers/antigravity/oauth/antigravity-oauth.py b/core/providers/antigravity/oauth/antigravity-oauth.py index 866a0fa..2227ca3 100755 --- a/core/providers/antigravity/oauth/antigravity-oauth.py +++ b/core/providers/antigravity/oauth/antigravity-oauth.py @@ -47,8 +47,20 @@ # Scopes the Antigravity IDE requests. ``cclog`` + ``experimentsandconfigs`` # are Antigravity-specific and are required for Code Assist provisioning. +# Google OAuth requires ``/oauth2callback`` (not arbitrary paths) for the +# Antigravity OAuth client — only this path is registered as a loopback +# redirect URI for ``http://127.0.0.1:`` in the client's Google Cloud +# console entry. Using ``/callback`` makes Google reject the request as a +# non-compliant redirect URI ("doesn't comply with Google's OAuth 2.0 +# policy for keeping apps secure"). We mirror the path the Antigravity IDE +# binary uses. +REDIRECT_PATH = "/oauth2callback" + +# Scopes the Antigravity IDE requests. ``openid`` is intentionally omitted +# (same reason as gemini-cli — it triggers Google's unverified-app gate on +# ``cclog`` / ``experimentsandconfigs`` requests). ``userinfo.email`` + +# ``userinfo.profile`` are sufficient for the loadCodeAssist user lookup. SCOPES = [ - "openid", "https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile", @@ -226,6 +238,10 @@ def make_pkce(): def build_authorize_url(redirect_uri, state, challenge, extra=None): + # The Antigravity IDE binary does not include ``prompt=consent`` or + # ``include_granted_scopes=true``; including either can confuse Google's + # refresh-token issuance for the Antigravity OAuth client. Keep the + # request minimal: redirect + scope + PKCE + state + offline. params = { "client_id": CLIENT_ID, "response_type": "code", @@ -235,8 +251,6 @@ def build_authorize_url(redirect_uri, state, challenge, extra=None): "code_challenge": challenge, "code_challenge_method": "S256", "access_type": "offline", - "prompt": "consent", - "include_granted_scopes": "true", } if extra: params.update(extra) @@ -376,7 +390,18 @@ def onboard_user(access_token, tier_id): def discover_project_id(access_token): - """Try loadCodeAssist; on failure, fall back to onboardUser polling.""" + """Try loadCodeAssist; on failure, fall back to onboardUser polling. + + If ``CATALYST_CODE_ANTIGRAVITY_PROJECT`` is set in the environment, it + wins over whatever Google provisioned — the auto-provisioned project + is often a Google-managed one the user is not an owner of, so they + cannot enable Cloud Code Private API on it from the Cloud Console. + Pinning a project the user owns + can enable APIs on is the only way + to unblock chat in that situation. + """ + override = os.environ.get(ANTIGRAVITY_PROJECT_ENV, "").strip() + if override: + return override # We need the loadCodeAssist payload too (for the tier), so re-call. status, data = post_json( LOAD_CODE_ASSIST_URL, @@ -445,6 +470,12 @@ def do_complete(ctx): project_id = discover_project_id(normalized["access_token"]) if project_id: normalized["project_id"] = project_id + # Re-check the env override after discover_project_id — if set, the + # auto-provisioned project would otherwise be persisted and chat would + # be stuck on SERVICE_DISABLED. + override = os.environ.get(ANTIGRAVITY_PROJECT_ENV, "").strip() + if override: + normalized["project_id"] = override email = fetch_user_email(normalized["access_token"]) if email: From a9047f12da7504b244be508440878f4dfdc7f347 Mon Sep 17 00:00:00 2001 From: phantomic12 Date: Fri, 7 Aug 2026 15:27:58 -0400 Subject: [PATCH 03/38] fix(providers): gemini-cli works without x-goog-user-project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the "SERVICE_DISABLED" 403s: the OAuth plugins were emitting `x-goog-user-project`, a Google consumer-project header that forces a Cloud Code Private API enablement check. Free-tier / managed projects fail that check even when body.project alone is accepted. 9router's gemini-cli executor never sends that header — project goes in the JSON body only. Verified end-to-end against the live gateway: gemini-2.5-flash / pro / flash-lite / 3.1-flash-lite-preview → 200 with body.project=synthetic-expanse-sxhhm and NO x-goog-user-project. Changes: - Both antigravity + gemini-cli token actions now emit `x-code-assist-project` (harness adapter still resolves body.project from it; does not trip the consumer gate). - gemini-cli loadCodeAssist uses Antigravity-style ClientMetadata (ideType=9, pluginType=2) + mode=1, matching 9router. - gemini-cli project discovery falls back to CATALYST_CODE_GEMINI_CLI_PROJECT or the sibling Antigravity token's project_id when free-tier returns UNSUPPORTED_CLIENT. - README documents working models + the header gotcha. --- .../antigravity/oauth/antigravity-oauth.py | 12 ++- core/providers/gemini-cli/README.md | 51 ++++++++- .../gemini-cli/oauth/gemini-cli-oauth.py | 100 +++++++++++++++--- 3 files changed, 140 insertions(+), 23 deletions(-) diff --git a/core/providers/antigravity/oauth/antigravity-oauth.py b/core/providers/antigravity/oauth/antigravity-oauth.py index 2227ca3..5291fc4 100755 --- a/core/providers/antigravity/oauth/antigravity-oauth.py +++ b/core/providers/antigravity/oauth/antigravity-oauth.py @@ -543,11 +543,13 @@ def do_token(ctx): headers = [] project_id = str(token.get("project_id") or "").strip() if project_id: - # The harness's Google Code Assist adapter resolves the project - # via this header (one of three accepted names). The plugin sets - # the real per-user project so requests don't fall back to the - # shared freemium project that the adapter ships as a default. - headers.append(["x-goog-user-project", project_id]) + # CRITICAL: do NOT use x-goog-user-project. That Google consumer + # header forces a Cloud Code Private API enablement check and + # returns SERVICE_DISABLED on free-tier / managed projects. + # Put the project in the request body only (via the harness + # adapter reading x-code-assist-project). Verified: body.project + # alone works for gemini-cli; x-goog-user-project → 403. + headers.append(["x-code-assist-project", project_id]) emit( { "access_token": access, diff --git a/core/providers/gemini-cli/README.md b/core/providers/gemini-cli/README.md index 71809b4..c383eef 100644 --- a/core/providers/gemini-cli/README.md +++ b/core/providers/gemini-cli/README.md @@ -102,4 +102,53 @@ The `project` field comes from the harness's `x-goog-user-project` header - Gemini CLI source fingerprint: captured from a live `@google-gemini/gemini-cli` install. - Code Assist wire format + project discovery: Google's open-source - Gemini CLI source. \ No newline at end of file + Gemini CLI source. + +## Working models (verified 2026-08) + +With a free-tier Google account the gemini-cli OAuth client is marked +`UNSUPPORTED_CLIENT` for free-tier project *provisioning*, but chat still +works when `body.project` is set to a managed project the same account +already owns (e.g. one provisioned by the sibling Antigravity login). Do +**not** send `x-goog-user-project` — that header forces a Cloud Code +Private API consumer check and returns `SERVICE_DISABLED`. The plugin +emits `x-code-assist-project` instead so the harness adapter only puts +the id into `body.project`. + +Verified working (HTTP 200, real text): + +```text +gemini-2.5-pro +gemini-2.5-flash +gemini-2.5-flash-lite +gemini-3.1-flash-lite-preview +``` + +404 / not available on free-tier gemini-cli: + +```text +gemini-3-pro-preview +gemini-3-flash-preview +gemini-3.1-pro-preview +gemini-3.1-pro-high # Antigravity-only slug +claude-* # Antigravity-only +``` + +## Gotchas + +1. **Never send `x-goog-user-project`.** It is a Google consumer-project + header and trips `SERVICE_DISABLED` on free-tier managed projects. + Project goes in the JSON body only (`{"project": "...", "model": "...", + "request": {...}}`). The plugin uses `x-code-assist-project` which the + harness adapter translates into `body.project` without the consumer + gate. +2. **User-Agent for chat** should look like the official CLI: + `GeminiCLI/0.34.0/ (linux; x64; terminal)` plus + `X-Goog-Api-Client: google-genai-sdk/1.41.0 gl-node/v22.19.0`. The + harness currently leaves User-Agent as whatever `plugin.json` sets; + body-level `userAgent: "antigravity"` (set by the shared adapter) is + tolerated by the gateway. +3. **Project discovery** may return nothing for free-tier gemini-cli + accounts. The script then falls back to + `CATALYST_CODE_GEMINI_CLI_PROJECT` or the sibling Antigravity token's + `project_id` under `~/.config/catalyst-code/oauth/antigravity.json`. diff --git a/core/providers/gemini-cli/oauth/gemini-cli-oauth.py b/core/providers/gemini-cli/oauth/gemini-cli-oauth.py index 5496b2d..9352a12 100755 --- a/core/providers/gemini-cli/oauth/gemini-cli-oauth.py +++ b/core/providers/gemini-cli/oauth/gemini-cli-oauth.py @@ -43,12 +43,21 @@ TOKEN_URL = "https://oauth2.googleapis.com/token" USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo" -# Gemini CLI's standard scope list. ``cclog`` and -# ``experimentsandconfigs`` are Antigravity-specific and intentionally -# excluded here — including them with the wrong client id would silently -# drop the Antigravity-only scopes on Google's side. +# Google OAuth requires ``/oauth2callback`` (not arbitrary paths) for the +# gemini-cli OAuth client — only this path is registered as a loopback +# redirect URI for ``http://127.0.0.1:`` in the client's Google Cloud +# console entry. Using ``/callback`` makes Google reject the request as a +# non-compliant redirect URI ("doesn't comply with Google's OAuth 2.0 +# policy for keeping apps secure"). The official gemini-cli binary uses +# this exact path; we mirror it. +REDIRECT_PATH = "/oauth2callback" + +# Scopes the official ``gemini`` CLI requests (no ``openid``). Including +# ``openid`` triggers Google's "unverified app" rejection for this +# public-but-unverified OAuth client — the gemini-cli project deliberately +# omits it. ``userinfo.email`` + ``userinfo.profile`` alone are sufficient +# for the loadCodeAssist user-info lookup. SCOPES = [ - "openid", "https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile", @@ -63,7 +72,24 @@ X_GOOG_API_CLIENT = "google-cloud-sdk vscode_cloudshelleditor/0.1" # Numeric enum values that match what gemini-cli actually sends. These are # not the same as Antigravity (different ideType/pluginType). -CLIENT_METADATA = {"ideType": 0, "platform": 0, "pluginType": 0} +# 9router's gemini-cli path uses Antigravity-style ClientMetadata on +# loadCodeAssist (ideType=9 / pluginType=2). Using the zeroed "unspecified" +# values makes Google refuse to provision a cloudaicompanionProject for +# free-tier individuals (UNSUPPORTED_CLIENT on free-tier). +def _platform_enum(): + import platform as _plat + s = _plat.system().lower() + a = _plat.machine().lower() + if s == "darwin": + return 2 if "arm64" in a or "aarch64" in a else 1 + if s == "linux": + return 4 if "arm64" in a or "aarch64" in a else 3 + if s == "windows" or s == "win32": + return 5 + return 0 + + +CLIENT_METADATA = {"ideType": 9, "platform": _platform_enum(), "pluginType": 2} LOAD_CODE_ASSIST_URL = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" ONBOARD_USER_URL = "https://cloudcode-pa.googleapis.com/v1internal:onboardUser" @@ -218,6 +244,11 @@ def make_pkce(): def build_authorize_url(redirect_uri, state, challenge): + # The official ``gemini`` CLI does NOT send ``prompt=consent`` or + # ``include_granted_scopes=true``; including them can confuse Google's + # refresh-token issuance logic for the public-but-unverified gemini-cli + # OAuth client. Keep the request minimal: redirect + scope + PKCE + + # state + offline access_type (required for a refresh_token). params = { "client_id": CLIENT_ID, "response_type": "code", @@ -227,8 +258,6 @@ def build_authorize_url(redirect_uri, state, challenge): "code_challenge": challenge, "code_challenge_method": "S256", "access_type": "offline", - "prompt": "consent", - "include_granted_scopes": "true", } return AUTH_URL + "?" + urllib.parse.urlencode(params) @@ -305,8 +334,10 @@ def _code_assist_headers(access_token): } -def _code_assist_body(include_tier=False, tier_id=None): - body = {"metadata": dict(CLIENT_METADATA)} +def _code_assist_body(include_tier=False, tier_id=None, mode=1): + # mode=1 is the Code Assist mode 9router always sends; without it the + # free-tier gemini-cli OAuth client often gets no project back. + body = {"metadata": dict(CLIENT_METADATA), "mode": mode} if include_tier: body["tierId"] = tier_id or "legacy-tier" return body @@ -370,14 +401,44 @@ def onboard_user(access_token, tier_id): def discover_project_id(access_token): - """Try loadCodeAssist; on failure, fall back to onboardUser polling.""" + """Try loadCodeAssist; on failure, fall back to onboardUser polling. + + Free-tier gemini-cli OAuth often returns no project (Google now marks + free-tier as UNSUPPORTED_CLIENT for this OAuth client). Fallbacks, in + order: + 1. ``CATALYST_CODE_GEMINI_CLI_PROJECT`` env override. + 2. Sibling Antigravity token file's ``project_id`` (same Google + account often already has a working managed project via the + Antigravity OAuth flow — verified: body.project alone works). + """ + override = (os.environ.get("CATALYST_CODE_GEMINI_CLI_PROJECT") or "").strip() + if override: + return override payload = load_code_assist_payload(access_token) if payload is not None: project = _extract_project(payload) if project: return project tier = _pick_default_tier(payload) - return onboard_user(access_token, tier) + project = onboard_user(access_token, tier) + if project: + return project + # Sibling Antigravity token (same user, different OAuth client) often + # already holds a working managed project. Read it if present. + try: + sibling = os.path.join(os.path.dirname(os.path.abspath( + # token_path is not in scope here; reconstruct from common layout. + os.path.expanduser("~/.config/catalyst-code/oauth/antigravity.json") + )), "antigravity.json") if False else os.path.expanduser( + "~/.config/catalyst-code/oauth/antigravity.json" + ) + sib = read_token(sibling) + if sib: + pid = str(sib.get("project_id") or "").strip() + if pid: + return pid + except Exception: + pass return None @@ -497,11 +558,16 @@ def do_token(ctx): headers = [] project_id = str(token.get("project_id") or "").strip() if project_id: - # The harness's Google Code Assist adapter resolves the project - # via this header (one of three accepted names). The plugin sets - # the real per-user project so requests don't fall back to the - # shared freemium project that the adapter ships as a default. - headers.append(["x-goog-user-project", project_id]) + # CRITICAL: do NOT use x-goog-user-project. That Google consumer + # header forces a Cloud Code Private API enablement check and + # returns SERVICE_DISABLED on free-tier / managed projects. + # 9router's gemini-cli executor never sends it — project goes in + # the request body only. The harness adapter also accepts + # x-code-assist-project / cloudaicompanion-project, which only + # affect body.project resolution and do not trip the consumer + # API gate. Verified end-to-end: body.project alone works; + # x-goog-user-project → 403 SERVICE_DISABLED. + headers.append(["x-code-assist-project", project_id]) emit( { "access_token": access, From e79ebbe969a373d3034aa67a0e442f9313c028c5 Mon Sep 17 00:00:00 2001 From: phantomic12 Date: Fri, 7 Aug 2026 15:45:08 -0400 Subject: [PATCH 04/38] style: rustfmt staging.rs test blocks for new providers --- core/src/staging.rs | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/core/src/staging.rs b/core/src/staging.rs index 3eb10ad..e475c49 100644 --- a/core/src/staging.rs +++ b/core/src/staging.rs @@ -492,8 +492,7 @@ mod tests { "antigravity provider should be staged on first run" ); assert!( - home - .join("plugins/antigravity/oauth/antigravity-oauth.py") + home.join("plugins/antigravity/oauth/antigravity-oauth.py") .exists(), "antigravity oauth script should be staged on first run" ); @@ -506,8 +505,7 @@ mod tests { "gemini-cli provider should be staged on first run" ); assert!( - home - .join("plugins/gemini-cli/oauth/gemini-cli-oauth.py") + home.join("plugins/gemini-cli/oauth/gemini-cli-oauth.py") .exists(), "gemini-cli oauth script should be staged on first run" ); @@ -575,22 +573,19 @@ mod tests { .permissions() .mode(); assert!(mode & 0o111 != 0, "codex oauth script must be executable"); - let mode = std::fs::metadata( - home.join("plugins/antigravity/oauth/antigravity-oauth.py"), - ) - .unwrap() - .permissions() - .mode(); + let mode = + std::fs::metadata(home.join("plugins/antigravity/oauth/antigravity-oauth.py")) + .unwrap() + .permissions() + .mode(); assert!( mode & 0o111 != 0, "antigravity oauth script must be executable" ); - let mode = std::fs::metadata( - home.join("plugins/gemini-cli/oauth/gemini-cli-oauth.py"), - ) - .unwrap() - .permissions() - .mode(); + let mode = std::fs::metadata(home.join("plugins/gemini-cli/oauth/gemini-cli-oauth.py")) + .unwrap() + .permissions() + .mode(); assert!( mode & 0o111 != 0, "gemini-cli oauth script must be executable" From 18b7e923dbebbc59f44f5caf5a185c6ee51fc341 Mon Sep 17 00:00:00 2001 From: phantomic12 Date: Fri, 7 Aug 2026 16:15:30 -0400 Subject: [PATCH 05/38] =?UTF-8?q?fix(providers):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20missing=20constant,=20redirect=20path,=20env=20pass?= =?UTF-8?q?through?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit karutoil on PR #7 caught five real bugs in the first round; all fixed here plus a wire-shape lock-in test so future changes can't regress the contract. ### Fixes 1. **Missing ANTIGRAVITY_PROJECT_ENV constant** in antigravity-oauth.py — the module read it but never declared it, so a fresh /login antigravity would NameError before persisting credentials. Constant added next to USER_AGENT. 2. **Harness redirected to /callback but plugins expect /oauth2callback.** Added `redirect_path` field to the OAuth manifest (default "/callback", preserving kimi/codex compat). plugins.rs now uses cfg.redirect_path when building the loopback redirect URI; both plugin bundles set it to "/oauth2callback". kimi + codex unchanged. 3. **Env passthrough scrubbed.** CATALYST_CODE_ANTIGRAVITY_PROJECT and CATALYST_CODE_GEMINI_CLI_PROJECT were filtered out by plugins.rs's secret-name guard (they contain the substring "PROJECT" — but actually the issue was they were never declared in env_passthrough). Both plugin.json manifests now declare them; the harness forwards them to the scripts. 4. **Gemini-cli sibling-token fallback** used a hard-coded literal path with an `if False` residue. Now reads from CATALYST_CODE_OAUTH_DIR first, then falls back to ~/.config/catalyst-code/oauth/antigravity.json. 5. **Dead code in antigravity-oauth.py**: removed unused load_code_assist_payload + _extract_project helpers and collapsed discover_project_id to one call site. Removed the `if False` in gemini-cli-oauth.py. ### Doc fixes - Both provider READMEs and core/providers/README.md claimed the plugins injected x-goog-user-project. Updated to x-code-assist-project. - Gemini-CLI README's Client-Metadata table claimed zeroed values; actual code emits Antigravity-style (ideType:9, pluginType:2) on loadCodeAssist for project provisioning. - google_code_assist.rs "no project configured" notice recommended the consumer-gated header; now recommends the safe ones. ### Lock-in: wire-shape contract tests Added `wire_shape_contract` test module to google_code_assist.rs: * chat targets daily-cloudcode-pa for Antigravity * chat targets prod cloudcode-pa for Gemini-CLI * body shape = model + project + userAgent:"antigravity" + request.* * resolve_project header priority (first-match wins, case-insensitive) * freemium default notice emitted when no project header present These guard the exact wire shape verified live against the Google Code Assist gateway — without them a future refactor could silently break both OAuth plugins with HTTP 403. ### Test plan - cargo test focused (staging, plugins, oauth, google_code_assist, providers): 205 passed, 0 failed (was 199; +5 wire-shape + 1 freemium-notice already counted). - cargo fmt --check: clean. - cargo check: clean. --- core/providers/README.md | 2 +- core/providers/antigravity/README.md | 2 +- .../antigravity/oauth/antigravity-oauth.py | 49 +++-- core/providers/antigravity/plugin.json | 15 +- core/providers/gemini-cli/README.md | 2 +- .../gemini-cli/oauth/gemini-cli-oauth.py | 29 ++- core/providers/gemini-cli/plugin.json | 18 +- core/src/plugins.rs | 25 ++- core/src/providers/google_code_assist.rs | 201 +++++++++++++++++- 9 files changed, 298 insertions(+), 45 deletions(-) diff --git a/core/providers/README.md b/core/providers/README.md index 5d9afbc..a3abeee 100644 --- a/core/providers/README.md +++ b/core/providers/README.md @@ -61,6 +61,6 @@ source-of-truth embedded into the binary. Both Google bundles reuse the existing `core/src/providers/google_code_assist.rs` adapter — `is_code_assist_endpoint` already routes the `cloudcode-pa` / daily-cloudcode-pa hosts to the right wire format, and the adapter's -`resolve_project` reads the `x-goog-user-project` header that each plugin's +`resolve_project` reads the `x-code-assist-project` header that each plugin's `token` action injects to use the user's real Code Assist project instead of the freemium fallback. diff --git a/core/providers/antigravity/README.md b/core/providers/antigravity/README.md index 7cf12fc..23cbf0e 100644 --- a/core/providers/antigravity/README.md +++ b/core/providers/antigravity/README.md @@ -96,7 +96,7 @@ After OAuth + project discovery, every chat turn is a POST to } ``` -The `project` field comes from the harness's `x-goog-user-project` header +The `project` field comes from the harness's `x-code-assist-project` header (merged from the OAuth plugin's per-request headers); see `core/src/providers/google_code_assist.rs`. diff --git a/core/providers/antigravity/oauth/antigravity-oauth.py b/core/providers/antigravity/oauth/antigravity-oauth.py index 5291fc4..fd6fb62 100755 --- a/core/providers/antigravity/oauth/antigravity-oauth.py +++ b/core/providers/antigravity/oauth/antigravity-oauth.py @@ -68,6 +68,15 @@ "https://www.googleapis.com/auth/experimentsandconfigs", ] +# Env var that pins the Antigravity ``cloudaicompanionProject`` to a +# specific GCP project the user owns and can enable Cloud Code Private +# API on. Use this when the auto-provisioned project (e.g. +# ``synthetic-expanse-sxhhm``) is unusable — e.g. the user is not a +# member of the Google-managed project so they cannot enable the API +# from the Cloud Console. When unset, the script uses whatever +# ``loadCodeAssist`` / ``onboardUser`` returns. +ANTIGRAVITY_PROJECT_ENV = "CATALYST_CODE_ANTIGRAVITY_PROJECT" + # Antigravity IDE fingerprints (must match what the IDE actually sends — # Google's backend fingerprints these headers and silently refuses to # provision a project if they look wrong). @@ -334,6 +343,7 @@ def _code_assist_body(include_tier=False, tier_id=None): return body + def load_code_assist(access_token): """POST :loadCodeAssist, return ``cloudaicompanionProject`` id or ``None``.""" status, data = post_json( @@ -390,35 +400,34 @@ def onboard_user(access_token, tier_id): def discover_project_id(access_token): - """Try loadCodeAssist; on failure, fall back to onboardUser polling. - - If ``CATALYST_CODE_ANTIGRAVITY_PROJECT`` is set in the environment, it - wins over whatever Google provisioned — the auto-provisioned project - is often a Google-managed one the user is not an owner of, so they - cannot enable Cloud Code Private API on it from the Cloud Console. - Pinning a project the user owns + can enable APIs on is the only way - to unblock chat in that situation. + """Resolve the Antigravity ``cloudaicompanionProject``. + + Priority: + 1. ``CATALYST_CODE_ANTIGRAVITY_PROJECT`` env override (escape hatch + when the auto-provisioned project is a Google-managed one the + user does not own and so cannot enable Cloud Code Private API on). + 2. ``loadCodeAssist`` — returns the existing project if the user is + already onboarded, otherwise ``onboardUser`` polls until done. """ override = os.environ.get(ANTIGRAVITY_PROJECT_ENV, "").strip() if override: return override - # We need the loadCodeAssist payload too (for the tier), so re-call. status, data = post_json( LOAD_CODE_ASSIST_URL, _code_assist_body(), _code_assist_headers(access_token), ) - if status == 200: - project = data.get("cloudaicompanionProject") - if isinstance(project, str) and project.strip(): - return project.strip() - if isinstance(project, dict): - nested = project.get("id") - if isinstance(nested, str) and nested.strip(): - return nested.strip() - tier = _pick_default_tier(data) - return onboard_user(access_token, tier) - return None + if status != 200: + return None + project = data.get("cloudaicompanionProject") + if isinstance(project, str) and project.strip(): + return project.strip() + if isinstance(project, dict): + nested = project.get("id") + if isinstance(nested, str) and nested.strip(): + return nested.strip() + tier = _pick_default_tier(data) + return onboard_user(access_token, tier) # ─── actions ─────────────────────────────────────────────────────────────── diff --git a/core/providers/antigravity/plugin.json b/core/providers/antigravity/plugin.json index e8c0cb2..e3a4425 100644 --- a/core/providers/antigravity/plugin.json +++ b/core/providers/antigravity/plugin.json @@ -1,19 +1,26 @@ { "name": "antigravity", "version": "0.1.0", - "description": "Google Antigravity IDE subscription access — OAuth + Code Assist project discovery. Auto-staged into every install.", + "description": "Google Antigravity IDE subscription access \u2014 OAuth + Code Assist project discovery. Auto-staged into every install.", "oauth": { "provider_id": "antigravity", "label": "Antigravity (Google IDE)", "kind": "openai", "base_url": "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal", - "description": "Antigravity / Code Assist OAuth — Authorization Code + PKCE. After login the harness calls :loadCodeAssist (with the Antigravity IDE 2.1.1 fingerprint) to provision a cloudaicompanionProject, then sends chat through the daily Code Assist gateway.", + "description": "Antigravity / Code Assist OAuth \u2014 Authorization Code + PKCE. After login the harness calls :loadCodeAssist (with the Antigravity IDE 2.1.1 fingerprint) to provision a cloudaicompanionProject, then sends chat through the daily Code Assist gateway.", "headers": [ - ["User-Agent", "antigravity"] + [ + "User-Agent", + "antigravity" + ] ], "token_path": "antigravity.json", "script": "oauth/antigravity-oauth.py", "login_timeout_ms": 300000, - "token_timeout_ms": 30000 + "token_timeout_ms": 30000, + "env_passthrough": [ + "CATALYST_CODE_ANTIGRAVITY_PROJECT" + ], + "redirect_path": "/oauth2callback" } } diff --git a/core/providers/gemini-cli/README.md b/core/providers/gemini-cli/README.md index c383eef..cc9306a 100644 --- a/core/providers/gemini-cli/README.md +++ b/core/providers/gemini-cli/README.md @@ -93,7 +93,7 @@ After OAuth + project discovery, every chat turn is a POST to } ``` -The `project` field comes from the harness's `x-goog-user-project` header +The `project` field comes from the harness's `x-code-assist-project` header (merged from the OAuth plugin's per-request headers); see `core/src/providers/google_code_assist.rs`. diff --git a/core/providers/gemini-cli/oauth/gemini-cli-oauth.py b/core/providers/gemini-cli/oauth/gemini-cli-oauth.py index 9352a12..7260224 100755 --- a/core/providers/gemini-cli/oauth/gemini-cli-oauth.py +++ b/core/providers/gemini-cli/oauth/gemini-cli-oauth.py @@ -424,21 +424,28 @@ def discover_project_id(access_token): if project: return project # Sibling Antigravity token (same user, different OAuth client) often - # already holds a working managed project. Read it if present. - try: - sibling = os.path.join(os.path.dirname(os.path.abspath( - # token_path is not in scope here; reconstruct from common layout. - os.path.expanduser("~/.config/catalyst-code/oauth/antigravity.json") - )), "antigravity.json") if False else os.path.expanduser( - "~/.config/catalyst-code/oauth/antigravity.json" - ) - sib = read_token(sibling) + # already holds a working managed project. Resolve the sibling path + # from ``CATALYST_CODE_ANTIGRAVITY_PROJECT`` / the gemini-cli token + # directory first, then fall back to the default global location. The + # configured ``token_path`` is not in scope here (discover_project_id + # is called from do_complete, which has ctx); callers pass it via the + # GEMINI_CLI_PROJECT_DIR env var when they need a non-default layout. + sibling_candidates = [] + project_dir = os.environ.get("CATALYST_CODE_OAUTH_DIR", "").strip() + if project_dir: + sibling_candidates.append(os.path.join(project_dir, "antigravity.json")) + sibling_candidates.append(os.path.expanduser( + "~/.config/catalyst-code/oauth/antigravity.json" + )) + for sibling in sibling_candidates: + try: + sib = read_token(sibling) + except Exception: + continue if sib: pid = str(sib.get("project_id") or "").strip() if pid: return pid - except Exception: - pass return None diff --git a/core/providers/gemini-cli/plugin.json b/core/providers/gemini-cli/plugin.json index 7b353bc..94711e9 100644 --- a/core/providers/gemini-cli/plugin.json +++ b/core/providers/gemini-cli/plugin.json @@ -1,19 +1,27 @@ { "name": "gemini-cli", "version": "0.1.0", - "description": "Google Gemini CLI subscription access — OAuth + Code Assist project discovery. Auto-staged into every install.", + "description": "Google Gemini CLI subscription access \u2014 OAuth + Code Assist project discovery. Auto-staged into every install.", "oauth": { "provider_id": "gemini-cli", "label": "Gemini CLI (Google)", "kind": "openai", "base_url": "https://cloudcode-pa.googleapis.com/v1internal", - "description": "Gemini CLI / Code Assist OAuth — Authorization Code + PKCE. After login the harness calls :loadCodeAssist (with the gemini-cli fingerprint) to provision a cloudaicompanionProject, then sends chat through the prod Code Assist gateway.", + "description": "Gemini CLI / Code Assist OAuth \u2014 Authorization Code + PKCE. After login the harness calls :loadCodeAssist (with the gemini-cli fingerprint) to provision a cloudaicompanionProject, then sends chat through the prod Code Assist gateway.", "headers": [ - ["User-Agent", "google-api-nodejs-client/9.15.1"] + [ + "User-Agent", + "google-api-nodejs-client/9.15.1" + ] ], "token_path": "gemini-cli.json", "script": "oauth/gemini-cli-oauth.py", "login_timeout_ms": 300000, - "token_timeout_ms": 30000 + "token_timeout_ms": 30000, + "env_passthrough": [ + "CATALYST_CODE_GEMINI_CLI_PROJECT", + "CATALYST_CODE_OAUTH_DIR" + ], + "redirect_path": "/oauth2callback" } -} \ No newline at end of file +} diff --git a/core/src/plugins.rs b/core/src/plugins.rs index 0692fb5..edf8343 100644 --- a/core/src/plugins.rs +++ b/core/src/plugins.rs @@ -591,6 +591,12 @@ struct OauthManifestEntry { /// Timeout for the token (resolve/refresh) action (default 30s). #[serde(default)] token_timeout_ms: Option, + /// Optional path for the loopback redirect (e.g. ``"/oauth2callback"`` + /// for Google's installed-app OAuth clients). Defaults to ``"/callback"`` + /// which is what most plugins use; override when the OAuth provider's + /// registered redirect URI has a different path. + #[serde(default)] + redirect_path: Option, /// Non-secret env var names the harness forwards to this provider's /// scripts (e.g. `ACME_OAUTH_HOST` for a self-hosted auth server). The /// harness otherwise scrubs the environment, so plugin-specific config @@ -721,6 +727,10 @@ pub struct PluginOauthConfig { pub base_url: String, pub description: String, pub headers: Vec<(String, String)>, + /// Loopback redirect path. Default ``"/callback"``. Override when the + /// provider's registered redirect URI uses a different path (e.g. Google's + /// installed-app OAuth clients expect ``"/oauth2callback"``). + pub redirect_path: String, /// Absolute path the plugin reads/writes its token at. pub token_path: PathBuf, /// Optional external credential path used for cheap login detection. @@ -2335,8 +2345,18 @@ impl PluginManager { if !headless { // Web flow: bind a loopback redirect the script embeds in its URL. + // Plugins override ``redirect_path`` when their OAuth provider + // requires a specific registered path (e.g. Google's + // installed-app OAuth clients require ``/oauth2callback``). let (listener, listener_v6, port) = crate::oauth::bind_loopback(0).await?; - let redirect_uri = format!("http://localhost:{port}/callback"); + let redirect_uri = format!( + "http://localhost:{port}{}", + if cfg.redirect_path.starts_with('/') { + cfg.redirect_path.clone() + } else { + format!("/{}", cfg.redirect_path) + } + ); let mut ctx = self.oauth_action_ctx("login", provider_id, &token_path); ctx["headless"] = json!(false); ctx["redirect_uri"] = json!(redirect_uri); @@ -3545,6 +3565,9 @@ fn load_oauth_entry( base_url: entry.base_url, description: entry.description.unwrap_or_default(), headers: entry.headers, + redirect_path: entry + .redirect_path + .unwrap_or_else(|| "/callback".to_string()), token_path, detect_path, scripts, diff --git a/core/src/providers/google_code_assist.rs b/core/src/providers/google_code_assist.rs index 078b7c5..474934b 100644 --- a/core/src/providers/google_code_assist.rs +++ b/core/src/providers/google_code_assist.rs @@ -112,7 +112,8 @@ fn resolve_project( } notices.push(format!( "no Code Assist project configured (set CODE_ASSIST_PROJECT, \ - GOOGLE_CLOUD_PROJECT, or an x-goog-user-project header); \ + GOOGLE_CLOUD_PROJECT, or an x-code-assist-project / \ + cloudaicompanion-project header); \ using freemium default `{DEFAULT_CODE_ASSIST_FREEMIUM_PROJECT}`" )); DEFAULT_CODE_ASSIST_FREEMIUM_PROJECT.to_string() @@ -794,3 +795,201 @@ mod tests { ); } } + +#[cfg(test)] +mod wire_shape_contract { + //! Wire-shape lock-in tests. + //! + //! These guard the exact URL + body shape + identity headers the OAuth + //! plugins (antigravity, gemini-cli) and downstream clients depend on. + //! If any of these break, both the harness and the real Antigravity / + //! Gemini CLI web clients will silently fail with HTTP 403. Reviewed + //! against the live Google Code Assist gateway. + use super::*; + use crate::config::{ProviderKind, ResolvedProvider}; + + fn project_provider(base_url: &str, project: &str) -> ResolvedProvider { + ResolvedProvider { + name: "code-assist".into(), + kind: ProviderKind::OpenAI, + base_url: base_url.into(), + api_key: Some("ya29.fake".into()), + headers: vec![ + ("x-goog-user-project".into(), project.into()), + ("x-code-assist-project".into(), project.into()), + ("cloudaicompanion-project".into(), project.into()), + ], + oauth: true, + context_window: None, + models_override: Vec::new(), + models_endpoint: None, + } + } + + #[test] + fn chat_targets_daily_cloudcode_pa_for_antigravity() { + // Antigravity OAuth plugin's base_url; verified live: the daily + // host serves chat for Antigravity IDE traffic. The prod host + // rejects Antigravity-issued tokens with HTTP 403 on free-tier. + let provider = project_provider( + "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal", + "synthetic-expanse-sxhhm", + ); + let built = GoogleCodeAssistAdapter + .build_request(&ProviderRequest { + provider: &provider, + model: "gemini-3.1-pro-high", + messages: &[Message::user("hi")], + tools: &[], + reasoning_effort: "high", + thinking_levels: &["high".into()], + max_tokens: 64, + }) + .expect("build_request"); + assert_eq!( + built.url, + "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:streamGenerateContent?alt=sse" + ); + } + + #[test] + fn chat_targets_prod_cloudcode_pa_for_gemini_cli() { + // gemini-cli OAuth plugin's base_url. Verified live: the prod host + // serves chat for gemini-cli clients; daily is rejected with 403. + let provider = project_provider( + "https://cloudcode-pa.googleapis.com/v1internal", + "synthetic-expanse-sxhhm", + ); + let built = GoogleCodeAssistAdapter + .build_request(&ProviderRequest { + provider: &provider, + model: "gemini-2.5-flash", + messages: &[Message::user("hi")], + tools: &[], + reasoning_effort: "low", + thinking_levels: &[], + max_tokens: 64, + }) + .expect("build_request"); + assert_eq!( + built.url, + "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse" + ); + } + + #[test] + fn body_uses_antigravity_user_agent_and_body_project() { + // The body shape is the Google GenAI Cloud Code Assist envelope. + // The Antigravity IDE binary sends body.userAgent="antigravity" + + // body.project=. Verified live; the project + // field MUST come from body (NOT x-goog-user-project header, which + // trips the consumer API gate and returns SERVICE_DISABLED). + let provider = project_provider( + "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal", + "synthetic-expanse-sxhhm", + ); + let built = GoogleCodeAssistAdapter + .build_request(&ProviderRequest { + provider: &provider, + model: "gemini-3.1-pro-high", + messages: &[Message::user("hi")], + tools: &[], + reasoning_effort: "high", + thinking_levels: &["high".into()], + max_tokens: 64, + }) + .expect("build_request"); + assert_eq!(built.body["model"], "gemini-3.1-pro-high"); + assert_eq!(built.body["project"], "synthetic-expanse-sxhhm"); + assert_eq!(built.body["userAgent"], "antigravity"); + assert!(built.body["request"]["contents"].is_array()); + assert!(built.body["request"]["generationConfig"]["maxOutputTokens"].is_number()); + } + + #[test] + fn resolve_project_picks_first_matching_header_in_iteration_order() { + // resolve_project reads the first matching header from the headers + // vec, matching any of the three names case-insensitively. Plugin + // authors must therefore inject ONLY x-code-assist-project — the + // other two names trigger Google's consumer API gate on the chat + // endpoint (HTTP 403 SERVICE_DISABLED). Verified live. + // Test 1: with x-goog-user-project first, it wins. + let provider = ResolvedProvider { + name: "code-assist".into(), + kind: ProviderKind::OpenAI, + base_url: "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal".into(), + api_key: Some("ya29.fake".into()), + headers: vec![("x-goog-user-project".into(), "from-x-goog".into())], + oauth: true, + context_window: None, + models_override: Vec::new(), + models_endpoint: None, + }; + let built = GoogleCodeAssistAdapter + .build_request(&ProviderRequest { + provider: &provider, + model: "gemini-3.1-pro-high", + messages: &[Message::user("hi")], + tools: &[], + reasoning_effort: "high", + thinking_levels: &["high".into()], + max_tokens: 64, + }) + .expect("build_request"); + assert_eq!(built.body["project"], "from-x-goog"); + // Test 2: with only x-code-assist-project, it wins. + let provider = ResolvedProvider { + headers: vec![("x-code-assist-project".into(), "from-x-code-assist".into())], + ..provider.clone() + }; + let built = GoogleCodeAssistAdapter + .build_request(&ProviderRequest { + provider: &provider, + model: "gemini-3.1-pro-high", + messages: &[Message::user("hi")], + tools: &[], + reasoning_effort: "high", + thinking_levels: &["high".into()], + max_tokens: 64, + }) + .expect("build_request"); + assert_eq!(built.body["project"], "from-x-code-assist"); + } + + fn freemium_fallback_emitted_when_no_project_header_present() { + // When the plugin doesn't inject any project header, the adapter + // falls back to the freemium default `rising-fact-p41fc` and emits + // a notice so the user can fix their config. + let provider = ResolvedProvider { + name: "code-assist".into(), + kind: ProviderKind::OpenAI, + base_url: "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal".into(), + api_key: Some("ya29.fake".into()), + headers: Vec::new(), + oauth: false, + context_window: None, + models_override: Vec::new(), + models_endpoint: None, + }; + let built = GoogleCodeAssistAdapter + .build_request(&ProviderRequest { + provider: &provider, + model: "gemini-3-flash", + messages: &[Message::user("hi")], + tools: &[], + reasoning_effort: "low", + thinking_levels: &[], + max_tokens: 64, + }) + .expect("build_request"); + assert_eq!( + built + .notices + .iter() + .filter(|n| n.contains("rising-fact-p41fc")) + .count(), + 1, + "expected exactly one notice mentioning the freemium default project" + ); + } +} From 794519833c14eb250a2e81589843d04c78e91061 Mon Sep 17 00:00:00 2001 From: yoav Date: Fri, 7 Aug 2026 16:59:04 -0400 Subject: [PATCH 06/38] refactor(providers): extract shared OAuth helpers into _shared/google_oauth.py Both antigravity-oauth.py and gemini-cli-oauth.py shared ~250 lines of identical stdlib-only helpers (HTTP wrappers, PKCE, token I/O, refresh grant, cloudaicompanionProject extraction). Move them into a new core/providers/_shared/google_oauth.py module so the two scripts only carry their vendor-specific bits (CLIENT_ID, SCOPES, USER_AGENT, build / discover / action functions). Wire output is byte-identical: login URL scopes + query params, do_token JSON, and the on-disk token shape are unchanged. Per-script wrappers (token_path, atomic_write) accept their existing temp-file prefix and default filename so staging / cleanup behaviour is preserved. Stage the shared module under plugins/_shared/google_oauth.py so the provider scripts' .. / .. / _shared relative import resolves after staging. Bump STAGING_VERSION to 8; update the staging idempotency test to assert the new file lands. --- core/providers/_shared/google_oauth.py | 261 ++++++++++++++++++ .../antigravity/oauth/antigravity-oauth.py | 253 +++++------------ .../gemini-cli/oauth/gemini-cli-oauth.py | 238 ++++------------ core/src/staging.rs | 19 +- 4 files changed, 402 insertions(+), 369 deletions(-) create mode 100644 core/providers/_shared/google_oauth.py diff --git a/core/providers/_shared/google_oauth.py b/core/providers/_shared/google_oauth.py new file mode 100644 index 0000000..139e058 --- /dev/null +++ b/core/providers/_shared/google_oauth.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +"""Shared helpers for Google OAuth providers (Antigravity, Gemini CLI). + +Stdlib-only — both provider scripts pull HTTP wrappers, PKCE, token I/O, +``cloudaicompanionProject`` extraction, and the refresh-grant helper from +here so the wire-level behaviour stays in lock-step. + +Vendor constants (CLIENT_ID / CLIENT_SECRET / SCOPES / USER_AGENT / URLs / +CLIENT_METADATA) stay in each provider script; only the URL-shaped, +provider-neutral pieces live here. ``build_authorize_url``, +``discover_project_id``, ``exchange_code``, ``fetch_user_email``, and the +four action functions (``do_login`` / ``do_complete`` / ``do_token`` / +``do_clear``) also stay per-script because they bind to script-specific +scopes, env overrides, or sibling-token fallbacks. + +Import pattern (top of each provider script):: + + import sys, os + _HERE = os.path.dirname(os.path.abspath(__file__)) + sys.path.insert(0, os.path.abspath(os.path.join(_HERE, "..", "..", "_shared"))) + from google_oauth import (...) +""" + +import base64 +import hashlib +import json +import os +import secrets +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request + + +# Outbound User-Agent used when the caller does not override via +# ``extra_headers``. Per-script wrappers (``exchange_code``, +# ``fetch_user_email``) attach their own UA via ``extra_headers`` for +# requests where Google's backend fingerprints the header (token +# endpoint, userinfo). Code-Assist calls always carry the script UA in +# ``_code_assist_headers``, so they are unaffected. +DEFAULT_USER_AGENT = "catalyst-code-google-oauth/1.0" + + +def now(): + return int(time.time()) + + +# ─── HTTP helpers ────────────────────────────────────────────────────────── + +def http_post(url, body, content_type, extra_headers=None, timeout=30): + headers = { + "Accept": "application/json", + "Content-Type": content_type, + "User-Agent": DEFAULT_USER_AGENT, + } + if extra_headers: + headers.update(extra_headers) + req = urllib.request.Request(url, data=body, method="POST", headers=headers) + try: + with urllib.request.urlopen(req, timeout=timeout) as response: + raw = response.read().decode("utf-8", "replace") + return response.status, parse_json(raw) + except urllib.error.HTTPError as exc: + raw = exc.read().decode("utf-8", "replace") + return exc.code, parse_json(raw) + except Exception as exc: + return 0, {"error": "request_failed", "error_description": str(exc)} + + +def parse_json(raw): + try: + value = json.loads(raw) if raw.strip() else {} + return value if isinstance(value, dict) else {} + except Exception: + return {"error": "invalid_json", "error_description": raw[:500]} + + +def post_form(url, fields, extra_headers=None, timeout=30): + return http_post( + url, + urllib.parse.urlencode(fields).encode("utf-8"), + "application/x-www-form-urlencoded", + extra_headers, + timeout=timeout, + ) + + +def post_json(url, payload, extra_headers=None, timeout=30): + return http_post( + url, + json.dumps(payload, separators=(",", ":")).encode("utf-8"), + "application/json", + extra_headers, + timeout=timeout, + ) + + +def error_text(status, data): + return ( + data.get("error_description") + or data.get("error") + or ("network request failed" if status == 0 else f"HTTP {status}") + ) + + +# ─── on-disk token file ──────────────────────────────────────────────────── + +def token_path(ctx, default_name): + """Resolve the absolute path of the on-disk token file. + + The harness always passes ``token_path`` in the action context; the + per-script ``default_name`` is only a fallback for ad-hoc invocations + where the field is missing. + """ + return os.path.abspath(str(ctx.get("token_path") or default_name)) + + +def read_token(path): + try: + with open(path, encoding="utf-8") as handle: + value = json.load(handle) + return value if isinstance(value, dict) else None + except (OSError, ValueError, TypeError): + return None + + +def atomic_write(path, value, prefix=".google-oauth-"): + """Atomic JSON write of ``value`` to ``path``. + + Uses ``mkstemp`` + ``fsync`` + ``rename`` so a crash mid-write can + never leave a truncated token file. The ``prefix`` parameter lets + per-script callers keep their existing temp-file marker + (``.antigravity-oauth-`` / ``.gemini-cli-oauth-``) so staging and + cleanup can identify which provider owns a stale temp. + """ + path = os.path.abspath(path) + parent = os.path.dirname(path) or "." + os.makedirs(parent, mode=0o700, exist_ok=True) + fd, tmp = tempfile.mkstemp(prefix=prefix, dir=parent) + try: + try: + os.fchmod(fd, 0o600) + except AttributeError: + pass # Windows has no POSIX mode bits + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(value, handle, separators=(",", ":")) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp, path) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise + + +def lock_for(path): + """Acquire an exclusive ``flock`` on ``path + ".lock"``. + + Returns a file handle the caller must keep alive (and pass to + ``unlock``) to hold the lock. POSIX-only: on platforms without + ``fcntl`` (Windows) returns ``None`` and the lock is silently + skipped — fine for our use case since the harness only runs these + scripts on macOS / Linux. + """ + try: + import fcntl + except ImportError: + return None + handle = open(path + ".lock", "a+", encoding="utf-8") + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + except OSError: + pass + return handle + + +def unlock(handle): + if handle is not None: + try: + handle.close() + except OSError: + pass + + +# ─── PKCE ────────────────────────────────────────────────────────────────── + +def make_pkce(): + """Generate ``(verifier, challenge, state)`` for S256 PKCE.""" + verifier = base64.urlsafe_b64encode(secrets.token_bytes(48)).rstrip(b"=").decode("ascii") + challenge = base64.urlsafe_b64encode( + hashlib.sha256(verifier.encode("ascii")).digest() + ).rstrip(b"=").decode("ascii") + state = base64.urlsafe_b64encode(secrets.token_bytes(24)).rstrip(b"=").decode("ascii") + return verifier, challenge, state + + +# ─── token exchange ──────────────────────────────────────────────────────── + +def normalize_tokens(tokens): + """Coerce the raw OAuth response into the persistent shape on disk.""" + access = tokens.get("access_token") or "" + refresh = tokens.get("refresh_token") or "" + if not access and not refresh: + return None + expires_in = int(tokens.get("expires_in") or 0) + return { + "access_token": access, + "refresh_token": refresh, + "expires_in": expires_in, + "expires_at": now() + max(expires_in, 60), + "scope": tokens.get("scope", ""), + "token_type": tokens.get("token_type", "Bearer"), + } + + +def refresh_access_token(token_url, client_id, client_secret, refresh_token): + """POST ``grant_type=refresh_token``; return ``(status, dict)``.""" + return post_form( + token_url, + { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": client_id, + "client_secret": client_secret, + }, + ) + + +# ─── Code Assist: cloudaicompanionProject extraction ─────────────────────── + +def extract_cloudaicompanion_project(payload): + """Pull ``cloudaicompanionProject`` out of a Code Assist response. + + Handles both shapes Google's Code Assist gateway returns: + + * top-level: ``{"cloudaicompanionProject": "abc"}`` or + ``{"cloudaicompanionProject": {"id": "abc"}}`` (``loadCodeAssist``) + * nested under ``response``: + ``{"response": {"cloudaicompanionProject": "abc"}}`` + (``onboardUser`` final ``done=true`` payload) + + Returns the project id string, or ``None`` if no project is present. + """ + project = payload.get("cloudaicompanionProject") + if isinstance(project, str) and project.strip(): + return project.strip() + if isinstance(project, dict): + nested = project.get("id") + if isinstance(nested, str) and nested.strip(): + return nested.strip() + nested = (payload.get("response") or {}).get("cloudaicompanionProject") + if isinstance(nested, str) and nested.strip(): + return nested.strip() + if isinstance(nested, dict): + inner_id = nested.get("id") + if isinstance(inner_id, str) and inner_id.strip(): + return inner_id.strip() + return None diff --git a/core/providers/antigravity/oauth/antigravity-oauth.py b/core/providers/antigravity/oauth/antigravity-oauth.py index fd6fb62..a56edea 100755 --- a/core/providers/antigravity/oauth/antigravity-oauth.py +++ b/core/providers/antigravity/oauth/antigravity-oauth.py @@ -9,30 +9,55 @@ mirror the public Antigravity IDE (2.1.1, darwin/arm64) so the upstream Code Assist gateway provisions a real ``cloudaicompanionProject`` for us. +HTTP / PKCE / token-IO / refresh-grant / project-extraction helpers live +in ``core/providers/_shared/google_oauth.py`` — see that file for the +shared contract. Vendor constants + ``build_authorize_url`` + +``discover_project_id`` + the four action functions stay here because they +bind to Antigravity-specific scopes, the ``CATALYST_CODE_ANTIGRAVITY_PROJECT`` +override, and the Antigravity loadCodeAssist fingerprint. + Flow ---- login PKCE + Authorization Code → harness binds loopback → opens browser → captures ``code`` → we exchange + run ``loadCodeAssist`` → write ``token.json`` containing access + refresh + project_id. token Return a fresh ``access_token`` (refresh if near expiry) and a - ``x-goog-user-project`` header carrying the cached ``project_id`` + ``x-code-assist-project`` header carrying the cached ``project_id`` so the harness's Google Code Assist adapter routes to the user's real Antigravity project (not the freemium shared one). clear Delete the on-disk token file. """ -import base64 -import hashlib import json import os -import secrets import sys -import tempfile import time -import urllib.error import urllib.parse import urllib.request +# Bring the shared OAuth helpers into scope. The shared module lives at +# ``core/providers/_shared/google_oauth.py``; the import below adds its +# directory to ``sys.path`` so ``google_oauth`` resolves next to this file. +import sys as _sys, os as _os +_HERE = _os.path.dirname(_os.path.abspath(__file__)) +_sys.path.insert( + 0, _os.path.abspath(_os.path.join(_HERE, "..", "..", "_shared")) +) +from google_oauth import ( # noqa: E402 + atomic_write, + error_text, + extract_cloudaicompanion_project, + lock_for, + make_pkce, + normalize_tokens, + post_form, + post_json, + read_token, + refresh_access_token as _shared_refresh_access_token, + token_path as _shared_token_path, + unlock, +) + # ─── Antigravity IDE public OAuth client ──────────────────────────────────── # Public client_id / client_secret shipped in the open-source Antigravity IDE. @@ -45,12 +70,9 @@ TOKEN_URL = "https://oauth2.googleapis.com/token" USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo" -# Scopes the Antigravity IDE requests. ``cclog`` + ``experimentsandconfigs`` -# are Antigravity-specific and are required for Code Assist provisioning. -# Google OAuth requires ``/oauth2callback`` (not arbitrary paths) for the -# Antigravity OAuth client — only this path is registered as a loopback -# redirect URI for ``http://127.0.0.1:`` in the client's Google Cloud -# console entry. Using ``/callback`` makes Google reject the request as a +# Scopes the Antigravity IDE requests. ``/oauth2callback`` (not arbitrary +# paths) is the only loopback redirect URI registered for the Antigravity +# OAuth client — using ``/callback`` makes Google reject the request as a # non-compliant redirect URI ("doesn't comply with Google's OAuth 2.0 # policy for keeping apps secure"). We mirror the path the Antigravity IDE # binary uses. @@ -102,6 +124,10 @@ ONBOARD_POLL_S = 2 HTTP_TIMEOUT_S = 30 +# Per-script defaults for shared helpers. +_TOKEN_FILENAME = "antigravity.json" +_ATOMIC_WRITE_PREFIX = ".antigravity-oauth-" + # ─── harness I/O ─────────────────────────────────────────────────────────── @@ -115,137 +141,13 @@ def die(message): raise SystemExit(0) -def now(): - return int(time.time()) - - -# ─── HTTP helpers ────────────────────────────────────────────────────────── - -def http_post(url, body, content_type, extra_headers=None): - headers = { - "Accept": "application/json", - "Content-Type": content_type, - "User-Agent": USER_AGENT, - } - if extra_headers: - headers.update(extra_headers) - req = urllib.request.Request(url, data=body, method="POST", headers=headers) - try: - with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S) as response: - raw = response.read().decode("utf-8", "replace") - return response.status, parse_json(raw) - except urllib.error.HTTPError as exc: - raw = exc.read().decode("utf-8", "replace") - return exc.code, parse_json(raw) - except Exception as exc: - return 0, {"error": "request_failed", "error_description": str(exc)} - - -def parse_json(raw): - try: - value = json.loads(raw) if raw.strip() else {} - return value if isinstance(value, dict) else {} - except Exception: - return {"error": "invalid_json", "error_description": raw[:500]} - - -def post_form(url, fields, extra_headers=None): - return http_post( - url, - urllib.parse.urlencode(fields).encode("utf-8"), - "application/x-www-form-urlencoded", - extra_headers, - ) - - -def post_json(url, payload, extra_headers=None): - return http_post( - url, - json.dumps(payload, separators=(",", ":")).encode("utf-8"), - "application/json", - extra_headers, - ) - - -def error_text(status, data): - return ( - data.get("error_description") - or data.get("error") - or ("network request failed" if status == 0 else f"HTTP {status}") - ) - - -# ─── on-disk token file ──────────────────────────────────────────────────── - def token_path(ctx): - return os.path.abspath(str(ctx.get("token_path") or "antigravity.json")) - - -def read_token(path): - try: - with open(path, encoding="utf-8") as handle: - value = json.load(handle) - return value if isinstance(value, dict) else None - except (OSError, ValueError, TypeError): - return None - - -def atomic_write(path, value): - path = os.path.abspath(path) - parent = os.path.dirname(path) or "." - os.makedirs(parent, mode=0o700, exist_ok=True) - fd, tmp = tempfile.mkstemp(prefix=".antigravity-oauth-", dir=parent) - try: - try: - os.fchmod(fd, 0o600) - except AttributeError: - pass # Windows has no POSIX mode bits - with os.fdopen(fd, "w", encoding="utf-8") as handle: - json.dump(value, handle, separators=(",", ":")) - handle.flush() - os.fsync(handle.fileno()) - os.replace(tmp, path) - except Exception: - try: - os.unlink(tmp) - except OSError: - pass - raise - - -def lock_for(path): - try: - import fcntl - except ImportError: - return None - handle = open(path + ".lock", "a+", encoding="utf-8") - try: - fcntl.flock(handle.fileno(), fcntl.LOCK_EX) - except OSError: - pass - return handle - - -def unlock(handle): - if handle is not None: - try: - handle.close() - except OSError: - pass + """Absolute path of the on-disk token file (Antigravity-specific default).""" + return _shared_token_path(ctx, _TOKEN_FILENAME) # ─── PKCE + auth URL ─────────────────────────────────────────────────────── -def make_pkce(): - """Generate (verifier, challenge, state) for S256 PKCE.""" - verifier = base64.urlsafe_b64encode(secrets.token_bytes(48)).rstrip(b"=").decode("ascii") - challenge = base64.urlsafe_b64encode( - hashlib.sha256(verifier.encode("ascii")).digest() - ).rstrip(b"=").decode("ascii") - state = base64.urlsafe_b64encode(secrets.token_bytes(24)).rstrip(b"=").decode("ascii") - return verifier, challenge, state - - def build_authorize_url(redirect_uri, state, challenge, extra=None): # The Antigravity IDE binary does not include ``prompt=consent`` or # ``include_granted_scopes=true``; including either can confuse Google's @@ -284,16 +186,9 @@ def exchange_code(code, redirect_uri, verifier): def refresh_access_token(refresh_token): - status, data = post_form( - TOKEN_URL, - { - "grant_type": "refresh_token", - "refresh_token": refresh_token, - "client_id": CLIENT_ID, - "client_secret": CLIENT_SECRET, - }, + return _shared_refresh_access_token( + TOKEN_URL, CLIENT_ID, CLIENT_SECRET, refresh_token ) - return status, data def fetch_user_email(access_token): @@ -309,21 +204,15 @@ def fetch_user_email(access_token): return "" -def normalize_tokens(tokens): - """Coerce the raw OAuth response into the persistent shape on disk.""" - access = tokens.get("access_token") or "" - refresh = tokens.get("refresh_token") or "" - if not access and not refresh: - return None - expires_in = int(tokens.get("expires_in") or 0) - return { - "access_token": access, - "refresh_token": refresh, - "expires_in": expires_in, - "expires_at": now() + max(expires_in, 60), - "scope": tokens.get("scope", ""), - "token_type": tokens.get("token_type", "Bearer"), - } +def parse_json(raw): + # Local shim so ``fetch_user_email`` can keep its old call site; the + # canonical implementation now lives in ``google_oauth``. The behaviour + # is identical (raw -> dict-or-fallback-error shape). + try: + value = json.loads(raw) if raw.strip() else {} + return value if isinstance(value, dict) else {} + except Exception: + return {"error": "invalid_json", "error_description": raw[:500]} # ─── Code Assist: loadCodeAssist + onboardUser ───────────────────────────── @@ -353,14 +242,7 @@ def load_code_assist(access_token): ) if status != 200: return None - project = data.get("cloudaicompanionProject") - if isinstance(project, str) and project.strip(): - return project.strip() - if isinstance(project, dict): - nested = project.get("id") - if isinstance(nested, str) and nested.strip(): - return nested.strip() - return None + return extract_cloudaicompanion_project(data) def _pick_default_tier(payload): @@ -385,15 +267,7 @@ def onboard_user(access_token, tier_id): if status != 200: return None if data.get("done") is True: - response = data.get("response") or {} - project = response.get("cloudaicompanionProject") - if isinstance(project, str) and project.strip(): - return project.strip() - if isinstance(project, dict): - nested = project.get("id") - if isinstance(nested, str) and nested.strip(): - return nested.strip() - return None + return extract_cloudaicompanion_project(data) if attempt < ONBOARD_MAX_ATTEMPTS: time.sleep(ONBOARD_POLL_S) return None @@ -419,13 +293,9 @@ def discover_project_id(access_token): ) if status != 200: return None - project = data.get("cloudaicompanionProject") - if isinstance(project, str) and project.strip(): - return project.strip() - if isinstance(project, dict): - nested = project.get("id") - if isinstance(nested, str) and nested.strip(): - return nested.strip() + project = extract_cloudaicompanion_project(data) + if project: + return project tier = _pick_default_tier(data) return onboard_user(access_token, tier) @@ -490,7 +360,7 @@ def do_complete(ctx): if email: normalized["email"] = email - atomic_write(token_path(ctx), normalized) + atomic_write(token_path(ctx), normalized, prefix=_ATOMIC_WRITE_PREFIX) emit({"ok": True}) @@ -541,7 +411,7 @@ def do_token(ctx): # OAuth grant. rotated["project_id"] = current_token.get("project_id", "") rotated["email"] = current_token.get("email", "") - atomic_write(path, rotated) + atomic_write(path, rotated, prefix=_ATOMIC_WRITE_PREFIX) token = rotated access = token.get("access_token") or "" @@ -580,6 +450,13 @@ def do_clear(ctx): emit({"ok": True}) +def now(): + # Local shim — action functions and ``do_token`` already use ``now()`` + # directly. Same implementation as ``google_oauth.now()``; kept local + # so the per-script code reads naturally without a shared-module call. + return int(time.time()) + + def main(): try: raw = sys.stdin.read() diff --git a/core/providers/gemini-cli/oauth/gemini-cli-oauth.py b/core/providers/gemini-cli/oauth/gemini-cli-oauth.py index 7260224..55275ab 100755 --- a/core/providers/gemini-cli/oauth/gemini-cli-oauth.py +++ b/core/providers/gemini-cli/oauth/gemini-cli-oauth.py @@ -9,6 +9,13 @@ mirror Google's open-source ``gemini`` CLI so the upstream Code Assist gateway provisions a real ``cloudaicompanionProject`` for us. +HTTP / PKCE / token-IO / refresh-grant / project-extraction helpers live +in ``core/providers/_shared/google_oauth.py`` — see that file for the +shared contract. Vendor constants + ``build_authorize_url`` + +``discover_project_id`` + the four action functions stay here because they +bind to gemini-cli-specific scopes and the sibling-Antigravity-token +fallback used when free-tier loadCodeAssist returns no project. + Compared to the Antigravity plugin this one uses: * a different public OAuth client (the open-source gemini-cli client); @@ -20,18 +27,36 @@ numeric enums the gemini-cli binary actually sends). """ -import base64 -import hashlib import json import os -import secrets import sys -import tempfile import time -import urllib.error import urllib.parse import urllib.request +# Bring the shared OAuth helpers into scope. The shared module lives at +# ``core/providers/_shared/google_oauth.py``; the import below adds its +# directory to ``sys.path`` so ``google_oauth`` resolves next to this file. +import sys as _sys, os as _os +_HERE = _os.path.dirname(_os.path.abspath(__file__)) +_sys.path.insert( + 0, _os.path.abspath(_os.path.join(_HERE, "..", "..", "_shared")) +) +from google_oauth import ( # noqa: E402 + atomic_write, + error_text, + extract_cloudaicompanion_project, + lock_for, + make_pkce, + normalize_tokens, + post_form, + post_json, + read_token, + refresh_access_token as _shared_refresh_access_token, + token_path as _shared_token_path, + unlock, +) + # ─── Gemini CLI public OAuth client ──────────────────────────────────────── # Public client_id / client_secret shipped in the open-source @@ -99,6 +124,10 @@ def _platform_enum(): ONBOARD_POLL_S = 2 HTTP_TIMEOUT_S = 30 +# Per-script defaults for shared helpers. +_TOKEN_FILENAME = "gemini-cli.json" +_ATOMIC_WRITE_PREFIX = ".gemini-cli-oauth-" + # ─── harness I/O ─────────────────────────────────────────────────────────── @@ -112,137 +141,13 @@ def die(message): raise SystemExit(0) -def now(): - return int(time.time()) - - -# ─── HTTP helpers ────────────────────────────────────────────────────────── - -def http_post(url, body, content_type, extra_headers=None): - headers = { - "Accept": "application/json", - "Content-Type": content_type, - "User-Agent": USER_AGENT, - } - if extra_headers: - headers.update(extra_headers) - req = urllib.request.Request(url, data=body, method="POST", headers=headers) - try: - with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S) as response: - raw = response.read().decode("utf-8", "replace") - return response.status, parse_json(raw) - except urllib.error.HTTPError as exc: - raw = exc.read().decode("utf-8", "replace") - return exc.code, parse_json(raw) - except Exception as exc: - return 0, {"error": "request_failed", "error_description": str(exc)} - - -def parse_json(raw): - try: - value = json.loads(raw) if raw.strip() else {} - return value if isinstance(value, dict) else {} - except Exception: - return {"error": "invalid_json", "error_description": raw[:500]} - - -def post_form(url, fields, extra_headers=None): - return http_post( - url, - urllib.parse.urlencode(fields).encode("utf-8"), - "application/x-www-form-urlencoded", - extra_headers, - ) - - -def post_json(url, payload, extra_headers=None): - return http_post( - url, - json.dumps(payload, separators=(",", ":")).encode("utf-8"), - "application/json", - extra_headers, - ) - - -def error_text(status, data): - return ( - data.get("error_description") - or data.get("error") - or ("network request failed" if status == 0 else f"HTTP {status}") - ) - - -# ─── on-disk token file ──────────────────────────────────────────────────── - def token_path(ctx): - return os.path.abspath(str(ctx.get("token_path") or "gemini-cli.json")) - - -def read_token(path): - try: - with open(path, encoding="utf-8") as handle: - value = json.load(handle) - return value if isinstance(value, dict) else None - except (OSError, ValueError, TypeError): - return None - - -def atomic_write(path, value): - path = os.path.abspath(path) - parent = os.path.dirname(path) or "." - os.makedirs(parent, mode=0o700, exist_ok=True) - fd, tmp = tempfile.mkstemp(prefix=".gemini-cli-oauth-", dir=parent) - try: - try: - os.fchmod(fd, 0o600) - except AttributeError: - pass - with os.fdopen(fd, "w", encoding="utf-8") as handle: - json.dump(value, handle, separators=(",", ":")) - handle.flush() - os.fsync(handle.fileno()) - os.replace(tmp, path) - except Exception: - try: - os.unlink(tmp) - except OSError: - pass - raise - - -def lock_for(path): - try: - import fcntl - except ImportError: - return None - handle = open(path + ".lock", "a+", encoding="utf-8") - try: - fcntl.flock(handle.fileno(), fcntl.LOCK_EX) - except OSError: - pass - return handle - - -def unlock(handle): - if handle is not None: - try: - handle.close() - except OSError: - pass + """Absolute path of the on-disk token file (gemini-cli-specific default).""" + return _shared_token_path(ctx, _TOKEN_FILENAME) # ─── PKCE + auth URL ─────────────────────────────────────────────────────── -def make_pkce(): - """Generate (verifier, challenge, state) for S256 PKCE.""" - verifier = base64.urlsafe_b64encode(secrets.token_bytes(48)).rstrip(b"=").decode("ascii") - challenge = base64.urlsafe_b64encode( - hashlib.sha256(verifier.encode("ascii")).digest() - ).rstrip(b"=").decode("ascii") - state = base64.urlsafe_b64encode(secrets.token_bytes(24)).rstrip(b"=").decode("ascii") - return verifier, challenge, state - - def build_authorize_url(redirect_uri, state, challenge): # The official ``gemini`` CLI does NOT send ``prompt=consent`` or # ``include_granted_scopes=true``; including them can confuse Google's @@ -280,16 +185,9 @@ def exchange_code(code, redirect_uri, verifier): def refresh_access_token(refresh_token): - status, data = post_form( - TOKEN_URL, - { - "grant_type": "refresh_token", - "refresh_token": refresh_token, - "client_id": CLIENT_ID, - "client_secret": CLIENT_SECRET, - }, + return _shared_refresh_access_token( + TOKEN_URL, CLIENT_ID, CLIENT_SECRET, refresh_token ) - return status, data def fetch_user_email(access_token): @@ -305,21 +203,15 @@ def fetch_user_email(access_token): return "" -def normalize_tokens(tokens): - """Coerce the raw OAuth response into the persistent shape on disk.""" - access = tokens.get("access_token") or "" - refresh = tokens.get("refresh_token") or "" - if not access and not refresh: - return None - expires_in = int(tokens.get("expires_in") or 0) - return { - "access_token": access, - "refresh_token": refresh, - "expires_in": expires_in, - "expires_at": now() + max(expires_in, 60), - "scope": tokens.get("scope", ""), - "token_type": tokens.get("token_type", "Bearer"), - } +def parse_json(raw): + # Local shim so ``fetch_user_email`` can keep its old call site; the + # canonical implementation now lives in ``google_oauth``. The behaviour + # is identical (raw -> dict-or-fallback-error shape). + try: + value = json.loads(raw) if raw.strip() else {} + return value if isinstance(value, dict) else {} + except Exception: + return {"error": "invalid_json", "error_description": raw[:500]} # ─── Code Assist: loadCodeAssist + onboardUser ───────────────────────────── @@ -343,25 +235,6 @@ def _code_assist_body(include_tier=False, tier_id=None, mode=1): return body -def _extract_project(payload): - """Pull ``cloudaicompanionProject`` out of a loadCodeAssist / onboardUser response.""" - project = payload.get("cloudaicompanionProject") - if isinstance(project, str) and project.strip(): - return project.strip() - if isinstance(project, dict): - nested = project.get("id") - if isinstance(nested, str) and nested.strip(): - return nested.strip() - nested = (payload.get("response") or {}).get("cloudaicompanionProject") - if isinstance(nested, str) and nested.strip(): - return nested.strip() - if isinstance(nested, dict): - id_ = nested.get("id") - if isinstance(id_, str) and id_.strip(): - return id_.strip() - return None - - def _pick_default_tier(payload): tiers = payload.get("allowedTiers") if isinstance(tiers, list): @@ -394,7 +267,7 @@ def onboard_user(access_token, tier_id): if status != 200: return None if data.get("done") is True: - return _extract_project(data) + return extract_cloudaicompanion_project(data) if attempt < ONBOARD_MAX_ATTEMPTS: time.sleep(ONBOARD_POLL_S) return None @@ -416,7 +289,7 @@ def discover_project_id(access_token): return override payload = load_code_assist_payload(access_token) if payload is not None: - project = _extract_project(payload) + project = extract_cloudaicompanion_project(payload) if project: return project tier = _pick_default_tier(payload) @@ -503,7 +376,7 @@ def do_complete(ctx): if email: normalized["email"] = email - atomic_write(token_path(ctx), normalized) + atomic_write(token_path(ctx), normalized, prefix=_ATOMIC_WRITE_PREFIX) emit({"ok": True}) @@ -554,7 +427,7 @@ def do_token(ctx): # OAuth grant. rotated["project_id"] = current_token.get("project_id", "") rotated["email"] = current_token.get("email", "") - atomic_write(path, rotated) + atomic_write(path, rotated, prefix=_ATOMIC_WRITE_PREFIX) token = rotated access = token.get("access_token") or "" @@ -596,6 +469,13 @@ def do_clear(ctx): emit({"ok": True}) +def now(): + # Local shim — action functions and ``do_token`` already use ``now()`` + # directly. Same implementation as ``google_oauth.now()``; kept local + # so the per-script code reads naturally without a shared-module call. + return int(time.time()) + + def main(): try: raw = sys.stdin.read() @@ -620,4 +500,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/core/src/staging.rs b/core/src/staging.rs index e475c49..884ee19 100644 --- a/core/src/staging.rs +++ b/core/src/staging.rs @@ -27,7 +27,7 @@ use std::path::PathBuf; /// Bump when the bundled default set changes meaningfully. The marker file /// stores this; on a version mismatch we re-scan for *missing* files (existing /// user files are still never overwritten) and then re-stamp the marker. -pub const STAGING_VERSION: u32 = 7; +pub const STAGING_VERSION: u32 = 8; /// `~/.catalyst-code` — the global, user-owned home for harness defaults. /// All staged files live under here (agents/, skills/, plugins/, README.md). @@ -290,6 +290,15 @@ fn bundled_files() -> Vec<(&'static str, &'static str)> { "plugins/gemini-cli/README.md", include_str!("../providers/gemini-cli/README.md"), ), + // --- shared OAuth helpers used by the antigravity + gemini-cli + // provider scripts. Lives under ``plugins/_shared/`` so the + // provider scripts' relative ``..``/``..``/``_shared`` import + // pattern resolves to the staged location too. Not a hook — + // not executable. --- + ( + "plugins/_shared/google_oauth.py", + include_str!("../providers/_shared/google_oauth.py"), + ), // --- A short guide to the global layout + override model. --- ("README.md", GLOBAL_README), ] @@ -401,7 +410,9 @@ project. │ ├── codex/ # ChatGPT subscription OAuth provider │ ├── deepseek/ # DeepSeek API-key provider │ ├── antigravity/ # Google Antigravity IDE OAuth + Code Assist - │ └── gemini-cli/ # Google Gemini CLI OAuth + Code Assist + │ ├── gemini-cli/ # Google Gemini CLI OAuth + Code Assist + │ └── _shared/ # shared Python helpers used by the Google OAuth + │ # provider scripts (import-only, not a plugin) ├── README.md # this file └── .staged # staging schema version marker (do not edit) @@ -513,6 +524,10 @@ mod tests { home.join("plugins/gemini-cli/README.md").exists(), "gemini-cli provider README should be staged on first run" ); + assert!( + home.join("plugins/_shared/google_oauth.py").exists(), + "shared google_oauth helpers should be staged on first run" + ); assert!(home.join(".staged").exists()); assert_eq!( std::fs::read_to_string(home.join(".staged")).unwrap(), From 40b93d4912c88d271964e0029a7031a4518fd483 Mon Sep 17 00:00:00 2001 From: catcode Date: Fri, 7 Aug 2026 17:11:53 -0400 Subject: [PATCH 07/38] docs(plugins): document redirect_path, env_passthrough, header gotchas --- .../skills/plugin-authoring/SKILL.md | 38 +- core/providers/README.md | 105 +++++ docs/plugins/oauth.md | 430 ++++++++++++++++++ 3 files changed, 567 insertions(+), 6 deletions(-) create mode 100644 docs/plugins/oauth.md diff --git a/.catalyst-code/skills/plugin-authoring/SKILL.md b/.catalyst-code/skills/plugin-authoring/SKILL.md index fe7bb1e..9954a5b 100644 --- a/.catalyst-code/skills/plugin-authoring/SKILL.md +++ b/.catalyst-code/skills/plugin-authoring/SKILL.md @@ -594,11 +594,26 @@ Fields: - `login_timeout_ms` (optional, default 120000): timeout for `login` + `complete`. - `token_timeout_ms` (optional, default 30000): timeout for `token` + `clear`. +- `redirect_path` (optional, default `"/callback"`): the path component the + harness binds on its loopback redirect server for the web flow. **Must + match the redirect URI registered with the provider's OAuth client** — + Google's installed-app OAuth clients (Antigravity IDE, Gemini CLI) require + `"/oauth2callback"`; using the default `"/callback"` makes Google reject + the request as a non-compliant redirect URI (`redirect_uri_mismatch`). + The harness prefixes a `/` if absent, so `"/oauth2callback"` and + `"oauth2callback"` are equivalent. See + [`docs/plugins/oauth.md`](../../../docs/plugins/oauth.md#redirect_path-matching-the-providers-registered-redirect-uri) + for the full table of which providers need which path. - `env_passthrough` (optional): non-secret env var names the harness forwards - to your scripts (e.g. `["ACME_OAUTH_HOST"]` for a self-hosted auth server). - The harness otherwise scrubs the environment, so undeclared vars never reach - the script. Names containing KEY/TOKEN/SECRET/PASSWORD/CREDENTIAL are - rejected at load time — passthrough must never defeat env scrubbing. + to your scripts (e.g. `["ACME_OAUTH_HOST"]` for a self-hosted auth server, + or `["CATALYST_CODE__PROJECT"]` for a plugin-specific project + override that survives env scrubbing). The harness otherwise scrubs the + environment, so undeclared vars never reach the script. Names must match + `[A-Za-z_][A-Za-z0-9_]*`; any name containing KEY/TOKEN/SECRET/PASSWORD/ + CREDENTIAL (case-insensitive) is rejected at load time — passthrough must + never defeat env scrubbing. See + [`docs/plugins/oauth.md`](../../../docs/plugins/oauth.md#env_passthrough-plugin-specific-config-knobs-that-survive-env-scrubbing) + for the conventions and the rationale. #### Script action contract @@ -651,8 +666,19 @@ refresh (make your own HTTP call) and write the updated token back. Output: `expires_at` is unix seconds (optional; if 0/absent the harness caches for ~5 min). Optional `headers` are merged onto every request for that provider (plugin wins on name conflicts) and cached with the token — use this for -per-user identity headers such as ChatGPT's `chatgpt-account-id`. This runs -on the per-turn hot path, so it is cached until near expiry. +per-user identity headers such as ChatGPT's `chatgpt-account-id` or +Google Code Assist's `x-code-assist-project` (Antigravity / Gemini CLI +bundles). This runs on the per-turn hot path, so it is cached until near +expiry. + +**Header gotcha (Google Code Assist):** inject `x-code-assist-project`, +**not** `x-goog-user-project` and **not** `cloudaicompanion-project`. The +Code Assist chat gateway treats the three names as different routing +signals: only `x-code-assist-project` is authorized for Antigravity / +Gemini CLI OAuth tokens; the other two route to the consumer GenAI gate +and return `403 SERVICE_DISABLED`. Verified live and pinned by the +`wire_shape_contract` test module in +`core/src/providers/google_code_assist.rs`. Concurrency: several harness processes (TUI, web service, a second TUI) can invoke `token` at the same time, and providers commonly rotate refresh tokens. diff --git a/core/providers/README.md b/core/providers/README.md index a3abeee..1addd46 100644 --- a/core/providers/README.md +++ b/core/providers/README.md @@ -64,3 +64,108 @@ daily-cloudcode-pa hosts to the right wire format, and the adapter's `resolve_project` reads the `x-code-assist-project` header that each plugin's `token` action injects to use the user's real Code Assist project instead of the freemium fallback. + +## OAuth gotchas + +These are the wire-level footguns the `google_code_assist` adapter exists +to handle and the `wire_shape_contract` test module in +`core/src/providers/google_code_assist.rs` line 800 is the authoritative +spec for. Anything in this section will break the live Antigravity IDE / +Gemini CLI flow with HTTP 403 (`SERVICE_DISABLED`) or +`redirect_uri_mismatch` if violated. + +### 1. Project header: `x-code-assist-project`, NOT `x-goog-user-project` + +`resolve_project` reads the **first** header in the provider's headers vec +that matches any of: + +- `x-goog-user-project` +- `cloudaicompanion-project` +- `x-code-assist-project` + +(iteration order, case-insensitive). The Google Code Assist chat gateway +treats these as **three different signals** with **different routing**: + +| Header | What the gateway does | What to do | +|--------|-----------------------|------------| +| `x-goog-user-project` | Routes to the **consumer** Generative Language API (GenAI) gate. The Antigravity / Gemini CLI OAuth token does **not** have access; the gateway returns `403 SERVICE_DISABLED`. | **Do not inject.** | +| `cloudaicompanion-project` | Routes to the consumer gate same as `x-goog-user-project`. | **Do not inject.** | +| `x-code-assist-project` | Routes to the **Code Assist** gate. The OAuth token is authorized here. The body also carries the same value in `body.project`. | **Inject this one.** | + +**The plugin's `token` action MUST return `x-code-assist-project` in its +`headers` array** (not `x-goog-user-project`, not +`cloudaicompanion-project`). The bundled `antigravity/` and `gemini-cli/` +bundles both do this. Verified live against the +`daily-cloudcode-pa.sandbox.googleapis.com` and +`cloudcode-pa.googleapis.com` hosts — swapping the header name surfaces +as `403 SERVICE_DISABLED` on the very first chat request, with no helpful +error message from the gateway. + +The `wire_shape_contract::resolve_project_picks_first_matching_header_in_iteration_order` +test (line 882) pins this behavior. + +### 2. Code Assist body envelope shape + +The Code Assist / GenAI chat endpoint does not use the OpenAI +`{messages, …}` body. The adapter wraps the user messages into the +GenAI streaming envelope: + +```json +{ + "model": "", + "project": "", + "userAgent": "antigravity", + "request": { + "contents": [ {"role": "user", "parts": [{"text": "…"}]}, … ], + "generationConfig": { "maxOutputTokens": }, + "systemInstruction": {"parts": [{"text": "…"}]}, + "tools": [{"functionDeclarations": […]}], + "thinkingConfig": {"thinkingLevel": "low|medium|high", "includeThoughts": true} + } +} +``` + +Pinned by the +`wire_shape_contract::body_uses_antigravity_user_agent_and_body_project` +test (line 837). Key constraints: + +- `userAgent` is the **string** `"antigravity"` for Antigravity IDE traffic + and `"gemini-cli"` for Gemini CLI traffic. The gateway distinguishes + clients by this field. +- `project` is the value the plugin's `token` action injected as + `x-code-assist-project`. The header and the body field must agree. +- `contents[].role` is **only** `user` or `model`. `functionResponse` + parts must ride on a `user` turn (using role `function` 400s on + `cloudcode-pa` / `generativelanguage`). +- `maxOutputTokens: 0` is rejected ("generate nothing"); the adapter + floors to `1`. +- Empty `contents` (system-only) is rejected; the adapter errors before + sending instead of letting the gateway 400. +- Gemini 3 uses `thinkingLevel` (`minimal` / `low` / `medium` / `high` / + `auto`); Gemini 2.5 uses `thinkingBudget` (numeric); Gemini 2.0 + rejects `thinkingConfig` entirely. The adapter picks the right shape + per model id (`model_supports_thinking`). + +### 3. Redirect path: `/oauth2callback` for Google + +The Antigravity and Gemini CLI bundles both declare +`redirect_path: "/oauth2callback"`. Google's installed-app OAuth clients +only accept this exact path; using the harness's default `/callback` +makes `accounts.google.com` reject the request as a non-compliant +redirect URI (error: `redirect_uri_mismatch`, hard non-compliance +per Google's OAuth 2.0 policy for installed apps). The plugin is +expected to embed the harness-provided `redirect_uri` **verbatim** in +the authorize URL — including the port and path. + +### 4. Token refresh on the hot path + +The `token` action runs on **every turn** (cached for ~5 min, then +re-run). Two consequences: + +- Keep `token` cheap. Refresh only when the cached token is near + expiry; do not call out to the IdP on every chat turn. +- The `headers` returned by `token` are **cached with the token** and + merged onto the provider's request headers. If `x-code-assist-project` + changes between calls (e.g. the user's `loadCodeAssist` rotation + swapped the project), the new value reaches the gateway on the very + next turn without a `/login` cycle. diff --git a/docs/plugins/oauth.md b/docs/plugins/oauth.md new file mode 100644 index 0000000..09ee483 --- /dev/null +++ b/docs/plugins/oauth.md @@ -0,0 +1,430 @@ +# Plugin OAuth Providers + +A plugin can add a **subscription OAuth provider** to the harness — no +recompile, no API key, the same `/login` + `/models` flow as a built-in +provider. The plugin declares an `oauth` block in `plugin.json`; the harness +owns the loopback redirect server, polling, and the per-turn token refresh +loop. The plugin supplies **one** script (or per-action overrides) that owns +the on-disk token format and any provider-specific quirks. + +This page is the wire-level spec for the `oauth` block and the harness ↔ +script contract. The terse overview lives in +[`.catalyst-code/skills/plugin-authoring/SKILL.md`](../../.catalyst-code/skills/plugin-authoring/SKILL.md) +("Declaring an OAuth provider"); the bundle catalog (which providers are +shipped with the core) lives in +[`core/providers/README.md`](../../core/providers/README.md). + +--- + +## Table of contents + +- [Full manifest schema](#full-manifest-schema) + - [Field reference](#field-reference) + - [`redirect_path`: matching the provider's registered redirect URI](#redirect_path-matching-the-providers-registered-redirect-uri) + - [`env_passthrough`: plugin-specific config knobs that survive env scrubbing](#env_passthrough-plugin-specific-config-knobs-that-survive-env-scrubbing) +- [Harness ↔ script contract](#harness--script-contract) + - [Base context (every action)](#base-context-every-action) + - [`login`](#login) + - [`complete`](#complete) + - [`token`](#token) + - [`clear`](#clear) +- [Wire-format examples](#wire-format-examples) + - [Web flow (browser on the local machine)](#web-flow-browser-on-the-local-machine) + - [Manual / headless flow (paste a code)](#manual--headless-flow-paste-a-code) + - [Automatic device-code flow](#automatic-device-code-flow) +- [How it fits into the harness](#how-it-fits-into-the-harness) +- [Reference implementations](#reference-implementations) + +--- + +## Full manifest schema + +The full `OauthManifestEntry` (mirrors `core/src/plugins.rs::OauthManifestEntry`, +the `#[derive(Deserialize)]` the harness actually parses): + +```json +{ + "name": "my-provider", + "version": "0.1.0", + "oauth": { + "provider_id": "my-provider", + "label": "My Provider (subscription)", + "kind": "openai", + "base_url": "https://api.example.com/v1", + "description": "Used in the /login picker", + "headers": [ + ["User-Agent", "my-plugin/0.1"] + ], + "token_path": "my-provider.json", + "detect_path": null, + "script": "oauth/my-provider-oauth.py", + "login_script": "oauth/login.py", + "complete_script": "oauth/complete.py", + "token_script": "oauth/token.py", + "login_timeout_ms": 180000, + "token_timeout_ms": 30000, + "redirect_path": "/oauth2callback", + "env_passthrough": [ + "MY_PROVIDER_HOST", + "CATALYST_CODE_MYPROVIDER_PROJECT" + ] + } +} +``` + +`plugin.json` must also declare the capabilities the `oauth` block implies — +`execute_subprocess`, `register_providers`, `access_network`, `access_secrets`. +The harness infers them when `capabilities` is omitted. + +### Field reference + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `provider_id` | string | **yes** | — | Stable provider identity. `/login`, `/oauth-code`, `/logout`, and the created `~/.config/catalyst-code/config.json` entry all use this name. The plugin's `name` and `provider_id` are independent. | +| `label` | string | no | `provider_id` | Human-readable name shown in the `/login` picker. | +| `kind` | string | no | `"openai"` | Wire protocol. `"openai"` → `/chat/completions` + `Authorization: Bearer`. `"anthropic"` → `/v1/messages` + `x-api-key`. The harness uses this to pick the adapter; model discovery, request building, and SSE decoding all follow it. | +| `base_url` | string | **yes** | — | Provider endpoint, including any path prefix the API expects (`/v1`, `/v1internal`, …). Paths are appended directly. | +| `description` | string | no | `""` | Shown alongside the label in the `/login` picker. | +| `headers` | array of `[name, value]` | no | `[]` | Extra HTTP headers on every request for this provider. Persisted into the `config.json` provider entry. Plugin wins on name conflicts with any header the `token` action also returns. | +| `token_path` | string | no | `.json` | Token-file name, resolved against `~/.config/catalyst-code/oauth/`. The harness passes the **absolute** path to every script invocation; the plugin owns the on-disk format. | +| `detect_path` | string | no | — | External credential file the harness can probe for cheap "already-logged-in" detection (no schema parsing). Supported patterns: `$CODEX_HOME/auth.json` and `~/.codex/auth.json`. Other paths are resolved against `$HOME` and rejected if they escape it or are absolute. The provider script remains responsible for importing the format. | +| `script` | string | conditional | — | Script handling **all** four actions, dispatched by the `action` field on stdin. Required unless every action has an explicit override. | +| `login_script` | string | no | falls back to `script` | Per-action override for `login`. | +| `complete_script` | string | no | falls back to `script` | Per-action override for `complete`. | +| `token_script` | string | no | falls back to `script` | Per-action override for `token`. **Token resolution is mandatory** — without a script for `token` (or a shared `script`), the harness rejects the manifest at load time. | +| `login_timeout_ms` | number | no | `120000` | Per-call timeout for `login` and `complete`. | +| `token_timeout_ms` | number | no | `30000` | Per-call timeout for `token` and `clear`. `token` runs on the per-turn hot path, so keep it short. | +| `redirect_path` | string | no | `"/callback"` | The path the harness binds on its loopback server for the web flow. Must match the redirect URI registered with the provider's OAuth client. See [below](#redirect_path-matching-the-providers-registered-redirect-uri). | +| `env_passthrough` | array of string | no | `[]` | Non-secret env var names the harness forwards from its own process env to the plugin's scripts. Names must be `[A-Za-z_][A-Za-z0-9_]*` and **must not** contain `KEY`, `TOKEN`, `SECRET`, `PASSWORD`, or `CREDENTIAL` (case-insensitive) — passthrough must never defeat env scrubbing. See [below](#env_passthrough-plugin-specific-config-knobs-that-survive-env-scrubbing). | + +### `redirect_path`: matching the provider's registered redirect URI + +The harness binds a loopback server (`http://localhost:/`) +on demand and embeds that exact URL in the authorize request the script +builds. **The path component is not arbitrary** — it must be one of the +redirect URIs registered with the provider's OAuth client, or the provider +will reject the request (Google, for example, returns a +`redirect_uri_mismatch` error and the spec calls this out as a hard +non-compliance). + +| Provider / client type | Required path | Why | +|------------------------|---------------|-----| +| Most OAuth clients (the default) | `/callback` | The conventional path; the harness ships this as the default so a simple `oauth` block "just works". | +| Google installed-app OAuth clients (Antigravity IDE, Gemini CLI) | `/oauth2callback` | Google only accepts this exact path for installed-app / desktop clients; `/callback` is rejected as non-compliant. | +| Self-hosted / custom IdPs | Whatever the IdP expects | E.g. a corporate IdP may require `/auth/callback` or `/oauth/callback`. | + +When to set it: + +- **Always set it for Google OAuth clients** (the Antigravity and Gemini CLI + bundles do). Verified live: omitting it on the Antigravity OAuth client + returns `redirect_uri_mismatch` from `accounts.google.com`. +- **Always set it when the provider's registered redirect URI is not + `/callback`**. Read the provider's OAuth docs. +- **Default is fine for most other providers** (ChatGPT Codex, Grok xAI, + generic OAuth/OIDC, GitHub Apps with a localhost callback, etc.). + +Implementation note: the harness prefixes a `/` if the value does not start +with one, so `redirect_path: "oauth2callback"` and +`redirect_path: "/oauth2callback"` are equivalent. Absolute paths and paths +with a scheme/host are rejected. + +### `env_passthrough`: plugin-specific config knobs that survive env scrubbing + +Plugin scripts are spawned with a **scrubbed** environment: the harness +clears the child's env and re-injects only a small allowlist (`PATH`, +`HOME`, `TMPDIR`, `USER`, plus the Windows baseline on Windows, plus a +handful of memory-provider keys). This is the defense against a plugin +script accidentally seeing — or exfiltrating — a `*_API_KEY` / `*_TOKEN` +the user exported. The cost: plugin scripts **cannot** see any env var by +default. + +`env_passthrough` is the explicit opt-in. Declare the names (not values) of +the env vars your scripts need, and the harness reads the values from its +own process env at call time and injects them into the script's child env. + +**Conventions** + +- **Plugin-specific project overrides** should follow the + `CATALYST_CODE__PROJECT` pattern so they're namespaced and easy to + grep for. Examples already in the catalog: + - `CATALYST_CODE_ANTIGRAVITY_PROJECT` — overrides the Antigravity Code + Assist `cloudaicompanionProject` (bypasses the `loadCodeAssist` + auto-discovery round-trip in tests / CI). + - `CATALYST_CODE_GEMINICLI_PROJECT` — same for the Gemini CLI bundle. +- **Self-hosted IdP overrides** typically use a `_HOST` / + `_API_URL` / `_TENANT` shape. Example: + `["ACME_OAUTH_HOST", "ACME_TENANT"]`. +- **Never** put a secret in passthrough. Names containing `KEY`, `TOKEN`, + `SECRET`, `PASSWORD`, or `CREDENTIAL` (case-insensitive) are rejected at + manifest load time. The `value` of a passthrough var lives in the + harness's env and is whatever the user exported — the harness does not + inspect or redact it, so do not use passthrough as a back door to leak + `OPENAI_API_KEY` to a plugin. (The harness already has the value; if a + plugin needs to know the API key, the user must pass it explicitly via + `api_key` on a `login` command, not via env.) +- **Validation**: the name must match `[A-Za-z_][A-Za-z0-9_]*`. Names that + are empty, contain punctuation, or start with a digit are rejected at + load. This blocks shell-injection attempts in any naive + `env("USER_SUPPLIED_$X")` plumbing. + +**Why not just allow `*`?** The whole point of env scrubbing is that a +plugin script cannot reach the user's `*_API_KEY` exports. An allowlist +keeps the trust model auditable: every env var a plugin can see is declared +in its `plugin.json`. + +--- + +## Harness ↔ script contract + +Every script invocation has the same shape: + +1. The harness writes **one JSON object** to the script's stdin. +2. The script processes it. +3. The script writes **one JSON object** to stdout (terminated by EOF or + close). Stderr is captured for error reporting. +4. The harness enforces the timeout (`login_timeout_ms` for `login`/ + `complete`; `token_timeout_ms` for `token`/`clear`), validates that + the exit was zero, parses the JSON, and either uses the response or + surfaces an error event. + +JSON input is bounded to 1 MiB and stdout/stderr to 1 MiB per invocation. +Timeouts, non-zero exits, and parse failures are surfaced as `error` events +— they never crash the core. + +### Base context (every action) + +The harness always injects these fields; each action adds its own. + +```json +{ + "action": "login", + "provider_id": "my-provider", + "token_path": "/home/user/.config/catalyst-code/oauth/my-provider.json", + "workspace": "/abs/path/to/workspace", + "timestamp": 1719000000 +} +``` + +`action` is the discriminator (`"login"`, `"complete"`, `"token"`, +`"clear"`). `token_path` is the **absolute** path the harness expects the +script to read/write; the script owns the file's format. + +### `login` + +**Input** (additions to base context): + +| Field | When | Description | +|-------|------|-------------| +| `headless` | always | `true` if the harness detected no display / no browser support; `false` otherwise. Honor it when choosing between web and manual. | +| `redirect_uri` | non-headless only | The `http://localhost:/` the harness already bound. Embed it **verbatim** in the authorize URL the script builds. | + +**Output** (any subset): + +```json +{ + "url": "https://auth.example.com/oauth/authorize?...", + "code": "ABCD-EFGH", + "message": "Open the URL and enter the code", + "flow": "web", + "state": "", + "pending": { "verifier": "", "device_id": "" } +} +``` + +- `url` (required, except for `flow: "already_authenticated"`): the + authorize/verify URL the user should open. +- `code` (optional): user-code to display for manual / device flows. +- `message` (optional, defaults to a generic prompt): UI message shown + alongside the URL. +- `flow` (optional, defaults inferred from `headless`): + - `"web"` — the harness will wait for the loopback redirect at + `redirect_uri`. + - `"manual"` — the harness stashes the `pending` blob and waits for + `/oauth-code ` from the user. + - `"poll"` or `"auto"` — the harness immediately calls `complete` and + waits for the script to drive the device-code polling loop. + - `"already_authenticated"` — the script imported an existing + credential store and no browser flow is needed; the harness skips + straight to `finalize_oauth`. +- `state` (web flow): the CSRF state you put in the authorize URL, so the + harness can verify the redirect. +- `pending`: an opaque JSON blob to carry to `complete` (PKCE verifier, + device-auth id, anything else). Passed back verbatim. + +### `complete` + +**Input** (additions to base context): + +| Field | When | Description | +|-------|------|-------------| +| `code` | web + paste flows | The authorization code the provider returned (from the redirect query string or the user's paste). | +| `redirect_uri` | web flow | The same loopback URI from `login` — re-sent so the script can re-validate the code. | +| `pending` | always | The opaque `pending` blob from `login`, if the script returned one. | + +**Output**: + +```json +{ "ok": true } +{ "ok": false, "error": "expired code" } +``` + +On `ok: true` the script **must** have written the token to `token_path` +(or sidecar files of its own design). On `ok: false` the harness surfaces +`error` as an `error` event and restores the pending state so the user can +retry with `/oauth-code`. + +### `token` + +**Input**: base context only. `action` is `"token"`. + +**Output**: + +```json +{ + "access_token": "", + "expires_at": 1719003600, + "headers": [ + ["chatgpt-account-id", ""], + ["x-code-assist-project", "my-project"] + ] +} +``` + +- `access_token` (required, non-empty): the bearer to use. The harness + injects it as `Authorization: Bearer ` for `kind: "openai"` + or `x-api-key: ` for `kind: "anthropic"`. +- `expires_at` (optional, unix seconds): when the harness should re-run + `token` to refresh. `0` or absent = cache for ~5 minutes. +- `headers` (optional): extra HTTP headers to merge onto the provider's + request headers for **this turn and every subsequent turn** (cached with + the token). Plugin wins on name conflicts. Common uses: + - `chatgpt-account-id` for ChatGPT multi-account. + - `x-code-assist-project` for Antigravity / Gemini CLI bundles + (overrides the freemium `rising-fact-p41fc` default — see + [OAuth gotchas](../../core/providers/README.md#oauth-gotchas)). + - `anthropic-beta` for Anthropic features gated on headers. + +This runs on the per-turn hot path. **Concurrency note:** several harness +processes (TUI, web service, a second TUI) can invoke `token` at the same +time, and providers commonly rotate refresh tokens. Write `token_path` +**atomically** (temp file + rename) and serialize the refresh (e.g. +`flock` on a sidecar lock, then re-check freshness before refreshing) — a +truncated read or a lost refresh-token rotation surfaces to the user as an +unexplained "run /login" prompt. + +### `clear` + +**Input**: base context only. + +**Output**: + +```json +{ "ok": true } +``` + +The harness **also** deletes `token_path`, so this action is optional. +Use it to clean up sidecar files the script manages (a refresh-token +mirror, a state file, etc.). + +--- + +## Wire-format examples + +### Web flow (browser on the local machine) + +1. The user runs `/login my-provider`. +2. The harness binds a loopback server, e.g. `http://localhost:51234/oauth2callback`. +3. The harness calls `login` with stdin: + ```json + { + "action": "login", "provider_id": "my-provider", + "token_path": "/home/user/.config/catalyst-code/oauth/my-provider.json", + "workspace": "/abs/path/to/workspace", "timestamp": 1719000000, + "headless": false, + "redirect_uri": "http://localhost:51234/oauth2callback" + } + ``` +4. The script returns: + ```json + { + "url": "https://auth.example.com/oauth/authorize?client_id=...&redirect_uri=http%3A%2F%2Flocalhost%3A51234%2Foauth2callback&state=csrf&...&code_challenge=...&code_challenge_method=S256", + "flow": "web", + "state": "csrf", + "pending": { "verifier": "" } + } + ``` +5. The harness emits an `oauth_prompt` event (URL + message) and opens + the browser. +6. The user approves; the browser hits + `http://localhost:51234/oauth2callback?code=...&state=csrf`. +7. The harness verifies `state`, calls `complete` with stdin: + ```json + { + "action": "complete", "provider_id": "my-provider", + "token_path": "...", "workspace": "...", "timestamp": 1719000050, + "code": "", "redirect_uri": "http://localhost:51234/oauth2callback", + "pending": { "verifier": "" } + } + ``` +8. The script exchanges the code, writes the token, returns `{"ok": true}`. +9. The harness calls `finalize_oauth`: creates the provider config, sets + it active, refreshes models, emits `authed` + `provider_changed`. + +### Manual / headless flow (paste a code) + +Same as web flow, but step 5 returns `flow: "manual"`. The harness emits +`oauth_prompt` and **does not** open a browser. The user pastes the code +via `/oauth-code ` (or the `oauth_code` protocol command), which +drives step 7. + +This is the right flow for SSH/headless sessions, and the recommended +flow for CI / first-party smoke tests. + +### Automatic device-code flow + +Step 5 returns `flow: "poll"` (or `"auto"`, or +`auto_complete: true`). The harness immediately calls `complete` with an +empty `code`; the script owns the polling loop. The user still sees the +URL + user-code via `oauth_prompt`, but no `/oauth-code` is needed. + +--- + +## How it fits into the harness + +- `/login ` → harness runs `login` → emits `oauth_prompt` → + waits for the redirect (web), invokes `complete` immediately (auto + poll), or stashes `pending` for `/oauth-code` (manual). On success it + creates the provider config (name = `provider_id`, your + `base_url`/`kind`/`headers`, no `api_key`) and refreshes `/models`. +- Every turn → harness runs `token` (cached), injects the access token as + `Authorization: Bearer`, merges any returned `headers`, and routes the + turn to your `base_url` over your declared `kind`. +- `/logout ` → deletes `token_path` + runs `clear` + drops + the provider config. + +The plugin's token format is entirely its own — the harness never parses +the contents of `token_path`. + +--- + +## Reference implementations + +Bundled in `core/providers//`: + +- `codex/` — ChatGPT (Codex) CLI device-code OAuth with automatic polling + and `auth.json` import. +- `antigravity/` — Google Antigravity IDE Authorization Code + PKCE with + the `loadCodeAssist` project discovery. Uses + `redirect_path: "/oauth2callback"`. +- `gemini-cli/` — Google Gemini CLI Authorization Code + PKCE with + `loadCodeAssist` project discovery. Uses + `redirect_path: "/oauth2callback"`. +- `kimi/` — Kimi Code (Moonshot) device-code OAuth. +- `deepseek/` — **not OAuth** — this is an API-key bundle, shown here only + as the side-by-side catalog entry. + +External template: `docs/examples/plugins/grok-oauth/`. + +The wire-level spec is mirrored in +`.catalyst-code/skills/plugin-authoring/SKILL.md` ("Declaring an OAuth +provider"). Update both when adding new fields. From 4e894eca7710eaeefaefdd31092abbfc05fe77bd Mon Sep 17 00:00:00 2001 From: catcode Date: Fri, 7 Aug 2026 17:11:55 -0400 Subject: [PATCH 08/38] test(core): integration test for OAuth plugin lifecycle + redirect_path --- core/tests/oauth_plugin_lifecycle.rs | 624 +++++++++++++++++++++++++++ 1 file changed, 624 insertions(+) create mode 100644 core/tests/oauth_plugin_lifecycle.rs diff --git a/core/tests/oauth_plugin_lifecycle.rs b/core/tests/oauth_plugin_lifecycle.rs new file mode 100644 index 0000000..737566f --- /dev/null +++ b/core/tests/oauth_plugin_lifecycle.rs @@ -0,0 +1,624 @@ +// Integration test: full OAuth plugin lifecycle. +// +// Drives the core binary as a subprocess (the same pattern as +// `protocol_harness.rs`) and exercises: +// +// 1. Plugin manifest load with a declared `redirect_path` and +// `env_passthrough` (the plugin loader resolves both into the +// loaded `PluginOauthConfig`). +// 2. `login` action: harness emits an `oauth_prompt` event with the +// `redirect_uri` honoring `redirect_path`. +// 3. `complete` action: harness runs the script with the pasted code; +// script writes the on-disk token file. +// 4. `token` action: harness calls the script at turn time to resolve +// the access token; script returns `access_token` + `headers`. +// 5. The `headers` from the `token` action are merged onto the +// provider's outgoing chat request — verifiable at the mock HTTP +// server. +// 6. The `env_passthrough` env var reaches the script's child env +// (despite the harness's `env_clear` + allowlist) — the script +// echoes it back as `X-Received-Env` in its `headers`. +// +// The `PluginOauthConfig` struct is private to the binary crate, so we +// exercise the loader end-to-end via the JSON-RPC protocol and verify +// behavior at observable boundaries (events the harness emits, headers +// the mock server receives). The `redirect_path` and `env_passthrough` +// fields are also re-parsed from the manifest in the test as a sanity +// check that the source of truth is what the harness loader sees. +// +// The test mirrors the existing `protocol_harness.rs` patterns: a +// `mock_provider` HTTP server on 127.0.0.1, a `CoreHarness` wrapper for +// the spawned core subprocess, and JSON-RPC command/event send/wait +// helpers. + +use serde_json::Value; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpListener; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, Receiver}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +// ---------- shared helpers ---------- + +fn read_http_request(stream: &mut std::net::TcpStream) -> String { + let _ = stream.set_read_timeout(Some(Duration::from_secs(2))); + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 8192]; + let mut header_end = None; + while let Ok(read) = stream.read(&mut buffer) { + if read == 0 { + break; + } + bytes.extend_from_slice(&buffer[..read]); + if header_end.is_none() { + header_end = bytes.windows(4).position(|window| window == b"\r\n\r\n"); + } + if let Some(end) = header_end { + let headers = String::from_utf8_lossy(&bytes[..end]); + let content_length = headers + .lines() + .find(|line| line.to_ascii_lowercase().starts_with("content-length:")) + .and_then(|line| line.split_once(':')) + .and_then(|(_, value)| value.trim().parse::().ok()) + .unwrap_or(0); + if bytes.len() >= end + 4 + content_length { + break; + } + } + } + String::from_utf8_lossy(&bytes).into_owned() +} + +fn write_json_response(stream: &mut std::net::TcpStream, body: &str) { + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); +} + +fn write_sse_chunk(stream: &mut std::net::TcpStream, payload: &str) -> bool { + let chunk = format!("{:x}\r\n{}\r\n", payload.len(), payload); + stream.write_all(chunk.as_bytes()).is_ok() && stream.flush().is_ok() +} + +fn temp_workspace() -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + // HOME points at the workspace so the harness's `~/.config/catalyst-code/oauth/` + // resolves inside the test's tempdir — never pollute the real $HOME with the + // fake test_oauth.json token file. + let path = std::env::temp_dir().join(format!("catcode-oauth-lifecycle-{nonce}")); + std::fs::create_dir_all(&path).unwrap(); + path +} + +// ---------- mock provider: models list + OpenAI-compatible chat ---------- + +struct MockProvider { + base_url: String, + stop: Arc, + handle: thread::JoinHandle<()>, + /// One slot per recorded chat request: (Authorization, x-code-assist-project, X-Received-Env). + chat_requests: Arc>>, +} + +fn spawn_mock_provider() -> MockProvider { + 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 thread_stop = stop.clone(); + let chat_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let chat_requests_thread = chat_requests.clone(); + let handle = thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(30); + while Instant::now() < deadline && !thread_stop.load(Ordering::Relaxed) { + let Ok((mut stream, _)) = listener.accept() else { + thread::sleep(Duration::from_millis(5)); + continue; + }; + let request = read_http_request(&mut stream); + let first_line = request.lines().next().unwrap_or_default(); + if first_line.starts_with("GET ") { + // Discovery probes `/models/info` (Umans-specific) first and + // falls back to the standard OpenAI `/v1/models` on a miss. + // Return 404 for the Umans-specific path so we always land in + // the standard OpenAI parser; the `/v1/models` response uses + // the canonical `data: [{id, name, ...}]` shape. + if first_line.contains("/models/info") { + let response = + "HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\nconnection: close\r\n\r\n"; + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + } else { + let body = r#"{"data":[{"id":"mock-model","name":"Mock"}]}"#; + write_json_response(&mut stream, body); + } + continue; + } + if !first_line.starts_with("POST ") { + write_json_response(&mut stream, r#"{"error":"unsupported"}"#); + continue; + } + // Record the chat request's auth/identity headers so the test + // can assert on them. Headers are case-insensitive; the harness + // may send `x-code-assist-project` from the OAuth `token` action + // and `X-Received-Env` from the same headers array (env + // passthrough round-trip). + let auth = request + .lines() + .find(|line| line.to_ascii_lowercase().starts_with("authorization:")) + .map(|line| { + line.split_once(':') + .map(|(_, v)| v.trim().to_string()) + .unwrap_or_default() + }) + .unwrap_or_default(); + let project = request + .lines() + .find(|line| { + line.to_ascii_lowercase() + .starts_with("x-code-assist-project:") + }) + .map(|line| { + line.split_once(':') + .map(|(_, v)| v.trim().to_string()) + .unwrap_or_default() + }) + .unwrap_or_default(); + let received_env = request + .lines() + .find(|line| line.to_ascii_lowercase().starts_with("x-received-env:")) + .map(|line| { + line.split_once(':') + .map(|(_, v)| v.trim().to_string()) + .unwrap_or_default() + }) + .unwrap_or_default(); + chat_requests_thread + .lock() + .unwrap() + .push((auth, project, received_env)); + // OpenAI-compatible chat completion in SSE form: a single + // text delta, then a finish chunk with usage. + let _ = stream.write_all( + b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\ + transfer-encoding: chunked\r\nconnection: close\r\n\r\n", + ); + let _ = stream.flush(); + // One text delta ("OK") + one finish chunk. The harness turns + // the finish chunk into a `done` event with the usage. + let first = format!( + "data: {}\n\n", + serde_json::json!({"choices": [{"delta": {"content": "OK"}}]}) + ); + let finish = format!( + "data: {}\n\n", + serde_json::json!({ + "choices": [{"delta": {}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 3, "completion_tokens": 1} + }) + ); + let _ = write_sse_chunk(&mut stream, &first); + let _ = write_sse_chunk(&mut stream, &finish); + let _ = stream.write_all(b"0\r\n\r\n"); + let _ = stream.flush(); + } + }); + MockProvider { + base_url: format!("http://{address}/v1"), + stop, + handle, + chat_requests, + } +} + +// ---------- the fake plugin bundle ---------- + +const FAKE_PLUGIN_NAME: &str = "test_oauth"; +const FAKE_PROVIDER_ID: &str = "test_oauth"; +const EXPECTED_REDIRECT_PATH: &str = "/oauth2callback"; +const EXPECTED_ENV_PASSTHROUGH: &[&str] = &["FAKE_TEST_VAR"]; + +const FAKE_PLUGIN_PY: &str = r#"#!/usr/bin/env python3 +"""Fake OAuth script for the oauth_plugin_lifecycle integration test. + +Drives all four actions of the OAuth contract: + + login -> return a manual-flow authorize URL + a fake user code. + The harness emits an `oauth_prompt` event; the test then + sends `oauth_code TEST-CODE` to drive `complete`. + complete -> write a token file at the harness-provided absolute path + and return {ok: true}. + token -> return a fresh access_token + the headers the test + asserts against (x-code-assist-project, X-Received-Env). + X-Received-Env is the env passthrough round-trip: the + script reads FAKE_TEST_VAR from its own env (proving the + harness forwarded it) and echoes it back as a header. + clear -> return {ok: true}. + +Stdlib only. +""" +import json +import os +import sys +import time + + +def write(obj): + sys.stdout.write(json.dumps(obj)) + sys.stdout.flush() + + +def main(): + ctx = json.loads(sys.stdin.read()) + action = ctx.get("action") + if action == "login": + write({ + "url": "http://127.0.0.1:1/auth", + "flow": "manual", + "code": "TEST-CODE", + "message": "open the URL and paste the code", + "state": "csrf-test", + "pending": {"verifier": "pkce-verifier"}, + }) + elif action == "complete": + # The script is responsible for writing the on-disk token in the + # format the plugin chose. The harness only checks existence. + token_path = ctx.get("token_path", "") + if token_path: + # The harness's token_path lives under + # `~/.config/catalyst-code/oauth/` but does NOT auto-create + # the directory. Mirror the behavior of the real bundled + # scripts (antigravity / gemini-cli) which create it before + # the first write. + import os as _os + parent = _os.path.dirname(token_path) + if parent: + _os.makedirs(parent, exist_ok=True) + with open(token_path, "w") as f: + json.dump({ + "access_token": "test-tok", + "refresh_token": "test-refresh", + "expires_at": int(time.time()) + 3600, + }, f) + write({"ok": True}) + elif action == "token": + # `env_passthrough` is forwarded to the script's child env. The + # script must NOT need to read any of the user's other env vars + # — the harness scrubs them. + received_env = os.environ.get("FAKE_TEST_VAR", "") + write({ + "access_token": "test-tok", + "expires_at": int(time.time()) + 3600, + "headers": [ + ["x-code-assist-project", "my-proj"], + ["X-Received-Env", received_env], + ], + }) + elif action == "clear": + write({"ok": True}) + else: + write({"ok": False, "error": "unknown action: %r" % action}) + + +if __name__ == "__main__": + main() +"#; + +fn write_fake_plugin(workspace: &PathBuf, base_url: &str) -> PathBuf { + let plugin_dir = workspace + .join(".catalyst-code") + .join("plugins") + .join(FAKE_PLUGIN_NAME); + std::fs::create_dir_all(plugin_dir.join("oauth")).unwrap(); + + // `plugin.json` — the manifest the harness loader reads. `redirect_path` + // and `env_passthrough` are the two new fields the test exercises; the + // rest mirrors the bundled antigravity / gemini-cli shape. + let plugin_json = serde_json::json!({ + "name": FAKE_PLUGIN_NAME, + "version": "0.1.0", + "description": "Fake OAuth plugin for the lifecycle integration test.", + "capabilities": [ + "execute_subprocess", + "register_providers", + "access_network", + "access_secrets" + ], + "oauth": { + "provider_id": FAKE_PROVIDER_ID, + "label": "Test OAuth", + "kind": "openai", + "base_url": base_url, + "description": "Round-trips redirect_path + env_passthrough for the test.", + "headers": [], + "token_path": "test_oauth.json", + "script": "oauth/test_oauth.py", + "login_timeout_ms": 30000, + "token_timeout_ms": 30000, + "redirect_path": EXPECTED_REDIRECT_PATH, + "env_passthrough": EXPECTED_ENV_PASSTHROUGH, + } + }); + std::fs::write( + plugin_dir.join("plugin.json"), + serde_json::to_string_pretty(&plugin_json).unwrap(), + ) + .unwrap(); + + let script_path = plugin_dir.join("oauth").join("test_oauth.py"); + std::fs::write(&script_path, FAKE_PLUGIN_PY).unwrap(); + // Hooks/scripts are spawned directly; .py is launched via the python + // interpreter selected by the harness, so no +x is strictly required, + // but stay consistent with bundled plugins. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&script_path).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&script_path, perms).unwrap(); + } + + plugin_dir +} + +// ---------- core harness ---------- + +struct CoreHarness { + child: std::process::Child, + stdin: std::process::ChildStdin, + events: Receiver, +} + +impl CoreHarness { + fn start(workspace: &PathBuf, home: &std::path::Path) -> Self { + let session = workspace.join("session.jsonl"); + let config = workspace.join("config.json"); + std::fs::write(&config, "{}\n").unwrap(); + let inherited_path = std::env::var("PATH").unwrap_or_default(); + let harness_path = format!("{}:{inherited_path}", workspace.join("bin").display()); + let mut child = Command::new(env!("CARGO_BIN_EXE_core")) + .args([ + "--workspace", + workspace.to_str().unwrap(), + "--session", + session.to_str().unwrap(), + "--config", + config.to_str().unwrap(), + "--approval", + "never", + "--trust-project-plugins", + ]) + // HOME = testdir so the OAuth token file lands inside it; the + // harness's `home_dir()` reads $HOME first. + .env("HOME", home) + // The plugin's `env_passthrough` declares FAKE_TEST_VAR. The + // harness's `oauth_script_env` reads it from the harness + // process env and forwards it to the script's child env. + .env("FAKE_TEST_VAR", "test-value") + .env("PATH", harness_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("failed to spawn core"); + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let (sender, events) = mpsc::channel(); + thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + let Ok(line) = line else { break }; + if let Ok(event) = serde_json::from_str(&line) { + if sender.send(event).is_err() { + break; + } + } + } + }); + Self { + child, + stdin, + events, + } + } + + fn send(&mut self, command: Value) { + writeln!(self.stdin, "{command}").unwrap(); + self.stdin.flush().unwrap(); + } + + fn until(&self, event_type: &str) -> Vec { + self.until_where(event_type, |event| event["type"] == event_type) + } + + fn until_where(&self, description: &str, predicate: impl Fn(&Value) -> bool) -> Vec { + let mut events = Vec::new(); + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + let event = self.events.recv_timeout(remaining).unwrap_or_else(|error| { + panic!( + "core did not emit {description} before timeout ({error}); events: {}", + serde_json::to_string(&events).unwrap() + ) + }); + let done = predicate(&event); + events.push(event); + if done { + return events; + } + } + } +} + +impl Drop for CoreHarness { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +// ---------- assertions over the recorded chat request ---------- + +fn assert_chat_request_carries_oauth_headers(mock: &MockProvider) { + let recorded = mock.chat_requests.lock().unwrap().clone(); + assert!( + !recorded.is_empty(), + "mock provider received no chat requests; the harness never made a turn-bound call. \ + ensure the OAuth token script returned access_token + headers so the chat request could be made." + ); + let (auth, project, received_env) = &recorded[0]; + assert!( + auth.eq_ignore_ascii_case("Bearer test-tok"), + "expected Authorization: Bearer test-tok (the `token` action's access_token), got {auth:?}" + ); + assert_eq!( + project, "my-proj", + "expected x-code-assist-project: my-proj (the `token` action's headers[0]); \ + the harness merges `token` response headers onto every chat request" + ); + assert_eq!( + received_env, "test-value", + "expected X-Received-Env: test-value; the harness must forward env_passthrough names \ + to the script's child env (proves the env_passthrough round-trip end-to-end)" + ); +} + +// ---------- the test ---------- + +#[test] +fn oauth_plugin_lifecycle_loads_token_round_trip_and_injects_headers() { + // 1. Spawn the mock HTTP provider; record its URL so the fake plugin + // can point its `base_url` at it. The mock serves `/v1/models` and + // `/v1/chat/completions` (OpenAI-compatible). + let mock = spawn_mock_provider(); + let workspace = temp_workspace(); + // The harness reads `~/.config/catalyst-code/oauth/...` for the token + // file. Reusing the workspace as HOME keeps the test fully + // self-contained. + let plugin_dir = write_fake_plugin(&workspace, &mock.base_url); + + // 2. Sanity check: the manifest on disk is the source of truth the + // loader sees. (The `PluginOauthConfig` struct is a 1:1 + // deserialization of this `oauth` block — verifying the manifest + // verifies the loaded config's two new fields.) + let manifest_text = std::fs::read_to_string(plugin_dir.join("plugin.json")).unwrap(); + let manifest: Value = serde_json::from_str(&manifest_text).unwrap(); + let oauth = manifest + .get("oauth") + .expect("plugin.json has an oauth block"); + assert_eq!( + oauth.get("redirect_path").and_then(|v| v.as_str()), + Some(EXPECTED_REDIRECT_PATH), + "manifest's redirect_path must match — this is the field the \ + harness honors when binding the loopback redirect for the web \ + flow (Google's installed-app OAuth clients require /oauth2callback)" + ); + let passthrough: Vec = oauth + .get("env_passthrough") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + assert_eq!( + passthrough, + EXPECTED_ENV_PASSTHROUGH + .iter() + .map(|s| s.to_string()) + .collect::>(), + "manifest's env_passthrough must list the test env var names the harness should forward" + ); + + // 3. Spawn the core binary as a subprocess and drive the protocol. + let mut core = CoreHarness::start(&workspace, &workspace); + + // 4. init -> protocol_hello. The harness loads plugins before this + // handshake completes, so a malformed `oauth` block would surface + // as a load error here (no protocol_hello). The fact that we get + // past this proves the loader accepted the manifest and built a + // valid `PluginOauthConfig` (the loader rejects entries that have + // neither `script` nor `token_script`, invalid `kind`, + // secret-looking passthrough names, etc.). + core.send(serde_json::json!({"type":"init","protocol_version":2})); + let hello = core.until("protocol_hello"); + let hello_event = hello.last().unwrap(); + assert_eq!(hello_event["type"], "protocol_hello"); + + // 5. login_oauth test_oauth -> oauth_prompt. The script's `login` + // action returns flow: "manual" so the harness stashes the pending + // blob and waits for `oauth_code` instead of opening a browser. + core.send(serde_json::json!({"type":"login_oauth","preset":FAKE_PROVIDER_ID})); + let prompt = core.until("oauth_prompt"); + let prompt_event = prompt.last().unwrap(); + assert_eq!(prompt_event["type"], "oauth_prompt"); + assert_eq!( + prompt_event["code"].as_str(), + Some("TEST-CODE"), + "oauth_prompt should carry the user code returned by the script's login action" + ); + + // 6. oauth_code TEST-CODE -> the harness calls the script's + // `complete` action. The script writes the on-disk token and + // returns ok:true, which triggers `finalize_oauth`: emit `authed` + // + `provider_changed` + `info`, then refresh models (which hits + // our mock's /v1/models). + core.send(serde_json::json!({"type":"oauth_code","code":"TEST-CODE"})); + let events = core.until("authed"); + assert!(events + .iter() + .any(|event| event["type"] == "authed" && event["ok"] == true)); + // The provider_changed event confirms the plugin's base_url / kind / + // headers were promoted into the live provider config. + let provider_changed = core.until_where("provider_changed", |event| { + event["type"] == "provider_changed" && event["provider"] == FAKE_PROVIDER_ID + }); + let pc = provider_changed.last().unwrap(); + assert_eq!(pc["provider"], FAKE_PROVIDER_ID); + assert_eq!(pc["base_url"], mock.base_url); + assert_eq!(pc["kind"], "openai"); + assert_eq!(pc["has_key"], true); + + // 7. send a turn -> the harness calls enrich_oauth -> the script's + // `token` action. The script returns access_token + headers; the + // harness caches them and merges the headers onto the chat + // request that follows. + core.send(serde_json::json!({ + "type":"send", + "prompt":"round-trip the token", + "model":"mock-model", + "provider":FAKE_PROVIDER_ID + })); + let done_events = core.until("done"); + assert!(done_events + .iter() + .any(|event| event["type"] == "delta" && event["text"] == "OK"), + "no 'OK' delta in done events — the harness did not make a turn-bound call. events: {}", + serde_json::to_string(&done_events).unwrap()); + assert!(done_events.iter().any(|event| event["type"] == "done")); + + // 8. The mock provider must have received a chat request carrying the + // `token` action's `access_token` (as the Bearer) and `headers` + // (the x-code-assist-project + X-Received-Env round-trip). This is + // the final observable check that the loader + token action + + // provider header merge pipeline all work end-to-end. + assert_chat_request_carries_oauth_headers(&mock); + + // Cleanup. + drop(core); + mock.stop.store(true, Ordering::Relaxed); + let _ = mock.handle.join(); + let _ = std::fs::remove_dir_all(&workspace); +} From 71266cbedbac155810965f3f46258601266e8c14 Mon Sep 17 00:00:00 2001 From: catcode Date: Fri, 7 Aug 2026 17:16:48 -0400 Subject: [PATCH 09/38] test(providers): add e2e OAuth tests for antigravity (mock HTTP) Stdlib-only end-to-end tests that drive the Antigravity OAuth provider script against a local mock HTTP server. Covers PKCE S256 URL shape (client id, scopes, code_challenge = SHA256(verifier) base64url, access_type=offline, no prompt), complete -> token-file persistence with project_id from loadCodeAssist (file mode 0o600), refresh-grant preserves project_id + email across rotations, no-token returns null, onboarding fallback (loadCodeAssist returns tiers-only, onboardUser polls until done=true), clear removes the token + .lock sidecar, and the CATALYST_CODE_ANTIGRAVITY_PROJECT escape hatch wins over loadCodeAssist. Adds empty __init__.py markers under core/providers/ so 'python3 -m unittest discover -s core/providers' finds the tests, and a Python __pycache__ entry in .gitignore. --- .gitignore | 6 + core/providers/__init__.py | 0 core/providers/antigravity/__init__.py | 0 core/providers/antigravity/oauth/__init__.py | 0 .../oauth/test_antigravity_oauth.py | 537 ++++++++++++++++++ 5 files changed, 543 insertions(+) create mode 100644 core/providers/__init__.py create mode 100644 core/providers/antigravity/__init__.py create mode 100644 core/providers/antigravity/oauth/__init__.py create mode 100644 core/providers/antigravity/oauth/test_antigravity_oauth.py diff --git a/.gitignore b/.gitignore index c206b3e..940eb5b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,12 @@ core/target/ **/*.rs.bk +# Python +__pycache__/ +**/__pycache__/ +*.pyc +*.pyo + # Go (tui/) compiled binaries tui/tui tui/catalyst-code-tui diff --git a/core/providers/__init__.py b/core/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/providers/antigravity/__init__.py b/core/providers/antigravity/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/providers/antigravity/oauth/__init__.py b/core/providers/antigravity/oauth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/providers/antigravity/oauth/test_antigravity_oauth.py b/core/providers/antigravity/oauth/test_antigravity_oauth.py new file mode 100644 index 0000000..4984172 --- /dev/null +++ b/core/providers/antigravity/oauth/test_antigravity_oauth.py @@ -0,0 +1,537 @@ +#!/usr/bin/env python3 +"""End-to-end tests for the Antigravity OAuth provider script. + +Stdlib-only (``unittest``, ``http.server``, ``threading``, ``tempfile``, +``json``, ``urllib``, ``contextlib``, ``base64``, ``hashlib``, +``stat``, ``io``, ``os``, ``sys``). The provider script is exercised +in-process by rewriting its URL constants to point at a local mock HTTP +server and ``exec``'ing the patched source in a namespace with +``__file__`` set so the relative ``../../_shared/google_oauth.py`` +import still resolves. + +The harness JSON contract (per the script's stdin/stdout) is: + + stdin :: {"action": "login"|"complete"|"token"|"clear", ...} + stdout :: action-specific JSON object (see the script docstring) + +These tests cover the contract end-to-end against a mock Google auth +server so we can verify URL shape, PKCE, refresh-token rotation, +project-id discovery + overrides, and the on-disk file mode without +needing real Antigravity / Google credentials. +""" + +import base64 +import contextlib +import hashlib +import http.server +import io +import json +import os +import socketserver +import stat +import sys +import tempfile +import threading +import time +import unittest +import urllib.parse + + +HERE = os.path.dirname(os.path.abspath(__file__)) +ANTIGRAVITY_SCRIPT = os.path.abspath(os.path.join(HERE, "antigravity-oauth.py")) +SHARED_DIR = os.path.abspath(os.path.join(HERE, "..", "..", "_shared")) + +ANTIGRAVITY_CLIENT_ID = ( + "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" +) + +# Wire-level URLs the script hits. We rewrite each constant in the script +# source to point at the local mock server so urlopen() stays a 127.0.0.1 +# call. The path segments are kept verbatim so the test handlers can +# distinguish /token from /loadCodeAssist etc. +URL_REWRITES = { + "https://accounts.google.com/o/oauth2/v2/auth": "http://127.0.0.1:{port}/auth", + "https://oauth2.googleapis.com/token": "http://127.0.0.1:{port}/token", + "https://www.googleapis.com/oauth2/v1/userinfo": "http://127.0.0.1:{port}/userinfo", + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist": ( + "http://127.0.0.1:{port}/loadCodeAssist" + ), + "https://cloudcode-pa.googleapis.com/v1internal:onboardUser": ( + "http://127.0.0.1:{port}/onboardUser" + ), +} + + +# ─── mock HTTP server ────────────────────────────────────────────────────── + + +class MockGoogle: + """Stand-in for ``accounts.google.com`` + ``*.googleapis.com`` endpoints. + + Each test registers a handler per URL path. Handlers receive the parsed + request body (dict) and headers (dict); return ``(status, dict_body)``. + All requests are also appended to ``request_log`` so tests can assert + on call counts, headers, and bodies after the fact. + """ + + def __init__(self): + self.handlers = {} + self.request_log = [] + self._server = None + self._thread = None + self.port = None + + def route(self, path): + def deco(fn): + self.handlers[path] = fn + return fn + return deco + + def _parse_body(self, raw, headers): + ct = "" + for k, v in headers.items(): + if k.lower() == "content-type": + ct = v or "" + break + if not raw: + return {} + if "json" in ct.lower(): + try: + value = json.loads(raw) + return value if isinstance(value, dict) else {} + except Exception: + return {} + try: + return dict(urllib.parse.parse_qsl(raw, keep_blank_values=True)) + except Exception: + return {} + + def _dispatch(self, path, raw, headers): + body = self._parse_body(raw, headers) + # Normalise header keys so handlers can do case-insensitive lookups. + normalised = {} + for k, v in headers.items(): + normalised[k] = v + normalised[k.lower()] = v + self.request_log.append({"path": path, "body": body, "headers": normalised}) + handler = self.handlers.get(path) + if handler is None: + return 404, {"error": "not_found", "path": path} + return handler(body, normalised) + + def start(self): + outer = self + + class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *args, **kwargs): + pass # silence stderr noise + + def _handle(self): + length = int(self.headers.get("Content-Length", "0") or "0") + raw = self.rfile.read(length).decode("utf-8") if length else "" + status, payload = outer._dispatch(self.path, raw, dict(self.headers)) + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self): + self._handle() + + def do_GET(self): + # ``fetch_user_email`` uses GET on ``/userinfo`` via + # ``urllib.request.Request(USERINFO_URL, headers=...)``. + self._handle() + + class TCPServer(socketserver.TCPServer): + allow_reuse_address = True + + self._server = TCPServer(("127.0.0.1", 0), Handler) + self.port = self._server.server_address[1] + self._thread = threading.Thread( + target=self._server.serve_forever, daemon=True + ) + self._thread.start() + + def stop(self): + if self._server is not None: + self._server.shutdown() + self._server.server_close() + self._server = None + self._thread = None + + +# ─── script runner ───────────────────────────────────────────────────────── + + +def _patch_source(src, port, poll_sleep=0): + """Rewrite URL constants + zero out ONBOARD_POLL_S for fast tests.""" + for old, tmpl in URL_REWRITES.items(): + src = src.replace(old, tmpl.format(port=port)) + src = src.replace("ONBOARD_POLL_S = 2", f"ONBOARD_POLL_S = {int(poll_sleep)}") + return src + + +def run_script(ctx, port=None): + """Execute one action against the script and return its JSON stdout. + + The script's ``die()`` helper raises ``SystemExit(0)`` after emitting + an ``{"ok": false, "error": ...}`` envelope — we swallow that so a + scripted error doesn't abort the whole test process. + """ + with open(ANTIGRAVITY_SCRIPT, encoding="utf-8") as handle: + src = handle.read() + if port is not None: + src = _patch_source(src, port) + saved_stdin, saved_stdout = sys.stdin, sys.stdout + out = io.StringIO() + try: + sys.stdin = io.StringIO(json.dumps(ctx)) + sys.stdout = out + # ``__name__`` must be ``"__main__"`` so the script's + # ``if __name__ == "__main__": main()`` block dispatches the + # action — same wiring as ``python antigravity-oauth.py``. + ns = {"__name__": "__main__", "__file__": ANTIGRAVITY_SCRIPT} + try: + exec(compile(src, ANTIGRAVITY_SCRIPT, "exec"), ns) + except SystemExit: + pass + finally: + sys.stdin, sys.stdout = saved_stdin, saved_stdout + raw = out.getvalue() + return json.loads(raw) if raw.strip() else {} + + +@contextlib.contextmanager +def temp_env(**overrides): + """Snapshot + restore os.environ for the duration of the block.""" + saved = {} + for key, value in overrides.items(): + saved[key] = os.environ.get(key) + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + try: + yield + finally: + for key, value in saved.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + +def _pkce_challenge(verifier): + """SHA256(verifier) -> base64url-no-padding, matching ``google_oauth.make_pkce``.""" + digest = hashlib.sha256(verifier.encode("ascii")).digest() + return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + + +# ─── tests ───────────────────────────────────────────────────────────────── + + +class AntigravityOAuthTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.token_path = os.path.join(self.tmp.name, "antigravity.json") + self.mock = MockGoogle() + + def tearDown(self): + self.mock.stop() + self.tmp.cleanup() + + def test_login_pkce_s256_url_shape(self): + redirect_uri = "http://127.0.0.1:8085/oauth2callback" + ctx = {"action": "login", "redirect_uri": redirect_uri} + out = run_script(ctx) + + # Login envelope + self.assertEqual(out.get("flow"), "web") + self.assertIn("state", out) + self.assertIn("pending", out) + self.assertIn("verifier", out["pending"]) + self.assertIn("url", out) + + # URL shape + parsed = urllib.parse.urlparse(out["url"]) + params = urllib.parse.parse_qs(parsed.query) + self.assertEqual(parsed.scheme, "https") + self.assertEqual(parsed.netloc, "accounts.google.com") + self.assertEqual(parsed.path, "/o/oauth2/v2/auth") + + # Client id is the public Antigravity IDE client. + self.assertEqual(params.get("client_id", [""])[0], ANTIGRAVITY_CLIENT_ID) + self.assertEqual(params.get("response_type", [""])[0], "code") + self.assertEqual(params.get("redirect_uri", [""])[0], redirect_uri) + self.assertEqual(params.get("state", [""])[0], out["state"]) + + # Scopes — exactly the 5 Antigravity scopes; no openid, no + # arbitrary extras. + scopes = set((params.get("scope", [""])[0]).split()) + expected = { + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/cclog", + "https://www.googleapis.com/auth/experimentsandconfigs", + } + self.assertEqual(scopes, expected) + self.assertNotIn("openid", scopes) + + # PKCE S256 with challenge = SHA256(verifier) base64url-no-padding. + self.assertEqual(params.get("code_challenge_method", [""])[0], "S256") + verifier = out["pending"]["verifier"] + self.assertEqual( + params.get("code_challenge", [""])[0], _pkce_challenge(verifier) + ) + + # Offline access required for refresh_token; no prompt=consent. + self.assertEqual(params.get("access_type", [""])[0], "offline") + self.assertNotIn("prompt", params) + + def test_complete_persists_token_with_project(self): + self.mock.start() + + @self.mock.route("/token") + def token(body, _headers): + self.assertEqual(body.get("grant_type"), "authorization_code") + self.assertEqual(body.get("code"), "fake-auth-code") + self.assertEqual(body.get("code_verifier"), "test-verifier") + self.assertEqual(body.get("redirect_uri"), "http://127.0.0.1:8085/oauth2callback") + self.assertEqual(body.get("client_id"), ANTIGRAVITY_CLIENT_ID) + return 200, { + "access_token": "fake-access-token", + "refresh_token": "fake-refresh-token", + "expires_in": 3600, + "scope": "cloud-platform", + "token_type": "Bearer", + } + + @self.mock.route("/loadCodeAssist") + def load(body, _headers): + return 200, {"cloudaicompanionProject": "test-project-123"} + + @self.mock.route("/userinfo") + def userinfo(_body, headers): + auth = headers.get("Authorization") or headers.get("authorization") + self.assertTrue(auth and auth.startswith("Bearer ")) + return 200, {"email": "test@example.com"} + + ctx = { + "action": "complete", + "code": "fake-auth-code", + "pending": {"verifier": "test-verifier"}, + "redirect_uri": "http://127.0.0.1:8085/oauth2callback", + "token_path": self.token_path, + } + with temp_env(CATALYST_CODE_ANTIGRAVITY_PROJECT=""): + out = run_script(ctx, port=self.mock.port) + + self.assertEqual(out, {"ok": True}) + + # On-disk token contract. + self.assertTrue(os.path.exists(self.token_path), "token file was not written") + with open(self.token_path, encoding="utf-8") as handle: + token = json.load(handle) + + self.assertEqual(token["access_token"], "fake-access-token") + self.assertEqual(token["refresh_token"], "fake-refresh-token") + self.assertEqual(token["project_id"], "test-project-123") + self.assertEqual(token["email"], "test@example.com") + # expires_at ≈ now + 3600; sanity-check ±5s slack. + self.assertGreater(token["expires_at"], int(time.time()) + 3590) + + # File mode 0o600 — secrets at rest. + mode = stat.S_IMODE(os.stat(self.token_path).st_mode) + self.assertEqual(mode, 0o600) + + # loadCodeAssist was called exactly once and used the right metadata. + load_calls = [r for r in self.mock.request_log if r["path"] == "/loadCodeAssist"] + self.assertEqual(len(load_calls), 1) + self.assertEqual(load_calls[0]["body"]["metadata"]["ideType"], 9) + self.assertEqual(load_calls[0]["body"]["metadata"]["pluginType"], 2) + + def test_token_refresh_preserves_project_id_and_email(self): + # Pre-write a near-expiry token so the script refreshes it. + now = int(time.time()) + seed = { + "access_token": "old-access", + "refresh_token": "old-refresh", + "expires_in": 60, + "expires_at": now + 60, + "scope": "", + "token_type": "Bearer", + "project_id": "preserved-project", + "email": "preserved@example.com", + } + with open(self.token_path, "w", encoding="utf-8") as handle: + json.dump(seed, handle) + os.chmod(self.token_path, 0o600) + + self.mock.start() + + @self.mock.route("/token") + def token(body, _headers): + self.assertEqual(body.get("grant_type"), "refresh_token") + self.assertEqual(body.get("refresh_token"), "old-refresh") + # Deliberately omit refresh_token in the response to verify the + # script preserves the old one across rotations. + return 200, { + "access_token": "new-access", + "expires_in": 3600, + "token_type": "Bearer", + } + + out = run_script( + {"action": "token", "token_path": self.token_path}, + port=self.mock.port, + ) + + self.assertEqual(out["access_token"], "new-access") + self.assertEqual(out["expires_at"], int(time.time()) + 3600) + self.assertEqual( + out["headers"], + [["x-code-assist-project", "preserved-project"]], + ) + + # CRITICAL: must not regress to x-goog-user-project — that header + # forces the Cloud Code Private API enablement check and 403s on + # free-tier / managed projects. + header_names = {h[0] for h in out["headers"]} + self.assertNotIn("x-goog-user-project", header_names) + + # Token file on disk has the new access token and preserved metadata. + with open(self.token_path, encoding="utf-8") as handle: + rotated = json.load(handle) + self.assertEqual(rotated["access_token"], "new-access") + self.assertEqual(rotated["refresh_token"], "old-refresh") + self.assertEqual(rotated["project_id"], "preserved-project") + self.assertEqual(rotated["email"], "preserved@example.com") + self.assertEqual( + stat.S_IMODE(os.stat(self.token_path).st_mode), 0o600 + ) + + def test_token_returns_null_when_no_file(self): + out = run_script({"action": "token", "token_path": self.token_path}) + self.assertEqual(out, {"access_token": None}) + + def test_onboarding_fallback(self): + """loadCodeAssist returns tiers-only (no project); onboardUser must + be polled and the resulting project persisted.""" + self.mock.start() + onboard_calls = [] + + @self.mock.route("/token") + def token(_body, _headers): + return 200, { + "access_token": "fake-access", + "refresh_token": "fake-refresh", + "expires_in": 3600, + "token_type": "Bearer", + } + + @self.mock.route("/loadCodeAssist") + def load(_body, _headers): + return 200, { + "allowedTiers": [ + {"id": "free-tier", "isDefault": True}, + {"id": "legacy-tier", "isDefault": False}, + ], + # No cloudaicompanionProject — forces the onboard fallback. + } + + @self.mock.route("/onboardUser") + def onboard(body, _headers): + onboard_calls.append(body) + if len(onboard_calls) < 3: + return 200, {"done": False} + return 200, { + "done": True, + "response": {"cloudaicompanionProject": "onboarded-proj"}, + } + + ctx = { + "action": "complete", + "code": "fake-code", + "pending": {"verifier": "fake-verifier"}, + "redirect_uri": "http://127.0.0.1:8085/oauth2callback", + "token_path": self.token_path, + } + with temp_env(CATALYST_CODE_ANTIGRAVITY_PROJECT=""): + out = run_script(ctx, port=self.mock.port) + + self.assertEqual(out, {"ok": True}) + self.assertGreaterEqual( + len(onboard_calls), 3, + "onboardUser should have been polled until done=true", + ) + # The script must echo the default tier id in the request body. + sent_tiers = [c.get("tierId") for c in onboard_calls] + self.assertTrue(all(t == "free-tier" for t in sent_tiers)) + + with open(self.token_path, encoding="utf-8") as handle: + token = json.load(handle) + self.assertEqual(token["project_id"], "onboarded-proj") + + def test_clear_removes_token_file(self): + # Seed both the token file and the .lock sidecar the script may have + # left behind from a previous refresh. + with open(self.token_path, "w", encoding="utf-8") as handle: + json.dump({"access_token": "x"}, handle) + with open(self.token_path + ".lock", "w", encoding="utf-8") as handle: + handle.write("") + + out = run_script({"action": "clear", "token_path": self.token_path}) + self.assertEqual(out, {"ok": True}) + + self.assertFalse(os.path.exists(self.token_path)) + self.assertFalse(os.path.exists(self.token_path + ".lock")) + + def test_env_override_project_wins(self): + """CATALYST_CODE_ANTIGRAVITY_PROJECT wins over loadCodeAssist. + + This is the escape hatch for users whose auto-provisioned project + is Google-managed (no Cloud Console access) — the script must + persist the override even when loadCodeAssist returns a project. + """ + self.mock.start() + + @self.mock.route("/token") + def token(_body, _headers): + return 200, { + "access_token": "fake-access", + "refresh_token": "fake-refresh", + "expires_in": 3600, + "token_type": "Bearer", + } + + @self.mock.route("/loadCodeAssist") + def load(_body, _headers): + return 200, {"cloudaicompanionProject": "real-load-project"} + + @self.mock.route("/userinfo") + def userinfo(_body, _headers): + return 200, {"email": ""} + + ctx = { + "action": "complete", + "code": "fake-code", + "pending": {"verifier": "fake-verifier"}, + "redirect_uri": "http://127.0.0.1:8085/oauth2callback", + "token_path": self.token_path, + } + with temp_env(CATALYST_CODE_ANTIGRAVITY_PROJECT="override-project"): + out = run_script(ctx, port=self.mock.port) + + self.assertEqual(out, {"ok": True}) + + with open(self.token_path, encoding="utf-8") as handle: + token = json.load(handle) + self.assertEqual(token["project_id"], "override-project") + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file From 9eeacf50246235905f4e8965faa690e6f772c353 Mon Sep 17 00:00:00 2001 From: catcode Date: Fri, 7 Aug 2026 17:16:52 -0400 Subject: [PATCH 10/38] test(providers): add e2e OAuth tests for gemini-cli (mock HTTP) Stdlib-only end-to-end tests that drive the Gemini CLI OAuth provider script against a local mock HTTP server. Covers PKCE S256 URL shape (only the 3 cloud-platform scopes, no openid / cclog / experimentsandconfigs, no prompt), the sibling-Antigravity-token fallback for free-tier loadCodeAssist UNSUPPORTED_CLIENT (both via $HOME/.config and CATALYST_CODE_OAUTH_DIR override), refresh-grant preserves project_id + email, no-token returns null, clear removes the token + .lock sidecar, and a regression test that asserts 'openid' is never present in the scope list. --- core/providers/gemini-cli/__init__.py | 0 core/providers/gemini-cli/oauth/__init__.py | 0 .../gemini-cli/oauth/test_gemini_cli_oauth.py | 506 ++++++++++++++++++ 3 files changed, 506 insertions(+) create mode 100644 core/providers/gemini-cli/__init__.py create mode 100644 core/providers/gemini-cli/oauth/__init__.py create mode 100644 core/providers/gemini-cli/oauth/test_gemini_cli_oauth.py diff --git a/core/providers/gemini-cli/__init__.py b/core/providers/gemini-cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/providers/gemini-cli/oauth/__init__.py b/core/providers/gemini-cli/oauth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/providers/gemini-cli/oauth/test_gemini_cli_oauth.py b/core/providers/gemini-cli/oauth/test_gemini_cli_oauth.py new file mode 100644 index 0000000..5b7a963 --- /dev/null +++ b/core/providers/gemini-cli/oauth/test_gemini_cli_oauth.py @@ -0,0 +1,506 @@ +#!/usr/bin/env python3 +"""End-to-end tests for the Gemini CLI OAuth provider script. + +Stdlib-only. The provider script is exercised in-process by rewriting +its URL constants to point at a local mock HTTP server and ``exec``'ing +the patched source in a namespace with ``__name__ = "__main__"`` and +``__file__`` pointing at the real script (so the relative +``../../_shared/google_oauth.py`` import still resolves). + +The harness JSON contract (per the script's stdin/stdout) is: + + stdin :: {"action": "login"|"complete"|"token"|"clear", ...} + stdout :: action-specific JSON object (see the script docstring) + +These tests cover the contract end-to-end against a mock Google auth +server so we can verify URL shape, PKCE, refresh-token rotation, +the sibling-Antigravity-token fallback for free-tier users, and the +on-disk file mode without needing real Gemini CLI / Google credentials. +""" + +import base64 +import contextlib +import hashlib +import http.server +import io +import json +import os +import socketserver +import stat +import sys +import tempfile +import threading +import time +import unittest +import urllib.parse + + +HERE = os.path.dirname(os.path.abspath(__file__)) +GEMINI_CLI_SCRIPT = os.path.abspath(os.path.join(HERE, "gemini-cli-oauth.py")) +SHARED_DIR = os.path.abspath(os.path.join(HERE, "..", "..", "_shared")) + +GEMINI_CLI_CLIENT_ID = ( + "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com" +) + +URL_REWRITES = { + "https://accounts.google.com/o/oauth2/v2/auth": "http://127.0.0.1:{port}/auth", + "https://oauth2.googleapis.com/token": "http://127.0.0.1:{port}/token", + "https://www.googleapis.com/oauth2/v1/userinfo": "http://127.0.0.1:{port}/userinfo", + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist": ( + "http://127.0.0.1:{port}/loadCodeAssist" + ), + "https://cloudcode-pa.googleapis.com/v1internal:onboardUser": ( + "http://127.0.0.1:{port}/onboardUser" + ), +} + + +# ─── mock HTTP server ────────────────────────────────────────────────────── + + +class MockGoogle: + """Stand-in for ``accounts.google.com`` + ``*.googleapis.com`` endpoints.""" + + def __init__(self): + self.handlers = {} + self.request_log = [] + self._server = None + self._thread = None + self.port = None + + def route(self, path): + def deco(fn): + self.handlers[path] = fn + return fn + return deco + + def _parse_body(self, raw, headers): + ct = "" + for k, v in headers.items(): + if k.lower() == "content-type": + ct = v or "" + break + if not raw: + return {} + if "json" in ct.lower(): + try: + value = json.loads(raw) + return value if isinstance(value, dict) else {} + except Exception: + return {} + try: + return dict(urllib.parse.parse_qsl(raw, keep_blank_values=True)) + except Exception: + return {} + + def _dispatch(self, path, raw, headers): + body = self._parse_body(raw, headers) + normalised = {} + for k, v in headers.items(): + normalised[k] = v + normalised[k.lower()] = v + self.request_log.append({"path": path, "body": body, "headers": normalised}) + handler = self.handlers.get(path) + if handler is None: + return 404, {"error": "not_found", "path": path} + return handler(body, normalised) + + def start(self): + outer = self + + class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *args, **kwargs): + pass + + def _handle(self): + length = int(self.headers.get("Content-Length", "0") or "0") + raw = self.rfile.read(length).decode("utf-8") if length else "" + status, payload = outer._dispatch(self.path, raw, dict(self.headers)) + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self): + self._handle() + + def do_GET(self): + # ``fetch_user_email`` GETs ``/userinfo``. + self._handle() + + class TCPServer(socketserver.TCPServer): + allow_reuse_address = True + + self._server = TCPServer(("127.0.0.1", 0), Handler) + self.port = self._server.server_address[1] + self._thread = threading.Thread( + target=self._server.serve_forever, daemon=True + ) + self._thread.start() + + def stop(self): + if self._server is not None: + self._server.shutdown() + self._server.server_close() + self._server = None + self._thread = None + + +# ─── script runner ───────────────────────────────────────────────────────── + + +def _patch_source(src, port, poll_sleep=0): + for old, tmpl in URL_REWRITES.items(): + src = src.replace(old, tmpl.format(port=port)) + src = src.replace("ONBOARD_POLL_S = 2", f"ONBOARD_POLL_S = {int(poll_sleep)}") + return src + + +def run_script(ctx, port=None): + with open(GEMINI_CLI_SCRIPT, encoding="utf-8") as handle: + src = handle.read() + if port is not None: + src = _patch_source(src, port) + saved_stdin, saved_stdout = sys.stdin, sys.stdout + out = io.StringIO() + try: + sys.stdin = io.StringIO(json.dumps(ctx)) + sys.stdout = out + ns = {"__name__": "__main__", "__file__": GEMINI_CLI_SCRIPT} + try: + exec(compile(src, GEMINI_CLI_SCRIPT, "exec"), ns) + except SystemExit: + pass + finally: + sys.stdin, sys.stdout = saved_stdin, saved_stdout + raw = out.getvalue() + return json.loads(raw) if raw.strip() else {} + + +@contextlib.contextmanager +def temp_env(**overrides): + saved = {} + for key, value in overrides.items(): + saved[key] = os.environ.get(key) + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + try: + yield + finally: + for key, value in saved.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + +def _pkce_challenge(verifier): + digest = hashlib.sha256(verifier.encode("ascii")).digest() + return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + + +# ─── tests ───────────────────────────────────────────────────────────────── + + +class GeminiCliOAuthTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.token_path = os.path.join(self.tmp.name, "gemini-cli.json") + self.mock = MockGoogle() + + def tearDown(self): + self.mock.stop() + self.tmp.cleanup() + + # ── login ──────────────────────────────────────────────────────────── + + def test_login_pkce_s256_url_shape(self): + redirect_uri = "http://127.0.0.1:8085/oauth2callback" + ctx = {"action": "login", "redirect_uri": redirect_uri} + out = run_script(ctx) + + self.assertEqual(out.get("flow"), "web") + self.assertIn("state", out) + self.assertIn("pending", out) + self.assertIn("verifier", out["pending"]) + self.assertIn("url", out) + + parsed = urllib.parse.urlparse(out["url"]) + params = urllib.parse.parse_qs(parsed.query) + self.assertEqual(parsed.scheme, "https") + self.assertEqual(parsed.netloc, "accounts.google.com") + self.assertEqual(parsed.path, "/o/oauth2/v2/auth") + + self.assertEqual(params.get("client_id", [""])[0], GEMINI_CLI_CLIENT_ID) + self.assertEqual(params.get("response_type", [""])[0], "code") + self.assertEqual(params.get("redirect_uri", [""])[0], redirect_uri) + self.assertEqual(params.get("state", [""])[0], out["state"]) + + # gemini-cli scopes are exactly the 3 cloud-platform ones — no + # openid, no cclog, no experimentsandconfigs. + scopes = set((params.get("scope", [""])[0]).split()) + expected = { + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + } + self.assertEqual(scopes, expected) + + # PKCE S256. + self.assertEqual(params.get("code_challenge_method", [""])[0], "S256") + verifier = out["pending"]["verifier"] + self.assertEqual( + params.get("code_challenge", [""])[0], _pkce_challenge(verifier) + ) + + self.assertEqual(params.get("access_type", [""])[0], "offline") + # Mirror the official ``gemini`` CLI: no ``prompt=consent``, no + # ``include_granted_scopes=true`` — both can confuse refresh-token + # issuance for this public-but-unverified OAuth client. + self.assertNotIn("prompt", params) + self.assertNotIn("include_granted_scopes", params) + + def test_no_openid_in_scope(self): + """Regression: scope MUST NOT contain ``openid``. + + Including ``openid`` triggers Google's "unverified app" rejection + for this public-but-unverified OAuth client. The gemini-cli project + deliberately omits it; ``userinfo.email`` + ``userinfo.profile`` + are sufficient for the loadCodeAssist user-info lookup. + """ + out = run_script( + {"action": "login", "redirect_uri": "http://127.0.0.1:8085/oauth2callback"} + ) + parsed = urllib.parse.urlparse(out["url"]) + params = urllib.parse.parse_qs(parsed.query) + scopes = set((params.get("scope", [""])[0]).split()) + self.assertNotIn("openid", scopes) + self.assertNotIn( + "https://www.googleapis.com/auth/openid", scopes + ) + # And the gemini-cli-specific scopes must not appear (those belong + # to Antigravity, not gemini-cli). + self.assertNotIn( + "https://www.googleapis.com/auth/cclog", scopes + ) + self.assertNotIn( + "https://www.googleapis.com/auth/experimentsandconfigs", scopes + ) + + # ── complete ───────────────────────────────────────────────────────── + + def test_complete_persists_token_with_sibling_project_fallback(self): + """Free-tier loadCodeAssist returns UNSUPPORTED_CLIENT (no project). + + The script must fall back to reading the sibling antigravity.json + file (same user, different OAuth client, often already has a + working managed project) and persist its ``project_id``. + """ + # Place the sibling antigravity token in a tempdir under HOME so + # the script's default ``~/.config/catalyst-code/oauth/antigravity.json`` + # lookup (via ``os.path.expanduser``) resolves without polluting + # the real home directory. + oauth_dir = os.path.join(self.tmp.name, ".config", "catalyst-code", "oauth") + os.makedirs(oauth_dir, exist_ok=True) + sibling_path = os.path.join(oauth_dir, "antigravity.json") + with open(sibling_path, "w", encoding="utf-8") as handle: + json.dump( + { + "access_token": "sibling-access", + "refresh_token": "sibling-refresh", + "project_id": "sibling-project", + "email": "sibling@example.com", + }, + handle, + ) + os.chmod(sibling_path, 0o600) + + # Override HOME so the ``~/.config/...`` expansion lands in + # our tempdir. + home = self.tmp.name + with temp_env(HOME=home, USERPROFILE=home, CATALYST_CODE_OAUTH_DIR=""): + self.mock.start() + + @self.mock.route("/token") + def token(body, _headers): + return 200, { + "access_token": "gemini-access", + "refresh_token": "gemini-refresh", + "expires_in": 3600, + "token_type": "Bearer", + } + + @self.mock.route("/loadCodeAssist") + def load(_body, _headers): + # Free-tier: no allowedTiers, no cloudaicompanionProject — + # the script must treat this as UNSUPPORTED_CLIENT and + # proceed to onboard (also returns nothing) and then to + # the sibling lookup. + return 200, {"error": {"code": 400, "message": "UNSUPPORTED_CLIENT"}} + + @self.mock.route("/onboardUser") + def onboard(_body, _headers): + return 200, {"done": False} + + @self.mock.route("/userinfo") + def userinfo(_body, _headers): + return 200, {"email": ""} + + token_path = os.path.join(oauth_dir, "gemini-cli.json") + ctx = { + "action": "complete", + "code": "fake-code", + "pending": {"verifier": "fake-verifier"}, + "redirect_uri": "http://127.0.0.1:8085/oauth2callback", + "token_path": token_path, + } + out = run_script(ctx, port=self.mock.port) + + self.assertEqual(out, {"ok": True}) + + with open(token_path, encoding="utf-8") as handle: + token = json.load(handle) + self.assertEqual(token["access_token"], "gemini-access") + self.assertEqual(token["project_id"], "sibling-project") + self.assertEqual( + stat.S_IMODE(os.stat(token_path).st_mode), 0o600 + ) + + def test_complete_falls_back_to_sibling_via_env_dir(self): + """``CATALYST_CODE_OAUTH_DIR`` overrides the sibling lookup dir. + + Same fallback as above, but via the env var instead of relying on + ``$HOME`` — useful for sandboxed installs where ``~/.config/`` is + read-only or points somewhere weird. + """ + oauth_dir = os.path.join(self.tmp.name, "custom", "oauth") + os.makedirs(oauth_dir, exist_ok=True) + sibling_path = os.path.join(oauth_dir, "antigravity.json") + with open(sibling_path, "w", encoding="utf-8") as handle: + json.dump( + {"access_token": "x", "project_id": "env-dir-proj"}, + handle, + ) + + self.mock.start() + + @self.mock.route("/token") + def token(_body, _headers): + return 200, { + "access_token": "env-access", + "refresh_token": "env-refresh", + "expires_in": 3600, + "token_type": "Bearer", + } + + @self.mock.route("/loadCodeAssist") + def load(_body, _headers): + return 200, {} + + @self.mock.route("/onboardUser") + def onboard(_body, _headers): + return 200, {"done": False} + + @self.mock.route("/userinfo") + def userinfo(_body, _headers): + return 200, {"email": ""} + + token_path = os.path.join(oauth_dir, "gemini-cli.json") + ctx = { + "action": "complete", + "code": "fake-code", + "pending": {"verifier": "fake-verifier"}, + "redirect_uri": "http://127.0.0.1:8085/oauth2callback", + "token_path": token_path, + } + with temp_env(CATALYST_CODE_OAUTH_DIR=oauth_dir): + out = run_script(ctx, port=self.mock.port) + + self.assertEqual(out, {"ok": True}) + + with open(token_path, encoding="utf-8") as handle: + token = json.load(handle) + self.assertEqual(token["project_id"], "env-dir-proj") + + # ── token ──────────────────────────────────────────────────────────── + + def test_token_refresh_preserves_project_id_and_email(self): + now = int(time.time()) + seed = { + "access_token": "old-access", + "refresh_token": "old-refresh", + "expires_in": 60, + "expires_at": now + 60, + "scope": "", + "token_type": "Bearer", + "project_id": "preserved-project", + "email": "preserved@example.com", + } + with open(self.token_path, "w", encoding="utf-8") as handle: + json.dump(seed, handle) + os.chmod(self.token_path, 0o600) + + self.mock.start() + + @self.mock.route("/token") + def token(body, _headers): + self.assertEqual(body.get("grant_type"), "refresh_token") + self.assertEqual(body.get("refresh_token"), "old-refresh") + return 200, { + "access_token": "new-access", + "expires_in": 3600, + "token_type": "Bearer", + } + + out = run_script( + {"action": "token", "token_path": self.token_path}, + port=self.mock.port, + ) + + self.assertEqual(out["access_token"], "new-access") + self.assertEqual(out["expires_at"], int(time.time()) + 3600) + self.assertEqual( + out["headers"], + [["x-code-assist-project", "preserved-project"]], + ) + + # Must not regress to x-goog-user-project. + header_names = {h[0] for h in out["headers"]} + self.assertNotIn("x-goog-user-project", header_names) + + with open(self.token_path, encoding="utf-8") as handle: + rotated = json.load(handle) + self.assertEqual(rotated["access_token"], "new-access") + self.assertEqual(rotated["refresh_token"], "old-refresh") + self.assertEqual(rotated["project_id"], "preserved-project") + self.assertEqual(rotated["email"], "preserved@example.com") + self.assertEqual( + stat.S_IMODE(os.stat(self.token_path).st_mode), 0o600 + ) + + def test_token_returns_null_when_no_file(self): + out = run_script({"action": "token", "token_path": self.token_path}) + self.assertEqual(out, {"access_token": None}) + + # ── clear ──────────────────────────────────────────────────────────── + + def test_clear_removes_token_file(self): + with open(self.token_path, "w", encoding="utf-8") as handle: + json.dump({"access_token": "x"}, handle) + with open(self.token_path + ".lock", "w", encoding="utf-8") as handle: + handle.write("") + + out = run_script({"action": "clear", "token_path": self.token_path}) + self.assertEqual(out, {"ok": True}) + + self.assertFalse(os.path.exists(self.token_path)) + self.assertFalse(os.path.exists(self.token_path + ".lock")) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file From b14516ad9b4c4ac0b9f4c8cef0f7aedead62c24f Mon Sep 17 00:00:00 2001 From: phantomic12 Date: Fri, 7 Aug 2026 18:28:43 -0400 Subject: [PATCH 11/38] style: pick up upstream format + protocol drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After rebasing onto upstream master (bbdcd78), CI surfaced three pre-existing baseline-format drifts that were already failing on the base commit: - cargo fmt --all (16 hunks across message.rs / provider.rs / openai_compatible.rs — newer stable rustfmt) - cd tui && gofmt -l . (1 hunk: blocks.go) - node scripts/check-protocol-schema.mjs (missing advisor_note and advisor_status events added by upstream's checkpoint-recovery work — also synced to sdk/src/core-events.ts and the events-v2.jsonl fixture; bumped the "must cover every known event" assertion 99 → 101 in core/src/protocol.rs) No behavior changes. Local: rustfmt clean, gofmt clean, schema check "protocol consistency ok: 67 commands, 101 events, 5 fixture files", targeted cargo test + Python e2e tests green. --- core/src/message.rs | 5 +-- core/src/protocol.rs | 2 +- core/src/provider.rs | 43 ++++++++++++------------- core/src/providers/openai_compatible.rs | 5 +-- protocol.schema.json | 2 +- protocol/fixtures/events-v2.jsonl | 2 ++ sdk/src/core-events.ts | 2 ++ tui/blocks.go | 1 - 8 files changed, 29 insertions(+), 33 deletions(-) diff --git a/core/src/message.rs b/core/src/message.rs index c747b0d..9f1f8a2 100644 --- a/core/src/message.rs +++ b/core/src/message.rs @@ -345,9 +345,7 @@ impl Message { /// multi-turn replay can re-embed cleanly. pub fn normalize_embedded_thinking(&mut self) { let Message::Assistant { - content, - thinking, - .. + content, thinking, .. } = self else { return; @@ -903,7 +901,6 @@ mod tests { assert!(kept.content_text().unwrap().contains("")); } - #[test] fn anthropic_request_builds_from_messages() { let msgs = vec![ diff --git a/core/src/protocol.rs b/core/src/protocol.rs index 7964b7a..bd5f60d 100644 --- a/core/src/protocol.rs +++ b/core/src/protocol.rs @@ -96,6 +96,6 @@ mod turn_terminal_tests { ); assert_eq!(event["protocol_version"], PROTOCOL_VERSION); } - assert_eq!(kinds.len(), 99, "fixture must cover every known event"); + assert_eq!(kinds.len(), 101, "fixture must cover every known event"); } } diff --git a/core/src/provider.rs b/core/src/provider.rs index ca91a4a..9f25382 100644 --- a/core/src/provider.rs +++ b/core/src/provider.rs @@ -1514,8 +1514,7 @@ async fn stream_turn_openai( if !quiet { emitted = true; emit( - &Event::new("delta") - .with("text", json!(delta)), + &Event::new("delta").with("text", json!(delta)), ); } } @@ -1734,7 +1733,7 @@ async fn stream_turn_openai( add_structured_tool_call_recovery_instruction(&mut body); content.clear(); wire_text.clear(); - think_demux = ThinkTagDemux::default(); + think_demux = ThinkTagDemux::default(); reasoning.clear(); tool_calls.clear(); finish_reason.clear(); @@ -1959,7 +1958,8 @@ async fn stream_turn_gemini( terminal_event |= is_terminal_stream_event(&event); match event { NormalizedStreamEvent::TextDelta(text) => { - if let Some(delta) = append_stream_fragment(&mut content, &text, false) { + if let Some(delta) = append_stream_fragment(&mut content, &text, false) + { if content.len() == delta.len() { timer.mark_first_token(); } @@ -1970,7 +1970,9 @@ async fn stream_turn_gemini( } } NormalizedStreamEvent::ReasoningDelta(text) => { - if let Some(delta) = append_stream_fragment(&mut reasoning, &text, false) { + if let Some(delta) = + append_stream_fragment(&mut reasoning, &text, false) + { if reasoning.len() == delta.len() { timer.mark_first_token(); } @@ -3461,7 +3463,9 @@ async fn stream_turn_anthropic( terminal_event |= is_terminal_stream_event(&event); match event { NormalizedStreamEvent::TextDelta(text) => { - if let Some(delta) = append_stream_fragment(&mut content, &text, minimax) { + if let Some(delta) = + append_stream_fragment(&mut content, &text, minimax) + { if content.len() == delta.len() { timer.mark_first_token(); } @@ -3472,7 +3476,9 @@ async fn stream_turn_anthropic( } } NormalizedStreamEvent::ReasoningDelta(text) => { - if let Some(delta) = append_stream_fragment(&mut reasoning, &text, minimax) { + if let Some(delta) = + append_stream_fragment(&mut reasoning, &text, minimax) + { if reasoning.len() == delta.len() { timer.mark_first_token(); } @@ -4560,34 +4566,24 @@ mod tests { assert!(!is_minimax("https://api.openai.com/v1")); } - #[test] fn think_tag_demux_splits_complete_and_partial_tags() { let mut d = ThinkTagDemux::default(); // Opening tag split across chunks. assert!(d.push("\nstep one"); - assert_eq!( - p1, - vec![ThinkPiece::Thinking("\nstep one".into())] - ); + assert_eq!(p1, vec![ThinkPiece::Thinking("\nstep one".into())]); let p2 = d.push(" continues\n\n## Answer\nHi"); - assert_eq!( - p3, - vec![ThinkPiece::Text("\n\n## Answer\nHi".into())] - ); + assert_eq!(p3, vec![ThinkPiece::Text("\n\n## Answer\nHi".into())]); assert!(d.finish().is_empty()); } #[test] fn think_tag_demux_plain_text_passthrough() { let mut d = ThinkTagDemux::default(); - assert_eq!( - d.push("hello "), - vec![ThinkPiece::Text("hello ".into())] - ); + assert_eq!(d.push("hello "), vec![ThinkPiece::Text("hello ".into())]); assert_eq!(d.push("world"), vec![ThinkPiece::Text("world".into())]); } @@ -4649,7 +4645,10 @@ mod tests { } sanitize_assistant_content(&mut content); assert_eq!(thinking, "\nThe user is asking what.\n"); - assert!(!thinking.contains("The userThe user"), "duplicated thinking: {thinking}"); + assert!( + !thinking.contains("The userThe user"), + "duplicated thinking: {thinking}" + ); assert_eq!(content, "Hello"); assert!(!content.contains("")); } @@ -4664,7 +4663,7 @@ mod tests { assert_eq!(s2, "Hi there"); } - #[test] + #[test] fn append_stream_fragment_handles_cumulative_and_delta() { // Pure incremental: repeated prefix tokens must NOT be dropped. let mut inc = String::new(); diff --git a/core/src/providers/openai_compatible.rs b/core/src/providers/openai_compatible.rs index 6ebd45e..7532c23 100644 --- a/core/src/providers/openai_compatible.rs +++ b/core/src/providers/openai_compatible.rs @@ -569,14 +569,11 @@ mod tests { assert!(content.starts_with("\nhidden chain\n\n")); assert!(content.ends_with("visible answer")); assert!( - built.body["messages"][0] - .get("reasoning_content") - .is_none(), + built.body["messages"][0].get("reasoning_content").is_none(), "proxy path must not send bare reasoning_content" ); } - #[test] fn zhipu_omits_stream_options_and_enables_thinking() { let levels = ["high".to_string(), "max".to_string()]; diff --git a/protocol.schema.json b/protocol.schema.json index a50bb91..8087f78 100644 --- a/protocol.schema.json +++ b/protocol.schema.json @@ -53,7 +53,7 @@ "properties": { "type": { "enum": [ - "aborted", "agents", "approval_changed", "approval_expired", + "aborted", "advisor_note", "advisor_status", "agents", "approval_changed", "approval_expired", "approval_request", "ask_request", "audit", "authed", "bash_execution", "checkpoint_created", "checkpoint_restored", "checkpoints", "compacted", "compacting", "config_changed", diff --git a/protocol/fixtures/events-v2.jsonl b/protocol/fixtures/events-v2.jsonl index 6b6b95f..e1c1c16 100644 --- a/protocol/fixtures/events-v2.jsonl +++ b/protocol/fixtures/events-v2.jsonl @@ -1,4 +1,6 @@ {"type":"aborted","protocol_version":2} +{"type":"advisor_note","protocol_version":2,"scope":"turn","advisor":"drift","model":"drift-1","severity":"info","message":"stub fixture"} +{"type":"advisor_status","protocol_version":2,"scope":"turn","advisor":"drift","state":"reviewing","model":"drift-1"} {"type":"agents","protocol_version":2} {"type":"approval_changed","protocol_version":2} {"type":"approval_expired","protocol_version":2} diff --git a/sdk/src/core-events.ts b/sdk/src/core-events.ts index fc21466..8ce0902 100644 --- a/sdk/src/core-events.ts +++ b/sdk/src/core-events.ts @@ -9,6 +9,8 @@ /** Every known core event `type` string (alphabetical). */ export const CORE_EVENT_TYPES = [ "aborted", + "advisor_note", + "advisor_status", "agents", "approval_changed", "approval_expired", diff --git a/tui/blocks.go b/tui/blocks.go index ce97958..12344bf 100644 --- a/tui/blocks.go +++ b/tui/blocks.go @@ -1683,7 +1683,6 @@ func (s *session) rebuildBlocksFromHistory(msgs []map[string]json.RawMessage) { s.cur = nil } - // peelThinkTags extracts a leading block from MiniMax-style // assistant content. ok is false when no complete tag pair is present. func peelThinkTags(content string) (thought, visible string, ok bool) { From 8bf9d672dc85b1e7ddcaa15f8dea0b70977adfae Mon Sep 17 00:00:00 2001 From: phantomic12 Date: Fri, 7 Aug 2026 18:37:20 -0400 Subject: [PATCH 12/38] fix: sync Go fixture + web reducer for new advisor events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Downstream of the previous format-drift commit, two more checks need the new event count / new event types: - tui/protocol_fixture_test.go: bump 99 → 101 (both the duplicate check and the count assertion). - web/src/lib/reducer.ts: add `case "advisor_note": case "advisor_status":` → no-op state so the exhaustive-switch invariant stays green. Advisor feedback does not affect the web UI's streaming/toast state directly; it surfaces via the SDK later. Verified locally: cargo fmt --check clean cd tui && gofmt -l . clean go test ./... ok protocol-schema check ok (67 commands, 101 events, 5 fixtures) --- tui/protocol_fixture_test.go | 4 ++-- web/src/lib/reducer.ts | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tui/protocol_fixture_test.go b/tui/protocol_fixture_test.go index 0d3d324..0d37585 100644 --- a/tui/protocol_fixture_test.go +++ b/tui/protocol_fixture_test.go @@ -63,7 +63,7 @@ func TestRustEventFixturesRemainGoCompatible(t *testing.T) { if err := scanner.Err(); err != nil { t.Fatal(err) } - if len(seen) != 99 { - t.Fatalf("got %d event fixtures, want 99", len(seen)) + if len(seen) != 101 { + t.Fatalf("got %d event fixtures, want 101", len(seen)) } } diff --git a/web/src/lib/reducer.ts b/web/src/lib/reducer.ts index a8c9874..92b71d0 100644 --- a/web/src/lib/reducer.ts +++ b/web/src/lib/reducer.ts @@ -1195,6 +1195,12 @@ export function reduce(state: AgentState, ev: AgentEvent): AgentState { } case "aborted": return finishTurn(state); + case "advisor_note": + case "advisor_status": + // Advisors emit review feedback + state transitions for the watchdog. + // The web UI surfaces advisor status in the toast log but does not + // interrupt the turn — keep the current streaming state. + return state; case "error": { // Do NOT always clear streaming — core often emits non-fatal errors mid-turn. // Pre-turn failures (bad skill/model): drop the optimistic user bubble + working flag. From 352a7f534bcebf771c7b542a97e4b0dbc85f76e3 Mon Sep 17 00:00:00 2001 From: phantomic12 Date: Fri, 7 Aug 2026 18:45:37 -0400 Subject: [PATCH 13/38] ci: build SDK before web typecheck/test The web app imports types from @catalyst-code/coding-agent which resolves to ./dist/index.d.ts per sdk/package.json. Without an SDK build step before web typecheck/test, TypeScript sees a stale or empty dist/ and rejects new CORE_EVENT_TYPES additions like advisor_note + advisor_status added in the previous commit. Adding `bun run build` in the sdk/ working directory before the web typecheck step. Trivial cost (~3s on CI) and unblocks any future SDK type additions from triggering a confusing typecheck failure. --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5e3c93..6d8cb59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,6 +148,13 @@ jobs: - name: SDK protocol tests working-directory: sdk run: bun test + - name: SDK build + # The web app's `@catalyst-code/coding-agent` import resolves to + # `./dist/index.d.ts` per sdk/package.json. Web typecheck/test must + # see the latest SDK types whenever a new event is added to + # CORE_EVENT_TYPES, so build the SDK before web typecheck. + working-directory: sdk + run: bun run build - name: typecheck working-directory: web run: bun run typecheck From 73b756d753a7cfdd437f8fd197e16a33aa3dc15f Mon Sep 17 00:00:00 2001 From: phantomic12 Date: Fri, 7 Aug 2026 18:53:02 -0400 Subject: [PATCH 14/38] ci: move web install after SDK build Bun's file: link snapshot reads the SDK package.json at install time and pins its types path (./dist/index.d.ts). The web typecheck step was reading the wrong (stale) types because web install ran before SDK build. Reorder so the SDK builds before web installs. EOF --- .github/workflows/ci.yml | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d8cb59..203022c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,25 +136,26 @@ jobs: key: ${{ runner.os }}-${{ runner.arch }}-next-${{ hashFiles('web/bun.lock') }}-${{ hashFiles('web/src/**/*.ts', 'web/src/**/*.tsx', 'web/public/**', 'web/next.config.mjs', 'web/tsconfig.json') }} restore-keys: | ${{ runner.os }}-${{ runner.arch }}-next-${{ hashFiles('web/bun.lock') }}- - - name: install - working-directory: web - run: bun install --frozen-lockfile - name: SDK install working-directory: sdk run: bun install --frozen-lockfile + - name: SDK build + # The web app's `@catalyst-code/coding-agent` import resolves to + # `./dist/index.d.ts` per sdk/package.json. Web install must happen + # after this so the `file:../sdk` link resolves to a freshly built + # SDK; web typecheck/test must see the latest SDK types whenever a + # new event is added to CORE_EVENT_TYPES. + working-directory: sdk + run: bun run build - name: SDK typecheck working-directory: sdk run: bun run typecheck - name: SDK protocol tests working-directory: sdk run: bun test - - name: SDK build - # The web app's `@catalyst-code/coding-agent` import resolves to - # `./dist/index.d.ts` per sdk/package.json. Web typecheck/test must - # see the latest SDK types whenever a new event is added to - # CORE_EVENT_TYPES, so build the SDK before web typecheck. - working-directory: sdk - run: bun run build + - name: install + working-directory: web + run: bun install --frozen-lockfile - name: typecheck working-directory: web run: bun run typecheck From 6e4181d4588560cfaf29abc1efe5a3ac6d89eb1e Mon Sep 17 00:00:00 2001 From: phantomic12 Date: Fri, 7 Aug 2026 19:00:16 -0400 Subject: [PATCH 15/38] fix(web): add advisor_note + advisor_status to web's CoreEvent type The web reducer's exhaustive switch is over the WEB's local CoreEvent union (web/src/lib/types.ts), not the SDK's. The new advisor events needed to be added there too. The previous commit only added them to the SDK's CORE_EVENT_TYPES, which is a different type. Now the web typecheck sees advisor_note + advisor_status as valid variants of the web CoreEvent union; the reducer's case labels match. EOF --- web/src/lib/types.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index d56d967..16cd4eb 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -781,6 +781,8 @@ export type CoreEvent = | { type: "history"; messages: unknown[]; tokens_in?: number } | { type: "done" } | { type: "aborted" } + | { type: "advisor_note"; scope: string; advisor: string; model: string; severity: string; message: string } + | { type: "advisor_status"; scope: string; advisor: string; state: string; model?: string } | { type: "reset" } | { type: "error"; message: string } | { type: "info"; message: string } From 84b6f692e3df42a2f657657f0174622e6fdaaa6b Mon Sep 17 00:00:00 2001 From: phantomic12 Date: Sun, 9 Aug 2026 13:13:38 -0400 Subject: [PATCH 16/38] fix(providers): address review feedback (docs, tests, hardening) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit karutoil + coderabbitai + pullfrog surfaced a fresh batch of issues on the latest push. All addressed: ### Real bugs fixed 1. **gemini-cli README + antigravity README** still claimed the harness injects `x-goog-user-project` in the intro paragraphs. Replaced with `x-code-assist-project` + a warning about the consumer-API gate. The Gotchas sections already said the right thing; the intros contradicted them and operators would re-introduce the 403. 2. **`discover_project_id` ignored harness `token_path`** in the gemini-cli sibling-token fallback. Now takes `oauth_dir` parameter and looks for `antigravity.json` next to the gemini-cli token first; keeps the default global path as fallback. Removed the unused `CATALYST_CODE_OAUTH_DIR` env var path (it was never forwarded by the harness, so the branch was unreachable). The corresponding `env_passthrough` entry in plugin.json is gone. 3. **`freemium_fallback_emitted_when_no_project_header_present` missing `#[test]` attribute**. Added. Without it, the freemium default + notice path never ran under `cargo test`, so the wire contract was incomplete. 4. **`lock_for` swallow `flock` OSError + missing parent dir** in the shared module. Now: - makedirs the parent dir with mode 0o700 before opening the lock file; - on `flock` OSError, close the handle and return None instead of returning a handle that didn't actually take the lock (silent loss of cross-process serialization). 5. **`token_timeout_ms = 30000`** collides with the script's HTTP timeout, producing opaque harness timeouts on slow refreshes. Bumped both manifests to **45000** (15 s margin for interpreter start + flock + file IO). 7. **`docs/plugins/oauth.md`** referenced `CATALYST_CODE_GEMINICLI_PROJECT` (missing underscore — copy-paste from the antigravity var). Fixed. 8. **`core/providers/README.md`** "Token refresh on the hot path" said "cached for ~5 min" without noting that the 5-min cache only applies when `expires_at` is absent. Corrected: when `expires_at` is present, the harness uses it to decide refresh timing, so changed headers only reach the gateway after the next token refresh, not "the very next turn". 9. **`plugin-authoring/SKILL.md`** login flow description still said `http://localhost:/callback`. Updated to use `` so it matches the new manifest field semantics. ### Test fixes - `test_antigravity_oauth.py` + `test_gemini_cli_oauth.py`: `assertEqual(out["expires_at"], int(time.time()) + 3600)` is a flaky exact comparison when the script samples a different integer-second boundary than the test. Captured `t0` / `t1` around `run_script()` and use `assertIn` to accept either `t0 + 3600` or `t1 + 3600`. Verified locally: cargo fmt --check clean cargo check clean cargo test (focused) 5 wire-shape + freemium-fallback pass python3 -m unittest 14/14 pass --- .../skills/plugin-authoring/SKILL.md | 4 +-- core/providers/README.md | 14 +++++--- core/providers/_shared/google_oauth.py | 15 ++++++-- core/providers/antigravity/README.md | 11 +++--- .../oauth/test_antigravity_oauth.py | 6 +++- core/providers/antigravity/plugin.json | 2 +- core/providers/gemini-cli/README.md | 7 ++-- .../gemini-cli/oauth/gemini-cli-oauth.py | 35 ++++++++++--------- .../gemini-cli/oauth/test_gemini_cli_oauth.py | 6 +++- core/providers/gemini-cli/plugin.json | 5 ++- core/src/providers/google_code_assist.rs | 1 + docs/plugins/oauth.md | 2 +- 12 files changed, 69 insertions(+), 39 deletions(-) mode change 100755 => 100644 core/providers/gemini-cli/oauth/gemini-cli-oauth.py diff --git a/.catalyst-code/skills/plugin-authoring/SKILL.md b/.catalyst-code/skills/plugin-authoring/SKILL.md index 9954a5b..d06e3cf 100644 --- a/.catalyst-code/skills/plugin-authoring/SKILL.md +++ b/.catalyst-code/skills/plugin-authoring/SKILL.md @@ -623,8 +623,8 @@ includes `action`, `provider_id`, `token_path` (absolute), `workspace`, and `timestamp`; each action adds its own fields. **`login`** — build the authorize/verify URL. Input adds `headless` (bool) and, -for the web flow, `redirect_uri` (a `http://localhost:/callback` the -harness already bound — embed it verbatim in your authorize URL). Output: +for the web flow, `redirect_uri` (a `http://localhost:/` +the harness already bound — embed it verbatim in your authorize URL). Output: ```json { "url": "https://auth.example.com/device?...", "code": "ABCD-EFGH", "message": "Open the URL and enter the code", diff --git a/core/providers/README.md b/core/providers/README.md index 1addd46..e356d9f 100644 --- a/core/providers/README.md +++ b/core/providers/README.md @@ -159,13 +159,17 @@ the authorize URL — including the port and path. ### 4. Token refresh on the hot path -The `token` action runs on **every turn** (cached for ~5 min, then -re-run). Two consequences: +The `token` action runs on **every turn** (cached for ~5 min +**only when the token file has no `expires_at`**, then re-run). When +`expires_at` is present, the harness uses it to decide when to call +`token` again — typically within a 5-minute refresh lead. Two +consequences: - Keep `token` cheap. Refresh only when the cached token is near expiry; do not call out to the IdP on every chat turn. - The `headers` returned by `token` are **cached with the token** and merged onto the provider's request headers. If `x-code-assist-project` - changes between calls (e.g. the user's `loadCodeAssist` rotation - swapped the project), the new value reaches the gateway on the very - next turn without a `/login` cycle. + changes (e.g. the user's `loadCodeAssist` rotation swapped the + project), the new value reaches the gateway **only after the token + is refreshed or invalidated** — stale headers persist for ~5 min + otherwise. diff --git a/core/providers/_shared/google_oauth.py b/core/providers/_shared/google_oauth.py index 139e058..2d2b5ae 100644 --- a/core/providers/_shared/google_oauth.py +++ b/core/providers/_shared/google_oauth.py @@ -164,16 +164,27 @@ def lock_for(path): ``fcntl`` (Windows) returns ``None`` and the lock is silently skipped — fine for our use case since the harness only runs these scripts on macOS / Linux. + + Returns ``None`` when ``fcntl`` is unavailable, when the lock file + cannot be opened (e.g. parent dir missing), or when ``LOCK_EX`` + fails. The caller treats ``None`` as "no cross-process + serialization" — same semantics as the previous behaviour for + the Windows path. The lock file is created if missing. """ try: import fcntl except ImportError: return None - handle = open(path + ".lock", "a+", encoding="utf-8") + try: + os.makedirs(os.path.dirname(os.path.abspath(path)) or ".", mode=0o700, exist_ok=True) + handle = open(path + ".lock", "a+", encoding="utf-8") + except OSError: + return None try: fcntl.flock(handle.fileno(), fcntl.LOCK_EX) except OSError: - pass + handle.close() + return None return handle diff --git a/core/providers/antigravity/README.md b/core/providers/antigravity/README.md index 23cbf0e..91908d2 100644 --- a/core/providers/antigravity/README.md +++ b/core/providers/antigravity/README.md @@ -17,9 +17,12 @@ authorization page, captures the code, exchanges it for tokens, runs `loadCodeAssist` (with the Antigravity IDE 2.1.1 fingerprint headers), and persists everything to `~/.config/catalyst-code/oauth/antigravity.json`. On every subsequent turn the harness refreshes the access token when needed -and injects an `x-goog-user-project` header carrying the discovered project +and injects an `x-code-assist-project` header carrying the discovered project id, so requests route to the user's real Antigravity project — not the -shared freemium project the adapter ships as a fallback. +shared freemium project the adapter ships as a fallback. (Do **not** inject +`x-goog-user-project`: that consumer header forces a Cloud Code Private API +enablement check and returns `SERVICE_DISABLED` on free-tier / managed +projects. Only `x-code-assist-project` survives the consumer gate.) If `loadCodeAssist` returns no project (new Google account with no Code Assist history yet), the harness also calls `:onboardUser` and polls @@ -96,8 +99,8 @@ After OAuth + project discovery, every chat turn is a POST to } ``` -The `project` field comes from the harness's `x-code-assist-project` header -(merged from the OAuth plugin's per-request headers); see +The `project` field comes from the harness's `x-code-assist-project` header (NOT `x-goog-user-project`, +which trips the consumer-API gate and returns `SERVICE_DISABLED`); see `core/src/providers/google_code_assist.rs`. ## References diff --git a/core/providers/antigravity/oauth/test_antigravity_oauth.py b/core/providers/antigravity/oauth/test_antigravity_oauth.py index 4984172..4a39436 100644 --- a/core/providers/antigravity/oauth/test_antigravity_oauth.py +++ b/core/providers/antigravity/oauth/test_antigravity_oauth.py @@ -385,13 +385,17 @@ def token(body, _headers): "token_type": "Bearer", } + t0 = int(time.time()) out = run_script( {"action": "token", "token_path": self.token_path}, port=self.mock.port, ) + t1 = int(time.time()) self.assertEqual(out["access_token"], "new-access") - self.assertEqual(out["expires_at"], int(time.time()) + 3600) + # expires_at is integer-seconds; the script may have sampled time + # either just before or just after our t0/t1 captures. + self.assertIn(out["expires_at"], (t0 + 3600, t1 + 3600)) self.assertEqual( out["headers"], [["x-code-assist-project", "preserved-project"]], diff --git a/core/providers/antigravity/plugin.json b/core/providers/antigravity/plugin.json index e3a4425..8912826 100644 --- a/core/providers/antigravity/plugin.json +++ b/core/providers/antigravity/plugin.json @@ -17,7 +17,7 @@ "token_path": "antigravity.json", "script": "oauth/antigravity-oauth.py", "login_timeout_ms": 300000, - "token_timeout_ms": 30000, + "token_timeout_ms": 45000, "env_passthrough": [ "CATALYST_CODE_ANTIGRAVITY_PROJECT" ], diff --git a/core/providers/gemini-cli/README.md b/core/providers/gemini-cli/README.md index cc9306a..03c102d 100644 --- a/core/providers/gemini-cli/README.md +++ b/core/providers/gemini-cli/README.md @@ -19,9 +19,12 @@ authorization page, captures the code, exchanges it for tokens, runs + `Client-Metadata`), and persists everything to `~/.config/catalyst-code/oauth/gemini-cli.json`. On every subsequent turn the harness refreshes the access token when needed and injects an -`x-goog-user-project` header carrying the discovered project id, so +`x-code-assist-project` header carrying the discovered project id, so requests route to the user's real Cloud project — not the shared -freemium project the adapter ships as a fallback. +freemium project the adapter ships as a fallback. (Do **not** inject +`x-goog-user-project`: that consumer header forces a Cloud Code Private API +enablement check and returns `SERVICE_DISABLED` on free-tier / managed +projects. Only `x-code-assist-project` survives the consumer gate.) If `loadCodeAssist` returns no project (new Google account with no Code Assist history yet), the harness also calls `:onboardUser` and polls diff --git a/core/providers/gemini-cli/oauth/gemini-cli-oauth.py b/core/providers/gemini-cli/oauth/gemini-cli-oauth.py old mode 100755 new mode 100644 index 55275ab..70278c3 --- a/core/providers/gemini-cli/oauth/gemini-cli-oauth.py +++ b/core/providers/gemini-cli/oauth/gemini-cli-oauth.py @@ -273,16 +273,21 @@ def onboard_user(access_token, tier_id): return None -def discover_project_id(access_token): +def discover_project_id(access_token, oauth_dir=None): """Try loadCodeAssist; on failure, fall back to onboardUser polling. Free-tier gemini-cli OAuth often returns no project (Google now marks free-tier as UNSUPPORTED_CLIENT for this OAuth client). Fallbacks, in order: 1. ``CATALYST_CODE_GEMINI_CLI_PROJECT`` env override. - 2. Sibling Antigravity token file's ``project_id`` (same Google + 2. ``loadCodeAssist`` — returns the existing project if the user + is already onboarded, otherwise ``onboardUser`` polls until done. + 3. Sibling Antigravity token file's ``project_id`` — same Google account often already has a working managed project via the - Antigravity OAuth flow — verified: body.project alone works). + Antigravity OAuth flow (verified: body.project alone works). + Looked up next to the gemini-cli token file first (so custom + ``token_path`` layouts still find their sibling), then in the + default global location. """ override = (os.environ.get("CATALYST_CODE_GEMINI_CLI_PROJECT") or "").strip() if override: @@ -297,24 +302,17 @@ def discover_project_id(access_token): if project: return project # Sibling Antigravity token (same user, different OAuth client) often - # already holds a working managed project. Resolve the sibling path - # from ``CATALYST_CODE_ANTIGRAVITY_PROJECT`` / the gemini-cli token - # directory first, then fall back to the default global location. The - # configured ``token_path`` is not in scope here (discover_project_id - # is called from do_complete, which has ctx); callers pass it via the - # GEMINI_CLI_PROJECT_DIR env var when they need a non-default layout. + # already holds a working managed project. Look next to the gemini-cli + # token first (so non-default token layouts still resolve the sibling), + # then fall back to the default global location. sibling_candidates = [] - project_dir = os.environ.get("CATALYST_CODE_OAUTH_DIR", "").strip() - if project_dir: - sibling_candidates.append(os.path.join(project_dir, "antigravity.json")) + if oauth_dir: + sibling_candidates.append(os.path.join(oauth_dir, "antigravity.json")) sibling_candidates.append(os.path.expanduser( "~/.config/catalyst-code/oauth/antigravity.json" )) for sibling in sibling_candidates: - try: - sib = read_token(sibling) - except Exception: - continue + sib = read_token(sibling) if sib: pid = str(sib.get("project_id") or "").strip() if pid: @@ -368,7 +366,10 @@ def do_complete(ctx): if not normalized: die("Gemini CLI token exchange returned no usable tokens") - project_id = discover_project_id(normalized["access_token"]) + project_id = discover_project_id( + normalized["access_token"], + oauth_dir=os.path.dirname(token_path(ctx)), + ) if project_id: normalized["project_id"] = project_id diff --git a/core/providers/gemini-cli/oauth/test_gemini_cli_oauth.py b/core/providers/gemini-cli/oauth/test_gemini_cli_oauth.py index 5b7a963..00001b9 100644 --- a/core/providers/gemini-cli/oauth/test_gemini_cli_oauth.py +++ b/core/providers/gemini-cli/oauth/test_gemini_cli_oauth.py @@ -457,13 +457,17 @@ def token(body, _headers): "token_type": "Bearer", } + t0 = int(time.time()) out = run_script( {"action": "token", "token_path": self.token_path}, port=self.mock.port, ) + t1 = int(time.time()) self.assertEqual(out["access_token"], "new-access") - self.assertEqual(out["expires_at"], int(time.time()) + 3600) + # expires_at is integer-seconds; the script may have sampled time + # either just before or just after our t0/t1 captures. + self.assertIn(out["expires_at"], (t0 + 3600, t1 + 3600)) self.assertEqual( out["headers"], [["x-code-assist-project", "preserved-project"]], diff --git a/core/providers/gemini-cli/plugin.json b/core/providers/gemini-cli/plugin.json index 94711e9..1b7b9b1 100644 --- a/core/providers/gemini-cli/plugin.json +++ b/core/providers/gemini-cli/plugin.json @@ -17,10 +17,9 @@ "token_path": "gemini-cli.json", "script": "oauth/gemini-cli-oauth.py", "login_timeout_ms": 300000, - "token_timeout_ms": 30000, + "token_timeout_ms": 45000, "env_passthrough": [ - "CATALYST_CODE_GEMINI_CLI_PROJECT", - "CATALYST_CODE_OAUTH_DIR" + "CATALYST_CODE_GEMINI_CLI_PROJECT" ], "redirect_path": "/oauth2callback" } diff --git a/core/src/providers/google_code_assist.rs b/core/src/providers/google_code_assist.rs index 474934b..30a61e0 100644 --- a/core/src/providers/google_code_assist.rs +++ b/core/src/providers/google_code_assist.rs @@ -956,6 +956,7 @@ mod wire_shape_contract { assert_eq!(built.body["project"], "from-x-code-assist"); } + #[test] fn freemium_fallback_emitted_when_no_project_header_present() { // When the plugin doesn't inject any project header, the adapter // falls back to the freemium default `rising-fact-p41fc` and emits diff --git a/docs/plugins/oauth.md b/docs/plugins/oauth.md index 09ee483..a2d886e 100644 --- a/docs/plugins/oauth.md +++ b/docs/plugins/oauth.md @@ -150,7 +150,7 @@ own process env at call time and injects them into the script's child env. - `CATALYST_CODE_ANTIGRAVITY_PROJECT` — overrides the Antigravity Code Assist `cloudaicompanionProject` (bypasses the `loadCodeAssist` auto-discovery round-trip in tests / CI). - - `CATALYST_CODE_GEMINICLI_PROJECT` — same for the Gemini CLI bundle. + - `CATALYST_CODE_GEMINI_CLI_PROJECT` — same for the Gemini CLI bundle. - **Self-hosted IdP overrides** typically use a `_HOST` / `_API_URL` / `_TENANT` shape. Example: `["ACME_OAUTH_HOST", "ACME_TENANT"]`. From bf31d3d8d08df94a9390e107256dc6c0b25998f7 Mon Sep 17 00:00:00 2001 From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:33:04 +0000 Subject: [PATCH 17/38] fix(providers): retire CATALYST_CODE_OAUTH_DIR test story and align Client-Metadata docs Rename the sibling fallback test to document harness token_path layout resolution and drop the unused env. Match gemini-cli README Client-Metadata to ideType 9 / pluginType 2 with runtime platform enum. --- core/providers/gemini-cli/README.md | 2 +- .../gemini-cli/oauth/test_gemini_cli_oauth.py | 23 +++++++++---------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/core/providers/gemini-cli/README.md b/core/providers/gemini-cli/README.md index 03c102d..1841628 100644 --- a/core/providers/gemini-cli/README.md +++ b/core/providers/gemini-cli/README.md @@ -68,7 +68,7 @@ The script uses the public Gemini CLI OAuth client: | `client_secret` | `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` | | `User-Agent` | `google-api-nodejs-client/9.15.1` | | `X-Goog-Api-Client` | `google-cloud-sdk vscode_cloudshelleditor/0.1` | -| `Client-Metadata`| `{ ideType: 0, platform: 0, pluginType: 0 }` | +| `Client-Metadata`| `{ ideType: 9, platform: , pluginType: 2 }` (runtime platform) | These are intentional — the gemini-cli npm package ships the same public client and fingerprints. Google's backend uses them to differentiate diff --git a/core/providers/gemini-cli/oauth/test_gemini_cli_oauth.py b/core/providers/gemini-cli/oauth/test_gemini_cli_oauth.py index 00001b9..034ddd2 100644 --- a/core/providers/gemini-cli/oauth/test_gemini_cli_oauth.py +++ b/core/providers/gemini-cli/oauth/test_gemini_cli_oauth.py @@ -323,7 +323,7 @@ def test_complete_persists_token_with_sibling_project_fallback(self): # Override HOME so the ``~/.config/...`` expansion lands in # our tempdir. home = self.tmp.name - with temp_env(HOME=home, USERPROFILE=home, CATALYST_CODE_OAUTH_DIR=""): + with temp_env(HOME=home, USERPROFILE=home): self.mock.start() @self.mock.route("/token") @@ -371,19 +371,19 @@ def userinfo(_body, _headers): stat.S_IMODE(os.stat(token_path).st_mode), 0o600 ) - def test_complete_falls_back_to_sibling_via_env_dir(self): - """``CATALYST_CODE_OAUTH_DIR`` overrides the sibling lookup dir. + def test_complete_falls_back_to_sibling_via_token_path_dir(self): + """Sibling lookup uses ``dirname(token_path)`` for custom layouts. - Same fallback as above, but via the env var instead of relying on - ``$HOME`` — useful for sandboxed installs where ``~/.config/`` is - read-only or points somewhere weird. + When the harness passes a non-default ``token_path``, discovery + still finds ``antigravity.json`` next to it — without relying on + ``$HOME`` or any env override. """ oauth_dir = os.path.join(self.tmp.name, "custom", "oauth") os.makedirs(oauth_dir, exist_ok=True) sibling_path = os.path.join(oauth_dir, "antigravity.json") with open(sibling_path, "w", encoding="utf-8") as handle: json.dump( - {"access_token": "x", "project_id": "env-dir-proj"}, + {"access_token": "x", "project_id": "custom-layout-proj"}, handle, ) @@ -392,8 +392,8 @@ def test_complete_falls_back_to_sibling_via_env_dir(self): @self.mock.route("/token") def token(_body, _headers): return 200, { - "access_token": "env-access", - "refresh_token": "env-refresh", + "access_token": "custom-access", + "refresh_token": "custom-refresh", "expires_in": 3600, "token_type": "Bearer", } @@ -418,14 +418,13 @@ def userinfo(_body, _headers): "redirect_uri": "http://127.0.0.1:8085/oauth2callback", "token_path": token_path, } - with temp_env(CATALYST_CODE_OAUTH_DIR=oauth_dir): - out = run_script(ctx, port=self.mock.port) + out = run_script(ctx, port=self.mock.port) self.assertEqual(out, {"ok": True}) with open(token_path, encoding="utf-8") as handle: token = json.load(handle) - self.assertEqual(token["project_id"], "env-dir-proj") + self.assertEqual(token["project_id"], "custom-layout-proj") # ── token ──────────────────────────────────────────────────────────── From a41ac49f58e7a7389d4c559427724dc30ca0172e Mon Sep 17 00:00:00 2001 From: phantomic12 Date: Fri, 7 Aug 2026 00:24:00 -0400 Subject: [PATCH 18/38] feat(providers): add Antigravity + Gemini CLI OAuth plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new first-party OAuth provider bundles, mirroring the Gemini CLI and Antigravity IDE subscription flows used by Google's open-source clients. Both reuse the existing core/src/providers/google_code_assist.rs adapter — the is_code_assist_endpoint() gate already routes the cloudcode-pa / daily-cloudcode-pa hosts to the right wire format, and the adapter's resolve_project() reads the x-goog-user-project header that each plugin's 'token' action injects so requests route to the user's real Code Assist project (not the freemium shared default). Flow: 1. /login antigravity (or gemini-cli) 2. Harness binds loopback, opens Google OAuth page (PKCE S256). 3. Script exchanges the code, calls :loadCodeAssist with the matching client fingerprint (Antigravity IDE 2.1.1 UA + enum 9/2/2 metadata; gemini-cli google-api-nodejs-client UA + X-Goog-Api-Client + Client-Metadata). 4. If loadCodeAssist returns no project (new account), the script calls :onboardUser and polls until done=true. 5. Token file persisted to ~/.config/catalyst-code/oauth/{antigravity,gemini-cli}.json with access_token, refresh_token, expires_at, project_id, and email. 6. On every turn the harness refreshes near-expiry tokens and injects an x-goog-user-project header carrying the discovered project. Verified end-to-end: * cargo test (focused: staging, plugins, oauth, providers, google_code_assist) — 199 passed, 0 failed. * cargo build --release -p catalyst-code-core — clean. * OAuth script against mock OAuth + loadCodeAssist + onboardUser backend — login, complete, token, refresh, clear, onboarding fallback all work. * Fresh $HOME → release binary auto-stages both plugins into ~/.catalyst-code/plugins/, byte-identical to source, scripts marked executable. * strings(1) confirms provider_id=antigravity and provider_id= gemini-cli are baked into the binary. --- core/providers/README.md | 9 + core/providers/antigravity/README.md | 107 ++++ .../antigravity/oauth/antigravity-oauth.py | 565 ++++++++++++++++++ core/providers/antigravity/plugin.json | 19 + core/providers/gemini-cli/README.md | 105 ++++ .../gemini-cli/oauth/gemini-cli-oauth.py | 550 +++++++++++++++++ core/providers/gemini-cli/plugin.json | 19 + core/src/staging.rs | 84 ++- 8 files changed, 1456 insertions(+), 2 deletions(-) create mode 100644 core/providers/antigravity/README.md create mode 100755 core/providers/antigravity/oauth/antigravity-oauth.py create mode 100644 core/providers/antigravity/plugin.json create mode 100644 core/providers/gemini-cli/README.md create mode 100755 core/providers/gemini-cli/oauth/gemini-cli-oauth.py create mode 100644 core/providers/gemini-cli/plugin.json diff --git a/core/providers/README.md b/core/providers/README.md index 574ab59..5d9afbc 100644 --- a/core/providers/README.md +++ b/core/providers/README.md @@ -55,3 +55,12 @@ source-of-truth embedded into the binary. - `kimi/` — Kimi Code (Moonshot), device-code OAuth subscription. - `codex/` — ChatGPT (Codex), official Codex CLI device-code OAuth with automatic polling. - `deepseek/` — DeepSeek API, official OpenAI-compatible API-key provider. +- `antigravity/` — Google Antigravity IDE, OAuth + Code Assist `loadCodeAssist` project discovery (Authorization Code + PKCE). +- `gemini-cli/` — Google Gemini CLI, OAuth + Code Assist `loadCodeAssist` project discovery (Authorization Code + PKCE). + +Both Google bundles reuse the existing `core/src/providers/google_code_assist.rs` +adapter — `is_code_assist_endpoint` already routes the `cloudcode-pa` / +daily-cloudcode-pa hosts to the right wire format, and the adapter's +`resolve_project` reads the `x-goog-user-project` header that each plugin's +`token` action injects to use the user's real Code Assist project instead +of the freemium fallback. diff --git a/core/providers/antigravity/README.md b/core/providers/antigravity/README.md new file mode 100644 index 0000000..7cf12fc --- /dev/null +++ b/core/providers/antigravity/README.md @@ -0,0 +1,107 @@ +# Antigravity — Google IDE OAuth + +This first-party bundle connects the harness to the Google Antigravity IDE +subscription via the **Code Assist / `cloudcode-pa` gateway**. It uses +Google's standard OAuth 2.0 Authorization Code flow with PKCE against the +public Antigravity IDE client, then runs `:loadCodeAssist` to fetch a real +`cloudaicompanionProject` for the authenticated user. + +Use `/login` and choose **Antigravity (Google IDE)**, or run: + +```text +/login antigravity +``` + +The harness binds a loopback redirect, opens the browser to Google's +authorization page, captures the code, exchanges it for tokens, runs +`loadCodeAssist` (with the Antigravity IDE 2.1.1 fingerprint headers), and +persists everything to `~/.config/catalyst-code/oauth/antigravity.json`. +On every subsequent turn the harness refreshes the access token when needed +and injects an `x-goog-user-project` header carrying the discovered project +id, so requests route to the user's real Antigravity project — not the +shared freemium project the adapter ships as a fallback. + +If `loadCodeAssist` returns no project (new Google account with no +Code Assist history yet), the harness also calls `:onboardUser` and polls +until provisioning finishes (`done=true`), so the first request never fails +with `project not found`. + +## Models + +Antigravity exposes Gemini 3 / 3.1 Pro and Flash (with tiered -high / -low +for Pro), Claude Sonnet 4.6 and Opus 4.6 Thinking, plus GPT-OSS 120B. +Model IDs map 1:1 to upstream Code Assist slugs — no aliasing: + +```text +gemini-3.1-pro-high +gemini-3.1-pro-low +gemini-3-pro-high +gemini-3-pro-low +gemini-3-flash +gemini-2.5-pro +gemini-2.5-flash +claude-opus-4-6-thinking +claude-sonnet-4-6 +gpt-oss-120b-medium +``` + +## Endpoints + +| Purpose | URL | +|-----------------------------|--------------------------------------------------------------------| +| Authorization | `https://accounts.google.com/o/oauth2/v2/auth` | +| Token exchange / refresh | `https://oauth2.googleapis.com/token` | +| Userinfo (email) | `https://www.googleapis.com/oauth2/v1/userinfo` | +| Project discovery | `https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` | +| User onboarding (fallback) | `https://cloudcode-pa.googleapis.com/v1internal:onboardUser` | +| Chat (streamGenerateContent)| `https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal` | + +Project discovery + onboarding use the **prod** Code Assist host — the +daily/sandbox host rejects `loadCodeAssist` and `onboardUser`. Only chat +traffic uses the daily host (to bypass prod-side 429 rate limits). + +## Client identity + +The script uses the public Antigravity IDE OAuth client: + +| Field | Value | +|---------------|-----------------------------------------------------------------------------------| +| `client_id` | `1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com` | +| `client_secret` | `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` | +| User-Agent | `antigravity/ide/2.1.1 darwin/arm64` | +| Metadata | `{ ideType: 9, platform: 2, pluginType: 2 }` (ANTIGRAVITY, DARWIN_ARM64, GEMINI) | + +These are intentional — every Antigravity IDE install carries the same +public client and the same fingerprints. Google's backend uses the +fingerprint to detect non-IDE clients and silently refuses to provision a +project if it looks wrong, so matching them is what lets the first +request succeed. + +## Wire + +After OAuth + project discovery, every chat turn is a POST to +`{base_url}:streamGenerateContent?alt=sse` with body shape: + +```json +{ + "model": "gemini-3.1-pro-high", + "project": "", + "userAgent": "antigravity", + "request": { + "contents": [...], + "systemInstruction": {...}, + "tools": [...], + "generationConfig": {"maxOutputTokens": N} + } +} +``` + +The `project` field comes from the harness's `x-goog-user-project` header +(merged from the OAuth plugin's per-request headers); see +`core/src/providers/google_code_assist.rs`. + +## References + +- Antigravity IDE source fingerprint: captured from a real 2.1.1 install. +- Code Assist wire format + project discovery: Google's open-source + Gemini CLI + Antigravity IDE. \ No newline at end of file diff --git a/core/providers/antigravity/oauth/antigravity-oauth.py b/core/providers/antigravity/oauth/antigravity-oauth.py new file mode 100755 index 0000000..866a0fa --- /dev/null +++ b/core/providers/antigravity/oauth/antigravity-oauth.py @@ -0,0 +1,565 @@ +#!/usr/bin/env python3 +"""Antigravity (Google IDE) OAuth + Code Assist project discovery. + +The harness sends one JSON object on stdin with an ``action`` of ``login``, +``complete``, ``token``, or ``clear``. One JSON object is written to stdout. + +This file deliberately uses only the Python standard library. The endpoint +names, client id, redirect URI, refresh grant, and loadCodeAssist metadata +mirror the public Antigravity IDE (2.1.1, darwin/arm64) so the upstream +Code Assist gateway provisions a real ``cloudaicompanionProject`` for us. + +Flow +---- +login PKCE + Authorization Code → harness binds loopback → opens browser + → captures ``code`` → we exchange + run ``loadCodeAssist`` → + write ``token.json`` containing access + refresh + project_id. +token Return a fresh ``access_token`` (refresh if near expiry) and a + ``x-goog-user-project`` header carrying the cached ``project_id`` + so the harness's Google Code Assist adapter routes to the user's + real Antigravity project (not the freemium shared one). +clear Delete the on-disk token file. +""" + +import base64 +import hashlib +import json +import os +import secrets +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request + + +# ─── Antigravity IDE public OAuth client ──────────────────────────────────── +# Public client_id / client_secret shipped in the open-source Antigravity IDE. +# Both values are intentionally public — every Antigravity IDE install carries +# the same pair — and are reused here unchanged. +CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" +CLIENT_SECRET = "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" + +AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" +TOKEN_URL = "https://oauth2.googleapis.com/token" +USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo" + +# Scopes the Antigravity IDE requests. ``cclog`` + ``experimentsandconfigs`` +# are Antigravity-specific and are required for Code Assist provisioning. +SCOPES = [ + "openid", + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/cclog", + "https://www.googleapis.com/auth/experimentsandconfigs", +] + +# Antigravity IDE fingerprints (must match what the IDE actually sends — +# Google's backend fingerprints these headers and silently refuses to +# provision a project if they look wrong). +USER_AGENT = "antigravity/ide/2.1.1 darwin/arm64" + +# Project discovery stays on PROD — the daily host rejects loadCodeAssist / +# onboardUser calls. Only chat traffic uses the daily host (via base_url in +# plugin.json). +LOAD_CODE_ASSIST_URL = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" +ONBOARD_USER_URL = "https://cloudcode-pa.googleapis.com/v1internal:onboardUser" + +# Numeric enum values that the Code Assist backend fingerprints. Values +# captured from a real Antigravity IDE 2.1.1 / darwin-arm64 install. Anything +# else triggers silent provisioning failure (no cloudaicompanionProject in +# the response, and onboardUser's poll never reaches ``done=true``). +# IDE_TYPE_ANTIGRAVITY = 9 +# PLATFORM_DARWIN_ARM64 = 2 +# PLUGIN_TYPE_GEMINI = 2 +CLIENT_METADATA = {"ideType": 9, "platform": 2, "pluginType": 2} + +REFRESH_LEAD_S = 300 +ONBOARD_MAX_ATTEMPTS = 5 +ONBOARD_POLL_S = 2 +HTTP_TIMEOUT_S = 30 + + +# ─── harness I/O ─────────────────────────────────────────────────────────── + +def emit(obj): + sys.stdout.write(json.dumps(obj, separators=(",", ":"))) + sys.stdout.flush() + + +def die(message): + emit({"ok": False, "error": str(message)}) + raise SystemExit(0) + + +def now(): + return int(time.time()) + + +# ─── HTTP helpers ────────────────────────────────────────────────────────── + +def http_post(url, body, content_type, extra_headers=None): + headers = { + "Accept": "application/json", + "Content-Type": content_type, + "User-Agent": USER_AGENT, + } + if extra_headers: + headers.update(extra_headers) + req = urllib.request.Request(url, data=body, method="POST", headers=headers) + try: + with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S) as response: + raw = response.read().decode("utf-8", "replace") + return response.status, parse_json(raw) + except urllib.error.HTTPError as exc: + raw = exc.read().decode("utf-8", "replace") + return exc.code, parse_json(raw) + except Exception as exc: + return 0, {"error": "request_failed", "error_description": str(exc)} + + +def parse_json(raw): + try: + value = json.loads(raw) if raw.strip() else {} + return value if isinstance(value, dict) else {} + except Exception: + return {"error": "invalid_json", "error_description": raw[:500]} + + +def post_form(url, fields, extra_headers=None): + return http_post( + url, + urllib.parse.urlencode(fields).encode("utf-8"), + "application/x-www-form-urlencoded", + extra_headers, + ) + + +def post_json(url, payload, extra_headers=None): + return http_post( + url, + json.dumps(payload, separators=(",", ":")).encode("utf-8"), + "application/json", + extra_headers, + ) + + +def error_text(status, data): + return ( + data.get("error_description") + or data.get("error") + or ("network request failed" if status == 0 else f"HTTP {status}") + ) + + +# ─── on-disk token file ──────────────────────────────────────────────────── + +def token_path(ctx): + return os.path.abspath(str(ctx.get("token_path") or "antigravity.json")) + + +def read_token(path): + try: + with open(path, encoding="utf-8") as handle: + value = json.load(handle) + return value if isinstance(value, dict) else None + except (OSError, ValueError, TypeError): + return None + + +def atomic_write(path, value): + path = os.path.abspath(path) + parent = os.path.dirname(path) or "." + os.makedirs(parent, mode=0o700, exist_ok=True) + fd, tmp = tempfile.mkstemp(prefix=".antigravity-oauth-", dir=parent) + try: + try: + os.fchmod(fd, 0o600) + except AttributeError: + pass # Windows has no POSIX mode bits + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(value, handle, separators=(",", ":")) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp, path) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise + + +def lock_for(path): + try: + import fcntl + except ImportError: + return None + handle = open(path + ".lock", "a+", encoding="utf-8") + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + except OSError: + pass + return handle + + +def unlock(handle): + if handle is not None: + try: + handle.close() + except OSError: + pass + + +# ─── PKCE + auth URL ─────────────────────────────────────────────────────── + +def make_pkce(): + """Generate (verifier, challenge, state) for S256 PKCE.""" + verifier = base64.urlsafe_b64encode(secrets.token_bytes(48)).rstrip(b"=").decode("ascii") + challenge = base64.urlsafe_b64encode( + hashlib.sha256(verifier.encode("ascii")).digest() + ).rstrip(b"=").decode("ascii") + state = base64.urlsafe_b64encode(secrets.token_bytes(24)).rstrip(b"=").decode("ascii") + return verifier, challenge, state + + +def build_authorize_url(redirect_uri, state, challenge, extra=None): + params = { + "client_id": CLIENT_ID, + "response_type": "code", + "redirect_uri": redirect_uri, + "scope": " ".join(SCOPES), + "state": state, + "code_challenge": challenge, + "code_challenge_method": "S256", + "access_type": "offline", + "prompt": "consent", + "include_granted_scopes": "true", + } + if extra: + params.update(extra) + return AUTH_URL + "?" + urllib.parse.urlencode(params) + + +# ─── token exchange ──────────────────────────────────────────────────────── + +def exchange_code(code, redirect_uri, verifier): + status, data = post_form( + TOKEN_URL, + { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "code_verifier": verifier, + }, + ) + return status, data + + +def refresh_access_token(refresh_token): + status, data = post_form( + TOKEN_URL, + { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + }, + ) + return status, data + + +def fetch_user_email(access_token): + req = urllib.request.Request( + USERINFO_URL, + headers={"Authorization": f"Bearer {access_token}", "User-Agent": USER_AGENT}, + ) + try: + with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S) as response: + data = parse_json(response.read().decode("utf-8", "replace")) + return data.get("email") or "" + except Exception: + return "" + + +def normalize_tokens(tokens): + """Coerce the raw OAuth response into the persistent shape on disk.""" + access = tokens.get("access_token") or "" + refresh = tokens.get("refresh_token") or "" + if not access and not refresh: + return None + expires_in = int(tokens.get("expires_in") or 0) + return { + "access_token": access, + "refresh_token": refresh, + "expires_in": expires_in, + "expires_at": now() + max(expires_in, 60), + "scope": tokens.get("scope", ""), + "token_type": tokens.get("token_type", "Bearer"), + } + + +# ─── Code Assist: loadCodeAssist + onboardUser ───────────────────────────── + +def _code_assist_headers(access_token): + return { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + "User-Agent": USER_AGENT, + } + + +def _code_assist_body(include_tier=False, tier_id=None): + body = {"metadata": dict(CLIENT_METADATA)} + if include_tier: + body["tierId"] = tier_id or "legacy-tier" + return body + + +def load_code_assist(access_token): + """POST :loadCodeAssist, return ``cloudaicompanionProject`` id or ``None``.""" + status, data = post_json( + LOAD_CODE_ASSIST_URL, + _code_assist_body(), + _code_assist_headers(access_token), + ) + if status != 200: + return None + project = data.get("cloudaicompanionProject") + if isinstance(project, str) and project.strip(): + return project.strip() + if isinstance(project, dict): + nested = project.get("id") + if isinstance(nested, str) and nested.strip(): + return nested.strip() + return None + + +def _pick_default_tier(payload): + tiers = payload.get("allowedTiers") + if isinstance(tiers, list): + for tier in tiers: + if isinstance(tier, dict) and tier.get("isDefault") is True: + tid = tier.get("id") + if isinstance(tid, str) and tid.strip(): + return tid.strip() + return "legacy-tier" + + +def onboard_user(access_token, tier_id): + """POST :onboardUser, polling until ``done=true``; return project_id.""" + for attempt in range(1, ONBOARD_MAX_ATTEMPTS + 1): + status, data = post_json( + ONBOARD_USER_URL, + _code_assist_body(include_tier=True, tier_id=tier_id), + _code_assist_headers(access_token), + ) + if status != 200: + return None + if data.get("done") is True: + response = data.get("response") or {} + project = response.get("cloudaicompanionProject") + if isinstance(project, str) and project.strip(): + return project.strip() + if isinstance(project, dict): + nested = project.get("id") + if isinstance(nested, str) and nested.strip(): + return nested.strip() + return None + if attempt < ONBOARD_MAX_ATTEMPTS: + time.sleep(ONBOARD_POLL_S) + return None + + +def discover_project_id(access_token): + """Try loadCodeAssist; on failure, fall back to onboardUser polling.""" + # We need the loadCodeAssist payload too (for the tier), so re-call. + status, data = post_json( + LOAD_CODE_ASSIST_URL, + _code_assist_body(), + _code_assist_headers(access_token), + ) + if status == 200: + project = data.get("cloudaicompanionProject") + if isinstance(project, str) and project.strip(): + return project.strip() + if isinstance(project, dict): + nested = project.get("id") + if isinstance(nested, str) and nested.strip(): + return nested.strip() + tier = _pick_default_tier(data) + return onboard_user(access_token, tier) + return None + + +# ─── actions ─────────────────────────────────────────────────────────────── + +def do_login(ctx): + """Build the authorize URL. The harness binds the loopback + opens the + browser; we receive the ``code`` back in ``complete``.""" + verifier, challenge, state = make_pkce() + redirect_uri = str(ctx.get("redirect_uri") or "").strip() + if not redirect_uri: + die("Antigravity OAuth requires a loopback redirect_uri (catcode binds this)") + + url = build_authorize_url(redirect_uri, state, challenge) + emit( + { + "url": url, + "flow": "web", + "state": state, + "pending": {"verifier": verifier}, + "message": ( + "Open the URL to authorize Antigravity. The token is then " + "auto-discovered via loadCodeAssist and a real Cloud project " + "is provisioned if needed." + ), + } + ) + + +def do_complete(ctx): + code = str(ctx.get("code") or "").strip() + if not code: + die("no authorization code received from Antigravity") + pending = ctx.get("pending") or {} + verifier = str(pending.get("verifier") or "").strip() + if not verifier: + die("missing PKCE verifier in pending state; restart /login antigravity") + redirect_uri = str(ctx.get("redirect_uri") or "").strip() + if not redirect_uri: + die("missing redirect_uri in complete context; restart /login antigravity") + + status, tokens = exchange_code(code, redirect_uri, verifier) + if status != 200 or not tokens.get("access_token"): + die("Antigravity token exchange failed: " + error_text(status, tokens)) + + normalized = normalize_tokens(tokens) + if not normalized: + die("Antigravity token exchange returned no usable tokens") + + project_id = discover_project_id(normalized["access_token"]) + if project_id: + normalized["project_id"] = project_id + + email = fetch_user_email(normalized["access_token"]) + if email: + normalized["email"] = email + + atomic_write(token_path(ctx), normalized) + emit({"ok": True}) + + +def do_token(ctx): + path = token_path(ctx) + handle = lock_for(path) + try: + token = read_token(path) + if not token: + emit({"access_token": None}) + return + + current = now() + expires_at = int(token.get("expires_at") or 0) + needs_refresh = (not token.get("access_token")) or ( + expires_at > 0 and expires_at - current <= REFRESH_LEAD_S + ) + + if needs_refresh and token.get("refresh_token"): + # Another harness process may have refreshed while we waited for + # the flock; always re-read before making a network request. + current_token = read_token(path) or token + current_exp = int(current_token.get("expires_at") or 0) + if current_token.get("access_token") and ( + current_exp == 0 or current_exp - now() > REFRESH_LEAD_S + ): + token = current_token + else: + status, data = refresh_access_token(current_token["refresh_token"]) + if status != 200 or not data.get("access_token"): + emit({"access_token": None}) + return + rotated = normalize_tokens( + { + "access_token": data.get("access_token", ""), + "refresh_token": data.get("refresh_token") + or current_token.get("refresh_token", ""), + "expires_in": int(data.get("expires_in") or 0), + "scope": data.get("scope", current_token.get("scope", "")), + "token_type": data.get("token_type", "Bearer"), + } + ) + if not rotated: + emit({"access_token": None}) + return + # Preserve project_id + email across rotations — those were + # discovered once and remain valid for the lifetime of the + # OAuth grant. + rotated["project_id"] = current_token.get("project_id", "") + rotated["email"] = current_token.get("email", "") + atomic_write(path, rotated) + token = rotated + + access = token.get("access_token") or "" + if not access: + emit({"access_token": None}) + return + + headers = [] + project_id = str(token.get("project_id") or "").strip() + if project_id: + # The harness's Google Code Assist adapter resolves the project + # via this header (one of three accepted names). The plugin sets + # the real per-user project so requests don't fall back to the + # shared freemium project that the adapter ships as a default. + headers.append(["x-goog-user-project", project_id]) + emit( + { + "access_token": access, + "expires_at": int(token.get("expires_at") or 0), + "headers": headers, + } + ) + finally: + unlock(handle) + + +def do_clear(ctx): + path = token_path(ctx) + for candidate in (path, path + ".lock"): + try: + os.remove(candidate) + except OSError: + pass + emit({"ok": True}) + + +def main(): + try: + raw = sys.stdin.read() + ctx = json.loads(raw) if raw.strip() else {} + if not isinstance(ctx, dict): + die("OAuth context must be a JSON object") + action = ctx.get("action", "") + if action == "login": + do_login(ctx) + elif action == "complete": + do_complete(ctx) + elif action == "token": + do_token(ctx) + elif action == "clear": + do_clear(ctx) + else: + die("unknown action: %r" % action) + except SystemExit: + raise + except Exception as exc: + die("Antigravity OAuth provider error: " + str(exc)) + + +if __name__ == "__main__": + main() diff --git a/core/providers/antigravity/plugin.json b/core/providers/antigravity/plugin.json new file mode 100644 index 0000000..e8c0cb2 --- /dev/null +++ b/core/providers/antigravity/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "antigravity", + "version": "0.1.0", + "description": "Google Antigravity IDE subscription access — OAuth + Code Assist project discovery. Auto-staged into every install.", + "oauth": { + "provider_id": "antigravity", + "label": "Antigravity (Google IDE)", + "kind": "openai", + "base_url": "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal", + "description": "Antigravity / Code Assist OAuth — Authorization Code + PKCE. After login the harness calls :loadCodeAssist (with the Antigravity IDE 2.1.1 fingerprint) to provision a cloudaicompanionProject, then sends chat through the daily Code Assist gateway.", + "headers": [ + ["User-Agent", "antigravity"] + ], + "token_path": "antigravity.json", + "script": "oauth/antigravity-oauth.py", + "login_timeout_ms": 300000, + "token_timeout_ms": 30000 + } +} diff --git a/core/providers/gemini-cli/README.md b/core/providers/gemini-cli/README.md new file mode 100644 index 0000000..71809b4 --- /dev/null +++ b/core/providers/gemini-cli/README.md @@ -0,0 +1,105 @@ +# Gemini CLI — Google OAuth + +This first-party bundle connects the harness to the **Gemini CLI** +(`@google-gemini/gemini-cli`) subscription tier via Google's **Code +Assist / `cloudcode-pa` gateway**. It uses Google's standard OAuth 2.0 +Authorization Code flow with PKCE against the public gemini-cli client, +then runs `:loadCodeAssist` to fetch a real `cloudaicompanionProject` +for the authenticated user. + +Use `/login` and choose **Gemini CLI (Google)**, or run: + +```text +/login gemini-cli +``` + +The harness binds a loopback redirect, opens the browser to Google's +authorization page, captures the code, exchanges it for tokens, runs +`loadCodeAssist` (with the gemini-cli fingerprint headers — `X-Goog-Api-Client` ++ `Client-Metadata`), and persists everything to +`~/.config/catalyst-code/oauth/gemini-cli.json`. On every subsequent +turn the harness refreshes the access token when needed and injects an +`x-goog-user-project` header carrying the discovered project id, so +requests route to the user's real Cloud project — not the shared +freemium project the adapter ships as a fallback. + +If `loadCodeAssist` returns no project (new Google account with no +Code Assist history yet), the harness also calls `:onboardUser` and polls +until provisioning finishes (`done=true`), so the first request never +fails with `project not found`. + +## Models + +Gemini CLI exposes the Gemini 3 / 3.1 Pro + Flash previews plus the +2.5 family. Model IDs map 1:1 to upstream Code Assist slugs — no +aliasing: + +```text +gemini-3.1-pro-preview +gemini-3-pro-preview +gemini-3-flash-preview +gemini-3.1-flash-lite-preview +gemini-2.5-pro +gemini-2.5-flash +gemini-2.5-flash-lite +``` + +## Endpoints + +| Purpose | URL | +|-----------------------------|--------------------------------------------------------------------| +| Authorization | `https://accounts.google.com/o/oauth2/v2/auth` | +| Token exchange / refresh | `https://oauth2.googleapis.com/token` | +| Userinfo (email) | `https://www.googleapis.com/oauth2/v1/userinfo` | +| Project discovery | `https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` | +| User onboarding (fallback) | `https://cloudcode-pa.googleapis.com/v1internal:onboardUser` | +| Chat (streamGenerateContent)| `https://cloudcode-pa.googleapis.com/v1internal` | + +## Client identity + +The script uses the public Gemini CLI OAuth client: + +| Field | Value | +|------------------|----------------------------------------------------------------------------------| +| `client_id` | `681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com` | +| `client_secret` | `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` | +| `User-Agent` | `google-api-nodejs-client/9.15.1` | +| `X-Goog-Api-Client` | `google-cloud-sdk vscode_cloudshelleditor/0.1` | +| `Client-Metadata`| `{ ideType: 0, platform: 0, pluginType: 0 }` | + +These are intentional — the gemini-cli npm package ships the same public +client and fingerprints. Google's backend uses them to differentiate +gemini-cli traffic from Antigravity / 3rd-party clients; including the +wrong pair (or omitting the `X-Goog-Api-Client` / `Client-Metadata` +headers) makes OAuth succeed but `loadCodeAssist` returns no project +and the first chat request fails with "project not found". + +## Wire + +After OAuth + project discovery, every chat turn is a POST to +`{base_url}:streamGenerateContent?alt=sse` with body shape: + +```json +{ + "model": "gemini-3.1-pro-preview", + "project": "", + "userAgent": "google-api-nodejs-client/9.15.1", + "request": { + "contents": [...], + "systemInstruction": {...}, + "tools": [...], + "generationConfig": {"maxOutputTokens": N} + } +} +``` + +The `project` field comes from the harness's `x-goog-user-project` header +(merged from the OAuth plugin's per-request headers); see +`core/src/providers/google_code_assist.rs`. + +## References + +- Gemini CLI source fingerprint: captured from a live `@google-gemini/gemini-cli` + install. +- Code Assist wire format + project discovery: Google's open-source + Gemini CLI source. \ No newline at end of file diff --git a/core/providers/gemini-cli/oauth/gemini-cli-oauth.py b/core/providers/gemini-cli/oauth/gemini-cli-oauth.py new file mode 100755 index 0000000..5496b2d --- /dev/null +++ b/core/providers/gemini-cli/oauth/gemini-cli-oauth.py @@ -0,0 +1,550 @@ +#!/usr/bin/env python3 +"""Gemini CLI (Google) OAuth + Code Assist project discovery. + +The harness sends one JSON object on stdin with an ``action`` of ``login``, +``complete``, ``token``, or ``clear``. One JSON object is written to stdout. + +This file deliberately uses only the Python standard library. The endpoint +names, client id, redirect URI, refresh grant, and loadCodeAssist metadata +mirror Google's open-source ``gemini`` CLI so the upstream Code Assist +gateway provisions a real ``cloudaicompanionProject`` for us. + +Compared to the Antigravity plugin this one uses: + +* a different public OAuth client (the open-source gemini-cli client); +* a simpler scope list (no cclog / experimentsandconfigs); +* the prod Code Assist host for chat (the daily host rejects gemini-cli + traffic more often than antigravity traffic in practice); +* the gemini-cli loadCodeAssist fingerprint (google-api-nodejs-client UA + + X-Goog-Api-Client + Client-Metadata with the IDE/PLATFORM/PLUGIN_TYPE + numeric enums the gemini-cli binary actually sends). +""" + +import base64 +import hashlib +import json +import os +import secrets +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request + + +# ─── Gemini CLI public OAuth client ──────────────────────────────────────── +# Public client_id / client_secret shipped in the open-source +# ``@google-gemini/gemini-cli`` npm package. Reused here unchanged. +CLIENT_ID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com" +CLIENT_SECRET = "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" + +AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" +TOKEN_URL = "https://oauth2.googleapis.com/token" +USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo" + +# Gemini CLI's standard scope list. ``cclog`` and +# ``experimentsandconfigs`` are Antigravity-specific and intentionally +# excluded here — including them with the wrong client id would silently +# drop the Antigravity-only scopes on Google's side. +SCOPES = [ + "openid", + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", +] + +# Gemini CLI fingerprints captured from a live ``gemini`` CLI install. +# Google fingerprints these headers + the metadata payload and silently +# refuses to provision a project if they look wrong (or if they're +# missing entirely), so the OAuth flow would technically succeed but the +# first chat request would 404 with "Project not found". +USER_AGENT = "google-api-nodejs-client/9.15.1" +X_GOOG_API_CLIENT = "google-cloud-sdk vscode_cloudshelleditor/0.1" +# Numeric enum values that match what gemini-cli actually sends. These are +# not the same as Antigravity (different ideType/pluginType). +CLIENT_METADATA = {"ideType": 0, "platform": 0, "pluginType": 0} + +LOAD_CODE_ASSIST_URL = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" +ONBOARD_USER_URL = "https://cloudcode-pa.googleapis.com/v1internal:onboardUser" + +REFRESH_LEAD_S = 300 +ONBOARD_MAX_ATTEMPTS = 5 +ONBOARD_POLL_S = 2 +HTTP_TIMEOUT_S = 30 + + +# ─── harness I/O ─────────────────────────────────────────────────────────── + +def emit(obj): + sys.stdout.write(json.dumps(obj, separators=(",", ":"))) + sys.stdout.flush() + + +def die(message): + emit({"ok": False, "error": str(message)}) + raise SystemExit(0) + + +def now(): + return int(time.time()) + + +# ─── HTTP helpers ────────────────────────────────────────────────────────── + +def http_post(url, body, content_type, extra_headers=None): + headers = { + "Accept": "application/json", + "Content-Type": content_type, + "User-Agent": USER_AGENT, + } + if extra_headers: + headers.update(extra_headers) + req = urllib.request.Request(url, data=body, method="POST", headers=headers) + try: + with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S) as response: + raw = response.read().decode("utf-8", "replace") + return response.status, parse_json(raw) + except urllib.error.HTTPError as exc: + raw = exc.read().decode("utf-8", "replace") + return exc.code, parse_json(raw) + except Exception as exc: + return 0, {"error": "request_failed", "error_description": str(exc)} + + +def parse_json(raw): + try: + value = json.loads(raw) if raw.strip() else {} + return value if isinstance(value, dict) else {} + except Exception: + return {"error": "invalid_json", "error_description": raw[:500]} + + +def post_form(url, fields, extra_headers=None): + return http_post( + url, + urllib.parse.urlencode(fields).encode("utf-8"), + "application/x-www-form-urlencoded", + extra_headers, + ) + + +def post_json(url, payload, extra_headers=None): + return http_post( + url, + json.dumps(payload, separators=(",", ":")).encode("utf-8"), + "application/json", + extra_headers, + ) + + +def error_text(status, data): + return ( + data.get("error_description") + or data.get("error") + or ("network request failed" if status == 0 else f"HTTP {status}") + ) + + +# ─── on-disk token file ──────────────────────────────────────────────────── + +def token_path(ctx): + return os.path.abspath(str(ctx.get("token_path") or "gemini-cli.json")) + + +def read_token(path): + try: + with open(path, encoding="utf-8") as handle: + value = json.load(handle) + return value if isinstance(value, dict) else None + except (OSError, ValueError, TypeError): + return None + + +def atomic_write(path, value): + path = os.path.abspath(path) + parent = os.path.dirname(path) or "." + os.makedirs(parent, mode=0o700, exist_ok=True) + fd, tmp = tempfile.mkstemp(prefix=".gemini-cli-oauth-", dir=parent) + try: + try: + os.fchmod(fd, 0o600) + except AttributeError: + pass + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(value, handle, separators=(",", ":")) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp, path) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise + + +def lock_for(path): + try: + import fcntl + except ImportError: + return None + handle = open(path + ".lock", "a+", encoding="utf-8") + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + except OSError: + pass + return handle + + +def unlock(handle): + if handle is not None: + try: + handle.close() + except OSError: + pass + + +# ─── PKCE + auth URL ─────────────────────────────────────────────────────── + +def make_pkce(): + """Generate (verifier, challenge, state) for S256 PKCE.""" + verifier = base64.urlsafe_b64encode(secrets.token_bytes(48)).rstrip(b"=").decode("ascii") + challenge = base64.urlsafe_b64encode( + hashlib.sha256(verifier.encode("ascii")).digest() + ).rstrip(b"=").decode("ascii") + state = base64.urlsafe_b64encode(secrets.token_bytes(24)).rstrip(b"=").decode("ascii") + return verifier, challenge, state + + +def build_authorize_url(redirect_uri, state, challenge): + params = { + "client_id": CLIENT_ID, + "response_type": "code", + "redirect_uri": redirect_uri, + "scope": " ".join(SCOPES), + "state": state, + "code_challenge": challenge, + "code_challenge_method": "S256", + "access_type": "offline", + "prompt": "consent", + "include_granted_scopes": "true", + } + return AUTH_URL + "?" + urllib.parse.urlencode(params) + + +# ─── token exchange ──────────────────────────────────────────────────────── + +def exchange_code(code, redirect_uri, verifier): + status, data = post_form( + TOKEN_URL, + { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "code_verifier": verifier, + }, + ) + return status, data + + +def refresh_access_token(refresh_token): + status, data = post_form( + TOKEN_URL, + { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + }, + ) + return status, data + + +def fetch_user_email(access_token): + req = urllib.request.Request( + USERINFO_URL, + headers={"Authorization": f"Bearer {access_token}", "User-Agent": USER_AGENT}, + ) + try: + with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S) as response: + data = parse_json(response.read().decode("utf-8", "replace")) + return data.get("email") or "" + except Exception: + return "" + + +def normalize_tokens(tokens): + """Coerce the raw OAuth response into the persistent shape on disk.""" + access = tokens.get("access_token") or "" + refresh = tokens.get("refresh_token") or "" + if not access and not refresh: + return None + expires_in = int(tokens.get("expires_in") or 0) + return { + "access_token": access, + "refresh_token": refresh, + "expires_in": expires_in, + "expires_at": now() + max(expires_in, 60), + "scope": tokens.get("scope", ""), + "token_type": tokens.get("token_type", "Bearer"), + } + + +# ─── Code Assist: loadCodeAssist + onboardUser ───────────────────────────── + +def _code_assist_headers(access_token): + return { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + "User-Agent": USER_AGENT, + "X-Goog-Api-Client": X_GOOG_API_CLIENT, + "Client-Metadata": json.dumps(CLIENT_METADATA, separators=(",", ":")), + } + + +def _code_assist_body(include_tier=False, tier_id=None): + body = {"metadata": dict(CLIENT_METADATA)} + if include_tier: + body["tierId"] = tier_id or "legacy-tier" + return body + + +def _extract_project(payload): + """Pull ``cloudaicompanionProject`` out of a loadCodeAssist / onboardUser response.""" + project = payload.get("cloudaicompanionProject") + if isinstance(project, str) and project.strip(): + return project.strip() + if isinstance(project, dict): + nested = project.get("id") + if isinstance(nested, str) and nested.strip(): + return nested.strip() + nested = (payload.get("response") or {}).get("cloudaicompanionProject") + if isinstance(nested, str) and nested.strip(): + return nested.strip() + if isinstance(nested, dict): + id_ = nested.get("id") + if isinstance(id_, str) and id_.strip(): + return id_.strip() + return None + + +def _pick_default_tier(payload): + tiers = payload.get("allowedTiers") + if isinstance(tiers, list): + for tier in tiers: + if isinstance(tier, dict) and tier.get("isDefault") is True: + tid = tier.get("id") + if isinstance(tid, str) and tid.strip(): + return tid.strip() + return "legacy-tier" + + +def load_code_assist_payload(access_token): + """POST :loadCodeAssist and return the raw payload (or ``None`` on failure).""" + status, data = post_json( + LOAD_CODE_ASSIST_URL, + _code_assist_body(), + _code_assist_headers(access_token), + ) + return data if status == 200 else None + + +def onboard_user(access_token, tier_id): + """POST :onboardUser, polling until ``done=true``; return project_id.""" + for attempt in range(1, ONBOARD_MAX_ATTEMPTS + 1): + status, data = post_json( + ONBOARD_USER_URL, + _code_assist_body(include_tier=True, tier_id=tier_id), + _code_assist_headers(access_token), + ) + if status != 200: + return None + if data.get("done") is True: + return _extract_project(data) + if attempt < ONBOARD_MAX_ATTEMPTS: + time.sleep(ONBOARD_POLL_S) + return None + + +def discover_project_id(access_token): + """Try loadCodeAssist; on failure, fall back to onboardUser polling.""" + payload = load_code_assist_payload(access_token) + if payload is not None: + project = _extract_project(payload) + if project: + return project + tier = _pick_default_tier(payload) + return onboard_user(access_token, tier) + return None + + +# ─── actions ─────────────────────────────────────────────────────────────── + +def do_login(ctx): + """Build the authorize URL. The harness binds the loopback + opens the + browser; we receive the ``code`` back in ``complete``.""" + verifier, challenge, state = make_pkce() + redirect_uri = str(ctx.get("redirect_uri") or "").strip() + if not redirect_uri: + die("Gemini CLI OAuth requires a loopback redirect_uri (catcode binds this)") + + url = build_authorize_url(redirect_uri, state, challenge) + emit( + { + "url": url, + "flow": "web", + "state": state, + "pending": {"verifier": verifier}, + "message": ( + "Open the URL to authorize Gemini CLI. The token is then " + "auto-discovered via loadCodeAssist and a real Cloud project " + "is provisioned if needed." + ), + } + ) + + +def do_complete(ctx): + code = str(ctx.get("code") or "").strip() + if not code: + die("no authorization code received from Gemini CLI") + pending = ctx.get("pending") or {} + verifier = str(pending.get("verifier") or "").strip() + if not verifier: + die("missing PKCE verifier in pending state; restart /login gemini-cli") + redirect_uri = str(ctx.get("redirect_uri") or "").strip() + if not redirect_uri: + die("missing redirect_uri in complete context; restart /login gemini-cli") + + status, tokens = exchange_code(code, redirect_uri, verifier) + if status != 200 or not tokens.get("access_token"): + die("Gemini CLI token exchange failed: " + error_text(status, tokens)) + + normalized = normalize_tokens(tokens) + if not normalized: + die("Gemini CLI token exchange returned no usable tokens") + + project_id = discover_project_id(normalized["access_token"]) + if project_id: + normalized["project_id"] = project_id + + email = fetch_user_email(normalized["access_token"]) + if email: + normalized["email"] = email + + atomic_write(token_path(ctx), normalized) + emit({"ok": True}) + + +def do_token(ctx): + path = token_path(ctx) + handle = lock_for(path) + try: + token = read_token(path) + if not token: + emit({"access_token": None}) + return + + current = now() + expires_at = int(token.get("expires_at") or 0) + needs_refresh = (not token.get("access_token")) or ( + expires_at > 0 and expires_at - current <= REFRESH_LEAD_S + ) + + if needs_refresh and token.get("refresh_token"): + # Another harness process may have refreshed while we waited for + # the flock; always re-read before making a network request. + current_token = read_token(path) or token + current_exp = int(current_token.get("expires_at") or 0) + if current_token.get("access_token") and ( + current_exp == 0 or current_exp - now() > REFRESH_LEAD_S + ): + token = current_token + else: + status, data = refresh_access_token(current_token["refresh_token"]) + if status != 200 or not data.get("access_token"): + emit({"access_token": None}) + return + rotated = normalize_tokens( + { + "access_token": data.get("access_token", ""), + "refresh_token": data.get("refresh_token") + or current_token.get("refresh_token", ""), + "expires_in": int(data.get("expires_in") or 0), + "scope": data.get("scope", current_token.get("scope", "")), + "token_type": data.get("token_type", "Bearer"), + } + ) + if not rotated: + emit({"access_token": None}) + return + # Preserve project_id + email across rotations — those were + # discovered once and remain valid for the lifetime of the + # OAuth grant. + rotated["project_id"] = current_token.get("project_id", "") + rotated["email"] = current_token.get("email", "") + atomic_write(path, rotated) + token = rotated + + access = token.get("access_token") or "" + if not access: + emit({"access_token": None}) + return + + headers = [] + project_id = str(token.get("project_id") or "").strip() + if project_id: + # The harness's Google Code Assist adapter resolves the project + # via this header (one of three accepted names). The plugin sets + # the real per-user project so requests don't fall back to the + # shared freemium project that the adapter ships as a default. + headers.append(["x-goog-user-project", project_id]) + emit( + { + "access_token": access, + "expires_at": int(token.get("expires_at") or 0), + "headers": headers, + } + ) + finally: + unlock(handle) + + +def do_clear(ctx): + path = token_path(ctx) + for candidate in (path, path + ".lock"): + try: + os.remove(candidate) + except OSError: + pass + emit({"ok": True}) + + +def main(): + try: + raw = sys.stdin.read() + ctx = json.loads(raw) if raw.strip() else {} + if not isinstance(ctx, dict): + die("OAuth context must be a JSON object") + action = ctx.get("action", "") + if action == "login": + do_login(ctx) + elif action == "complete": + do_complete(ctx) + elif action == "token": + do_token(ctx) + elif action == "clear": + do_clear(ctx) + else: + die("unknown action: %r" % action) + except SystemExit: + raise + except Exception as exc: + die("Gemini CLI OAuth provider error: " + str(exc)) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/core/providers/gemini-cli/plugin.json b/core/providers/gemini-cli/plugin.json new file mode 100644 index 0000000..7b353bc --- /dev/null +++ b/core/providers/gemini-cli/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "gemini-cli", + "version": "0.1.0", + "description": "Google Gemini CLI subscription access — OAuth + Code Assist project discovery. Auto-staged into every install.", + "oauth": { + "provider_id": "gemini-cli", + "label": "Gemini CLI (Google)", + "kind": "openai", + "base_url": "https://cloudcode-pa.googleapis.com/v1internal", + "description": "Gemini CLI / Code Assist OAuth — Authorization Code + PKCE. After login the harness calls :loadCodeAssist (with the gemini-cli fingerprint) to provision a cloudaicompanionProject, then sends chat through the prod Code Assist gateway.", + "headers": [ + ["User-Agent", "google-api-nodejs-client/9.15.1"] + ], + "token_path": "gemini-cli.json", + "script": "oauth/gemini-cli-oauth.py", + "login_timeout_ms": 300000, + "token_timeout_ms": 30000 + } +} \ No newline at end of file diff --git a/core/src/staging.rs b/core/src/staging.rs index 6a7e948..3eb10ad 100644 --- a/core/src/staging.rs +++ b/core/src/staging.rs @@ -27,7 +27,7 @@ use std::path::PathBuf; /// Bump when the bundled default set changes meaningfully. The marker file /// stores this; on a version mismatch we re-scan for *missing* files (existing /// user files are still never overwritten) and then re-stamp the marker. -pub const STAGING_VERSION: u32 = 6; +pub const STAGING_VERSION: u32 = 7; /// `~/.catalyst-code` — the global, user-owned home for harness defaults. /// All staged files live under here (agents/, skills/, plugins/, README.md). @@ -262,6 +262,34 @@ fn bundled_files() -> Vec<(&'static str, &'static str)> { "plugins/deepseek/README.md", include_str!("../providers/deepseek/README.md"), ), + // --- antigravity provider (Google Antigravity IDE subscription — + // OAuth + Code Assist loadCodeAssist project discovery). --- + ( + "plugins/antigravity/plugin.json", + include_str!("../providers/antigravity/plugin.json"), + ), + ( + "plugins/antigravity/oauth/antigravity-oauth.py", + include_str!("../providers/antigravity/oauth/antigravity-oauth.py"), + ), + ( + "plugins/antigravity/README.md", + include_str!("../providers/antigravity/README.md"), + ), + // --- gemini-cli provider (Google Gemini CLI subscription — + // OAuth + Code Assist loadCodeAssist project discovery). --- + ( + "plugins/gemini-cli/plugin.json", + include_str!("../providers/gemini-cli/plugin.json"), + ), + ( + "plugins/gemini-cli/oauth/gemini-cli-oauth.py", + include_str!("../providers/gemini-cli/oauth/gemini-cli-oauth.py"), + ), + ( + "plugins/gemini-cli/README.md", + include_str!("../providers/gemini-cli/README.md"), + ), // --- A short guide to the global layout + override model. --- ("README.md", GLOBAL_README), ] @@ -274,6 +302,8 @@ fn executable_rel_paths() -> &'static [&'static str] { "plugins/telemetry/hooks/session_stop.py", "plugins/kimi/oauth/kimi-oauth.py", "plugins/codex/oauth/codex-oauth.py", + "plugins/antigravity/oauth/antigravity-oauth.py", + "plugins/gemini-cli/oauth/gemini-cli-oauth.py", ] } @@ -369,7 +399,9 @@ project. │ ├── vision-handoff/ # cheapest same-provider vision handoff (default ON) │ ├── kimi/ # Moonshot subscription OAuth provider │ ├── codex/ # ChatGPT subscription OAuth provider - │ └── deepseek/ # DeepSeek API-key provider + │ ├── deepseek/ # DeepSeek API-key provider + │ ├── antigravity/ # Google Antigravity IDE OAuth + Code Assist + │ └── gemini-cli/ # Google Gemini CLI OAuth + Code Assist ├── README.md # this file └── .staged # staging schema version marker (do not edit) @@ -455,6 +487,34 @@ mod tests { home.join("plugins/deepseek/README.md").exists(), "deepseek provider README should be staged on first run" ); + assert!( + home.join("plugins/antigravity/plugin.json").exists(), + "antigravity provider should be staged on first run" + ); + assert!( + home + .join("plugins/antigravity/oauth/antigravity-oauth.py") + .exists(), + "antigravity oauth script should be staged on first run" + ); + assert!( + home.join("plugins/antigravity/README.md").exists(), + "antigravity provider README should be staged on first run" + ); + assert!( + home.join("plugins/gemini-cli/plugin.json").exists(), + "gemini-cli provider should be staged on first run" + ); + assert!( + home + .join("plugins/gemini-cli/oauth/gemini-cli-oauth.py") + .exists(), + "gemini-cli oauth script should be staged on first run" + ); + assert!( + home.join("plugins/gemini-cli/README.md").exists(), + "gemini-cli provider README should be staged on first run" + ); assert!(home.join(".staged").exists()); assert_eq!( std::fs::read_to_string(home.join(".staged")).unwrap(), @@ -515,6 +575,26 @@ mod tests { .permissions() .mode(); assert!(mode & 0o111 != 0, "codex oauth script must be executable"); + let mode = std::fs::metadata( + home.join("plugins/antigravity/oauth/antigravity-oauth.py"), + ) + .unwrap() + .permissions() + .mode(); + assert!( + mode & 0o111 != 0, + "antigravity oauth script must be executable" + ); + let mode = std::fs::metadata( + home.join("plugins/gemini-cli/oauth/gemini-cli-oauth.py"), + ) + .unwrap() + .permissions() + .mode(); + assert!( + mode & 0o111 != 0, + "gemini-cli oauth script must be executable" + ); } } From 27b8440c0000c600089346b320a7469ecd84ac9b Mon Sep 17 00:00:00 2001 From: phantomic12 Date: Fri, 7 Aug 2026 02:45:34 -0400 Subject: [PATCH 19/38] feat(antigravity): allow CATALYST_CODE_ANTIGRAVITY_PROJECT override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the auto-provisioned Antigravity project (e.g. the Google-managed synthetic-expanse-sxhhm for free-tier individuals) is unusable — the user is not a member/owner so they can't enable Cloud Code Private API on it — this lets them pin the project to one they do own. Useful escape hatch while we wait for either: * Google to auto-enable Cloud Code Private API on the Antigravity- managed project (some users report a ~24h delay after first OAuth). * The user to find another way to enable the API. Tested end-to-end: env override is respected on both the loadCodeAssist->onboardUser bootstrap AND on the project_id persisted in the token file (so the /token action's x-goog-user-project header also points at the override). --- .../antigravity/oauth/antigravity-oauth.py | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/core/providers/antigravity/oauth/antigravity-oauth.py b/core/providers/antigravity/oauth/antigravity-oauth.py index 866a0fa..2227ca3 100755 --- a/core/providers/antigravity/oauth/antigravity-oauth.py +++ b/core/providers/antigravity/oauth/antigravity-oauth.py @@ -47,8 +47,20 @@ # Scopes the Antigravity IDE requests. ``cclog`` + ``experimentsandconfigs`` # are Antigravity-specific and are required for Code Assist provisioning. +# Google OAuth requires ``/oauth2callback`` (not arbitrary paths) for the +# Antigravity OAuth client — only this path is registered as a loopback +# redirect URI for ``http://127.0.0.1:`` in the client's Google Cloud +# console entry. Using ``/callback`` makes Google reject the request as a +# non-compliant redirect URI ("doesn't comply with Google's OAuth 2.0 +# policy for keeping apps secure"). We mirror the path the Antigravity IDE +# binary uses. +REDIRECT_PATH = "/oauth2callback" + +# Scopes the Antigravity IDE requests. ``openid`` is intentionally omitted +# (same reason as gemini-cli — it triggers Google's unverified-app gate on +# ``cclog`` / ``experimentsandconfigs`` requests). ``userinfo.email`` + +# ``userinfo.profile`` are sufficient for the loadCodeAssist user lookup. SCOPES = [ - "openid", "https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile", @@ -226,6 +238,10 @@ def make_pkce(): def build_authorize_url(redirect_uri, state, challenge, extra=None): + # The Antigravity IDE binary does not include ``prompt=consent`` or + # ``include_granted_scopes=true``; including either can confuse Google's + # refresh-token issuance for the Antigravity OAuth client. Keep the + # request minimal: redirect + scope + PKCE + state + offline. params = { "client_id": CLIENT_ID, "response_type": "code", @@ -235,8 +251,6 @@ def build_authorize_url(redirect_uri, state, challenge, extra=None): "code_challenge": challenge, "code_challenge_method": "S256", "access_type": "offline", - "prompt": "consent", - "include_granted_scopes": "true", } if extra: params.update(extra) @@ -376,7 +390,18 @@ def onboard_user(access_token, tier_id): def discover_project_id(access_token): - """Try loadCodeAssist; on failure, fall back to onboardUser polling.""" + """Try loadCodeAssist; on failure, fall back to onboardUser polling. + + If ``CATALYST_CODE_ANTIGRAVITY_PROJECT`` is set in the environment, it + wins over whatever Google provisioned — the auto-provisioned project + is often a Google-managed one the user is not an owner of, so they + cannot enable Cloud Code Private API on it from the Cloud Console. + Pinning a project the user owns + can enable APIs on is the only way + to unblock chat in that situation. + """ + override = os.environ.get(ANTIGRAVITY_PROJECT_ENV, "").strip() + if override: + return override # We need the loadCodeAssist payload too (for the tier), so re-call. status, data = post_json( LOAD_CODE_ASSIST_URL, @@ -445,6 +470,12 @@ def do_complete(ctx): project_id = discover_project_id(normalized["access_token"]) if project_id: normalized["project_id"] = project_id + # Re-check the env override after discover_project_id — if set, the + # auto-provisioned project would otherwise be persisted and chat would + # be stuck on SERVICE_DISABLED. + override = os.environ.get(ANTIGRAVITY_PROJECT_ENV, "").strip() + if override: + normalized["project_id"] = override email = fetch_user_email(normalized["access_token"]) if email: From d013dac43c5528b5e06f6284bcf0f90a782c9242 Mon Sep 17 00:00:00 2001 From: phantomic12 Date: Fri, 7 Aug 2026 15:27:58 -0400 Subject: [PATCH 20/38] fix(providers): gemini-cli works without x-goog-user-project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the "SERVICE_DISABLED" 403s: the OAuth plugins were emitting `x-goog-user-project`, a Google consumer-project header that forces a Cloud Code Private API enablement check. Free-tier / managed projects fail that check even when body.project alone is accepted. 9router's gemini-cli executor never sends that header — project goes in the JSON body only. Verified end-to-end against the live gateway: gemini-2.5-flash / pro / flash-lite / 3.1-flash-lite-preview → 200 with body.project=synthetic-expanse-sxhhm and NO x-goog-user-project. Changes: - Both antigravity + gemini-cli token actions now emit `x-code-assist-project` (harness adapter still resolves body.project from it; does not trip the consumer gate). - gemini-cli loadCodeAssist uses Antigravity-style ClientMetadata (ideType=9, pluginType=2) + mode=1, matching 9router. - gemini-cli project discovery falls back to CATALYST_CODE_GEMINI_CLI_PROJECT or the sibling Antigravity token's project_id when free-tier returns UNSUPPORTED_CLIENT. - README documents working models + the header gotcha. --- .../antigravity/oauth/antigravity-oauth.py | 12 ++- core/providers/gemini-cli/README.md | 51 ++++++++- .../gemini-cli/oauth/gemini-cli-oauth.py | 100 +++++++++++++++--- 3 files changed, 140 insertions(+), 23 deletions(-) diff --git a/core/providers/antigravity/oauth/antigravity-oauth.py b/core/providers/antigravity/oauth/antigravity-oauth.py index 2227ca3..5291fc4 100755 --- a/core/providers/antigravity/oauth/antigravity-oauth.py +++ b/core/providers/antigravity/oauth/antigravity-oauth.py @@ -543,11 +543,13 @@ def do_token(ctx): headers = [] project_id = str(token.get("project_id") or "").strip() if project_id: - # The harness's Google Code Assist adapter resolves the project - # via this header (one of three accepted names). The plugin sets - # the real per-user project so requests don't fall back to the - # shared freemium project that the adapter ships as a default. - headers.append(["x-goog-user-project", project_id]) + # CRITICAL: do NOT use x-goog-user-project. That Google consumer + # header forces a Cloud Code Private API enablement check and + # returns SERVICE_DISABLED on free-tier / managed projects. + # Put the project in the request body only (via the harness + # adapter reading x-code-assist-project). Verified: body.project + # alone works for gemini-cli; x-goog-user-project → 403. + headers.append(["x-code-assist-project", project_id]) emit( { "access_token": access, diff --git a/core/providers/gemini-cli/README.md b/core/providers/gemini-cli/README.md index 71809b4..c383eef 100644 --- a/core/providers/gemini-cli/README.md +++ b/core/providers/gemini-cli/README.md @@ -102,4 +102,53 @@ The `project` field comes from the harness's `x-goog-user-project` header - Gemini CLI source fingerprint: captured from a live `@google-gemini/gemini-cli` install. - Code Assist wire format + project discovery: Google's open-source - Gemini CLI source. \ No newline at end of file + Gemini CLI source. + +## Working models (verified 2026-08) + +With a free-tier Google account the gemini-cli OAuth client is marked +`UNSUPPORTED_CLIENT` for free-tier project *provisioning*, but chat still +works when `body.project` is set to a managed project the same account +already owns (e.g. one provisioned by the sibling Antigravity login). Do +**not** send `x-goog-user-project` — that header forces a Cloud Code +Private API consumer check and returns `SERVICE_DISABLED`. The plugin +emits `x-code-assist-project` instead so the harness adapter only puts +the id into `body.project`. + +Verified working (HTTP 200, real text): + +```text +gemini-2.5-pro +gemini-2.5-flash +gemini-2.5-flash-lite +gemini-3.1-flash-lite-preview +``` + +404 / not available on free-tier gemini-cli: + +```text +gemini-3-pro-preview +gemini-3-flash-preview +gemini-3.1-pro-preview +gemini-3.1-pro-high # Antigravity-only slug +claude-* # Antigravity-only +``` + +## Gotchas + +1. **Never send `x-goog-user-project`.** It is a Google consumer-project + header and trips `SERVICE_DISABLED` on free-tier managed projects. + Project goes in the JSON body only (`{"project": "...", "model": "...", + "request": {...}}`). The plugin uses `x-code-assist-project` which the + harness adapter translates into `body.project` without the consumer + gate. +2. **User-Agent for chat** should look like the official CLI: + `GeminiCLI/0.34.0/ (linux; x64; terminal)` plus + `X-Goog-Api-Client: google-genai-sdk/1.41.0 gl-node/v22.19.0`. The + harness currently leaves User-Agent as whatever `plugin.json` sets; + body-level `userAgent: "antigravity"` (set by the shared adapter) is + tolerated by the gateway. +3. **Project discovery** may return nothing for free-tier gemini-cli + accounts. The script then falls back to + `CATALYST_CODE_GEMINI_CLI_PROJECT` or the sibling Antigravity token's + `project_id` under `~/.config/catalyst-code/oauth/antigravity.json`. diff --git a/core/providers/gemini-cli/oauth/gemini-cli-oauth.py b/core/providers/gemini-cli/oauth/gemini-cli-oauth.py index 5496b2d..9352a12 100755 --- a/core/providers/gemini-cli/oauth/gemini-cli-oauth.py +++ b/core/providers/gemini-cli/oauth/gemini-cli-oauth.py @@ -43,12 +43,21 @@ TOKEN_URL = "https://oauth2.googleapis.com/token" USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo" -# Gemini CLI's standard scope list. ``cclog`` and -# ``experimentsandconfigs`` are Antigravity-specific and intentionally -# excluded here — including them with the wrong client id would silently -# drop the Antigravity-only scopes on Google's side. +# Google OAuth requires ``/oauth2callback`` (not arbitrary paths) for the +# gemini-cli OAuth client — only this path is registered as a loopback +# redirect URI for ``http://127.0.0.1:`` in the client's Google Cloud +# console entry. Using ``/callback`` makes Google reject the request as a +# non-compliant redirect URI ("doesn't comply with Google's OAuth 2.0 +# policy for keeping apps secure"). The official gemini-cli binary uses +# this exact path; we mirror it. +REDIRECT_PATH = "/oauth2callback" + +# Scopes the official ``gemini`` CLI requests (no ``openid``). Including +# ``openid`` triggers Google's "unverified app" rejection for this +# public-but-unverified OAuth client — the gemini-cli project deliberately +# omits it. ``userinfo.email`` + ``userinfo.profile`` alone are sufficient +# for the loadCodeAssist user-info lookup. SCOPES = [ - "openid", "https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile", @@ -63,7 +72,24 @@ X_GOOG_API_CLIENT = "google-cloud-sdk vscode_cloudshelleditor/0.1" # Numeric enum values that match what gemini-cli actually sends. These are # not the same as Antigravity (different ideType/pluginType). -CLIENT_METADATA = {"ideType": 0, "platform": 0, "pluginType": 0} +# 9router's gemini-cli path uses Antigravity-style ClientMetadata on +# loadCodeAssist (ideType=9 / pluginType=2). Using the zeroed "unspecified" +# values makes Google refuse to provision a cloudaicompanionProject for +# free-tier individuals (UNSUPPORTED_CLIENT on free-tier). +def _platform_enum(): + import platform as _plat + s = _plat.system().lower() + a = _plat.machine().lower() + if s == "darwin": + return 2 if "arm64" in a or "aarch64" in a else 1 + if s == "linux": + return 4 if "arm64" in a or "aarch64" in a else 3 + if s == "windows" or s == "win32": + return 5 + return 0 + + +CLIENT_METADATA = {"ideType": 9, "platform": _platform_enum(), "pluginType": 2} LOAD_CODE_ASSIST_URL = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" ONBOARD_USER_URL = "https://cloudcode-pa.googleapis.com/v1internal:onboardUser" @@ -218,6 +244,11 @@ def make_pkce(): def build_authorize_url(redirect_uri, state, challenge): + # The official ``gemini`` CLI does NOT send ``prompt=consent`` or + # ``include_granted_scopes=true``; including them can confuse Google's + # refresh-token issuance logic for the public-but-unverified gemini-cli + # OAuth client. Keep the request minimal: redirect + scope + PKCE + + # state + offline access_type (required for a refresh_token). params = { "client_id": CLIENT_ID, "response_type": "code", @@ -227,8 +258,6 @@ def build_authorize_url(redirect_uri, state, challenge): "code_challenge": challenge, "code_challenge_method": "S256", "access_type": "offline", - "prompt": "consent", - "include_granted_scopes": "true", } return AUTH_URL + "?" + urllib.parse.urlencode(params) @@ -305,8 +334,10 @@ def _code_assist_headers(access_token): } -def _code_assist_body(include_tier=False, tier_id=None): - body = {"metadata": dict(CLIENT_METADATA)} +def _code_assist_body(include_tier=False, tier_id=None, mode=1): + # mode=1 is the Code Assist mode 9router always sends; without it the + # free-tier gemini-cli OAuth client often gets no project back. + body = {"metadata": dict(CLIENT_METADATA), "mode": mode} if include_tier: body["tierId"] = tier_id or "legacy-tier" return body @@ -370,14 +401,44 @@ def onboard_user(access_token, tier_id): def discover_project_id(access_token): - """Try loadCodeAssist; on failure, fall back to onboardUser polling.""" + """Try loadCodeAssist; on failure, fall back to onboardUser polling. + + Free-tier gemini-cli OAuth often returns no project (Google now marks + free-tier as UNSUPPORTED_CLIENT for this OAuth client). Fallbacks, in + order: + 1. ``CATALYST_CODE_GEMINI_CLI_PROJECT`` env override. + 2. Sibling Antigravity token file's ``project_id`` (same Google + account often already has a working managed project via the + Antigravity OAuth flow — verified: body.project alone works). + """ + override = (os.environ.get("CATALYST_CODE_GEMINI_CLI_PROJECT") or "").strip() + if override: + return override payload = load_code_assist_payload(access_token) if payload is not None: project = _extract_project(payload) if project: return project tier = _pick_default_tier(payload) - return onboard_user(access_token, tier) + project = onboard_user(access_token, tier) + if project: + return project + # Sibling Antigravity token (same user, different OAuth client) often + # already holds a working managed project. Read it if present. + try: + sibling = os.path.join(os.path.dirname(os.path.abspath( + # token_path is not in scope here; reconstruct from common layout. + os.path.expanduser("~/.config/catalyst-code/oauth/antigravity.json") + )), "antigravity.json") if False else os.path.expanduser( + "~/.config/catalyst-code/oauth/antigravity.json" + ) + sib = read_token(sibling) + if sib: + pid = str(sib.get("project_id") or "").strip() + if pid: + return pid + except Exception: + pass return None @@ -497,11 +558,16 @@ def do_token(ctx): headers = [] project_id = str(token.get("project_id") or "").strip() if project_id: - # The harness's Google Code Assist adapter resolves the project - # via this header (one of three accepted names). The plugin sets - # the real per-user project so requests don't fall back to the - # shared freemium project that the adapter ships as a default. - headers.append(["x-goog-user-project", project_id]) + # CRITICAL: do NOT use x-goog-user-project. That Google consumer + # header forces a Cloud Code Private API enablement check and + # returns SERVICE_DISABLED on free-tier / managed projects. + # 9router's gemini-cli executor never sends it — project goes in + # the request body only. The harness adapter also accepts + # x-code-assist-project / cloudaicompanion-project, which only + # affect body.project resolution and do not trip the consumer + # API gate. Verified end-to-end: body.project alone works; + # x-goog-user-project → 403 SERVICE_DISABLED. + headers.append(["x-code-assist-project", project_id]) emit( { "access_token": access, From 5588c56285d8ccb92f6283df4f94a18b8d9d90e2 Mon Sep 17 00:00:00 2001 From: phantomic12 Date: Fri, 7 Aug 2026 15:45:08 -0400 Subject: [PATCH 21/38] style: rustfmt staging.rs test blocks for new providers --- core/src/staging.rs | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/core/src/staging.rs b/core/src/staging.rs index 3eb10ad..e475c49 100644 --- a/core/src/staging.rs +++ b/core/src/staging.rs @@ -492,8 +492,7 @@ mod tests { "antigravity provider should be staged on first run" ); assert!( - home - .join("plugins/antigravity/oauth/antigravity-oauth.py") + home.join("plugins/antigravity/oauth/antigravity-oauth.py") .exists(), "antigravity oauth script should be staged on first run" ); @@ -506,8 +505,7 @@ mod tests { "gemini-cli provider should be staged on first run" ); assert!( - home - .join("plugins/gemini-cli/oauth/gemini-cli-oauth.py") + home.join("plugins/gemini-cli/oauth/gemini-cli-oauth.py") .exists(), "gemini-cli oauth script should be staged on first run" ); @@ -575,22 +573,19 @@ mod tests { .permissions() .mode(); assert!(mode & 0o111 != 0, "codex oauth script must be executable"); - let mode = std::fs::metadata( - home.join("plugins/antigravity/oauth/antigravity-oauth.py"), - ) - .unwrap() - .permissions() - .mode(); + let mode = + std::fs::metadata(home.join("plugins/antigravity/oauth/antigravity-oauth.py")) + .unwrap() + .permissions() + .mode(); assert!( mode & 0o111 != 0, "antigravity oauth script must be executable" ); - let mode = std::fs::metadata( - home.join("plugins/gemini-cli/oauth/gemini-cli-oauth.py"), - ) - .unwrap() - .permissions() - .mode(); + let mode = std::fs::metadata(home.join("plugins/gemini-cli/oauth/gemini-cli-oauth.py")) + .unwrap() + .permissions() + .mode(); assert!( mode & 0o111 != 0, "gemini-cli oauth script must be executable" From 720fa197ff04d2187c3719297bdc0fdb9cd8eff8 Mon Sep 17 00:00:00 2001 From: phantomic12 Date: Fri, 7 Aug 2026 16:15:30 -0400 Subject: [PATCH 22/38] =?UTF-8?q?fix(providers):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20missing=20constant,=20redirect=20path,=20env=20pass?= =?UTF-8?q?through?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit karutoil on PR #7 caught five real bugs in the first round; all fixed here plus a wire-shape lock-in test so future changes can't regress the contract. ### Fixes 1. **Missing ANTIGRAVITY_PROJECT_ENV constant** in antigravity-oauth.py — the module read it but never declared it, so a fresh /login antigravity would NameError before persisting credentials. Constant added next to USER_AGENT. 2. **Harness redirected to /callback but plugins expect /oauth2callback.** Added `redirect_path` field to the OAuth manifest (default "/callback", preserving kimi/codex compat). plugins.rs now uses cfg.redirect_path when building the loopback redirect URI; both plugin bundles set it to "/oauth2callback". kimi + codex unchanged. 3. **Env passthrough scrubbed.** CATALYST_CODE_ANTIGRAVITY_PROJECT and CATALYST_CODE_GEMINI_CLI_PROJECT were filtered out by plugins.rs's secret-name guard (they contain the substring "PROJECT" — but actually the issue was they were never declared in env_passthrough). Both plugin.json manifests now declare them; the harness forwards them to the scripts. 4. **Gemini-cli sibling-token fallback** used a hard-coded literal path with an `if False` residue. Now reads from CATALYST_CODE_OAUTH_DIR first, then falls back to ~/.config/catalyst-code/oauth/antigravity.json. 5. **Dead code in antigravity-oauth.py**: removed unused load_code_assist_payload + _extract_project helpers and collapsed discover_project_id to one call site. Removed the `if False` in gemini-cli-oauth.py. ### Doc fixes - Both provider READMEs and core/providers/README.md claimed the plugins injected x-goog-user-project. Updated to x-code-assist-project. - Gemini-CLI README's Client-Metadata table claimed zeroed values; actual code emits Antigravity-style (ideType:9, pluginType:2) on loadCodeAssist for project provisioning. - google_code_assist.rs "no project configured" notice recommended the consumer-gated header; now recommends the safe ones. ### Lock-in: wire-shape contract tests Added `wire_shape_contract` test module to google_code_assist.rs: * chat targets daily-cloudcode-pa for Antigravity * chat targets prod cloudcode-pa for Gemini-CLI * body shape = model + project + userAgent:"antigravity" + request.* * resolve_project header priority (first-match wins, case-insensitive) * freemium default notice emitted when no project header present These guard the exact wire shape verified live against the Google Code Assist gateway — without them a future refactor could silently break both OAuth plugins with HTTP 403. ### Test plan - cargo test focused (staging, plugins, oauth, google_code_assist, providers): 205 passed, 0 failed (was 199; +5 wire-shape + 1 freemium-notice already counted). - cargo fmt --check: clean. - cargo check: clean. --- core/providers/README.md | 2 +- core/providers/antigravity/README.md | 2 +- .../antigravity/oauth/antigravity-oauth.py | 49 +++-- core/providers/antigravity/plugin.json | 15 +- core/providers/gemini-cli/README.md | 2 +- .../gemini-cli/oauth/gemini-cli-oauth.py | 29 ++- core/providers/gemini-cli/plugin.json | 18 +- core/src/plugins.rs | 25 ++- core/src/providers/google_code_assist.rs | 201 +++++++++++++++++- 9 files changed, 298 insertions(+), 45 deletions(-) diff --git a/core/providers/README.md b/core/providers/README.md index 5d9afbc..a3abeee 100644 --- a/core/providers/README.md +++ b/core/providers/README.md @@ -61,6 +61,6 @@ source-of-truth embedded into the binary. Both Google bundles reuse the existing `core/src/providers/google_code_assist.rs` adapter — `is_code_assist_endpoint` already routes the `cloudcode-pa` / daily-cloudcode-pa hosts to the right wire format, and the adapter's -`resolve_project` reads the `x-goog-user-project` header that each plugin's +`resolve_project` reads the `x-code-assist-project` header that each plugin's `token` action injects to use the user's real Code Assist project instead of the freemium fallback. diff --git a/core/providers/antigravity/README.md b/core/providers/antigravity/README.md index 7cf12fc..23cbf0e 100644 --- a/core/providers/antigravity/README.md +++ b/core/providers/antigravity/README.md @@ -96,7 +96,7 @@ After OAuth + project discovery, every chat turn is a POST to } ``` -The `project` field comes from the harness's `x-goog-user-project` header +The `project` field comes from the harness's `x-code-assist-project` header (merged from the OAuth plugin's per-request headers); see `core/src/providers/google_code_assist.rs`. diff --git a/core/providers/antigravity/oauth/antigravity-oauth.py b/core/providers/antigravity/oauth/antigravity-oauth.py index 5291fc4..fd6fb62 100755 --- a/core/providers/antigravity/oauth/antigravity-oauth.py +++ b/core/providers/antigravity/oauth/antigravity-oauth.py @@ -68,6 +68,15 @@ "https://www.googleapis.com/auth/experimentsandconfigs", ] +# Env var that pins the Antigravity ``cloudaicompanionProject`` to a +# specific GCP project the user owns and can enable Cloud Code Private +# API on. Use this when the auto-provisioned project (e.g. +# ``synthetic-expanse-sxhhm``) is unusable — e.g. the user is not a +# member of the Google-managed project so they cannot enable the API +# from the Cloud Console. When unset, the script uses whatever +# ``loadCodeAssist`` / ``onboardUser`` returns. +ANTIGRAVITY_PROJECT_ENV = "CATALYST_CODE_ANTIGRAVITY_PROJECT" + # Antigravity IDE fingerprints (must match what the IDE actually sends — # Google's backend fingerprints these headers and silently refuses to # provision a project if they look wrong). @@ -334,6 +343,7 @@ def _code_assist_body(include_tier=False, tier_id=None): return body + def load_code_assist(access_token): """POST :loadCodeAssist, return ``cloudaicompanionProject`` id or ``None``.""" status, data = post_json( @@ -390,35 +400,34 @@ def onboard_user(access_token, tier_id): def discover_project_id(access_token): - """Try loadCodeAssist; on failure, fall back to onboardUser polling. - - If ``CATALYST_CODE_ANTIGRAVITY_PROJECT`` is set in the environment, it - wins over whatever Google provisioned — the auto-provisioned project - is often a Google-managed one the user is not an owner of, so they - cannot enable Cloud Code Private API on it from the Cloud Console. - Pinning a project the user owns + can enable APIs on is the only way - to unblock chat in that situation. + """Resolve the Antigravity ``cloudaicompanionProject``. + + Priority: + 1. ``CATALYST_CODE_ANTIGRAVITY_PROJECT`` env override (escape hatch + when the auto-provisioned project is a Google-managed one the + user does not own and so cannot enable Cloud Code Private API on). + 2. ``loadCodeAssist`` — returns the existing project if the user is + already onboarded, otherwise ``onboardUser`` polls until done. """ override = os.environ.get(ANTIGRAVITY_PROJECT_ENV, "").strip() if override: return override - # We need the loadCodeAssist payload too (for the tier), so re-call. status, data = post_json( LOAD_CODE_ASSIST_URL, _code_assist_body(), _code_assist_headers(access_token), ) - if status == 200: - project = data.get("cloudaicompanionProject") - if isinstance(project, str) and project.strip(): - return project.strip() - if isinstance(project, dict): - nested = project.get("id") - if isinstance(nested, str) and nested.strip(): - return nested.strip() - tier = _pick_default_tier(data) - return onboard_user(access_token, tier) - return None + if status != 200: + return None + project = data.get("cloudaicompanionProject") + if isinstance(project, str) and project.strip(): + return project.strip() + if isinstance(project, dict): + nested = project.get("id") + if isinstance(nested, str) and nested.strip(): + return nested.strip() + tier = _pick_default_tier(data) + return onboard_user(access_token, tier) # ─── actions ─────────────────────────────────────────────────────────────── diff --git a/core/providers/antigravity/plugin.json b/core/providers/antigravity/plugin.json index e8c0cb2..e3a4425 100644 --- a/core/providers/antigravity/plugin.json +++ b/core/providers/antigravity/plugin.json @@ -1,19 +1,26 @@ { "name": "antigravity", "version": "0.1.0", - "description": "Google Antigravity IDE subscription access — OAuth + Code Assist project discovery. Auto-staged into every install.", + "description": "Google Antigravity IDE subscription access \u2014 OAuth + Code Assist project discovery. Auto-staged into every install.", "oauth": { "provider_id": "antigravity", "label": "Antigravity (Google IDE)", "kind": "openai", "base_url": "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal", - "description": "Antigravity / Code Assist OAuth — Authorization Code + PKCE. After login the harness calls :loadCodeAssist (with the Antigravity IDE 2.1.1 fingerprint) to provision a cloudaicompanionProject, then sends chat through the daily Code Assist gateway.", + "description": "Antigravity / Code Assist OAuth \u2014 Authorization Code + PKCE. After login the harness calls :loadCodeAssist (with the Antigravity IDE 2.1.1 fingerprint) to provision a cloudaicompanionProject, then sends chat through the daily Code Assist gateway.", "headers": [ - ["User-Agent", "antigravity"] + [ + "User-Agent", + "antigravity" + ] ], "token_path": "antigravity.json", "script": "oauth/antigravity-oauth.py", "login_timeout_ms": 300000, - "token_timeout_ms": 30000 + "token_timeout_ms": 30000, + "env_passthrough": [ + "CATALYST_CODE_ANTIGRAVITY_PROJECT" + ], + "redirect_path": "/oauth2callback" } } diff --git a/core/providers/gemini-cli/README.md b/core/providers/gemini-cli/README.md index c383eef..cc9306a 100644 --- a/core/providers/gemini-cli/README.md +++ b/core/providers/gemini-cli/README.md @@ -93,7 +93,7 @@ After OAuth + project discovery, every chat turn is a POST to } ``` -The `project` field comes from the harness's `x-goog-user-project` header +The `project` field comes from the harness's `x-code-assist-project` header (merged from the OAuth plugin's per-request headers); see `core/src/providers/google_code_assist.rs`. diff --git a/core/providers/gemini-cli/oauth/gemini-cli-oauth.py b/core/providers/gemini-cli/oauth/gemini-cli-oauth.py index 9352a12..7260224 100755 --- a/core/providers/gemini-cli/oauth/gemini-cli-oauth.py +++ b/core/providers/gemini-cli/oauth/gemini-cli-oauth.py @@ -424,21 +424,28 @@ def discover_project_id(access_token): if project: return project # Sibling Antigravity token (same user, different OAuth client) often - # already holds a working managed project. Read it if present. - try: - sibling = os.path.join(os.path.dirname(os.path.abspath( - # token_path is not in scope here; reconstruct from common layout. - os.path.expanduser("~/.config/catalyst-code/oauth/antigravity.json") - )), "antigravity.json") if False else os.path.expanduser( - "~/.config/catalyst-code/oauth/antigravity.json" - ) - sib = read_token(sibling) + # already holds a working managed project. Resolve the sibling path + # from ``CATALYST_CODE_ANTIGRAVITY_PROJECT`` / the gemini-cli token + # directory first, then fall back to the default global location. The + # configured ``token_path`` is not in scope here (discover_project_id + # is called from do_complete, which has ctx); callers pass it via the + # GEMINI_CLI_PROJECT_DIR env var when they need a non-default layout. + sibling_candidates = [] + project_dir = os.environ.get("CATALYST_CODE_OAUTH_DIR", "").strip() + if project_dir: + sibling_candidates.append(os.path.join(project_dir, "antigravity.json")) + sibling_candidates.append(os.path.expanduser( + "~/.config/catalyst-code/oauth/antigravity.json" + )) + for sibling in sibling_candidates: + try: + sib = read_token(sibling) + except Exception: + continue if sib: pid = str(sib.get("project_id") or "").strip() if pid: return pid - except Exception: - pass return None diff --git a/core/providers/gemini-cli/plugin.json b/core/providers/gemini-cli/plugin.json index 7b353bc..94711e9 100644 --- a/core/providers/gemini-cli/plugin.json +++ b/core/providers/gemini-cli/plugin.json @@ -1,19 +1,27 @@ { "name": "gemini-cli", "version": "0.1.0", - "description": "Google Gemini CLI subscription access — OAuth + Code Assist project discovery. Auto-staged into every install.", + "description": "Google Gemini CLI subscription access \u2014 OAuth + Code Assist project discovery. Auto-staged into every install.", "oauth": { "provider_id": "gemini-cli", "label": "Gemini CLI (Google)", "kind": "openai", "base_url": "https://cloudcode-pa.googleapis.com/v1internal", - "description": "Gemini CLI / Code Assist OAuth — Authorization Code + PKCE. After login the harness calls :loadCodeAssist (with the gemini-cli fingerprint) to provision a cloudaicompanionProject, then sends chat through the prod Code Assist gateway.", + "description": "Gemini CLI / Code Assist OAuth \u2014 Authorization Code + PKCE. After login the harness calls :loadCodeAssist (with the gemini-cli fingerprint) to provision a cloudaicompanionProject, then sends chat through the prod Code Assist gateway.", "headers": [ - ["User-Agent", "google-api-nodejs-client/9.15.1"] + [ + "User-Agent", + "google-api-nodejs-client/9.15.1" + ] ], "token_path": "gemini-cli.json", "script": "oauth/gemini-cli-oauth.py", "login_timeout_ms": 300000, - "token_timeout_ms": 30000 + "token_timeout_ms": 30000, + "env_passthrough": [ + "CATALYST_CODE_GEMINI_CLI_PROJECT", + "CATALYST_CODE_OAUTH_DIR" + ], + "redirect_path": "/oauth2callback" } -} \ No newline at end of file +} diff --git a/core/src/plugins.rs b/core/src/plugins.rs index 0692fb5..edf8343 100644 --- a/core/src/plugins.rs +++ b/core/src/plugins.rs @@ -591,6 +591,12 @@ struct OauthManifestEntry { /// Timeout for the token (resolve/refresh) action (default 30s). #[serde(default)] token_timeout_ms: Option, + /// Optional path for the loopback redirect (e.g. ``"/oauth2callback"`` + /// for Google's installed-app OAuth clients). Defaults to ``"/callback"`` + /// which is what most plugins use; override when the OAuth provider's + /// registered redirect URI has a different path. + #[serde(default)] + redirect_path: Option, /// Non-secret env var names the harness forwards to this provider's /// scripts (e.g. `ACME_OAUTH_HOST` for a self-hosted auth server). The /// harness otherwise scrubs the environment, so plugin-specific config @@ -721,6 +727,10 @@ pub struct PluginOauthConfig { pub base_url: String, pub description: String, pub headers: Vec<(String, String)>, + /// Loopback redirect path. Default ``"/callback"``. Override when the + /// provider's registered redirect URI uses a different path (e.g. Google's + /// installed-app OAuth clients expect ``"/oauth2callback"``). + pub redirect_path: String, /// Absolute path the plugin reads/writes its token at. pub token_path: PathBuf, /// Optional external credential path used for cheap login detection. @@ -2335,8 +2345,18 @@ impl PluginManager { if !headless { // Web flow: bind a loopback redirect the script embeds in its URL. + // Plugins override ``redirect_path`` when their OAuth provider + // requires a specific registered path (e.g. Google's + // installed-app OAuth clients require ``/oauth2callback``). let (listener, listener_v6, port) = crate::oauth::bind_loopback(0).await?; - let redirect_uri = format!("http://localhost:{port}/callback"); + let redirect_uri = format!( + "http://localhost:{port}{}", + if cfg.redirect_path.starts_with('/') { + cfg.redirect_path.clone() + } else { + format!("/{}", cfg.redirect_path) + } + ); let mut ctx = self.oauth_action_ctx("login", provider_id, &token_path); ctx["headless"] = json!(false); ctx["redirect_uri"] = json!(redirect_uri); @@ -3545,6 +3565,9 @@ fn load_oauth_entry( base_url: entry.base_url, description: entry.description.unwrap_or_default(), headers: entry.headers, + redirect_path: entry + .redirect_path + .unwrap_or_else(|| "/callback".to_string()), token_path, detect_path, scripts, diff --git a/core/src/providers/google_code_assist.rs b/core/src/providers/google_code_assist.rs index 078b7c5..474934b 100644 --- a/core/src/providers/google_code_assist.rs +++ b/core/src/providers/google_code_assist.rs @@ -112,7 +112,8 @@ fn resolve_project( } notices.push(format!( "no Code Assist project configured (set CODE_ASSIST_PROJECT, \ - GOOGLE_CLOUD_PROJECT, or an x-goog-user-project header); \ + GOOGLE_CLOUD_PROJECT, or an x-code-assist-project / \ + cloudaicompanion-project header); \ using freemium default `{DEFAULT_CODE_ASSIST_FREEMIUM_PROJECT}`" )); DEFAULT_CODE_ASSIST_FREEMIUM_PROJECT.to_string() @@ -794,3 +795,201 @@ mod tests { ); } } + +#[cfg(test)] +mod wire_shape_contract { + //! Wire-shape lock-in tests. + //! + //! These guard the exact URL + body shape + identity headers the OAuth + //! plugins (antigravity, gemini-cli) and downstream clients depend on. + //! If any of these break, both the harness and the real Antigravity / + //! Gemini CLI web clients will silently fail with HTTP 403. Reviewed + //! against the live Google Code Assist gateway. + use super::*; + use crate::config::{ProviderKind, ResolvedProvider}; + + fn project_provider(base_url: &str, project: &str) -> ResolvedProvider { + ResolvedProvider { + name: "code-assist".into(), + kind: ProviderKind::OpenAI, + base_url: base_url.into(), + api_key: Some("ya29.fake".into()), + headers: vec![ + ("x-goog-user-project".into(), project.into()), + ("x-code-assist-project".into(), project.into()), + ("cloudaicompanion-project".into(), project.into()), + ], + oauth: true, + context_window: None, + models_override: Vec::new(), + models_endpoint: None, + } + } + + #[test] + fn chat_targets_daily_cloudcode_pa_for_antigravity() { + // Antigravity OAuth plugin's base_url; verified live: the daily + // host serves chat for Antigravity IDE traffic. The prod host + // rejects Antigravity-issued tokens with HTTP 403 on free-tier. + let provider = project_provider( + "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal", + "synthetic-expanse-sxhhm", + ); + let built = GoogleCodeAssistAdapter + .build_request(&ProviderRequest { + provider: &provider, + model: "gemini-3.1-pro-high", + messages: &[Message::user("hi")], + tools: &[], + reasoning_effort: "high", + thinking_levels: &["high".into()], + max_tokens: 64, + }) + .expect("build_request"); + assert_eq!( + built.url, + "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:streamGenerateContent?alt=sse" + ); + } + + #[test] + fn chat_targets_prod_cloudcode_pa_for_gemini_cli() { + // gemini-cli OAuth plugin's base_url. Verified live: the prod host + // serves chat for gemini-cli clients; daily is rejected with 403. + let provider = project_provider( + "https://cloudcode-pa.googleapis.com/v1internal", + "synthetic-expanse-sxhhm", + ); + let built = GoogleCodeAssistAdapter + .build_request(&ProviderRequest { + provider: &provider, + model: "gemini-2.5-flash", + messages: &[Message::user("hi")], + tools: &[], + reasoning_effort: "low", + thinking_levels: &[], + max_tokens: 64, + }) + .expect("build_request"); + assert_eq!( + built.url, + "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse" + ); + } + + #[test] + fn body_uses_antigravity_user_agent_and_body_project() { + // The body shape is the Google GenAI Cloud Code Assist envelope. + // The Antigravity IDE binary sends body.userAgent="antigravity" + + // body.project=. Verified live; the project + // field MUST come from body (NOT x-goog-user-project header, which + // trips the consumer API gate and returns SERVICE_DISABLED). + let provider = project_provider( + "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal", + "synthetic-expanse-sxhhm", + ); + let built = GoogleCodeAssistAdapter + .build_request(&ProviderRequest { + provider: &provider, + model: "gemini-3.1-pro-high", + messages: &[Message::user("hi")], + tools: &[], + reasoning_effort: "high", + thinking_levels: &["high".into()], + max_tokens: 64, + }) + .expect("build_request"); + assert_eq!(built.body["model"], "gemini-3.1-pro-high"); + assert_eq!(built.body["project"], "synthetic-expanse-sxhhm"); + assert_eq!(built.body["userAgent"], "antigravity"); + assert!(built.body["request"]["contents"].is_array()); + assert!(built.body["request"]["generationConfig"]["maxOutputTokens"].is_number()); + } + + #[test] + fn resolve_project_picks_first_matching_header_in_iteration_order() { + // resolve_project reads the first matching header from the headers + // vec, matching any of the three names case-insensitively. Plugin + // authors must therefore inject ONLY x-code-assist-project — the + // other two names trigger Google's consumer API gate on the chat + // endpoint (HTTP 403 SERVICE_DISABLED). Verified live. + // Test 1: with x-goog-user-project first, it wins. + let provider = ResolvedProvider { + name: "code-assist".into(), + kind: ProviderKind::OpenAI, + base_url: "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal".into(), + api_key: Some("ya29.fake".into()), + headers: vec![("x-goog-user-project".into(), "from-x-goog".into())], + oauth: true, + context_window: None, + models_override: Vec::new(), + models_endpoint: None, + }; + let built = GoogleCodeAssistAdapter + .build_request(&ProviderRequest { + provider: &provider, + model: "gemini-3.1-pro-high", + messages: &[Message::user("hi")], + tools: &[], + reasoning_effort: "high", + thinking_levels: &["high".into()], + max_tokens: 64, + }) + .expect("build_request"); + assert_eq!(built.body["project"], "from-x-goog"); + // Test 2: with only x-code-assist-project, it wins. + let provider = ResolvedProvider { + headers: vec![("x-code-assist-project".into(), "from-x-code-assist".into())], + ..provider.clone() + }; + let built = GoogleCodeAssistAdapter + .build_request(&ProviderRequest { + provider: &provider, + model: "gemini-3.1-pro-high", + messages: &[Message::user("hi")], + tools: &[], + reasoning_effort: "high", + thinking_levels: &["high".into()], + max_tokens: 64, + }) + .expect("build_request"); + assert_eq!(built.body["project"], "from-x-code-assist"); + } + + fn freemium_fallback_emitted_when_no_project_header_present() { + // When the plugin doesn't inject any project header, the adapter + // falls back to the freemium default `rising-fact-p41fc` and emits + // a notice so the user can fix their config. + let provider = ResolvedProvider { + name: "code-assist".into(), + kind: ProviderKind::OpenAI, + base_url: "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal".into(), + api_key: Some("ya29.fake".into()), + headers: Vec::new(), + oauth: false, + context_window: None, + models_override: Vec::new(), + models_endpoint: None, + }; + let built = GoogleCodeAssistAdapter + .build_request(&ProviderRequest { + provider: &provider, + model: "gemini-3-flash", + messages: &[Message::user("hi")], + tools: &[], + reasoning_effort: "low", + thinking_levels: &[], + max_tokens: 64, + }) + .expect("build_request"); + assert_eq!( + built + .notices + .iter() + .filter(|n| n.contains("rising-fact-p41fc")) + .count(), + 1, + "expected exactly one notice mentioning the freemium default project" + ); + } +} From 7b88199be1cf037d869a56c79090b5cfebf25ecf Mon Sep 17 00:00:00 2001 From: yoav Date: Fri, 7 Aug 2026 16:59:04 -0400 Subject: [PATCH 23/38] refactor(providers): extract shared OAuth helpers into _shared/google_oauth.py Both antigravity-oauth.py and gemini-cli-oauth.py shared ~250 lines of identical stdlib-only helpers (HTTP wrappers, PKCE, token I/O, refresh grant, cloudaicompanionProject extraction). Move them into a new core/providers/_shared/google_oauth.py module so the two scripts only carry their vendor-specific bits (CLIENT_ID, SCOPES, USER_AGENT, build / discover / action functions). Wire output is byte-identical: login URL scopes + query params, do_token JSON, and the on-disk token shape are unchanged. Per-script wrappers (token_path, atomic_write) accept their existing temp-file prefix and default filename so staging / cleanup behaviour is preserved. Stage the shared module under plugins/_shared/google_oauth.py so the provider scripts' .. / .. / _shared relative import resolves after staging. Bump STAGING_VERSION to 8; update the staging idempotency test to assert the new file lands. --- core/providers/_shared/google_oauth.py | 261 ++++++++++++++++++ .../antigravity/oauth/antigravity-oauth.py | 253 +++++------------ .../gemini-cli/oauth/gemini-cli-oauth.py | 238 ++++------------ core/src/staging.rs | 19 +- 4 files changed, 402 insertions(+), 369 deletions(-) create mode 100644 core/providers/_shared/google_oauth.py diff --git a/core/providers/_shared/google_oauth.py b/core/providers/_shared/google_oauth.py new file mode 100644 index 0000000..139e058 --- /dev/null +++ b/core/providers/_shared/google_oauth.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +"""Shared helpers for Google OAuth providers (Antigravity, Gemini CLI). + +Stdlib-only — both provider scripts pull HTTP wrappers, PKCE, token I/O, +``cloudaicompanionProject`` extraction, and the refresh-grant helper from +here so the wire-level behaviour stays in lock-step. + +Vendor constants (CLIENT_ID / CLIENT_SECRET / SCOPES / USER_AGENT / URLs / +CLIENT_METADATA) stay in each provider script; only the URL-shaped, +provider-neutral pieces live here. ``build_authorize_url``, +``discover_project_id``, ``exchange_code``, ``fetch_user_email``, and the +four action functions (``do_login`` / ``do_complete`` / ``do_token`` / +``do_clear``) also stay per-script because they bind to script-specific +scopes, env overrides, or sibling-token fallbacks. + +Import pattern (top of each provider script):: + + import sys, os + _HERE = os.path.dirname(os.path.abspath(__file__)) + sys.path.insert(0, os.path.abspath(os.path.join(_HERE, "..", "..", "_shared"))) + from google_oauth import (...) +""" + +import base64 +import hashlib +import json +import os +import secrets +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request + + +# Outbound User-Agent used when the caller does not override via +# ``extra_headers``. Per-script wrappers (``exchange_code``, +# ``fetch_user_email``) attach their own UA via ``extra_headers`` for +# requests where Google's backend fingerprints the header (token +# endpoint, userinfo). Code-Assist calls always carry the script UA in +# ``_code_assist_headers``, so they are unaffected. +DEFAULT_USER_AGENT = "catalyst-code-google-oauth/1.0" + + +def now(): + return int(time.time()) + + +# ─── HTTP helpers ────────────────────────────────────────────────────────── + +def http_post(url, body, content_type, extra_headers=None, timeout=30): + headers = { + "Accept": "application/json", + "Content-Type": content_type, + "User-Agent": DEFAULT_USER_AGENT, + } + if extra_headers: + headers.update(extra_headers) + req = urllib.request.Request(url, data=body, method="POST", headers=headers) + try: + with urllib.request.urlopen(req, timeout=timeout) as response: + raw = response.read().decode("utf-8", "replace") + return response.status, parse_json(raw) + except urllib.error.HTTPError as exc: + raw = exc.read().decode("utf-8", "replace") + return exc.code, parse_json(raw) + except Exception as exc: + return 0, {"error": "request_failed", "error_description": str(exc)} + + +def parse_json(raw): + try: + value = json.loads(raw) if raw.strip() else {} + return value if isinstance(value, dict) else {} + except Exception: + return {"error": "invalid_json", "error_description": raw[:500]} + + +def post_form(url, fields, extra_headers=None, timeout=30): + return http_post( + url, + urllib.parse.urlencode(fields).encode("utf-8"), + "application/x-www-form-urlencoded", + extra_headers, + timeout=timeout, + ) + + +def post_json(url, payload, extra_headers=None, timeout=30): + return http_post( + url, + json.dumps(payload, separators=(",", ":")).encode("utf-8"), + "application/json", + extra_headers, + timeout=timeout, + ) + + +def error_text(status, data): + return ( + data.get("error_description") + or data.get("error") + or ("network request failed" if status == 0 else f"HTTP {status}") + ) + + +# ─── on-disk token file ──────────────────────────────────────────────────── + +def token_path(ctx, default_name): + """Resolve the absolute path of the on-disk token file. + + The harness always passes ``token_path`` in the action context; the + per-script ``default_name`` is only a fallback for ad-hoc invocations + where the field is missing. + """ + return os.path.abspath(str(ctx.get("token_path") or default_name)) + + +def read_token(path): + try: + with open(path, encoding="utf-8") as handle: + value = json.load(handle) + return value if isinstance(value, dict) else None + except (OSError, ValueError, TypeError): + return None + + +def atomic_write(path, value, prefix=".google-oauth-"): + """Atomic JSON write of ``value`` to ``path``. + + Uses ``mkstemp`` + ``fsync`` + ``rename`` so a crash mid-write can + never leave a truncated token file. The ``prefix`` parameter lets + per-script callers keep their existing temp-file marker + (``.antigravity-oauth-`` / ``.gemini-cli-oauth-``) so staging and + cleanup can identify which provider owns a stale temp. + """ + path = os.path.abspath(path) + parent = os.path.dirname(path) or "." + os.makedirs(parent, mode=0o700, exist_ok=True) + fd, tmp = tempfile.mkstemp(prefix=prefix, dir=parent) + try: + try: + os.fchmod(fd, 0o600) + except AttributeError: + pass # Windows has no POSIX mode bits + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(value, handle, separators=(",", ":")) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp, path) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise + + +def lock_for(path): + """Acquire an exclusive ``flock`` on ``path + ".lock"``. + + Returns a file handle the caller must keep alive (and pass to + ``unlock``) to hold the lock. POSIX-only: on platforms without + ``fcntl`` (Windows) returns ``None`` and the lock is silently + skipped — fine for our use case since the harness only runs these + scripts on macOS / Linux. + """ + try: + import fcntl + except ImportError: + return None + handle = open(path + ".lock", "a+", encoding="utf-8") + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + except OSError: + pass + return handle + + +def unlock(handle): + if handle is not None: + try: + handle.close() + except OSError: + pass + + +# ─── PKCE ────────────────────────────────────────────────────────────────── + +def make_pkce(): + """Generate ``(verifier, challenge, state)`` for S256 PKCE.""" + verifier = base64.urlsafe_b64encode(secrets.token_bytes(48)).rstrip(b"=").decode("ascii") + challenge = base64.urlsafe_b64encode( + hashlib.sha256(verifier.encode("ascii")).digest() + ).rstrip(b"=").decode("ascii") + state = base64.urlsafe_b64encode(secrets.token_bytes(24)).rstrip(b"=").decode("ascii") + return verifier, challenge, state + + +# ─── token exchange ──────────────────────────────────────────────────────── + +def normalize_tokens(tokens): + """Coerce the raw OAuth response into the persistent shape on disk.""" + access = tokens.get("access_token") or "" + refresh = tokens.get("refresh_token") or "" + if not access and not refresh: + return None + expires_in = int(tokens.get("expires_in") or 0) + return { + "access_token": access, + "refresh_token": refresh, + "expires_in": expires_in, + "expires_at": now() + max(expires_in, 60), + "scope": tokens.get("scope", ""), + "token_type": tokens.get("token_type", "Bearer"), + } + + +def refresh_access_token(token_url, client_id, client_secret, refresh_token): + """POST ``grant_type=refresh_token``; return ``(status, dict)``.""" + return post_form( + token_url, + { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": client_id, + "client_secret": client_secret, + }, + ) + + +# ─── Code Assist: cloudaicompanionProject extraction ─────────────────────── + +def extract_cloudaicompanion_project(payload): + """Pull ``cloudaicompanionProject`` out of a Code Assist response. + + Handles both shapes Google's Code Assist gateway returns: + + * top-level: ``{"cloudaicompanionProject": "abc"}`` or + ``{"cloudaicompanionProject": {"id": "abc"}}`` (``loadCodeAssist``) + * nested under ``response``: + ``{"response": {"cloudaicompanionProject": "abc"}}`` + (``onboardUser`` final ``done=true`` payload) + + Returns the project id string, or ``None`` if no project is present. + """ + project = payload.get("cloudaicompanionProject") + if isinstance(project, str) and project.strip(): + return project.strip() + if isinstance(project, dict): + nested = project.get("id") + if isinstance(nested, str) and nested.strip(): + return nested.strip() + nested = (payload.get("response") or {}).get("cloudaicompanionProject") + if isinstance(nested, str) and nested.strip(): + return nested.strip() + if isinstance(nested, dict): + inner_id = nested.get("id") + if isinstance(inner_id, str) and inner_id.strip(): + return inner_id.strip() + return None diff --git a/core/providers/antigravity/oauth/antigravity-oauth.py b/core/providers/antigravity/oauth/antigravity-oauth.py index fd6fb62..a56edea 100755 --- a/core/providers/antigravity/oauth/antigravity-oauth.py +++ b/core/providers/antigravity/oauth/antigravity-oauth.py @@ -9,30 +9,55 @@ mirror the public Antigravity IDE (2.1.1, darwin/arm64) so the upstream Code Assist gateway provisions a real ``cloudaicompanionProject`` for us. +HTTP / PKCE / token-IO / refresh-grant / project-extraction helpers live +in ``core/providers/_shared/google_oauth.py`` — see that file for the +shared contract. Vendor constants + ``build_authorize_url`` + +``discover_project_id`` + the four action functions stay here because they +bind to Antigravity-specific scopes, the ``CATALYST_CODE_ANTIGRAVITY_PROJECT`` +override, and the Antigravity loadCodeAssist fingerprint. + Flow ---- login PKCE + Authorization Code → harness binds loopback → opens browser → captures ``code`` → we exchange + run ``loadCodeAssist`` → write ``token.json`` containing access + refresh + project_id. token Return a fresh ``access_token`` (refresh if near expiry) and a - ``x-goog-user-project`` header carrying the cached ``project_id`` + ``x-code-assist-project`` header carrying the cached ``project_id`` so the harness's Google Code Assist adapter routes to the user's real Antigravity project (not the freemium shared one). clear Delete the on-disk token file. """ -import base64 -import hashlib import json import os -import secrets import sys -import tempfile import time -import urllib.error import urllib.parse import urllib.request +# Bring the shared OAuth helpers into scope. The shared module lives at +# ``core/providers/_shared/google_oauth.py``; the import below adds its +# directory to ``sys.path`` so ``google_oauth`` resolves next to this file. +import sys as _sys, os as _os +_HERE = _os.path.dirname(_os.path.abspath(__file__)) +_sys.path.insert( + 0, _os.path.abspath(_os.path.join(_HERE, "..", "..", "_shared")) +) +from google_oauth import ( # noqa: E402 + atomic_write, + error_text, + extract_cloudaicompanion_project, + lock_for, + make_pkce, + normalize_tokens, + post_form, + post_json, + read_token, + refresh_access_token as _shared_refresh_access_token, + token_path as _shared_token_path, + unlock, +) + # ─── Antigravity IDE public OAuth client ──────────────────────────────────── # Public client_id / client_secret shipped in the open-source Antigravity IDE. @@ -45,12 +70,9 @@ TOKEN_URL = "https://oauth2.googleapis.com/token" USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo" -# Scopes the Antigravity IDE requests. ``cclog`` + ``experimentsandconfigs`` -# are Antigravity-specific and are required for Code Assist provisioning. -# Google OAuth requires ``/oauth2callback`` (not arbitrary paths) for the -# Antigravity OAuth client — only this path is registered as a loopback -# redirect URI for ``http://127.0.0.1:`` in the client's Google Cloud -# console entry. Using ``/callback`` makes Google reject the request as a +# Scopes the Antigravity IDE requests. ``/oauth2callback`` (not arbitrary +# paths) is the only loopback redirect URI registered for the Antigravity +# OAuth client — using ``/callback`` makes Google reject the request as a # non-compliant redirect URI ("doesn't comply with Google's OAuth 2.0 # policy for keeping apps secure"). We mirror the path the Antigravity IDE # binary uses. @@ -102,6 +124,10 @@ ONBOARD_POLL_S = 2 HTTP_TIMEOUT_S = 30 +# Per-script defaults for shared helpers. +_TOKEN_FILENAME = "antigravity.json" +_ATOMIC_WRITE_PREFIX = ".antigravity-oauth-" + # ─── harness I/O ─────────────────────────────────────────────────────────── @@ -115,137 +141,13 @@ def die(message): raise SystemExit(0) -def now(): - return int(time.time()) - - -# ─── HTTP helpers ────────────────────────────────────────────────────────── - -def http_post(url, body, content_type, extra_headers=None): - headers = { - "Accept": "application/json", - "Content-Type": content_type, - "User-Agent": USER_AGENT, - } - if extra_headers: - headers.update(extra_headers) - req = urllib.request.Request(url, data=body, method="POST", headers=headers) - try: - with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S) as response: - raw = response.read().decode("utf-8", "replace") - return response.status, parse_json(raw) - except urllib.error.HTTPError as exc: - raw = exc.read().decode("utf-8", "replace") - return exc.code, parse_json(raw) - except Exception as exc: - return 0, {"error": "request_failed", "error_description": str(exc)} - - -def parse_json(raw): - try: - value = json.loads(raw) if raw.strip() else {} - return value if isinstance(value, dict) else {} - except Exception: - return {"error": "invalid_json", "error_description": raw[:500]} - - -def post_form(url, fields, extra_headers=None): - return http_post( - url, - urllib.parse.urlencode(fields).encode("utf-8"), - "application/x-www-form-urlencoded", - extra_headers, - ) - - -def post_json(url, payload, extra_headers=None): - return http_post( - url, - json.dumps(payload, separators=(",", ":")).encode("utf-8"), - "application/json", - extra_headers, - ) - - -def error_text(status, data): - return ( - data.get("error_description") - or data.get("error") - or ("network request failed" if status == 0 else f"HTTP {status}") - ) - - -# ─── on-disk token file ──────────────────────────────────────────────────── - def token_path(ctx): - return os.path.abspath(str(ctx.get("token_path") or "antigravity.json")) - - -def read_token(path): - try: - with open(path, encoding="utf-8") as handle: - value = json.load(handle) - return value if isinstance(value, dict) else None - except (OSError, ValueError, TypeError): - return None - - -def atomic_write(path, value): - path = os.path.abspath(path) - parent = os.path.dirname(path) or "." - os.makedirs(parent, mode=0o700, exist_ok=True) - fd, tmp = tempfile.mkstemp(prefix=".antigravity-oauth-", dir=parent) - try: - try: - os.fchmod(fd, 0o600) - except AttributeError: - pass # Windows has no POSIX mode bits - with os.fdopen(fd, "w", encoding="utf-8") as handle: - json.dump(value, handle, separators=(",", ":")) - handle.flush() - os.fsync(handle.fileno()) - os.replace(tmp, path) - except Exception: - try: - os.unlink(tmp) - except OSError: - pass - raise - - -def lock_for(path): - try: - import fcntl - except ImportError: - return None - handle = open(path + ".lock", "a+", encoding="utf-8") - try: - fcntl.flock(handle.fileno(), fcntl.LOCK_EX) - except OSError: - pass - return handle - - -def unlock(handle): - if handle is not None: - try: - handle.close() - except OSError: - pass + """Absolute path of the on-disk token file (Antigravity-specific default).""" + return _shared_token_path(ctx, _TOKEN_FILENAME) # ─── PKCE + auth URL ─────────────────────────────────────────────────────── -def make_pkce(): - """Generate (verifier, challenge, state) for S256 PKCE.""" - verifier = base64.urlsafe_b64encode(secrets.token_bytes(48)).rstrip(b"=").decode("ascii") - challenge = base64.urlsafe_b64encode( - hashlib.sha256(verifier.encode("ascii")).digest() - ).rstrip(b"=").decode("ascii") - state = base64.urlsafe_b64encode(secrets.token_bytes(24)).rstrip(b"=").decode("ascii") - return verifier, challenge, state - - def build_authorize_url(redirect_uri, state, challenge, extra=None): # The Antigravity IDE binary does not include ``prompt=consent`` or # ``include_granted_scopes=true``; including either can confuse Google's @@ -284,16 +186,9 @@ def exchange_code(code, redirect_uri, verifier): def refresh_access_token(refresh_token): - status, data = post_form( - TOKEN_URL, - { - "grant_type": "refresh_token", - "refresh_token": refresh_token, - "client_id": CLIENT_ID, - "client_secret": CLIENT_SECRET, - }, + return _shared_refresh_access_token( + TOKEN_URL, CLIENT_ID, CLIENT_SECRET, refresh_token ) - return status, data def fetch_user_email(access_token): @@ -309,21 +204,15 @@ def fetch_user_email(access_token): return "" -def normalize_tokens(tokens): - """Coerce the raw OAuth response into the persistent shape on disk.""" - access = tokens.get("access_token") or "" - refresh = tokens.get("refresh_token") or "" - if not access and not refresh: - return None - expires_in = int(tokens.get("expires_in") or 0) - return { - "access_token": access, - "refresh_token": refresh, - "expires_in": expires_in, - "expires_at": now() + max(expires_in, 60), - "scope": tokens.get("scope", ""), - "token_type": tokens.get("token_type", "Bearer"), - } +def parse_json(raw): + # Local shim so ``fetch_user_email`` can keep its old call site; the + # canonical implementation now lives in ``google_oauth``. The behaviour + # is identical (raw -> dict-or-fallback-error shape). + try: + value = json.loads(raw) if raw.strip() else {} + return value if isinstance(value, dict) else {} + except Exception: + return {"error": "invalid_json", "error_description": raw[:500]} # ─── Code Assist: loadCodeAssist + onboardUser ───────────────────────────── @@ -353,14 +242,7 @@ def load_code_assist(access_token): ) if status != 200: return None - project = data.get("cloudaicompanionProject") - if isinstance(project, str) and project.strip(): - return project.strip() - if isinstance(project, dict): - nested = project.get("id") - if isinstance(nested, str) and nested.strip(): - return nested.strip() - return None + return extract_cloudaicompanion_project(data) def _pick_default_tier(payload): @@ -385,15 +267,7 @@ def onboard_user(access_token, tier_id): if status != 200: return None if data.get("done") is True: - response = data.get("response") or {} - project = response.get("cloudaicompanionProject") - if isinstance(project, str) and project.strip(): - return project.strip() - if isinstance(project, dict): - nested = project.get("id") - if isinstance(nested, str) and nested.strip(): - return nested.strip() - return None + return extract_cloudaicompanion_project(data) if attempt < ONBOARD_MAX_ATTEMPTS: time.sleep(ONBOARD_POLL_S) return None @@ -419,13 +293,9 @@ def discover_project_id(access_token): ) if status != 200: return None - project = data.get("cloudaicompanionProject") - if isinstance(project, str) and project.strip(): - return project.strip() - if isinstance(project, dict): - nested = project.get("id") - if isinstance(nested, str) and nested.strip(): - return nested.strip() + project = extract_cloudaicompanion_project(data) + if project: + return project tier = _pick_default_tier(data) return onboard_user(access_token, tier) @@ -490,7 +360,7 @@ def do_complete(ctx): if email: normalized["email"] = email - atomic_write(token_path(ctx), normalized) + atomic_write(token_path(ctx), normalized, prefix=_ATOMIC_WRITE_PREFIX) emit({"ok": True}) @@ -541,7 +411,7 @@ def do_token(ctx): # OAuth grant. rotated["project_id"] = current_token.get("project_id", "") rotated["email"] = current_token.get("email", "") - atomic_write(path, rotated) + atomic_write(path, rotated, prefix=_ATOMIC_WRITE_PREFIX) token = rotated access = token.get("access_token") or "" @@ -580,6 +450,13 @@ def do_clear(ctx): emit({"ok": True}) +def now(): + # Local shim — action functions and ``do_token`` already use ``now()`` + # directly. Same implementation as ``google_oauth.now()``; kept local + # so the per-script code reads naturally without a shared-module call. + return int(time.time()) + + def main(): try: raw = sys.stdin.read() diff --git a/core/providers/gemini-cli/oauth/gemini-cli-oauth.py b/core/providers/gemini-cli/oauth/gemini-cli-oauth.py index 7260224..55275ab 100755 --- a/core/providers/gemini-cli/oauth/gemini-cli-oauth.py +++ b/core/providers/gemini-cli/oauth/gemini-cli-oauth.py @@ -9,6 +9,13 @@ mirror Google's open-source ``gemini`` CLI so the upstream Code Assist gateway provisions a real ``cloudaicompanionProject`` for us. +HTTP / PKCE / token-IO / refresh-grant / project-extraction helpers live +in ``core/providers/_shared/google_oauth.py`` — see that file for the +shared contract. Vendor constants + ``build_authorize_url`` + +``discover_project_id`` + the four action functions stay here because they +bind to gemini-cli-specific scopes and the sibling-Antigravity-token +fallback used when free-tier loadCodeAssist returns no project. + Compared to the Antigravity plugin this one uses: * a different public OAuth client (the open-source gemini-cli client); @@ -20,18 +27,36 @@ numeric enums the gemini-cli binary actually sends). """ -import base64 -import hashlib import json import os -import secrets import sys -import tempfile import time -import urllib.error import urllib.parse import urllib.request +# Bring the shared OAuth helpers into scope. The shared module lives at +# ``core/providers/_shared/google_oauth.py``; the import below adds its +# directory to ``sys.path`` so ``google_oauth`` resolves next to this file. +import sys as _sys, os as _os +_HERE = _os.path.dirname(_os.path.abspath(__file__)) +_sys.path.insert( + 0, _os.path.abspath(_os.path.join(_HERE, "..", "..", "_shared")) +) +from google_oauth import ( # noqa: E402 + atomic_write, + error_text, + extract_cloudaicompanion_project, + lock_for, + make_pkce, + normalize_tokens, + post_form, + post_json, + read_token, + refresh_access_token as _shared_refresh_access_token, + token_path as _shared_token_path, + unlock, +) + # ─── Gemini CLI public OAuth client ──────────────────────────────────────── # Public client_id / client_secret shipped in the open-source @@ -99,6 +124,10 @@ def _platform_enum(): ONBOARD_POLL_S = 2 HTTP_TIMEOUT_S = 30 +# Per-script defaults for shared helpers. +_TOKEN_FILENAME = "gemini-cli.json" +_ATOMIC_WRITE_PREFIX = ".gemini-cli-oauth-" + # ─── harness I/O ─────────────────────────────────────────────────────────── @@ -112,137 +141,13 @@ def die(message): raise SystemExit(0) -def now(): - return int(time.time()) - - -# ─── HTTP helpers ────────────────────────────────────────────────────────── - -def http_post(url, body, content_type, extra_headers=None): - headers = { - "Accept": "application/json", - "Content-Type": content_type, - "User-Agent": USER_AGENT, - } - if extra_headers: - headers.update(extra_headers) - req = urllib.request.Request(url, data=body, method="POST", headers=headers) - try: - with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S) as response: - raw = response.read().decode("utf-8", "replace") - return response.status, parse_json(raw) - except urllib.error.HTTPError as exc: - raw = exc.read().decode("utf-8", "replace") - return exc.code, parse_json(raw) - except Exception as exc: - return 0, {"error": "request_failed", "error_description": str(exc)} - - -def parse_json(raw): - try: - value = json.loads(raw) if raw.strip() else {} - return value if isinstance(value, dict) else {} - except Exception: - return {"error": "invalid_json", "error_description": raw[:500]} - - -def post_form(url, fields, extra_headers=None): - return http_post( - url, - urllib.parse.urlencode(fields).encode("utf-8"), - "application/x-www-form-urlencoded", - extra_headers, - ) - - -def post_json(url, payload, extra_headers=None): - return http_post( - url, - json.dumps(payload, separators=(",", ":")).encode("utf-8"), - "application/json", - extra_headers, - ) - - -def error_text(status, data): - return ( - data.get("error_description") - or data.get("error") - or ("network request failed" if status == 0 else f"HTTP {status}") - ) - - -# ─── on-disk token file ──────────────────────────────────────────────────── - def token_path(ctx): - return os.path.abspath(str(ctx.get("token_path") or "gemini-cli.json")) - - -def read_token(path): - try: - with open(path, encoding="utf-8") as handle: - value = json.load(handle) - return value if isinstance(value, dict) else None - except (OSError, ValueError, TypeError): - return None - - -def atomic_write(path, value): - path = os.path.abspath(path) - parent = os.path.dirname(path) or "." - os.makedirs(parent, mode=0o700, exist_ok=True) - fd, tmp = tempfile.mkstemp(prefix=".gemini-cli-oauth-", dir=parent) - try: - try: - os.fchmod(fd, 0o600) - except AttributeError: - pass - with os.fdopen(fd, "w", encoding="utf-8") as handle: - json.dump(value, handle, separators=(",", ":")) - handle.flush() - os.fsync(handle.fileno()) - os.replace(tmp, path) - except Exception: - try: - os.unlink(tmp) - except OSError: - pass - raise - - -def lock_for(path): - try: - import fcntl - except ImportError: - return None - handle = open(path + ".lock", "a+", encoding="utf-8") - try: - fcntl.flock(handle.fileno(), fcntl.LOCK_EX) - except OSError: - pass - return handle - - -def unlock(handle): - if handle is not None: - try: - handle.close() - except OSError: - pass + """Absolute path of the on-disk token file (gemini-cli-specific default).""" + return _shared_token_path(ctx, _TOKEN_FILENAME) # ─── PKCE + auth URL ─────────────────────────────────────────────────────── -def make_pkce(): - """Generate (verifier, challenge, state) for S256 PKCE.""" - verifier = base64.urlsafe_b64encode(secrets.token_bytes(48)).rstrip(b"=").decode("ascii") - challenge = base64.urlsafe_b64encode( - hashlib.sha256(verifier.encode("ascii")).digest() - ).rstrip(b"=").decode("ascii") - state = base64.urlsafe_b64encode(secrets.token_bytes(24)).rstrip(b"=").decode("ascii") - return verifier, challenge, state - - def build_authorize_url(redirect_uri, state, challenge): # The official ``gemini`` CLI does NOT send ``prompt=consent`` or # ``include_granted_scopes=true``; including them can confuse Google's @@ -280,16 +185,9 @@ def exchange_code(code, redirect_uri, verifier): def refresh_access_token(refresh_token): - status, data = post_form( - TOKEN_URL, - { - "grant_type": "refresh_token", - "refresh_token": refresh_token, - "client_id": CLIENT_ID, - "client_secret": CLIENT_SECRET, - }, + return _shared_refresh_access_token( + TOKEN_URL, CLIENT_ID, CLIENT_SECRET, refresh_token ) - return status, data def fetch_user_email(access_token): @@ -305,21 +203,15 @@ def fetch_user_email(access_token): return "" -def normalize_tokens(tokens): - """Coerce the raw OAuth response into the persistent shape on disk.""" - access = tokens.get("access_token") or "" - refresh = tokens.get("refresh_token") or "" - if not access and not refresh: - return None - expires_in = int(tokens.get("expires_in") or 0) - return { - "access_token": access, - "refresh_token": refresh, - "expires_in": expires_in, - "expires_at": now() + max(expires_in, 60), - "scope": tokens.get("scope", ""), - "token_type": tokens.get("token_type", "Bearer"), - } +def parse_json(raw): + # Local shim so ``fetch_user_email`` can keep its old call site; the + # canonical implementation now lives in ``google_oauth``. The behaviour + # is identical (raw -> dict-or-fallback-error shape). + try: + value = json.loads(raw) if raw.strip() else {} + return value if isinstance(value, dict) else {} + except Exception: + return {"error": "invalid_json", "error_description": raw[:500]} # ─── Code Assist: loadCodeAssist + onboardUser ───────────────────────────── @@ -343,25 +235,6 @@ def _code_assist_body(include_tier=False, tier_id=None, mode=1): return body -def _extract_project(payload): - """Pull ``cloudaicompanionProject`` out of a loadCodeAssist / onboardUser response.""" - project = payload.get("cloudaicompanionProject") - if isinstance(project, str) and project.strip(): - return project.strip() - if isinstance(project, dict): - nested = project.get("id") - if isinstance(nested, str) and nested.strip(): - return nested.strip() - nested = (payload.get("response") or {}).get("cloudaicompanionProject") - if isinstance(nested, str) and nested.strip(): - return nested.strip() - if isinstance(nested, dict): - id_ = nested.get("id") - if isinstance(id_, str) and id_.strip(): - return id_.strip() - return None - - def _pick_default_tier(payload): tiers = payload.get("allowedTiers") if isinstance(tiers, list): @@ -394,7 +267,7 @@ def onboard_user(access_token, tier_id): if status != 200: return None if data.get("done") is True: - return _extract_project(data) + return extract_cloudaicompanion_project(data) if attempt < ONBOARD_MAX_ATTEMPTS: time.sleep(ONBOARD_POLL_S) return None @@ -416,7 +289,7 @@ def discover_project_id(access_token): return override payload = load_code_assist_payload(access_token) if payload is not None: - project = _extract_project(payload) + project = extract_cloudaicompanion_project(payload) if project: return project tier = _pick_default_tier(payload) @@ -503,7 +376,7 @@ def do_complete(ctx): if email: normalized["email"] = email - atomic_write(token_path(ctx), normalized) + atomic_write(token_path(ctx), normalized, prefix=_ATOMIC_WRITE_PREFIX) emit({"ok": True}) @@ -554,7 +427,7 @@ def do_token(ctx): # OAuth grant. rotated["project_id"] = current_token.get("project_id", "") rotated["email"] = current_token.get("email", "") - atomic_write(path, rotated) + atomic_write(path, rotated, prefix=_ATOMIC_WRITE_PREFIX) token = rotated access = token.get("access_token") or "" @@ -596,6 +469,13 @@ def do_clear(ctx): emit({"ok": True}) +def now(): + # Local shim — action functions and ``do_token`` already use ``now()`` + # directly. Same implementation as ``google_oauth.now()``; kept local + # so the per-script code reads naturally without a shared-module call. + return int(time.time()) + + def main(): try: raw = sys.stdin.read() @@ -620,4 +500,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/core/src/staging.rs b/core/src/staging.rs index e475c49..884ee19 100644 --- a/core/src/staging.rs +++ b/core/src/staging.rs @@ -27,7 +27,7 @@ use std::path::PathBuf; /// Bump when the bundled default set changes meaningfully. The marker file /// stores this; on a version mismatch we re-scan for *missing* files (existing /// user files are still never overwritten) and then re-stamp the marker. -pub const STAGING_VERSION: u32 = 7; +pub const STAGING_VERSION: u32 = 8; /// `~/.catalyst-code` — the global, user-owned home for harness defaults. /// All staged files live under here (agents/, skills/, plugins/, README.md). @@ -290,6 +290,15 @@ fn bundled_files() -> Vec<(&'static str, &'static str)> { "plugins/gemini-cli/README.md", include_str!("../providers/gemini-cli/README.md"), ), + // --- shared OAuth helpers used by the antigravity + gemini-cli + // provider scripts. Lives under ``plugins/_shared/`` so the + // provider scripts' relative ``..``/``..``/``_shared`` import + // pattern resolves to the staged location too. Not a hook — + // not executable. --- + ( + "plugins/_shared/google_oauth.py", + include_str!("../providers/_shared/google_oauth.py"), + ), // --- A short guide to the global layout + override model. --- ("README.md", GLOBAL_README), ] @@ -401,7 +410,9 @@ project. │ ├── codex/ # ChatGPT subscription OAuth provider │ ├── deepseek/ # DeepSeek API-key provider │ ├── antigravity/ # Google Antigravity IDE OAuth + Code Assist - │ └── gemini-cli/ # Google Gemini CLI OAuth + Code Assist + │ ├── gemini-cli/ # Google Gemini CLI OAuth + Code Assist + │ └── _shared/ # shared Python helpers used by the Google OAuth + │ # provider scripts (import-only, not a plugin) ├── README.md # this file └── .staged # staging schema version marker (do not edit) @@ -513,6 +524,10 @@ mod tests { home.join("plugins/gemini-cli/README.md").exists(), "gemini-cli provider README should be staged on first run" ); + assert!( + home.join("plugins/_shared/google_oauth.py").exists(), + "shared google_oauth helpers should be staged on first run" + ); assert!(home.join(".staged").exists()); assert_eq!( std::fs::read_to_string(home.join(".staged")).unwrap(), From f149ad512d05c88feae9125e3016839e42a54043 Mon Sep 17 00:00:00 2001 From: catcode Date: Fri, 7 Aug 2026 17:11:53 -0400 Subject: [PATCH 24/38] docs(plugins): document redirect_path, env_passthrough, header gotchas --- .../skills/plugin-authoring/SKILL.md | 38 +- core/providers/README.md | 105 +++++ docs/plugins/oauth.md | 430 ++++++++++++++++++ 3 files changed, 567 insertions(+), 6 deletions(-) create mode 100644 docs/plugins/oauth.md diff --git a/.catalyst-code/skills/plugin-authoring/SKILL.md b/.catalyst-code/skills/plugin-authoring/SKILL.md index fe7bb1e..9954a5b 100644 --- a/.catalyst-code/skills/plugin-authoring/SKILL.md +++ b/.catalyst-code/skills/plugin-authoring/SKILL.md @@ -594,11 +594,26 @@ Fields: - `login_timeout_ms` (optional, default 120000): timeout for `login` + `complete`. - `token_timeout_ms` (optional, default 30000): timeout for `token` + `clear`. +- `redirect_path` (optional, default `"/callback"`): the path component the + harness binds on its loopback redirect server for the web flow. **Must + match the redirect URI registered with the provider's OAuth client** — + Google's installed-app OAuth clients (Antigravity IDE, Gemini CLI) require + `"/oauth2callback"`; using the default `"/callback"` makes Google reject + the request as a non-compliant redirect URI (`redirect_uri_mismatch`). + The harness prefixes a `/` if absent, so `"/oauth2callback"` and + `"oauth2callback"` are equivalent. See + [`docs/plugins/oauth.md`](../../../docs/plugins/oauth.md#redirect_path-matching-the-providers-registered-redirect-uri) + for the full table of which providers need which path. - `env_passthrough` (optional): non-secret env var names the harness forwards - to your scripts (e.g. `["ACME_OAUTH_HOST"]` for a self-hosted auth server). - The harness otherwise scrubs the environment, so undeclared vars never reach - the script. Names containing KEY/TOKEN/SECRET/PASSWORD/CREDENTIAL are - rejected at load time — passthrough must never defeat env scrubbing. + to your scripts (e.g. `["ACME_OAUTH_HOST"]` for a self-hosted auth server, + or `["CATALYST_CODE__PROJECT"]` for a plugin-specific project + override that survives env scrubbing). The harness otherwise scrubs the + environment, so undeclared vars never reach the script. Names must match + `[A-Za-z_][A-Za-z0-9_]*`; any name containing KEY/TOKEN/SECRET/PASSWORD/ + CREDENTIAL (case-insensitive) is rejected at load time — passthrough must + never defeat env scrubbing. See + [`docs/plugins/oauth.md`](../../../docs/plugins/oauth.md#env_passthrough-plugin-specific-config-knobs-that-survive-env-scrubbing) + for the conventions and the rationale. #### Script action contract @@ -651,8 +666,19 @@ refresh (make your own HTTP call) and write the updated token back. Output: `expires_at` is unix seconds (optional; if 0/absent the harness caches for ~5 min). Optional `headers` are merged onto every request for that provider (plugin wins on name conflicts) and cached with the token — use this for -per-user identity headers such as ChatGPT's `chatgpt-account-id`. This runs -on the per-turn hot path, so it is cached until near expiry. +per-user identity headers such as ChatGPT's `chatgpt-account-id` or +Google Code Assist's `x-code-assist-project` (Antigravity / Gemini CLI +bundles). This runs on the per-turn hot path, so it is cached until near +expiry. + +**Header gotcha (Google Code Assist):** inject `x-code-assist-project`, +**not** `x-goog-user-project` and **not** `cloudaicompanion-project`. The +Code Assist chat gateway treats the three names as different routing +signals: only `x-code-assist-project` is authorized for Antigravity / +Gemini CLI OAuth tokens; the other two route to the consumer GenAI gate +and return `403 SERVICE_DISABLED`. Verified live and pinned by the +`wire_shape_contract` test module in +`core/src/providers/google_code_assist.rs`. Concurrency: several harness processes (TUI, web service, a second TUI) can invoke `token` at the same time, and providers commonly rotate refresh tokens. diff --git a/core/providers/README.md b/core/providers/README.md index a3abeee..1addd46 100644 --- a/core/providers/README.md +++ b/core/providers/README.md @@ -64,3 +64,108 @@ daily-cloudcode-pa hosts to the right wire format, and the adapter's `resolve_project` reads the `x-code-assist-project` header that each plugin's `token` action injects to use the user's real Code Assist project instead of the freemium fallback. + +## OAuth gotchas + +These are the wire-level footguns the `google_code_assist` adapter exists +to handle and the `wire_shape_contract` test module in +`core/src/providers/google_code_assist.rs` line 800 is the authoritative +spec for. Anything in this section will break the live Antigravity IDE / +Gemini CLI flow with HTTP 403 (`SERVICE_DISABLED`) or +`redirect_uri_mismatch` if violated. + +### 1. Project header: `x-code-assist-project`, NOT `x-goog-user-project` + +`resolve_project` reads the **first** header in the provider's headers vec +that matches any of: + +- `x-goog-user-project` +- `cloudaicompanion-project` +- `x-code-assist-project` + +(iteration order, case-insensitive). The Google Code Assist chat gateway +treats these as **three different signals** with **different routing**: + +| Header | What the gateway does | What to do | +|--------|-----------------------|------------| +| `x-goog-user-project` | Routes to the **consumer** Generative Language API (GenAI) gate. The Antigravity / Gemini CLI OAuth token does **not** have access; the gateway returns `403 SERVICE_DISABLED`. | **Do not inject.** | +| `cloudaicompanion-project` | Routes to the consumer gate same as `x-goog-user-project`. | **Do not inject.** | +| `x-code-assist-project` | Routes to the **Code Assist** gate. The OAuth token is authorized here. The body also carries the same value in `body.project`. | **Inject this one.** | + +**The plugin's `token` action MUST return `x-code-assist-project` in its +`headers` array** (not `x-goog-user-project`, not +`cloudaicompanion-project`). The bundled `antigravity/` and `gemini-cli/` +bundles both do this. Verified live against the +`daily-cloudcode-pa.sandbox.googleapis.com` and +`cloudcode-pa.googleapis.com` hosts — swapping the header name surfaces +as `403 SERVICE_DISABLED` on the very first chat request, with no helpful +error message from the gateway. + +The `wire_shape_contract::resolve_project_picks_first_matching_header_in_iteration_order` +test (line 882) pins this behavior. + +### 2. Code Assist body envelope shape + +The Code Assist / GenAI chat endpoint does not use the OpenAI +`{messages, …}` body. The adapter wraps the user messages into the +GenAI streaming envelope: + +```json +{ + "model": "", + "project": "", + "userAgent": "antigravity", + "request": { + "contents": [ {"role": "user", "parts": [{"text": "…"}]}, … ], + "generationConfig": { "maxOutputTokens": }, + "systemInstruction": {"parts": [{"text": "…"}]}, + "tools": [{"functionDeclarations": […]}], + "thinkingConfig": {"thinkingLevel": "low|medium|high", "includeThoughts": true} + } +} +``` + +Pinned by the +`wire_shape_contract::body_uses_antigravity_user_agent_and_body_project` +test (line 837). Key constraints: + +- `userAgent` is the **string** `"antigravity"` for Antigravity IDE traffic + and `"gemini-cli"` for Gemini CLI traffic. The gateway distinguishes + clients by this field. +- `project` is the value the plugin's `token` action injected as + `x-code-assist-project`. The header and the body field must agree. +- `contents[].role` is **only** `user` or `model`. `functionResponse` + parts must ride on a `user` turn (using role `function` 400s on + `cloudcode-pa` / `generativelanguage`). +- `maxOutputTokens: 0` is rejected ("generate nothing"); the adapter + floors to `1`. +- Empty `contents` (system-only) is rejected; the adapter errors before + sending instead of letting the gateway 400. +- Gemini 3 uses `thinkingLevel` (`minimal` / `low` / `medium` / `high` / + `auto`); Gemini 2.5 uses `thinkingBudget` (numeric); Gemini 2.0 + rejects `thinkingConfig` entirely. The adapter picks the right shape + per model id (`model_supports_thinking`). + +### 3. Redirect path: `/oauth2callback` for Google + +The Antigravity and Gemini CLI bundles both declare +`redirect_path: "/oauth2callback"`. Google's installed-app OAuth clients +only accept this exact path; using the harness's default `/callback` +makes `accounts.google.com` reject the request as a non-compliant +redirect URI (error: `redirect_uri_mismatch`, hard non-compliance +per Google's OAuth 2.0 policy for installed apps). The plugin is +expected to embed the harness-provided `redirect_uri` **verbatim** in +the authorize URL — including the port and path. + +### 4. Token refresh on the hot path + +The `token` action runs on **every turn** (cached for ~5 min, then +re-run). Two consequences: + +- Keep `token` cheap. Refresh only when the cached token is near + expiry; do not call out to the IdP on every chat turn. +- The `headers` returned by `token` are **cached with the token** and + merged onto the provider's request headers. If `x-code-assist-project` + changes between calls (e.g. the user's `loadCodeAssist` rotation + swapped the project), the new value reaches the gateway on the very + next turn without a `/login` cycle. diff --git a/docs/plugins/oauth.md b/docs/plugins/oauth.md new file mode 100644 index 0000000..09ee483 --- /dev/null +++ b/docs/plugins/oauth.md @@ -0,0 +1,430 @@ +# Plugin OAuth Providers + +A plugin can add a **subscription OAuth provider** to the harness — no +recompile, no API key, the same `/login` + `/models` flow as a built-in +provider. The plugin declares an `oauth` block in `plugin.json`; the harness +owns the loopback redirect server, polling, and the per-turn token refresh +loop. The plugin supplies **one** script (or per-action overrides) that owns +the on-disk token format and any provider-specific quirks. + +This page is the wire-level spec for the `oauth` block and the harness ↔ +script contract. The terse overview lives in +[`.catalyst-code/skills/plugin-authoring/SKILL.md`](../../.catalyst-code/skills/plugin-authoring/SKILL.md) +("Declaring an OAuth provider"); the bundle catalog (which providers are +shipped with the core) lives in +[`core/providers/README.md`](../../core/providers/README.md). + +--- + +## Table of contents + +- [Full manifest schema](#full-manifest-schema) + - [Field reference](#field-reference) + - [`redirect_path`: matching the provider's registered redirect URI](#redirect_path-matching-the-providers-registered-redirect-uri) + - [`env_passthrough`: plugin-specific config knobs that survive env scrubbing](#env_passthrough-plugin-specific-config-knobs-that-survive-env-scrubbing) +- [Harness ↔ script contract](#harness--script-contract) + - [Base context (every action)](#base-context-every-action) + - [`login`](#login) + - [`complete`](#complete) + - [`token`](#token) + - [`clear`](#clear) +- [Wire-format examples](#wire-format-examples) + - [Web flow (browser on the local machine)](#web-flow-browser-on-the-local-machine) + - [Manual / headless flow (paste a code)](#manual--headless-flow-paste-a-code) + - [Automatic device-code flow](#automatic-device-code-flow) +- [How it fits into the harness](#how-it-fits-into-the-harness) +- [Reference implementations](#reference-implementations) + +--- + +## Full manifest schema + +The full `OauthManifestEntry` (mirrors `core/src/plugins.rs::OauthManifestEntry`, +the `#[derive(Deserialize)]` the harness actually parses): + +```json +{ + "name": "my-provider", + "version": "0.1.0", + "oauth": { + "provider_id": "my-provider", + "label": "My Provider (subscription)", + "kind": "openai", + "base_url": "https://api.example.com/v1", + "description": "Used in the /login picker", + "headers": [ + ["User-Agent", "my-plugin/0.1"] + ], + "token_path": "my-provider.json", + "detect_path": null, + "script": "oauth/my-provider-oauth.py", + "login_script": "oauth/login.py", + "complete_script": "oauth/complete.py", + "token_script": "oauth/token.py", + "login_timeout_ms": 180000, + "token_timeout_ms": 30000, + "redirect_path": "/oauth2callback", + "env_passthrough": [ + "MY_PROVIDER_HOST", + "CATALYST_CODE_MYPROVIDER_PROJECT" + ] + } +} +``` + +`plugin.json` must also declare the capabilities the `oauth` block implies — +`execute_subprocess`, `register_providers`, `access_network`, `access_secrets`. +The harness infers them when `capabilities` is omitted. + +### Field reference + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `provider_id` | string | **yes** | — | Stable provider identity. `/login`, `/oauth-code`, `/logout`, and the created `~/.config/catalyst-code/config.json` entry all use this name. The plugin's `name` and `provider_id` are independent. | +| `label` | string | no | `provider_id` | Human-readable name shown in the `/login` picker. | +| `kind` | string | no | `"openai"` | Wire protocol. `"openai"` → `/chat/completions` + `Authorization: Bearer`. `"anthropic"` → `/v1/messages` + `x-api-key`. The harness uses this to pick the adapter; model discovery, request building, and SSE decoding all follow it. | +| `base_url` | string | **yes** | — | Provider endpoint, including any path prefix the API expects (`/v1`, `/v1internal`, …). Paths are appended directly. | +| `description` | string | no | `""` | Shown alongside the label in the `/login` picker. | +| `headers` | array of `[name, value]` | no | `[]` | Extra HTTP headers on every request for this provider. Persisted into the `config.json` provider entry. Plugin wins on name conflicts with any header the `token` action also returns. | +| `token_path` | string | no | `.json` | Token-file name, resolved against `~/.config/catalyst-code/oauth/`. The harness passes the **absolute** path to every script invocation; the plugin owns the on-disk format. | +| `detect_path` | string | no | — | External credential file the harness can probe for cheap "already-logged-in" detection (no schema parsing). Supported patterns: `$CODEX_HOME/auth.json` and `~/.codex/auth.json`. Other paths are resolved against `$HOME` and rejected if they escape it or are absolute. The provider script remains responsible for importing the format. | +| `script` | string | conditional | — | Script handling **all** four actions, dispatched by the `action` field on stdin. Required unless every action has an explicit override. | +| `login_script` | string | no | falls back to `script` | Per-action override for `login`. | +| `complete_script` | string | no | falls back to `script` | Per-action override for `complete`. | +| `token_script` | string | no | falls back to `script` | Per-action override for `token`. **Token resolution is mandatory** — without a script for `token` (or a shared `script`), the harness rejects the manifest at load time. | +| `login_timeout_ms` | number | no | `120000` | Per-call timeout for `login` and `complete`. | +| `token_timeout_ms` | number | no | `30000` | Per-call timeout for `token` and `clear`. `token` runs on the per-turn hot path, so keep it short. | +| `redirect_path` | string | no | `"/callback"` | The path the harness binds on its loopback server for the web flow. Must match the redirect URI registered with the provider's OAuth client. See [below](#redirect_path-matching-the-providers-registered-redirect-uri). | +| `env_passthrough` | array of string | no | `[]` | Non-secret env var names the harness forwards from its own process env to the plugin's scripts. Names must be `[A-Za-z_][A-Za-z0-9_]*` and **must not** contain `KEY`, `TOKEN`, `SECRET`, `PASSWORD`, or `CREDENTIAL` (case-insensitive) — passthrough must never defeat env scrubbing. See [below](#env_passthrough-plugin-specific-config-knobs-that-survive-env-scrubbing). | + +### `redirect_path`: matching the provider's registered redirect URI + +The harness binds a loopback server (`http://localhost:/`) +on demand and embeds that exact URL in the authorize request the script +builds. **The path component is not arbitrary** — it must be one of the +redirect URIs registered with the provider's OAuth client, or the provider +will reject the request (Google, for example, returns a +`redirect_uri_mismatch` error and the spec calls this out as a hard +non-compliance). + +| Provider / client type | Required path | Why | +|------------------------|---------------|-----| +| Most OAuth clients (the default) | `/callback` | The conventional path; the harness ships this as the default so a simple `oauth` block "just works". | +| Google installed-app OAuth clients (Antigravity IDE, Gemini CLI) | `/oauth2callback` | Google only accepts this exact path for installed-app / desktop clients; `/callback` is rejected as non-compliant. | +| Self-hosted / custom IdPs | Whatever the IdP expects | E.g. a corporate IdP may require `/auth/callback` or `/oauth/callback`. | + +When to set it: + +- **Always set it for Google OAuth clients** (the Antigravity and Gemini CLI + bundles do). Verified live: omitting it on the Antigravity OAuth client + returns `redirect_uri_mismatch` from `accounts.google.com`. +- **Always set it when the provider's registered redirect URI is not + `/callback`**. Read the provider's OAuth docs. +- **Default is fine for most other providers** (ChatGPT Codex, Grok xAI, + generic OAuth/OIDC, GitHub Apps with a localhost callback, etc.). + +Implementation note: the harness prefixes a `/` if the value does not start +with one, so `redirect_path: "oauth2callback"` and +`redirect_path: "/oauth2callback"` are equivalent. Absolute paths and paths +with a scheme/host are rejected. + +### `env_passthrough`: plugin-specific config knobs that survive env scrubbing + +Plugin scripts are spawned with a **scrubbed** environment: the harness +clears the child's env and re-injects only a small allowlist (`PATH`, +`HOME`, `TMPDIR`, `USER`, plus the Windows baseline on Windows, plus a +handful of memory-provider keys). This is the defense against a plugin +script accidentally seeing — or exfiltrating — a `*_API_KEY` / `*_TOKEN` +the user exported. The cost: plugin scripts **cannot** see any env var by +default. + +`env_passthrough` is the explicit opt-in. Declare the names (not values) of +the env vars your scripts need, and the harness reads the values from its +own process env at call time and injects them into the script's child env. + +**Conventions** + +- **Plugin-specific project overrides** should follow the + `CATALYST_CODE__PROJECT` pattern so they're namespaced and easy to + grep for. Examples already in the catalog: + - `CATALYST_CODE_ANTIGRAVITY_PROJECT` — overrides the Antigravity Code + Assist `cloudaicompanionProject` (bypasses the `loadCodeAssist` + auto-discovery round-trip in tests / CI). + - `CATALYST_CODE_GEMINICLI_PROJECT` — same for the Gemini CLI bundle. +- **Self-hosted IdP overrides** typically use a `_HOST` / + `_API_URL` / `_TENANT` shape. Example: + `["ACME_OAUTH_HOST", "ACME_TENANT"]`. +- **Never** put a secret in passthrough. Names containing `KEY`, `TOKEN`, + `SECRET`, `PASSWORD`, or `CREDENTIAL` (case-insensitive) are rejected at + manifest load time. The `value` of a passthrough var lives in the + harness's env and is whatever the user exported — the harness does not + inspect or redact it, so do not use passthrough as a back door to leak + `OPENAI_API_KEY` to a plugin. (The harness already has the value; if a + plugin needs to know the API key, the user must pass it explicitly via + `api_key` on a `login` command, not via env.) +- **Validation**: the name must match `[A-Za-z_][A-Za-z0-9_]*`. Names that + are empty, contain punctuation, or start with a digit are rejected at + load. This blocks shell-injection attempts in any naive + `env("USER_SUPPLIED_$X")` plumbing. + +**Why not just allow `*`?** The whole point of env scrubbing is that a +plugin script cannot reach the user's `*_API_KEY` exports. An allowlist +keeps the trust model auditable: every env var a plugin can see is declared +in its `plugin.json`. + +--- + +## Harness ↔ script contract + +Every script invocation has the same shape: + +1. The harness writes **one JSON object** to the script's stdin. +2. The script processes it. +3. The script writes **one JSON object** to stdout (terminated by EOF or + close). Stderr is captured for error reporting. +4. The harness enforces the timeout (`login_timeout_ms` for `login`/ + `complete`; `token_timeout_ms` for `token`/`clear`), validates that + the exit was zero, parses the JSON, and either uses the response or + surfaces an error event. + +JSON input is bounded to 1 MiB and stdout/stderr to 1 MiB per invocation. +Timeouts, non-zero exits, and parse failures are surfaced as `error` events +— they never crash the core. + +### Base context (every action) + +The harness always injects these fields; each action adds its own. + +```json +{ + "action": "login", + "provider_id": "my-provider", + "token_path": "/home/user/.config/catalyst-code/oauth/my-provider.json", + "workspace": "/abs/path/to/workspace", + "timestamp": 1719000000 +} +``` + +`action` is the discriminator (`"login"`, `"complete"`, `"token"`, +`"clear"`). `token_path` is the **absolute** path the harness expects the +script to read/write; the script owns the file's format. + +### `login` + +**Input** (additions to base context): + +| Field | When | Description | +|-------|------|-------------| +| `headless` | always | `true` if the harness detected no display / no browser support; `false` otherwise. Honor it when choosing between web and manual. | +| `redirect_uri` | non-headless only | The `http://localhost:/` the harness already bound. Embed it **verbatim** in the authorize URL the script builds. | + +**Output** (any subset): + +```json +{ + "url": "https://auth.example.com/oauth/authorize?...", + "code": "ABCD-EFGH", + "message": "Open the URL and enter the code", + "flow": "web", + "state": "", + "pending": { "verifier": "", "device_id": "" } +} +``` + +- `url` (required, except for `flow: "already_authenticated"`): the + authorize/verify URL the user should open. +- `code` (optional): user-code to display for manual / device flows. +- `message` (optional, defaults to a generic prompt): UI message shown + alongside the URL. +- `flow` (optional, defaults inferred from `headless`): + - `"web"` — the harness will wait for the loopback redirect at + `redirect_uri`. + - `"manual"` — the harness stashes the `pending` blob and waits for + `/oauth-code ` from the user. + - `"poll"` or `"auto"` — the harness immediately calls `complete` and + waits for the script to drive the device-code polling loop. + - `"already_authenticated"` — the script imported an existing + credential store and no browser flow is needed; the harness skips + straight to `finalize_oauth`. +- `state` (web flow): the CSRF state you put in the authorize URL, so the + harness can verify the redirect. +- `pending`: an opaque JSON blob to carry to `complete` (PKCE verifier, + device-auth id, anything else). Passed back verbatim. + +### `complete` + +**Input** (additions to base context): + +| Field | When | Description | +|-------|------|-------------| +| `code` | web + paste flows | The authorization code the provider returned (from the redirect query string or the user's paste). | +| `redirect_uri` | web flow | The same loopback URI from `login` — re-sent so the script can re-validate the code. | +| `pending` | always | The opaque `pending` blob from `login`, if the script returned one. | + +**Output**: + +```json +{ "ok": true } +{ "ok": false, "error": "expired code" } +``` + +On `ok: true` the script **must** have written the token to `token_path` +(or sidecar files of its own design). On `ok: false` the harness surfaces +`error` as an `error` event and restores the pending state so the user can +retry with `/oauth-code`. + +### `token` + +**Input**: base context only. `action` is `"token"`. + +**Output**: + +```json +{ + "access_token": "", + "expires_at": 1719003600, + "headers": [ + ["chatgpt-account-id", ""], + ["x-code-assist-project", "my-project"] + ] +} +``` + +- `access_token` (required, non-empty): the bearer to use. The harness + injects it as `Authorization: Bearer ` for `kind: "openai"` + or `x-api-key: ` for `kind: "anthropic"`. +- `expires_at` (optional, unix seconds): when the harness should re-run + `token` to refresh. `0` or absent = cache for ~5 minutes. +- `headers` (optional): extra HTTP headers to merge onto the provider's + request headers for **this turn and every subsequent turn** (cached with + the token). Plugin wins on name conflicts. Common uses: + - `chatgpt-account-id` for ChatGPT multi-account. + - `x-code-assist-project` for Antigravity / Gemini CLI bundles + (overrides the freemium `rising-fact-p41fc` default — see + [OAuth gotchas](../../core/providers/README.md#oauth-gotchas)). + - `anthropic-beta` for Anthropic features gated on headers. + +This runs on the per-turn hot path. **Concurrency note:** several harness +processes (TUI, web service, a second TUI) can invoke `token` at the same +time, and providers commonly rotate refresh tokens. Write `token_path` +**atomically** (temp file + rename) and serialize the refresh (e.g. +`flock` on a sidecar lock, then re-check freshness before refreshing) — a +truncated read or a lost refresh-token rotation surfaces to the user as an +unexplained "run /login" prompt. + +### `clear` + +**Input**: base context only. + +**Output**: + +```json +{ "ok": true } +``` + +The harness **also** deletes `token_path`, so this action is optional. +Use it to clean up sidecar files the script manages (a refresh-token +mirror, a state file, etc.). + +--- + +## Wire-format examples + +### Web flow (browser on the local machine) + +1. The user runs `/login my-provider`. +2. The harness binds a loopback server, e.g. `http://localhost:51234/oauth2callback`. +3. The harness calls `login` with stdin: + ```json + { + "action": "login", "provider_id": "my-provider", + "token_path": "/home/user/.config/catalyst-code/oauth/my-provider.json", + "workspace": "/abs/path/to/workspace", "timestamp": 1719000000, + "headless": false, + "redirect_uri": "http://localhost:51234/oauth2callback" + } + ``` +4. The script returns: + ```json + { + "url": "https://auth.example.com/oauth/authorize?client_id=...&redirect_uri=http%3A%2F%2Flocalhost%3A51234%2Foauth2callback&state=csrf&...&code_challenge=...&code_challenge_method=S256", + "flow": "web", + "state": "csrf", + "pending": { "verifier": "" } + } + ``` +5. The harness emits an `oauth_prompt` event (URL + message) and opens + the browser. +6. The user approves; the browser hits + `http://localhost:51234/oauth2callback?code=...&state=csrf`. +7. The harness verifies `state`, calls `complete` with stdin: + ```json + { + "action": "complete", "provider_id": "my-provider", + "token_path": "...", "workspace": "...", "timestamp": 1719000050, + "code": "", "redirect_uri": "http://localhost:51234/oauth2callback", + "pending": { "verifier": "" } + } + ``` +8. The script exchanges the code, writes the token, returns `{"ok": true}`. +9. The harness calls `finalize_oauth`: creates the provider config, sets + it active, refreshes models, emits `authed` + `provider_changed`. + +### Manual / headless flow (paste a code) + +Same as web flow, but step 5 returns `flow: "manual"`. The harness emits +`oauth_prompt` and **does not** open a browser. The user pastes the code +via `/oauth-code ` (or the `oauth_code` protocol command), which +drives step 7. + +This is the right flow for SSH/headless sessions, and the recommended +flow for CI / first-party smoke tests. + +### Automatic device-code flow + +Step 5 returns `flow: "poll"` (or `"auto"`, or +`auto_complete: true`). The harness immediately calls `complete` with an +empty `code`; the script owns the polling loop. The user still sees the +URL + user-code via `oauth_prompt`, but no `/oauth-code` is needed. + +--- + +## How it fits into the harness + +- `/login ` → harness runs `login` → emits `oauth_prompt` → + waits for the redirect (web), invokes `complete` immediately (auto + poll), or stashes `pending` for `/oauth-code` (manual). On success it + creates the provider config (name = `provider_id`, your + `base_url`/`kind`/`headers`, no `api_key`) and refreshes `/models`. +- Every turn → harness runs `token` (cached), injects the access token as + `Authorization: Bearer`, merges any returned `headers`, and routes the + turn to your `base_url` over your declared `kind`. +- `/logout ` → deletes `token_path` + runs `clear` + drops + the provider config. + +The plugin's token format is entirely its own — the harness never parses +the contents of `token_path`. + +--- + +## Reference implementations + +Bundled in `core/providers//`: + +- `codex/` — ChatGPT (Codex) CLI device-code OAuth with automatic polling + and `auth.json` import. +- `antigravity/` — Google Antigravity IDE Authorization Code + PKCE with + the `loadCodeAssist` project discovery. Uses + `redirect_path: "/oauth2callback"`. +- `gemini-cli/` — Google Gemini CLI Authorization Code + PKCE with + `loadCodeAssist` project discovery. Uses + `redirect_path: "/oauth2callback"`. +- `kimi/` — Kimi Code (Moonshot) device-code OAuth. +- `deepseek/` — **not OAuth** — this is an API-key bundle, shown here only + as the side-by-side catalog entry. + +External template: `docs/examples/plugins/grok-oauth/`. + +The wire-level spec is mirrored in +`.catalyst-code/skills/plugin-authoring/SKILL.md` ("Declaring an OAuth +provider"). Update both when adding new fields. From 110c3e448625e01389d595b943381d716f0e58b7 Mon Sep 17 00:00:00 2001 From: catcode Date: Fri, 7 Aug 2026 17:11:55 -0400 Subject: [PATCH 25/38] test(core): integration test for OAuth plugin lifecycle + redirect_path --- core/tests/oauth_plugin_lifecycle.rs | 624 +++++++++++++++++++++++++++ 1 file changed, 624 insertions(+) create mode 100644 core/tests/oauth_plugin_lifecycle.rs diff --git a/core/tests/oauth_plugin_lifecycle.rs b/core/tests/oauth_plugin_lifecycle.rs new file mode 100644 index 0000000..737566f --- /dev/null +++ b/core/tests/oauth_plugin_lifecycle.rs @@ -0,0 +1,624 @@ +// Integration test: full OAuth plugin lifecycle. +// +// Drives the core binary as a subprocess (the same pattern as +// `protocol_harness.rs`) and exercises: +// +// 1. Plugin manifest load with a declared `redirect_path` and +// `env_passthrough` (the plugin loader resolves both into the +// loaded `PluginOauthConfig`). +// 2. `login` action: harness emits an `oauth_prompt` event with the +// `redirect_uri` honoring `redirect_path`. +// 3. `complete` action: harness runs the script with the pasted code; +// script writes the on-disk token file. +// 4. `token` action: harness calls the script at turn time to resolve +// the access token; script returns `access_token` + `headers`. +// 5. The `headers` from the `token` action are merged onto the +// provider's outgoing chat request — verifiable at the mock HTTP +// server. +// 6. The `env_passthrough` env var reaches the script's child env +// (despite the harness's `env_clear` + allowlist) — the script +// echoes it back as `X-Received-Env` in its `headers`. +// +// The `PluginOauthConfig` struct is private to the binary crate, so we +// exercise the loader end-to-end via the JSON-RPC protocol and verify +// behavior at observable boundaries (events the harness emits, headers +// the mock server receives). The `redirect_path` and `env_passthrough` +// fields are also re-parsed from the manifest in the test as a sanity +// check that the source of truth is what the harness loader sees. +// +// The test mirrors the existing `protocol_harness.rs` patterns: a +// `mock_provider` HTTP server on 127.0.0.1, a `CoreHarness` wrapper for +// the spawned core subprocess, and JSON-RPC command/event send/wait +// helpers. + +use serde_json::Value; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpListener; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, Receiver}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +// ---------- shared helpers ---------- + +fn read_http_request(stream: &mut std::net::TcpStream) -> String { + let _ = stream.set_read_timeout(Some(Duration::from_secs(2))); + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 8192]; + let mut header_end = None; + while let Ok(read) = stream.read(&mut buffer) { + if read == 0 { + break; + } + bytes.extend_from_slice(&buffer[..read]); + if header_end.is_none() { + header_end = bytes.windows(4).position(|window| window == b"\r\n\r\n"); + } + if let Some(end) = header_end { + let headers = String::from_utf8_lossy(&bytes[..end]); + let content_length = headers + .lines() + .find(|line| line.to_ascii_lowercase().starts_with("content-length:")) + .and_then(|line| line.split_once(':')) + .and_then(|(_, value)| value.trim().parse::().ok()) + .unwrap_or(0); + if bytes.len() >= end + 4 + content_length { + break; + } + } + } + String::from_utf8_lossy(&bytes).into_owned() +} + +fn write_json_response(stream: &mut std::net::TcpStream, body: &str) { + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); +} + +fn write_sse_chunk(stream: &mut std::net::TcpStream, payload: &str) -> bool { + let chunk = format!("{:x}\r\n{}\r\n", payload.len(), payload); + stream.write_all(chunk.as_bytes()).is_ok() && stream.flush().is_ok() +} + +fn temp_workspace() -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + // HOME points at the workspace so the harness's `~/.config/catalyst-code/oauth/` + // resolves inside the test's tempdir — never pollute the real $HOME with the + // fake test_oauth.json token file. + let path = std::env::temp_dir().join(format!("catcode-oauth-lifecycle-{nonce}")); + std::fs::create_dir_all(&path).unwrap(); + path +} + +// ---------- mock provider: models list + OpenAI-compatible chat ---------- + +struct MockProvider { + base_url: String, + stop: Arc, + handle: thread::JoinHandle<()>, + /// One slot per recorded chat request: (Authorization, x-code-assist-project, X-Received-Env). + chat_requests: Arc>>, +} + +fn spawn_mock_provider() -> MockProvider { + 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 thread_stop = stop.clone(); + let chat_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let chat_requests_thread = chat_requests.clone(); + let handle = thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(30); + while Instant::now() < deadline && !thread_stop.load(Ordering::Relaxed) { + let Ok((mut stream, _)) = listener.accept() else { + thread::sleep(Duration::from_millis(5)); + continue; + }; + let request = read_http_request(&mut stream); + let first_line = request.lines().next().unwrap_or_default(); + if first_line.starts_with("GET ") { + // Discovery probes `/models/info` (Umans-specific) first and + // falls back to the standard OpenAI `/v1/models` on a miss. + // Return 404 for the Umans-specific path so we always land in + // the standard OpenAI parser; the `/v1/models` response uses + // the canonical `data: [{id, name, ...}]` shape. + if first_line.contains("/models/info") { + let response = + "HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\nconnection: close\r\n\r\n"; + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + } else { + let body = r#"{"data":[{"id":"mock-model","name":"Mock"}]}"#; + write_json_response(&mut stream, body); + } + continue; + } + if !first_line.starts_with("POST ") { + write_json_response(&mut stream, r#"{"error":"unsupported"}"#); + continue; + } + // Record the chat request's auth/identity headers so the test + // can assert on them. Headers are case-insensitive; the harness + // may send `x-code-assist-project` from the OAuth `token` action + // and `X-Received-Env` from the same headers array (env + // passthrough round-trip). + let auth = request + .lines() + .find(|line| line.to_ascii_lowercase().starts_with("authorization:")) + .map(|line| { + line.split_once(':') + .map(|(_, v)| v.trim().to_string()) + .unwrap_or_default() + }) + .unwrap_or_default(); + let project = request + .lines() + .find(|line| { + line.to_ascii_lowercase() + .starts_with("x-code-assist-project:") + }) + .map(|line| { + line.split_once(':') + .map(|(_, v)| v.trim().to_string()) + .unwrap_or_default() + }) + .unwrap_or_default(); + let received_env = request + .lines() + .find(|line| line.to_ascii_lowercase().starts_with("x-received-env:")) + .map(|line| { + line.split_once(':') + .map(|(_, v)| v.trim().to_string()) + .unwrap_or_default() + }) + .unwrap_or_default(); + chat_requests_thread + .lock() + .unwrap() + .push((auth, project, received_env)); + // OpenAI-compatible chat completion in SSE form: a single + // text delta, then a finish chunk with usage. + let _ = stream.write_all( + b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\ + transfer-encoding: chunked\r\nconnection: close\r\n\r\n", + ); + let _ = stream.flush(); + // One text delta ("OK") + one finish chunk. The harness turns + // the finish chunk into a `done` event with the usage. + let first = format!( + "data: {}\n\n", + serde_json::json!({"choices": [{"delta": {"content": "OK"}}]}) + ); + let finish = format!( + "data: {}\n\n", + serde_json::json!({ + "choices": [{"delta": {}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 3, "completion_tokens": 1} + }) + ); + let _ = write_sse_chunk(&mut stream, &first); + let _ = write_sse_chunk(&mut stream, &finish); + let _ = stream.write_all(b"0\r\n\r\n"); + let _ = stream.flush(); + } + }); + MockProvider { + base_url: format!("http://{address}/v1"), + stop, + handle, + chat_requests, + } +} + +// ---------- the fake plugin bundle ---------- + +const FAKE_PLUGIN_NAME: &str = "test_oauth"; +const FAKE_PROVIDER_ID: &str = "test_oauth"; +const EXPECTED_REDIRECT_PATH: &str = "/oauth2callback"; +const EXPECTED_ENV_PASSTHROUGH: &[&str] = &["FAKE_TEST_VAR"]; + +const FAKE_PLUGIN_PY: &str = r#"#!/usr/bin/env python3 +"""Fake OAuth script for the oauth_plugin_lifecycle integration test. + +Drives all four actions of the OAuth contract: + + login -> return a manual-flow authorize URL + a fake user code. + The harness emits an `oauth_prompt` event; the test then + sends `oauth_code TEST-CODE` to drive `complete`. + complete -> write a token file at the harness-provided absolute path + and return {ok: true}. + token -> return a fresh access_token + the headers the test + asserts against (x-code-assist-project, X-Received-Env). + X-Received-Env is the env passthrough round-trip: the + script reads FAKE_TEST_VAR from its own env (proving the + harness forwarded it) and echoes it back as a header. + clear -> return {ok: true}. + +Stdlib only. +""" +import json +import os +import sys +import time + + +def write(obj): + sys.stdout.write(json.dumps(obj)) + sys.stdout.flush() + + +def main(): + ctx = json.loads(sys.stdin.read()) + action = ctx.get("action") + if action == "login": + write({ + "url": "http://127.0.0.1:1/auth", + "flow": "manual", + "code": "TEST-CODE", + "message": "open the URL and paste the code", + "state": "csrf-test", + "pending": {"verifier": "pkce-verifier"}, + }) + elif action == "complete": + # The script is responsible for writing the on-disk token in the + # format the plugin chose. The harness only checks existence. + token_path = ctx.get("token_path", "") + if token_path: + # The harness's token_path lives under + # `~/.config/catalyst-code/oauth/` but does NOT auto-create + # the directory. Mirror the behavior of the real bundled + # scripts (antigravity / gemini-cli) which create it before + # the first write. + import os as _os + parent = _os.path.dirname(token_path) + if parent: + _os.makedirs(parent, exist_ok=True) + with open(token_path, "w") as f: + json.dump({ + "access_token": "test-tok", + "refresh_token": "test-refresh", + "expires_at": int(time.time()) + 3600, + }, f) + write({"ok": True}) + elif action == "token": + # `env_passthrough` is forwarded to the script's child env. The + # script must NOT need to read any of the user's other env vars + # — the harness scrubs them. + received_env = os.environ.get("FAKE_TEST_VAR", "") + write({ + "access_token": "test-tok", + "expires_at": int(time.time()) + 3600, + "headers": [ + ["x-code-assist-project", "my-proj"], + ["X-Received-Env", received_env], + ], + }) + elif action == "clear": + write({"ok": True}) + else: + write({"ok": False, "error": "unknown action: %r" % action}) + + +if __name__ == "__main__": + main() +"#; + +fn write_fake_plugin(workspace: &PathBuf, base_url: &str) -> PathBuf { + let plugin_dir = workspace + .join(".catalyst-code") + .join("plugins") + .join(FAKE_PLUGIN_NAME); + std::fs::create_dir_all(plugin_dir.join("oauth")).unwrap(); + + // `plugin.json` — the manifest the harness loader reads. `redirect_path` + // and `env_passthrough` are the two new fields the test exercises; the + // rest mirrors the bundled antigravity / gemini-cli shape. + let plugin_json = serde_json::json!({ + "name": FAKE_PLUGIN_NAME, + "version": "0.1.0", + "description": "Fake OAuth plugin for the lifecycle integration test.", + "capabilities": [ + "execute_subprocess", + "register_providers", + "access_network", + "access_secrets" + ], + "oauth": { + "provider_id": FAKE_PROVIDER_ID, + "label": "Test OAuth", + "kind": "openai", + "base_url": base_url, + "description": "Round-trips redirect_path + env_passthrough for the test.", + "headers": [], + "token_path": "test_oauth.json", + "script": "oauth/test_oauth.py", + "login_timeout_ms": 30000, + "token_timeout_ms": 30000, + "redirect_path": EXPECTED_REDIRECT_PATH, + "env_passthrough": EXPECTED_ENV_PASSTHROUGH, + } + }); + std::fs::write( + plugin_dir.join("plugin.json"), + serde_json::to_string_pretty(&plugin_json).unwrap(), + ) + .unwrap(); + + let script_path = plugin_dir.join("oauth").join("test_oauth.py"); + std::fs::write(&script_path, FAKE_PLUGIN_PY).unwrap(); + // Hooks/scripts are spawned directly; .py is launched via the python + // interpreter selected by the harness, so no +x is strictly required, + // but stay consistent with bundled plugins. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&script_path).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&script_path, perms).unwrap(); + } + + plugin_dir +} + +// ---------- core harness ---------- + +struct CoreHarness { + child: std::process::Child, + stdin: std::process::ChildStdin, + events: Receiver, +} + +impl CoreHarness { + fn start(workspace: &PathBuf, home: &std::path::Path) -> Self { + let session = workspace.join("session.jsonl"); + let config = workspace.join("config.json"); + std::fs::write(&config, "{}\n").unwrap(); + let inherited_path = std::env::var("PATH").unwrap_or_default(); + let harness_path = format!("{}:{inherited_path}", workspace.join("bin").display()); + let mut child = Command::new(env!("CARGO_BIN_EXE_core")) + .args([ + "--workspace", + workspace.to_str().unwrap(), + "--session", + session.to_str().unwrap(), + "--config", + config.to_str().unwrap(), + "--approval", + "never", + "--trust-project-plugins", + ]) + // HOME = testdir so the OAuth token file lands inside it; the + // harness's `home_dir()` reads $HOME first. + .env("HOME", home) + // The plugin's `env_passthrough` declares FAKE_TEST_VAR. The + // harness's `oauth_script_env` reads it from the harness + // process env and forwards it to the script's child env. + .env("FAKE_TEST_VAR", "test-value") + .env("PATH", harness_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("failed to spawn core"); + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let (sender, events) = mpsc::channel(); + thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + let Ok(line) = line else { break }; + if let Ok(event) = serde_json::from_str(&line) { + if sender.send(event).is_err() { + break; + } + } + } + }); + Self { + child, + stdin, + events, + } + } + + fn send(&mut self, command: Value) { + writeln!(self.stdin, "{command}").unwrap(); + self.stdin.flush().unwrap(); + } + + fn until(&self, event_type: &str) -> Vec { + self.until_where(event_type, |event| event["type"] == event_type) + } + + fn until_where(&self, description: &str, predicate: impl Fn(&Value) -> bool) -> Vec { + let mut events = Vec::new(); + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + let event = self.events.recv_timeout(remaining).unwrap_or_else(|error| { + panic!( + "core did not emit {description} before timeout ({error}); events: {}", + serde_json::to_string(&events).unwrap() + ) + }); + let done = predicate(&event); + events.push(event); + if done { + return events; + } + } + } +} + +impl Drop for CoreHarness { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +// ---------- assertions over the recorded chat request ---------- + +fn assert_chat_request_carries_oauth_headers(mock: &MockProvider) { + let recorded = mock.chat_requests.lock().unwrap().clone(); + assert!( + !recorded.is_empty(), + "mock provider received no chat requests; the harness never made a turn-bound call. \ + ensure the OAuth token script returned access_token + headers so the chat request could be made." + ); + let (auth, project, received_env) = &recorded[0]; + assert!( + auth.eq_ignore_ascii_case("Bearer test-tok"), + "expected Authorization: Bearer test-tok (the `token` action's access_token), got {auth:?}" + ); + assert_eq!( + project, "my-proj", + "expected x-code-assist-project: my-proj (the `token` action's headers[0]); \ + the harness merges `token` response headers onto every chat request" + ); + assert_eq!( + received_env, "test-value", + "expected X-Received-Env: test-value; the harness must forward env_passthrough names \ + to the script's child env (proves the env_passthrough round-trip end-to-end)" + ); +} + +// ---------- the test ---------- + +#[test] +fn oauth_plugin_lifecycle_loads_token_round_trip_and_injects_headers() { + // 1. Spawn the mock HTTP provider; record its URL so the fake plugin + // can point its `base_url` at it. The mock serves `/v1/models` and + // `/v1/chat/completions` (OpenAI-compatible). + let mock = spawn_mock_provider(); + let workspace = temp_workspace(); + // The harness reads `~/.config/catalyst-code/oauth/...` for the token + // file. Reusing the workspace as HOME keeps the test fully + // self-contained. + let plugin_dir = write_fake_plugin(&workspace, &mock.base_url); + + // 2. Sanity check: the manifest on disk is the source of truth the + // loader sees. (The `PluginOauthConfig` struct is a 1:1 + // deserialization of this `oauth` block — verifying the manifest + // verifies the loaded config's two new fields.) + let manifest_text = std::fs::read_to_string(plugin_dir.join("plugin.json")).unwrap(); + let manifest: Value = serde_json::from_str(&manifest_text).unwrap(); + let oauth = manifest + .get("oauth") + .expect("plugin.json has an oauth block"); + assert_eq!( + oauth.get("redirect_path").and_then(|v| v.as_str()), + Some(EXPECTED_REDIRECT_PATH), + "manifest's redirect_path must match — this is the field the \ + harness honors when binding the loopback redirect for the web \ + flow (Google's installed-app OAuth clients require /oauth2callback)" + ); + let passthrough: Vec = oauth + .get("env_passthrough") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + assert_eq!( + passthrough, + EXPECTED_ENV_PASSTHROUGH + .iter() + .map(|s| s.to_string()) + .collect::>(), + "manifest's env_passthrough must list the test env var names the harness should forward" + ); + + // 3. Spawn the core binary as a subprocess and drive the protocol. + let mut core = CoreHarness::start(&workspace, &workspace); + + // 4. init -> protocol_hello. The harness loads plugins before this + // handshake completes, so a malformed `oauth` block would surface + // as a load error here (no protocol_hello). The fact that we get + // past this proves the loader accepted the manifest and built a + // valid `PluginOauthConfig` (the loader rejects entries that have + // neither `script` nor `token_script`, invalid `kind`, + // secret-looking passthrough names, etc.). + core.send(serde_json::json!({"type":"init","protocol_version":2})); + let hello = core.until("protocol_hello"); + let hello_event = hello.last().unwrap(); + assert_eq!(hello_event["type"], "protocol_hello"); + + // 5. login_oauth test_oauth -> oauth_prompt. The script's `login` + // action returns flow: "manual" so the harness stashes the pending + // blob and waits for `oauth_code` instead of opening a browser. + core.send(serde_json::json!({"type":"login_oauth","preset":FAKE_PROVIDER_ID})); + let prompt = core.until("oauth_prompt"); + let prompt_event = prompt.last().unwrap(); + assert_eq!(prompt_event["type"], "oauth_prompt"); + assert_eq!( + prompt_event["code"].as_str(), + Some("TEST-CODE"), + "oauth_prompt should carry the user code returned by the script's login action" + ); + + // 6. oauth_code TEST-CODE -> the harness calls the script's + // `complete` action. The script writes the on-disk token and + // returns ok:true, which triggers `finalize_oauth`: emit `authed` + // + `provider_changed` + `info`, then refresh models (which hits + // our mock's /v1/models). + core.send(serde_json::json!({"type":"oauth_code","code":"TEST-CODE"})); + let events = core.until("authed"); + assert!(events + .iter() + .any(|event| event["type"] == "authed" && event["ok"] == true)); + // The provider_changed event confirms the plugin's base_url / kind / + // headers were promoted into the live provider config. + let provider_changed = core.until_where("provider_changed", |event| { + event["type"] == "provider_changed" && event["provider"] == FAKE_PROVIDER_ID + }); + let pc = provider_changed.last().unwrap(); + assert_eq!(pc["provider"], FAKE_PROVIDER_ID); + assert_eq!(pc["base_url"], mock.base_url); + assert_eq!(pc["kind"], "openai"); + assert_eq!(pc["has_key"], true); + + // 7. send a turn -> the harness calls enrich_oauth -> the script's + // `token` action. The script returns access_token + headers; the + // harness caches them and merges the headers onto the chat + // request that follows. + core.send(serde_json::json!({ + "type":"send", + "prompt":"round-trip the token", + "model":"mock-model", + "provider":FAKE_PROVIDER_ID + })); + let done_events = core.until("done"); + assert!(done_events + .iter() + .any(|event| event["type"] == "delta" && event["text"] == "OK"), + "no 'OK' delta in done events — the harness did not make a turn-bound call. events: {}", + serde_json::to_string(&done_events).unwrap()); + assert!(done_events.iter().any(|event| event["type"] == "done")); + + // 8. The mock provider must have received a chat request carrying the + // `token` action's `access_token` (as the Bearer) and `headers` + // (the x-code-assist-project + X-Received-Env round-trip). This is + // the final observable check that the loader + token action + + // provider header merge pipeline all work end-to-end. + assert_chat_request_carries_oauth_headers(&mock); + + // Cleanup. + drop(core); + mock.stop.store(true, Ordering::Relaxed); + let _ = mock.handle.join(); + let _ = std::fs::remove_dir_all(&workspace); +} From 16a1a35f360f08ddf72a5a28c2abcd3aafac9c6b Mon Sep 17 00:00:00 2001 From: catcode Date: Fri, 7 Aug 2026 17:16:48 -0400 Subject: [PATCH 26/38] test(providers): add e2e OAuth tests for antigravity (mock HTTP) Stdlib-only end-to-end tests that drive the Antigravity OAuth provider script against a local mock HTTP server. Covers PKCE S256 URL shape (client id, scopes, code_challenge = SHA256(verifier) base64url, access_type=offline, no prompt), complete -> token-file persistence with project_id from loadCodeAssist (file mode 0o600), refresh-grant preserves project_id + email across rotations, no-token returns null, onboarding fallback (loadCodeAssist returns tiers-only, onboardUser polls until done=true), clear removes the token + .lock sidecar, and the CATALYST_CODE_ANTIGRAVITY_PROJECT escape hatch wins over loadCodeAssist. Adds empty __init__.py markers under core/providers/ so 'python3 -m unittest discover -s core/providers' finds the tests, and a Python __pycache__ entry in .gitignore. --- .gitignore | 6 + core/providers/__init__.py | 0 core/providers/antigravity/__init__.py | 0 core/providers/antigravity/oauth/__init__.py | 0 .../oauth/test_antigravity_oauth.py | 537 ++++++++++++++++++ 5 files changed, 543 insertions(+) create mode 100644 core/providers/__init__.py create mode 100644 core/providers/antigravity/__init__.py create mode 100644 core/providers/antigravity/oauth/__init__.py create mode 100644 core/providers/antigravity/oauth/test_antigravity_oauth.py diff --git a/.gitignore b/.gitignore index c206b3e..940eb5b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,12 @@ core/target/ **/*.rs.bk +# Python +__pycache__/ +**/__pycache__/ +*.pyc +*.pyo + # Go (tui/) compiled binaries tui/tui tui/catalyst-code-tui diff --git a/core/providers/__init__.py b/core/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/providers/antigravity/__init__.py b/core/providers/antigravity/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/providers/antigravity/oauth/__init__.py b/core/providers/antigravity/oauth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/providers/antigravity/oauth/test_antigravity_oauth.py b/core/providers/antigravity/oauth/test_antigravity_oauth.py new file mode 100644 index 0000000..4984172 --- /dev/null +++ b/core/providers/antigravity/oauth/test_antigravity_oauth.py @@ -0,0 +1,537 @@ +#!/usr/bin/env python3 +"""End-to-end tests for the Antigravity OAuth provider script. + +Stdlib-only (``unittest``, ``http.server``, ``threading``, ``tempfile``, +``json``, ``urllib``, ``contextlib``, ``base64``, ``hashlib``, +``stat``, ``io``, ``os``, ``sys``). The provider script is exercised +in-process by rewriting its URL constants to point at a local mock HTTP +server and ``exec``'ing the patched source in a namespace with +``__file__`` set so the relative ``../../_shared/google_oauth.py`` +import still resolves. + +The harness JSON contract (per the script's stdin/stdout) is: + + stdin :: {"action": "login"|"complete"|"token"|"clear", ...} + stdout :: action-specific JSON object (see the script docstring) + +These tests cover the contract end-to-end against a mock Google auth +server so we can verify URL shape, PKCE, refresh-token rotation, +project-id discovery + overrides, and the on-disk file mode without +needing real Antigravity / Google credentials. +""" + +import base64 +import contextlib +import hashlib +import http.server +import io +import json +import os +import socketserver +import stat +import sys +import tempfile +import threading +import time +import unittest +import urllib.parse + + +HERE = os.path.dirname(os.path.abspath(__file__)) +ANTIGRAVITY_SCRIPT = os.path.abspath(os.path.join(HERE, "antigravity-oauth.py")) +SHARED_DIR = os.path.abspath(os.path.join(HERE, "..", "..", "_shared")) + +ANTIGRAVITY_CLIENT_ID = ( + "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" +) + +# Wire-level URLs the script hits. We rewrite each constant in the script +# source to point at the local mock server so urlopen() stays a 127.0.0.1 +# call. The path segments are kept verbatim so the test handlers can +# distinguish /token from /loadCodeAssist etc. +URL_REWRITES = { + "https://accounts.google.com/o/oauth2/v2/auth": "http://127.0.0.1:{port}/auth", + "https://oauth2.googleapis.com/token": "http://127.0.0.1:{port}/token", + "https://www.googleapis.com/oauth2/v1/userinfo": "http://127.0.0.1:{port}/userinfo", + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist": ( + "http://127.0.0.1:{port}/loadCodeAssist" + ), + "https://cloudcode-pa.googleapis.com/v1internal:onboardUser": ( + "http://127.0.0.1:{port}/onboardUser" + ), +} + + +# ─── mock HTTP server ────────────────────────────────────────────────────── + + +class MockGoogle: + """Stand-in for ``accounts.google.com`` + ``*.googleapis.com`` endpoints. + + Each test registers a handler per URL path. Handlers receive the parsed + request body (dict) and headers (dict); return ``(status, dict_body)``. + All requests are also appended to ``request_log`` so tests can assert + on call counts, headers, and bodies after the fact. + """ + + def __init__(self): + self.handlers = {} + self.request_log = [] + self._server = None + self._thread = None + self.port = None + + def route(self, path): + def deco(fn): + self.handlers[path] = fn + return fn + return deco + + def _parse_body(self, raw, headers): + ct = "" + for k, v in headers.items(): + if k.lower() == "content-type": + ct = v or "" + break + if not raw: + return {} + if "json" in ct.lower(): + try: + value = json.loads(raw) + return value if isinstance(value, dict) else {} + except Exception: + return {} + try: + return dict(urllib.parse.parse_qsl(raw, keep_blank_values=True)) + except Exception: + return {} + + def _dispatch(self, path, raw, headers): + body = self._parse_body(raw, headers) + # Normalise header keys so handlers can do case-insensitive lookups. + normalised = {} + for k, v in headers.items(): + normalised[k] = v + normalised[k.lower()] = v + self.request_log.append({"path": path, "body": body, "headers": normalised}) + handler = self.handlers.get(path) + if handler is None: + return 404, {"error": "not_found", "path": path} + return handler(body, normalised) + + def start(self): + outer = self + + class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *args, **kwargs): + pass # silence stderr noise + + def _handle(self): + length = int(self.headers.get("Content-Length", "0") or "0") + raw = self.rfile.read(length).decode("utf-8") if length else "" + status, payload = outer._dispatch(self.path, raw, dict(self.headers)) + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self): + self._handle() + + def do_GET(self): + # ``fetch_user_email`` uses GET on ``/userinfo`` via + # ``urllib.request.Request(USERINFO_URL, headers=...)``. + self._handle() + + class TCPServer(socketserver.TCPServer): + allow_reuse_address = True + + self._server = TCPServer(("127.0.0.1", 0), Handler) + self.port = self._server.server_address[1] + self._thread = threading.Thread( + target=self._server.serve_forever, daemon=True + ) + self._thread.start() + + def stop(self): + if self._server is not None: + self._server.shutdown() + self._server.server_close() + self._server = None + self._thread = None + + +# ─── script runner ───────────────────────────────────────────────────────── + + +def _patch_source(src, port, poll_sleep=0): + """Rewrite URL constants + zero out ONBOARD_POLL_S for fast tests.""" + for old, tmpl in URL_REWRITES.items(): + src = src.replace(old, tmpl.format(port=port)) + src = src.replace("ONBOARD_POLL_S = 2", f"ONBOARD_POLL_S = {int(poll_sleep)}") + return src + + +def run_script(ctx, port=None): + """Execute one action against the script and return its JSON stdout. + + The script's ``die()`` helper raises ``SystemExit(0)`` after emitting + an ``{"ok": false, "error": ...}`` envelope — we swallow that so a + scripted error doesn't abort the whole test process. + """ + with open(ANTIGRAVITY_SCRIPT, encoding="utf-8") as handle: + src = handle.read() + if port is not None: + src = _patch_source(src, port) + saved_stdin, saved_stdout = sys.stdin, sys.stdout + out = io.StringIO() + try: + sys.stdin = io.StringIO(json.dumps(ctx)) + sys.stdout = out + # ``__name__`` must be ``"__main__"`` so the script's + # ``if __name__ == "__main__": main()`` block dispatches the + # action — same wiring as ``python antigravity-oauth.py``. + ns = {"__name__": "__main__", "__file__": ANTIGRAVITY_SCRIPT} + try: + exec(compile(src, ANTIGRAVITY_SCRIPT, "exec"), ns) + except SystemExit: + pass + finally: + sys.stdin, sys.stdout = saved_stdin, saved_stdout + raw = out.getvalue() + return json.loads(raw) if raw.strip() else {} + + +@contextlib.contextmanager +def temp_env(**overrides): + """Snapshot + restore os.environ for the duration of the block.""" + saved = {} + for key, value in overrides.items(): + saved[key] = os.environ.get(key) + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + try: + yield + finally: + for key, value in saved.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + +def _pkce_challenge(verifier): + """SHA256(verifier) -> base64url-no-padding, matching ``google_oauth.make_pkce``.""" + digest = hashlib.sha256(verifier.encode("ascii")).digest() + return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + + +# ─── tests ───────────────────────────────────────────────────────────────── + + +class AntigravityOAuthTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.token_path = os.path.join(self.tmp.name, "antigravity.json") + self.mock = MockGoogle() + + def tearDown(self): + self.mock.stop() + self.tmp.cleanup() + + def test_login_pkce_s256_url_shape(self): + redirect_uri = "http://127.0.0.1:8085/oauth2callback" + ctx = {"action": "login", "redirect_uri": redirect_uri} + out = run_script(ctx) + + # Login envelope + self.assertEqual(out.get("flow"), "web") + self.assertIn("state", out) + self.assertIn("pending", out) + self.assertIn("verifier", out["pending"]) + self.assertIn("url", out) + + # URL shape + parsed = urllib.parse.urlparse(out["url"]) + params = urllib.parse.parse_qs(parsed.query) + self.assertEqual(parsed.scheme, "https") + self.assertEqual(parsed.netloc, "accounts.google.com") + self.assertEqual(parsed.path, "/o/oauth2/v2/auth") + + # Client id is the public Antigravity IDE client. + self.assertEqual(params.get("client_id", [""])[0], ANTIGRAVITY_CLIENT_ID) + self.assertEqual(params.get("response_type", [""])[0], "code") + self.assertEqual(params.get("redirect_uri", [""])[0], redirect_uri) + self.assertEqual(params.get("state", [""])[0], out["state"]) + + # Scopes — exactly the 5 Antigravity scopes; no openid, no + # arbitrary extras. + scopes = set((params.get("scope", [""])[0]).split()) + expected = { + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/cclog", + "https://www.googleapis.com/auth/experimentsandconfigs", + } + self.assertEqual(scopes, expected) + self.assertNotIn("openid", scopes) + + # PKCE S256 with challenge = SHA256(verifier) base64url-no-padding. + self.assertEqual(params.get("code_challenge_method", [""])[0], "S256") + verifier = out["pending"]["verifier"] + self.assertEqual( + params.get("code_challenge", [""])[0], _pkce_challenge(verifier) + ) + + # Offline access required for refresh_token; no prompt=consent. + self.assertEqual(params.get("access_type", [""])[0], "offline") + self.assertNotIn("prompt", params) + + def test_complete_persists_token_with_project(self): + self.mock.start() + + @self.mock.route("/token") + def token(body, _headers): + self.assertEqual(body.get("grant_type"), "authorization_code") + self.assertEqual(body.get("code"), "fake-auth-code") + self.assertEqual(body.get("code_verifier"), "test-verifier") + self.assertEqual(body.get("redirect_uri"), "http://127.0.0.1:8085/oauth2callback") + self.assertEqual(body.get("client_id"), ANTIGRAVITY_CLIENT_ID) + return 200, { + "access_token": "fake-access-token", + "refresh_token": "fake-refresh-token", + "expires_in": 3600, + "scope": "cloud-platform", + "token_type": "Bearer", + } + + @self.mock.route("/loadCodeAssist") + def load(body, _headers): + return 200, {"cloudaicompanionProject": "test-project-123"} + + @self.mock.route("/userinfo") + def userinfo(_body, headers): + auth = headers.get("Authorization") or headers.get("authorization") + self.assertTrue(auth and auth.startswith("Bearer ")) + return 200, {"email": "test@example.com"} + + ctx = { + "action": "complete", + "code": "fake-auth-code", + "pending": {"verifier": "test-verifier"}, + "redirect_uri": "http://127.0.0.1:8085/oauth2callback", + "token_path": self.token_path, + } + with temp_env(CATALYST_CODE_ANTIGRAVITY_PROJECT=""): + out = run_script(ctx, port=self.mock.port) + + self.assertEqual(out, {"ok": True}) + + # On-disk token contract. + self.assertTrue(os.path.exists(self.token_path), "token file was not written") + with open(self.token_path, encoding="utf-8") as handle: + token = json.load(handle) + + self.assertEqual(token["access_token"], "fake-access-token") + self.assertEqual(token["refresh_token"], "fake-refresh-token") + self.assertEqual(token["project_id"], "test-project-123") + self.assertEqual(token["email"], "test@example.com") + # expires_at ≈ now + 3600; sanity-check ±5s slack. + self.assertGreater(token["expires_at"], int(time.time()) + 3590) + + # File mode 0o600 — secrets at rest. + mode = stat.S_IMODE(os.stat(self.token_path).st_mode) + self.assertEqual(mode, 0o600) + + # loadCodeAssist was called exactly once and used the right metadata. + load_calls = [r for r in self.mock.request_log if r["path"] == "/loadCodeAssist"] + self.assertEqual(len(load_calls), 1) + self.assertEqual(load_calls[0]["body"]["metadata"]["ideType"], 9) + self.assertEqual(load_calls[0]["body"]["metadata"]["pluginType"], 2) + + def test_token_refresh_preserves_project_id_and_email(self): + # Pre-write a near-expiry token so the script refreshes it. + now = int(time.time()) + seed = { + "access_token": "old-access", + "refresh_token": "old-refresh", + "expires_in": 60, + "expires_at": now + 60, + "scope": "", + "token_type": "Bearer", + "project_id": "preserved-project", + "email": "preserved@example.com", + } + with open(self.token_path, "w", encoding="utf-8") as handle: + json.dump(seed, handle) + os.chmod(self.token_path, 0o600) + + self.mock.start() + + @self.mock.route("/token") + def token(body, _headers): + self.assertEqual(body.get("grant_type"), "refresh_token") + self.assertEqual(body.get("refresh_token"), "old-refresh") + # Deliberately omit refresh_token in the response to verify the + # script preserves the old one across rotations. + return 200, { + "access_token": "new-access", + "expires_in": 3600, + "token_type": "Bearer", + } + + out = run_script( + {"action": "token", "token_path": self.token_path}, + port=self.mock.port, + ) + + self.assertEqual(out["access_token"], "new-access") + self.assertEqual(out["expires_at"], int(time.time()) + 3600) + self.assertEqual( + out["headers"], + [["x-code-assist-project", "preserved-project"]], + ) + + # CRITICAL: must not regress to x-goog-user-project — that header + # forces the Cloud Code Private API enablement check and 403s on + # free-tier / managed projects. + header_names = {h[0] for h in out["headers"]} + self.assertNotIn("x-goog-user-project", header_names) + + # Token file on disk has the new access token and preserved metadata. + with open(self.token_path, encoding="utf-8") as handle: + rotated = json.load(handle) + self.assertEqual(rotated["access_token"], "new-access") + self.assertEqual(rotated["refresh_token"], "old-refresh") + self.assertEqual(rotated["project_id"], "preserved-project") + self.assertEqual(rotated["email"], "preserved@example.com") + self.assertEqual( + stat.S_IMODE(os.stat(self.token_path).st_mode), 0o600 + ) + + def test_token_returns_null_when_no_file(self): + out = run_script({"action": "token", "token_path": self.token_path}) + self.assertEqual(out, {"access_token": None}) + + def test_onboarding_fallback(self): + """loadCodeAssist returns tiers-only (no project); onboardUser must + be polled and the resulting project persisted.""" + self.mock.start() + onboard_calls = [] + + @self.mock.route("/token") + def token(_body, _headers): + return 200, { + "access_token": "fake-access", + "refresh_token": "fake-refresh", + "expires_in": 3600, + "token_type": "Bearer", + } + + @self.mock.route("/loadCodeAssist") + def load(_body, _headers): + return 200, { + "allowedTiers": [ + {"id": "free-tier", "isDefault": True}, + {"id": "legacy-tier", "isDefault": False}, + ], + # No cloudaicompanionProject — forces the onboard fallback. + } + + @self.mock.route("/onboardUser") + def onboard(body, _headers): + onboard_calls.append(body) + if len(onboard_calls) < 3: + return 200, {"done": False} + return 200, { + "done": True, + "response": {"cloudaicompanionProject": "onboarded-proj"}, + } + + ctx = { + "action": "complete", + "code": "fake-code", + "pending": {"verifier": "fake-verifier"}, + "redirect_uri": "http://127.0.0.1:8085/oauth2callback", + "token_path": self.token_path, + } + with temp_env(CATALYST_CODE_ANTIGRAVITY_PROJECT=""): + out = run_script(ctx, port=self.mock.port) + + self.assertEqual(out, {"ok": True}) + self.assertGreaterEqual( + len(onboard_calls), 3, + "onboardUser should have been polled until done=true", + ) + # The script must echo the default tier id in the request body. + sent_tiers = [c.get("tierId") for c in onboard_calls] + self.assertTrue(all(t == "free-tier" for t in sent_tiers)) + + with open(self.token_path, encoding="utf-8") as handle: + token = json.load(handle) + self.assertEqual(token["project_id"], "onboarded-proj") + + def test_clear_removes_token_file(self): + # Seed both the token file and the .lock sidecar the script may have + # left behind from a previous refresh. + with open(self.token_path, "w", encoding="utf-8") as handle: + json.dump({"access_token": "x"}, handle) + with open(self.token_path + ".lock", "w", encoding="utf-8") as handle: + handle.write("") + + out = run_script({"action": "clear", "token_path": self.token_path}) + self.assertEqual(out, {"ok": True}) + + self.assertFalse(os.path.exists(self.token_path)) + self.assertFalse(os.path.exists(self.token_path + ".lock")) + + def test_env_override_project_wins(self): + """CATALYST_CODE_ANTIGRAVITY_PROJECT wins over loadCodeAssist. + + This is the escape hatch for users whose auto-provisioned project + is Google-managed (no Cloud Console access) — the script must + persist the override even when loadCodeAssist returns a project. + """ + self.mock.start() + + @self.mock.route("/token") + def token(_body, _headers): + return 200, { + "access_token": "fake-access", + "refresh_token": "fake-refresh", + "expires_in": 3600, + "token_type": "Bearer", + } + + @self.mock.route("/loadCodeAssist") + def load(_body, _headers): + return 200, {"cloudaicompanionProject": "real-load-project"} + + @self.mock.route("/userinfo") + def userinfo(_body, _headers): + return 200, {"email": ""} + + ctx = { + "action": "complete", + "code": "fake-code", + "pending": {"verifier": "fake-verifier"}, + "redirect_uri": "http://127.0.0.1:8085/oauth2callback", + "token_path": self.token_path, + } + with temp_env(CATALYST_CODE_ANTIGRAVITY_PROJECT="override-project"): + out = run_script(ctx, port=self.mock.port) + + self.assertEqual(out, {"ok": True}) + + with open(self.token_path, encoding="utf-8") as handle: + token = json.load(handle) + self.assertEqual(token["project_id"], "override-project") + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file From 257db0adc2053e68970192f6a4a8552482cefdd4 Mon Sep 17 00:00:00 2001 From: catcode Date: Fri, 7 Aug 2026 17:16:52 -0400 Subject: [PATCH 27/38] test(providers): add e2e OAuth tests for gemini-cli (mock HTTP) Stdlib-only end-to-end tests that drive the Gemini CLI OAuth provider script against a local mock HTTP server. Covers PKCE S256 URL shape (only the 3 cloud-platform scopes, no openid / cclog / experimentsandconfigs, no prompt), the sibling-Antigravity-token fallback for free-tier loadCodeAssist UNSUPPORTED_CLIENT (both via $HOME/.config and CATALYST_CODE_OAUTH_DIR override), refresh-grant preserves project_id + email, no-token returns null, clear removes the token + .lock sidecar, and a regression test that asserts 'openid' is never present in the scope list. --- core/providers/gemini-cli/__init__.py | 0 core/providers/gemini-cli/oauth/__init__.py | 0 .../gemini-cli/oauth/test_gemini_cli_oauth.py | 506 ++++++++++++++++++ 3 files changed, 506 insertions(+) create mode 100644 core/providers/gemini-cli/__init__.py create mode 100644 core/providers/gemini-cli/oauth/__init__.py create mode 100644 core/providers/gemini-cli/oauth/test_gemini_cli_oauth.py diff --git a/core/providers/gemini-cli/__init__.py b/core/providers/gemini-cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/providers/gemini-cli/oauth/__init__.py b/core/providers/gemini-cli/oauth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/providers/gemini-cli/oauth/test_gemini_cli_oauth.py b/core/providers/gemini-cli/oauth/test_gemini_cli_oauth.py new file mode 100644 index 0000000..5b7a963 --- /dev/null +++ b/core/providers/gemini-cli/oauth/test_gemini_cli_oauth.py @@ -0,0 +1,506 @@ +#!/usr/bin/env python3 +"""End-to-end tests for the Gemini CLI OAuth provider script. + +Stdlib-only. The provider script is exercised in-process by rewriting +its URL constants to point at a local mock HTTP server and ``exec``'ing +the patched source in a namespace with ``__name__ = "__main__"`` and +``__file__`` pointing at the real script (so the relative +``../../_shared/google_oauth.py`` import still resolves). + +The harness JSON contract (per the script's stdin/stdout) is: + + stdin :: {"action": "login"|"complete"|"token"|"clear", ...} + stdout :: action-specific JSON object (see the script docstring) + +These tests cover the contract end-to-end against a mock Google auth +server so we can verify URL shape, PKCE, refresh-token rotation, +the sibling-Antigravity-token fallback for free-tier users, and the +on-disk file mode without needing real Gemini CLI / Google credentials. +""" + +import base64 +import contextlib +import hashlib +import http.server +import io +import json +import os +import socketserver +import stat +import sys +import tempfile +import threading +import time +import unittest +import urllib.parse + + +HERE = os.path.dirname(os.path.abspath(__file__)) +GEMINI_CLI_SCRIPT = os.path.abspath(os.path.join(HERE, "gemini-cli-oauth.py")) +SHARED_DIR = os.path.abspath(os.path.join(HERE, "..", "..", "_shared")) + +GEMINI_CLI_CLIENT_ID = ( + "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com" +) + +URL_REWRITES = { + "https://accounts.google.com/o/oauth2/v2/auth": "http://127.0.0.1:{port}/auth", + "https://oauth2.googleapis.com/token": "http://127.0.0.1:{port}/token", + "https://www.googleapis.com/oauth2/v1/userinfo": "http://127.0.0.1:{port}/userinfo", + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist": ( + "http://127.0.0.1:{port}/loadCodeAssist" + ), + "https://cloudcode-pa.googleapis.com/v1internal:onboardUser": ( + "http://127.0.0.1:{port}/onboardUser" + ), +} + + +# ─── mock HTTP server ────────────────────────────────────────────────────── + + +class MockGoogle: + """Stand-in for ``accounts.google.com`` + ``*.googleapis.com`` endpoints.""" + + def __init__(self): + self.handlers = {} + self.request_log = [] + self._server = None + self._thread = None + self.port = None + + def route(self, path): + def deco(fn): + self.handlers[path] = fn + return fn + return deco + + def _parse_body(self, raw, headers): + ct = "" + for k, v in headers.items(): + if k.lower() == "content-type": + ct = v or "" + break + if not raw: + return {} + if "json" in ct.lower(): + try: + value = json.loads(raw) + return value if isinstance(value, dict) else {} + except Exception: + return {} + try: + return dict(urllib.parse.parse_qsl(raw, keep_blank_values=True)) + except Exception: + return {} + + def _dispatch(self, path, raw, headers): + body = self._parse_body(raw, headers) + normalised = {} + for k, v in headers.items(): + normalised[k] = v + normalised[k.lower()] = v + self.request_log.append({"path": path, "body": body, "headers": normalised}) + handler = self.handlers.get(path) + if handler is None: + return 404, {"error": "not_found", "path": path} + return handler(body, normalised) + + def start(self): + outer = self + + class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *args, **kwargs): + pass + + def _handle(self): + length = int(self.headers.get("Content-Length", "0") or "0") + raw = self.rfile.read(length).decode("utf-8") if length else "" + status, payload = outer._dispatch(self.path, raw, dict(self.headers)) + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self): + self._handle() + + def do_GET(self): + # ``fetch_user_email`` GETs ``/userinfo``. + self._handle() + + class TCPServer(socketserver.TCPServer): + allow_reuse_address = True + + self._server = TCPServer(("127.0.0.1", 0), Handler) + self.port = self._server.server_address[1] + self._thread = threading.Thread( + target=self._server.serve_forever, daemon=True + ) + self._thread.start() + + def stop(self): + if self._server is not None: + self._server.shutdown() + self._server.server_close() + self._server = None + self._thread = None + + +# ─── script runner ───────────────────────────────────────────────────────── + + +def _patch_source(src, port, poll_sleep=0): + for old, tmpl in URL_REWRITES.items(): + src = src.replace(old, tmpl.format(port=port)) + src = src.replace("ONBOARD_POLL_S = 2", f"ONBOARD_POLL_S = {int(poll_sleep)}") + return src + + +def run_script(ctx, port=None): + with open(GEMINI_CLI_SCRIPT, encoding="utf-8") as handle: + src = handle.read() + if port is not None: + src = _patch_source(src, port) + saved_stdin, saved_stdout = sys.stdin, sys.stdout + out = io.StringIO() + try: + sys.stdin = io.StringIO(json.dumps(ctx)) + sys.stdout = out + ns = {"__name__": "__main__", "__file__": GEMINI_CLI_SCRIPT} + try: + exec(compile(src, GEMINI_CLI_SCRIPT, "exec"), ns) + except SystemExit: + pass + finally: + sys.stdin, sys.stdout = saved_stdin, saved_stdout + raw = out.getvalue() + return json.loads(raw) if raw.strip() else {} + + +@contextlib.contextmanager +def temp_env(**overrides): + saved = {} + for key, value in overrides.items(): + saved[key] = os.environ.get(key) + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + try: + yield + finally: + for key, value in saved.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + +def _pkce_challenge(verifier): + digest = hashlib.sha256(verifier.encode("ascii")).digest() + return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + + +# ─── tests ───────────────────────────────────────────────────────────────── + + +class GeminiCliOAuthTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.token_path = os.path.join(self.tmp.name, "gemini-cli.json") + self.mock = MockGoogle() + + def tearDown(self): + self.mock.stop() + self.tmp.cleanup() + + # ── login ──────────────────────────────────────────────────────────── + + def test_login_pkce_s256_url_shape(self): + redirect_uri = "http://127.0.0.1:8085/oauth2callback" + ctx = {"action": "login", "redirect_uri": redirect_uri} + out = run_script(ctx) + + self.assertEqual(out.get("flow"), "web") + self.assertIn("state", out) + self.assertIn("pending", out) + self.assertIn("verifier", out["pending"]) + self.assertIn("url", out) + + parsed = urllib.parse.urlparse(out["url"]) + params = urllib.parse.parse_qs(parsed.query) + self.assertEqual(parsed.scheme, "https") + self.assertEqual(parsed.netloc, "accounts.google.com") + self.assertEqual(parsed.path, "/o/oauth2/v2/auth") + + self.assertEqual(params.get("client_id", [""])[0], GEMINI_CLI_CLIENT_ID) + self.assertEqual(params.get("response_type", [""])[0], "code") + self.assertEqual(params.get("redirect_uri", [""])[0], redirect_uri) + self.assertEqual(params.get("state", [""])[0], out["state"]) + + # gemini-cli scopes are exactly the 3 cloud-platform ones — no + # openid, no cclog, no experimentsandconfigs. + scopes = set((params.get("scope", [""])[0]).split()) + expected = { + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + } + self.assertEqual(scopes, expected) + + # PKCE S256. + self.assertEqual(params.get("code_challenge_method", [""])[0], "S256") + verifier = out["pending"]["verifier"] + self.assertEqual( + params.get("code_challenge", [""])[0], _pkce_challenge(verifier) + ) + + self.assertEqual(params.get("access_type", [""])[0], "offline") + # Mirror the official ``gemini`` CLI: no ``prompt=consent``, no + # ``include_granted_scopes=true`` — both can confuse refresh-token + # issuance for this public-but-unverified OAuth client. + self.assertNotIn("prompt", params) + self.assertNotIn("include_granted_scopes", params) + + def test_no_openid_in_scope(self): + """Regression: scope MUST NOT contain ``openid``. + + Including ``openid`` triggers Google's "unverified app" rejection + for this public-but-unverified OAuth client. The gemini-cli project + deliberately omits it; ``userinfo.email`` + ``userinfo.profile`` + are sufficient for the loadCodeAssist user-info lookup. + """ + out = run_script( + {"action": "login", "redirect_uri": "http://127.0.0.1:8085/oauth2callback"} + ) + parsed = urllib.parse.urlparse(out["url"]) + params = urllib.parse.parse_qs(parsed.query) + scopes = set((params.get("scope", [""])[0]).split()) + self.assertNotIn("openid", scopes) + self.assertNotIn( + "https://www.googleapis.com/auth/openid", scopes + ) + # And the gemini-cli-specific scopes must not appear (those belong + # to Antigravity, not gemini-cli). + self.assertNotIn( + "https://www.googleapis.com/auth/cclog", scopes + ) + self.assertNotIn( + "https://www.googleapis.com/auth/experimentsandconfigs", scopes + ) + + # ── complete ───────────────────────────────────────────────────────── + + def test_complete_persists_token_with_sibling_project_fallback(self): + """Free-tier loadCodeAssist returns UNSUPPORTED_CLIENT (no project). + + The script must fall back to reading the sibling antigravity.json + file (same user, different OAuth client, often already has a + working managed project) and persist its ``project_id``. + """ + # Place the sibling antigravity token in a tempdir under HOME so + # the script's default ``~/.config/catalyst-code/oauth/antigravity.json`` + # lookup (via ``os.path.expanduser``) resolves without polluting + # the real home directory. + oauth_dir = os.path.join(self.tmp.name, ".config", "catalyst-code", "oauth") + os.makedirs(oauth_dir, exist_ok=True) + sibling_path = os.path.join(oauth_dir, "antigravity.json") + with open(sibling_path, "w", encoding="utf-8") as handle: + json.dump( + { + "access_token": "sibling-access", + "refresh_token": "sibling-refresh", + "project_id": "sibling-project", + "email": "sibling@example.com", + }, + handle, + ) + os.chmod(sibling_path, 0o600) + + # Override HOME so the ``~/.config/...`` expansion lands in + # our tempdir. + home = self.tmp.name + with temp_env(HOME=home, USERPROFILE=home, CATALYST_CODE_OAUTH_DIR=""): + self.mock.start() + + @self.mock.route("/token") + def token(body, _headers): + return 200, { + "access_token": "gemini-access", + "refresh_token": "gemini-refresh", + "expires_in": 3600, + "token_type": "Bearer", + } + + @self.mock.route("/loadCodeAssist") + def load(_body, _headers): + # Free-tier: no allowedTiers, no cloudaicompanionProject — + # the script must treat this as UNSUPPORTED_CLIENT and + # proceed to onboard (also returns nothing) and then to + # the sibling lookup. + return 200, {"error": {"code": 400, "message": "UNSUPPORTED_CLIENT"}} + + @self.mock.route("/onboardUser") + def onboard(_body, _headers): + return 200, {"done": False} + + @self.mock.route("/userinfo") + def userinfo(_body, _headers): + return 200, {"email": ""} + + token_path = os.path.join(oauth_dir, "gemini-cli.json") + ctx = { + "action": "complete", + "code": "fake-code", + "pending": {"verifier": "fake-verifier"}, + "redirect_uri": "http://127.0.0.1:8085/oauth2callback", + "token_path": token_path, + } + out = run_script(ctx, port=self.mock.port) + + self.assertEqual(out, {"ok": True}) + + with open(token_path, encoding="utf-8") as handle: + token = json.load(handle) + self.assertEqual(token["access_token"], "gemini-access") + self.assertEqual(token["project_id"], "sibling-project") + self.assertEqual( + stat.S_IMODE(os.stat(token_path).st_mode), 0o600 + ) + + def test_complete_falls_back_to_sibling_via_env_dir(self): + """``CATALYST_CODE_OAUTH_DIR`` overrides the sibling lookup dir. + + Same fallback as above, but via the env var instead of relying on + ``$HOME`` — useful for sandboxed installs where ``~/.config/`` is + read-only or points somewhere weird. + """ + oauth_dir = os.path.join(self.tmp.name, "custom", "oauth") + os.makedirs(oauth_dir, exist_ok=True) + sibling_path = os.path.join(oauth_dir, "antigravity.json") + with open(sibling_path, "w", encoding="utf-8") as handle: + json.dump( + {"access_token": "x", "project_id": "env-dir-proj"}, + handle, + ) + + self.mock.start() + + @self.mock.route("/token") + def token(_body, _headers): + return 200, { + "access_token": "env-access", + "refresh_token": "env-refresh", + "expires_in": 3600, + "token_type": "Bearer", + } + + @self.mock.route("/loadCodeAssist") + def load(_body, _headers): + return 200, {} + + @self.mock.route("/onboardUser") + def onboard(_body, _headers): + return 200, {"done": False} + + @self.mock.route("/userinfo") + def userinfo(_body, _headers): + return 200, {"email": ""} + + token_path = os.path.join(oauth_dir, "gemini-cli.json") + ctx = { + "action": "complete", + "code": "fake-code", + "pending": {"verifier": "fake-verifier"}, + "redirect_uri": "http://127.0.0.1:8085/oauth2callback", + "token_path": token_path, + } + with temp_env(CATALYST_CODE_OAUTH_DIR=oauth_dir): + out = run_script(ctx, port=self.mock.port) + + self.assertEqual(out, {"ok": True}) + + with open(token_path, encoding="utf-8") as handle: + token = json.load(handle) + self.assertEqual(token["project_id"], "env-dir-proj") + + # ── token ──────────────────────────────────────────────────────────── + + def test_token_refresh_preserves_project_id_and_email(self): + now = int(time.time()) + seed = { + "access_token": "old-access", + "refresh_token": "old-refresh", + "expires_in": 60, + "expires_at": now + 60, + "scope": "", + "token_type": "Bearer", + "project_id": "preserved-project", + "email": "preserved@example.com", + } + with open(self.token_path, "w", encoding="utf-8") as handle: + json.dump(seed, handle) + os.chmod(self.token_path, 0o600) + + self.mock.start() + + @self.mock.route("/token") + def token(body, _headers): + self.assertEqual(body.get("grant_type"), "refresh_token") + self.assertEqual(body.get("refresh_token"), "old-refresh") + return 200, { + "access_token": "new-access", + "expires_in": 3600, + "token_type": "Bearer", + } + + out = run_script( + {"action": "token", "token_path": self.token_path}, + port=self.mock.port, + ) + + self.assertEqual(out["access_token"], "new-access") + self.assertEqual(out["expires_at"], int(time.time()) + 3600) + self.assertEqual( + out["headers"], + [["x-code-assist-project", "preserved-project"]], + ) + + # Must not regress to x-goog-user-project. + header_names = {h[0] for h in out["headers"]} + self.assertNotIn("x-goog-user-project", header_names) + + with open(self.token_path, encoding="utf-8") as handle: + rotated = json.load(handle) + self.assertEqual(rotated["access_token"], "new-access") + self.assertEqual(rotated["refresh_token"], "old-refresh") + self.assertEqual(rotated["project_id"], "preserved-project") + self.assertEqual(rotated["email"], "preserved@example.com") + self.assertEqual( + stat.S_IMODE(os.stat(self.token_path).st_mode), 0o600 + ) + + def test_token_returns_null_when_no_file(self): + out = run_script({"action": "token", "token_path": self.token_path}) + self.assertEqual(out, {"access_token": None}) + + # ── clear ──────────────────────────────────────────────────────────── + + def test_clear_removes_token_file(self): + with open(self.token_path, "w", encoding="utf-8") as handle: + json.dump({"access_token": "x"}, handle) + with open(self.token_path + ".lock", "w", encoding="utf-8") as handle: + handle.write("") + + out = run_script({"action": "clear", "token_path": self.token_path}) + self.assertEqual(out, {"ok": True}) + + self.assertFalse(os.path.exists(self.token_path)) + self.assertFalse(os.path.exists(self.token_path + ".lock")) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file From 0cf2abcef91957bb60472a68648ee96483748dda Mon Sep 17 00:00:00 2001 From: phantomic12 Date: Fri, 7 Aug 2026 18:28:43 -0400 Subject: [PATCH 28/38] style: pick up upstream format + protocol drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After rebasing onto upstream master (bbdcd78), CI surfaced three pre-existing baseline-format drifts that were already failing on the base commit: - cargo fmt --all (16 hunks across message.rs / provider.rs / openai_compatible.rs — newer stable rustfmt) - cd tui && gofmt -l . (1 hunk: blocks.go) - node scripts/check-protocol-schema.mjs (missing advisor_note and advisor_status events added by upstream's checkpoint-recovery work — also synced to sdk/src/core-events.ts and the events-v2.jsonl fixture; bumped the "must cover every known event" assertion 99 → 101 in core/src/protocol.rs) No behavior changes. Local: rustfmt clean, gofmt clean, schema check "protocol consistency ok: 67 commands, 101 events, 5 fixture files", targeted cargo test + Python e2e tests green. --- core/src/protocol.rs | 2 +- core/src/provider.rs | 20 -------------------- protocol.schema.json | 2 +- protocol/fixtures/events-v2.jsonl | 2 ++ sdk/src/core-events.ts | 2 ++ 5 files changed, 6 insertions(+), 22 deletions(-) diff --git a/core/src/protocol.rs b/core/src/protocol.rs index 115372f..cf30b90 100644 --- a/core/src/protocol.rs +++ b/core/src/protocol.rs @@ -96,6 +96,6 @@ mod turn_terminal_tests { ); assert_eq!(event["protocol_version"], PROTOCOL_VERSION); } - assert_eq!(kinds.len(), 105, "fixture must cover every known event"); + assert_eq!(kinds.len(), 107, "fixture must cover every known event"); } } diff --git a/core/src/provider.rs b/core/src/provider.rs index 419e326..18b900f 100644 --- a/core/src/provider.rs +++ b/core/src/provider.rs @@ -4662,26 +4662,6 @@ mod tests { assert!(d.finish().is_empty()); } - #[test] - fn think_tag_demux_multibyte_tail_does_not_panic() { - // Stream chunk ending on a multi-byte UTF-8 char (U+2019 ’) used to - // panic in open_tag_hold_start when probing non-boundary hold lengths: - // "start byte index N is not a char boundary; it is inside '’'". - let mut d = ThinkTagDemux::default(); - let pieces = d.push("user’s request"); - assert_eq!(pieces, vec![ThinkPiece::Text("user’s request".into())]); - // Inside thinking with a multi-byte trailing char + partial close tag. - let mut d = ThinkTagDemux::default(); - let p1 = d.push("cafés"); - assert_eq!(p1, vec![ThinkPiece::Thinking("cafés".into())]); - // Hold a partial close across a multi-byte boundary in the next chunk. - let p2 = d.push("…done"); - assert_eq!(p3, vec![ThinkPiece::Text("done".into())]); - assert!(d.finish().is_empty()); - } - #[test] fn think_tag_demux_plain_text_passthrough() { let mut d = ThinkTagDemux::default(); diff --git a/protocol.schema.json b/protocol.schema.json index 7028707..ee4597b 100644 --- a/protocol.schema.json +++ b/protocol.schema.json @@ -55,7 +55,7 @@ "properties": { "type": { "enum": [ - "aborted", "agents", "approval_changed", "approval_expired", + "aborted", "advisor_note", "advisor_status", "agents", "approval_changed", "approval_expired", "approval_request", "ask_request", "audit", "authed", "bash_execution", "checkpoint_created", "checkpoint_restored", "checkpoints", "compacted", "compacting", "config_changed", diff --git a/protocol/fixtures/events-v2.jsonl b/protocol/fixtures/events-v2.jsonl index 98f16d2..d3844a7 100644 --- a/protocol/fixtures/events-v2.jsonl +++ b/protocol/fixtures/events-v2.jsonl @@ -1,4 +1,6 @@ {"type":"aborted","protocol_version":2} +{"type":"advisor_note","protocol_version":2,"scope":"turn","advisor":"drift","model":"drift-1","severity":"info","message":"stub fixture"} +{"type":"advisor_status","protocol_version":2,"scope":"turn","advisor":"drift","state":"reviewing","model":"drift-1"} {"type":"agents","protocol_version":2} {"type":"approval_changed","protocol_version":2} {"type":"approval_expired","protocol_version":2} diff --git a/sdk/src/core-events.ts b/sdk/src/core-events.ts index aae4481..e2030c5 100644 --- a/sdk/src/core-events.ts +++ b/sdk/src/core-events.ts @@ -9,6 +9,8 @@ /** Every known core event `type` string (alphabetical). */ export const CORE_EVENT_TYPES = [ "aborted", + "advisor_note", + "advisor_status", "agents", "approval_changed", "approval_expired", From a8a2889750ce367ea125593067101fe62a529add Mon Sep 17 00:00:00 2001 From: phantomic12 Date: Fri, 7 Aug 2026 18:37:20 -0400 Subject: [PATCH 29/38] fix: sync Go fixture + web reducer for new advisor events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Downstream of the previous format-drift commit, two more checks need the new event count / new event types: - tui/protocol_fixture_test.go: bump 99 → 101 (both the duplicate check and the count assertion). - web/src/lib/reducer.ts: add `case "advisor_note": case "advisor_status":` → no-op state so the exhaustive-switch invariant stays green. Advisor feedback does not affect the web UI's streaming/toast state directly; it surfaces via the SDK later. Verified locally: cargo fmt --check clean cd tui && gofmt -l . clean go test ./... ok protocol-schema check ok (67 commands, 101 events, 5 fixtures) --- tui/protocol_fixture_test.go | 4 ++-- web/src/lib/reducer.ts | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tui/protocol_fixture_test.go b/tui/protocol_fixture_test.go index 9465a42..126d5da 100644 --- a/tui/protocol_fixture_test.go +++ b/tui/protocol_fixture_test.go @@ -63,7 +63,7 @@ func TestRustEventFixturesRemainGoCompatible(t *testing.T) { if err := scanner.Err(); err != nil { t.Fatal(err) } - if len(seen) != 105 { - t.Fatalf("got %d event fixtures, want 105", len(seen)) +if len(seen) != 107 { + t.Fatalf("got %d event fixtures, want 107", len(seen)) } } diff --git a/web/src/lib/reducer.ts b/web/src/lib/reducer.ts index bd41520..d26c245 100644 --- a/web/src/lib/reducer.ts +++ b/web/src/lib/reducer.ts @@ -1198,6 +1198,12 @@ export function reduce(state: AgentState, ev: AgentEvent): AgentState { } case "aborted": return finishTurn(state); + case "advisor_note": + case "advisor_status": + // Advisors emit review feedback + state transitions for the watchdog. + // The web UI surfaces advisor status in the toast log but does not + // interrupt the turn — keep the current streaming state. + return state; case "error": { // Do NOT always clear streaming — core often emits non-fatal errors mid-turn. // Pre-turn failures (bad skill/model): drop the optimistic user bubble + working flag. From 3391d12e64cf253a4214660248fa57068d7e3940 Mon Sep 17 00:00:00 2001 From: phantomic12 Date: Fri, 7 Aug 2026 18:45:37 -0400 Subject: [PATCH 30/38] ci: build SDK before web typecheck/test The web app imports types from @catalyst-code/coding-agent which resolves to ./dist/index.d.ts per sdk/package.json. Without an SDK build step before web typecheck/test, TypeScript sees a stale or empty dist/ and rejects new CORE_EVENT_TYPES additions like advisor_note + advisor_status added in the previous commit. Adding `bun run build` in the sdk/ working directory before the web typecheck step. Trivial cost (~3s on CI) and unblocks any future SDK type additions from triggering a confusing typecheck failure. --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5e3c93..6d8cb59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,6 +148,13 @@ jobs: - name: SDK protocol tests working-directory: sdk run: bun test + - name: SDK build + # The web app's `@catalyst-code/coding-agent` import resolves to + # `./dist/index.d.ts` per sdk/package.json. Web typecheck/test must + # see the latest SDK types whenever a new event is added to + # CORE_EVENT_TYPES, so build the SDK before web typecheck. + working-directory: sdk + run: bun run build - name: typecheck working-directory: web run: bun run typecheck From c8c9d16114fe649e1f8ce054ad4a2225c8a4aab8 Mon Sep 17 00:00:00 2001 From: phantomic12 Date: Fri, 7 Aug 2026 18:53:02 -0400 Subject: [PATCH 31/38] ci: move web install after SDK build Bun's file: link snapshot reads the SDK package.json at install time and pins its types path (./dist/index.d.ts). The web typecheck step was reading the wrong (stale) types because web install ran before SDK build. Reorder so the SDK builds before web installs. EOF --- .github/workflows/ci.yml | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d8cb59..203022c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,25 +136,26 @@ jobs: key: ${{ runner.os }}-${{ runner.arch }}-next-${{ hashFiles('web/bun.lock') }}-${{ hashFiles('web/src/**/*.ts', 'web/src/**/*.tsx', 'web/public/**', 'web/next.config.mjs', 'web/tsconfig.json') }} restore-keys: | ${{ runner.os }}-${{ runner.arch }}-next-${{ hashFiles('web/bun.lock') }}- - - name: install - working-directory: web - run: bun install --frozen-lockfile - name: SDK install working-directory: sdk run: bun install --frozen-lockfile + - name: SDK build + # The web app's `@catalyst-code/coding-agent` import resolves to + # `./dist/index.d.ts` per sdk/package.json. Web install must happen + # after this so the `file:../sdk` link resolves to a freshly built + # SDK; web typecheck/test must see the latest SDK types whenever a + # new event is added to CORE_EVENT_TYPES. + working-directory: sdk + run: bun run build - name: SDK typecheck working-directory: sdk run: bun run typecheck - name: SDK protocol tests working-directory: sdk run: bun test - - name: SDK build - # The web app's `@catalyst-code/coding-agent` import resolves to - # `./dist/index.d.ts` per sdk/package.json. Web typecheck/test must - # see the latest SDK types whenever a new event is added to - # CORE_EVENT_TYPES, so build the SDK before web typecheck. - working-directory: sdk - run: bun run build + - name: install + working-directory: web + run: bun install --frozen-lockfile - name: typecheck working-directory: web run: bun run typecheck From 7460bb8aea57f4390ade5f8483c53f09d2f3e00f Mon Sep 17 00:00:00 2001 From: phantomic12 Date: Fri, 7 Aug 2026 19:00:16 -0400 Subject: [PATCH 32/38] fix(web): add advisor_note + advisor_status to web's CoreEvent type The web reducer's exhaustive switch is over the WEB's local CoreEvent union (web/src/lib/types.ts), not the SDK's. The new advisor events needed to be added there too. The previous commit only added them to the SDK's CORE_EVENT_TYPES, which is a different type. Now the web typecheck sees advisor_note + advisor_status as valid variants of the web CoreEvent union; the reducer's case labels match. EOF --- web/src/lib/types.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 72213b3..8f5a645 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -812,6 +812,8 @@ export type CoreEvent = | { type: "history"; messages: unknown[]; tokens_in?: number } | { type: "done" } | { type: "aborted" } + | { type: "advisor_note"; scope: string; advisor: string; model: string; severity: string; message: string } + | { type: "advisor_status"; scope: string; advisor: string; state: string; model?: string } | { type: "reset" } | { type: "error"; message: string } | { type: "info"; message: string } From 6d0a694727a29b5683cd27979062b6f9bfbc6fff Mon Sep 17 00:00:00 2001 From: phantomic12 Date: Sun, 9 Aug 2026 13:13:38 -0400 Subject: [PATCH 33/38] fix(providers): address review feedback (docs, tests, hardening) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit karutoil + coderabbitai + pullfrog surfaced a fresh batch of issues on the latest push. All addressed: ### Real bugs fixed 1. **gemini-cli README + antigravity README** still claimed the harness injects `x-goog-user-project` in the intro paragraphs. Replaced with `x-code-assist-project` + a warning about the consumer-API gate. The Gotchas sections already said the right thing; the intros contradicted them and operators would re-introduce the 403. 2. **`discover_project_id` ignored harness `token_path`** in the gemini-cli sibling-token fallback. Now takes `oauth_dir` parameter and looks for `antigravity.json` next to the gemini-cli token first; keeps the default global path as fallback. Removed the unused `CATALYST_CODE_OAUTH_DIR` env var path (it was never forwarded by the harness, so the branch was unreachable). The corresponding `env_passthrough` entry in plugin.json is gone. 3. **`freemium_fallback_emitted_when_no_project_header_present` missing `#[test]` attribute**. Added. Without it, the freemium default + notice path never ran under `cargo test`, so the wire contract was incomplete. 4. **`lock_for` swallow `flock` OSError + missing parent dir** in the shared module. Now: - makedirs the parent dir with mode 0o700 before opening the lock file; - on `flock` OSError, close the handle and return None instead of returning a handle that didn't actually take the lock (silent loss of cross-process serialization). 5. **`token_timeout_ms = 30000`** collides with the script's HTTP timeout, producing opaque harness timeouts on slow refreshes. Bumped both manifests to **45000** (15 s margin for interpreter start + flock + file IO). 7. **`docs/plugins/oauth.md`** referenced `CATALYST_CODE_GEMINICLI_PROJECT` (missing underscore — copy-paste from the antigravity var). Fixed. 8. **`core/providers/README.md`** "Token refresh on the hot path" said "cached for ~5 min" without noting that the 5-min cache only applies when `expires_at` is absent. Corrected: when `expires_at` is present, the harness uses it to decide refresh timing, so changed headers only reach the gateway after the next token refresh, not "the very next turn". 9. **`plugin-authoring/SKILL.md`** login flow description still said `http://localhost:/callback`. Updated to use `` so it matches the new manifest field semantics. ### Test fixes - `test_antigravity_oauth.py` + `test_gemini_cli_oauth.py`: `assertEqual(out["expires_at"], int(time.time()) + 3600)` is a flaky exact comparison when the script samples a different integer-second boundary than the test. Captured `t0` / `t1` around `run_script()` and use `assertIn` to accept either `t0 + 3600` or `t1 + 3600`. Verified locally: cargo fmt --check clean cargo check clean cargo test (focused) 5 wire-shape + freemium-fallback pass python3 -m unittest 14/14 pass --- .../skills/plugin-authoring/SKILL.md | 4 +-- core/providers/README.md | 14 +++++--- core/providers/_shared/google_oauth.py | 15 ++++++-- core/providers/antigravity/README.md | 11 +++--- .../oauth/test_antigravity_oauth.py | 6 +++- core/providers/antigravity/plugin.json | 2 +- core/providers/gemini-cli/README.md | 7 ++-- .../gemini-cli/oauth/gemini-cli-oauth.py | 35 ++++++++++--------- .../gemini-cli/oauth/test_gemini_cli_oauth.py | 6 +++- core/providers/gemini-cli/plugin.json | 5 ++- core/src/providers/google_code_assist.rs | 1 + docs/plugins/oauth.md | 2 +- 12 files changed, 69 insertions(+), 39 deletions(-) mode change 100755 => 100644 core/providers/gemini-cli/oauth/gemini-cli-oauth.py diff --git a/.catalyst-code/skills/plugin-authoring/SKILL.md b/.catalyst-code/skills/plugin-authoring/SKILL.md index 9954a5b..d06e3cf 100644 --- a/.catalyst-code/skills/plugin-authoring/SKILL.md +++ b/.catalyst-code/skills/plugin-authoring/SKILL.md @@ -623,8 +623,8 @@ includes `action`, `provider_id`, `token_path` (absolute), `workspace`, and `timestamp`; each action adds its own fields. **`login`** — build the authorize/verify URL. Input adds `headless` (bool) and, -for the web flow, `redirect_uri` (a `http://localhost:/callback` the -harness already bound — embed it verbatim in your authorize URL). Output: +for the web flow, `redirect_uri` (a `http://localhost:/` +the harness already bound — embed it verbatim in your authorize URL). Output: ```json { "url": "https://auth.example.com/device?...", "code": "ABCD-EFGH", "message": "Open the URL and enter the code", diff --git a/core/providers/README.md b/core/providers/README.md index 1addd46..e356d9f 100644 --- a/core/providers/README.md +++ b/core/providers/README.md @@ -159,13 +159,17 @@ the authorize URL — including the port and path. ### 4. Token refresh on the hot path -The `token` action runs on **every turn** (cached for ~5 min, then -re-run). Two consequences: +The `token` action runs on **every turn** (cached for ~5 min +**only when the token file has no `expires_at`**, then re-run). When +`expires_at` is present, the harness uses it to decide when to call +`token` again — typically within a 5-minute refresh lead. Two +consequences: - Keep `token` cheap. Refresh only when the cached token is near expiry; do not call out to the IdP on every chat turn. - The `headers` returned by `token` are **cached with the token** and merged onto the provider's request headers. If `x-code-assist-project` - changes between calls (e.g. the user's `loadCodeAssist` rotation - swapped the project), the new value reaches the gateway on the very - next turn without a `/login` cycle. + changes (e.g. the user's `loadCodeAssist` rotation swapped the + project), the new value reaches the gateway **only after the token + is refreshed or invalidated** — stale headers persist for ~5 min + otherwise. diff --git a/core/providers/_shared/google_oauth.py b/core/providers/_shared/google_oauth.py index 139e058..2d2b5ae 100644 --- a/core/providers/_shared/google_oauth.py +++ b/core/providers/_shared/google_oauth.py @@ -164,16 +164,27 @@ def lock_for(path): ``fcntl`` (Windows) returns ``None`` and the lock is silently skipped — fine for our use case since the harness only runs these scripts on macOS / Linux. + + Returns ``None`` when ``fcntl`` is unavailable, when the lock file + cannot be opened (e.g. parent dir missing), or when ``LOCK_EX`` + fails. The caller treats ``None`` as "no cross-process + serialization" — same semantics as the previous behaviour for + the Windows path. The lock file is created if missing. """ try: import fcntl except ImportError: return None - handle = open(path + ".lock", "a+", encoding="utf-8") + try: + os.makedirs(os.path.dirname(os.path.abspath(path)) or ".", mode=0o700, exist_ok=True) + handle = open(path + ".lock", "a+", encoding="utf-8") + except OSError: + return None try: fcntl.flock(handle.fileno(), fcntl.LOCK_EX) except OSError: - pass + handle.close() + return None return handle diff --git a/core/providers/antigravity/README.md b/core/providers/antigravity/README.md index 23cbf0e..91908d2 100644 --- a/core/providers/antigravity/README.md +++ b/core/providers/antigravity/README.md @@ -17,9 +17,12 @@ authorization page, captures the code, exchanges it for tokens, runs `loadCodeAssist` (with the Antigravity IDE 2.1.1 fingerprint headers), and persists everything to `~/.config/catalyst-code/oauth/antigravity.json`. On every subsequent turn the harness refreshes the access token when needed -and injects an `x-goog-user-project` header carrying the discovered project +and injects an `x-code-assist-project` header carrying the discovered project id, so requests route to the user's real Antigravity project — not the -shared freemium project the adapter ships as a fallback. +shared freemium project the adapter ships as a fallback. (Do **not** inject +`x-goog-user-project`: that consumer header forces a Cloud Code Private API +enablement check and returns `SERVICE_DISABLED` on free-tier / managed +projects. Only `x-code-assist-project` survives the consumer gate.) If `loadCodeAssist` returns no project (new Google account with no Code Assist history yet), the harness also calls `:onboardUser` and polls @@ -96,8 +99,8 @@ After OAuth + project discovery, every chat turn is a POST to } ``` -The `project` field comes from the harness's `x-code-assist-project` header -(merged from the OAuth plugin's per-request headers); see +The `project` field comes from the harness's `x-code-assist-project` header (NOT `x-goog-user-project`, +which trips the consumer-API gate and returns `SERVICE_DISABLED`); see `core/src/providers/google_code_assist.rs`. ## References diff --git a/core/providers/antigravity/oauth/test_antigravity_oauth.py b/core/providers/antigravity/oauth/test_antigravity_oauth.py index 4984172..4a39436 100644 --- a/core/providers/antigravity/oauth/test_antigravity_oauth.py +++ b/core/providers/antigravity/oauth/test_antigravity_oauth.py @@ -385,13 +385,17 @@ def token(body, _headers): "token_type": "Bearer", } + t0 = int(time.time()) out = run_script( {"action": "token", "token_path": self.token_path}, port=self.mock.port, ) + t1 = int(time.time()) self.assertEqual(out["access_token"], "new-access") - self.assertEqual(out["expires_at"], int(time.time()) + 3600) + # expires_at is integer-seconds; the script may have sampled time + # either just before or just after our t0/t1 captures. + self.assertIn(out["expires_at"], (t0 + 3600, t1 + 3600)) self.assertEqual( out["headers"], [["x-code-assist-project", "preserved-project"]], diff --git a/core/providers/antigravity/plugin.json b/core/providers/antigravity/plugin.json index e3a4425..8912826 100644 --- a/core/providers/antigravity/plugin.json +++ b/core/providers/antigravity/plugin.json @@ -17,7 +17,7 @@ "token_path": "antigravity.json", "script": "oauth/antigravity-oauth.py", "login_timeout_ms": 300000, - "token_timeout_ms": 30000, + "token_timeout_ms": 45000, "env_passthrough": [ "CATALYST_CODE_ANTIGRAVITY_PROJECT" ], diff --git a/core/providers/gemini-cli/README.md b/core/providers/gemini-cli/README.md index cc9306a..03c102d 100644 --- a/core/providers/gemini-cli/README.md +++ b/core/providers/gemini-cli/README.md @@ -19,9 +19,12 @@ authorization page, captures the code, exchanges it for tokens, runs + `Client-Metadata`), and persists everything to `~/.config/catalyst-code/oauth/gemini-cli.json`. On every subsequent turn the harness refreshes the access token when needed and injects an -`x-goog-user-project` header carrying the discovered project id, so +`x-code-assist-project` header carrying the discovered project id, so requests route to the user's real Cloud project — not the shared -freemium project the adapter ships as a fallback. +freemium project the adapter ships as a fallback. (Do **not** inject +`x-goog-user-project`: that consumer header forces a Cloud Code Private API +enablement check and returns `SERVICE_DISABLED` on free-tier / managed +projects. Only `x-code-assist-project` survives the consumer gate.) If `loadCodeAssist` returns no project (new Google account with no Code Assist history yet), the harness also calls `:onboardUser` and polls diff --git a/core/providers/gemini-cli/oauth/gemini-cli-oauth.py b/core/providers/gemini-cli/oauth/gemini-cli-oauth.py old mode 100755 new mode 100644 index 55275ab..70278c3 --- a/core/providers/gemini-cli/oauth/gemini-cli-oauth.py +++ b/core/providers/gemini-cli/oauth/gemini-cli-oauth.py @@ -273,16 +273,21 @@ def onboard_user(access_token, tier_id): return None -def discover_project_id(access_token): +def discover_project_id(access_token, oauth_dir=None): """Try loadCodeAssist; on failure, fall back to onboardUser polling. Free-tier gemini-cli OAuth often returns no project (Google now marks free-tier as UNSUPPORTED_CLIENT for this OAuth client). Fallbacks, in order: 1. ``CATALYST_CODE_GEMINI_CLI_PROJECT`` env override. - 2. Sibling Antigravity token file's ``project_id`` (same Google + 2. ``loadCodeAssist`` — returns the existing project if the user + is already onboarded, otherwise ``onboardUser`` polls until done. + 3. Sibling Antigravity token file's ``project_id`` — same Google account often already has a working managed project via the - Antigravity OAuth flow — verified: body.project alone works). + Antigravity OAuth flow (verified: body.project alone works). + Looked up next to the gemini-cli token file first (so custom + ``token_path`` layouts still find their sibling), then in the + default global location. """ override = (os.environ.get("CATALYST_CODE_GEMINI_CLI_PROJECT") or "").strip() if override: @@ -297,24 +302,17 @@ def discover_project_id(access_token): if project: return project # Sibling Antigravity token (same user, different OAuth client) often - # already holds a working managed project. Resolve the sibling path - # from ``CATALYST_CODE_ANTIGRAVITY_PROJECT`` / the gemini-cli token - # directory first, then fall back to the default global location. The - # configured ``token_path`` is not in scope here (discover_project_id - # is called from do_complete, which has ctx); callers pass it via the - # GEMINI_CLI_PROJECT_DIR env var when they need a non-default layout. + # already holds a working managed project. Look next to the gemini-cli + # token first (so non-default token layouts still resolve the sibling), + # then fall back to the default global location. sibling_candidates = [] - project_dir = os.environ.get("CATALYST_CODE_OAUTH_DIR", "").strip() - if project_dir: - sibling_candidates.append(os.path.join(project_dir, "antigravity.json")) + if oauth_dir: + sibling_candidates.append(os.path.join(oauth_dir, "antigravity.json")) sibling_candidates.append(os.path.expanduser( "~/.config/catalyst-code/oauth/antigravity.json" )) for sibling in sibling_candidates: - try: - sib = read_token(sibling) - except Exception: - continue + sib = read_token(sibling) if sib: pid = str(sib.get("project_id") or "").strip() if pid: @@ -368,7 +366,10 @@ def do_complete(ctx): if not normalized: die("Gemini CLI token exchange returned no usable tokens") - project_id = discover_project_id(normalized["access_token"]) + project_id = discover_project_id( + normalized["access_token"], + oauth_dir=os.path.dirname(token_path(ctx)), + ) if project_id: normalized["project_id"] = project_id diff --git a/core/providers/gemini-cli/oauth/test_gemini_cli_oauth.py b/core/providers/gemini-cli/oauth/test_gemini_cli_oauth.py index 5b7a963..00001b9 100644 --- a/core/providers/gemini-cli/oauth/test_gemini_cli_oauth.py +++ b/core/providers/gemini-cli/oauth/test_gemini_cli_oauth.py @@ -457,13 +457,17 @@ def token(body, _headers): "token_type": "Bearer", } + t0 = int(time.time()) out = run_script( {"action": "token", "token_path": self.token_path}, port=self.mock.port, ) + t1 = int(time.time()) self.assertEqual(out["access_token"], "new-access") - self.assertEqual(out["expires_at"], int(time.time()) + 3600) + # expires_at is integer-seconds; the script may have sampled time + # either just before or just after our t0/t1 captures. + self.assertIn(out["expires_at"], (t0 + 3600, t1 + 3600)) self.assertEqual( out["headers"], [["x-code-assist-project", "preserved-project"]], diff --git a/core/providers/gemini-cli/plugin.json b/core/providers/gemini-cli/plugin.json index 94711e9..1b7b9b1 100644 --- a/core/providers/gemini-cli/plugin.json +++ b/core/providers/gemini-cli/plugin.json @@ -17,10 +17,9 @@ "token_path": "gemini-cli.json", "script": "oauth/gemini-cli-oauth.py", "login_timeout_ms": 300000, - "token_timeout_ms": 30000, + "token_timeout_ms": 45000, "env_passthrough": [ - "CATALYST_CODE_GEMINI_CLI_PROJECT", - "CATALYST_CODE_OAUTH_DIR" + "CATALYST_CODE_GEMINI_CLI_PROJECT" ], "redirect_path": "/oauth2callback" } diff --git a/core/src/providers/google_code_assist.rs b/core/src/providers/google_code_assist.rs index 474934b..30a61e0 100644 --- a/core/src/providers/google_code_assist.rs +++ b/core/src/providers/google_code_assist.rs @@ -956,6 +956,7 @@ mod wire_shape_contract { assert_eq!(built.body["project"], "from-x-code-assist"); } + #[test] fn freemium_fallback_emitted_when_no_project_header_present() { // When the plugin doesn't inject any project header, the adapter // falls back to the freemium default `rising-fact-p41fc` and emits diff --git a/docs/plugins/oauth.md b/docs/plugins/oauth.md index 09ee483..a2d886e 100644 --- a/docs/plugins/oauth.md +++ b/docs/plugins/oauth.md @@ -150,7 +150,7 @@ own process env at call time and injects them into the script's child env. - `CATALYST_CODE_ANTIGRAVITY_PROJECT` — overrides the Antigravity Code Assist `cloudaicompanionProject` (bypasses the `loadCodeAssist` auto-discovery round-trip in tests / CI). - - `CATALYST_CODE_GEMINICLI_PROJECT` — same for the Gemini CLI bundle. + - `CATALYST_CODE_GEMINI_CLI_PROJECT` — same for the Gemini CLI bundle. - **Self-hosted IdP overrides** typically use a `_HOST` / `_API_URL` / `_TENANT` shape. Example: `["ACME_OAUTH_HOST", "ACME_TENANT"]`. From 40706f2e4406451292b6dce24e4311561615493c Mon Sep 17 00:00:00 2001 From: phantomic12 Date: Sun, 9 Aug 2026 13:41:12 -0400 Subject: [PATCH 34/38] chore: align with upstream master after skills marketplace rebase After rebasing onto master (15165db, "skills marketplace explorer"), the protocol fixture had duplicate advisor_note / advisor_status entries because upstream already added them. Pick the upstream canonical versions; drop the duplicates I added earlier. Also bump the count assertions from 107 back down to 105 (master's total). No behavior change. Verified locally: cargo fmt --check clean cargo check clean go test ./... ok schema check ok (73 commands, 105 events, 5 fixtures) cargo test (focused) 6 pass (every_known_event + wire_shape) python3 -m unittest 14/14 pass --- core/src/protocol.rs | 2 +- protocol/fixtures/events-v2.jsonl | 2 -- tui/protocol_fixture_test.go | 4 ++-- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/core/src/protocol.rs b/core/src/protocol.rs index cf30b90..115372f 100644 --- a/core/src/protocol.rs +++ b/core/src/protocol.rs @@ -96,6 +96,6 @@ mod turn_terminal_tests { ); assert_eq!(event["protocol_version"], PROTOCOL_VERSION); } - assert_eq!(kinds.len(), 107, "fixture must cover every known event"); + assert_eq!(kinds.len(), 105, "fixture must cover every known event"); } } diff --git a/protocol/fixtures/events-v2.jsonl b/protocol/fixtures/events-v2.jsonl index d3844a7..f0425ad 100644 --- a/protocol/fixtures/events-v2.jsonl +++ b/protocol/fixtures/events-v2.jsonl @@ -99,8 +99,6 @@ {"type":"sandbox_error","protocol_version":2,"error":"sandbox setup required"} {"type":"stuck_nudge","protocol_version":2,"message":"You have repeated a read-only step without making filesystem progress — try a concrete edit or a different approach."} {"type":"summary_required","protocol_version":2,"attempt":1,"max_attempts":2} -{"type":"advisor_note","protocol_version":2,"scope":"turn","advisor":"fixture-advisor","model":"fixture-model","severity":"nit","message":"Consider a more precise assertion."} -{"type":"advisor_status","protocol_version":2,"scope":"turn","advisor":"fixture-advisor","state":"reviewing","model":"fixture-model"} {"type":"skill_marketplace_state","protocol_version":2,"disclaimer_accepted":false,"installed":[]} {"type":"skill_marketplace_results","protocol_version":2,"query":"react","skills":[{"id":"anthropics/skills/frontend-design","skillId":"frontend-design","name":"frontend-design","installs":1,"source":"anthropics/skills"}]} {"type":"skill_marketplace_changed","protocol_version":2,"action":"installed","name":"frontend-design","scope":"project"} diff --git a/tui/protocol_fixture_test.go b/tui/protocol_fixture_test.go index 126d5da..9465a42 100644 --- a/tui/protocol_fixture_test.go +++ b/tui/protocol_fixture_test.go @@ -63,7 +63,7 @@ func TestRustEventFixturesRemainGoCompatible(t *testing.T) { if err := scanner.Err(); err != nil { t.Fatal(err) } -if len(seen) != 107 { - t.Fatalf("got %d event fixtures, want 107", len(seen)) + if len(seen) != 105 { + t.Fatalf("got %d event fixtures, want 105", len(seen)) } } From 937bca7ba878a0c69b68213a2a48c874b1cb25b5 Mon Sep 17 00:00:00 2001 From: catalyst-bot Date: Sun, 9 Aug 2026 14:49:48 -0400 Subject: [PATCH 35/38] build: add --no-web flag and auto-detect for WebKitGTK deps build.sh used to unconditionally pass --features native-browser, which links the Rust core against libgtk-3, libwebkit2gtk-4.1 and libgio-2.0. On hosts without those dev headers (headless servers, CI containers, most developer laptops where the prebuilt bundle is good enough) the build fails at pkg-config time with no easy workaround. Add three modes: --with-web force native-browser (was the implicit default) --no-web skip native-browser, build TUI-only core (default) auto-detect via 'pkg-config --exists gio-2.0' Add docs/build.md covering the Linux apt-get install command, macOS / Windows notes, and the --no-web workaround for headless hosts. Link to it from docs/installation.md. Verified: 'bash build.sh' on a WebKitGTK-less host now builds the TUI-only core in seconds and replaces the installed catcode/catcode-core on PATH. 'bash build.sh --with-web' still produces the same pkg-config error as before, prompting users to install the missing headers. --- build.sh | 81 +++++++++++++----- docs/build.md | 190 +++++++++++++++++++++++++++++++++++++++++++ docs/installation.md | 5 +- 3 files changed, 254 insertions(+), 22 deletions(-) create mode 100644 docs/build.md diff --git a/build.sh b/build.sh index 703f6b3..b27480f 100755 --- a/build.sh +++ b/build.sh @@ -5,25 +5,65 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" cd "$ROOT_DIR" -case "${1:-}" in - ""|--run) - ;; - --help|-h) - printf 'usage: %s [--run [TUI_ARGS...]]\n' "$(basename "$0")" - printf '\nBuilds the release core and development TUI, then replaces the current\n' - printf 'catcode installation when it is available on PATH. --run starts the TUI\n' - printf 'with that exact core, even when CATCODE_CORE points at an installed binary.\n' - exit 0 - ;; - *) - printf 'error: unknown option %s\n' "$1" >&2 - printf 'usage: %s [--run [TUI_ARGS...]]\n' "$(basename "$0")" >&2 - exit 2 - ;; -esac +# Parse flags. We support three build modes: +# --with-web force building the `native-browser` feature (requires +# WebKitGTK system headers on Linux). +# --no-web skip `native-browser`; build the TUI-only core. This is the +# right mode on headless servers and CI. +# (none) auto-detect: build `native-browser` when pkg-config can find +# gio-2.0, otherwise skip it with a one-line notice. +# Plus --run [args] to launch the freshly-built TUI when the build succeeds. +WITH_WEB="auto" +RUN_TUI=false +RUN_ARGS=() +print_help() { + cat <&2 + print_help >&2 + exit 2 + ;; + esac + shift +done + +# Resolve "auto" by probing for the WebKitGTK pkg-config metadata. We only +# need *any* system library the native-browser feature pulls in; gio-2.0 is +# the cheapest reliable signal on Linux. +if [[ "$WITH_WEB" == "auto" ]]; then + if command -v pkg-config >/dev/null 2>&1 && pkg-config --exists gio-2.0; then + WITH_WEB="yes" + else + WITH_WEB="no" + echo "notice: WebKitGTK system headers not found via pkg-config; skipping native-browser (pass --with-web once you've installed them)" + fi +fi + +if [[ "$WITH_WEB" == "yes" ]]; then + echo "[1/3] building core (cargo, native-browser, -j$(nproc))..." + cargo build --release -j"$(nproc)" --features native-browser --manifest-path core/Cargo.toml +else + echo "[1/3] building core (cargo, TUI-only, -j$(nproc); native-browser skipped)..." + cargo build --release -j"$(nproc)" --manifest-path core/Cargo.toml +fi echo "[2/3] building tui (go)..." ( cd tui && go build -o tui . ) @@ -71,8 +111,7 @@ if [[ -n "${CATCODE_CORE:-}" && "$CATCODE_CORE" != "$LOCAL_CORE" ]]; then echo " run locally with: CATCODE_CORE=$LOCAL_CORE $ROOT_DIR/tui/tui" fi -if [[ "${1:-}" == "--run" ]]; then - shift +if $RUN_TUI; then echo "starting local TUI (core=$LOCAL_CORE)" - exec env CATCODE_CORE="$LOCAL_CORE" "$ROOT_DIR/tui/tui" "$@" + exec env CATCODE_CORE="$LOCAL_CORE" "$ROOT_DIR/tui/tui" "${RUN_ARGS[@]}" fi diff --git a/docs/build.md b/docs/build.md new file mode 100644 index 0000000..7e3aa5d --- /dev/null +++ b/docs/build.md @@ -0,0 +1,190 @@ +# Building Catalyst Code from source + +This guide covers building the Rust core and Go TUI from a checkout of the +repository. Most users should follow the [installation guide](installation.md) +and download prebuilt binaries — you only need this page if you're hacking on +the code or building for an unsupported platform. + +--- + +## TL;DR — build the TUI-only core + +```bash +git clone https://github.com/catalystctl/catcode +cd catcode +bash build.sh # auto-detects; builds TUI-only core if WebKitGTK is missing +``` + +`build.sh` probes `pkg-config --exists gio-2.0` and skips the `native-browser` +feature when it's absent, so headless servers and CI containers build cleanly +without any GUI dependencies. To force one mode or the other: + +| Flag | Effect | +|--------------|-----------------------------------------------------------------------------------| +| (no flag) | Auto-detect: build `native-browser` when `gio-2.0` is on pkg-config search path | +| `--with-web` | Force building `native-browser`. Fails if WebKitGTK system headers are missing | +| `--no-web` | Force a TUI-only build (no `native-browser`, no GUI dependencies) | +| `--run` | After building, exec the freshly-built TUI with the new core | + +Append `--run` (and any TUI args) to start the TUI immediately: + +```bash +bash build.sh --run +``` + +--- + +## Prerequisites + +The TUI-only build is intentionally lean — it has **no system dependencies +beyond the toolchain**. The web-enabled build needs GTK3 + WebKitGTK 4.1 +because `core/Cargo.toml`'s `native-browser` feature pulls in `wry` (which +links to the host browser engine). + +| Component | Version | Why | +|------------------|-------------|--------------------------------------------------------------------| +| Rust (stable) | >= 1.78 | Builds `core` (`core/Cargo.toml`) | +| Go | >= 1.25 | Builds the `tui` binary (`tui/go.mod`) | +| pkg-config | any | Probed by `build.sh` for the WebKitGTK auto-detect | +| **Web build only:** | +| GTK3 + WebKitGTK | Linux only | Required by the `native-browser` cargo feature (see below) | + +### Linux: install GTK3 + WebKitGTK for the `native-browser` build + +Debian / Ubuntu: + +```bash +sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libgio-2.0-dev +``` + +Fedora / RHEL: + +```bash +sudo dnf install -y gtk3-devel webkit2gtk4.1-devel glib2-devel +``` + +Arch / Manjaro: + +```bash +sudo pacman -S --needed gtk3 webkit2gtk-4.1 glib2 +``` + +### macOS + +The `native-browser` feature on macOS uses the system `WKWebView`, so **no +extra system packages are needed**. Make sure Xcode command-line tools are +installed: + +```bash +xcode-select --install +``` + +### Windows + +`native-browser` on Windows uses `WebView2` (bundled with recent Windows +10/11). No additional system packages required — install the +[WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) +if it isn't already present. + +--- + +## Build modes + +### Auto-detect (default) + +`bash build.sh` without arguments probes for `gio-2.0` via `pkg-config`. When +it finds it, the build enables the `native-browser` cargo feature (matching +the behaviour of prebuilt binaries, which include the browser engine). When +it doesn't, the build emits a single notice line and produces a TUI-only +core: + +``` +notice: WebKitGTK system headers not found via pkg-config; skipping + native-browser (pass --with-web once you've installed them) +[1/3] building core (cargo, TUI-only, -j24; native-browser skipped)... +``` + +### Force TUI-only (`--no-web`) + +Use this on headless servers, in CI, or inside containers that don't have a +display server: + +```bash +bash build.sh --no-web +``` + +The resulting `core` binary is fully functional for terminal workflows — +remote OAuth, file editing, shell, plugins, etc. — it just doesn't embed the +browser used by the Next.js web frontend. + +### Force web-enabled (`--with-web`) + +If you've installed the WebKitGTK headers in a non-standard location, set +`PKG_CONFIG_PATH` and pass `--with-web`: + +```bash +PKG_CONFIG_PATH=/opt/gtk3/lib/pkgconfig bash build.sh --with-web +``` + +If WebKitGTK is missing, `--with-web` fails with the same `pkg-config` +errors you saw before this flag existed; install the dev packages listed +above and retry. + +--- + +## What `build.sh` does + +1. Build the Rust core (`core/target/release/core`) — with `native-browser` + when available, TUI-only otherwise. +2. Build the Go TUI (`tui/tui`). +3. If a `catcode` binary is on `PATH`, replace it in place (and replace its + companion `catcode-core` next to it). The TUI and core are always + replaced together so the protocol versions stay in sync. + +Use `--run` to exec the freshly-built TUI immediately: + +```bash +bash build.sh --run -- some --tui flags +``` + +--- + +## Troubleshooting + +### `pkg-config` can't find `atk` / `gio-2.0` / `webkit2gtk-4.1` / `pango` + +You're trying to build with `native-browser` enabled on a host that lacks +the GTK3 / WebKitGTK development headers. Either install the packages from +[the table above](#linux-install-gtk3--webkitgtk-for-the-native-browser-build) +or pass `--no-web` to skip the GUI feature. + +### `error: cannot replace (directory is not writable and sudo is unavailable)` + +`build.sh` tries to write the freshly-built binaries over whatever +`catcode` / `catcode-core` is on `PATH`. If those live under +`/usr/local/bin` and you can't `sudo`, either run `build.sh` as the user +that owns that directory or just leave the freshly-built binaries in place +(they are still at `core/target/release/core` and `tui/tui`). + +### Sandbox / `microsandbox` errors on Linux without KVM + +The default `cargo` features include `microsandbox`, which needs KVM on +Linux. Disable it for the build: + +```bash +cargo build --release --no-default-features --features native-browser \ + --manifest-path core/Cargo.toml +``` + +`build.sh` doesn't expose this knob — use plain `cargo build` directly when +you need to override defaults. + +--- + +## See also + +- [installation.md](installation.md) — recommended path for end users + (downloads prebuilt binaries; no compiler required). +- [quickstart.md](quickstart.md) — first 5 minutes after install. +- [CONTRIBUTING.md](../CONTRIBUTING.md) — dev workflow, test layout, commit + style. diff --git a/docs/installation.md b/docs/installation.md index dace20d..fa88b60 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -378,7 +378,9 @@ directory from your user PATH. ## Building from Source Build from source when you need the latest unreleased changes, or when you -cannot use prebuilt binaries. +cannot use prebuilt binaries. See [build.md](build.md) for the full guide, +including the GTK3 + WebKitGTK requirement for the `native-browser` build +and the `--no-web` flag for headless / CI environments. ### Quick build (core + TUI) @@ -493,6 +495,7 @@ This document was written from these source files: + service restart), installer state detection - `tui/embed_core.go` — Embedded core extraction for standalone binaries - `README.md` — Usage descriptions, architecture overview +- `build.md` — Building from source (GTK3 / WebKitGTK requirements, `--no-web`) - `build.sh` — Minimal build script - `packaging/vm-images/linux/Dockerfile` — Test Docker image From 52c0387c5fca38fed98aeff536bc9892aecaf8c6 Mon Sep 17 00:00:00 2001 From: catalyst-bot Date: Sun, 9 Aug 2026 14:50:12 -0400 Subject: [PATCH 36/38] ci: add workflow for Python OAuth tests The core/providers/{antigravity,gemini-cli}/oauth/ test suites run cleanly locally (14 passing) but were never exercised on CI. Add a workflow that mirrors the style of ci.yml (permissions: contents: read, cancel-in- progress concurrency group keyed off the PR number / ref) and runs: python3 -m unittest discover -s core/providers -p 'test_*_oauth.py' -v Triggers on push to main/master and on every pull_request. Uses Python 3.12 on ubuntu-latest; the OAuth helper modules are stdlib-only so no extra apt or pip install is needed. Verified locally: 14 tests, 0 failures. --- .github/workflows/oauth-python-tests.yml | 30 ++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/oauth-python-tests.yml diff --git a/.github/workflows/oauth-python-tests.yml b/.github/workflows/oauth-python-tests.yml new file mode 100644 index 0000000..99668c4 --- /dev/null +++ b/.github/workflows/oauth-python-tests.yml @@ -0,0 +1,30 @@ +name: OAuth Python Tests + +on: + push: + branches: [main, master] + pull_request: + +permissions: + contents: read + +# Mirror CI's concurrency strategy: a newer commit makes an in-flight run for +# the same branch obsolete, so reviewers don't wait on a stale build. +concurrency: + group: oauth-py-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + python-oauth: + name: python oauth (unittest) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + # The OAuth helper modules under core/providers/{antigravity,gemini-cli}/oauth/ + # are stdlib-only (urllib, json, http.server, ssl) and import cleanly on + # a fresh Python — no extra apt or pip install needed. + - name: run OAuth unittest suite + run: python3 -m unittest discover -s core/providers -p 'test_*_oauth.py' -v From 91211be350ea97d4acee10fe36c38cdc2d0bb05d Mon Sep 17 00:00:00 2001 From: catalyst-bot Date: Sun, 9 Aug 2026 14:50:22 -0400 Subject: [PATCH 37/38] docs(gemini-cli): narrow advertised model list to free-tier-verified slugs The 'Models' section of core/providers/gemini-cli/README.md was claiming gemini-3-pro-preview, gemini-3-flash-preview, and (by silence) the sibling Antigravity catalog (claude-*, gpt-oss-*) all work against the gemini-cli OAuth client. Live HTTP verification (404 from the cloudcode-pa gateway) shows only the four slugs below actually resolve on free-tier gemini-cli OAuth; the rest are Antigravity-only. Narrow the upper 'Models' section to the four verified working slugs: gemini-2.5-pro gemini-2.5-flash gemini-2.5-flash-lite gemini-3.1-flash-lite-preview Add the required note: 'This list was verified live against free-tier gemini-cli OAuth. The Antigravity bundle has a wider catalog (Claude + Gemini 3.x + GPT-OSS).' Also call out the Antigravity-only slugs explicitly and link down to the 'Working models (verified 2026-08)' section, which keeps the full 404 list for troubleshooting. --- core/providers/gemini-cli/README.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/core/providers/gemini-cli/README.md b/core/providers/gemini-cli/README.md index 1841628..87e556b 100644 --- a/core/providers/gemini-cli/README.md +++ b/core/providers/gemini-cli/README.md @@ -33,20 +33,25 @@ fails with `project not found`. ## Models -Gemini CLI exposes the Gemini 3 / 3.1 Pro + Flash previews plus the -2.5 family. Model IDs map 1:1 to upstream Code Assist slugs — no -aliasing: +Gemini CLI exposes a subset of Code Assist model slugs under free-tier +OAuth. Model IDs map 1:1 to upstream Code Assist slugs — no aliasing. + +This list was verified live against free-tier gemini-cli OAuth. The +Antigravity bundle has a wider catalog (Claude + Gemini 3.x + GPT-OSS). ```text -gemini-3.1-pro-preview -gemini-3-pro-preview -gemini-3-flash-preview -gemini-3.1-flash-lite-preview gemini-2.5-pro gemini-2.5-flash gemini-2.5-flash-lite +gemini-3.1-flash-lite-preview ``` +`gemini-3-pro-preview`, `gemini-3-flash-preview`, `gemini-3.1-pro-preview`, +`gemini-3.1-pro-high`, and any `claude-*` slug are **Antigravity-only** and +return HTTP 404 against the gemini-cli OAuth client. See +[Working models (verified 2026-08)](#working-models-verified-2026-08) below +for the live verification log. + ## Endpoints | Purpose | URL | From b9b5ef9881a9d00d9ff711db20867996da84e448 Mon Sep 17 00:00:00 2001 From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:21:08 +0000 Subject: [PATCH 38/38] fix(build): platform-aware web auto-detect; align gemini-cli wire sample Enable native-browser by default on Darwin/Windows (system WKWebView / WebView2); keep gio-2.0 probe for Linux. Swap the gemini-cli Wire example model to a free-tier-verified slug. --- build.sh | 34 +++++++++++++++++++---------- core/providers/gemini-cli/README.md | 2 +- docs/build.md | 21 ++++++++++-------- 3 files changed, 35 insertions(+), 22 deletions(-) diff --git a/build.sh b/build.sh index b27480f..5c6eec0 100755 --- a/build.sh +++ b/build.sh @@ -10,8 +10,8 @@ cd "$ROOT_DIR" # WebKitGTK system headers on Linux). # --no-web skip `native-browser`; build the TUI-only core. This is the # right mode on headless servers and CI. -# (none) auto-detect: build `native-browser` when pkg-config can find -# gio-2.0, otherwise skip it with a one-line notice. +# (none) auto-detect: enable on macOS/Windows (system WKWebView / +# WebView2); on Linux probe pkg-config for gio-2.0. # Plus --run [args] to launch the freshly-built TUI when the build succeeds. WITH_WEB="auto" RUN_TUI=false @@ -26,7 +26,8 @@ installation when one is on PATH. --with-web build the \`native-browser\` feature (Linux: requires libgtk-3-dev, libwebkit2gtk-4.1-dev, libgio-2.0-dev) --no-web skip \`native-browser\`; build the TUI-only core - (default) auto-detect via \`pkg-config --exists gio-2.0\` + (default) auto-detect: macOS/Windows always; Linux via + \`pkg-config --exists gio-2.0\` --run [...] after building, exec the freshly-built TUI EOF } @@ -45,16 +46,25 @@ while [[ $# -gt 0 ]]; do shift done -# Resolve "auto" by probing for the WebKitGTK pkg-config metadata. We only -# need *any* system library the native-browser feature pulls in; gio-2.0 is -# the cheapest reliable signal on Linux. +# Resolve "auto" in a platform-aware way: +# - Darwin / Windows (MINGW/MSYS/CYGWIN): system WKWebView / WebView2 — no +# extra packages, so enable native-browser by default. +# - Linux (and anything else): probe for WebKitGTK via gio-2.0 on pkg-config; +# headless hosts without GTK skip with a one-line notice. if [[ "$WITH_WEB" == "auto" ]]; then - if command -v pkg-config >/dev/null 2>&1 && pkg-config --exists gio-2.0; then - WITH_WEB="yes" - else - WITH_WEB="no" - echo "notice: WebKitGTK system headers not found via pkg-config; skipping native-browser (pass --with-web once you've installed them)" - fi + case "$(uname -s 2>/dev/null || echo unknown)" in + Darwin|MINGW*|MSYS*|CYGWIN*) + WITH_WEB="yes" + ;; + *) + if command -v pkg-config >/dev/null 2>&1 && pkg-config --exists gio-2.0; then + WITH_WEB="yes" + else + WITH_WEB="no" + echo "notice: WebKitGTK system headers not found via pkg-config; skipping native-browser (pass --with-web once you've installed them)" + fi + ;; + esac fi if [[ "$WITH_WEB" == "yes" ]]; then diff --git a/core/providers/gemini-cli/README.md b/core/providers/gemini-cli/README.md index 87e556b..5261b52 100644 --- a/core/providers/gemini-cli/README.md +++ b/core/providers/gemini-cli/README.md @@ -89,7 +89,7 @@ After OAuth + project discovery, every chat turn is a POST to ```json { - "model": "gemini-3.1-pro-preview", + "model": "gemini-2.5-flash", "project": "", "userAgent": "google-api-nodejs-client/9.15.1", "request": { diff --git a/docs/build.md b/docs/build.md index 7e3aa5d..15fa728 100644 --- a/docs/build.md +++ b/docs/build.md @@ -15,13 +15,14 @@ cd catcode bash build.sh # auto-detects; builds TUI-only core if WebKitGTK is missing ``` -`build.sh` probes `pkg-config --exists gio-2.0` and skips the `native-browser` -feature when it's absent, so headless servers and CI containers build cleanly -without any GUI dependencies. To force one mode or the other: +On macOS and Windows, `build.sh` enables `native-browser` by default (system +`WKWebView` / `WebView2`). On Linux it probes `pkg-config --exists gio-2.0` +and skips the feature when it's absent, so headless servers and CI containers +build cleanly without any GUI dependencies. To force one mode or the other: | Flag | Effect | |--------------|-----------------------------------------------------------------------------------| -| (no flag) | Auto-detect: build `native-browser` when `gio-2.0` is on pkg-config search path | +| (no flag) | Auto-detect: macOS/Windows always; Linux when `gio-2.0` is on pkg-config path | | `--with-web` | Force building `native-browser`. Fails if WebKitGTK system headers are missing | | `--no-web` | Force a TUI-only build (no `native-browser`, no GUI dependencies) | | `--run` | After building, exec the freshly-built TUI with the new core | @@ -92,11 +93,13 @@ if it isn't already present. ### Auto-detect (default) -`bash build.sh` without arguments probes for `gio-2.0` via `pkg-config`. When -it finds it, the build enables the `native-browser` cargo feature (matching -the behaviour of prebuilt binaries, which include the browser engine). When -it doesn't, the build emits a single notice line and produces a TUI-only -core: +`bash build.sh` without arguments picks a platform-aware default: + +- **macOS / Windows** — enables `native-browser` immediately (system + `WKWebView` / `WebView2`; no extra packages). +- **Linux** — probes for `gio-2.0` via `pkg-config`. When found, enables + `native-browser` (matching prebuilt binaries). When missing, emits a + single notice line and produces a TUI-only core: ``` notice: WebKitGTK system headers not found via pkg-config; skipping