diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b98a9a1..88379af4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,6 +79,8 @@ jobs: workspace: "@cortexkit/opencode-antigravity-auth" - package: "@cortexkit/pi-antigravity-auth" workspace: "@cortexkit/pi-antigravity-auth" + - package: "@cortexkit/opencode-v2-antigravity-auth" + workspace: "@cortexkit/opencode-v2-antigravity-auth" steps: - uses: actions/checkout@v5 with: diff --git a/README.md b/README.md index 626becf1..bbfe9b9d 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,13 @@ Google Antigravity OAuth for coding agents. Authenticate with your Google account and access Antigravity quota for Gemini, Claude, and GPT-OSS models from OpenCode, the Pi coding agent, or the standalone CLI. -This monorepo ships three packages: +This monorepo ships four packages: | Package | Host | Role | | --- | --- | --- | | [`@cortexkit/opencode-antigravity-auth`](packages/opencode) | OpenCode 1.x server | Intercepts `fetch()`, runs the account pool + quota manager, drives slash commands, and exposes a TUI sidebar through a loopback RPC. | | [`@cortexkit/pi-antigravity-auth`](packages/pi) | Pi coding agent | Registers a custom provider with OAuth login + Gemini streaming. | +| [`@cortexkit/opencode-v2-antigravity-auth`](packages/opencode-v2) | OpenCode 2.x server | Registers the Antigravity models through the native plugin API: a loopback bridge for the account pool and transport, an OAuth method that appends accounts, and a document tool for PDF input. | | [`@cortexkit/antigravity-auth-core`](packages/core) | Any harness | Harness-agnostic core: OAuth PKCE, raw HTTP/1.1 transport, device fingerprint, request transforms, account pool, quota manager, durable storage. Both host packages depend on it. | ## Risk and terms-of-service warning @@ -404,14 +405,14 @@ The script enforces semver, refuses to run on a dirty tree, and refuses to run o The `Release` workflow (`.github/workflows/release.yml`) runs on tag push (or `workflow_dispatch`) and: 1. **Test** job — checkout the tag ref → install Bun 1.3 + Node 24 → `bun install --frozen-lockfile` → `bun run typecheck` → `bun run build` → `bun run --cwd packages/opencode smoke:tui` → `bun test` → `bun run test:e2e` → `bun run format:check` → `bun run lint`. -2. **Publish** job — matrixed across the three packages (`@cortexkit/antigravity-auth-core`, `@cortexkit/opencode-antigravity-auth`, `@cortexkit/pi-antigravity-auth`) with `max-parallel: 1` so each package picks up its own OIDC token; `npm Trusted Publishing` (`npm publish --workspace … --access public --provenance`) handles the upload. The job skips a package if its npm version is already published. +2. **Publish** job — matrixed across the four packages (`@cortexkit/antigravity-auth-core`, `@cortexkit/opencode-antigravity-auth`, `@cortexkit/pi-antigravity-auth`, `@cortexkit/opencode-v2-antigravity-auth`) with `max-parallel: 1` so each package picks up its own OIDC token; `npm Trusted Publishing` (`npm publish --workspace … --access public --provenance`) handles the upload. The job skips a package if its npm version is already published. 3. **GitHub Release** job — `softprops/action-gh-release@v2` creates the GitHub release with `generate_release_notes: true`. CI (`.github/workflows/ci.yml`) reuses the same typecheck → build → smoke → test → e2e → format → lint chain on every push to `main` and on every pull request. The `issue-triage.yml` workflow handles GitHub-issue routing. ## Architecture and structure links -- [ARCHITECTURE.md](ARCHITECTURE.md) — system-of-record architecture doc across all three packages. +- [ARCHITECTURE.md](ARCHITECTURE.md) — system-of-record architecture doc across all four packages. - [STRUCTURE.md](STRUCTURE.md) — file-system map of the monorepo. - [packages/opencode/ARCHITECTURE.md](packages/opencode/ARCHITECTURE.md) — OpenCode plugin architecture in detail. - [packages/opencode/STRUCTURE.md](packages/opencode/STRUCTURE.md) — OpenCode package file-system map. diff --git a/biome.jsonc b/biome.jsonc index 7221bf89..4354bb32 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -24,6 +24,7 @@ "packages/*/scripts/**", "scripts/**", "test/**", + "packages/*/test/**", "*.json", "packages/*/*.json", ".github/**/*.yml" diff --git a/bun.lock b/bun.lock index 6bbe0352..6083de94 100644 --- a/bun.lock +++ b/bun.lock @@ -19,7 +19,7 @@ }, "packages/core": { "name": "@cortexkit/antigravity-auth-core", - "version": "2.0.0", + "version": "2.1.0", "dependencies": { "@openauthjs/openauth": "^0.4.3", "xdg-basedir": "^5.1.0", @@ -41,7 +41,7 @@ }, "packages/opencode": { "name": "@cortexkit/opencode-antigravity-auth", - "version": "2.0.0", + "version": "2.1.0", "bin": { "antigravity-auth": "./dist/cli.js", }, @@ -66,9 +66,16 @@ "typescript": "^5 || ^6", }, }, + "packages/opencode-v2": { + "name": "@cortexkit/opencode-v2-antigravity-auth", + "version": "2.1.0", + "dependencies": { + "@cortexkit/antigravity-auth-core": "2.1.0", + }, + }, "packages/pi": { "name": "@cortexkit/pi-antigravity-auth", - "version": "2.0.0", + "version": "2.1.0", "dependencies": { "@cortexkit/antigravity-auth-core": "2.0.0", }, @@ -222,6 +229,8 @@ "@cortexkit/opencode-antigravity-auth": ["@cortexkit/opencode-antigravity-auth@workspace:packages/opencode"], + "@cortexkit/opencode-v2-antigravity-auth": ["@cortexkit/opencode-v2-antigravity-auth@workspace:packages/opencode-v2"], + "@cortexkit/pi-antigravity-auth": ["@cortexkit/pi-antigravity-auth@workspace:packages/pi"], "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.79.10", "", { "dependencies": { "@earendil-works/pi-ai": "^0.79.10", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" } }, "sha512-XKxgdjhcPuyjrthCOFSgfzT3xZ1uBrJ1IMVDxci1to6hIN6BIg9J5iY8q0pGXK1DLgATLP23da+1UyZLwA360Q=="], diff --git a/package.json b/package.json index 2282d0b9..334b1ffb 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ ], "scripts": { "build": "bun run --cwd packages/core build && bun run --cwd packages/opencode build && bun run --cwd packages/pi build", - "typecheck": "bun run --cwd packages/core build && bun run --cwd packages/opencode typecheck && bun run --cwd packages/pi typecheck && tsc -p tsconfig.scripts.json", - "test": "bun run --cwd packages/core build && bun test --isolate packages/core/src packages/opencode/src packages/pi/src test/", + "typecheck": "bun run --cwd packages/core build && bun run --cwd packages/opencode typecheck && bun run --cwd packages/pi typecheck && tsc -p tsconfig.scripts.json && node --check packages/opencode-v2/src/plugin.mjs && node --check packages/opencode-v2/src/oauth-callback.mjs", + "test": "bun run --cwd packages/core build && bun test --isolate packages/core/src packages/opencode/src packages/pi/src test/ packages/opencode-v2/test/", "test:e2e": "bun test --isolate ./packages/e2e-tests/src/plugin-flow.e2e.test.ts ./packages/e2e-tests/src/cli-flow.e2e.test.ts ./packages/e2e-tests/src/rpc-tui-flow.e2e.test.ts ./packages/e2e-tests/src/fetch-guard.test.ts ./packages/e2e-tests/src/mock-antigravity-server.test.ts", "test:e2e:models": "bun run --cwd packages/opencode test:e2e:models", "test:e2e:regression": "bun run --cwd packages/opencode test:e2e:regression", diff --git a/packages/opencode-v2/LICENSE b/packages/opencode-v2/LICENSE new file mode 100644 index 00000000..7d3ab8fb --- /dev/null +++ b/packages/opencode-v2/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/opencode-v2/README.md b/packages/opencode-v2/README.md new file mode 100644 index 00000000..ae16cbc3 --- /dev/null +++ b/packages/opencode-v2/README.md @@ -0,0 +1,174 @@ +# opencode-v2-antigravity + +Google Antigravity provider for **OpenCode 2.x**, built on +[`@cortexkit/antigravity-auth-core`](https://www.npmjs.com/package/@cortexkit/antigravity-auth-core). + +`@cortexkit/opencode-antigravity-auth` targets the OpenCode 1.x host +(`engines.opencode: ">=1.17.13 <2"`): it patches `fetch()` and registers a TUI sidebar. +OpenCode 2.x replaced that surface with a typed plugin API (`session.hook`, +`integration.transform`, `tool.transform`, native provider packages), so the 1.x plugin cannot +load there. This package is that missing host adapter — OAuth, transport, account pool, quota +bookkeeping and the model registry all stay in the shared core. + +> **Terms-of-service warning.** This calls Antigravity's non-public internal API. It is not +> endorsed by Google and may violate Google's Terms of Service; accounts have reportedly been +> suspended for similar use. Use at your own risk and never with an important account. + +## Design + +``` +OpenCode 2.x this plugin Antigravity +──────────── ─────────── ─────────── +native @opencode-ai/ai/providers/google + builds Gemini request ──▶ session.hook("http.request") + rewrites the URL to a 127.0.0.1 loopback + │ + ▼ + loopback HTTP server + · picks an account (hybrid strategy) + · refreshes the OAuth token + · ensureProjectContext() + · agent envelope + labels/sessionId + · fetchWithAgyCliTransport() ──▶ daily-cloudcode-pa + (fallback cloudcode-pa) + │ + parses Gemini SSE ◀─────── unwrapped, schema-normalised SSE +``` + +Keeping the native `@opencode-ai/ai/providers/google` package as the codec means image, PDF and +tool-call handling comes from the host instead of a hand-written adapter. + +Why a loopback server instead of returning a `Response` from the hook: OpenCode 2.x sends +whatever `event.request` the hook leaves behind through its own HTTP client, and the +`http.response` hook only runs after that request succeeded. A loopback endpoint keeps the +core's raw HTTP/1.1 transport (agy header order, proxy support) while the host still sees a +plain SSE response it can stream and cancel. + +## Install + +Install the adapter package itself (the shared core is pulled in automatically): + +```bash +# once the package is published: +npm install @cortexkit/opencode-v2-antigravity-auth +# or from this repository (Bun workspace): +bun install +``` + +The plugin entry is `src/plugin.mjs` inside the package. In `opencode.json`, point +`plugins[].package` at that file — for an npm install use +`node_modules/@cortexkit/opencode-v2-antigravity-auth/src/plugin.mjs` relative to the project, +for a checkout use the absolute path +`/path/to/antigravity-auth/packages/opencode-v2/src/plugin.mjs`. The example below uses a +checkout path. + +Register the plugin and the models in `opencode.json` (full snippet in +[`example/opencode.json`](example/opencode.json)): + +```jsonc +{ + "plugins": [ + { + "package": "/absolute/path/to/antigravity-auth/packages/opencode-v2/src/plugin.mjs", + "options": { + // Optional: restrict which directories antigravity_read_document may read. + // Default: anything under the user's home directory (credential/secret + // paths are always blocked). + "readDocumentRoots": ["/absolute/path/to/project/docs"] + } + } + ], + "providers": { + "google": { + "models": { + "gemini-3.7-flash": { + "name": "Gemini 3.7 Flash", + "modelID": "gemini-3.7-flash", + "package": "@opencode-ai/ai/providers/google", + "capabilities": { "tools": true, "input": ["text", "image", "pdf"], "output": ["text"] }, + "limit": { "context": 1048576, "output": 65536 }, + "variants": [{ "id": "low" }, { "id": "medium" }, { "id": "high" }] + } + } + } + } +} +``` + +## Accounts + +- Pool file: `antigravity-accounts.json` in the OpenCode config dir + (`$OPENCODE_CONFIG_DIR`, `$XDG_CONFIG_HOME/opencode`, `%APPDATA%\opencode`, or + `~/.config/opencode`); override with `ANTIGRAVITY_ACCOUNTS_FILE`. Storage schema v4 with the + core's fenced file lock, so the pool is shared with the 1.x plugin and the standalone CLI. +- Add an account: connect the `google` integration and pick + **"Google Antigravity (add account)"**. Each login appends to the pool; existing accounts are + preserved. The callback listens on `127.0.0.1:51121/oauth-callback`. +- Disable an account with `"enabled": false`. +- Selection uses the core `hybrid` strategy. On `429`/`403` the account is cooled down and the + next one is tried, `401` forces a token refresh, and a bare `STOP` (empty candidates) is + retried up to three times. + +## Models + +| Selector | Variants | Wire model | +| --- | --- | --- | +| `google/gemini-3.7-flash` | low, medium, high | `gemini-3.7-flash-{tier}` | +| `google/gemini-3.6-flash` | low, medium, high | `gemini-3.6-flash-{tier}` | +| `google/gemini-3.5-flash` | low, medium, high | `gemini-3.5-flash-extra-low` / `gemini-3.5-flash-low` / `gemini-3-flash-agent` | +| `google/gemini-3.1-pro` | low, high | `gemini-3.1-pro-low` / `gemini-pro-agent` | +| `google/gemini-3.1-flash-image` | — | `gemini-3.1-flash-image` | +| `google/claude-sonnet-4-6-thinking` | — | `claude-sonnet-4-6` | +| `google/claude-opus-4-6-thinking` | — | `claude-opus-4-6-thinking` | +| `google/gpt-oss-120b-medium` | — | `gpt-oss-120b-medium` | + +Model ids and tiers come from `resolveModelForHeaderStyle()`, so the registry stays the single +source of truth. + +## Host quirks this plugin works around + +1. **GPT-OSS tool schemas** — the AGY GPT bridge re-encodes protobuf numeric constraints as + strings, so `minLength: 1` fails OpenAI JSON-Schema validation with `400 INVALID_ARGUMENT`. + Fixed by calling `normalizeGeminiTools(request, { moveNumericConstraintsToDescription: true })` + for `gpt-*` wire models. +2. **Strict native event schema** — GPT-OSS opens a turn with `content` and no `parts`, and + Claude sometimes uses role `assistant`. Both are rejected by the native Gemini event schema + (`Invalid google/gemini stream event`), so every frame is normalised before being forwarded. +3. **Response encoding** — the core transport already inflates gzip, so upstream + `content-encoding` headers must not be copied onto the loopback response. +4. **PDF attachments** — the OpenCode 2.x CLI drops PDF attachments before the provider sees + them (the request arrives without any `inlineData`). The plugin therefore also registers an + `antigravity_read_document` tool that loads the file itself: + `antigravity_read_document({ path, question?, model? })` for `.pdf`, `.png`, `.jpg`, `.webp`, + `.gif`, `.heic`. Images attached in chat work without the tool. + **Security note:** the tool reads local files and sends them to the Antigravity server — + an untrusted PDF/image is a prompt-injection vector that could instruct the model to read + sensitive files. Paths are therefore checked: well-known credential/secret locations + (`~/.ssh`, `~/.config`, `~/.local`, `AppData`, `.env`, `*.key`, `*.pem`, `*.p12`, `*.pfx`, + `id_rsa`, `credentials`, `auth.json`, `antigravity-accounts.json`, …) are always refused, + and by default only files under the user's home directory are readable. To narrow the + readable root further, set the `readDocumentRoots` plugin option (array of absolute + directories). Prefer attaching documents in chat where the host preserves them. +5. **Image output** — the native parser renders text and tool calls only, so generated images + are written to `/antigravity-images/` and announced as text. + +## Logging and privacy + +`/antigravity-v2.log` records routing, `#`, upstream status codes, +rotation and saved image paths. No prompts, tokens, e-mail addresses or refresh tokens are +written. Credentials live only in the pool file owned by the core. + +## Verified + +Windows 11, Node 24, OpenCode `0.0.0-beta-17595`, two-account pool: + +- all eight selectors above answered a live prompt, including every reasoning tier; +- tool calling works end to end (the model called `read` and returned a directory listing); +- PNG attachment recognised (a red square → "Red"); +- PDF read through `antigravity_read_document` (exact embedded string returned); +- image generation produced two JPEG files on disk; +- forced failover: disabling account `#0` routed the next request to account `#1`. + +## License + +MIT. diff --git a/packages/opencode-v2/README.zh-CN.md b/packages/opencode-v2/README.zh-CN.md new file mode 100644 index 00000000..cfa484ad --- /dev/null +++ b/packages/opencode-v2/README.zh-CN.md @@ -0,0 +1,161 @@ +# opencode-v2-antigravity + +面向 **OpenCode 2.x** 的 Google Antigravity provider,基于 +[`@cortexkit/antigravity-auth-core`](https://www.npmjs.com/package/@cortexkit/antigravity-auth-core) 实现。 + +`@cortexkit/opencode-antigravity-auth` 面向 OpenCode 1.x 宿主 +(`engines.opencode: ">=1.17.13 <2"`):它通过劫持 `fetch()` 并注册 TUI 侧边栏来工作。 +OpenCode 2.x 用新的插件 API 取代了这些接口(`session.hook`、`integration.transform`、 +`tool.transform`、原生 provider 包),因此 1.x 插件无法在 2.x 中加载。本包补上了这层宿主适配: +OAuth、传输、账号池、配额与模型注册表仍然全部复用共享 core。 + +> **服务条款警告。** 本项目调用 Antigravity 的非公开内部 API,未获 Google 认可,可能违反 +> Google 服务条款;已有账号因类似用法被限制的报告。请自行评估风险,不要使用重要账号。 + +## 设计 + +``` +OpenCode 2.x 本插件 Antigravity +──────────── ────── ─────────── +原生 @opencode-ai/ai/providers/google + 构造 Gemini 请求 ──▶ session.hook("http.request") + 将 URL 改写为 127.0.0.1 回环地址 + │ + ▼ + 回环 HTTP 服务 + · 选择账号(hybrid 策略) + · 刷新 OAuth token + · ensureProjectContext() + · agent 信封 + labels/sessionId + · fetchWithAgyCliTransport() ──▶ daily-cloudcode-pa + (回退 cloudcode-pa) + │ + 解析 Gemini SSE ◀─────── 解包并规范化后的 SSE +``` + +保留原生 `@opencode-ai/ai/providers/google` 作为编解码器,意味着图片、PDF 和 tool call +都交由宿主处理,不需要手写 adapter。 + +为什么使用回环服务,而不是在 hook 里直接返回 `Response`:OpenCode 2.x 会把 hook 留下的 +`event.request` 交给自己的 HTTP 客户端发送,而 `http.response` hook 只在该请求成功之后才会执行。 +回环端点既保留了 core 的原始 HTTP/1.1 传输(agy 的 header 顺序、代理支持),又让宿主看到一个 +可以正常流式读取与取消的 SSE 响应。 + +## 安装 + +```bash +# 包发布后: +npm install @cortexkit/opencode-v2-antigravity-auth +# 或从本仓库(Bun workspace): +bun install +``` + +插件的入口是包内的 `src/plugin.mjs`。在 `opencode.json` 中,把 `plugins[].package` 指向该文件: +npm 安装时用 `node_modules/@cortexkit/opencode-v2-antigravity-auth/src/plugin.mjs` +(相对于项目目录),从仓库检出时用绝对路径 +`/path/to/antigravity-auth/packages/opencode-v2/src/plugin.mjs`。下面示例使用检出路径。 + +然后在 `opencode.json` 中注册插件与模型(完整示例见 +[`example/opencode.json`](example/opencode.json)): + +```jsonc +{ + "plugins": [ + { + "package": "/绝对路径/antigravity-auth/packages/opencode-v2/src/plugin.mjs", + "options": { + // 可选:限定 antigravity_read_document 可读取的目录。 + // 默认:用户主目录下的任意位置(凭据/密钥路径始终被拦截)。 + "readDocumentRoots": ["/绝对路径/project/docs"] + } + } + ], + "providers": { + "google": { + "models": { + "gemini-3.7-flash": { + "name": "Gemini 3.7 Flash", + "modelID": "gemini-3.7-flash", + "package": "@opencode-ai/ai/providers/google", + "capabilities": { "tools": true, "input": ["text", "image", "pdf"], "output": ["text"] }, + "limit": { "context": 1048576, "output": 65536 }, + "variants": [{ "id": "low" }, { "id": "medium" }, { "id": "high" }] + } + } + } + } +} +``` + +## 账号 + +- 账号池文件:OpenCode 配置目录下的 `antigravity-accounts.json` + (`$OPENCODE_CONFIG_DIR`、`$XDG_CONFIG_HOME/opencode`、`%APPDATA%\opencode` + 或 `~/.config/opencode`),可用 `ANTIGRAVITY_ACCOUNTS_FILE` 覆盖。 + 存储结构为 v4,并使用 core 的文件锁,因此与 1.x 插件、独立 CLI 共享同一份数据。 +- 添加账号:连接 `google` integration,选择 + **“Google Antigravity (add account)”**。每次登录都是**追加**,不会覆盖已有账号。 + 回调监听 `127.0.0.1:51121/oauth-callback`。 +- 停用账号:把该条目设为 `"enabled": false`。 +- 账号选择使用 core 的 `hybrid` 策略;遇到 `429`/`403` 会让该账号冷却并切换到下一个, + `401` 会强制刷新 token,返回空候选的 `STOP` 最多重试三次。 + +## 模型 + +| 选择器 | 变体 | 实际下发模型 | +| --- | --- | --- | +| `google/gemini-3.7-flash` | low, medium, high | `gemini-3.7-flash-{tier}` | +| `google/gemini-3.6-flash` | low, medium, high | `gemini-3.6-flash-{tier}` | +| `google/gemini-3.5-flash` | low, medium, high | `gemini-3.5-flash-extra-low` / `gemini-3.5-flash-low` / `gemini-3-flash-agent` | +| `google/gemini-3.1-pro` | low, high | `gemini-3.1-pro-low` / `gemini-pro-agent` | +| `google/gemini-3.1-flash-image` | — | `gemini-3.1-flash-image` | +| `google/claude-sonnet-4-6-thinking` | — | `claude-sonnet-4-6` | +| `google/claude-opus-4-6-thinking` | — | `claude-opus-4-6-thinking` | +| `google/gpt-oss-120b-medium` | — | `gpt-oss-120b-medium` | + +模型 id 与推理档位来自 `resolveModelForHeaderStyle()`,注册表仍是唯一事实来源。 + +## 已绕过的宿主/上游问题 + +1. **GPT-OSS 的工具 schema**:AGY 的 GPT 桥接会把 protobuf 数值约束重新编码为字符串, + 于是 `minLength: 1` 在 OpenAI JSON-Schema 校验中失败,返回 `400 INVALID_ARGUMENT`。 + 对 `gpt-*` 下发模型调用 + `normalizeGeminiTools(request, { moveNumericConstraintsToDescription: true })` 即可解决。 +2. **原生事件 schema 很严格**:GPT-OSS 的首帧只有 `content` 而没有 `parts`,Claude 有时使用 + `assistant` 角色,两者都会触发 `Invalid google/gemini stream event`。 + 因此每一帧在转发前都会被规范化。 +3. **响应编码**:core 传输层已经解压 gzip,因此上游的 `content-encoding` 头不能复制到 + 回环响应上。 +4. **PDF 附件**:OpenCode 2.x CLI 在请求到达 provider 之前就丢弃了 PDF 附件 + (请求中没有任何 `inlineData`)。因此插件额外注册了 `antigravity_read_document` 工具, + 由插件自己读取文件:`antigravity_read_document({ path, question?, model? })`, + 支持 `.pdf`、`.png`、`.jpg`、`.webp`、`.gif`、`.heic`。聊天中粘贴的图片无需该工具即可工作。 + **安全说明**:该工具会读取本地文件并发送到 Antigravity 服务器 —— 不可信的 PDF/图片是 + prompt-injection 的载体,可能诱导模型读取敏感文件。因此路径经过检查: + 常见的凭据/密钥位置(`~/.ssh`、`~/.config`、`~/.local`、`AppData`、`.env`、`*.key`、 + `*.pem`、`*.p12`、`*.pfx`、`id_rsa`、`credentials`、`auth.json`、 + `antigravity-accounts.json` 等)始终被拒绝;默认只允许读取用户主目录下的文件。如需 + 进一步收窄可读范围,请设置插件选项 `readDocumentRoots`(绝对路径数组)。尽量在聊天中 + 附带文档,宿主会保留附件。 +5. **图片输出**:原生解析器只渲染文本与 tool call,因此生成的图片会写入 + `/antigravity-images/`,并以文本形式告知路径。 + +## 日志与隐私 + +`/antigravity-v2.log` 只记录路由、`#<账号序号>`、上游状态码、账号轮换与已保存图片路径; +不写入任何 prompt、token、邮箱或 refresh token。凭据只保存在由 core 管理的账号池文件中。 + +## 实测 + +Windows 11、Node 24、OpenCode `0.0.0-beta-17595`、两账号池: + +- 上表 8 个选择器全部通过真实请求(包含每个推理档位); +- tool call 端到端可用(模型调用 `read` 并返回目录列表); +- PNG 附件识别正确(纯红色方块 → “Red”); +- 通过 `antigravity_read_document` 读取 PDF,返回了其中的精确文本; +- 图片生成得到两个 JPEG 文件; +- 强制故障转移:停用 `#0` 账号后,下一次请求走到了 `#1` 账号。 + +## 许可证 + +MIT。 diff --git a/packages/opencode-v2/example/opencode.json b/packages/opencode-v2/example/opencode.json new file mode 100644 index 00000000..c09053eb --- /dev/null +++ b/packages/opencode-v2/example/opencode.json @@ -0,0 +1,76 @@ +{ + "plugins": [ + { + "package": "/absolute/path/to/antigravity-auth/packages/opencode-v2/src/plugin.mjs", + "options": { + "readDocumentRoots": ["/absolute/path/to/documents"] + } + } + ], + "providers": { + "google": { + "models": { + "gemini-3.7-flash": { + "name": "Gemini 3.7 Flash", + "modelID": "gemini-3.7-flash", + "package": "@opencode-ai/ai/providers/google", + "capabilities": { "tools": true, "input": ["text", "image", "pdf"], "output": ["text"] }, + "limit": { "context": 1048576, "output": 65536 }, + "variants": [{ "id": "low" }, { "id": "medium" }, { "id": "high" }] + }, + "gemini-3.6-flash": { + "name": "Gemini 3.6 Flash", + "modelID": "gemini-3.6-flash", + "package": "@opencode-ai/ai/providers/google", + "capabilities": { "tools": true, "input": ["text", "image", "pdf"], "output": ["text"] }, + "limit": { "context": 1048576, "output": 65536 }, + "variants": [{ "id": "low" }, { "id": "medium" }, { "id": "high" }] + }, + "gemini-3.5-flash": { + "name": "Gemini 3.5 Flash", + "modelID": "gemini-3.5-flash", + "package": "@opencode-ai/ai/providers/google", + "capabilities": { "tools": true, "input": ["text", "image", "pdf"], "output": ["text"] }, + "limit": { "context": 1048576, "output": 65536 }, + "variants": [{ "id": "low" }, { "id": "medium" }, { "id": "high" }] + }, + "gemini-3.1-pro": { + "name": "Gemini 3.1 Pro", + "modelID": "gemini-3.1-pro", + "package": "@opencode-ai/ai/providers/google", + "capabilities": { "tools": true, "input": ["text", "image", "pdf"], "output": ["text"] }, + "limit": { "context": 1048576, "output": 65535 }, + "variants": [{ "id": "low" }, { "id": "high" }] + }, + "gemini-3.1-flash-image": { + "name": "Gemini 3.1 Flash Image", + "modelID": "gemini-3.1-flash-image", + "package": "@opencode-ai/ai/providers/google", + "capabilities": { "tools": true, "input": ["text", "image"], "output": ["text", "image"] }, + "limit": { "context": 66000, "output": 33000 } + }, + "claude-sonnet-4-6-thinking": { + "name": "Claude Sonnet 4.6 (Thinking)", + "modelID": "claude-sonnet-4-6-thinking", + "package": "@opencode-ai/ai/providers/google", + "capabilities": { "tools": true, "input": ["text", "image", "pdf"], "output": ["text"] }, + "limit": { "context": 250000, "output": 64000 } + }, + "claude-opus-4-6-thinking": { + "name": "Claude Opus 4.6 (Thinking)", + "modelID": "claude-opus-4-6-thinking", + "package": "@opencode-ai/ai/providers/google", + "capabilities": { "tools": true, "input": ["text", "image", "pdf"], "output": ["text"] }, + "limit": { "context": 250000, "output": 64000 } + }, + "gpt-oss-120b-medium": { + "name": "GPT-OSS 120B", + "modelID": "gpt-oss-120b-medium", + "package": "@opencode-ai/ai/providers/google", + "capabilities": { "tools": true, "input": ["text", "image", "pdf"], "output": ["text"] }, + "limit": { "context": 131072, "output": 32768 } + } + } + } + } +} diff --git a/packages/opencode-v2/package.json b/packages/opencode-v2/package.json new file mode 100644 index 00000000..18a698e3 --- /dev/null +++ b/packages/opencode-v2/package.json @@ -0,0 +1,51 @@ +{ + "name": "@cortexkit/opencode-v2-antigravity-auth", + "version": "2.1.0", + "description": "Google Antigravity provider for OpenCode 2.x (native plugin API)", + "type": "module", + "license": "MIT", + "author": "CortexKit", + "repository": { + "type": "git", + "url": "git+https://github.com/cortexkit/antigravity-auth.git", + "directory": "packages/opencode-v2" + }, + "homepage": "https://github.com/cortexkit/antigravity-auth#readme", + "bugs": { + "url": "https://github.com/cortexkit/antigravity-auth/issues" + }, + "keywords": [ + "opencode", + "opencode-v2", + "antigravity", + "gemini", + "claude", + "oauth", + "plugin" + ], + "engines": { + "node": ">=20.0.0", + "opencode": ">=2 <3" + }, + "main": "./src/plugin.mjs", + "exports": { + ".": "./src/plugin.mjs", + "./oauth-callback": "./src/oauth-callback.mjs" + }, + "files": [ + "src/", + "example/", + "README.md", + "README.zh-CN.md", + "LICENSE" + ], + "scripts": { + "lint": "biome lint src example", + "format:check": "biome format src example README.md README.zh-CN.md", + "typecheck": "node --check src/plugin.mjs && node --check src/oauth-callback.mjs", + "test": "bun run --cwd ../core build && cd ../.. && bun test --isolate packages/opencode-v2/test" + }, + "dependencies": { + "@cortexkit/antigravity-auth-core": "2.1.0" + } +} diff --git a/packages/opencode-v2/src/oauth-callback.mjs b/packages/opencode-v2/src/oauth-callback.mjs new file mode 100644 index 00000000..0c650531 --- /dev/null +++ b/packages/opencode-v2/src/oauth-callback.mjs @@ -0,0 +1,95 @@ +// Loopback OAuth callback listener for the Antigravity login flow. +// Google redirects to http://localhost:51121/oauth-callback (the redirect URI +// registered for the Antigravity OAuth client), so the port is fixed. + +import { createServer } from 'node:http' + +const CALLBACK_HOST = '127.0.0.1' +const CALLBACK_PATH = '/oauth-callback' +const CALLBACK_PORT = 51121 +const CALLBACK_TIMEOUT_MS = 10 * 60_000 + +// The callback page only acknowledges that the authorization code arrived; the +// token exchange and account persistence happen afterwards in the plugin, so the +// page must not claim the account was added. +const PAGE_OK = `Antigravity authorization received +

