はじめに
Antigravity(Google DeepMind製のAIコーディングアシスタント)を使ってみたんだけど、これがまぁすごい。
Web検索、ファイル操作、ターミナル操作——全部AIが勝手にやってくれる。
しかもクォータ(使用制限)がめちゃくちゃ寛容。
貧乏学生な俺には、Googleに恋心を抱くにはもう十分だよね。
で、ふと思った。
「これ、プログラムから自由に動かせたら OpenClaw みたいな自律AIエージェント作れるんじゃね?」
てことで公式SDKがないか調べてみると、存在しない...。
CDP(Chrome DevTools Protocol)を使った手法は存在するが、これはUIの操作を模倣しているだけで、SDKとは言えない。Connect RPC で直接LS鯖と通信する今回実装した手法がより低レイヤーでネイティブな操作を可能にする。
じゃあプロトコル解析して自分で作るしかないよね!!!!
ということで作りました。
GitHub
やったことの全体像
ざっくりこんな流れ。
① アプリの仕組みを丸裸にしてあげる。
② cURLでLanguage Serverを直接叩いてみる → 動いた!
③ JSの中からProtobuf定義を自動で全部抜き出すスクリプトを書く
④ TypeScriptでSDK化 → 既存のIDEに接続してAIと対話できるように
⑤ AI回答がどこから来てるのか解明(ここが一番大変だった)
⑥ IDEを起動しなくてもLanguage Serverを単独起動できるように
順番に紹介していきます〜
① まずアプリの中身を覗く
AntigravityくんはVS Codeのフォーク。
中を見ると、こんな構造になってる。
Antigravity.app/Contents/Resources/app/extensions/antigravity/
├── dist/extension.js ← Extension本体(minified webpack bundle)
├── bin/language_server_macos_arm ← Go製のLanguage Server
├── dist/sidebar/index.html ← チャットUI
└── package.json ← 拡張機能のメタデータ
extension.js はminifiedのままだと読めないので、まず適当に整形。
node format_js.js
# → extension_formatted.js(数十万行の整形済みJS)
これで読めるようになった。おっけい〜
② cURLで直接叩いてみる
自分で読んでたら気がおかしくなるので、AntigravityにJSを読ませていくと、Language Server(以下LS)が Connect RPC(gRPCのHTTP互換プロトコル)でHTTPS通信してることがわかった。
LSのポートとCSRFトークンの見つけ方はこんな感じ。
# 起動中のLSを探す
lsof -nP -iTCP -sTCP:LISTEN | grep language_
# CSRFトークンをプロセス引数から取得
ps eww <PID> | grep -oE "csrf_token [^ ]+"
LSはプロセスごとにランダムなポートで起動する。HTTPSポート(Connect RPC / gRPC)とHTTPポート(JSONフォールバック用)があって、ストリーミングRPCはHTTPSのほうじゃないと動かないのがハマりポイント。
あと、全リクエストに x-codeium-csrf-token ヘッダーが必須。これがないと弾かれる。
で、cURLで叩いてみた。
打ったコマンド忘れたけどこんなんだった気がする。
curl -k https://127.0.0.1:<PORT>/exa.language_server_pb.LanguageServerService/GetUserStatus \
-H "Content-Type: application/json" \
-H "x-codeium-csrf-token: <CSRF>" \
返ってきた!!!
長いから今回は省略するけど、ユーザー名もメール情報、全部取れた🎉
勝ち確ですね
③ Protobuf定義を自動で全部抜き出す
cURLで通信できることは確認できた。
でもProtobufの型定義を手動で再構成するのは現実的じゃない。
LSが使ってる型、1096個もあるし、手動だったら完成する頃にはもう老けちゃう。
そこで、クライアントに乗っているはずのproto定義から再構築することにした。
色々調べると...
extension_formatted.js と chat_formatted.js の中に、Base64エンコードされた FileDescriptorProto(Protobufのスキーマ定義そのもの)が埋まってる!
ということで、これを全自動で .proto に再構築するスクリプト generate_from_js.ts を書いた。
// scripts/generate_from_js.ts
import { FileDescriptorProto } from "@bufbuild/protobuf";
// JSファイルの中から100文字以上のBase64文字列を片っ端から探す
const regex = /(?:"|')([A-Za-z0-9+/=]{100,})(?:"|')/g;
for (const match of content.matchAll(regex)) {
try {
const bytes = Buffer.from(match[1], "base64");
const desc = FileDescriptorProto.fromBinary(bytes);
if (desc.package) {
descriptors.set(desc.name!, desc);
}
} catch (e) {
// Protoじゃないものは無視
}
}
やってることは単純で、「Base64っぽい文字列を全部Protobufとしてパースしてみて、成功したやつだけ残す」。力技だけど確実なはず。
さらに、抽出した型定義から完全な .proto ファイルを生成する。型の依存関係(import)の解決、oneof の再構築、ネストされた message/enum の処理も全自動。google.protobuf 等の標準ライブラリはスキップ。
$ npx tsx scripts/generate_from_js.ts
Scanning chat_formatted.js...
Scanning extension_formatted.js...
Found 47 valid protobuf file descriptors.
Known types: 1096
Generated exa/language_server_pb/language_server.proto
Generated exa/codeium_common_pb/codeium_common.proto
Generated exa/cortex_pb/cortex.proto
...
これで1096個の型定義と80以上のRPCメソッドが一気に手に入った。
有能ちゃんですね。
④ SDKにして既存LSに接続
Proto定義から、TypeScriptのコードを生成して、SDK化。
基本的な接続
AntigravityClient.connect({ autoDetect: true }); を呼ぶと、lsof を使って起動中のLSを自動検出してくれる。
import { AntigravityClient } from "../src/client.js";
async function main() {
// IDEが起動したLanguage Serverを自動検出して接続
const client = await AntigravityClient.connect({ autoDetect: true });
// ユーザー情報取得
const status = await client.getUserStatus();
console.log("User Status:", status);
}
main();
$ npx tsx test/test_test.ts
[Client] Connected to LS (PID: 26705, Port: 58946)
User Status: GetUserStatusResponse {
userStatus: UserStatus {
pro: false,
disableTelemetry: false,
name: 'jkfujinami',
ignoreChatTelemetrySetting: false,
teamId: '',
...
動いた〜
AIとチャットする
const cascade = await client.startCascade();
console.log(`Cascade ID: ${cascade.cascadeId}`);
// テキストのリアルタイム受信
cascade.on("text", (ev) => {
process.stdout.write(ev.delta);
});
// 思考プロセスの受信(灰色で表示)
cascade.on("thinking", (ev) => {
process.stdout.write(`\x1b[90m${ev.delta}\x1b[0m`);
});
cascade.on("error", (err) => {
console.error("Cascade Error:", err);
});
await cascade.sendMessage("Whoamiを実行してほしい.");
cascade.on("text", ...) の ev には delta(今回の差分テキスト)が入ってる。process.stdout.write(ev.delta) でストリーミング表示ができる。
⑤ 最大の壁——AI回答ってどこから来るの?
メッセージの送信は sendUserCascadeMessage でサクッとできた。
でも、AIの回答がどこから返ってくるかがマジでわからなかった。。。
必死に(AIが)JSを読み込んで、ついに発見。
AIの回答は普通のレスポンスで返ってこない。
streamCascadeReactiveUpdates っていうServer Streamingのエンドポイントから、Reactive State Diff っていう独自プロトコルで CascadeState の差分が降ってくることが判明した。
CascadeState {
trajectory: Trajectory {
steps: Step[] {
[N]: Step {
step.case: "plannerResponse"
step.value: CortexStepPlannerResponse {
modifiedResponse: "AIの回答テキスト(LS側で後処理済み)"
response: "AIの回答テキスト(raw)"
thinking: "思考プロセス"
toolCalls: [...]
}
status: CortexStepStatus // PENDING → RUNNING → DONE
requestedInteraction: { // コマンド実行やファイル操作の承認要求
interaction.case: "runCommand" | "filePermission" | ...
}
}
}
}
status: CascadeRunStatus // IDLE, RUNNING, etc.
}
Diffの適用は applyMessageDiff() で CascadeState オブジェクトに再帰的にマージしていく。
MessageDiff → FieldDiff[] → 各フィールドに SingularValue, RepeatedDiff, MapDiff を適用する仕組み。
ちなみに本家UIは response じゃなくて modifiedResponse(LSが後処理したテキスト)を表示に使ってる。ストリーム終了前は response が空のこともあるけど、再接続時の初期同期で modifiedResponse が配信されるので、SDK側でもそっちを優先するようにした。
// cascade.ts (実際の実装)
const response = planner.modifiedResponse || planner.response || "";
この差分適用アルゴリズムの解析が一番時間かかった。でもこれが解けた瞬間、脳汁ドバドバ。
もうゴールは近い...
⑥ IDEを不要にする
最後の仕上げ。IDEを起動しなくても、LSを単独で立ち上げて使えるようにする。
壁は3つあった。
壁1: LSは起動直後にExtension Serverに接続しに行く
→ LSが求めるレスポンスを返す Mock Extension Server (mock-extension-server.ts) を作った。
USSプロトコル(Unified State Sync)でOAuthトークンを供給する結構ゴツいやつ。
壁2: 認証トークンが必要
→ Antigravity IDEがローカルに保存してる state.vscdb(SQLite)から sqlite3 コマンドで読み取る。
// src/server/auth-reader.ts(実際のコード)
const STATE_DB_PATH = path.join(
homedir(),
"Library/Application Support/Antigravity/User/globalStorage/state.vscdb"
);
export function readAuthStatus(): { apiKey: string; email: string; name: string } {
const result = execSync(
`sqlite3 "${STATE_DB_PATH}" "SELECT value FROM ItemTable WHERE key='antigravityAuthStatus'"`,
{ encoding: "utf8" }
).trim();
const parsed = JSON.parse(result);
return {
apiKey: parsed.apiKey || "",
email: parsed.email || "",
name: parsed.name || "",
};
}
USS OAuth データは別のキー antigravityUnifiedStateSync.oauthToken に Base64エンコードされた Topic protobuf として格納されてる。これも readUssOAuthData() で抽出する。
壁3: LSのstdin初期化データの形式
→ LSは起動時にstdinからメタデータ(Protobuf)を受け取る。JSをさらに読み込んでフォーマットを解明した。
実際に動かしてみる
import { AntigravityClient } from "antigravity-client";
// IDE無しでLS単独起動
const client = await AntigravityClient.launch({
workspacePath: process.cwd(),
verbose: true,
});
console.log(`LS running (PID: ${client.launcher.pid}, HTTPS: ${client.launcher.httpsPort})`);
// あとは普通にAIと対話できる
const status = await client.getUserStatus();
const us = status.userStatus as any;
console.log(`Name: ${us?.name || "N/A"}`);
console.log(`Email: ${us?.email || "N/A"}`);
// 終わったら停止
await client.launcher.stop();
└──╼ $ npx tsx test/test_test.ts
[Launcher] Mock Extension Server on port 62225
[Launcher] Pre-launching CDP Chrome...
[MockExtSrv] Pre-launching Chrome (CDP port 9222)...
[MockExtSrv] ✅ Chrome ready on CDP port 9222
[Launcher] Chrome pre-launch: OK
[Launcher] Spawning: /Applications/Antigravity.app/Contents/Resources/app/extensions/antigravity/bin/language_server_macos_arm --extension_server_port=62225 --workspace_id=indie-antigravity-client --gemini_dir=~~ --app_data_dir=~~ --enable_lsp=true --csrf_token=~~ --random_port=true --cloud_code_endpoint=https://daily-cloudcode-pa.googleapis.com
[LS:STDERR] I0310 15:23:51.472316 36175 server.go:1130] Starting language server process with pid 36175
[LS:STDERR] I0310 15:23:51.472446 36175 server.go:265] Setting GOMAXPROCS to 4
[LS:STDERR] I0310 15:23:51.473308 36175 server.go:453] Language server will attempt to listen on host 127.0.0.1
[LS:STDERR] I0310 15:23:51.473375 36175 server.go:468] Language server listening on random port at 62242 for HTTPS
I0310 15:23:51.473387 36175 server.go:475] Language server listening on random port at 62243 for HTTP
[LS:STDERR] I0310 15:23:51.733028 36175 server.go:445] Created extension server client at port 62225
[LS:STDERR] I0310 15:23:51.734685 36175 server.go:584] Using ApiServerClientV2
[MockExtSrv] POST /exa.extension_server_pb.ExtensionServerService/LanguageServerStarted
[Launcher] LS ready - HTTPS:62242 HTTP:62243 LSP:62250
[LS:STDERR] I0310 15:23:51.807432 36175 server.go:1488] initialized server successfully in 334.976708ms
[MockExtSrv] POST /exa.extension_server_pb.ExtensionServerService/SubscribeToUnifiedStateSyncTopic
[MockExtSrv] POST /exa.extension_server_pb.ExtensionServerService/SubscribeToUnifiedStateSyncTopic
[Launcher] LS does not support SetUserSettings (ignoring).
LS running (PID: 36175, HTTPS: 62242)
[MockExtSrv] POST /exa.extension_server_pb.ExtensionServerService/LaunchBrowser
[MockExtSrv] LaunchBrowser requested, returning http://127.0.0.1:9222
[LS:STDERR] W0310 15:23:52.848675 36175 browser_liveness_utils.go:140] Connecting to browser via CDP: http://127.0.0.1:9222
Name: カワハギうなぎ
Email: unaginoipponnzuri@gmail.com
[LS:STDERR] I0310 15:23:52.997799 36175 server.go:1619] Got signal terminated, shutting down
I0310 15:23:52.997840 36175 server.go:1640] Language server shutting down
[Launcher] LS exited with code 0
ログが煩いけど、一応Antigravity IDEを一切起動せずにAIエージェントをフル制御できるようになった🎉
できること一覧
| 機能 | メソッド / イベント |
|---|---|
| AIとチャット | cascade.sendMessage(text) |
| テキスト受信 | cascade.on("text", ev => ev.delta) |
| 思考プロセス受信 | cascade.on("thinking", ev => ev.delta) |
| コマンド承認 | cascade.on("approval:needed", req => req.approve()) |
| ファイルアクセス承認 |
req.approve("once") / req.approve("conversation")
|
| コマンド出力受信 | cascade.on("command_output", ev => ev.delta) |
| ステップ追跡 |
cascade.on("step:new", ev) / cascade.on("step:update", ev)
|
| ステータス変化 |
cascade.on("status_change", ev) / cascade.on("done")
|
| モデル選択 | cascade.sendMessage(text, { model: Model.xxx }) |
| 画像付き送信 | cascade.sendMessage(text, { images: [...] }) |
| 会話履歴取得 | cascade.getHistory() |
| キャンセル | cascade.cancel() |
| ユーザー情報 | client.getUserStatus() |
| モデル一覧 | client.getAvailableModels() |
| IDE無しモード | AntigravityClient.launch({ workspacePath: "..." }) |
| LS停止 | client.launcher.stop() |
技術スタック
| 技術 | なにに使ったか |
|---|---|
| TypeScript (commonjs) | ランタイム |
@connectrpc/connect + @connectrpc/connect-node
|
HTTP/2 + TLS の Connect RPC通信 |
@bufbuild/protobuf |
Proto定義の抽出 & TypeScript生成 |
node-pty |
LS子プロセスのPTY管理 |
| EventEmitter | Reactive Diffのイベント処理 |
最後に
まだ完成してない部分もあります〜
- Windows / Linux 未対応(今はmacOSのみ。
lsofやstate.vscdbのパスがmacOS固有) - Chromeのブラウザ操作はまだ未実装で、基本的にChromeは使わないように指示したほうが良きです。
- トークンリフレッシュの実装
issueやPRくれたらめちゃくちゃ喜びます。
学生のクソコードなので、温かい目で見て下さい。
批判さたらギャン泣きします。
誰もやってない未知の開拓のはずなのに誰も褒めてくれないから、わざわざ記事にしたので褒めて下さい。
リバースエンジニアリングの詳しい話(数十万行のminified JSのどこを見ればいいかとか、Reactive Diffの適用アルゴリズムの全貌とか)は、需要あれば別記事で書こうかな〜と思ってます。
公式SDKが無い?
じゃあ中身を解析して自分で作ればいいじゃんの精神でみんなも生きていこう✨