diff --git a/README.md b/README.md index 367d8e3a..44bae57f 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ OpenUsage lives in your menu bar and shows you how much of your AI coding subscr - [**Factory / Droid**](docs/providers/factory.md) / standard, premium tokens - [**Gemini**](docs/providers/gemini.md) / pro, flash, workspace/free/paid tier - [**JetBrains AI Assistant**](docs/providers/jetbrains-ai-assistant.md) / quota, remaining +- [**Kiro**](docs/providers/kiro.md) / credits, bonus credits, overages - [**Kimi Code**](docs/providers/kimi.md) / session, weekly - [**MiniMax**](docs/providers/minimax.md) / coding plan session - [**OpenCode Go**](docs/providers/opencode-go.md) / 5h, weekly, monthly spend limits diff --git a/docs/providers/kiro.md b/docs/providers/kiro.md new file mode 100644 index 00000000..c7972b22 --- /dev/null +++ b/docs/providers/kiro.md @@ -0,0 +1,168 @@ +# Kiro + +> Reverse-engineered from the shipped Kiro extension, local Kiro state, and local Kiro logs. The live API is not publicly documented and may change without notice. + +## Overview + +- **Product:** [Kiro](https://kiro.dev/) +- **Runtime service:** AWS CodeWhisperer Runtime (`https://q..amazonaws.com`) +- **Primary local state:** `~/Library/Application Support/Kiro/User/globalStorage/state.vscdb` +- **Primary local metadata fallback:** `~/Library/Application Support/Kiro/logs/*/window*/exthost/kiro.kiroAgent/q-client.log` +- **Auth token file:** `~/.aws/sso/cache/kiro-auth-token.json` +- **Profile fallback:** `~/Library/Application Support/Kiro/User/globalStorage/kiro.kiroagent/profile.json` + +OpenUsage uses Kiro's local normalized usage cache first, enriches it from Kiro's own runtime logs when available, and only falls back to the live refresh/API path when the local picture is missing or stale. + +## Plugin Metrics + +| Metric | Source | Scope | Format | Notes | +| --- | --- | --- | --- | --- | +| Credits | `usageBreakdowns[*]` | overview | count | Monthly included Kiro plan credits | +| Bonus Credits | `freeTrialUsage` or active `bonuses[0]` | overview | count | Free-trial / bonus credit pool when present | +| Overages | `overageConfiguration.overageStatus` | detail | badge | `Enabled` / `Disabled` | + +The plan label comes from `subscriptionInfo.subscriptionTitle` when a recent `GetUsageLimits` response is available from logs or the live API. + +## Local Sources + +### 1) SQLite usage cache + +Path: `~/Library/Application Support/Kiro/User/globalStorage/state.vscdb` + +Key: + +- `kiro.kiroAgent` + +That JSON currently contains a nested key: + +- `kiro.resourceNotifications.usageState` + +Observed shape: + +```json +{ + "usageBreakdowns": [ + { + "type": "CREDIT", + "currentUsage": 0, + "usageLimit": 50, + "resetDate": "2026-05-01T00:00:00.000Z", + "displayName": "Credit", + "displayNamePlural": "Credits", + "freeTrialUsage": { + "currentUsage": 106.11, + "usageLimit": 500, + "expiryDate": "2026-05-03T15:09:55.196Z", + "daysRemaining": 27 + } + } + ], + "timestamp": 1775500185544 +} +``` + +This is the cleanest local source for the numeric usage lines. + +### 2) q-client runtime logs + +Path pattern: + +```text +~/Library/Application Support/Kiro/logs//window*/exthost/kiro.kiroAgent/q-client.log +``` + +Kiro logs the full `GetUsageLimitsCommand` request/response. That response includes the fields missing from the SQLite cache, especially: + +- `subscriptionInfo.subscriptionTitle` +- `subscriptionInfo.type` +- `overageConfiguration.overageStatus` +- full `usageBreakdownList` + +OpenUsage uses the latest logged response to recover plan metadata without needing network access. + +## Authentication + +### Token file + +Kiro's desktop extension stores auth in: + +```text +~/.aws/sso/cache/kiro-auth-token.json +``` + +Observed fields: + +```json +{ + "accessToken": "...", + "refreshToken": "...", + "expiresAt": "2026-04-06T19:29:16.090Z", + "authMethod": "social", + "provider": "Google", + "profileArn": "arn:aws:codewhisperer:us-east-1:699475941385:profile/..." +} +``` + +### Profile fallback + +If `profileArn` is not embedded in the token file, Kiro also persists the selected profile: + +```json +{ + "arn": "arn:aws:codewhisperer:us-east-1:699475941385:profile/...", + "name": "Google" +} +``` + +## Live Refresh + Usage API + +### Refresh social auth token + +```http +POST https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken +Content-Type: application/json +User-Agent: KiroIDE-- +``` + +```json +{ + "refreshToken": "" +} +``` + +Observed refresh response fields: + +- `accessToken` +- `refreshToken` +- `expiresIn` +- `profileArn` + +### Fetch usage + +```http +GET https://q..amazonaws.com/getUsageLimits?origin=AI_EDITOR&profileArn=&resourceType=AGENTIC_REQUEST +Authorization: Bearer +Accept: application/json +``` + +Extra headers used by Kiro only for specific auth modes: + +- `TokenType: EXTERNAL_IDP` for external IdP accounts +- `redirect-for-internal: true` for internal AWS accounts + +Observed response fields: + +- `nextDateReset` +- `overageConfiguration.overageStatus` +- `subscriptionInfo.subscriptionTitle` +- `subscriptionInfo.type` +- `usageBreakdownList[*]` +- `userInfo.userId` + +## Provider Strategy in OpenUsage + +1. Require Kiro auth token presence so stale post-logout cache data is not shown as an active account. +2. Read `state.vscdb` for the normalized numeric usage view. +3. Read the latest `q-client.log` `GetUsageLimitsCommand` response for plan and overage metadata. +4. If the local cache is missing, incomplete, or older than the app's staleness threshold, call the live refresh/API path. +5. If live fetch fails but the local cache is usable, keep showing the last local snapshot. diff --git a/plugins/kiro/icon.svg b/plugins/kiro/icon.svg new file mode 100644 index 00000000..c0c7c696 --- /dev/null +++ b/plugins/kiro/icon.svg @@ -0,0 +1 @@ + diff --git a/plugins/kiro/plugin.js b/plugins/kiro/plugin.js new file mode 100644 index 00000000..c9625371 --- /dev/null +++ b/plugins/kiro/plugin.js @@ -0,0 +1,383 @@ +(function () { + const STATE_DB = "~/Library/Application Support/Kiro/User/globalStorage/state.vscdb" + const STATE_KEY = "kiro.kiroAgent" + const LOGS_ROOT = "~/Library/Application Support/Kiro/logs" + const LOG_FILE_NAME = "q-client.log" + const TOKEN_PATH = "~/.aws/sso/cache/kiro-auth-token.json" + const PROFILE_PATH = "~/Library/Application Support/Kiro/User/globalStorage/kiro.kiroagent/profile.json" + const REFRESH_URL = "https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken" + const LIVE_STALE_MS = 15 * 60 * 1000 + const REFRESH_BUFFER_MS = 10 * 60 * 1000 + const DEFAULT_REGION = "us-east-1" + const COUNT_FORMAT = { kind: "count", suffix: "credits" } + const LOGIN_HINT = "Open Kiro and sign in, then try again." + const SESSION_HINT = "Kiro session expired. Open Kiro and sign in again." + const DATA_HINT = "Kiro usage data unavailable. Open the Kiro account dashboard once and try again." + + function num(value) { + if (typeof value === "number") return Number.isFinite(value) ? value : null + if (typeof value !== "string" || !value.trim()) return null + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : null + } + function first() { + for (let i = 0; i < arguments.length; i += 1) { + const value = num(arguments[i]) + if (value !== null) return value + } + return null + } + function iso(ctx, value) { + const normalized = ctx.util.toIso(value) + return typeof normalized === "string" && normalized ? normalized : null + } + function title(value) { + const trimmed = String(value || "").trim() + return trimmed + ? trimmed + .toLowerCase() + .split(/\s+/) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" ") + : null + } + function sanitizeAuth(token) { + if (!token || typeof token !== "object") return null + if (token.provider === "Google" || token.provider === "Github") return { ...token, authMethod: "social" } + if (token.provider === "ExternalIdp") return { ...token, authMethod: "external_idp" } + if (token.provider === "Enterprise" || token.provider === "BuilderId" || token.provider === "Internal") { + return { ...token, authMethod: "IdC" } + } + return token + } + function readJsonFile(ctx, path, label) { + if (!ctx.host.fs.exists(path)) return null + try { + return ctx.util.tryParseJson(ctx.host.fs.readText(path)) + } catch (e) { + ctx.host.log.warn(label + " read failed: " + String(e)) + return null + } + } + function loadAuthState(ctx) { + const parsed = readJsonFile(ctx, TOKEN_PATH, "auth token") + if (!parsed || typeof parsed !== "object") return null + const token = sanitizeAuth(parsed) + return token && (token.refreshToken || token.accessToken) ? { path: TOKEN_PATH, token } : null + } + function saveAuthState(ctx, authState) { + try { + ctx.host.fs.writeText(authState.path, JSON.stringify(authState.token, null, 2)) + return true + } catch (e) { + ctx.host.log.warn("failed to persist refreshed Kiro auth: " + String(e)) + return false + } + } + function loadProfileArn(ctx, authState) { + const fromToken = authState && authState.token && authState.token.profileArn + if (typeof fromToken === "string" && fromToken) return fromToken + const parsed = readJsonFile(ctx, PROFILE_PATH, "profile") + return parsed && typeof parsed.arn === "string" && parsed.arn.trim() ? parsed.arn.trim() : null + } + function regionFromArn(profileArn) { + const parts = String(profileArn || "").split(":") + return parts.length > 3 && parts[3] ? parts[3] : DEFAULT_REGION + } + function readStateValue(ctx, key) { + try { + const sql = "SELECT value FROM ItemTable WHERE key = '" + String(key).replace(/'/g, "''") + "' LIMIT 1;" + const rows = ctx.util.tryParseJson(ctx.host.sqlite.query(STATE_DB, sql)) + return Array.isArray(rows) && rows.length && typeof rows[0].value === "string" ? rows[0].value : null + } catch (e) { + ctx.host.log.warn("Kiro sqlite read failed: " + String(e)) + return null + } + } + function normalizePool(ctx, raw, config) { + if (!raw || typeof raw !== "object") return null + if (config.statusKey) { + const status = raw[config.statusKey] + if (status && !config.allowedStatuses.includes(status)) return null + } + const currentUsage = first(raw[config.preciseCurrent], raw[config.current]) + const usageLimit = first(raw[config.preciseLimit], raw[config.limit]) + if (currentUsage === null || usageLimit === null || usageLimit <= 0) return null + return { + currentUsage, + usageLimit, + expiryDate: iso(ctx, raw[config.expiryA] || raw[config.expiryB]), + displayName: typeof raw.displayName === "string" && raw.displayName.trim() ? raw.displayName.trim() : null, + } + } + function normalizeBreakdown(ctx, raw) { + if (!raw || typeof raw !== "object") return null + const currentUsage = first(raw.currentUsageWithPrecision, raw.currentUsage) + const usageLimit = first(raw.usageLimitWithPrecision, raw.usageLimit) + if (currentUsage === null || usageLimit === null || usageLimit <= 0) return null + const bonuses = Array.isArray(raw.bonuses) + ? raw.bonuses + .map((item) => + normalizePool(ctx, item, { + current: "currentUsage", + preciseCurrent: null, + limit: "usageLimit", + preciseLimit: null, + expiryA: "expiresAt", + expiryB: "expiryDate", + statusKey: "status", + allowedStatuses: ["ACTIVE", "EXHAUSTED"], + }) + ) + .filter(Boolean) + : [] + + return { + type: typeof raw.resourceType === "string" ? raw.resourceType : raw.type, + currentUsage, + usageLimit, + resetDate: iso(ctx, raw.nextDateReset || raw.resetDate), + freeTrialUsage: normalizePool(ctx, raw.freeTrialInfo || raw.freeTrialUsage, { + current: "currentUsage", + preciseCurrent: "currentUsageWithPrecision", + limit: "usageLimit", + preciseLimit: "usageLimitWithPrecision", + expiryA: "freeTrialExpiry", + expiryB: "expiryDate", + statusKey: "freeTrialStatus", + allowedStatuses: ["ACTIVE"], + }), + bonuses, + } + } + function normalizeCachedState(ctx) { + const parsed = ctx.util.tryParseJson(readStateValue(ctx, STATE_KEY)) + const usageState = parsed && parsed["kiro.resourceNotifications.usageState"] + if (!usageState || !Array.isArray(usageState.usageBreakdowns)) return null + const usageBreakdowns = usageState.usageBreakdowns.map((item) => normalizeBreakdown(ctx, item)).filter(Boolean) + return usageBreakdowns.length + ? { usageBreakdowns, timestampMs: first(usageState.timestamp), plan: null, overageEnabled: null } + : null + } + function normalizeApiSnapshot(ctx, raw, timestampMs) { + if (!raw || typeof raw !== "object") return null + return { + usageBreakdowns: Array.isArray(raw.usageBreakdownList) + ? raw.usageBreakdownList.map((item) => normalizeBreakdown(ctx, item)).filter(Boolean) + : [], + timestampMs: timestampMs !== null ? timestampMs : null, + plan: title(raw.subscriptionInfo && raw.subscriptionInfo.subscriptionTitle), + overageEnabled: raw.overageConfiguration ? raw.overageConfiguration.overageStatus === "ENABLED" : null, + } + } + function parseUsageLogText(ctx, text) { + const lines = String(text || "").split(/\r?\n/) + for (let i = lines.length - 1; i >= 0; i -= 1) { + const line = lines[i] + if (line.indexOf('"commandName":"GetUsageLimitsCommand"') === -1) continue + const jsonStart = line.indexOf("{") + if (jsonStart === -1) continue + const parsed = ctx.util.tryParseJson(line.slice(jsonStart)) + if (!parsed || !parsed.output) continue + const loggedAt = line.slice(0, jsonStart).trim().split(" [")[0] + return normalizeApiSnapshot(ctx, parsed.output, loggedAt ? ctx.util.parseDateMs(loggedAt.replace(" ", "T")) : null) + } + return null + } + function loadLoggedState(ctx) { + let sessions = [] + try { + sessions = ctx.host.fs.listDir(LOGS_ROOT).slice().sort().reverse() + } catch { + return null + } + for (let i = 0; i < sessions.length && i < 12; i += 1) { + const sessionRoot = LOGS_ROOT + "/" + sessions[i] + let windows = [] + try { + windows = ctx.host.fs.listDir(sessionRoot).slice().sort().reverse() + } catch { + continue + } + for (let j = 0; j < windows.length; j += 1) { + const logPath = sessionRoot + "/" + windows[j] + "/exthost/kiro.kiroAgent/" + LOG_FILE_NAME + if (!ctx.host.fs.exists(logPath)) continue + try { + const snapshot = parseUsageLogText(ctx, ctx.host.fs.readText(logPath)) + if (snapshot) return snapshot + } catch (e) { + ctx.host.log.warn("failed to parse Kiro usage log: " + String(e)) + } + } + } + return null + } + function buildUserAgent(ctx) { + return "OpenUsage/" + String(ctx.app && ctx.app.version ? ctx.app.version : "0.0.0") + } + function needsRefresh(ctx, authState, nowMs) { + return ctx.util.needsRefreshByExpiry({ + nowMs, + expiresAtMs: ctx.util.parseDateMs(authState.token && authState.token.expiresAt), + bufferMs: REFRESH_BUFFER_MS, + }) + } + function buildUsageHeaders(ctx, authState, accessToken) { + const headers = { Authorization: "Bearer " + accessToken, Accept: "application/json", "User-Agent": buildUserAgent(ctx) } + if (authState.token && authState.token.authMethod === "external_idp") headers.TokenType = "EXTERNAL_IDP" + if (authState.token && authState.token.provider === "Internal") headers["redirect-for-internal"] = "true" + return headers + } + function refreshAccessToken(ctx, authState, nowMs) { + if (!authState.token || !authState.token.refreshToken) throw SESSION_HINT + const { resp, json } = ctx.util.requestJson({ + method: "POST", + url: REFRESH_URL, + headers: { "Content-Type": "application/json", "User-Agent": buildUserAgent(ctx) }, + bodyText: JSON.stringify({ refreshToken: authState.token.refreshToken }), + timeoutMs: 15000, + }) + if (ctx.util.isAuthStatus(resp.status)) throw SESSION_HINT + if (resp.status < 200 || resp.status >= 300 || !json || typeof json.accessToken !== "string" || !json.accessToken) { + ctx.host.log.warn("Kiro token refresh failed: HTTP " + resp.status) + return null + } + const expiresIn = first(json.expiresIn, json.expires_in) + authState.token = sanitizeAuth({ + ...authState.token, + accessToken: json.accessToken, + refreshToken: typeof json.refreshToken === "string" && json.refreshToken ? json.refreshToken : authState.token.refreshToken, + profileArn: typeof json.profileArn === "string" && json.profileArn ? json.profileArn : authState.token.profileArn, + expiresAt: expiresIn !== null && expiresIn > 0 ? new Date(nowMs + expiresIn * 1000).toISOString() : authState.token.expiresAt, + }) + saveAuthState(ctx, authState) + return authState.token.accessToken + } + function fetchLiveState(ctx, authState, nowMs) { + const profileArn = loadProfileArn(ctx, authState) + if (!profileArn) return null + const url = + "https://q." + + regionFromArn(profileArn) + + ".amazonaws.com/getUsageLimits?origin=" + + encodeURIComponent("AI_EDITOR") + + "&profileArn=" + + encodeURIComponent(profileArn) + + "&resourceType=" + + encodeURIComponent("AGENTIC_REQUEST") + + let accessToken = authState.token && authState.token.accessToken + if (!accessToken || needsRefresh(ctx, authState, nowMs)) { + const refreshed = refreshAccessToken(ctx, authState, nowMs) + if (refreshed) accessToken = refreshed + } + if (!accessToken) throw SESSION_HINT + + const resp = ctx.util.retryOnceOnAuth({ + request: (tokenOverride) => + ctx.util.request({ + method: "GET", + url, + headers: buildUsageHeaders(ctx, authState, tokenOverride || accessToken), + timeoutMs: 15000, + }), + refresh: () => refreshAccessToken(ctx, authState, nowMs), + }) + + if (ctx.util.isAuthStatus(resp.status)) throw SESSION_HINT + if (resp.status < 200 || resp.status >= 300) { + ctx.host.log.warn("Kiro live usage request failed: HTTP " + resp.status) + return null + } + const parsed = ctx.util.tryParseJson(resp.bodyText) + if (!parsed) { + ctx.host.log.warn("Kiro live usage response invalid JSON") + return null + } + return normalizeApiSnapshot(ctx, parsed, nowMs) + } + function shouldTryLive(localState, loggedState, nowMs) { + return !localState || !loggedState || !loggedState.plan || localState.timestampMs === null || nowMs - localState.timestampMs > LIVE_STALE_MS + } + function mergeSnapshots(localState, loggedState, liveState, nowMs) { + const usageSource = + liveState && liveState.usageBreakdowns.length + ? liveState + : localState && localState.usageBreakdowns.length + ? localState + : loggedState && loggedState.usageBreakdowns.length + ? loggedState + : null + if (!usageSource) return null + return { + plan: (liveState && liveState.plan) || (loggedState && loggedState.plan) || null, + overageEnabled: + liveState && liveState.overageEnabled !== null + ? liveState.overageEnabled + : loggedState && loggedState.overageEnabled !== null + ? loggedState.overageEnabled + : null, + usageBreakdowns: usageSource.usageBreakdowns, + timestampMs: usageSource.timestampMs !== null ? usageSource.timestampMs : nowMs, + } + } + function pickPrimaryBreakdown(usageBreakdowns) { + for (let i = 0; i < usageBreakdowns.length; i += 1) if (usageBreakdowns[i].type === "CREDIT") return usageBreakdowns[i] + return usageBreakdowns.length ? usageBreakdowns[0] : null + } + function pickBonusUsage(primary) { + return !primary ? null : primary.freeTrialUsage && primary.freeTrialUsage.usageLimit > 0 ? primary.freeTrialUsage : primary.bonuses && primary.bonuses.length ? primary.bonuses[0] : null + } + function formatAge(nowMs, timestampMs) { + if (!Number.isFinite(nowMs) || !Number.isFinite(timestampMs)) return null + const diffMs = Math.max(0, nowMs - timestampMs) + if (diffMs < 60000) return "Just now" + const minutes = Math.floor(diffMs / 60000) + if (minutes < 60) return minutes + "m ago" + const hours = Math.floor(minutes / 60) + return hours < 48 ? hours + "h ago" : Math.floor(hours / 24) + "d ago" + } + function buildOutput(ctx, snapshot, nowMs) { + const primary = pickPrimaryBreakdown(snapshot.usageBreakdowns) + if (!primary) throw DATA_HINT + const lines = [ctx.line.progress({ label: "Credits", used: primary.currentUsage, limit: primary.usageLimit, format: COUNT_FORMAT, resetsAt: primary.resetDate || undefined })] + const bonusUsage = pickBonusUsage(primary) + if (bonusUsage) { + lines.push( + ctx.line.progress({ + label: "Bonus Credits", + used: bonusUsage.currentUsage, + limit: bonusUsage.usageLimit, + format: COUNT_FORMAT, + resetsAt: bonusUsage.expiryDate || undefined, + }) + ) + } + if (snapshot.overageEnabled !== null) lines.push(ctx.line.badge({ label: "Overages", text: snapshot.overageEnabled ? "Enabled" : "Disabled" })) + return { plan: snapshot.plan || undefined, lines } + } + function probe(ctx) { + const nowMs = ctx.util.parseDateMs(ctx.nowIso) || Date.now() + const authState = loadAuthState(ctx) + if (!authState || !authState.token || !authState.token.refreshToken) throw LOGIN_HINT + const localState = normalizeCachedState(ctx) + const loggedState = loadLoggedState(ctx) + let liveState = null + let liveError = null + if (shouldTryLive(localState, loggedState, nowMs)) { + try { + liveState = fetchLiveState(ctx, authState, nowMs) + } catch (e) { + liveError = e + ctx.host.log.warn("Kiro live fallback failed: " + String(e)) + } + } + const snapshot = mergeSnapshots(localState, loggedState, liveState, nowMs) + if (!snapshot) { + if (typeof liveError === "string" && liveError) throw liveError + throw DATA_HINT + } + return buildOutput(ctx, snapshot, nowMs) + } + globalThis.__openusage_plugin = { id: "kiro", probe } +})() diff --git a/plugins/kiro/plugin.json b/plugins/kiro/plugin.json new file mode 100644 index 00000000..c8bf5412 --- /dev/null +++ b/plugins/kiro/plugin.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "kiro", + "name": "Kiro", + "version": "0.0.1", + "entry": "plugin.js", + "icon": "icon.svg", + "brandColor": "#C09CFF", + "lines": [ + { "type": "progress", "label": "Credits", "scope": "overview", "primaryOrder": 1 }, + { "type": "progress", "label": "Bonus Credits", "scope": "overview" }, + { "type": "badge", "label": "Overages", "scope": "detail" } + ] +} diff --git a/plugins/kiro/plugin.test.js b/plugins/kiro/plugin.test.js new file mode 100644 index 00000000..7ee67c11 --- /dev/null +++ b/plugins/kiro/plugin.test.js @@ -0,0 +1,284 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { makeCtx } from "../test-helpers.js" + +const TOKEN_PATH = "~/.aws/sso/cache/kiro-auth-token.json" +const PROFILE_PATH = "~/Library/Application Support/Kiro/User/globalStorage/kiro.kiroagent/profile.json" +const LOG_PATH = + "~/Library/Application Support/Kiro/logs/20260406T235910/window1/exthost/kiro.kiroAgent/q-client.log" + +const loadPlugin = async () => { + await import("./plugin.js") + return globalThis.__openusage_plugin +} + +const makeToken = (overrides = {}) => ({ + accessToken: "kiro-access-token", + refreshToken: "kiro-refresh-token", + expiresAt: "2026-02-02T01:00:00.000Z", + authMethod: "social", + provider: "Google", + profileArn: "arn:aws:codewhisperer:us-east-1:699475941385:profile/EHGA3GRVQMUK", + ...overrides, +}) + +const makeStatePayload = (overrides = {}) => ({ + "kiro.resourceNotifications.usageState": { + usageBreakdowns: [ + { + type: "CREDIT", + currentUsage: 0, + usageLimit: 50, + resetDate: "2026-05-01T00:00:00.000Z", + displayName: "Credit", + displayNamePlural: "Credits", + freeTrialUsage: { + currentUsage: 106.11, + usageLimit: 500, + expiryDate: "2026-05-03T15:09:55.196Z", + }, + }, + ], + timestamp: Date.parse("2026-02-01T23:58:00.000Z"), + ...overrides, + }, +}) + +const makeUsageOutput = (overrides = {}) => ({ + nextDateReset: "2026-05-01T00:00:00.000Z", + overageConfiguration: { overageStatus: "DISABLED" }, + subscriptionInfo: { + subscriptionTitle: "KIRO FREE", + type: "Q_DEVELOPER_STANDALONE_FREE", + }, + usageBreakdownList: [ + { + resourceType: "CREDIT", + currentUsage: 0, + currentUsageWithPrecision: 0, + usageLimit: 50, + usageLimitWithPrecision: 50, + nextDateReset: "2026-05-01T00:00:00.000Z", + displayName: "Credit", + displayNamePlural: "Credits", + freeTrialInfo: { + currentUsage: 106, + currentUsageWithPrecision: 106.11, + usageLimit: 500, + usageLimitWithPrecision: 500, + freeTrialStatus: "ACTIVE", + freeTrialExpiry: "2026-05-03T15:09:55.196Z", + }, + bonuses: [], + }, + ], + ...overrides, +}) + +const writeToken = (ctx, token = makeToken()) => { + ctx.host.fs.writeText(TOKEN_PATH, JSON.stringify(token, null, 2)) +} + +const writeProfile = (ctx, arn = makeToken().profileArn) => { + ctx.host.fs.writeText(PROFILE_PATH, JSON.stringify({ arn, name: "Google" }, null, 2)) +} + +const mockStateDb = (ctx, payload) => { + ctx.host.sqlite.query.mockImplementation((db, sql) => { + if (String(sql).includes("kiro.kiroAgent")) { + return JSON.stringify([{ value: JSON.stringify(payload) }]) + } + return JSON.stringify([]) + }) +} + +const writeUsageLog = (ctx, output = makeUsageOutput(), loggedAt = "2026-02-01 23:57:00.000") => { + const line = + loggedAt + + ' [info] ' + + JSON.stringify({ + clientName: "CodeWhispererRuntimeClient", + commandName: "GetUsageLimitsCommand", + input: { + origin: "AI_EDITOR", + profileArn: makeToken().profileArn, + resourceType: "AGENTIC_REQUEST", + }, + output, + }) + ctx.host.fs.writeText(LOG_PATH, line + "\n") +} + +describe("kiro plugin", () => { + beforeEach(() => { + delete globalThis.__openusage_plugin + vi.resetModules() + }) + + it("throws when auth token is missing", async () => { + const ctx = makeCtx() + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Open Kiro and sign in, then try again.") + }) + + it("uses local usage state and usage log metadata without hitting the network", async () => { + const ctx = makeCtx() + writeToken(ctx) + mockStateDb(ctx, makeStatePayload()) + writeUsageLog(ctx, makeUsageOutput({ + usageBreakdownList: [ + { + resourceType: "CREDIT", + currentUsage: 1, + currentUsageWithPrecision: 1, + usageLimit: 50, + usageLimitWithPrecision: 50, + nextDateReset: "2026-05-01T00:00:00.000Z", + displayName: "Credit", + displayNamePlural: "Credits", + freeTrialInfo: { + currentUsage: 99, + currentUsageWithPrecision: 99.5, + usageLimit: 500, + usageLimitWithPrecision: 500, + freeTrialStatus: "ACTIVE", + freeTrialExpiry: "2026-05-03T15:09:55.196Z", + }, + bonuses: [], + }, + ], + })) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Kiro Free") + expect(result.lines.find((line) => line.label === "Credits")).toMatchObject({ + used: 0, + limit: 50, + format: { kind: "count", suffix: "credits" }, + }) + expect(result.lines.find((line) => line.label === "Bonus Credits")).toMatchObject({ + used: 106.11, + limit: 500, + format: { kind: "count", suffix: "credits" }, + }) + expect(result.lines.find((line) => line.label === "Overages")).toMatchObject({ + type: "badge", + text: "Disabled", + }) + expect(ctx.host.http.request).not.toHaveBeenCalled() + }) + + it("falls back to the latest usage log when sqlite state is missing", async () => { + const ctx = makeCtx() + writeToken(ctx) + writeUsageLog(ctx) + ctx.host.http.request.mockReturnValue({ + status: 503, + headers: {}, + bodyText: "{}", + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Kiro Free") + expect(result.lines.find((line) => line.label === "Credits")).toMatchObject({ + used: 0, + limit: 50, + }) + expect(result.lines.find((line) => line.label === "Bonus Credits")).toMatchObject({ + used: 106.11, + limit: 500, + }) + expect(ctx.host.http.request).toHaveBeenCalledTimes(1) + }) + + it("refreshes an expired token and fetches live usage when local data is unavailable", async () => { + const ctx = makeCtx() + writeToken(ctx, makeToken({ accessToken: "", expiresAt: "2026-02-01T00:00:00.000Z" })) + writeProfile(ctx) + + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("/refreshToken")) { + expect(JSON.parse(opts.bodyText)).toEqual({ refreshToken: "kiro-refresh-token" }) + return { + status: 200, + headers: {}, + bodyText: JSON.stringify({ + accessToken: "refreshed-access-token", + refreshToken: "refreshed-refresh-token", + expiresIn: 3600, + profileArn: makeToken().profileArn, + }), + } + } + + expect(String(opts.url)).toContain("https://q.us-east-1.amazonaws.com/getUsageLimits?") + expect(opts.headers.Authorization).toBe("Bearer refreshed-access-token") + return { + status: 200, + headers: {}, + bodyText: JSON.stringify(makeUsageOutput()), + } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Kiro Free") + expect(result.lines.find((line) => line.label === "Credits")).toBeTruthy() + const savedToken = JSON.parse(ctx.host.fs.readText(TOKEN_PATH)) + expect(savedToken.accessToken).toBe("refreshed-access-token") + expect(savedToken.refreshToken).toBe("refreshed-refresh-token") + }) + + it("adds TokenType for external IdP live requests", async () => { + const ctx = makeCtx() + writeToken(ctx, makeToken({ + authMethod: "external_idp", + provider: "ExternalIdp", + accessToken: "external-idp-token", + refreshToken: "external-idp-refresh", + profileArn: "", + })) + writeProfile(ctx) + + ctx.host.http.request.mockImplementation((opts) => ({ + status: 200, + headers: {}, + bodyText: JSON.stringify(makeUsageOutput()), + })) + + const plugin = await loadPlugin() + plugin.probe(ctx) + + const usageRequest = ctx.host.http.request.mock.calls.find( + ([opts]) => String(opts.url).includes("/getUsageLimits?") + )[0] + expect(usageRequest.headers.TokenType).toBe("EXTERNAL_IDP") + }) + + it("falls back to stale local data when live fetch fails", async () => { + const ctx = makeCtx() + writeToken(ctx) + mockStateDb(ctx, makeStatePayload({ + timestamp: Date.parse("2026-02-01T23:30:00.000Z"), + })) + + ctx.host.http.request.mockReturnValue({ + status: 503, + headers: {}, + bodyText: "{}", + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.lines.find((line) => line.label === "Credits")).toMatchObject({ + used: 0, + limit: 50, + }) + expect(result.plan).toBeUndefined() + }) +}) diff --git a/src-tauri/src/plugin_engine/host_api.rs b/src-tauri/src/plugin_engine/host_api.rs index 2b090cb9..ef184ad9 100644 --- a/src-tauri/src/plugin_engine/host_api.rs +++ b/src-tauri/src/plugin_engine/host_api.rs @@ -233,6 +233,8 @@ fn redact_url(url: &str) -> String { "userid", "account_id", "accountid", + "profilearn", + "profile_arn", "email", "login", ]; @@ -316,6 +318,8 @@ fn redact_body(body: &str) -> String { "teamId", "payment_id", "paymentId", + "profile_arn", + "profileArn", "email", "login", "analytics_tracking_id", @@ -2782,6 +2786,22 @@ mod tests { assert_eq!(redact_url(url), url); } + #[test] + fn redact_url_redacts_profile_arn_query_param() { + let url = "https://q.us-east-1.amazonaws.com/getUsageLimits?profileArn=arn:aws:codewhisperer:us-east-1:699475941385:profile/EHGA3GRVQMUK&origin=AI_EDITOR"; + let redacted = redact_url(url); + assert!( + !redacted.contains("699475941385"), + "profileArn should be redacted, got: {}", + redacted + ); + assert!( + redacted.contains("origin=AI_EDITOR"), + "non-sensitive params should remain visible, got: {}", + redacted + ); + } + #[test] fn redact_body_redacts_jwt() { let body = r#"{"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"}"#; @@ -2896,6 +2916,22 @@ mod tests { ); } + #[test] + fn redact_body_redacts_profile_arn_fields() { + let body = r#"{"profileArn":"arn:aws:codewhisperer:us-east-1:699475941385:profile/EHGA3GRVQMUK","profile_arn":"arn:aws:codewhisperer:us-east-1:699475941385:profile/EHGA3GRVQMUK"}"#; + let redacted = redact_body(body); + assert!( + !redacted.contains("699475941385"), + "profile arn should be redacted, got: {}", + redacted + ); + assert!( + redacted.contains("arn:...QMUK"), + "profile arn should use first4...last4 redaction, got: {}", + redacted + ); + } + #[test] fn redact_log_message_redacts_jwt_and_api_key() { let msg = "token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U key=sk-1234567890abcdef";