Skip to content
Merged
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ This file holds the project facts that are easy to get wrong.
- `plugins/<id>/plugin.js` runs in **a fresh QuickJS runtime per probe**: module-level variables
do NOT survive between refreshes (vitest reuses one instance, so tests hide this). Persist
anything cross-probe under `ctx.app.pluginDataDir` (see claude `live-plan.json`).
- A new install starts with every provider off and probes them all once (`use-provider-detection.ts`): a card
turns on unless its error is one `isNotSetUpError` knows. So a plugin with nothing set up must throw such an
error without a request (`plugins/not-set-up.test.js` checks every shipped plugin). Copilot needs a Copilot plan
in its answer (its login is the GitHub CLI's, which any GitHub user has). The store key `providerDetectionPending`
is set before the first settings save and cleared once every provider is decided, so a start cut short detects
again. A plugin whose login is rejected must not throw a "not set up" message (use "Session expired …").
- Every line label a plugin can emit on the overview card must be declared `scope: "overview"` in
`plugin.json`, or that account type gets a blank card (the card filters by manifest labels).
- Response bodies are logged (first 500 chars) after `redact_body` in
Expand Down
2 changes: 1 addition & 1 deletion docs/providers/antigravity.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,4 +280,4 @@ The Cloud Code model set is a superset of the LS model set. The LS returns only
4. Try unexpired SQLite/cached access tokens: the plan and project from `loadCodeAssist`, then `retrieveUserQuotaSummary` (then `fetchAvailableModels` for builds without it).
5. Refresh SQLite refresh tokens only after auth failure or when no access token exists.
6. Read `agy` keychain token from service `gemini`, account `antigravity`, then call `loadCodeAssist` and `retrieveUserQuota`.
7. If all strategies fail: error "Start Antigravity or run `agy` and try again."
7. If all strategies fail: with no login found, error "Start Antigravity or run `agy` and try again."; when every login found (SQLite and agy) was refused: Cloud Code or Google's token refresh refused (400/401/403, and no refresh worked), or an expired one with nothing to refresh it, "Session expired. Run `agy` to log in again."; otherwise (5xx, timeouts, no models, a refresh that failed or worked but Cloud Code then didn't answer), "Antigravity quota data unavailable. Try again later." Only the first counts as "nothing set up" for a new install's detection.
2 changes: 1 addition & 1 deletion docs/providers/devin.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ Devin currently returns no `weeklyQuotaRemainingPercent` field in the observed p
2. Read Devin app SQLite auth if CLI credentials are missing or different.
3. POST `GetUserStatus` with `ideName: "devin"`.
4. Build weekly quota and extra balance lines. Show daily quota only if Devin does not mark it hidden.
5. If auth fails, show: `Run devin auth login or sign in to Devin and try again.`
5. With no login found, show: `Run devin auth login or sign in to Devin and try again.` When every login found is rejected (401/403), show: ``Session expired. Sign in via Devin app or run `devin auth login`.`` (a new install's detection reads only the first as "nothing set up").

## WSL

Expand Down
33 changes: 30 additions & 3 deletions plugins/antigravity/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@

// --- Google OAuth token refresh ---

// The new access token; false when Google refused the refresh token (the login needs a new sign-in);
// null when it couldn't be checked (network, 5xx, 429, a malformed answer).
function refreshAccessToken(ctx, refreshTokenValue, cacheFile) {
if (!refreshTokenValue) {
ctx.host.log.warn("refresh skipped: no refresh token")
Expand All @@ -167,7 +169,8 @@
})
if (resp.status < 200 || resp.status >= 300) {
ctx.host.log.warn("Google OAuth refresh returned status: " + resp.status)
return null
// 400 is invalid_grant: the refresh token was revoked or expired.
return resp.status === 400 || ctx.util.isAuthStatus(resp.status) ? false : null
}
var body = ctx.util.tryParseJson(resp.bodyText)
if (!body || !body.access_token) {
Expand Down Expand Up @@ -1064,6 +1067,8 @@
}
var cached = loadCachedToken(ctx, agy.refreshToken ? [agy.refreshToken] : [], AGY_TOKEN_CACHE)
if (cached && tokens.indexOf(cached) === -1) tokens.push(cached)
// An expired login with nothing to refresh it reads as refused: probe then says "Session expired".
if (tokens.length === 0 && !agy.refreshToken) return { _authFailed: true }

var sawAuthFailure = false
for (var i = 0; i < tokens.length; i++) {
Expand All @@ -1073,6 +1078,7 @@
}
if ((sawAuthFailure || tokens.length === 0) && agy.refreshToken) {
var refreshed = refreshAccessToken(ctx, agy.refreshToken, AGY_TOKEN_CACHE)
if (refreshed === false) return { _authFailed: true }
if (refreshed) return probeAgyCloudCode(ctx, refreshed)
}
return sawAuthFailure ? { _authFailed: true } : null
Expand Down Expand Up @@ -1121,10 +1127,16 @@
// probeCloudCodeToken returns null for transient failures (5xx/timeouts); without this
// guard a Cloud Code incident would trigger a Google OAuth refresh every probe cycle
// instead of ~once per token lifetime — risking refresh-token throttling or rotation.
var refreshWorked = false
var refreshRefused = false
var refreshUnchecked = false
if (!ccResult && (sawAuthFailure || tokens.length === 0)) {
for (var k = 0; k < refreshTokens.length; k++) {
var refreshed = refreshAccessToken(ctx, refreshTokens[k], DB_TOKEN_CACHE)
if (refreshed === false) refreshRefused = true
else if (!refreshed) refreshUnchecked = true
if (!refreshed) continue
refreshWorked = true
var refreshedResult = probeCloudCodeToken(ctx, refreshed)
if (refreshedResult && !refreshedResult._authFailed) {
ccResult = refreshedResult
Expand All @@ -1134,18 +1146,33 @@
}
}

// Refused: Cloud Code turned a refreshed token down, or a token got a 401 or Google refused the refresh
// and no refresh worked, or an expired login has nothing to refresh it. A refused refresh next to one
// that couldn't be checked (a stale second database) isn't a refusal.
var sqliteRefused = (ccResult && ccResult._authFailed) ||
(!refreshWorked && (sawAuthFailure || (refreshRefused && !refreshUnchecked) || (tokens.length === 0 && refreshTokens.length === 0)))

var agyRefused = false
if (!ccResult || ccResult._authFailed) {
var agy = loadAgyKeychainToken(ctx)
if (agy) {
var agyResult = probeAgyKeychain(ctx, agy, nowSec)
if (agyResult && !agyResult._authFailed) return agyResult
if (agyResult && agyResult._authFailed) ccResult = agyResult
agyRefused = Boolean(agyResult)
}
}

if (ccResult && !ccResult._authFailed) return ccResult

throw LOGIN_MESSAGE
// LOGIN_MESSAGE says no login was found; a saved one, even expired, is the user's, and a new install
// shows its card (use-provider-detection.ts).
if (dbTokenCandidates.length === 0 && !agy) throw LOGIN_MESSAGE
// "Session expired" asks the user to sign in again (useLoginRecovery), so every login found must be
// refused; one that couldn't be checked (network, 5xx, even after a refresh) makes it "unavailable".
if ((dbTokenCandidates.length === 0 || sqliteRefused) && (!agy || agyRefused)) {
throw "Session expired. Run `agy` to log in again."
}
throw "Antigravity quota data unavailable. Try again later."
}

globalThis.__openusage_plugin = { id: "antigravity", probe: probe }
Expand Down
161 changes: 155 additions & 6 deletions plugins/antigravity/plugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -899,7 +899,7 @@ describe("antigravity plugin", () => {
})

const plugin = await loadPlugin()
expect(() => plugin.probe(ctx)).toThrow(LOGIN_MESSAGE)
expect(() => plugin.probe(ctx)).toThrow("Session expired. Run `agy` to log in again.")
})

