diff --git a/README.md b/README.md index d992761a..9fe523c4 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ OpenUsage lives in your menu bar and shows you how much of your AI coding subscr - [**Amp**](docs/providers/amp.md) / free tier, bonus, credits - [**Antigravity**](docs/providers/antigravity.md) / all models +- [**Antigravity CLI**](docs/providers/antigravity-cli.md) / gemini pro, gemini flash, claude - [**Claude**](docs/providers/claude.md) / session, weekly, extra usage, local token usage (ccusage) - [**Codex**](docs/providers/codex.md) / session, weekly, reviews, credits - [**Copilot**](docs/providers/copilot.md) / premium, chat, completions diff --git a/docs/plugins/api.md b/docs/plugins/api.md index 7e96a3e8..f54ae02d 100644 --- a/docs/plugins/api.md +++ b/docs/plugins/api.md @@ -230,7 +230,7 @@ const resp = ctx.host.http.request({ ## Keychain (macOS only) ```typescript -host.keychain.readGenericPassword(service: string): string +host.keychain.readGenericPassword(service: string, account?: string): string ``` Reads a generic password from the macOS Keychain. @@ -239,6 +239,7 @@ Reads a generic password from the macOS Keychain. - **macOS only**: Throws on other platforms - **Throws if not found**: Returns the password string if found, throws otherwise +- **Optional account**: When `account` is provided, lookup is scoped to both service and account ### Example diff --git a/docs/providers/antigravity-cli.md b/docs/providers/antigravity-cli.md new file mode 100644 index 00000000..2ad6f76d --- /dev/null +++ b/docs/providers/antigravity-cli.md @@ -0,0 +1,36 @@ +# Antigravity CLI + +Tracks Google Antigravity CLI (`agy`) Cloud Code quota. + +## Setup + +Sign in with the CLI first: + +```bash +agy +``` + +OpenUsage uses the CLI keychain login. It does not start a browser OAuth flow. + +## Data Sources + +- Non-secret CLI context: `~/.gemini/antigravity-cli/` +- Auth: the Antigravity CLI OS keyring login. On macOS, current CLI builds store this under keychain service `gemini`, account `antigravity`. +- Quota APIs: + - `POST https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` + - `POST https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels` + - `POST https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota` + +The provider does not read legacy Gemini OAuth files such as `~/.gemini/oauth_creds.json`. + +## Quota Lines + +- Gemini model IDs or labels containing `gemini` and `pro` -> `Gemini Pro` +- Gemini model IDs or labels containing `gemini` and `flash` -> `Gemini Flash` +- Other non-Gemini model pools -> `Claude` + +When multiple buckets map to the same line, OpenUsage shows the lowest remaining fraction. + +## Notes + +Official Antigravity docs describe a shared agent harness and shared settings between the CLI and Antigravity 2.0. OpenUsage tracks Antigravity CLI separately because the CLI uses different state and auth paths. diff --git a/plugins/antigravity-cli/README.md b/plugins/antigravity-cli/README.md new file mode 100644 index 00000000..be474a1d --- /dev/null +++ b/plugins/antigravity-cli/README.md @@ -0,0 +1,15 @@ +# Antigravity CLI + +This provider reads Google Antigravity CLI (`agy`) usage from the Cloud Code quota APIs used by the CLI. It is separate from the Antigravity IDE and Gemini providers because `agy` stores state and auth differently. + +Authenticate with `agy` before enabling this provider: + +```sh +agy +``` + +OpenUsage reads non-secret context only from `~/.gemini/antigravity-cli/`. It does not read legacy Gemini OAuth files such as `~/.gemini/oauth_creds.json`. + +Authentication comes from the Antigravity CLI OS keyring login. On macOS, current CLI builds store it under keychain service `gemini` and account `antigravity`. The provider accepts raw tokens, JSON OAuth-style payloads, and `go-keyring-base64:` wrapped values when the keychain returns them. + +Official Antigravity docs describe a shared agent harness and shared settings between the CLI and Antigravity 2.0. OpenUsage tracks Antigravity CLI separately so the auth path and implementation remain clear. diff --git a/plugins/antigravity-cli/icon.svg b/plugins/antigravity-cli/icon.svg new file mode 100644 index 00000000..4794cc58 --- /dev/null +++ b/plugins/antigravity-cli/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/plugins/antigravity-cli/plugin.js b/plugins/antigravity-cli/plugin.js new file mode 100644 index 00000000..c81c684b --- /dev/null +++ b/plugins/antigravity-cli/plugin.js @@ -0,0 +1,326 @@ +(function () { + const PROVIDER_ID = "antigravity-cli" + const CLI_STATE_DIR = "~/.gemini/antigravity-cli" + const KEYCHAIN_SERVICE = "gemini" + const KEYCHAIN_ACCOUNT = "antigravity" + const LOGIN_MESSAGE = "Not logged in. Run `agy` and complete Google sign-in first." + const REQUEST_FAILED_MESSAGE = "Antigravity CLI quota request failed. Check your connection and try again." + const INVALID_RESPONSE_MESSAGE = "Antigravity CLI quota response was invalid. Try again later." + + const LOAD_CODE_ASSIST_URL = "https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" + const FETCH_MODELS_URL = "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels" + const RETRIEVE_QUOTA_URL = "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota" + const QUOTA_PERIOD_MS = 5 * 60 * 60 * 1000 + + const IDE_METADATA = { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + duetProject: "default", + } + + function trimString(value) { + return typeof value === "string" ? value.trim() : "" + } + + function decodeBase64(ctx, text) { + try { + return ctx.base64.decode(text) + } catch (e) { + return null + } + } + + function readKeychainValue(ctx) { + if (!ctx.host.keychain || typeof ctx.host.keychain.readGenericPassword !== "function") { + return null + } + + try { + var accountValue = ctx.host.keychain.readGenericPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) + if (accountValue) return accountValue + } catch (e) { + ctx.host.log.info("antigravity-cli account keychain read failed: " + String(e)) + } + return null + } + + function unwrapKeychainText(ctx, raw) { + var text = trimString(raw) + if (!text) return null + if (text.indexOf("go-keyring-base64:") === 0) { + var decoded = decodeBase64(ctx, text.slice("go-keyring-base64:".length)) + text = trimString(decoded) + } + return text || null + } + + function extractTokenFromObject(obj) { + if (!obj || typeof obj !== "object") return null + + var directKeys = [ + "access_token", + "accessToken", + "token", + "id_token", + "idToken", + "bearerToken", + "auth_token", + "authToken", + ] + for (var i = 0; i < directKeys.length; i += 1) { + var value = obj[directKeys[i]] + if (typeof value === "string" && value.trim()) return value.trim() + } + + var nestedKeys = ["tokens", "oauth", "oauth2", "credentials", "auth"] + for (var j = 0; j < nestedKeys.length; j += 1) { + var nested = extractTokenFromObject(obj[nestedKeys[j]]) + if (nested) return nested + } + + return null + } + + function extractAccessToken(ctx, raw) { + var text = unwrapKeychainText(ctx, raw) + if (!text) return null + + var parsed = ctx.util.tryParseJson(text) + if (typeof parsed === "string" && parsed.trim()) return parsed.trim() + if (parsed) { + var token = extractTokenFromObject(parsed) + if (token) return token + return null + } + + if (text.indexOf("Bearer ") === 0) return text.slice("Bearer ".length).trim() || null + return text + } + + function readNonSecretCliContext(ctx) { + var context = {} + try { + if (ctx.host.fs.exists(CLI_STATE_DIR)) { + context.hasStateDir = true + } + } catch (e) { + context.hasStateDir = false + } + return context + } + + function requestJson(ctx, url, token, body) { + var request = ctx.host.http && typeof ctx.host.http.request === "function" + ? ctx.host.http.request + : ctx.util.request + var resp + try { + resp = request({ + method: "POST", + url: url, + headers: { + Authorization: "Bearer " + token, + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": "agy", + }, + bodyText: JSON.stringify(body || {}), + timeoutMs: 15000, + }) + } catch (e) { + throw REQUEST_FAILED_MESSAGE + } + if (!resp || typeof resp.status !== "number" || !Number.isFinite(resp.status)) { + throw REQUEST_FAILED_MESSAGE + } + if (ctx.util.isAuthStatus(resp.status)) { + throw LOGIN_MESSAGE + } + if (resp.status < 200 || resp.status >= 300) { + throw "Antigravity CLI quota request failed (HTTP " + String(resp.status) + "). Try again later." + } + var data = ctx.util.tryParseJson(resp.bodyText) + if (!data || typeof data !== "object") { + throw INVALID_RESPONSE_MESSAGE + } + return data + } + + function readFirstStringDeep(value, keys) { + if (!value || typeof value !== "object") return null + for (var i = 0; i < keys.length; i += 1) { + var v = value[keys[i]] + if (typeof v === "string" && v.trim()) return v.trim() + } + var values = Object.values(value) + for (var j = 0; j < values.length; j += 1) { + var found = readFirstStringDeep(values[j], keys) + if (found) return found + } + return null + } + + function readPlan(loadCodeAssistData) { + var direct = loadCodeAssistData && loadCodeAssistData.userTier + if (direct && typeof direct.name === "string" && direct.name.trim()) return direct.name.trim() + var equivalent = readTierObjectName(loadCodeAssistData) + if (equivalent) return equivalent + return readFirstStringDeep(loadCodeAssistData, ["userTierName", "tierName", "planName"]) + } + + function readTierObjectName(value) { + if (!value || typeof value !== "object") return null + var tierKeys = ["userTier", "tier", "subscriptionTier", "plan"] + for (var i = 0; i < tierKeys.length; i += 1) { + var tier = value[tierKeys[i]] + if (tier && typeof tier === "object" && typeof tier.name === "string" && tier.name.trim()) { + return tier.name.trim() + } + } + var values = Object.values(value) + for (var j = 0; j < values.length; j += 1) { + var found = readTierObjectName(values[j]) + if (found) return found + } + return null + } + + function modelText(value) { + var parts = [] + if (!value || typeof value !== "object") return "" + var keys = ["label", "displayName", "name", "model", "modelId", "model_id", "id"] + for (var i = 0; i < keys.length; i += 1) { + if (typeof value[keys[i]] === "string") parts.push(value[keys[i]]) + } + return parts.join(" ").toLowerCase() + } + + function poolForText(text) { + var lower = String(text || "").toLowerCase() + if (lower.indexOf("gemini") !== -1 && lower.indexOf("pro") !== -1) return "Gemini Pro" + if (lower.indexOf("gemini") !== -1 && lower.indexOf("flash") !== -1) return "Gemini Flash" + if (lower.indexOf("gemini") !== -1) return null + if (lower) return "Claude" + return null + } + + function pushBucket(out, pool, remainingFraction, resetTime) { + if (!pool || !Number.isFinite(remainingFraction)) return + out.push({ + pool: pool, + remainingFraction: remainingFraction, + resetTime: resetTime || null, + }) + } + + function collectFetchModelBuckets(value, out) { + if (Array.isArray(value)) { + for (var i = 0; i < value.length; i += 1) collectFetchModelBuckets(value[i], out) + return + } + if (!value || typeof value !== "object") return + if (value.isInternal) return + + var quota = value.quotaInfo || value.quota || null + var remaining = quota && typeof quota.remainingFraction === "number" + ? quota.remainingFraction + : typeof value.remainingFraction === "number" + ? value.remainingFraction + : null + if (remaining !== null) { + var text = modelText(value) + if (text) { + pushBucket(out, poolForText(text), remaining, (quota && (quota.resetTime || quota.reset_time)) || value.resetTime || value.reset_time) + } + } + + var children = Object.values(value) + for (var j = 0; j < children.length; j += 1) collectFetchModelBuckets(children[j], out) + } + + function collectQuotaBuckets(value, out, inheritedText) { + if (Array.isArray(value)) { + for (var i = 0; i < value.length; i += 1) collectQuotaBuckets(value[i], out, inheritedText) + return + } + if (!value || typeof value !== "object") return + + var text = (inheritedText || "") + " " + modelText(value) + if (typeof value.remainingFraction === "number") { + pushBucket(out, poolForText(text), value.remainingFraction, value.resetTime || value.reset_time) + } + + var entries = Object.keys(value) + for (var j = 0; j < entries.length; j += 1) { + var key = entries[j] + collectQuotaBuckets(value[key], out, text + " " + key) + } + } + + function dedupeBuckets(buckets) { + var byPool = {} + for (var i = 0; i < buckets.length; i += 1) { + var bucket = buckets[i] + if (!byPool[bucket.pool] || bucket.remainingFraction < byPool[bucket.pool].remainingFraction) { + byPool[bucket.pool] = bucket + } + } + return byPool + } + + function lineForBucket(ctx, label, bucket) { + var clamped = Math.max(0, Math.min(1, Number(bucket.remainingFraction))) + var opts = { + label: label, + used: Math.round((1 - clamped) * 100), + limit: 100, + format: { kind: "percent" }, + periodDurationMs: QUOTA_PERIOD_MS, + } + if (bucket.resetTime) { + var iso = ctx.util.toIso ? ctx.util.toIso(bucket.resetTime) : bucket.resetTime + if (iso) opts.resetsAt = iso + } + return ctx.line.progress(opts) + } + + function buildLines(ctx, buckets) { + var byPool = dedupeBuckets(buckets) + var order = ["Gemini Pro", "Gemini Flash", "Claude"] + var lines = [] + for (var i = 0; i < order.length; i += 1) { + var label = order[i] + if (byPool[label]) lines.push(lineForBucket(ctx, label, byPool[label])) + } + return lines + } + + function probe(ctx) { + readNonSecretCliContext(ctx) + + var token = extractAccessToken(ctx, readKeychainValue(ctx)) + if (!token) throw LOGIN_MESSAGE + + var loadData = requestJson(ctx, LOAD_CODE_ASSIST_URL, token, { metadata: IDE_METADATA }) + var plan = readPlan(loadData) + + var fetchData = requestJson(ctx, FETCH_MODELS_URL, token, { metadata: IDE_METADATA }) + var buckets = [] + collectFetchModelBuckets(fetchData, buckets) + + if (buckets.length === 0) { + var quotaData = requestJson(ctx, RETRIEVE_QUOTA_URL, token, { metadata: IDE_METADATA }) + collectQuotaBuckets(quotaData, buckets, "") + } + + var lines = buildLines(ctx, buckets) + if (lines.length === 0) { + lines.push(ctx.line.badge({ label: "Status", text: "No quota data", color: "#a3a3a3" })) + } + + return { plan: plan || undefined, lines: lines } + } + + globalThis.__openusage_plugin = { id: PROVIDER_ID, probe: probe } +})() diff --git a/plugins/antigravity-cli/plugin.json b/plugins/antigravity-cli/plugin.json new file mode 100644 index 00000000..5bf2ba67 --- /dev/null +++ b/plugins/antigravity-cli/plugin.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "antigravity-cli", + "name": "Antigravity CLI", + "version": "0.0.1", + "entry": "plugin.js", + "icon": "icon.svg", + "brandColor": "#4285F4", + "lines": [ + { "type": "progress", "label": "Gemini Pro", "scope": "overview", "primaryOrder": 1 }, + { "type": "progress", "label": "Gemini Flash", "scope": "overview", "primaryOrder": 2 }, + { "type": "progress", "label": "Claude", "scope": "overview", "primaryOrder": 3 } + ] +} diff --git a/plugins/antigravity-cli/plugin.test.js b/plugins/antigravity-cli/plugin.test.js new file mode 100644 index 00000000..77e766dd --- /dev/null +++ b/plugins/antigravity-cli/plugin.test.js @@ -0,0 +1,284 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { makeCtx } from "../test-helpers.js" + +const LOAD_CODE_ASSIST_URL = "https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" +const FETCH_MODELS_URL = "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels" +const RETRIEVE_QUOTA_URL = "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota" +const LOGIN_MESSAGE = "Not logged in. Run `agy` and complete Google sign-in first." +const REQUEST_FAILED_MESSAGE = "Antigravity CLI quota request failed. Check your connection and try again." +const INVALID_RESPONSE_MESSAGE = "Antigravity CLI quota response was invalid. Try again later." + +const loadPlugin = async () => { + await import("./plugin.js") + return globalThis.__openusage_plugin +} + +function setKeychain(ctx, value) { + ctx.host.keychain.readGenericPassword.mockImplementation((service, account) => { + if (service === "gemini" && account === "antigravity") return value + return null + }) +} + +function mockResponses(ctx, responses) { + ctx.host.http.request.mockImplementation((opts) => { + const url = String(opts.url) + if (!responses[url]) throw new Error("unexpected url: " + url) + return responses[url](opts) + }) +} + +function json(status, body) { + return { status, bodyText: JSON.stringify(body) } +} + +describe("antigravity-cli plugin", () => { + beforeEach(() => { + delete globalThis.__openusage_plugin + vi.resetModules() + }) + + it("loads a raw keychain bearer token and parses fetchAvailableModels quotaInfo", async () => { + const ctx = makeCtx() + setKeychain(ctx, "Bearer raw-token") + mockResponses(ctx, { + [LOAD_CODE_ASSIST_URL]: () => json(200, { userTier: { name: "Google AI Ultra" } }), + [FETCH_MODELS_URL]: (opts) => { + expect(opts.headers.Authorization).toBe("Bearer raw-token") + return json(200, { + models: { + proHigh: { + displayName: "Gemini 3 Pro (High)", + model: "gemini-3-pro", + quotaInfo: { remainingFraction: 0.4, resetTime: "2026-05-21T00:00:00Z" }, + }, + proLow: { + displayName: "Gemini 3 Pro (Low)", + model: "gemini-3-pro-low", + quotaInfo: { remainingFraction: 0.9 }, + }, + flash: { + displayName: "Gemini Flash", + model: "gemini-3-flash", + quotaInfo: { remainingFraction: 0.8 }, + }, + claude: { + displayName: "Claude Sonnet", + model: "claude-sonnet", + quotaInfo: { remainingFraction: 0.25 }, + }, + }, + }) + }, + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(plugin.id).toBe("antigravity-cli") + expect(result.plan).toBe("Google AI Ultra") + expect(result.lines.map((line) => line.label)).toEqual(["Gemini Pro", "Gemini Flash", "Claude"]) + expect(result.lines.find((line) => line.label === "Gemini Pro").used).toBe(60) + expect(result.lines.find((line) => line.label === "Gemini Flash").used).toBe(20) + expect(result.lines.find((line) => line.label === "Claude").used).toBe(75) + expect(ctx.host.http.request).toHaveBeenCalledTimes(2) + }) + + it("loads an OAuth-style JSON keychain token", async () => { + const ctx = makeCtx() + setKeychain(ctx, JSON.stringify({ access_token: "json-token", refresh_token: "refresh" })) + mockResponses(ctx, { + [LOAD_CODE_ASSIST_URL]: () => json(200, {}), + [FETCH_MODELS_URL]: (opts) => { + expect(opts.headers.Authorization).toBe("Bearer json-token") + return json(200, { + models: [{ label: "Gemini Pro", quotaInfo: { remainingFraction: 1 } }], + }) + }, + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(result.plan).toBeUndefined() + expect(result.lines.find((line) => line.label === "Gemini Pro").used).toBe(0) + }) + + it("loads a go-keyring-base64 wrapped JSON token", async () => { + const ctx = makeCtx() + const encoded = ctx.base64.encode(JSON.stringify({ tokens: { accessToken: "wrapped-token" } })) + setKeychain(ctx, "go-keyring-base64:" + encoded) + mockResponses(ctx, { + [LOAD_CODE_ASSIST_URL]: () => json(200, {}), + [FETCH_MODELS_URL]: (opts) => { + expect(opts.headers.Authorization).toBe("Bearer wrapped-token") + return json(200, { + models: [{ label: "Gemini Flash", model: "gemini-flash", quotaInfo: { remainingFraction: 0.55 } }], + }) + }, + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(result.lines.find((line) => line.label === "Gemini Flash").used).toBe(45) + }) + + it("throws agy login instruction when keychain entry is missing", async () => { + const ctx = makeCtx() + setKeychain(ctx, null) + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow(LOGIN_MESSAGE) + expect(ctx.host.http.request).not.toHaveBeenCalled() + }) + + it("does not fall back to service-wide Gemini keychain credentials", async () => { + const ctx = makeCtx() + ctx.host.keychain.readGenericPassword.mockImplementation((service, account) => { + if (service === "gemini" && account === "antigravity") return null + if (service === "gemini" && account === undefined) return "wrong-gemini-token" + return null + }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow(LOGIN_MESSAGE) + expect(ctx.host.keychain.readGenericPassword).toHaveBeenCalledTimes(1) + expect(ctx.host.keychain.readGenericPassword).toHaveBeenCalledWith("gemini", "antigravity") + expect(ctx.host.http.request).not.toHaveBeenCalled() + }) + + it("rejects JSON keychain payloads without an access token", async () => { + const ctx = makeCtx() + setKeychain(ctx, JSON.stringify({ refresh_token: "refresh-only" })) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow(LOGIN_MESSAGE) + expect(ctx.host.http.request).not.toHaveBeenCalled() + }) + + it("falls back to retrieveUserQuota nested buckets when fetchAvailableModels lacks quota", async () => { + const ctx = makeCtx() + setKeychain(ctx, "token") + mockResponses(ctx, { + [LOAD_CODE_ASSIST_URL]: () => json(200, { user: { tier: { name: "Ignored" } } }), + [FETCH_MODELS_URL]: () => json(200, { models: [{ displayName: "Gemini Pro", model: "gemini-pro" }] }), + [RETRIEVE_QUOTA_URL]: () => json(200, { + quota: { + pools: { + gemini_pro: { + buckets: [ + { modelId: "gemini-3-pro", remainingFraction: 0.7 }, + { modelId: "gemini-3-pro-high", remainingFraction: 0.2 }, + ], + }, + gemini_flash: { + buckets: [{ model_id: "gemini-3-flash", remainingFraction: 0.6 }], + }, + third_party: { + claude: [{ modelId: "claude-sonnet", remainingFraction: 0.3 }], + }, + }, + }, + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.lines.find((line) => line.label === "Gemini Pro").used).toBe(80) + expect(result.lines.find((line) => line.label === "Gemini Flash").used).toBe(40) + expect(result.lines.find((line) => line.label === "Claude").used).toBe(70) + expect(ctx.host.http.request).toHaveBeenCalledTimes(3) + }) + + it("returns no quota badge for missing or empty quota responses", async () => { + const ctx = makeCtx() + setKeychain(ctx, "token") + mockResponses(ctx, { + [LOAD_CODE_ASSIST_URL]: () => json(200, {}), + [FETCH_MODELS_URL]: () => json(200, { models: [] }), + [RETRIEVE_QUOTA_URL]: () => json(200, { quota: { pools: [] } }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(result.lines).toEqual([expect.objectContaining({ type: "badge", label: "Status", text: "No quota data" })]) + }) + + it("throws agy login instruction on auth failure", async () => { + const ctx = makeCtx() + setKeychain(ctx, "expired") + mockResponses(ctx, { + [LOAD_CODE_ASSIST_URL]: () => ({ status: 401, bodyText: "{}" }), + }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow(LOGIN_MESSAGE) + }) + + it("throws a clear request failure on transport errors", async () => { + const ctx = makeCtx() + setKeychain(ctx, "token") + ctx.host.http.request.mockImplementation(() => { + throw new Error("network down") + }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow(REQUEST_FAILED_MESSAGE) + }) + + it("throws a clear request failure for malformed HTTP responses", async () => { + const ctx = makeCtx() + setKeychain(ctx, "token") + mockResponses(ctx, { + [LOAD_CODE_ASSIST_URL]: () => ({ bodyText: "{}" }), + }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow(REQUEST_FAILED_MESSAGE) + }) + + it("throws a clear invalid response error for non-JSON response bodies", async () => { + const ctx = makeCtx() + setKeychain(ctx, "token") + mockResponses(ctx, { + [LOAD_CODE_ASSIST_URL]: () => ({ status: 200, bodyText: "login" }), + }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow(INVALID_RESPONSE_MESSAGE) + }) + + it("does not read legacy Gemini OAuth files", async () => { + const ctx = makeCtx() + const existsCalls = [] + const readCalls = [] + ctx.host.fs.exists = (path) => { + existsCalls.push(path) + if ( + path === "~/.gemini/settings.json" || + path === "~/.gemini/oauth_creds.json" || + String(path).includes("@google/gemini-cli") + ) { + throw new Error("legacy Gemini path touched: " + path) + } + return path === "~/.gemini/antigravity-cli" + } + ctx.host.fs.readText = (path) => { + readCalls.push(path) + throw new Error("unexpected readText: " + path) + } + setKeychain(ctx, "token") + mockResponses(ctx, { + [LOAD_CODE_ASSIST_URL]: () => json(200, {}), + [FETCH_MODELS_URL]: () => json(200, { + models: [{ label: "Gemini Pro", model: "gemini-pro", quotaInfo: { remainingFraction: 0.5 } }], + }), + }) + + const plugin = await loadPlugin() + plugin.probe(ctx) + + expect(readCalls).not.toContain("~/.gemini/oauth_creds.json") + expect(existsCalls).toContain("~/.gemini/antigravity-cli") + expect(ctx.host.keychain.readGenericPassword).toHaveBeenCalledWith("gemini", "antigravity") + }) +}) diff --git a/src-tauri/src/plugin_engine/host_api.rs b/src-tauri/src/plugin_engine/host_api.rs index a39ac09d..5d84a81d 100644 --- a/src-tauri/src/plugin_engine/host_api.rs +++ b/src-tauri/src/plugin_engine/host_api.rs @@ -2201,16 +2201,42 @@ fn inject_keychain<'js>( "readGenericPassword", Function::new( ctx.clone(), - move |ctx_inner: Ctx<'_>, service: String| -> rquickjs::Result { + move |ctx_inner: Ctx<'_>, + service: String, + account: Option| + -> rquickjs::Result { if !cfg!(target_os = "macos") { return Err(Exception::throw_message( &ctx_inner, "keychain API is only supported on macOS", )); } - log::info!("[plugin:{}] keychain read: service={}", pid_read, service); + let account = account.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }); + let redacted_account = account.as_deref().map(redact_value); + if let Some(ref redacted) = redacted_account { + log::info!( + "[plugin:{}] keychain read: service={}, account={}", + pid_read, + service, + redacted + ); + } else { + log::info!("[plugin:{}] keychain read: service={}", pid_read, service); + } + let args = if let Some(ref account) = account { + keychain_find_generic_password_args_for_account(&service, account) + } else { + keychain_find_generic_password_args(&service) + }; let output = std::process::Command::new("security") - .args(keychain_find_generic_password_args(&service)) + .args(args) .output() .map_err(|e| { Exception::throw_message( @@ -2222,23 +2248,42 @@ fn inject_keychain<'js>( if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); let first_line = stderr.lines().next().unwrap_or("").trim(); - log::warn!( - "[plugin:{}] keychain read miss: service={}, error={}", - pid_read, - service, - first_line - ); + if let Some(ref redacted) = redacted_account { + log::warn!( + "[plugin:{}] keychain read miss: service={}, account={}, error={}", + pid_read, + service, + redacted, + first_line + ); + } else { + log::warn!( + "[plugin:{}] keychain read miss: service={}, error={}", + pid_read, + service, + first_line + ); + } return Err(Exception::throw_message( &ctx_inner, &format!("keychain item not found: {}", first_line), )); } - log::info!( - "[plugin:{}] keychain read hit: service={}", - pid_read, - service - ); + if let Some(ref redacted) = redacted_account { + log::info!( + "[plugin:{}] keychain read hit: service={}, account={}", + pid_read, + service, + redacted + ); + } else { + log::info!( + "[plugin:{}] keychain read hit: service={}", + pid_read, + service + ); + } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) }, )?,