From edad407a258eddde390cb4e1db5ff8f78050a0d9 Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Wed, 23 Sep 2026 15:49:22 +0200 Subject: [PATCH 1/2] fix(credentials): store all secrets in a single keychain entry macOS ties a Keychain "Always Allow" grant to the IDE code signature, so every IDE (especially EAP) update re-prompted once per stored account. CredentialsStore now keeps every secret in one PasswordSafe entry (a JSON vault accountId -> secret), read once per session and cached in memory. Legacy per-account entries are migrated into the vault on first access and deleted, so after migration an IDE update costs a single prompt. --- .../tokenpulse/settings/CredentialsStore.kt | 105 ++++++++++--- .../settings/CredentialsStoreTest.kt | 141 ++++++++++++++++++ 2 files changed, 227 insertions(+), 19 deletions(-) create mode 100644 src/test/kotlin/org/zhavoronkov/tokenpulse/settings/CredentialsStoreTest.kt diff --git a/src/main/kotlin/org/zhavoronkov/tokenpulse/settings/CredentialsStore.kt b/src/main/kotlin/org/zhavoronkov/tokenpulse/settings/CredentialsStore.kt index f128180..e28f573 100644 --- a/src/main/kotlin/org/zhavoronkov/tokenpulse/settings/CredentialsStore.kt +++ b/src/main/kotlin/org/zhavoronkov/tokenpulse/settings/CredentialsStore.kt @@ -1,43 +1,110 @@ package org.zhavoronkov.tokenpulse.settings -import com.intellij.credentialStore.CredentialAttributes +import com.google.gson.Gson +import com.google.gson.JsonObject import com.intellij.credentialStore.generateServiceName import com.intellij.ide.passwordSafe.PasswordSafe import com.intellij.openapi.components.Service import com.intellij.openapi.components.service +import org.zhavoronkov.tokenpulse.utils.TokenPulseLogger + +/** + * Raw key/value secret storage. One [get] of a key = one OS keychain access, + * which on macOS may show an access prompt. + */ +internal interface SecretBackend { + fun get(key: String): String? + fun set(key: String, value: String?) +} + +/** [SecretBackend] on top of IntelliJ's PasswordSafe (macOS Keychain, KeePass, ...). */ +private class PasswordSafeBackend : SecretBackend { + override fun get(key: String): String? = PasswordSafe.instance.getPassword(attributes(key)) + + override fun set(key: String, value: String?) = PasswordSafe.instance.setPassword(attributes(key), value) + + // Constructed via the Java shim so the emitted bytecode binds to the plain + // CredentialAttributes(String, String) JVM constructor. A Kotlin-side call would + // route through the default-args synthetic ctor that 2026.1 (build 261) marks + // @Deprecated(ERROR); see CredentialAttributesFactory for details. + private fun attributes(key: String) = + CredentialAttributesFactory.create(generateServiceName("TokenPulse", key), key) +} /** * Secure credential storage for API keys and OAuth tokens. * - * Uses IntelliJ's PasswordSafe for secure storage. - * PasswordSafe handles threading internally. + * All secrets are kept in ONE PasswordSafe entry (the vault, a JSON map + * accountId -> secret) that is read once per IDE session and cached in memory. + * macOS ties a Keychain "Always Allow" grant to the IDE's code signature, so + * every IDE update re-prompts once per entry; a single entry means a single prompt. + * + * Older versions stored one entry per account. Such a legacy entry is migrated + * into the vault on first access and then deleted. */ @Service(Service.Level.APP) -class CredentialsStore { +class CredentialsStore internal constructor(private val backend: SecretBackend) { + constructor() : this(PasswordSafeBackend()) + companion object { + internal const val VAULT_KEY = "vault" + fun getInstance(): CredentialsStore = service() } - fun saveApiKey(accountId: String, apiKey: String) { - val attributes = createAttributes(accountId) - PasswordSafe.instance.setPassword(attributes, apiKey) + private val gson = Gson() + private val lock = Any() + private var vault: MutableMap? = null + + /** Account ids whose legacy per-account entry was already checked/removed this session. */ + private val legacyChecked = mutableSetOf() + + fun saveApiKey(accountId: String, apiKey: String) = synchronized(lock) { + loadedVault()[accountId] = apiKey + persist() + dropLegacy(accountId) + } + + fun getApiKey(accountId: String): String? = synchronized(lock) { + loadedVault()[accountId] ?: migrateLegacy(accountId) + } + + fun removeApiKey(accountId: String) = synchronized(lock) { + if (loadedVault().remove(accountId) != null) persist() + dropLegacy(accountId) + } + + private fun loadedVault(): MutableMap = + vault ?: parseVault(backend.get(VAULT_KEY)).also { vault = it } + + private fun parseVault(raw: String?): MutableMap { + if (raw.isNullOrEmpty()) return mutableMapOf() + return try { + gson.fromJson(raw, JsonObject::class.java).entrySet() + .filter { it.value.isJsonPrimitive } + .associateTo(mutableMapOf()) { it.key to it.value.asString } + } catch (e: Exception) { + TokenPulseLogger.Settings.warn("Credential vault is unreadable, starting empty", e) + mutableMapOf() + } } - fun getApiKey(accountId: String): String? { - val attributes = createAttributes(accountId) - return PasswordSafe.instance.getPassword(attributes) + private fun persist() { + backend.set(VAULT_KEY, gson.toJson(loadedVault())) } - fun removeApiKey(accountId: String) { - val attributes = createAttributes(accountId) - PasswordSafe.instance.setPassword(attributes, null) + private fun migrateLegacy(accountId: String): String? { + if (!legacyChecked.add(accountId)) return null + val legacy = backend.get(accountId) ?: return null + // Write the vault first so a failure between the two steps never loses the secret. + loadedVault()[accountId] = legacy + persist() + backend.set(accountId, null) + TokenPulseLogger.Settings.info("Migrated credential for account $accountId into the vault") + return legacy } - private fun createAttributes(accountId: String): CredentialAttributes { - // Constructed via the Java shim so the emitted bytecode binds to the plain - // CredentialAttributes(String, String) JVM constructor. A Kotlin-side call would - // route through the default-args synthetic ctor that 2026.1 (build 261) marks - // @Deprecated(ERROR); see CredentialAttributesFactory for details. - return CredentialAttributesFactory.create(generateServiceName("TokenPulse", accountId), accountId) + private fun dropLegacy(accountId: String) { + if (legacyChecked.add(accountId)) backend.set(accountId, null) } } diff --git a/src/test/kotlin/org/zhavoronkov/tokenpulse/settings/CredentialsStoreTest.kt b/src/test/kotlin/org/zhavoronkov/tokenpulse/settings/CredentialsStoreTest.kt new file mode 100644 index 0000000..779c34a --- /dev/null +++ b/src/test/kotlin/org/zhavoronkov/tokenpulse/settings/CredentialsStoreTest.kt @@ -0,0 +1,141 @@ +package org.zhavoronkov.tokenpulse.settings + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * Tests for [CredentialsStore]: all secrets live in ONE backend entry (the vault), + * so macOS Keychain asks for access once per IDE build instead of once per account. + */ +class CredentialsStoreTest { + + /** In-memory backend that counts reads per key (each read = potential Keychain prompt). */ + private class FakeBackend(initial: Map = emptyMap()) : SecretBackend { + val entries = initial.toMutableMap() + val reads = mutableMapOf() + + override fun get(key: String): String? { + reads.merge(key, 1, Int::plus) + return entries[key] + } + + override fun set(key: String, value: String?) { + if (value == null) entries.remove(key) else entries[key] = value + } + } + + @Test + fun `saved keys are stored in a single vault entry`() { + val backend = FakeBackend() + val store = CredentialsStore(backend) + + store.saveApiKey("a1", "key-1") + store.saveApiKey("a2", "key-2") + + assertEquals(setOf(CredentialsStore.VAULT_KEY), backend.entries.keys) + } + + @Test + fun `keys round-trip through a fresh store instance`() { + val backend = FakeBackend() + CredentialsStore(backend).apply { + saveApiKey("a1", "key-1") + saveApiKey("a2", "{\"pat\":\"x\",\"org\":\"y\"}") + } + + val reopened = CredentialsStore(backend) + + assertEquals("key-1", reopened.getApiKey("a1")) + assertEquals("{\"pat\":\"x\",\"org\":\"y\"}", reopened.getApiKey("a2")) + } + + @Test + fun `vault is read from the backend only once for many accounts`() { + val backend = FakeBackend() + CredentialsStore(backend).apply { (1..10).forEach { saveApiKey("a$it", "key-$it") } } + backend.reads.clear() + + val store = CredentialsStore(backend) + repeat(3) { (1..10).forEach { i -> assertEquals("key-$i", store.getApiKey("a$i")) } } + + assertEquals(1, backend.reads[CredentialsStore.VAULT_KEY]) + assertEquals(setOf(CredentialsStore.VAULT_KEY), backend.reads.keys) + } + + @Test + fun `remove deletes the key from the vault`() { + val backend = FakeBackend() + val store = CredentialsStore(backend) + store.saveApiKey("a1", "key-1") + store.saveApiKey("a2", "key-2") + + store.removeApiKey("a1") + + assertNull(store.getApiKey("a1")) + assertNull(CredentialsStore(backend).getApiKey("a1")) + assertEquals("key-2", CredentialsStore(backend).getApiKey("a2")) + } + + @Test + fun `legacy per-account entry is migrated into the vault and deleted`() { + val backend = FakeBackend(mapOf("a1" to "legacy-key")) + val store = CredentialsStore(backend) + + assertEquals("legacy-key", store.getApiKey("a1")) + + assertFalse(backend.entries.containsKey("a1")) + assertEquals("legacy-key", CredentialsStore(backend).getApiKey("a1")) + } + + @Test + fun `missing legacy entry is looked up only once per session`() { + val backend = FakeBackend() + val store = CredentialsStore(backend) + + repeat(5) { assertNull(store.getApiKey("ghost")) } + + assertEquals(1, backend.reads["ghost"]) + } + + @Test + fun `migrated account is not looked up in legacy storage again`() { + val backend = FakeBackend(mapOf("a1" to "legacy-key")) + CredentialsStore(backend).getApiKey("a1") + backend.reads.clear() + + CredentialsStore(backend).getApiKey("a1") + + assertNull(backend.reads["a1"]) + } + + @Test + fun `saving over a legacy entry removes the legacy entry`() { + val backend = FakeBackend(mapOf("a1" to "old")) + val store = CredentialsStore(backend) + + store.saveApiKey("a1", "new") + + assertFalse(backend.entries.containsKey("a1")) + assertEquals("new", CredentialsStore(backend).getApiKey("a1")) + } + + @Test + fun `removing an account also removes its legacy entry`() { + val backend = FakeBackend(mapOf("a1" to "old")) + + CredentialsStore(backend).removeApiKey("a1") + + assertTrue(backend.entries.isEmpty() || backend.entries.keys == setOf(CredentialsStore.VAULT_KEY)) + assertNull(CredentialsStore(backend).getApiKey("a1")) + } + + @Test + fun `corrupt vault is treated as empty and legacy entries still resolve`() { + val backend = FakeBackend(mapOf(CredentialsStore.VAULT_KEY to "not json", "a1" to "legacy-key")) + + assertEquals("legacy-key", CredentialsStore(backend).getApiKey("a1")) + } +} From 7fcb66874fcd8197eceef9cfd955ed1e1145b77f Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Wed, 23 Sep 2026 15:59:11 +0200 Subject: [PATCH 2/2] chore: prepare v0.5.1 release Bump pluginVersion to 0.5.1, add the 0.5.1 CHANGELOG entry, update the README/DEVELOPMENT version references, the What's New notification and the Marketplace change-notes (backfilling the missing 0.5.0 entry). --- CHANGELOG.md | 16 ++++++++++++++++ DEVELOPMENT.md | 4 ++-- README.md | 4 ++-- gradle.properties | 2 +- .../startup/WhatsNewNotificationActivity.kt | 6 +++++- src/main/resources/META-INF/plugin.xml | 14 ++++++++++++++ 6 files changed, 40 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63989ad..5874cc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Jump to the release that interests you: | Release | Highlights | |---------|-----------| +| [0.5.1](#051---2026-09-23) | One macOS Keychain prompt instead of one per account | | [0.5.0](#050---2026-08-18) | DeepSeek & GitHub Copilot providers, platform 2025.3, progress bar fix, build hardening | | [0.4.0](#040---2026-07-23) | Redesigned tooltip, session auto-refresh, multi-account support | | [0.3.1](#031---2026-07-08) | ClinePass usage limits, improved CLI detection | @@ -28,6 +29,21 @@ Jump to the release that interests you: ### Removed +## [0.5.1] - 2026-09-23 + +> **Highlights:** macOS Keychain now asks once for all TokenPulse credentials instead of once per account + +### Fixed +- **No more Keychain prompt storm on macOS** — every account's secret used to live in its own + Keychain entry, and macOS ties an "Always Allow" grant to the IDE's code signature. Each IDE + update (EAP builds especially) therefore revoked the grant and re-prompted once *per account* — + ten accounts meant ten password dialogs. All secrets now live in a single PasswordSafe entry + (`TokenPulse — vault`), read once per IDE session and cached in memory, so an IDE update costs + at most one prompt. +- **Automatic migration** — existing per-account entries are moved into the vault on first use + and then deleted. The vault is written before the old entry is removed, so a secret is never + lost mid-migration. macOS may ask for each old entry one last time during this migration. + ## [0.5.0] - 2026-08-18 > **Highlights:** New DeepSeek & GitHub Copilot providers • Platform raised to 2025.3 • Build hardening (non-public API & Detekt gates) • Progress bar & UI fixes diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 6dc697d..ae5f194 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -101,7 +101,7 @@ token-pulse/ All versioning and platform compatibility info is centralized in **`gradle.properties`**: ```properties -pluginVersion = 0.5.0 +pluginVersion = 0.5.1 pluginSinceBuild = 253 platformVersion = 2025.3.6 ``` @@ -135,6 +135,6 @@ Code quality is enforced via **Detekt**. The build will fail if any issues are f 1. Update version in `gradle.properties`. 2. Add a new entry to `CHANGELOG.md`. -3. Create a git tag: `git tag v0.5.0` and push it. +3. Create a git tag: `git tag v0.5.1` and push it. 4. CI will automatically create a GitHub Release and attach the ZIP artifact. 5. **Manually publish** the signed artifact to the JetBrains Marketplace (see `MARKETPLACE.md`). diff --git a/README.md b/README.md index bed2afd..10075a7 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # TokenPulse [![JetBrains Plugin](https://img.shields.io/badge/JetBrains-Plugin-orange.svg)](https://plugins.jetbrains.com/plugin/30615-tokenpulse) -[![Version](https://img.shields.io/badge/version-0.5.0-blue.svg)](https://github.com/DimazzzZ/tokenpulse-intellij-plugin/releases) +[![Version](https://img.shields.io/badge/version-0.5.1-blue.svg)](https://github.com/DimazzzZ/tokenpulse-intellij-plugin/releases) [![CI](https://github.com/DimazzzZ/tokenpulse-intellij-plugin/actions/workflows/ci.yml/badge.svg)](https://github.com/DimazzzZ/tokenpulse-intellij-plugin/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -> ⚠️ **Beta Release** — This is an early release (v0.5.0). Features may change and some functionality may be incomplete. Please [report issues](https://github.com/DimazzzZ/tokenpulse-intellij-plugin/issues) on GitHub. +> ⚠️ **Beta Release** — This is an early release (v0.5.1). Features may change and some functionality may be incomplete. Please [report issues](https://github.com/DimazzzZ/tokenpulse-intellij-plugin/issues) on GitHub. **TokenPulse** is an IntelliJ IDEA plugin that aggregates token balances and credit usage across multiple AI providers directly in your IDE status bar. diff --git a/gradle.properties b/gradle.properties index 1c14026..42daf09 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,7 +14,7 @@ kotlin.compiler.execution.strategy=in-process # Plugin metadata pluginGroup = org.zhavoronkov.tokenpulse pluginName = TokenPulse -pluginVersion = 0.5.0 +pluginVersion = 0.5.1 pluginRepositoryUrl = https://github.com/DimazzzZ/tokenpulse-intellij-plugin # Compatibility diff --git a/src/main/kotlin/org/zhavoronkov/tokenpulse/startup/WhatsNewNotificationActivity.kt b/src/main/kotlin/org/zhavoronkov/tokenpulse/startup/WhatsNewNotificationActivity.kt index 364d74b..9403ab8 100644 --- a/src/main/kotlin/org/zhavoronkov/tokenpulse/startup/WhatsNewNotificationActivity.kt +++ b/src/main/kotlin/org/zhavoronkov/tokenpulse/startup/WhatsNewNotificationActivity.kt @@ -41,7 +41,11 @@ class WhatsNewNotificationActivity : ProjectActivity { "TokenPulse Updated to v$version", """ Thank you for using TokenPulse!

- What’s new in v$version:
+ Fixed in v$version:
+ • One Keychain prompt instead of one per account — on macOS, all TokenPulse + credentials now share a single Keychain entry, so IDE updates no longer trigger a + password dialog for every account (existing keys are migrated automatically)

+ Recently added in v0.5.0:
• DeepSeek provider — track your DeepSeek API usage and quota
• GitHub Copilot provider — personal and organization budget tracking
• Platform raised to IntelliJ 2025.3 — required for forward compatibility
diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 1a3826e..6bbdd82 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -33,6 +33,20 @@ ]]> 0.5.1 — One macOS Keychain prompt instead of one per account (2026-09-23)
+
    +
  • Keychain fix: all credentials now share a single Keychain entry, so IDE updates no longer re-prompt once for every account
  • +
  • Automatic migration: existing per-account entries are moved into the shared entry on first use
  • +
+
+ 0.5.0 — New providers and platform 2025.3 (2026-08-18)
+
    +
  • DeepSeek provider: track your DeepSeek API usage and quota
  • +
  • GitHub Copilot provider: personal and organization budget tracking
  • +
  • Minimum platform raised to IntelliJ 2025.3 (build 253)
  • +
  • UI fix: Add/Edit Account dialog hint no longer floods the log or gets stuck tall
  • +
+
0.4.0 — Session robustness, unified Xiaomi, and a richer tooltip (2026-07-23)
  • Nebius session auto-refresh: silently re-mints the CSRF token when it rotates, so a still-valid session keeps working without reconnecting