it("Cloud Code tries multiple base URLs", async () => {
Expand Down Expand Up @@ -1147,7 +1147,7 @@ describe("antigravity plugin", () => {
})

const plugin = await loadPlugin()
expect(() => plugin.probe(ctx)).toThrow(LOGIN_MESSAGE)
expect(() => plugin.probe(ctx)).toThrow("Session expired. Run `agy` to log in again.")
})

it("tries DB token first, then cached on auth failure", async () => {
Expand Down Expand Up @@ -1659,7 +1659,7 @@ describe("antigravity plugin", () => {
})

const plugin = await loadPlugin()
expect(() => plugin.probe(ctx)).toThrow(LOGIN_MESSAGE)
expect(() => plugin.probe(ctx)).toThrow("Antigravity quota data unavailable. Try again later.")
})

it("handles refresh response missing access_token", async () => {
Expand All @@ -1682,7 +1682,7 @@ describe("antigravity plugin", () => {
})

const plugin = await loadPlugin()
expect(() => plugin.probe(ctx)).toThrow(LOGIN_MESSAGE)
expect(() => plugin.probe(ctx)).toThrow("Session expired. Run `agy` to log in again.")
expect(oauthCalls).toBe(1)
})

Expand Down Expand Up @@ -1885,7 +1885,7 @@ describe("antigravity plugin", () => {
})

