@specode/pi-antigravity 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +84 -2
- package/README.zh-CN.md +85 -0
- package/THIRD_PARTY_NOTICES.md +22 -0
- package/package.json +46 -4
- package/src/auth.ts +288 -0
- package/src/catalog.ts +276 -0
- package/src/client.ts +100 -0
- package/src/google-shared.ts +28 -0
- package/src/index.ts +72 -0
- package/src/request.ts +292 -0
- package/src/stream.ts +386 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Specode
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,85 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-antigravity
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[English](./README.md) | [简体中文](./README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
An independently maintained Antigravity subscription provider for Pi. It adapts the official **agy CLI HTTP protocol** without running the CLI, an ACP bridge, or another agent.
|
|
6
|
+
|
|
7
|
+
**Status: public-source chat provider, installed and user-tested locally.** Tested against Pi **1.5.2** with synthetic HTTP responses and bounded live diagnostics; see the acceptance limits below.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
Requires Node.js >= 22.19.0 and the Pi 1.5.2 provider APIs used by this implementation. Use version 0.1.1 or later for clean npm installations; 0.1.0 could rely on development dependencies to resolve Pi subpath imports.
|
|
12
|
+
|
|
13
|
+
From npm:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pi install npm:@specode/pi-antigravity
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or from GitHub:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
pi install git:github.com/specode/pi-antigravity
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
If the older `pi-antigravity` package is installed, remove it first with `pi remove npm:pi-antigravity`. Never load two implementations of the `antigravity` provider together. Existing Pi OAuth credentials are retained; no CLI credential migration is required.
|
|
26
|
+
|
|
27
|
+
Run `/reload` or restart Pi after installation. If needed, use `/login`, choose Antigravity, and paste the complete callback URL when prompted. Select a chat model with `/model`.
|
|
28
|
+
|
|
29
|
+
For local development:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
pi install /path/to/pi-antigravity
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Development
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
npm ci
|
|
39
|
+
npm run check
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`check` runs strict type checking, offline tests, an isolated packed-extension smoke test, and a package dry run. The smoke test packs and extracts the package outside the checkout, without the extension's development dependencies, then loads it in real Pi with synthetic credentials and network fetches blocked. It does not touch your installed extensions or credentials. After publication, `npm run smoke -- @specode/pi-antigravity@<version>` validates the registry artifact the same way.
|
|
43
|
+
|
|
44
|
+
The extension entry is `src/index.ts`. Do not load it alongside another provider registered as `antigravity`. Switching your installed provider, live account testing, and publishing are separate steps—not performed by development tests.
|
|
45
|
+
|
|
46
|
+
## Publishing
|
|
47
|
+
|
|
48
|
+
The scoped npm package is public. GitHub Actions validates pushes and pull requests. npm Trusted Publishing is configured for `specode/pi-antigravity` and `npm-publish.yml`, allowing future GitHub Release publications through OIDC without a long-lived npm token. Version 0.1.0 was bootstrapped through maintainer authentication; releases must use a new package version, never republish an existing one. Publishing and creating releases are external operations and require explicit authorization.
|
|
49
|
+
|
|
50
|
+
## Request flow
|
|
51
|
+
|
|
52
|
+
1. **Authentication:** native Pi OAuth registration, Google manual callback-URL login with PKCE/state validation, and token refresh. Pi owns credential storage and refresh locking. Existing OAuth credentials retain `access`, `refresh`, `expires`, `projectId`, and optional `email`.
|
|
53
|
+
2. **Discovery:** initialize the consumer account and fetch the curated chat catalog. Public model IDs are separate from runtime IDs and explicitly discovered effort variants. Pi's standard model store retains the last successful account-owned snapshot for offline startup; failed refreshes do not erase it.
|
|
54
|
+
3. **Conversion:** reuse Pi's public Google converters and normalized transcript. Rewrite only Pi's exact known opening sentences in the serialized system prompt; preserve rules, tools, user text, and session history.
|
|
55
|
+
4. **Streaming:** one HTTP attempt to an approved official HTTPS origin, incremental SSE parsing, Pi instrumentation hooks, signatures, tool calls, usage, cancellation, separate header/idle deadlines, and one terminal event. No internal retries or hidden endpoint/model failover.
|
|
56
|
+
|
|
57
|
+
The default endpoint is `https://daily-cloudcode-pa.googleapis.com`, observed for the consumer account in the handoff. This is not a universal enterprise-routing claim. CLI identity is pinned to **1.5.9 / build 1021519450**, with the actual host platform and architecture.
|
|
58
|
+
|
|
59
|
+
## Boundaries
|
|
60
|
+
|
|
61
|
+
- Chat/coding provider only. Antigravity image generation/editing has been cancelled and is not a remaining implementation or testing task.
|
|
62
|
+
- Only curated chat models and explicit supported thinking variants are exposed. Unknown thinking contracts are omitted rather than guessed; unsupported requested effort fails visibly.
|
|
63
|
+
- Subscription per-token billing is not known. Pi's zero cost metadata means **unpriced**, not free usage or an invoice estimate. Token usage is recorded separately from subscription quota.
|
|
64
|
+
- Tool schema names and literal values remain unchanged. Unsupported references or required strict-sampling contracts fail instead of silently removing tools.
|
|
65
|
+
- Request UUIDs and transcript counters are provider-local bookkeeping, not a claimed reconstruction of every CLI retry/resume rule. Pi session IDs keep ordinary resumes stable and forks separate; direct calls without a session ID are ephemeral.
|
|
66
|
+
- No account switching, credit overage, background warming, quota UI, search/fetch tools, CLI storage migration, or private Pi runtime imports.
|
|
67
|
+
|
|
68
|
+
## Companion plugins
|
|
69
|
+
|
|
70
|
+
- **[pi-subscription-usage](https://github.com/specode/pi-subscription-usage):** owns quota queries and display. The JSON credential bridge retains `token`, `projectId`, and optional `email`. Email-less accounts add an opaque `ownershipId` (persisted by Pi as `antigravityOwnershipId`) so token rotation cannot break catalog ownership; existing consumers can ignore it. This development work does not modify that plugin.
|
|
71
|
+
- **[pi-subscription-image](https://github.com/specode/pi-subscription-image):** its existing Codex/Grok functionality is unchanged. The planned Antigravity backend has been cancelled; this provider registers no image-generation API or tool. Earlier image evidence is retained only as historical research.
|
|
72
|
+
|
|
73
|
+
## Evidence and remaining acceptance
|
|
74
|
+
|
|
75
|
+
The earlier **2026-11-15** protocol investigation covered 17 generation requests, including native read-tool feasibility, strict prompt-opening controls, and one reference-image request. [The evidence manifest](./test/fixtures/agy-cli-1.5.9/manifest.json) distinguishes official captures from direct probes. Redacted records are structural evidence, not replayable requests.
|
|
76
|
+
|
|
77
|
+
Current offline tests exercise native Pi model/auth integration and real local Pi read-tool execution against mocked Gemini/Claude responses, plus malformed streams, cancellation, timeouts, signatures, concurrency, and cache isolation. OAuth application parameters were statically checked against the pinned official CLI archive; **this is not a live browser-login test**.
|
|
78
|
+
|
|
79
|
+
Local deployment also verified real account initialization and catalog discovery, persistence of six public models, and offline restoration by the installed CLI. Existing credentials were unchanged.
|
|
80
|
+
|
|
81
|
+
A separately authorized six-request Argon High diagnosis isolated a 429 to this repository's own injected instructions: they quoted the original Pi opening after the leading prompt had already been adapted. A strict same-request comparison changed only that documentation block to a source reference and returned 429 → 200. The literal example was removed from `AGENTS.md`, not from arbitrary user rules or history; a regression test prevents reintroduction. Small output caps and no tool execution mean this is not full tool-loop acceptance. Reload Pi's project rules before retrying an affected session.
|
|
82
|
+
|
|
83
|
+
The user subsequently reported successful real usage after the fix. This records local deployment/fix acceptance, not verified coverage of every model, tool continuation, login, resume, or cancellation path.
|
|
84
|
+
|
|
85
|
+
Remaining individually unverified areas: authorized `/login`, complete live tool-loop and session UI acceptance, and broader account/model coverage. Antigravity image generation is out of scope, not unfinished work. See [AGENTS.md](./AGENTS.md), the sole development/handoff entry point, for exact contracts and limitations.
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# pi-antigravity
|
|
2
|
+
|
|
3
|
+
[English](./README.md) | [简体中文](./README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
独立维护的 Pi Antigravity 订阅 provider。适配官方 **agy CLI 的 HTTP 协议**,运行时不启动官方 CLI、ACP bridge 或另一套 agent。
|
|
6
|
+
|
|
7
|
+
**状态:公开源码的聊天 provider,已安装到本机并获用户实测确认。** 基于 Pi **1.5.2** 完成合成 HTTP 测试和受限真实对照;验收边界见下文。
|
|
8
|
+
|
|
9
|
+
## 安装
|
|
10
|
+
|
|
11
|
+
需要 Node.js >= 22.19.0,以及本实现使用的 Pi 1.5.2 provider API。全新 npm 安装请使用 0.1.1 或更高版本;0.1.0 的 Pi 子路径导入可能依赖开发目录中的依赖才能解析。
|
|
12
|
+
|
|
13
|
+
从 npm 安装:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pi install npm:@specode/pi-antigravity
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
也可以从 GitHub 安装:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
pi install git:github.com/specode/pi-antigravity
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
如果已安装旧 `pi-antigravity` 包,先执行 `pi remove npm:pi-antigravity`。不要同时加载两个注册 `antigravity` 的实现。保留现有 Pi OAuth 凭据,无需迁移 CLI 登录存储。
|
|
26
|
+
|
|
27
|
+
安装后执行 `/reload` 或重启 Pi。需要登录时运行 `/login`,选择 Antigravity,并按提示粘贴完整回调 URL;通过 `/model` 选择聊天模型。
|
|
28
|
+
|
|
29
|
+
本地开发也可以直接安装源码目录:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
pi install /path/to/pi-antigravity
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## 开发验证
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
npm ci
|
|
39
|
+
npm run check
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`check` 包含严格类型检查、离线测试、隔离的发布包加载测试及打包预检。冒烟测试先打包,再解压到源码目录之外,不带扩展的开发依赖,以合成凭据在真实 Pi 中加载并阻止网络 fetch,不触碰用户已安装扩展或凭据。发布后可运行 `npm run smoke -- @specode/pi-antigravity@<version>` 同样验证 registry 中的实际包。
|
|
43
|
+
|
|
44
|
+
扩展入口为 `src/index.ts`。不要与另一个注册 `antigravity` 的 provider 同时加载。切换用户安装、真实账号测试和发布均为独立步骤,不会由开发测试执行。
|
|
45
|
+
|
|
46
|
+
## 发布
|
|
47
|
+
|
|
48
|
+
scoped npm 包公开访问。GitHub Actions 验证 push 和 pull request。npm 已为 `specode/pi-antigravity` 和 `npm-publish.yml` 配置 Trusted Publishing,后续 GitHub Release 通过 OIDC 发布,不使用长期 npm token。0.1.0 已经维护者认证完成首次发布;后续 Release 必须使用新版本号,不重复发布既有版本。发布和创建 Release 属于外部操作,仍须明确授权。
|
|
49
|
+
|
|
50
|
+
## 请求主流程
|
|
51
|
+
|
|
52
|
+
1. **认证**:Pi 原生 OAuth 注册,Google 手动粘贴完整回调 URL 登录,校验 PKCE/state,支持 token 刷新。凭据保存和刷新锁由 Pi 管理。保留现有 OAuth 的 `access`、`refresh`、`expires`、`projectId` 及可选 `email`。
|
|
53
|
+
2. **目录**:初始化 consumer 账号并拉取精选聊天目录。公开模型 id 与 runtime id、明确发现的思考档位分离。Pi 标准模型存储保留账号隔离的最后成功快照,支持离线启动;刷新失败不清空快照。
|
|
54
|
+
3. **转换**:复用 Pi 公开 Google 转换器和标准 transcript。仅在序列化系统提示词时精确替换 Pi 已知开头两句,保留规则、工具、用户文本和会话记录。
|
|
55
|
+
4. **流式处理**:只向批准的官方 HTTPS origin 发送一次 HTTP 请求,增量解析 SSE,保留 Pi 观测钩子、签名、工具调用与用量,支持取消、独立的响应头/流空闲超时及唯一终止事件。不内置重试,不暗中切换 endpoint 或模型。
|
|
56
|
+
|
|
57
|
+
默认 endpoint 为交接中 consumer 账号实际使用的 `https://daily-cloudcode-pa.googleapis.com`,不宣称适用于所有企业账号。CLI 身份固定为 **1.5.9/构建 1021519450**,平台与架构取当前主机实际值。
|
|
58
|
+
|
|
59
|
+
## 功能边界
|
|
60
|
+
|
|
61
|
+
- 仅维护聊天/编码 provider。Antigravity 生图/编辑计划已取消,不再列为待开发或待测试任务。
|
|
62
|
+
- 仅公开精选聊天模型和明确支持的思考变体。不猜测未知思考协议;不支持的显式档位直接报错。
|
|
63
|
+
- 尚无订阅按 token 计费依据。Pi 成本元数据中的零表示**未定价**,不表示免费,也不是账单估算。请求 token 用量与订阅额度分开。
|
|
64
|
+
- 保留工具 schema 属性名和字面值。不支持的引用或必须严格采样的契约明确失败,不静默删除工具。
|
|
65
|
+
- 请求 UUID 与 transcript 计数属于本 provider 的本地记账,不声称还原了 CLI 所有重试/恢复规则。Pi session id 让普通恢复保持标识稳定、fork 相互隔离;无 session id 的直接调用按临时会话处理。
|
|
66
|
+
- 不实现账号切换、超额 credits、后台预热、额度 UI、搜索/网页工具、CLI 登录存储迁移或 Pi 私有运行时依赖。
|
|
67
|
+
|
|
68
|
+
## 独立插件协作
|
|
69
|
+
|
|
70
|
+
- **[pi-subscription-usage](https://github.com/specode/pi-subscription-usage)**:负责额度查询和展示。JSON 凭据桥接保留 `token`、`projectId` 及可选 `email`。无 email 的账号额外携带不透明的 `ownershipId`(由 Pi 以 `antigravityOwnershipId` 保存),避免 token 轮换破坏目录归属;既有消费者可忽略此字段。本次开发不修改该插件。
|
|
71
|
+
- **[pi-subscription-image](https://github.com/specode/pi-subscription-image)**:已有 Codex/Grok 功能保持不变,原计划的 Antigravity 后端已取消;本 provider 不注册生图 API 或工具。早期生图证据仅作为历史调研记录保留。
|
|
72
|
+
|
|
73
|
+
## 证据与待完成验收
|
|
74
|
+
|
|
75
|
+
此前 **2026-11-15** 协议调研共执行 17 次生成请求,覆盖原生 read 工具可行性、严格的提示词开头对照及一次参考图请求。[证据清单](./test/fixtures/agy-cli-1.5.9/manifest.json) 区分官方抓包和直接探测。脱敏记录仅用于结构取证,不能重放。
|
|
76
|
+
|
|
77
|
+
当前离线测试覆盖 Pi 原生模型/认证集成,使用 mock Gemini/Claude 响应执行真实的本地 Pi read 工具,并覆盖异常流、取消、超时、签名、并发和缓存隔离。OAuth 应用参数已对照固定版本官方 CLI 归档静态核实;**这不等于真实浏览器登录已验收**。
|
|
78
|
+
|
|
79
|
+
本机部署另外验证了真实账号初始化和目录发现,6 个公开模型的持久化及已安装 CLI 的离线恢复。既有凭据保持不变。
|
|
80
|
+
|
|
81
|
+
另经独立授权执行 6 次 Argon High 对照,定位到本仓库自身注入的项目规则:真正的开头虽已适配,文档却又逐字引用了原始 Pi 开头。严格保持请求其他字段相同,仅把该文档段落改为源码引用,结果从 429 变为 200。已移除 `AGENTS.md` 的这一逐字示例并增加回归测试,不改写任意用户规则或历史。测试限制了输出且未执行工具,不能视为完整工具闭环验收。受影响会话应先重载 Pi 的项目规则再重试。
|
|
82
|
+
|
|
83
|
+
用户随后反馈修复后实际测试正常,记为本机部署及该修复的用户验收;不据此宣称所有模型、工具续轮、登录、恢复或取消流程均已验证。
|
|
84
|
+
|
|
85
|
+
仍未单独验收的范围:经授权的 `/login`、真实完整工具闭环与会话 UI,以及更广泛的账号/模型覆盖。Antigravity 生图不在范围内,不是未完成待办。精确契约和限制统一见唯一开发/交接入口 [AGENTS.md](./AGENTS.md)。
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Third-party notices
|
|
2
|
+
|
|
3
|
+
## Pi
|
|
4
|
+
|
|
5
|
+
- Project: <https://github.com/earendil-works/pi>
|
|
6
|
+
- License: MIT
|
|
7
|
+
- Integration: public provider/auth/model/transcript APIs and the public Google message/tool converters from `@earendil-works/pi-ai`.
|
|
8
|
+
|
|
9
|
+
Pi supplies these packages at runtime. This package declares them as peers and does not bundle their implementations.
|
|
10
|
+
|
|
11
|
+
## pi-antigravity by Rahul Arya
|
|
12
|
+
|
|
13
|
+
- Project: <https://github.com/Rahularya01/pi-antigravity>
|
|
14
|
+
- Author: Rahul Arya
|
|
15
|
+
- License: MIT
|
|
16
|
+
- Reference: the 0.14.0 provider was used as a protocol-research reference, including authentication and model-discovery clues. It is not a runtime dependency, and its agent, tools, compatibility registry, and account store are not bundled here.
|
|
17
|
+
|
|
18
|
+
## Official agy CLI observations
|
|
19
|
+
|
|
20
|
+
The independently implemented protocol adapter is also informed by redacted observations of the official agy CLI 1.5.9. No official binary, extracted source, user credential, or raw prompt capture is included. OAuth installed-application identifiers in `src/auth.ts` are public client application parameters, not user access or refresh tokens. The synthetic image and redacted protocol observations under `test/fixtures/` are research evidence, not an implemented image-generation feature.
|
|
21
|
+
|
|
22
|
+
This project is not affiliated with or endorsed by Google, Antigravity, Pi, or the upstream extension author.
|
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@specode/pi-antigravity",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Independent Antigravity subscription chat provider for Pi, aligned with the official agy CLI HTTP protocol.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Specode",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/specode/pi-antigravity.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/specode/pi-antigravity#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/specode/pi-antigravity/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package",
|
|
18
|
+
"pi-extension",
|
|
19
|
+
"pi-coding-agent",
|
|
20
|
+
"antigravity",
|
|
21
|
+
"oauth",
|
|
22
|
+
"provider",
|
|
23
|
+
"subscription"
|
|
24
|
+
],
|
|
25
|
+
"files": ["src", "README.md", "README.zh-CN.md", "LICENSE", "THIRD_PARTY_NOTICES.md"],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "tsx --test test/*.test.ts",
|
|
28
|
+
"typecheck": "tsc --noEmit",
|
|
29
|
+
"smoke": "tsx scripts/smoke.mjs",
|
|
30
|
+
"pack:check": "npm pack --dry-run",
|
|
31
|
+
"check": "npm run typecheck && npm test && npm run smoke && npm run pack:check",
|
|
32
|
+
"prepublishOnly": "npm run check"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@earendil-works/pi-ai": "*",
|
|
36
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@earendil-works/pi-ai": "1.5.2",
|
|
40
|
+
"@earendil-works/pi-coding-agent": "1.5.2",
|
|
41
|
+
"@types/node": "^26.0.0",
|
|
42
|
+
"tsx": "^4.21.0",
|
|
43
|
+
"typescript": "^6.0.0"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": { "access": "public" },
|
|
46
|
+
"pi": { "extensions": ["./src/index.ts"] },
|
|
47
|
+
"engines": { "node": ">=22.19.0" }
|
|
48
|
+
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import type { OAuthCredential, OAuthCredentials, ProviderAuth, ProviderAuthInteraction } from "@earendil-works/pi-ai";
|
|
3
|
+
import { assertOfficialBaseUrl, CLIENT_USER_AGENT, DEFAULT_BASE_URL, postJson, readBoundedBody } from "./client.ts";
|
|
4
|
+
|
|
5
|
+
/** Existing Pi auth.json shape. Pi alone owns storage and refresh locking. */
|
|
6
|
+
export interface AntigravityOAuthCredential extends OAuthCredential {
|
|
7
|
+
projectId: string;
|
|
8
|
+
email?: string;
|
|
9
|
+
/** Provider-local fallback identity, retained when a refresh token rotates. */
|
|
10
|
+
antigravityOwnershipId?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Public credential bridge consumed by chat, usage and image integrations. */
|
|
14
|
+
export interface AntigravityApiKey {
|
|
15
|
+
token: string;
|
|
16
|
+
projectId: string;
|
|
17
|
+
email?: string;
|
|
18
|
+
/** Optional additive bridge metadata; never a replacement bearer-token format. */
|
|
19
|
+
ownershipId?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface AccountInitialization {
|
|
23
|
+
baseUrl: string;
|
|
24
|
+
projectId: string;
|
|
25
|
+
currentTier?: { id?: string; name?: string };
|
|
26
|
+
paidTier?: { id?: string; name?: string };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface InitializeAccountOptions {
|
|
30
|
+
baseUrl?: string;
|
|
31
|
+
signal?: AbortSignal;
|
|
32
|
+
/** Injectable transport for offline tests; production uses the shared client. */
|
|
33
|
+
postJson?: typeof postJson;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
37
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
38
|
+
? value as Record<string, unknown> : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function wireString(value: unknown): value is string {
|
|
42
|
+
return typeof value === "string" && value.length <= 8192 && /^[\x21-\x7e]+$/.test(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function email(value: unknown): string | undefined {
|
|
46
|
+
return typeof value === "string" && value.length <= 254 && /^[^\s@]+@[^\s@]+$/.test(value)
|
|
47
|
+
? value : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function invalidCredentials(): Error {
|
|
51
|
+
// Never include JSON parser errors: they can quote access tokens.
|
|
52
|
+
return new Error("Invalid Antigravity credentials: expected token and Cloud Code Assist projectId.");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Strict JSON bridge; raw bearer tokens and fabricated project IDs are not accepted. */
|
|
56
|
+
export function parseApiKey(value: string | undefined): AntigravityApiKey {
|
|
57
|
+
let parsed: unknown;
|
|
58
|
+
try { parsed = JSON.parse(value ?? ""); } catch { throw invalidCredentials(); }
|
|
59
|
+
const data = record(parsed);
|
|
60
|
+
if (!data || !wireString(data.token) || !wireString(data.projectId)) throw invalidCredentials();
|
|
61
|
+
if (data.email !== undefined && !email(data.email)) throw invalidCredentials();
|
|
62
|
+
if (data.ownershipId !== undefined && !validOwnershipId(data.ownershipId)) throw invalidCredentials();
|
|
63
|
+
return { token: data.token, projectId: data.projectId, ...(email(data.email) ? { email: email(data.email) } : {}),
|
|
64
|
+
...(validOwnershipId(data.ownershipId) ? { ownershipId: data.ownershipId } : {}) };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function validOwnershipId(value: unknown): value is string {
|
|
68
|
+
return typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Derive once from the old refresh token; retain the marker before rotating it. */
|
|
72
|
+
function fallbackOwnershipId(credential: OAuthCredentials): string {
|
|
73
|
+
const existing = credential.antigravityOwnershipId;
|
|
74
|
+
if (existing !== undefined) {
|
|
75
|
+
if (!validOwnershipId(existing)) throw invalidCredentials();
|
|
76
|
+
return existing;
|
|
77
|
+
}
|
|
78
|
+
if (!wireString(credential.refresh)) throw invalidCredentials();
|
|
79
|
+
return createHash("sha256").update("pi-antigravity/ownership/v1\0").update(credential.refresh).digest("hex");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Pure serialization of existing Pi OAuth credentials; does not mutate or persist. */
|
|
83
|
+
export function getApiKey(credential: OAuthCredentials): string {
|
|
84
|
+
if (!wireString(credential.access) || !wireString(credential.projectId)) throw invalidCredentials();
|
|
85
|
+
if (credential.email !== undefined && !email(credential.email)) throw invalidCredentials();
|
|
86
|
+
return JSON.stringify({ token: credential.access, projectId: credential.projectId,
|
|
87
|
+
...(email(credential.email) ? { email: email(credential.email) } : { ownershipId: fallbackOwnershipId(credential) }) });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function tier(value: unknown): AccountInitialization["currentTier"] {
|
|
91
|
+
const data = record(value);
|
|
92
|
+
if (!data) return undefined;
|
|
93
|
+
const id = wireString(data.id) ? data.id : undefined;
|
|
94
|
+
const name = typeof data.name === "string" && data.name.length <= 256 && !/[\x00-\x1f\x7f]/.test(data.name)
|
|
95
|
+
? data.name : undefined;
|
|
96
|
+
return id || name ? { ...(id ? { id } : {}), ...(name ? { name } : {}) } : undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Initialize the consumer account on one explicitly approved origin.
|
|
101
|
+
* The minimal metadata body is a public upstream protocol clue, not a claim of
|
|
102
|
+
* byte-for-byte CLI capture. Only cloudaicompanionProject is recognized; no
|
|
103
|
+
* guessed UUID, onboarding, explicit GCP project_id, or endpoint probing.
|
|
104
|
+
*/
|
|
105
|
+
export async function initializeAccount(token: string, options: InitializeAccountOptions = {}): Promise<AccountInitialization> {
|
|
106
|
+
options.signal?.throwIfAborted();
|
|
107
|
+
if (!wireString(token)) throw invalidCredentials();
|
|
108
|
+
const baseUrl = assertOfficialBaseUrl(options.baseUrl ?? DEFAULT_BASE_URL);
|
|
109
|
+
const response = await (options.postJson ?? postJson)(baseUrl, "/v1internal:loadCodeAssist", token,
|
|
110
|
+
{ metadata: { ideType: "ANTIGRAVITY" } }, options.signal);
|
|
111
|
+
options.signal?.throwIfAborted();
|
|
112
|
+
const data = record(response);
|
|
113
|
+
const project = data?.cloudaicompanionProject;
|
|
114
|
+
const projectId = typeof project === "string" ? project : record(project)?.id;
|
|
115
|
+
if (!wireString(projectId)) {
|
|
116
|
+
throw new Error("Antigravity loadCodeAssist did not return a Cloud Code Assist project; automatic onboarding is not implemented.");
|
|
117
|
+
}
|
|
118
|
+
return { baseUrl, projectId, ...(tier(data?.currentTier) ? { currentTier: tier(data?.currentTier) } : {}),
|
|
119
|
+
...(tier(data?.paidTier) ? { paidTier: tier(data?.paidTier) } : {}) };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Static verification: official CLI 1.5.9 darwin-arm archive, SHA-512 recorded
|
|
124
|
+
* in test/fixtures/agy-cli-1.5.9/manifest.json. CLI backend/auth (not acpauth):
|
|
125
|
+
* getOauthParams consumer branch 0x102b7584c, getScopesByAuthMethod
|
|
126
|
+
* 0x102b759b0, StartInteractive 0x102b74e60, completeOAuth.func1.1
|
|
127
|
+
* 0x102b7a400. Google Endpoint has AuthStyleInParams (1). It calls standard
|
|
128
|
+
* oauth2 Config.Exchange and tokenRefresher.Token. These are public client
|
|
129
|
+
* application parameters, not user credentials. Static verification is not
|
|
130
|
+
* a live /login acceptance test. We require full URLs/state rather than the
|
|
131
|
+
* CLI's more permissive bare-code input; no localhost/ACP flow is implemented.
|
|
132
|
+
*/
|
|
133
|
+
export const OAUTH_CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
|
|
134
|
+
const OAUTH_CLIENT_SECRET = "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";
|
|
135
|
+
export const OAUTH_AUTH_URL = "https://accounts.google.com/o/oauth2/auth";
|
|
136
|
+
export const OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
137
|
+
export const OAUTH_REDIRECT_URI = "https://antigravity.google/oauth-callback";
|
|
138
|
+
const USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo";
|
|
139
|
+
export const OAUTH_SCOPES = Object.freeze([
|
|
140
|
+
"https://www.googleapis.com/auth/cloud-platform",
|
|
141
|
+
"https://www.googleapis.com/auth/userinfo.email",
|
|
142
|
+
"https://www.googleapis.com/auth/userinfo.profile",
|
|
143
|
+
"https://www.googleapis.com/auth/cclog",
|
|
144
|
+
"https://www.googleapis.com/auth/experimentsandconfigs",
|
|
145
|
+
"https://www.googleapis.com/auth/aicode",
|
|
146
|
+
"openid",
|
|
147
|
+
]);
|
|
148
|
+
export interface AuthOptions extends InitializeAccountOptions {
|
|
149
|
+
fetch?: typeof fetch;
|
|
150
|
+
now?: () => number;
|
|
151
|
+
requestTimeoutMs?: number;
|
|
152
|
+
loginTimeoutMs?: number;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Errors retain only allowlisted OAuth codes, never token response bodies. */
|
|
156
|
+
export class AntigravityOAuthError extends Error {
|
|
157
|
+
constructor(readonly status: number, readonly code?: string) {
|
|
158
|
+
super(`Antigravity OAuth request failed (HTTP ${status}${code ? `, ${code}` : ""}).`);
|
|
159
|
+
this.name = "AntigravityOAuthError";
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const SAFE_OAUTH_CODES = new Set(["invalid_grant", "invalid_client", "invalid_request", "unauthorized_client", "unsupported_grant_type", "access_denied", "temporarily_unavailable", "server_error"]);
|
|
163
|
+
|
|
164
|
+
/** Strict manual callback parsing. No arbitrary redirect origins or bare codes. */
|
|
165
|
+
export function parseOAuthCallback(value: string, expectedState: string): string {
|
|
166
|
+
let url: URL;
|
|
167
|
+
try { url = new URL(value.trim()); } catch { throw new Error("Paste the complete Antigravity callback URL including code and state."); }
|
|
168
|
+
if (url.origin + url.pathname !== OAUTH_REDIRECT_URI || url.username || url.password || url.hash) {
|
|
169
|
+
throw new Error("Unexpected Antigravity OAuth callback URL.");
|
|
170
|
+
}
|
|
171
|
+
const state = url.searchParams.getAll("state");
|
|
172
|
+
const a = Buffer.from(state[0] ?? "");
|
|
173
|
+
const b = Buffer.from(expectedState);
|
|
174
|
+
if (state.length !== 1 || !b.length || a.length !== b.length || !timingSafeEqual(a, b)) {
|
|
175
|
+
throw new Error("Antigravity OAuth state mismatch; restart login.");
|
|
176
|
+
}
|
|
177
|
+
if (url.searchParams.has("error")) throw new Error("Antigravity OAuth authorization was denied.");
|
|
178
|
+
const code = url.searchParams.getAll("code");
|
|
179
|
+
if (code.length !== 1 || !wireString(code[0])) throw new Error("Antigravity OAuth callback is missing a valid authorization code.");
|
|
180
|
+
return code[0];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function oauthJson(url: string, init: RequestInit, signal: AbortSignal, options: AuthOptions): Promise<Record<string, unknown>> {
|
|
184
|
+
signal.throwIfAborted();
|
|
185
|
+
const bounded = AbortSignal.any([signal, AbortSignal.timeout(options.requestTimeoutMs ?? 30_000)]);
|
|
186
|
+
let response: Response;
|
|
187
|
+
let text: string;
|
|
188
|
+
try {
|
|
189
|
+
response = await (options.fetch ?? fetch)(url, { ...init, redirect: "error", signal: bounded });
|
|
190
|
+
text = await readBoundedBody(response, 64 * 1024, bounded);
|
|
191
|
+
} catch {
|
|
192
|
+
bounded.throwIfAborted();
|
|
193
|
+
throw new Error("Antigravity OAuth network request failed.");
|
|
194
|
+
}
|
|
195
|
+
bounded.throwIfAborted();
|
|
196
|
+
let data: Record<string, unknown> | undefined;
|
|
197
|
+
try { data = record(JSON.parse(text)); } catch { /* Never retain parser errors containing secrets. */ }
|
|
198
|
+
if (!response.ok) {
|
|
199
|
+
const code = typeof data?.error === "string" && SAFE_OAUTH_CODES.has(data.error) ? data.error : undefined;
|
|
200
|
+
throw new AntigravityOAuthError(response.status, code);
|
|
201
|
+
}
|
|
202
|
+
if (!data) throw new Error("Antigravity OAuth returned an invalid JSON object.");
|
|
203
|
+
return data;
|
|
204
|
+
}
|
|
205
|
+
async function exchangeToken(params: Record<string, string>, signal: AbortSignal, options: AuthOptions): Promise<Record<string, unknown>> {
|
|
206
|
+
return oauthJson(OAUTH_TOKEN_URL, {
|
|
207
|
+
method: "POST",
|
|
208
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": CLIENT_USER_AGENT },
|
|
209
|
+
body: new URLSearchParams({ client_id: OAUTH_CLIENT_ID, client_secret: OAUTH_CLIENT_SECRET, ...params }).toString(),
|
|
210
|
+
}, signal, options);
|
|
211
|
+
}
|
|
212
|
+
function tokenFields(data: Record<string, unknown>, now: number, previousRefresh?: string): OAuthCredentials {
|
|
213
|
+
const refresh = data.refresh_token === undefined ? previousRefresh : data.refresh_token;
|
|
214
|
+
const seconds = data.expires_in;
|
|
215
|
+
if (!wireString(data.access_token) || !wireString(refresh) || typeof seconds !== "number" ||
|
|
216
|
+
!Number.isFinite(seconds) || seconds <= 0 || !Number.isSafeInteger(now + seconds * 1000) ||
|
|
217
|
+
(data.token_type !== undefined && (typeof data.token_type !== "string" || data.token_type.toLowerCase() !== "bearer"))) {
|
|
218
|
+
throw new Error("Antigravity OAuth returned invalid or incomplete token data.");
|
|
219
|
+
}
|
|
220
|
+
return { access: data.access_token, refresh, expires: now + seconds * 1000 };
|
|
221
|
+
}
|
|
222
|
+
function waitForPrompt<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
223
|
+
return new Promise((resolve, reject) => {
|
|
224
|
+
const abort = () => reject(signal.reason);
|
|
225
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
226
|
+
if (signal.aborted) abort();
|
|
227
|
+
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Native manual-paste OAuth login; returns credentials for Pi to store. */
|
|
232
|
+
export async function loginAntigravity(interaction: ProviderAuthInteraction, options: AuthOptions = {}): Promise<AntigravityOAuthCredential> {
|
|
233
|
+
const signal = AbortSignal.any([interaction.signal, ...(options.signal ? [options.signal] : []), AbortSignal.timeout(options.loginTimeoutMs ?? 5 * 60_000)]);
|
|
234
|
+
signal.throwIfAborted();
|
|
235
|
+
const baseUrl = assertOfficialBaseUrl(options.baseUrl ?? DEFAULT_BASE_URL);
|
|
236
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
237
|
+
const state = randomBytes(32).toString("base64url");
|
|
238
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
239
|
+
const params = new URLSearchParams({ client_id: OAUTH_CLIENT_ID, redirect_uri: OAUTH_REDIRECT_URI,
|
|
240
|
+
response_type: "code", scope: OAUTH_SCOPES.join(" "), code_challenge: challenge, code_challenge_method: "S256",
|
|
241
|
+
state, access_type: "offline", prompt: "consent" });
|
|
242
|
+
interaction.notify({ type: "auth_url", url: `${OAUTH_AUTH_URL}?${params}`,
|
|
243
|
+
instructions: "Sign in, then paste the complete final Antigravity callback URL (including code and state)." });
|
|
244
|
+
const pasted = await waitForPrompt(interaction.prompt({ type: "manual_code", message: "Paste the complete Antigravity callback URL:", signal }), signal);
|
|
245
|
+
signal.throwIfAborted();
|
|
246
|
+
const code = parseOAuthCallback(pasted, state);
|
|
247
|
+
const now = (options.now ?? Date.now)();
|
|
248
|
+
const data = await exchangeToken({ grant_type: "authorization_code", code, code_verifier: verifier, redirect_uri: OAUTH_REDIRECT_URI }, signal, options);
|
|
249
|
+
const fields = tokenFields(data, now);
|
|
250
|
+
interaction.notify({ type: "progress", message: "Initializing Antigravity account." });
|
|
251
|
+
const user = await oauthJson(USERINFO_URL, { method: "GET", headers: { Authorization: `Bearer ${fields.access}`, "User-Agent": CLIENT_USER_AGENT } }, signal, options);
|
|
252
|
+
const accountEmail = email(user.email);
|
|
253
|
+
if (!accountEmail) throw new Error("Antigravity OAuth user info did not contain a valid account email.");
|
|
254
|
+
const account = await initializeAccount(fields.access, { ...options, baseUrl, signal });
|
|
255
|
+
signal.throwIfAborted();
|
|
256
|
+
return { ...fields, type: "oauth", projectId: account.projectId, email: accountEmail };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Refresh only the current credential; preserve project/email and token rotation. */
|
|
260
|
+
export async function refreshAntigravityToken(credential: OAuthCredential, signal: AbortSignal, options: AuthOptions = {}): Promise<AntigravityOAuthCredential> {
|
|
261
|
+
signal = options.signal ? AbortSignal.any([signal, options.signal]) : signal;
|
|
262
|
+
signal.throwIfAborted();
|
|
263
|
+
assertOfficialBaseUrl(options.baseUrl ?? DEFAULT_BASE_URL);
|
|
264
|
+
if (!wireString(credential.refresh)) throw invalidCredentials();
|
|
265
|
+
if (credential.email !== undefined && !email(credential.email)) throw invalidCredentials();
|
|
266
|
+
// Require initialized credentials before rotating: no fallible discovery may
|
|
267
|
+
// discard the only valid replacement refresh token after the exchange.
|
|
268
|
+
if (!wireString(credential.projectId)) throw invalidCredentials();
|
|
269
|
+
const ownershipId = email(credential.email) ? undefined : fallbackOwnershipId(credential);
|
|
270
|
+
const now = (options.now ?? Date.now)();
|
|
271
|
+
const fields = tokenFields(await exchangeToken({ grant_type: "refresh_token", refresh_token: credential.refresh }, signal, options), now, credential.refresh);
|
|
272
|
+
// No discovery after token rotation when projectId is already known: avoid
|
|
273
|
+
// losing the rotated credential due to an unrelated initialization failure.
|
|
274
|
+
const projectId = credential.projectId;
|
|
275
|
+
signal.throwIfAborted();
|
|
276
|
+
return { ...credential, ...fields, type: "oauth", projectId,
|
|
277
|
+
...(ownershipId === undefined ? {} : { antigravityOwnershipId: ownershipId }) };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Native auth. Pi owns locking, persistence, login/logout and caller cancellation. */
|
|
281
|
+
export function createAntigravityAuth(options: AuthOptions = {}): ProviderAuth {
|
|
282
|
+
return { oauth: {
|
|
283
|
+
name: "Antigravity", isSubscription: true, loginLabel: "Sign in with Google (Antigravity subscription)",
|
|
284
|
+
login: interaction => loginAntigravity(interaction, options),
|
|
285
|
+
refresh: (credential, signal) => refreshAntigravityToken(credential, signal, options),
|
|
286
|
+
async toAuth(credential) { return { apiKey: getApiKey(credential) }; },
|
|
287
|
+
} };
|
|
288
|
+
}
|