diff --git a/.catalyst-code/skills/plugin-authoring/SKILL.md b/.catalyst-code/skills/plugin-authoring/SKILL.md index fe7bb1e..d06e3cf 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 @@ -608,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", @@ -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/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5e3c93..203022c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,18 +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: install + working-directory: web + run: bun install --frozen-lockfile - name: typecheck working-directory: web run: bun run typecheck 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 diff --git a/.gitignore b/.gitignore index 074969a..908f243 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/build.sh b/build.sh index 703f6b3..5c6eec0 100755 --- a/build.sh +++ b/build.sh @@ -5,25 +5,75 @@ 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: 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 +RUN_ARGS=() +print_help() { + cat <&2 + print_help >&2 + exit 2 + ;; + esac + shift +done + +# 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 + 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 + 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 +121,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/core/providers/README.md b/core/providers/README.md index 574ab59..e356d9f 100644 --- a/core/providers/README.md +++ b/core/providers/README.md @@ -55,3 +55,121 @@ 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-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 +**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 (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/__init__.py b/core/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/providers/_shared/google_oauth.py b/core/providers/_shared/google_oauth.py new file mode 100644 index 0000000..2d2b5ae --- /dev/null +++ b/core/providers/_shared/google_oauth.py @@ -0,0 +1,272 @@ +#!/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. + + 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 + 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: + handle.close() + return None + 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/README.md b/core/providers/antigravity/README.md new file mode 100644 index 0000000..91908d2 --- /dev/null +++ b/core/providers/antigravity/README.md @@ -0,0 +1,110 @@ +# 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-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. (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 +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-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 + +- 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/__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/antigravity-oauth.py b/core/providers/antigravity/oauth/antigravity-oauth.py new file mode 100644 index 0000000..7b25605 --- /dev/null +++ b/core/providers/antigravity/oauth/antigravity-oauth.py @@ -0,0 +1,472 @@ +#!/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. + +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-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 json +import os +import sys +import time +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. +# 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. ``/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. +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 = [ + "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", +] + +# 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). +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 + +# Per-script defaults for shared helpers. +_TOKEN_FILENAME = "antigravity.json" +_ATOMIC_WRITE_PREFIX = ".antigravity-oauth-" + + +# ─── 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 token_path(ctx): + """Absolute path of the on-disk token file (Antigravity-specific default).""" + return _shared_token_path(ctx, _TOKEN_FILENAME) + + +# ─── PKCE + auth URL ─────────────────────────────────────────────────────── + +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", + "redirect_uri": redirect_uri, + "scope": " ".join(SCOPES), + "state": state, + "code_challenge": challenge, + "code_challenge_method": "S256", + "access_type": "offline", + } + 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): + return _shared_refresh_access_token( + TOKEN_URL, CLIENT_ID, CLIENT_SECRET, refresh_token + ) + + +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 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 ───────────────────────────── + +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 _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: + return extract_cloudaicompanion_project(data) + if attempt < ONBOARD_MAX_ATTEMPTS: + time.sleep(ONBOARD_POLL_S) + return None + + +def discover_project_id(access_token): + """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 + status, data = post_json( + LOAD_CODE_ASSIST_URL, + _code_assist_body(), + _code_assist_headers(access_token), + ) + if status != 200: + return None + project = extract_cloudaicompanion_project(data) + if project: + return project + tier = _pick_default_tier(data) + return onboard_user(access_token, tier) + + +# ─── 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 + # 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: + normalized["email"] = email + + atomic_write(token_path(ctx), normalized, prefix=_ATOMIC_WRITE_PREFIX) + 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, prefix=_ATOMIC_WRITE_PREFIX) + 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: + # 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, + "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 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() + 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/oauth/test_antigravity_oauth.py b/core/providers/antigravity/oauth/test_antigravity_oauth.py new file mode 100644 index 0000000..19f520f --- /dev/null +++ b/core/providers/antigravity/oauth/test_antigravity_oauth.py @@ -0,0 +1,542 @@ +#!/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", + } + + 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") + # expires_at is integer-seconds; the script may have sampled time + # anywhere in the [t0, t1] window, so accept the full inclusive range. + self.assertGreaterEqual(out["expires_at"], t0 + 3600) + self.assertLessEqual(out["expires_at"], t1 + 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 diff --git a/core/providers/antigravity/plugin.json b/core/providers/antigravity/plugin.json new file mode 100644 index 0000000..8912826 --- /dev/null +++ b/core/providers/antigravity/plugin.json @@ -0,0 +1,26 @@ +{ + "name": "antigravity", + "version": "0.1.0", + "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 \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" + ] + ], + "token_path": "antigravity.json", + "script": "oauth/antigravity-oauth.py", + "login_timeout_ms": 300000, + "token_timeout_ms": 45000, + "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 new file mode 100644 index 0000000..5261b52 --- /dev/null +++ b/core/providers/gemini-cli/README.md @@ -0,0 +1,162 @@ +# 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-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. (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 +until provisioning finishes (`done=true`), so the first request never +fails with `project not found`. + +## Models + +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-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 | +|-----------------------------|--------------------------------------------------------------------| +| 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: 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 +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-2.5-flash", + "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-code-assist-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. + +## 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/__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/gemini-cli-oauth.py b/core/providers/gemini-cli/oauth/gemini-cli-oauth.py new file mode 100644 index 0000000..70278c3 --- /dev/null +++ b/core/providers/gemini-cli/oauth/gemini-cli-oauth.py @@ -0,0 +1,504 @@ +#!/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. + +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); +* 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 json +import os +import sys +import time +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 +# ``@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" + +# 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 = [ + "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). +# 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" + +REFRESH_LEAD_S = 300 +ONBOARD_MAX_ATTEMPTS = 5 +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 ─────────────────────────────────────────────────────────── + +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 token_path(ctx): + """Absolute path of the on-disk token file (gemini-cli-specific default).""" + return _shared_token_path(ctx, _TOKEN_FILENAME) + + +# ─── PKCE + auth URL ─────────────────────────────────────────────────────── + +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", + "redirect_uri": redirect_uri, + "scope": " ".join(SCOPES), + "state": state, + "code_challenge": challenge, + "code_challenge_method": "S256", + "access_type": "offline", + } + 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): + return _shared_refresh_access_token( + TOKEN_URL, CLIENT_ID, CLIENT_SECRET, refresh_token + ) + + +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 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 ───────────────────────────── + +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, 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 + + +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_cloudaicompanion_project(data) + if attempt < ONBOARD_MAX_ATTEMPTS: + time.sleep(ONBOARD_POLL_S) + return None + + +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. ``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). + 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: + return override + payload = load_code_assist_payload(access_token) + if payload is not None: + project = extract_cloudaicompanion_project(payload) + if project: + return project + tier = _pick_default_tier(payload) + 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. 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 = [] + 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: + sib = read_token(sibling) + if sib: + pid = str(sib.get("project_id") or "").strip() + if pid: + return pid + 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"], + oauth_dir=os.path.dirname(token_path(ctx)), + ) + 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, prefix=_ATOMIC_WRITE_PREFIX) + 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, prefix=_ATOMIC_WRITE_PREFIX) + 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: + # 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, + "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 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() + 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() 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..f5b0153 --- /dev/null +++ b/core/providers/gemini-cli/oauth/test_gemini_cli_oauth.py @@ -0,0 +1,510 @@ +#!/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): + 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_token_path_dir(self): + """Sibling lookup uses ``dirname(token_path)`` for custom layouts. + + 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": "custom-layout-proj"}, + handle, + ) + + self.mock.start() + + @self.mock.route("/token") + def token(_body, _headers): + return 200, { + "access_token": "custom-access", + "refresh_token": "custom-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, + } + 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"], "custom-layout-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", + } + + 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") + # expires_at is integer-seconds; the script may have sampled time + # anywhere in the [t0, t1] window, so accept the full inclusive range. + self.assertGreaterEqual(out["expires_at"], t0 + 3600) + self.assertLessEqual(out["expires_at"], t1 + 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 diff --git a/core/providers/gemini-cli/plugin.json b/core/providers/gemini-cli/plugin.json new file mode 100644 index 0000000..1b7b9b1 --- /dev/null +++ b/core/providers/gemini-cli/plugin.json @@ -0,0 +1,26 @@ +{ + "name": "gemini-cli", + "version": "0.1.0", + "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 \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" + ] + ], + "token_path": "gemini-cli.json", + "script": "oauth/gemini-cli-oauth.py", + "login_timeout_ms": 300000, + "token_timeout_ms": 45000, + "env_passthrough": [ + "CATALYST_CODE_GEMINI_CLI_PROJECT" + ], + "redirect_path": "/oauth2callback" + } +} diff --git a/core/src/plugins.rs b/core/src/plugins.rs index b9360c8..dc7cac5 100644 --- a/core/src/plugins.rs +++ b/core/src/plugins.rs @@ -596,6 +596,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 @@ -728,6 +734,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. @@ -2392,8 +2402,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); @@ -3630,6 +3650,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/provider.rs b/core/src/provider.rs index 1547677..4bc7947 100644 --- a/core/src/provider.rs +++ b/core/src/provider.rs @@ -4821,26 +4821,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/core/src/providers/google_code_assist.rs b/core/src/providers/google_code_assist.rs index 078b7c5..30a61e0 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,202 @@ 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"); + } + + #[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 + // 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" + ); + } +} diff --git a/core/src/staging.rs b/core/src/staging.rs index 6a7e948..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 = 6; +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). @@ -262,6 +262,43 @@ 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"), + ), + // --- 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), ] @@ -274,6 +311,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 +408,11 @@ 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 + │ └── _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) @@ -455,6 +498,36 @@ 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("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(), @@ -515,6 +588,23 @@ 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" + ); } } 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); +} diff --git a/docs/build.md b/docs/build.md new file mode 100644 index 0000000..15fa728 --- /dev/null +++ b/docs/build.md @@ -0,0 +1,193 @@ +# 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 +``` + +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: 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 | + +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 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 + 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 diff --git a/docs/plugins/oauth.md b/docs/plugins/oauth.md new file mode 100644 index 0000000..a2d886e --- /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_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"]`. +- **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. diff --git a/protocol.schema.json b/protocol.schema.json index 69f0dcf..f15a3dd 100644 --- a/protocol.schema.json +++ b/protocol.schema.json @@ -56,7 +56,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",