const plugin = await loadPlugin()
expect(() => plugin.probe(ctx)).toThrow(LOGIN_MESSAGE)
expect(() => plugin.probe(ctx)).toThrow("Antigravity quota data unavailable. Try again later.")
expect(refreshCalls).toBe(0)
})

Expand All @@ -1911,10 +1911,159 @@ describe("antigravity plugin", () => {
})

const plugin = await loadPlugin()
expect(() => plugin.probe(ctx)).toThrow(LOGIN_MESSAGE)
expect(() => plugin.probe(ctx)).toThrow("Antigravity quota data unavailable. Try again later.")
expect(refreshCalls).toBe(0)
})

it("says unavailable, not expired, when Cloud Code fails after a refresh that worked", async () => {
const ctx = makeCtx()
const futureExpiry = Math.floor(Date.now() / 1000) + 3600
setupSqliteMock(ctx, makeOAuthSentinelB64(ctx, { accessToken: "ya29.old", refreshToken: "1//refresh", expirySeconds: futureExpiry }))
ctx.host.ls.discover.mockReturnValue(null)
ctx.host.http.request.mockImplementation((opts) => {
const url = String(opts.url)
if (url.includes("oauth2.googleapis.com")) {
return { status: 200, bodyText: JSON.stringify({ access_token: "ya29.new", expires_in: 3600 }) }
}
if (url.includes("fetchAvailableModels") && opts.headers.Authorization === "Bearer ya29.old") {
return { status: 401, bodyText: "" }
}
return { status: 503, bodyText: "" }
})

const plugin = await loadPlugin()
expect(() => plugin.probe(ctx)).toThrow("Antigravity quota data unavailable. Try again later.")
})

it("says unavailable, not expired, when the agy login's Cloud Code calls fail", async () => {
const ctx = makeCtx()
setupSqliteMock(ctx, null)
ctx.host.ls.discover.mockReturnValue(null)
ctx.host.keychain.readGenericPassword.mockImplementation((service, account) => {
if (service === "gemini" && account === "antigravity") {
return "go-keyring-base64:" + ctx.base64.encode(JSON.stringify({
tokens: { access_token: "agy-keychain-token" },
}))
}
throw new Error("unexpected keychain lookup")
})
ctx.host.http.request.mockReturnValue({ status: 503, bodyText: "" })

const plugin = await loadPlugin()
expect(() => plugin.probe(ctx)).toThrow("Antigravity quota data unavailable. Try again later.")
})

it("says the session expired for an expired agy login with nothing to refresh it", async () => {
const ctx = makeCtx()
setupSqliteMock(ctx, null)
ctx.host.ls.discover.mockReturnValue(null)
ctx.host.keychain.readGenericPassword.mockReturnValue(JSON.stringify({
token: { access_token: "ya29.expired", expiry: new Date(Date.now() - 3600000).toISOString() },
}))

const plugin = await loadPlugin()
expect(() => plugin.probe(ctx)).toThrow("Session expired. Run `agy` to log in again.")
expect(ctx.host.http.request).not.toHaveBeenCalled()
})

it("says the session expired when Google refuses an expired agy login's refresh token", async () => {
const ctx = makeCtx()
setupSqliteMock(ctx, null)
ctx.host.ls.discover.mockReturnValue(null)
ctx.host.keychain.readGenericPassword.mockReturnValue(JSON.stringify({
token: {
access_token: "ya29.expired",
refresh_token: "1//revoked",
expiry: new Date(Date.now() - 3600000).toISOString(),
},
}))
ctx.host.http.request.mockReturnValue({ status: 400, bodyText: '{"error":"invalid_grant"}' })

const plugin = await loadPlugin()
expect(() => plugin.probe(ctx)).toThrow("Session expired. Run `agy` to log in again.")
})

