Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -1737,7 +1737,12 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
# Optional override; leave unset to follow the shared Codex client version.
# CODEX_USER_AGENT="codex-cli/0.155.0 (Windows 10.0.26200; x64)"
GITHUB_USER_AGENT="GitHubCopilotChat/0.54.0"
ANTIGRAVITY_USER_AGENT="antigravity/2.0.1 linux/arm64 google-api-nodejs-client/10.3.0"
# Optional override for the Antigravity CLI (`agy`) build changelist reported as `cl=` in
# the CLI User-Agent. Google's Piper changelist is not derivable from the version and is not
# exposed by the public auto-updater manifests, so it is seeded from a live capture per
# version (1.4.4 -> 1003595037). Unset on an unknown version omits `cl=` entirely rather
# than advertising another build's changelist.
# ANTIGRAVITY_CLI_CL=1003595037
KIRO_USER_AGENT="AWS-SDK-JS/3.0.0 kiro-ide/1.0.0"
# KIRO_VERIFY_FULL_CRC=false # opt-in: full per-frame message CRC validation on the Kiro event stream (debug corrupted streams; prelude CRC + TLS already protect framing)
# Optional override for the Kiro social device-code OAuth clientId. Kiro's
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(sse):** the Antigravity CLI profile now matches the native `agy` wire image — the User-Agent reports the real host in Go's `windows`/`amd64` dialect plus the build changelist (`cl=`) instead of the pinned `darwin/arm64`, `streamGenerateContent` no longer sends `Accept: text/event-stream` or `x-goog-user-project` on the consumer path, and `loadCodeAssist` sends the native 38-byte `{"metadata":{"ideType":"ANTIGRAVITY"}}` body. The IDE profile keeps its `darwin/arm64` pin and numeric protobuf enums. Also drops the never-read `ANTIGRAVITY_USER_AGENT` env knob.
2 changes: 1 addition & 1 deletion docs/reference/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -725,7 +725,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`]
| `CLAUDE_CODE_CLIENT_BUILD_REVISION` | `1e2` | Override the 3-character suffix OmniRoute appends to `cc_version=` in the Claude billing block. Bump alongside `CLAUDE_CODE_CLIENT_VERSION` — pinning only the version advertises a `version.revision` pair no real binary emits. |
| `GITHUB_COPILOT_CLI_VERSION` | `1.0.91` | Override advertised Copilot CLI version independently of `GITHUB_USER_AGENT` |
| `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.54.0` | When GitHub Copilot Chat updates |
| `ANTIGRAVITY_USER_AGENT` | `antigravity/2.0.1 darwin/arm64` | When Antigravity IDE updates |
| `ANTIGRAVITY_CLI_CL` | _(unset)_ | Override the Antigravity CLI (`agy`) Piper changelist reported as `cl=` in the CLI User-Agent. Not derivable from the version nor exposed by the public manifests; seeded per version from a live capture (1.4.4 → `1003595037`). Unset on an unknown version omits `cl=` instead of advertising another build's changelist. |
| `KIRO_USER_AGENT` | `AWS-SDK-JS/3.0.0 kiro-ide/1.0.0` | When Kiro IDE updates |
| `KIRO_OAUTH_CLIENT_ID` | `kiro-cli` | Override the Kiro social device-code `clientId` (public id) |
| `KIRO_VERIFY_FULL_CRC` | `false` | Opt-in: full per-frame message CRC validation on the Kiro event stream (debug corrupted streams) |
Expand Down
1 change: 0 additions & 1 deletion docs/security/STEALTH_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,6 @@ All MITM endpoints require management auth (`requireCliToolsAuth`). The sudo pas
| `CLAUDE_USER_AGENT` | `claude-cli/2.1.280 (external, cli)` |
| `CODEX_USER_AGENT` | `codex-cli/0.155.0 (Windows 10.0.26200; x64)` |
| `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.54.0` |
| `ANTIGRAVITY_USER_AGENT` | `antigravity/2.0.1 linux/arm64 google-api-nodejs-client/10.3.0` |
| `KIRO_USER_AGENT` | `AWS-SDK-JS/3.0.0 kiro-ide/1.0.0` |
| `QODER_USER_AGENT` | `Qoder-Cli` |
| `CURSOR_USER_AGENT` | `Cursor/3.4` |
Expand Down
17 changes: 7 additions & 10 deletions open-sse/config/cliFingerprints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,19 +172,16 @@ export const CLI_FINGERPRINTS: Record<string, CliFingerprint> = {
userAgent: getGitHubCopilotChatUserAgent,
},
antigravity: {
// Native capture of the Go CLI (agy 1.4.4, Windows): streaming is requested by the
// `?alt=sse` query param, so there is no Accept header, and the consumer path carries
// no x-goog-* identity headers at all.
headerOrder: [
"Accept",
"Accept-Encoding",
"Authorization",
"Host",
"Content-Type",
"Authorization",
"User-Agent",
"x-goog-api-client",
"x-client-name",
"x-client-version",
"x-machine-id",
"x-vscode-sessionid",
"Host",
"Connection",
"Accept-Encoding",
"Transfer-Encoding",
],
bodyFieldOrder: [
"project",
Expand Down
8 changes: 6 additions & 2 deletions open-sse/executors/antigravity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -623,10 +623,14 @@ export class AntigravityExecutor extends BaseExecutor {

buildHeaders(credentials: AntigravityCredentials, _stream = true): Record<string, string> {
const clientProfile = getAntigravityClientProfile(credentials);
const raw = {
const raw: Record<string, string> = {
...getAntigravityContentHeaders(clientProfile, credentials.accessToken),
Accept: "text/event-stream",
};
// The native Go CLI requests SSE through `?alt=sse` alone and sends no Accept header;
// only the IDE/Electron profile advertises `text/event-stream`.
if (clientProfile !== "cli") {
raw.Accept = "text/event-stream";
}
// Scrub proxy/fingerprint headers that reveal non-native traffic
return scrubProxyAndFingerprintHeaders(raw);
}
Expand Down
4 changes: 3 additions & 1 deletion open-sse/services/antigravityClientProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ export function applyAntigravityClientProfileHeaders(

const project = getProjectHeaderValue(body);
removeHeaderCaseInsensitive(headers, "x-goog-user-project");
if (project) {
// The native Go CLI (consumer auth) sends no quota-project header at all; only the IDE
// profile does. sendAntigravityRequest still retries once without it on a 403.
if (project && profile !== "cli") {
headers["x-goog-user-project"] = project;
}

Expand Down
80 changes: 72 additions & 8 deletions open-sse/services/antigravityHeaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,66 @@ function resolveAntigravityPlatformEnum(): number {
export const ANTIGRAVITY_IDE_NODE_API_CLIENT = "google-api-nodejs-client/10.3.0";
export const ANTIGRAVITY_IDE_NODE_X_GOOG_API_CLIENT = "gl-node/22.21.1";

// Antigravity presents the native macOS desktop client fingerprint: the upstream
// backend expects the Mac build, so the OS/arch token is pinned to darwin/arm64
// regardless of the host OmniRoute happens to run on (#8098). The IDE / CLI /
// IDE-Node User-Agent split (#8013) is preserved — only the platform token is fixed.
// Antigravity presents the native macOS desktop client fingerprint on the IDE path:
// the upstream backend expects the Mac build, so the OS/arch token is pinned to
// darwin/arm64 regardless of the host OmniRoute happens to run on (#8098). The IDE /
// CLI / IDE-Node User-Agent split (#8013) is preserved — only the platform token is fixed.
//
// The CLI path (agy, a Go binary) has no such constraint: it reports the real host in
// Go's runtime.GOOS/GOARCH dialect, captured live from `agy 1.4.4` on Windows x64 as
// `os_type=windows; arch=amd64`. Node's own names ("win32"/"x64") are a giveaway.
const ANTIGRAVITY_OS_TYPE = "darwin";
const ANTIGRAVITY_ARCH = "arm64";

const ANTIGRAVITY_GO_OS: Record<string, string> = {
win32: "windows",
darwin: "darwin",
linux: "linux",
freebsd: "freebsd",
openbsd: "openbsd",
sunos: "solaris",
aix: "aix",
};

const ANTIGRAVITY_GO_ARCH: Record<string, string> = {
x64: "amd64",
ia32: "386",
arm64: "arm64",
arm: "arm",
ppc64: "ppc64",
s390x: "s390x",
};

/** Map a Node `process.platform`/`process.arch` pair onto Go's GOOS/GOARCH tokens. */
export function resolveAntigravityGoPlatform(
platform: string = process.platform,
arch: string = process.arch
): { osType: string; arch: string } {
return {
osType: ANTIGRAVITY_GO_OS[platform] ?? platform,
arch: ANTIGRAVITY_GO_ARCH[arch] ?? arch,
};
}

// `cl` is Google's internal Piper changelist for the build that shipped a given CLI
// version — not derivable from the version, and not exposed by the public manifests
// (only version/url/sha512 are). Seeded from the native capture; refresh when bumping.
const ANTIGRAVITY_KNOWN_CHANGELISTS: Record<string, string> = {
"1.4.4": "1003595037",
};

/**
* Resolve the CLI build changelist: explicit override, then the known-version table.
* Returns `null` when the version has no recorded changelist, so the caller omits `cl=`
* entirely — a stale changelist paired with a newer version is a worse fingerprint than
* no changelist at all.
*/
export function resolveAntigravityCliChangelist(version: string): string | null {
const override = process.env.ANTIGRAVITY_CLI_CL?.trim();
if (override) return override;
return ANTIGRAVITY_KNOWN_CHANGELISTS[version] ?? null;
}

function withOptionalBearerAuth(
headers: Record<string, string>,
accessToken?: string | null
Expand All @@ -75,9 +128,12 @@ export function antigravityIdeUserAgent(version = getCachedAntigravityIdeVersion

export function antigravityCliUserAgent(
version = getCachedAntigravityCliVersion(),
authMethod = "consumer"
authMethod = "consumer",
changelist = resolveAntigravityCliChangelist(version),
platform = resolveAntigravityGoPlatform()
): string {
return `antigravity/cli/${version} (aidev_client; os_type=${ANTIGRAVITY_OS_TYPE}; arch=${ANTIGRAVITY_ARCH}; auth_method=${authMethod})`;
const clToken = changelist ? `cl=${changelist}; ` : "";
return `antigravity/cli/${version} (aidev_client; os_type=${platform.osType}; arch=${platform.arch}; ${clToken}auth_method=${authMethod})`;
}

export function antigravityIdeNodeUserAgent(version = getCachedAntigravityIdeVersion()): string {
Expand Down Expand Up @@ -112,8 +168,16 @@ export function getAntigravityIdeNodeHeaders(accessToken?: string | null): Recor
);
}

/** Native loadCodeAssist body metadata captured from both official clients. */
export function getAntigravityLoadCodeAssistMetadata(): Record<string, number> {
/** Native loadCodeAssist body metadata, per client profile. */
export function getAntigravityLoadCodeAssistMetadata(
profile: AntigravityClientProfile = "ide"
): Record<string, number> | { ideType: string } {
// The Go CLI sends the bare string form — 38 bytes, 200 OK, captured live. The numeric
// enum shape below is what the IDE build needs; sending it from the CLI path would be
// an identity mismatch.
if (profile === "cli") {
return { ideType: "ANTIGRAVITY" };
}
return {
ideType: ANTIGRAVITY_IDE_TYPE_ENUM,
platform: resolveAntigravityPlatformEnum(),
Expand Down
6 changes: 4 additions & 2 deletions open-sse/services/antigravityProjectBootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,9 @@ async function tryLoadCodeAssist(
const response = await fetchImpl(url, {
method: "POST",
headers,
body: JSON.stringify({ metadata: getAntigravityLoadCodeAssistMetadata() }),
body: JSON.stringify({
metadata: getAntigravityLoadCodeAssistMetadata(clientProfile),
}),
signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal,
});

Expand Down Expand Up @@ -222,7 +224,7 @@ async function tryOnboardUser(
const headers = getAntigravityContentHeaders(clientProfile, accessToken);
const body = JSON.stringify({
tier_id: tierId,
metadata: getAntigravityLoadCodeAssistMetadata(),
metadata: getAntigravityLoadCodeAssistMetadata(clientProfile),
});

for (const url of urls) {
Expand Down
2 changes: 1 addition & 1 deletion open-sse/services/usage/antigravity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -824,7 +824,7 @@ async function getAntigravitySubscriptionInfo(
const response = await fetch(ANTIGRAVITY_CONFIG.loadProjectApiUrl, {
method: "POST",
headers: getAntigravityContentHeaders(profile, accessToken),
body: JSON.stringify({ metadata: getAntigravityLoadCodeAssistMetadata() }),
body: JSON.stringify({ metadata: getAntigravityLoadCodeAssistMetadata(profile) }),
});

if (!response.ok) return null;
Expand Down
6 changes: 2 additions & 4 deletions src/lib/oauth/providers/antigravity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ async function postExchangeAntigravity(
tokens: AntigravityTokenPayload
): Promise<AntigravityPostExchange> {
const headers = getPostExchangeHeaders(clientProfile, tokens.access_token);
const metadata = getAntigravityLoadCodeAssistMetadata();
const metadata = getAntigravityLoadCodeAssistMetadata(clientProfile);
const userInfoResponse = await fetch(`${config.userInfoUrl}?alt=json`, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
signal: AbortSignal.timeout(POSTEXCHANGE_TIMEOUT_MS),
Expand Down Expand Up @@ -297,9 +297,7 @@ export function createAntigravityOAuthProvider(
// env-level custom client later on. Compare by value against the
// embedded default: `config` may be the very same object as
// ANTIGRAVITY_CONFIG when no runtime override exists.
oauthClient: isCustomAntigravityClient(config)
? `custom:${config.clientId}`
: "builtin",
oauthClient: isCustomAntigravityClient(config) ? `custom:${config.clientId}` : "builtin",
})),
mapTokens: (tokens, extra) => mapAntigravityTokens(clientProfile, tokens, extra),
};
Expand Down
2 changes: 1 addition & 1 deletion src/lib/oauth/utils/agyAuthImport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ export async function enrichWithAntigravityBackend(
const loadTimer = setTimeout(() => loadController.abort(), 8000);
try {
const headers = getAntigravityContentHeaders("cli", parsed.accessToken);
const metadata = getAntigravityLoadCodeAssistMetadata();
const metadata = getAntigravityLoadCodeAssistMetadata("cli");
for (const endpoint of AGY_CONFIG.loadCodeAssistEndpoints) {
try {
const res = await fetch(endpoint, {
Expand Down
45 changes: 45 additions & 0 deletions tests/fixtures/antigravity/native-wire.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"_comment": "Golden capture of the native Antigravity CLI (agy 1.4.4) on Windows x64. Values are copied verbatim from a mitmproxy capture except Authorization, which is replaced with a placeholder — never store a real bearer token here.",
"endpoint": "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse",
"requestHeaderOrder": [
"Accept-Encoding",
"Authorization",
"Content-Type",
"Host",
"Transfer-Encoding",
"User-Agent"
],
"requestHeaders": {
"Accept-Encoding": "gzip",
"Authorization": "Bearer <redacted>",
"Content-Type": "application/json",
"Host": "daily-cloudcode-pa.googleapis.com",
"Transfer-Encoding": "chunked",
"User-Agent": "antigravity/cli/1.4.4 (aidev_client; os_type=windows; arch=amd64; cl=1003595037; auth_method=consumer)"
},
"forbiddenHeaders": ["Accept", "x-goog-user-project", "x-goog-api-client", "Client-Metadata"],
"envelopeFieldOrder": [
"project",
"requestId",
"request",
"model",
"userAgent",
"requestType"
],
"requestFieldOrder": [
"contents",
"systemInstruction",
"tools",
"labels",
"generationConfig",
"sessionId"
],
"loadCodeAssistMetadata": {
"ideType": "ANTIGRAVITY"
},
"platformTokens": {
"win32": { "osType": "windows", "arch": "amd64" },
"darwin": { "osType": "darwin", "arch": "arm64" },
"linux": { "osType": "linux", "arch": "amd64" }
}
}
4 changes: 3 additions & 1 deletion tests/unit/antigravity-client-profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,14 +111,16 @@ test("content header application emits IDE and CLI identities and strips fake he

assert.match(ideHeaders["User-Agent"], /^antigravity\/ide\/2\.1\.1 /);
assert.match(ideHeaders["User-Agent"], / darwin\/arm64$| windows\/amd64$| linux\/[^ ]+$/);
// 1.1.1 has no recorded changelist, so `cl=` is omitted rather than borrowed from 1.4.4.
assert.match(
cliHeaders["User-Agent"],
/^antigravity\/cli\/1\.1\.1 \(aidev_client; os_type=.+; arch=.+; auth_method=consumer\)$/
);
assertIdentityHeadersAbsent(ideHeaders);
assertIdentityHeadersAbsent(cliHeaders);
// The Go CLI (consumer auth) sends no quota-project header; only the IDE profile does.
assert.equal(ideHeaders["x-goog-user-project"], "project-1");
assert.equal(cliHeaders["x-goog-user-project"], "project-1");
assert.equal(cliHeaders["x-goog-user-project"], undefined);
});

test("public request envelopes never infer the internal jetski identity from email", () => {
Expand Down
Loading
Loading