Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
038f5c8
feat(providers): add Antigravity + Gemini CLI OAuth plugins
phantomic12 Aug 7, 2026
8ec5ceb
feat(antigravity): allow CATALYST_CODE_ANTIGRAVITY_PROJECT override
phantomic12 Aug 7, 2026
a9047f1
fix(providers): gemini-cli works without x-goog-user-project
phantomic12 Aug 7, 2026
e79ebbe
style: rustfmt staging.rs test blocks for new providers
phantomic12 Aug 7, 2026
18b7e92
fix(providers): address review — missing constant, redirect path, env…
phantomic12 Aug 7, 2026
7945198
refactor(providers): extract shared OAuth helpers into _shared/google…
Aug 7, 2026
40b93d4
docs(plugins): document redirect_path, env_passthrough, header gotchas
catcode Aug 7, 2026
4e894ec
test(core): integration test for OAuth plugin lifecycle + redirect_path
catcode Aug 7, 2026
71266cb
test(providers): add e2e OAuth tests for antigravity (mock HTTP)
catcode Aug 7, 2026
9eeacf5
test(providers): add e2e OAuth tests for gemini-cli (mock HTTP)
catcode Aug 7, 2026
b14516a
style: pick up upstream format + protocol drift
phantomic12 Aug 7, 2026
8bf9d67
fix: sync Go fixture + web reducer for new advisor events
phantomic12 Aug 7, 2026
352a7f5
ci: build SDK before web typecheck/test
phantomic12 Aug 7, 2026
73b756d
ci: move web install after SDK build
phantomic12 Aug 7, 2026
6e4181d
fix(web): add advisor_note + advisor_status to web's CoreEvent type
phantomic12 Aug 7, 2026
84b6f69
fix(providers): address review feedback (docs, tests, hardening)
phantomic12 Aug 9, 2026
bf31d3d
fix(providers): retire CATALYST_CODE_OAUTH_DIR test story and align C…
pullfrog[bot] Aug 9, 2026
a41ac49
feat(providers): add Antigravity + Gemini CLI OAuth plugins
phantomic12 Aug 7, 2026
27b8440
feat(antigravity): allow CATALYST_CODE_ANTIGRAVITY_PROJECT override
phantomic12 Aug 7, 2026
d013dac
fix(providers): gemini-cli works without x-goog-user-project
phantomic12 Aug 7, 2026
5588c56
style: rustfmt staging.rs test blocks for new providers
phantomic12 Aug 7, 2026
720fa19
fix(providers): address review — missing constant, redirect path, env…
phantomic12 Aug 7, 2026
7b88199
refactor(providers): extract shared OAuth helpers into _shared/google…
Aug 7, 2026
f149ad5
docs(plugins): document redirect_path, env_passthrough, header gotchas
catcode Aug 7, 2026
110c3e4
test(core): integration test for OAuth plugin lifecycle + redirect_path
catcode Aug 7, 2026
16a1a35
test(providers): add e2e OAuth tests for antigravity (mock HTTP)
catcode Aug 7, 2026
257db0a
test(providers): add e2e OAuth tests for gemini-cli (mock HTTP)
catcode Aug 7, 2026
0cf2abc
style: pick up upstream format + protocol drift
phantomic12 Aug 7, 2026
a8a2889
fix: sync Go fixture + web reducer for new advisor events
phantomic12 Aug 7, 2026
3391d12
ci: build SDK before web typecheck/test
phantomic12 Aug 7, 2026
c8c9d16
ci: move web install after SDK build
phantomic12 Aug 7, 2026
7460bb8
fix(web): add advisor_note + advisor_status to web's CoreEvent type
phantomic12 Aug 7, 2026
6d0a694
fix(providers): address review feedback (docs, tests, hardening)
phantomic12 Aug 9, 2026
40706f2
chore: align with upstream master after skills marketplace rebase
phantomic12 Aug 9, 2026
8abf9e1
merge: incorporate pullfrog auto-fix bf31d3d (drop CATALYST_CODE_OAUT…
phantomic12 Aug 9, 2026
937bca7
build: add --no-web flag and auto-detect for WebKitGTK deps
Aug 9, 2026
52c0387
ci: add workflow for Python OAuth tests
Aug 9, 2026
91211be
docs(gemini-cli): narrow advertised model list to free-tier-verified …
Aug 9, 2026
b9b5ef9
fix(build): platform-aware web auto-detect; align gemini-cli wire sample
pullfrog[bot] Aug 10, 2026
c42d38d
merge(master): catch up + resolve open PR #7 review threads
phantomic12 Aug 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 34 additions & 8 deletions .catalyst-code/skills/plugin-authoring/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- `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_<NAME>_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

Expand All @@ -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:<port>/callback` the
harness already bound — embed it verbatim in your authorize URL). Output:
for the web flow, `redirect_uri` (a `http://localhost:<port>/<redirect_path>`
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",
Expand Down Expand Up @@ -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.
Expand Down
14 changes: 11 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions .github/workflows/oauth-python-tests.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
core/target/
**/*.rs.bk

# Python
__pycache__/
**/__pycache__/
*.pyc
*.pyo

# Go (tui/) compiled binaries
tui/tui
tui/catalyst-code-tui
Expand Down
91 changes: 70 additions & 21 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<EOF
usage: $(basename "$0") [--with-web | --no-web] [--run [TUI_ARGS...]]

echo "[1/3] building core (cargo, native-browser, -j$(nproc))..."
cargo build --release -j"$(nproc)" --features native-browser --manifest-path core/Cargo.toml
Builds the release core and Go TUI, then replaces the current catcode
installation when one is on PATH.

--with-web build the \`native-browser\` feature (Linux: requires
libgtk-3-dev, libwebkit2gtk-4.1-dev, libgio-2.0-dev)
--no-web skip \`native-browser\`; build the TUI-only core
(default) auto-detect: macOS/Windows always; Linux via
\`pkg-config --exists gio-2.0\`
--run [...] after building, exec the freshly-built TUI
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--with-web) WITH_WEB="yes" ;;
--no-web) WITH_WEB="no" ;;
--run) RUN_TUI=true; shift; RUN_ARGS=("$@"); break ;;
-h|--help) print_help; exit 0 ;;
*)
printf 'error: unknown option %s\n' "$1" >&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 . )
Expand Down Expand Up @@ -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
118 changes: 118 additions & 0 deletions core/providers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## 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": "<resolved-model-id>",
"project": "<from x-code-assist-project>",
"userAgent": "antigravity",
"request": {
"contents": [ {"role": "user", "parts": [{"text": "…"}]}, … ],
"generationConfig": { "maxOutputTokens": <n> },
"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.
Empty file added core/providers/__init__.py
Empty file.
Loading
Loading