it("says the session expired when Google refuses an expired SQLite token's refresh token", async () => {
const ctx = makeCtx()
const pastExpiry = Math.floor(Date.now() / 1000) - 3600
setupSqliteMock(ctx, makeOAuthSentinelB64(ctx, { accessToken: "ya29.expired", refreshToken: "1//revoked", expirySeconds: pastExpiry }))
ctx.host.ls.discover.mockReturnValue(null)
ctx.host.http.request.mockReturnValue({ status: 400, bodyText: '{"error":"invalid_grant"}' })

const plugin = await loadPlugin()
expect(() => plugin.probe(ctx)).toThrow("Session expired. Run `agy` to log in again.")
})

it("says unavailable when the SQLite login is refused but the agy login can't be checked", async () => {
const ctx = makeCtx()
const pastExpiry = Math.floor(Date.now() / 1000) - 3600
setupSqliteMock(ctx, makeOAuthSentinelB64(ctx, { accessToken: "ya29.expired", refreshToken: "1//revoked", expirySeconds: pastExpiry }))
ctx.host.ls.discover.mockReturnValue(null)
ctx.host.keychain.readGenericPassword.mockReturnValue(JSON.stringify({ token: { access_token: "agy-keychain-token" } }))
ctx.host.http.request.mockImplementation((opts) => {
if (String(opts.url).includes("oauth2.googleapis.com")) return { status: 400, bodyText: '{"error":"invalid_grant"}' }
return { status: 503, bodyText: "" }
})

const plugin = await loadPlugin()
expect(() => plugin.probe(ctx)).toThrow("Antigravity quota data unavailable. Try again later.")
})

it("says unavailable when the agy login is refused but the SQLite login can't be checked", async () => {
const ctx = makeCtx()
const futureExpiry = Math.floor(Date.now() / 1000) + 3600
setupSqliteMock(ctx, makeOAuthSentinelB64(ctx, { accessToken: "ya29.valid", refreshToken: "1//refresh", expirySeconds: futureExpiry }))
ctx.host.ls.discover.mockReturnValue(null)
ctx.host.keychain.readGenericPassword.mockReturnValue(JSON.stringify({
token: { access_token: "ya29.agy-expired", expiry: new Date(Date.now() - 3600000).toISOString() },
}))
ctx.host.http.request.mockReturnValue({ status: 503, bodyText: "" })

const plugin = await loadPlugin()
expect(() => plugin.probe(ctx)).toThrow("Antigravity quota data unavailable. Try again later.")
})

it("says unavailable when one database's refresh is refused and the other's can't reach Google", async () => {
const ctx = makeCtx()
const pastExpiry = Math.floor(Date.now() / 1000) - 3600
setupSqliteByPath(ctx, {
[STATE_DB_V2]: makeOAuthSentinelB64(ctx, { accessToken: "ya29.v2", refreshToken: "1//v2-current", expirySeconds: pastExpiry }),
[STATE_DB_V1]: makeOAuthSentinelB64(ctx, { accessToken: "ya29.v1", refreshToken: "1//v1-revoked", expirySeconds: pastExpiry }),
})
ctx.host.ls.discover.mockReturnValue(null)
ctx.host.http.request.mockImplementation((opts) => {
if (String(opts.bodyText).includes(encodeURIComponent("1//v1-revoked"))) {
return { status: 400, bodyText: '{"error":"invalid_grant"}' }
}
return { status: 503, bodyText: "" }
})

const plugin = await loadPlugin()
expect(() => plugin.probe(ctx)).toThrow("Antigravity quota data unavailable. Try again later.")
})

it("says unavailable when the refresh of an expired SQLite token can't reach Google", async () => {
const ctx = makeCtx()
const pastExpiry = Math.floor(Date.now() / 1000) - 3600
setupSqliteMock(ctx, makeOAuthSentinelB64(ctx, { accessToken: "ya29.expired", refreshToken: "1//refresh", expirySeconds: pastExpiry }))
ctx.host.ls.discover.mockReturnValue(null)
ctx.host.http.request.mockReturnValue({ status: 503, bodyText: "" })

const plugin = await loadPlugin()
expect(() => plugin.probe(ctx)).toThrow("Antigravity quota data unavailable. Try again later.")
})