Authorization received

Return to OpenCode — the login result is reported there.

` +const PAGE_FAIL = `Antigravity login failed +

Login failed

Return to OpenCode and try again.

` + +function respond(response, status, page) { + response.writeHead(status, { + 'cache-control': 'no-store', + connection: 'close', + 'content-type': 'text/html; charset=utf-8', + }) + response.end(page) +} + +/** + * Starts listening immediately and resolves with the authorization code once + * Google redirects back with the matching state. + */ +export function waitForAntigravityCode(expectedState, options = {}) { + if (!expectedState) throw new Error('OAuth state is empty') + const port = options.port ?? CALLBACK_PORT + const timeoutMs = options.timeoutMs ?? CALLBACK_TIMEOUT_MS + + return new Promise((resolve, reject) => { + let settled = false + let timer + + const server = createServer((request, response) => { + let url + try { + url = new URL(request.url ?? '/', `http://${CALLBACK_HOST}:${port}`) + } catch { + respond(response, 400, PAGE_FAIL) + return + } + if (url.pathname !== CALLBACK_PATH) { + respond(response, 404, PAGE_FAIL) + return + } + if (url.searchParams.get('state') !== expectedState) { + respond(response, 400, PAGE_FAIL) + return + } + if (url.searchParams.has('error')) { + respond(response, 400, PAGE_FAIL) + finish( + undefined, + new Error( + `Google authorization failed: ${url.searchParams.get('error')}`, + ), + ) + return + } + const code = url.searchParams.get('code') + if (!code) { + respond(response, 400, PAGE_FAIL) + return + } + respond(response, 200, PAGE_OK) + finish(code) + }) + + function finish(code, error) { + if (settled) return + settled = true + clearTimeout(timer) + server.close(() => {}) + if (error) reject(error) + else resolve(code) + } + + server.once('error', (error) => finish(undefined, error)) + server.listen(port, CALLBACK_HOST, () => { + timer = setTimeout( + () => + finish(undefined, new Error('Antigravity authorization timed out')), + timeoutMs, + ) + }) + }) +} diff --git a/packages/opencode-v2/src/plugin.mjs b/packages/opencode-v2/src/plugin.mjs new file mode 100644 index 00000000..9513f57e --- /dev/null +++ b/packages/opencode-v2/src/plugin.mjs @@ -0,0 +1,1177 @@ +// OpenCode V2 Antigravity provider (port of cortexkit/antigravity-auth). +// +// The native `@opencode-ai/ai/providers/google` package builds and parses Gemini +// traffic, so images/PDF/tool-calls need no custom codec. A `http.request` hook +// redirects each Antigravity model request to a loopback server owned by this +// plugin; the server performs the real call through +// `@cortexkit/antigravity-auth-core` (raw HTTP/1.1 transport matching the +// Antigravity CLI, proxy aware), rotating over the multi-account pool in +// `~/.config/opencode/antigravity-accounts.json`, and streams the unwrapped SSE +// back to OpenCode. + +import { randomUUID } from 'node:crypto' +import { appendFileSync, existsSync, realpathSync } from 'node:fs' +import { createServer } from 'node:http' +import { homedir } from 'node:os' +import { join, resolve } from 'node:path' + +const CORE = '@cortexkit/antigravity-auth-core' +const { + AccountManager, + defaultAccountStorageStore, + loadAccountStorage, + mutateAccountStorage, + formatRefreshParts, + refreshAntigravityToken, + resolveModelForHeaderStyle, + getModelFamily, + ensureProjectContext, + AgyRequestSessionStore, + buildAgyAgentRequestMetadata, + orderAgyRequestPayloadInPlace, + normalizeGeminiTools, + fetchWithAgyCliTransport, + buildAntigravityHarnessUserAgent, + authorizeAntigravity, + exchangeAntigravity, + parseRateLimitReason, + ANTIGRAVITY_ENDPOINT_FALLBACKS, +} = await import(CORE) + +function configDir() { + const explicit = process.env.OPENCODE_CONFIG_DIR?.trim() + if (explicit) return explicit + if (process.platform === 'win32' && process.env.APPDATA?.trim()) { + const appdata = join(process.env.APPDATA.trim(), 'opencode') + if (existsSync(join(appdata, 'antigravity-accounts.json'))) return appdata + } + const xdg = process.env.XDG_CONFIG_HOME?.trim() + return xdg ? join(xdg, 'opencode') : join(homedir(), '.config', 'opencode') +} + +function stateDir() { + const xdg = process.env.XDG_STATE_HOME?.trim() + return xdg + ? join(xdg, 'opencode') + : join(homedir(), '.local', 'state', 'opencode') +} + +function dataDir() { + const xdg = process.env.XDG_DATA_HOME?.trim() + return xdg + ? join(xdg, 'opencode') + : join(homedir(), '.local', 'share', 'opencode') +} + +const ACCOUNTS_FILE = + process.env.ANTIGRAVITY_ACCOUNTS_FILE?.trim() || + join(configDir(), 'antigravity-accounts.json') +const LOGFILE = join(stateDir(), 'antigravity-v2.log') +const METHOD_ID = 'antigravity-v2' +const EMPTY_RESPONSE_MAX_ATTEMPTS = 3 + +function log(...args) { + try { + appendFileSync( + LOGFILE, + `[${new Date().toISOString()}] ` + + args + .map((x) => (typeof x === 'string' ? x : JSON.stringify(x))) + .join(' ') + + '\n', + ) + } catch {} +} + +const MODEL_IDS = new Set([ + 'gemini-3.7-flash', + 'gemini-3.6-flash', + 'gemini-3.5-flash', + 'gemini-3.1-pro', + 'gemini-3.1-flash-image', + 'claude-sonnet-4-6-thinking', + 'claude-opus-4-6-thinking', + 'gpt-oss-120b-medium', +]) + +function familyFor(modelID) { + return getModelFamily(modelID) === 'claude' ? 'claude' : 'gemini' +} + +function requestedModel(modelID, variant) { + if (!variant || variant === 'default') return modelID + return `${modelID}-${variant}` +} + +function unwrapFrame(parsed) { + if ( + parsed && + typeof parsed === 'object' && + 'response' in parsed && + parsed.response && + typeof parsed.response === 'object' + ) { + return parsed.response + } + return parsed +} + +// Antigravity's GPT/Claude bridges occasionally emit parts and roles the strict +// native Gemini event schema rejects (e.g. role "assistant", or a part carrying +// only a thought signature). Normalise every frame to the Gemini shape. +function sanitizeInner(inner) { + const candidates = inner?.candidates + if (!Array.isArray(candidates)) return inner + for (const candidate of candidates) { + const content = candidate?.content + if (!content || typeof content !== 'object') continue + if (content.role !== 'user' && content.role !== 'model') + content.role = 'model' + // `parts` is required by the native schema; GPT-OSS opens a turn without it. + if (!Array.isArray(content.parts)) { + content.parts = [] + continue + } + content.parts = content.parts + .map((part) => { + if (!part || typeof part !== 'object') return null + const out = {} + if (typeof part.text === 'string') out.text = part.text + if (part.thought !== undefined) out.thought = Boolean(part.thought) + if (typeof part.thoughtSignature === 'string') + out.thoughtSignature = part.thoughtSignature + if (part.inlineData) out.inlineData = part.inlineData + if (part.functionCall) out.functionCall = part.functionCall + if (part.functionResponse) out.functionResponse = part.functionResponse + if ( + out.text === undefined && + !out.inlineData && + !out.functionCall && + !out.functionResponse + ) { + out.text = '' + } + return out + }) + .filter((part) => part !== null) + } + return inner +} + +const IMAGE_DIR = join(dataDir(), 'antigravity-images') +const IMAGE_EXTENSION = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/webp': 'webp', + 'image/gif': 'gif', +} + +// The native Gemini event parser only renders text and tool calls, so generated +// images are written to disk and announced as text instead of being dropped. +async function persistInlineImages(inner) { + const parts = inner?.candidates?.[0]?.content?.parts + if (!Array.isArray(parts)) return inner + for (let index = 0; index < parts.length; index += 1) { + const inline = parts[index]?.inlineData + if (!inline?.data || !String(inline.mimeType ?? '').startsWith('image/')) + continue + try { + const { mkdir, writeFile } = await import('node:fs/promises') + await mkdir(IMAGE_DIR, { recursive: true }) + const extension = IMAGE_EXTENSION[inline.mimeType] ?? 'png' + const file = join( + IMAGE_DIR, + `${Date.now()}-${randomUUID().slice(0, 8)}.${extension}`, + ) + await writeFile(file, Buffer.from(inline.data, 'base64')) + parts[index] = { text: `[Antigravity image saved: ${file}]` } + log('image-saved', file, inline.mimeType) + } catch (error) { + log('image-save-error', error?.message ?? String(error)) + parts[index] = { + text: '[Antigravity returned an image that could not be saved]', + } + } + } + return inner +} +function retryAfterMs(response) { + const value = response.headers.get('retry-after')?.trim() + if (!value) return undefined + const seconds = Number(value) + if (Number.isFinite(seconds) && seconds > 0) return seconds * 1000 + const date = Date.parse(value) + return Number.isFinite(date) && date - Date.now() > 0 + ? date - Date.now() + : undefined +} + +async function readErrorReason(response) { + try { + const parsed = JSON.parse(await response.clone().text()) + const details = parsed?.error?.details ?? parsed?.details ?? [] + return ( + details + .map((item) => item?.reason) + .find((value) => typeof value === 'string') ?? parsed?.error?.status + ) + } catch { + return undefined + } +} + +function buildEnvelope(payload, resolved, projectID, metadata) { + const request = { ...payload } + delete request.model + delete request.project + delete request.user_prompt_id + delete request.session_id + + const generationConfig = { ...(request.generationConfig ?? {}) } + delete generationConfig.thinkingConfig + if (resolved.thinkingLevel) { + generationConfig.thinkingConfig = { + includeThoughts: true, + thinkingLevel: resolved.thinkingLevel, + } + } else if (resolved.thinkingBudget !== undefined) { + generationConfig.thinkingConfig = { + includeThoughts: true, + thinkingBudget: resolved.thinkingBudget, + } + } + if (Object.keys(generationConfig).length > 0) + request.generationConfig = generationConfig + else delete request.generationConfig + + // AGY's GPT bridge re-encodes protobuf numeric constraints as strings before + // OpenAI JSON-Schema validation, so `minLength: 1` must move to the description. + const isGpt = /^gpt-/i.test(resolved.actualModel) + normalizeGeminiTools(request, { moveNumericConstraintsToDescription: isGpt }) + + request.labels = metadata.labels + request.sessionId = metadata.sessionId + orderAgyRequestPayloadInPlace(request) + + return { + project: projectID, + requestId: metadata.requestId, + request, + model: resolved.actualModel, + userAgent: 'antigravity', + requestType: 'agent', + } +} + +export default { + id: 'local.antigravity', + + async setup(ctx) { + const options = ctx.options ?? {} + const requestSessions = new AgyRequestSessionStore('opencode-v2') + const jobs = new Map() + + let accounts = await loadAccountStorage(ACCOUNTS_FILE).catch((error) => { + log('accounts-load-error', error?.message ?? String(error)) + return null + }) + let manager = new AccountManager(undefined, accounts, { + store: defaultAccountStorageStore, + storagePath: ACCOUNTS_FILE, + }) + log('setup-start', 'accounts', manager.getTotalAccountCount()) + + const reloadPool = async () => { + try { + await manager.flushSaveToDisk().catch(() => {}) + accounts = await loadAccountStorage(ACCOUNTS_FILE) + manager = new AccountManager(undefined, accounts, { + store: defaultAccountStorageStore, + storagePath: ACCOUNTS_FILE, + }) + log('pool-reloaded', manager.getTotalAccountCount()) + } catch (error) { + log('reload-pool-error', error?.message ?? String(error)) + } + } + + async function accessFor(account, force = false) { + if ( + !force && + account.access && + account.expires && + account.expires > Date.now() + 60_000 + ) { + return manager.toAuthDetails(account) + } + const refreshed = await refreshAntigravityToken( + account.parts.refreshToken, + ) + const auth = { + type: 'oauth', + refresh: formatRefreshParts({ + refreshToken: refreshed.refresh, + projectId: account.parts.projectId, + managedProjectId: account.parts.managedProjectId, + }), + access: refreshed.access, + expires: refreshed.expires, + } + manager.updateFromAuth(account, auth) + return auth + } + + function send(envelope, auth, endpoint, signal) { + return fetchWithAgyCliTransport( + `${endpoint}/v1internal:streamGenerateContent?alt=sse`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${auth.access}`, + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + 'Accept-Encoding': 'gzip', + 'User-Agent': buildAntigravityHarnessUserAgent(), + }, + body: JSON.stringify(envelope), + }, + { signal: signal ?? null, idleTimeoutMs: 300_000 }, + ) + } + + async function pickResponse(job, signal) { + const family = familyFor(job.modelID) + const requested = requestedModel(job.modelID, job.variant) + const identity = { id: job.sessionID ?? 'default', parentId: null } + const excluded = new Set() + const poolSize = Math.max(1, manager.getEnabledAccounts().length) + let failure = null + + for (let attempt = 0; attempt < poolSize + 2; attempt += 1) { + const account = manager.getCurrentOrNextForFamily( + family, + requested, + 'hybrid', + 'antigravity', + false, + 100, + 10 * 60_000, + identity, + excluded, + ) + if (!account) break + + let auth + try { + auth = await accessFor(account) + } catch (error) { + log( + 'token-error', + `#${account.index}`, + error?.message ?? String(error), + ) + excluded.add(account.index) + failure = error + continue + } + + let context + try { + context = await ensureProjectContext(auth) + } catch (error) { + log( + 'project-error', + `#${account.index}`, + error?.message ?? String(error), + ) + excluded.add(account.index) + failure = error + continue + } + + const scope = requestSessions.beginRequest( + String(job.sessionID ?? '__default__'), + ) + const metadata = buildAgyAgentRequestMetadata( + scope.session, + job.payload, + job.resolved.actualModel, + scope.timestamp, + { + stepCountMode: 'cli', + }, + ) + const envelope = buildEnvelope( + job.payload, + job.resolved, + context.effectiveProjectId, + metadata, + ) + + // A forced refresh happens at most once per account/request; if the + // endpoint still answers 401 afterwards the account is excluded and the + // pool selection continues instead of refreshing in an unbounded loop. + let forcedRefresh = false + for ( + let endpointIndex = 0; + endpointIndex < ANTIGRAVITY_ENDPOINT_FALLBACKS.length; + endpointIndex += 1 + ) { + const endpoint = ANTIGRAVITY_ENDPOINT_FALLBACKS[endpointIndex] + let response + try { + response = await send(envelope, auth, endpoint, signal) + } catch (error) { + log( + 'transport-error', + `#${account.index}`, + endpoint, + error?.message ?? String(error), + ) + failure = error + continue + } + log( + 'upstream', + `#${account.index}`, + endpoint, + job.resolved.actualModel, + response.status, + ) + + if (response.ok) { + manager.markRequestSuccess(account) + manager.markAccountUsed(account.index) + manager.recordRequest(account.index, family) + manager.requestSaveToDisk() + return { response, account } + } + + const reason = await readErrorReason(response) + if (!response.ok) { + const detail = await response + .clone() + .text() + .catch(() => '') + let message = '' + try { + message = JSON.parse(detail)?.error?.message ?? '' + } catch { + message = detail.slice(0, 200) + } + log( + 'upstream-error', + response.status, + reason ?? '', + message.slice(0, 300), + ) + } + if ( + response.status === 404 && + endpointIndex < ANTIGRAVITY_ENDPOINT_FALLBACKS.length - 1 + ) + continue + + if (response.status === 401) { + if (!forcedRefresh) { + try { + auth = await accessFor(account, true) + forcedRefresh = true + endpointIndex -= 1 + continue + } catch (error) { + failure = error + } + } + excluded.add(account.index) + break + } + + if (response.status === 429 || response.status === 403) { + const limit = + parseRateLimitReason(reason, '', response.status) || 'RATE_LIMIT' + manager.markRateLimitedWithReason( + account, + family, + 'antigravity', + requested, + limit, + retryAfterMs(response) ?? 60_000, + 3_600_000, + ) + excluded.add(account.index) + failure = new Error( + `Antigravity ${response.status}${reason ? ` (${reason})` : ''}`, + ) + break + } + + failure = new Error( + `Antigravity HTTP ${response.status}${reason ? ` (${reason})` : ''}`, + ) + excluded.add(account.index) + break + } + } + + throw ( + failure ?? + new Error( + 'No Antigravity account available (all rate-limited or disabled)', + ) + ) + } + + // Streams one upstream SSE response into the loopback response, unwrapping the + // `{ "response": … }` Antigravity envelope. Reports whether any model content + // arrived so a bare STOP can be retried. + async function pipeStream(upstream, res) { + const reader = upstream.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + let sawContent = false + let terminal = false + try { + while (!terminal) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + buffer = buffer.replace(/\r\n/g, '\n') + let boundary = buffer.indexOf('\n\n') + while (boundary !== -1) { + const frame = buffer.slice(0, boundary) + buffer = buffer.slice(boundary + 2) + boundary = buffer.indexOf('\n\n') + const data = frame + .split('\n') + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice(5).replace(/^ /, '')) + .join('\n') + .trim() + if (!data || data === '[DONE]') continue + let parsed + try { + parsed = JSON.parse(data) + } catch { + continue + } + const inner = await persistInlineImages( + sanitizeInner(unwrapFrame(parsed)), + ) + const parts = inner?.candidates?.[0]?.content?.parts ?? [] + if ( + parts.some( + (part) => part?.text || part?.functionCall || part?.inlineData, + ) + ) + sawContent = true + res.write(`data: ${JSON.stringify(inner)}\n\n`) + if (inner?.candidates?.[0]?.finishReason) { + terminal = true + break + } + } + } + } finally { + try { + await reader.cancel() + } catch {} + } + return { sawContent, terminal } + } + + // Collects one complete Antigravity answer (used by the document tool). + async function collectText(upstream) { + const reader = upstream.body.getReader() + const decoder = new TextDecoder() + const LF = String.fromCharCode(10) + const CR = String.fromCharCode(13) + let buffer = '' + const chunks = [] + const images = [] + let done = false + try { + while (!done) { + const { done: finished, value } = await reader.read() + if (finished) break + buffer += decoder.decode(value, { stream: true }) + buffer = buffer.split(CR).join('') + let boundary = buffer.indexOf(LF + LF) + while (boundary !== -1) { + const frame = buffer.slice(0, boundary) + buffer = buffer.slice(boundary + 2) + boundary = buffer.indexOf(LF + LF) + const data = frame + .split(LF) + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice(5).trimStart()) + .join(LF) + .trim() + if (!data || data === '[DONE]') continue + let parsed + try { + parsed = JSON.parse(data) + } catch { + continue + } + const inner = unwrapFrame(parsed) + for (const part of inner?.candidates?.[0]?.content?.parts ?? []) { + if (part?.thought) continue + if (typeof part?.text === 'string' && part.text) + chunks.push(part.text) + if (part?.inlineData?.data) images.push(part.inlineData) + } + if (inner?.candidates?.[0]?.finishReason) { + done = true + break + } + } + } + } finally { + try { + await reader.cancel() + } catch {} + } + return { text: chunks.join(''), images } + } + + // Collects one upstream SSE stream into a single JSON GenerateContentResponse + // (used when the host issued a non-streaming `generateContent` call — the + // loopback must not answer that with `text/event-stream`). + async function collectNonStream(upstream) { + const reader = upstream.body.getReader() + const decoder = new TextDecoder() + const LF = String.fromCharCode(10) + const CR = String.fromCharCode(13) + let buffer = '' + const byIndex = new Map() + let usageMetadata + let promptFeedback + try { + let terminal = false + while (!terminal) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + buffer = buffer.split(CR).join('') + let boundary = buffer.indexOf(LF + LF) + while (boundary !== -1) { + const frame = buffer.slice(0, boundary) + buffer = buffer.slice(boundary + 2) + boundary = buffer.indexOf(LF + LF) + const data = frame + .split(LF) + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice(5).trimStart()) + .join(LF) + .trim() + if (!data || data === '[DONE]') continue + let parsed + try { + parsed = JSON.parse(data) + } catch { + continue + } + const inner = sanitizeInner(unwrapFrame(parsed)) + for (const candidate of inner?.candidates ?? []) { + const index = candidate.index ?? 0 + let entry = byIndex.get(index) + if (!entry) { + entry = { + ...candidate, + content: { ...(candidate.content ?? {}) }, + } + entry.content.parts = [] + byIndex.set(index, entry) + } + for (const part of candidate.content?.parts ?? []) { + if (part?.text || part?.inlineData || part?.functionCall) + entry.content.parts.push(part) + } + if (candidate.finishReason) + entry.finishReason = candidate.finishReason + if (candidate.thought) entry.thought = candidate.thought + if (candidate.index !== undefined) entry.index = index + } + if (inner?.usageMetadata) usageMetadata = inner.usageMetadata + if (inner?.promptFeedback) promptFeedback = inner.promptFeedback + if (inner?.candidates?.some((candidate) => candidate.finishReason)) + terminal = true + } + } + } finally { + try { + await reader.cancel() + } catch {} + } + const merged = { + candidates: [...byIndex.values()], + ...(usageMetadata ? { usageMetadata } : {}), + ...(promptFeedback ? { promptFeedback } : {}), + } + return persistInlineImages(merged) + } + + const DOCUMENT_MIME = { + '.pdf': 'application/pdf', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.webp': 'image/webp', + '.gif': 'image/gif', + '.heic': 'image/heic', + '.heif': 'image/heif', + } + + // The document tool is a prompt-injection vector: an untrusted PDF/image can + // instruct the model to call it on sensitive local files. Always block the + // classic credential/secret locations, and narrow the readable root with the + // `readDocumentRoots` plugin option when a stricter policy is wanted. + const READ_DENYLIST = + /(^|[\\/])(\.ssh|\.gnupg|\.aws|\.azure|\.config|\.local|\.cache|AppData)([\\/]|$)|\.(?:env|key|pem|p12|pfx|p8|asc|gpg)$|(^|[\\/])(?:id_rsa|id_ed25519|id_ecdsa|credentials|secrets?|tokens?|auth\.json|antigravity-accounts\.json)(?:\.|$)/i + const readDocumentRoots = [options.readDocumentRoots ?? []] + .flat() + .map((root) => String(root).trim()) + .filter(Boolean) + // Canonicalize configured roots so a root that is itself a symlink or + // junction keeps matching the real (resolved) document paths. + .map((root) => { + try { + return realpathSync(root) + } catch { + return resolve(root) + } + }) + + function isPathAllowed(documentPath) { + const normalized = resolve(documentPath).replace(/\\/g, '/') + if (READ_DENYLIST.test(normalized)) return false + if (readDocumentRoots.length === 0) { + // Default: anything under the user's home directory (sensitive paths + // above are still blocked). Configure `readDocumentRoots` to restrict. + const home = homedir().replace(/\\/g, '/') + return normalized === home || normalized.startsWith(`${home}/`) + } + return readDocumentRoots.some((root) => { + const base = resolve(root).replace(/\\/g, '/') + return normalized === base || normalized.startsWith(`${base}/`) + }) + } + + const server = createServer((req, res) => { + const id = (req.url ?? '').split('/').filter(Boolean).pop() ?? '' + const job = jobs.get(id) + jobs.delete(id) + req.resume() + if (!job) { + res.writeHead(404, { 'content-type': 'application/json' }) + res.end( + JSON.stringify({ + error: { message: 'unknown antigravity request', status: 404 }, + }), + ) + return + } + const controller = new AbortController() + res.on('close', () => controller.abort()) + + ;(async () => { + let lastError = null + for ( + let attempt = 1; + attempt <= EMPTY_RESPONSE_MAX_ATTEMPTS; + attempt += 1 + ) { + let picked + try { + picked = await pickResponse(job, controller.signal) + } catch (error) { + lastError = error + break + } + const finish = (body) => { + requestSessions.completeExecution( + String(job.sessionID ?? '__default__'), + ) + if (!res.headersSent) { + res.writeHead(200, { + 'content-type': + job.stream === false + ? 'application/json; charset=utf-8' + : 'text/event-stream; charset=utf-8', + 'cache-control': 'no-cache', + }) + } + res.end(body) + } + if (job.stream === false) { + const collected = await collectNonStream(picked.response) + const sawContent = + collected.candidates?.some((candidate) => + candidate?.content?.parts?.some( + (part) => + part?.text || part?.functionCall || part?.inlineData, + ), + ) ?? false + log( + 'non-stream-done', + job.modelID, + 'content', + sawContent, + 'attempt', + attempt, + ) + if (sawContent || attempt === EMPTY_RESPONSE_MAX_ATTEMPTS) { + finish(JSON.stringify(collected)) + return + } + await new Promise((resolve) => setTimeout(resolve, 1_000)) + continue + } + if (!res.headersSent) { + res.writeHead(200, { + 'content-type': 'text/event-stream; charset=utf-8', + 'cache-control': 'no-cache', + }) + } + const { sawContent, terminal } = await pipeStream( + picked.response, + res, + ) + log( + 'stream-done', + job.modelID, + 'content', + sawContent, + 'terminal', + terminal, + 'attempt', + attempt, + ) + if (sawContent || attempt === EMPTY_RESPONSE_MAX_ATTEMPTS) { + finish() + return + } + await new Promise((resolve) => setTimeout(resolve, 1_000)) + } + const message = + lastError?.message ?? + String(lastError ?? 'Antigravity request failed') + log('request-failed', job.modelID, message) + if (!res.headersSent) { + res.writeHead(502, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ error: { message, status: 502 } })) + } else { + res.end() + } + })().catch((error) => { + log('server-error', error?.message ?? String(error)) + try { + if (!res.headersSent) { + res.writeHead(502, { 'content-type': 'application/json' }) + res.end( + JSON.stringify({ + error: { + message: error?.message ?? String(error), + status: 502, + }, + }), + ) + } else { + res.end() + } + } catch {} + }) + }) + + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve() + }) + }) + const port = server.address().port + log('loopback-listening', port) + + await ctx.session.hook('http.request', async (event) => { + try { + if (event.model.providerID !== 'google') return + if (!MODEL_IDS.has(event.model.id)) return + const url = new URL(event.request.url) + if ( + !/\/models\/[^:]+:(?:streamGenerateContent|generateContent)/.test( + url.pathname, + ) + ) + return + + const raw = Buffer.from(await event.request.arrayBuffer()).toString( + 'utf8', + ) + const payload = JSON.parse(raw || '{}') + const requested = requestedModel(event.model.id, event.model.variant) + const resolved = resolveModelForHeaderStyle(requested, 'antigravity') + const id = randomUUID() + jobs.set(id, { + payload, + resolved, + modelID: event.model.id, + variant: event.model.variant, + sessionID: event.sessionID, + // The hook matches both endpoints; the loopback answers the streaming + // one with SSE and the non-streaming one with a single JSON response. + stream: /streamGenerateContent/.test(url.pathname), + }) + setTimeout(() => jobs.delete(id), 10 * 60_000) + const attachments = (payload.contents ?? []) + .flatMap((content) => content?.parts ?? []) + .filter((part) => part?.inlineData) + .map( + (part) => + `${part.inlineData.mimeType}:${String(part.inlineData.data ?? '').length}b`, + ) + log( + 'route', + event.model.id, + event.model.variant ?? 'default', + '->', + resolved.actualModel, + 'media', + JSON.stringify(attachments), + ) + event.request = new Request(`http://127.0.0.1:${port}/agy/${id}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + }) + } catch (error) { + log('request-hook-error', error?.message ?? String(error)) + } + }) + + // PDF / image reader. OpenCode's CLI drops PDF attachments before they reach + // the provider, so the plugin loads the document itself and asks Antigravity + // directly — the same "reuse the working channel" approach as ModLens. + await ctx.tool.transform((draft) => { + draft.add({ + name: 'antigravity_read_document', + description: + 'Read a local PDF or image with an Antigravity multimodal model and return its text. Use for .pdf, .png, .jpg, .webp, .gif, .heic files. Arguments: path (absolute), question (optional), model (optional, defaults to gemini-3.6-flash). Only files under the configured document roots can be read; credential, secret and private-key files are always refused.', + input: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Absolute path to the PDF or image file (must be inside the configured document roots)', + }, + question: { + type: 'string', + description: 'What to extract or ask about the document', + }, + model: { + type: 'string', + description: + 'Antigravity model id, e.g. gemini-3.6-flash or gemini-3.1-pro', + }, + }, + required: ['path'], + }, + async execute(input) { + const { readFile, realpath } = await import('node:fs/promises') + const { extname, basename } = await import('node:path') + const path = String(input?.path ?? '').trim() + if (!path) + return { content: 'antigravity_read_document: `path` is required' } + const extension = extname(path).toLowerCase() + const mimeType = DOCUMENT_MIME[extension] + if (!mimeType) { + return { + content: `antigravity_read_document: unsupported file type ${extension || '(none)'}`, + } + } + let data + try { + // Canonicalize the target before reading: the boundary is enforced + // on the real path, so caller-supplied symlinks/junctions cannot + // escape a configured root, while roots that are themselves + // junctions/symlinks stay usable (they are canonicalized too). + const real = await realpath(path) + if (!isPathAllowed(real)) { + return { + content: + 'antigravity_read_document: access denied — the real path of this file is outside the allowed document roots or is a protected file. Configure the plugin option `readDocumentRoots` to allow specific directories.', + } + } + data = (await readFile(real)).toString('base64') + } catch (error) { + return { + content: `antigravity_read_document: cannot read ${path}: ${error?.message ?? String(error)}`, + } + } + const question = + String(input?.question ?? '').trim() || + 'Transcribe this document completely and describe any tables, figures or layout that matter.' + const modelID = MODEL_IDS.has(String(input?.model)) + ? String(input.model) + : 'gemini-3.6-flash' + const variant = + modelID.startsWith('gemini-3.') && !modelID.includes('image') + ? 'high' + : undefined + const resolved = resolveModelForHeaderStyle( + requestedModel(modelID, variant), + 'antigravity', + ) + const job = { + payload: { + contents: [ + { + role: 'user', + parts: [ + { inlineData: { mimeType, data } }, + { text: question }, + ], + }, + ], + }, + resolved, + modelID, + variant, + sessionID: `tool:${basename(path)}`, + } + log( + 'tool-read', + basename(path), + mimeType, + data.length, + '->', + resolved.actualModel, + ) + const picked = await pickResponse(job, undefined) + const { text, images } = await collectText(picked.response) + requestSessions.completeExecution( + String(job.sessionID ?? '__default__'), + ) + if (!text && images.length === 0) + return { + content: + 'antigravity_read_document: the model returned no content', + } + const content = [] + if (text) content.push({ type: 'text', text }) + for (const image of images) { + content.push({ + type: 'file', + mime: image.mimeType ?? 'image/png', + uri: `data:${image.mimeType ?? 'image/png'};base64,${image.data}`, + name: `${basename(path)}-output`, + }) + } + return { + content, + metadata: { + model: resolved.actualModel, + file: basename(path), + mime: mimeType, + }, + } + }, + }) + }) + + // OAuth: every login appends an account to the shared pool. + await ctx.integration.transform((draft) => { + draft.method.update({ + integrationID: 'google', + method: { + id: METHOD_ID, + type: 'oauth', + label: 'Google Antigravity (add account)', + }, + authorize: async () => { + const authorization = await authorizeAntigravity() + const state = new URL(authorization.url).searchParams.get('state') + if (!state) + throw new Error( + 'Antigravity authorization URL is missing OAuth state', + ) + const { waitForAntigravityCode } = await import( + './oauth-callback.mjs' + ) + const pending = waitForAntigravityCode(state) + return { + url: authorization.url, + instructions: + 'Open the URL and sign in. The Google account is appended to the Antigravity pool.', + mode: 'auto', + callback: async () => { + const code = await pending + const result = await exchangeAntigravity(code, state) + if (result.type === 'failed') + throw new Error( + `Antigravity token exchange failed: ${result.error}`, + ) + const now = Date.now() + await mutateAccountStorage(ACCOUNTS_FILE, (current) => ({ + ...current, + version: 4, + accounts: [ + ...current.accounts.filter( + (item) => item.email !== result.email, + ), + { + email: result.email, + refreshToken: result.refresh, + projectId: result.projectId || undefined, + addedAt: now, + lastUsed: now, + enabled: true, + rateLimitResetTimes: {}, + }, + ], + })) + await reloadPool() + log('account-added', 'pool', manager.getTotalAccountCount()) + return { + type: 'oauth', + methodID: METHOD_ID, + refresh: result.refresh, + access: result.access, + expires: result.expires, + metadata: { + accountID: result.email ?? '', + projectID: result.projectId ?? '', + }, + } + }, + } + }, + refresh: async (credential) => { + const refreshToken = String(credential.refresh ?? '').split('|')[0] + const refreshed = await refreshAntigravityToken(refreshToken) + return { + type: 'oauth', + methodID: METHOD_ID, + refresh: formatRefreshParts({ refreshToken: refreshed.refresh }), + access: refreshed.access, + expires: refreshed.expires, + ...(credential.metadata ? { metadata: credential.metadata } : {}), + } + }, + label: (credential) => + credential?.metadata?.accountID || 'Antigravity account', + }) + }) + + return async () => { + try { + await manager.flushSaveToDisk() + await manager.dispose() + } catch {} + await new Promise((resolve) => server.close(() => resolve())) + log('dispose') + } + }, +} diff --git a/packages/opencode-v2/test/plugin.test.mjs b/packages/opencode-v2/test/plugin.test.mjs new file mode 100644 index 00000000..7d444166 --- /dev/null +++ b/packages/opencode-v2/test/plugin.test.mjs @@ -0,0 +1,15 @@ +// Smoke test for the OpenCode 2.x host adapter: the plugin module must load +// (which imports the shared core) and expose the documented plugin contract. +import { describe, expect, test } from 'bun:test' + +describe('opencode-v2-antigravity-auth plugin entry', () => { + test('exports the plugin contract shape', async () => { + // Dynamic import: the plugin module starts with a top-level `await import` + // of the shared core, which does not mix well with a static import binding + // under the test runner. + const { default: plugin } = await import('../src/plugin.mjs') + expect(plugin).toBeTypeOf('object') + expect(plugin.id).toBe('local.antigravity') + expect(plugin.setup).toBeTypeOf('function') + }) +}) diff --git a/scripts/version-sync.mjs b/scripts/version-sync.mjs index a2efc484..747d8d13 100644 --- a/scripts/version-sync.mjs +++ b/scripts/version-sync.mjs @@ -20,6 +20,7 @@ const packageJsonPaths = [ join(root, 'packages', 'core', 'package.json'), join(root, 'packages', 'opencode', 'package.json'), join(root, 'packages', 'pi', 'package.json'), + join(root, 'packages', 'opencode-v2', 'package.json'), ] function parseArgs(argv) {