it("says the session expired for an expired SQLite token with nothing to refresh it", async () => {
const ctx = makeCtx()
const pastExpiry = Math.floor(Date.now() / 1000) - 3600
setupSqliteMock(ctx, makeOAuthSentinelB64(ctx, { accessToken: "ya29.expired", expirySeconds: pastExpiry }))
ctx.host.ls.discover.mockReturnValue(null)

const plugin = await loadPlugin()
expect(() => plugin.probe(ctx)).toThrow("Session expired. Run `agy` to log in again.")
expect(ctx.host.http.request).not.toHaveBeenCalled()
})

// --- Regression tests: RetrieveUserQuotaSummary (merged Gemini pool + weekly limits) ---

const SESSION_MS = 5 * 60 * 60 * 1000
Expand Down
2 changes: 1 addition & 1 deletion plugins/cursor/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -720,7 +720,7 @@
accessToken = refreshed
} else if (!accessToken) {
ctx.host.log.error("refresh failed and no access token available")
throw "Not logged in. " + LOGIN_HINT
throw "Session expired. " + LOGIN_HINT
}
}

Expand Down
3 changes: 2 additions & 1 deletion plugins/cursor/plugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1543,7 +1543,8 @@ describe("cursor plugin", () => {
})

const plugin = await loadPlugin()
expect(() => plugin.probe(ctx)).toThrow("Not logged in")
// A refresh token is a login the user has: not "Not logged in", which means nothing is set up.
expect(() => plugin.probe(ctx)).toThrow("Session expired. Sign in via Cursor app or run `agent login`.")
})

it("throws token expired when usage remains unauthorized after refresh retry", async () => {
Expand Down
3 changes: 2 additions & 1 deletion plugins/devin/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,8 @@
if (appAttempt.authFailure) sawAuthFailure = true
}

if (sawAuthFailure) throw LOGIN_HINT
// Not LOGIN_HINT: that says nothing is set up, and a new install would leave the card off.
if (sawAuthFailure) throw "Session expired. Sign in via Devin app or run `devin auth login`."
if (sawApiKey) throw QUOTA_HINT
throw LOGIN_HINT
}
Expand Down
9 changes: 9 additions & 0 deletions plugins/devin/plugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,15 @@ describe("devin plugin", () => {
])
})

it("says the session expired when every login is rejected, not that nothing is set up", async () => {
const ctx = makeCtx()
ctx.host.fs.writeText(CREDENTIALS_PATH, makeCredentialsToml())
ctx.host.http.request.mockReturnValue({ status: 401, bodyText: "{}" })
const plugin = await loadPlugin()

expect(() => plugin.probe(ctx)).toThrow("Session expired. Sign in via Devin app or run `devin auth login`.")
})

it("throws the login hint when no auth source is available", async () => {
const ctx = makeCtx()
const plugin = await loadPlugin()
Expand Down
5 changes: 4 additions & 1 deletion plugins/kimi/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,10 @@
if (refreshed) {
accessToken = refreshed
} else if (!accessToken) {
throw "Not logged in. Run `kimi login` to authenticate."
// A refresh token that no longer works is a login the user has (a new install shows its card).
throw creds.refresh_token
? "Session expired. Run `kimi login` to authenticate."
: "Not logged in. Run `kimi login` to authenticate."
}
}

Expand Down
5 changes: 3 additions & 2 deletions plugins/kimi/plugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ describe("kimi plugin", () => {
expect(() => plugin.probe(ctx)).toThrow("Usage request failed. Check your connection.")
})

it("throws not logged in when refresh response is missing access token", async () => {
it("says the session expired when the refresh token gives no access token", async () => {
const ctx = makeCtx()
ctx.host.fs.writeText(
CRED_PATH,
Expand All @@ -324,7 +324,8 @@ describe("kimi plugin", () => {
})

const plugin = await loadPlugin()
expect(() => plugin.probe(ctx)).toThrow("Not logged in")
// A refresh token is a login the user has: "Not logged in" would mean nothing is set up.
expect(() => plugin.probe(ctx)).toThrow("Session expired. Run `kimi login` to authenticate.")
})

it("returns badge when no valid quota is available", async () => {
Expand Down
Loading
Loading