diff --git a/CLAUDE.md b/CLAUDE.md index 11e90634..e34fe35f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,6 +46,42 @@ There is also **one shared Gradle wrapper at the repo root** (`gradlew` + `gradl Both jars are referenced via `../libs/*.jar`. **Always use the repo-root `libs/` jars and the repo-root Gradle wrapper — never bundle per-plugin copies.** A plugin that ships its own `libs/plugin-api.jar` / `libs/gradle-plugin.jar` (e.g. copied from another plugin) can drift out of sync with the rest of the repo; point `build.gradle.kts` (`compileOnly`) and `settings.gradle.kts` (buildscript `classpath`) at `../libs/*.jar` and delete any local `libs/`. The root `plugin-api.jar` already carries the full API surface (including `IdeTemplateService`/`CgtTemplateBuilder`), so newer sub-APIs do not justify a local copy. **A plugin folder is not standalone in isolation** — copy the root `libs/` along if you move one elsewhere. When CoGo's API changes, refresh via the script above or the **Update libs from CodeOnTheGo** GitHub Action (which also commits the refreshed jars, cuts a release, and deploys `.cgp` files to the website). +### Credentials: use the host's `KeystoreSecretStore`, never your own crypto + +A plugin that stores a credential encrypts it with `com.itsaky.androidide.plugins.security.KeystoreSecretStore` +from `plugin-api.jar` (**26.36+** — set `plugin.min_ide_version` accordingly). It is `compileOnly` +like the rest of the API, so there is one implementation in the IDE's process rather than a copy +compiled into each `.cgp`. Do not re-implement AES/GCM in a plugin; three AI plugins each grew a +copy that started to diverge, which is what ADFA-5255 removed. + +Construct it with **this plugin's own alias** (`KeystoreSecretStore(ALIAS)`) as a single +top-level `val` in a `SecureApiKeyStore.kt`/`SecureTokenStore.kt` that holds nothing but the alias; +callers use that instance directly. It takes no log tag — the store logs under its own name — and +that single-argument constructor is the only one a plugin can reach: the two-argument form takes an +`internal` `SecretKeySource`, which is not on the plugin's compile classpath at all. +`ai-agent-mcp`, `ai-agent-gemini` and `ai-agent-openai` are the reference shape. Do **not** wrap +it in an object of forwarding methods — that is just a second copy of the store's contract to keep +in step. The alias must be unique per plugin (all plugins share the host's UID and Keystore, so a +shared alias lets one plugin's invalidated-key recovery delete another's secret) and must never +change across releases. + +`readAndMigrate` returns a four-way `Stored` rather than a nullable String on purpose — each state +needs different advice, and a plugin that collapses them tells a user their credential was refused +when it was never sent: + +- `Absent` — nothing was ever saved. The ordinary first run; say nothing. +- `Value` — the plaintext. Trim it at the call site if your credential format wants it; + `readAndMigrate` migrates verbatim. +- `Unreadable` — stored, but this device's Keystore can no longer open it (a restored backup, an + OEM Keystore reset). Permanent: the user has to enter it again. +- `Unavailable` — the Keystore would not answer this time. **Transient: the credential is intact, + so retry and never re-prompt.** In particular a pane that reads `Unavailable` must not dress + itself as never-configured, and nothing on that screen may write over the credential it could + not read — an empty field then means "not shown", not "removed". + +Handle all four; collapse them only where the caller genuinely has one answer for every state, and +say so in a comment. + ### Plugin shape A plugin is an Android *application* module (despite installing as a library) with: diff --git a/ai-agent-gemini/README.md b/ai-agent-gemini/README.md index 4be7e9d3..e5b5251d 100644 --- a/ai-agent-gemini/README.md +++ b/ai-agent-gemini/README.md @@ -27,10 +27,14 @@ The key is entered in **AI Core → Agent settings**, not here. It is stored encrypted (AES/GCM under a hardware-backed Android Keystore secret) and sent as an `x-goog-api-key` **header**, never in a URL query string. -`security/SecureApiKeyStore.kt` is the only copy of the crypto — this plugin owns -both the write and the read, so there are no constants to keep in sync with -another plugin. A key written under an earlier plugin id is adopted once by -`preferences/GeminiPreferences.kt` and re-encrypted here. +`security/SecureApiKeyStore.kt` holds only this plugin's Keystore alias +(`cotg_ai_gemini_key_v1`); the AES/GCM itself is the IDE's `KeystoreSecretStore` +(`plugin-api`, since **26.36** — hence this plugin's `min_ide_version`), so there +is one implementation in the process rather than a copy per plugin. The alias +stays per plugin: they all share the host's Keystore, so a shared alias would let +one plugin's invalidated-key recovery delete another's secret. A key written under +an earlier plugin id is adopted once by `preferences/GeminiPreferences.kt` and +re-encrypted here. ## Installation @@ -57,7 +61,7 @@ root of `com/itsaky/androidide/plugins/aiagentgemini/`. - `plugin/GeminiPlugin.kt` — plugin entry point; registers the backend with ai-core - `backend/GeminiBackend.kt` — the REST transport, streaming (SSE) and model catalog - `errors/GeminiErrorFormatter.kt` — turns an API failure into one translated sentence -- `security/SecureApiKeyStore.kt` — AES/GCM at rest +- `security/SecureApiKeyStore.kt` — this plugin's Keystore alias, over the IDE's `KeystoreSecretStore` - `preferences/GeminiPreferences.kt` — this plugin's settings store, plus the one-time adoption of settings written under earlier plugin ids - `prompt/GeminiSystemPrompt.kt` — the system prompt this cloud model is given diff --git a/ai-agent-gemini/ai-agent-gemini.html b/ai-agent-gemini/ai-agent-gemini.html index 9a228040..e73c45ab 100644 --- a/ai-agent-gemini/ai-agent-gemini.html +++ b/ai-agent-gemini/ai-agent-gemini.html @@ -82,9 +82,9 @@

Technical architecture

GeminiErrorFormatterClassifies a failure (retired model, quota, refused key, outage, unreachable) so it can be reported as one translated sentence. - SecureApiKeyStoreAES/GCM encryption of the API - key under a hardware-backed Android Keystore secret owned by this - plugin. + SecureApiKeyStoreBinds this plugin's Keystore + alias to the IDE's KeystoreSecretStore, which AES/GCM-encrypts + the API key under a hardware-backed Android Keystore secret. GeminiSettingsFragmentThe settings pane AI Core mounts: key entry and verification, visibility toggle, and the model picker driven by the live catalog. diff --git a/ai-agent-gemini/build.gradle.kts b/ai-agent-gemini/build.gradle.kts index d3ecedf3..ab95ea6c 100644 --- a/ai-agent-gemini/build.gradle.kts +++ b/ai-agent-gemini/build.gradle.kts @@ -71,8 +71,8 @@ dependencies { testImplementation("org.json:json:20231013") } -// SecureApiKeyStore is no longer duplicated: this plugin holds the only copy, so there is nothing -// left to drift against. The parity check that guarded the ai-assistant copy went with that plugin. +// No SecureApiKeyStore parity check any more: the AES/GCM core is the host's KeystoreSecretStore +// (plugin-api), so there is one implementation in the process rather than copies to keep in step. // AAR metadata checks are disabled by convention for these application-as-library plugins. tasks.matching { diff --git a/ai-agent-gemini/src/main/AndroidManifest.xml b/ai-agent-gemini/src/main/AndroidManifest.xml index 5c721c7b..e463434a 100644 --- a/ai-agent-gemini/src/main/AndroidManifest.xml +++ b/ai-agent-gemini/src/main/AndroidManifest.xml @@ -29,11 +29,12 @@ android:name="plugin.author" android:value="App Dev for All" /> - + + android:value="26.36" /> stored.plain.trim().takeIf { it.isNotBlank() } + KeystoreSecretStore.Stored.Absent -> null + // Reported here rather than passed on as "no key": generation fails either way, but a + // lost Keystore entry needs the key entering again, and the log is all that says so. + KeystoreSecretStore.Stored.Unreadable -> { + context.logger.warn( + "GeminiBackend: the saved API key cannot be decrypted on this device; " + + "it has to be entered again in settings" + ) + null + } + // Transient, so it returns without caching: the key is very likely intact, and caching + // this answer would freeze "no key" until the stored value itself changed. + KeystoreSecretStore.Stored.Unavailable -> { + context.logger.warn( + "GeminiBackend: the keystore could not be reached to read the saved API key; " + + "retrying on the next read" + ) + return null + } + } val raw = prefs?.getString(GeminiPreferences.KEY_API_KEY, null) keyCache = raw?.let { it to plain } return plain diff --git a/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/preferences/GeminiPreferences.kt b/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/preferences/GeminiPreferences.kt index a23b01ff..56c5c8fb 100644 --- a/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/preferences/GeminiPreferences.kt +++ b/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/preferences/GeminiPreferences.kt @@ -2,6 +2,8 @@ package com.itsaky.androidide.plugins.aiagentgemini.preferences import android.content.SharedPreferences import com.itsaky.androidide.plugins.PluginContext +// Imported for the KDoc link below: the alias the API key is encrypted under lives there. +import com.itsaky.androidide.plugins.aiagentgemini.security.secureApiKeyStore /** * This plugin's own settings store, and the one-time move of its settings out of AI Core's. @@ -59,7 +61,7 @@ internal object GeminiPreferences { * Copies this backend's settings out of every store in [LEGACY_FILES], once. * * The API key moves as ciphertext and stays readable: it is encrypted under a Keystore alias - * (see [SecureApiKeyStore]) rather than under anything plugin-specific, and every plugin runs + * (see [secureApiKeyStore]) rather than under anything plugin-specific, and every plugin runs * in the host's process and UID. Copies rather than moves, so downgrading still finds the old * values. Call before anything reads a setting. * diff --git a/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/security/SecureApiKeyStore.kt b/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/security/SecureApiKeyStore.kt index 198b65e0..6daa4ca2 100644 --- a/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/security/SecureApiKeyStore.kt +++ b/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/security/SecureApiKeyStore.kt @@ -1,146 +1,17 @@ package com.itsaky.androidide.plugins.aiagentgemini.security -import android.content.SharedPreferences -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyPermanentlyInvalidatedException -import android.security.keystore.KeyProperties -import android.util.Base64 -import android.util.Log -import com.itsaky.androidide.plugins.aiagentgemini.logging.LOG_PREFIX -import java.security.GeneralSecurityException -import java.security.KeyStore -import javax.crypto.Cipher -import javax.crypto.KeyGenerator -import javax.crypto.SecretKey -import javax.crypto.spec.GCMParameterSpec +import com.itsaky.androidide.plugins.security.KeystoreSecretStore + +/** Unique to this plugin and fixed across releases; see [KeystoreSecretStore] for why both matter. */ +private const val ALIAS = "cotg_ai_gemini_key_v1" /** - * AES/GCM encryption for sensitive settings (currently the Gemini API key), - * keyed by a hardware-backed Android Keystore secret. Only ciphertext is - * written to SharedPreferences, so a copied prefs file (root, `adb backup`, - * forensic dump) is useless without this device's Keystore. + * This plugin's binding of [KeystoreSecretStore]: its API key, encrypted under this plugin's own + * Keystore alias. * - * The [ALIAS] must stay stable across releases: a key encrypted under one - * alias cannot be read under another, so changing it silently invalidates - * every stored key. It is also what lets a key written before the AI plugins - * were reorganised still decrypt today — every plugin runs in the host app's - * process and UID, so they all share one Android Keystore. + * The store is the IDE's, from plugin-api, and callers use it directly. A forwarding object per + * method would only be a second copy of its contract to keep in step — and one that had to pick a + * single answer for "absent" and "no longer decryptable", which callers here do not share. The + * thing this file owns is the alias. */ -object SecureApiKeyStore { - private const val TAG = "$LOG_PREFIX.SecureApiKeyStore" - private const val KEYSTORE = "AndroidKeyStore" - private const val ALIAS = "cotg_ai_gemini_key_v1" - private const val TRANSFORM = "AES/GCM/NoPadding" - private const val IV_LEN = 12 - private const val TAG_BITS = 128 - - /** Marks a stored value as ciphertext; anything without it is treated as legacy plaintext. */ - const val ENC_PREFIX = "enc:v1:" - - private fun getOrCreateKey(): SecretKey { - val ks = KeyStore.getInstance(KEYSTORE).apply { load(null) } - (ks.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey } - val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE) - generator.init( - KeyGenParameterSpec.Builder( - ALIAS, - KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT - ) - .setBlockModes(KeyProperties.BLOCK_MODE_GCM) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .build() - ) - return generator.generateKey() - } - - private fun deleteKey() { - try { - KeyStore.getInstance(KEYSTORE).apply { load(null) }.deleteEntry(ALIAS) - } catch (e: Exception) { - Log.w(TAG, "Failed to delete Keystore alias $ALIAS", e) - } - } - - private fun encryptWith(key: SecretKey, plain: String): String { - val cipher = Cipher.getInstance(TRANSFORM) - cipher.init(Cipher.ENCRYPT_MODE, key) - val iv = cipher.iv - val ciphertext = cipher.doFinal(plain.toByteArray(Charsets.UTF_8)) - val combined = ByteArray(iv.size + ciphertext.size) - System.arraycopy(iv, 0, combined, 0, iv.size) - System.arraycopy(ciphertext, 0, combined, iv.size, ciphertext.size) - return ENC_PREFIX + Base64.encodeToString(combined, Base64.NO_WRAP) - } - - /** - * Encrypt [plain] into a self-describing string: [ENC_PREFIX] + base64(iv | ciphertext). - * - * The key is not auth-bound, so a credential change does not invalidate it; an alias an - * OEM Keystore drops anyway is regenerated once before retrying. - * - * @param plain the value to encrypt - * @throws GeneralSecurityException on any other Keystore/cipher failure, so the caller can - * inform the user instead of crashing the IDE on Save - */ - @Throws(GeneralSecurityException::class) - fun encrypt(plain: String): String { - return try { - encryptWith(getOrCreateKey(), plain) - } catch (e: KeyPermanentlyInvalidatedException) { - Log.w(TAG, "Keystore key invalidated; regenerating and retrying encrypt", e) - deleteKey() - encryptWith(getOrCreateKey(), plain) - } - } - - /** - * Return the plaintext for a stored value, handling both formats transparently: - * an [ENC_PREFIX] value is decrypted; anything else is returned unchanged as - * legacy plaintext (use [readAndMigrate] to upgrade it in place). Returns - * null if a ciphertext value can't be decrypted — e.g. the Keystore key was - * lost or invalidated — in which case the user must re-enter the key. - */ - fun decrypt(stored: String?): String? { - if (stored == null) return null - if (!stored.startsWith(ENC_PREFIX)) return stored - return try { - val combined = Base64.decode(stored.removePrefix(ENC_PREFIX), Base64.NO_WRAP) - val iv = combined.copyOfRange(0, IV_LEN) - val ciphertext = combined.copyOfRange(IV_LEN, combined.size) - val cipher = Cipher.getInstance(TRANSFORM) - cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(TAG_BITS, iv)) - String(cipher.doFinal(ciphertext), Charsets.UTF_8) - } catch (e: Exception) { - Log.w(TAG, "Failed to decrypt stored API key", e) - null - } - } - - /** - * Read [key] from [prefs], upgrading a legacy plaintext value to ciphertext in place. - * - * Keys written before this store existed are still plaintext on disk, and [decrypt] alone - * hands them back unchanged forever — so an install that configured its key earlier would - * never actually gain encryption. Re-encrypting on the first read closes that gap without - * making the user re-enter the key. - * - * The value is trimmed on migration, so the stored, displayed and sent forms all agree. - * - * Keystore IPC + AES/GCM, so call this off the main thread. - * - * @return the trimmed plaintext value, or null when nothing is stored or decryption failed. - */ - fun readAndMigrate(prefs: SharedPreferences?, key: String): String? { - val stored = prefs?.getString(key, null) ?: return null - if (stored.startsWith(ENC_PREFIX)) return decrypt(stored) - val plain = stored.trim() - if (plain.isEmpty()) return plain - try { - prefs.edit().putString(key, encrypt(plain)).apply() - Log.i(TAG, "Upgraded legacy plaintext value for '$key' to ciphertext") - } catch (e: Exception) { - Log.w(TAG, "Could not upgrade legacy plaintext value for '$key' to ciphertext", e) - } - return plain - } -} +val secureApiKeyStore = KeystoreSecretStore(ALIAS) diff --git a/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsFragment.kt b/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsFragment.kt index bec2f7bd..cc0402ef 100644 --- a/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsFragment.kt +++ b/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsFragment.kt @@ -31,6 +31,7 @@ import com.itsaky.androidide.plugins.PluginContext import com.itsaky.androidide.plugins.aiagentgemini.plugin.GeminiPlugin import com.itsaky.androidide.plugins.aiagentgemini.R import com.itsaky.androidide.plugins.base.PluginFragmentHelper +import com.itsaky.androidide.plugins.security.KeystoreSecretStore import com.itsaky.androidide.plugins.services.IdeTooltipService import kotlinx.coroutines.launch import java.text.SimpleDateFormat @@ -189,20 +190,38 @@ class GeminiSettingsFragment : Fragment() { } viewLifecycleOwner.lifecycleScope.launch { - val savedApiKey = viewModel.getGeminiApiKey() + val stored = viewModel.getGeminiApiKey() + val savedApiKey = (stored as? KeystoreSecretStore.Stored.Value)?.plain val hasKey = !savedApiKey.isNullOrBlank() - updateUiState(isEditing = !hasKey) - if (hasKey) { + // A keystore that would not answer this time leaves the key on disk and intact, so the + // pane stays dressed as configured — status line, Edit, Remove. Opening edit mode + // instead would make it identical to a fresh install, contradicting the toast below. + val keptConfigured = + !hasKey && + stored is KeystoreSecretStore.Stored.Unavailable && + viewModel.hasStoredGeminiApiKey() + updateUiState(isEditing = !hasKey && !keptConfigured) + if (hasKey || keptConfigured) { statusTextView.text = savedApiKeyStatusText() - } else { + } + if (!hasKey) { apiKeyInput.setText("") - // A stored-but-undecryptable key also reads as null; warn as the Edit path does. - if (viewModel.hasStoredGeminiApiKey()) { + // Only for a key that is there and will not decrypt; an empty box alone looks like + // data loss. Nothing stored at all is the ordinary first run and says nothing. + if (stored is KeystoreSecretStore.Stored.Unreadable) { Toast.makeText( requireContext(), getString(R.string.msg_api_key_unreadable), Toast.LENGTH_LONG ).show() + } else if (stored is KeystoreSecretStore.Stored.Unavailable) { + // Said differently from the above: the key is still there and intact, so this + // must not send the user off to find and type it again. + Toast.makeText( + requireContext(), + getString(R.string.msg_api_key_unavailable), + Toast.LENGTH_LONG + ).show() } } } @@ -383,20 +402,33 @@ class GeminiSettingsFragment : Fragment() { editButton.setOnClickListener { editButton.isEnabled = false viewLifecycleOwner.lifecycleScope.launch { - val apiKey = try { + val stored = try { viewModel.getGeminiApiKey() } finally { editButton.isEnabled = true } - // null = a key IS stored but won't decrypt; an empty box alone looks like data loss. - if (apiKey == null) { + // A key that is stored and will not decrypt; an empty box alone looks like data + // loss. Told apart from "nothing stored" here, which this button rarely sees but + // must not report as a lost Keystore entry when it does. + if (stored is KeystoreSecretStore.Stored.Unavailable) { + // Said differently from an unreadable key: this one is still there and intact, + // so the pane stays as it is rather than emptying the field under a status line + // that just said the key is saved — it must not be re-typed to be recovered. + Toast.makeText( + requireContext(), + getString(R.string.msg_api_key_unavailable), + Toast.LENGTH_LONG + ).show() + return@launch + } + if (stored is KeystoreSecretStore.Stored.Unreadable) { Toast.makeText( requireContext(), getString(R.string.msg_api_key_unreadable), Toast.LENGTH_LONG ).show() } - revealEditMode(apiKey.orEmpty()) + revealEditMode((stored as? KeystoreSecretStore.Stored.Value)?.plain?.trim().orEmpty()) } } diff --git a/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsViewModel.kt b/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsViewModel.kt index 12060510..65dc0ac3 100644 --- a/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsViewModel.kt +++ b/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsViewModel.kt @@ -10,7 +10,8 @@ import com.itsaky.androidide.plugins.PluginLogger import com.itsaky.androidide.plugins.aiagentgemini.backend.GeminiBackend import com.itsaky.androidide.plugins.aiagentgemini.logging.LOG_PREFIX import com.itsaky.androidide.plugins.aiagentgemini.preferences.GeminiPreferences -import com.itsaky.androidide.plugins.aiagentgemini.security.SecureApiKeyStore +import com.itsaky.androidide.plugins.aiagentgemini.security.secureApiKeyStore +import com.itsaky.androidide.plugins.security.KeystoreSecretStore import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers @@ -112,7 +113,7 @@ class GeminiSettingsViewModel( } /** - * Encrypts [apiKey] via [SecureApiKeyStore] and persists only the ciphertext to private prefs, + * Encrypts [apiKey] via [secureApiKeyStore] and persists only the ciphertext to private prefs, * off the main thread. Nothing is written on failure. Kept separate from [verifyGeminiKey]: a * rejected key never reaches here, and an unverifiable one only after the user says so. * @@ -130,7 +131,7 @@ class GeminiSettingsViewModel( return@withContext false } val encrypted = try { - SecureApiKeyStore.encrypt(apiKey.trim()) + secureApiKeyStore.encrypt(apiKey.trim()) } catch (e: Exception) { logger?.error("$TAG: failed to encrypt Gemini API key", e) return@withContext false @@ -156,15 +157,19 @@ class GeminiSettingsViewModel( * Decrypt the stored key off the main thread (Keystore IPC + AES/GCM), upgrading a * pre-encryption plaintext key to ciphertext in passing so existing installs actually * end up encrypted rather than waiting for the user to re-enter the key. + * + * @return what is on disk: nothing, the key, a key this device's Keystore can no longer open, + * or one it would not open just now. Those are not the same — a lost Keystore entry has to be + * entered again, a keystore that did not answer only retried — so the caller says which. */ - suspend fun getGeminiApiKey(): String? = withContext(ioDispatcher) { - SecureApiKeyStore.readAndMigrate(prefs(), KEY_API_KEY) + suspend fun getGeminiApiKey(): KeystoreSecretStore.Stored = withContext(ioDispatcher) { + secureApiKeyStore.readAndMigrate(prefs(), KEY_API_KEY) } /** - * True when a key is present on disk, whether or not it can still be decrypted. Lets the UI - * tell "nothing was saved" from "the Keystore entry is gone" — [getGeminiApiKey] is null for - * both. Raw pref only, so no Keystore IPC and safe on the main thread. + * True when a key is present on disk, whether or not it can still be decrypted: what the key + * block is dressed from, which must not collapse the moment the Keystore declines to answer. + * Raw pref only, so no Keystore IPC and safe on the main thread — which [getGeminiApiKey] is not. */ fun hasStoredGeminiApiKey(): Boolean = !prefs()?.getString(KEY_API_KEY, null).isNullOrBlank() @@ -199,9 +204,11 @@ class GeminiSettingsViewModel( _geminiModelsLoading.postValue(true) try { - val apiKey = getGeminiApiKey()?.trim() + // The fallback list is the answer to any key it cannot read, whatever the reason, + // so this is one of the few callers that has no use for the difference. + val apiKey = (getGeminiApiKey() as? KeystoreSecretStore.Stored.Value)?.plain?.trim() if (apiKey.isNullOrBlank()) { - logger?.warn("$TAG: no Gemini API key saved; showing fallback models") + logger?.warn("$TAG: no usable Gemini API key saved; showing fallback models") _geminiModels.postValue(GeminiModelOptions(FALLBACK_MODELS, isLive = false)) return@launch } diff --git a/ai-agent-gemini/src/main/res/values/strings.xml b/ai-agent-gemini/src/main/res/values/strings.xml index 255b1997..d5345f3e 100644 --- a/ai-agent-gemini/src/main/res/values/strings.xml +++ b/ai-agent-gemini/src/main/res/values/strings.xml @@ -30,6 +30,7 @@ API Key saved on: %s API Key saved and verified on: %s The stored API key could not be read on this device. Please enter it again. + The device keystore could not be reached, so the stored API key could not be read. It is still saved — please try again in a moment. Couldn\'t save the API key on this device. Please try again. Checking this key with Google… Verified, your API key works diff --git a/ai-agent-mcp/README.md b/ai-agent-mcp/README.md index 67a7fb87..d2e2ab5b 100644 --- a/ai-agent-mcp/README.md +++ b/ai-agent-mcp/README.md @@ -79,7 +79,7 @@ Two dependencies were deliberately not taken: `McpToolCatalog` (what each server last advertised), `McpToolText` (sanitising) - `settings/` — server CRUD, per-tool toggles, the settings pane - `errors/` — HTTP and JSON-RPC failures reduced to one translated sentence -- `security/` — Keystore-backed token encryption +- `security/SecureTokenStore.kt` — this plugin's Keystore alias, over the IDE's `KeystoreSecretStore` ## Security notes @@ -91,11 +91,18 @@ Two dependencies were deliberately not taken: pairing — the two plugins version independently — so an `ai-core` older than the release that flattens at its `ContributedToolHandler` boundary would take raw multi-line server text into the prompt. -- Tokens are encrypted with an AES/GCM key held in the Android Keystore under - this plugin's own alias; only ciphertext is written to disk. A token that can no - longer be decrypted — a restored backup, an OEM Keystore reset — is reported as - exactly that, never sent as an absent one, which would surface as the server - refusing a token that is still stored and still correct. +- Tokens and custom headers are encrypted with an AES/GCM key held in the Android + Keystore under this plugin's own alias; only ciphertext is written to disk. + `security/SecureTokenStore.kt` holds nothing but that alias (`cotg_ai_mcp_token_v1`); + the AES/GCM itself is the IDE's `KeystoreSecretStore` (`plugin-api`, since + **26.36** — hence this plugin's `min_ide_version`), so there is one implementation + in the process rather than a copy per plugin. The alias stays per plugin: they all + share the host's Keystore, so a shared alias would let one plugin's invalidated-key + recovery delete another's secret. A token that can no longer be decrypted — a + restored backup, an OEM Keystore reset — is reported as exactly that, never sent as + an absent one, which would surface as the server refusing a token that is still + stored and still correct; a Keystore that merely would not answer is told apart + again, since that token is intact and the call is only worth retrying. - A token or a custom header is refused on an `http://` URL: encryption at rest buys nothing for a credential sent in the clear. - Redirects are never followed automatically. A 3xx is repeated only when it diff --git a/ai-agent-mcp/ai-agent-mcp.html b/ai-agent-mcp/ai-agent-mcp.html index 121feb82..d45b4742 100644 --- a/ai-agent-mcp/ai-agent-mcp.html +++ b/ai-agent-mcp/ai-agent-mcp.html @@ -107,8 +107,9 @@

Technical architecture

McpToolTextSanitises server-supplied names and descriptions — untrusted remote text that would otherwise land verbatim in a prompt assembled inside a third-party backend plugin. - SecureTokenStoreAES/GCM encryption under a - Keystore alias owned by this plugin, so only ciphertext reaches disk. + SecureTokenStoreBinds this plugin's own Keystore + alias to the IDE's KeystoreSecretStore, whose AES/GCM keeps + only ciphertext on disk.

The transport is MCP's Streamable HTTP revision over HttpURLConnection. Two dependencies were deliberately not taken: diff --git a/ai-agent-mcp/build.gradle.kts b/ai-agent-mcp/build.gradle.kts index cf5a3325..b082c4ce 100644 --- a/ai-agent-mcp/build.gradle.kts +++ b/ai-agent-mcp/build.gradle.kts @@ -75,6 +75,7 @@ dependencies { testImplementation(files("../libs/plugin-api.jar")) testImplementation("junit:junit:4.13.2") + testImplementation("io.mockk:mockk:1.13.8") testImplementation("org.json:json:20240303") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") } diff --git a/ai-agent-mcp/src/main/AndroidManifest.xml b/ai-agent-mcp/src/main/AndroidManifest.xml index e85fb8c9..5c9eeee8 100644 --- a/ai-agent-mcp/src/main/AndroidManifest.xml +++ b/ai-agent-mcp/src/main/AndroidManifest.xml @@ -29,9 +29,12 @@ android:name="plugin.author" android:value="App Dev for All" /> + + android:value="26.36" /> stored.plain - SecureTokenStore.Stored.Absent -> "" - SecureTokenStore.Stored.Unreadable -> + // Trimmed here, not by the store: the host's readAndMigrate migrates verbatim, and a + // legacy token saved with a stray newline is one `setRequestProperty` refuses to send. + is KeystoreSecretStore.Stored.Value -> stored.plain.trim() + KeystoreSecretStore.Stored.Absent -> "" + KeystoreSecretStore.Stored.Unreadable -> throw UnreadableSecretException("The stored token for '$serverId' cannot be decrypted.") + // Not Unreadable: the token is very likely intact and the call is worth repeating, so + // the user is told to retry rather than to enter the token again. + KeystoreSecretStore.Stored.Unavailable -> + throw UnavailableSecretException( + "The stored token for '$serverId' could not be read just now." + ) } return McpCredentials(token, McpServerStore.headers(serverId)) } diff --git a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/errors/McpErrorFormatter.kt b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/errors/McpErrorFormatter.kt index 30bc3ddd..05081868 100644 --- a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/errors/McpErrorFormatter.kt +++ b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/errors/McpErrorFormatter.kt @@ -3,6 +3,7 @@ package com.itsaky.androidide.plugins.aiagentmcp.errors import android.content.Context import com.itsaky.androidide.plugins.aiagentmcp.R import com.itsaky.androidide.plugins.aiagentmcp.client.McpProtocolException +import com.itsaky.androidide.plugins.aiagentmcp.security.UnavailableSecretException import com.itsaky.androidide.plugins.aiagentmcp.security.UnreadableSecretException import com.itsaky.androidide.plugins.aiagentmcp.transport.McpHttpException import com.itsaky.androidide.plugins.aiagentmcp.transport.McpRedirectException @@ -64,6 +65,9 @@ sealed interface McpFailure { /** A stored credential cannot be decrypted on this device, so nothing was sent. */ data object SecretUnreadable : McpFailure + /** The keystore would not open a stored credential just now, so nothing was sent — retry. */ + data object SecretUnavailable : McpFailure + /** The server redirected somewhere the request cannot be repeated with its credentials. */ data object RedirectRefused : McpFailure @@ -88,6 +92,7 @@ object McpErrorFormatter { fun classify(error: Throwable): McpFailure = when (error) { // Before the IOException branches below, which it is one of. is UnreadableSecretException -> McpFailure.SecretUnreadable + is UnavailableSecretException -> McpFailure.SecretUnavailable is McpRedirectException -> McpFailure.RedirectRefused is McpHttpException -> forStatus(error.statusCode) is McpProtocolException -> McpFailure.Rejected(error.message.orEmpty()) @@ -125,6 +130,8 @@ object McpErrorFormatter { McpFailure.Cancelled -> context.getString(R.string.mcp_error_cancelled, serverName) McpFailure.SecretUnreadable -> context.getString(R.string.mcp_error_secret_unreadable, serverName) + McpFailure.SecretUnavailable -> + context.getString(R.string.mcp_error_secret_unavailable, serverName) McpFailure.RedirectRefused -> context.getString(R.string.mcp_error_redirect_refused, serverName) is McpFailure.ServerError -> diff --git a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/plugin/McpPlugin.kt b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/plugin/McpPlugin.kt index 2dc68346..e9493e56 100644 --- a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/plugin/McpPlugin.kt +++ b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/plugin/McpPlugin.kt @@ -18,6 +18,7 @@ import com.itsaky.androidide.plugins.services.SharedServices import com.itsaky.androidide.plugins.services.ToolSourceRegistry import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.isActive @@ -38,15 +39,33 @@ class McpPlugin : IPlugin, SettingsExtension, DocumentationExtension { /** True once [toolSource] is registered with AI Core, so re-registration is idempotent. */ @Volatile private var registered = false + /** + * Serialises every swap of [scope]. + * + * Cancelling the old scope and installing a new one is one transition, not two: without this, + * two lifecycle calls landing together can each cancel the scope the other has already replaced, + * leaving a live refresh behind after [deactivate] or orphaning an activation's scope uncancelled. + * `@Volatile` alone would publish each write but still let the pair interleave. + */ + private val lifecycleLock = Any() + /** * Background work: listing tools is network work and never belongs on the main thread. * * Replaced on every [activate] and cancelled by [deactivate], so a refresh left running cannot * register sessions in [McpConnections] after `closeAll()` emptied the map. Volatile like - * [registered]: the host may drive the lifecycle from one thread and the next from another. + * [registered]: the host may drive the lifecycle from one thread and the next from another, + * and [scopeJob] reads it outside [lifecycleLock]. */ @Volatile private var scope = newScope() + /** + * The scope [stopScope] leaves behind: cancelled from birth, so a `launch` arriving after the + * lifecycle edge is the no-op it has always been. One per plugin, since cancellation is + * terminal and a cancelled scope carries no state a later stop could disturb. + */ + private val stoppedScope = newScope().apply { cancel() } + companion object { /** Must match `plugin.id` in AndroidManifest.xml; also this source's provider id. */ const val PLUGIN_ID = "com.itsaky.androidide.plugins.aiagentmcp" @@ -117,9 +136,11 @@ class McpPlugin : IPlugin, SettingsExtension, DocumentationExtension { } override fun activate(): Boolean = try { - // Cancelled first: a host that activates twice would otherwise orphan the running scope. - scope.cancel() - scope = newScope() + // Cancelled and replaced as one step: a host that activates twice would otherwise orphan + // the running scope, and the launch below has to use this activation's scope, not whatever + // a concurrent lifecycle call has since installed. + val active = swapScope(newScope()) + activationJob = active.coroutineContext[Job] toolSource = McpToolSource() McpServerStore.addChangeListener(settingsChanged) context.addPluginLifecycleListener(aiCoreLifecycle) @@ -130,7 +151,7 @@ class McpPlugin : IPlugin, SettingsExtension, DocumentationExtension { // Tool lists are answered from cache, so the cache has to be filled before the user opens // the Agent — otherwise the first cold-start session sees no MCP tools at all. - scope.launch { + active.launch { val refreshed = McpToolCatalog.refreshAll { isActive } if (refreshed > 0 && isActive) settingsChanged() } @@ -146,7 +167,7 @@ class McpPlugin : IPlugin, SettingsExtension, DocumentationExtension { unregisterToolSource() // Before the connections are closed: an in-flight refresh would otherwise repopulate the // catalogue and the session map straight after they were cleared. - scope.cancel() + stopScope() releaseConnections() true } catch (e: Exception) { @@ -158,7 +179,7 @@ class McpPlugin : IPlugin, SettingsExtension, DocumentationExtension { runCatching { context.removePluginLifecycleListener(aiCoreLifecycle) } McpServerStore.removeChangeListener(settingsChanged) unregisterToolSource() - scope.cancel() + stopScope() releaseConnections() pluginContext = null context.logger.info("McpPlugin: disposed") @@ -167,6 +188,52 @@ class McpPlugin : IPlugin, SettingsExtension, DocumentationExtension { /** A fresh scope for this activation; the previous one is cancelled, never reused. */ private fun newScope() = CoroutineScope(SupervisorJob() + Dispatchers.IO) + /** + * Installs [next] as the current scope and cancels whichever scope it displaced. + * + * @param next the scope to install. + * @return [next], so a caller can launch on the scope it installed rather than re-reading the + * field and handing its work to a later activation. + */ + private fun swapScope(next: CoroutineScope): CoroutineScope { + val previous = synchronized(lifecycleLock) { scope.also { scope = next } } + previous.cancel() + return next + } + + /** + * Ends the current activation's scope, leaving an already-cancelled one in the field. + * + * Installing [stoppedScope] rather than cancelling the field in place: an [activate] running + * alongside this has by then installed a scope of its own, and cancelling whatever the field + * happens to hold would either miss it or kill it. Swapping ends exactly the scope this call + * displaced. + */ + private fun stopScope() { + swapScope(stoppedScope) + } + + /** + * The current activation scope's job. + * + * A seam: the rule that a second [activate] orphans nothing and that [deactivate] leaves no + * refresh running is otherwise only observable on a device, where the symptom is a background + * `tools/list` repopulating a catalogue that was just cleared. + */ + internal val scopeJob: Job? + get() = scope.coroutineContext[Job] + + /** + * The job of the scope the last [activate] launched its refresh on. + * + * The second half of the seam: [scopeJob] says what the field holds, and this says what an + * activation actually handed work to. A stop that displaces some other scope leaves the two + * disagreeing — a live refresh nothing can reach — which is the orphan [lifecycleLock] exists + * to prevent and the only way a test can see it. + */ + @Volatile internal var activationJob: Job? = null + private set + /** * Registers this plugin's tools with AI Core, if the registry is reachable. * diff --git a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/security/SecureTokenStore.kt b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/security/SecureTokenStore.kt index 40e44e13..38139600 100644 --- a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/security/SecureTokenStore.kt +++ b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/security/SecureTokenStore.kt @@ -1,199 +1,16 @@ package com.itsaky.androidide.plugins.aiagentmcp.security -import android.content.SharedPreferences -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyPermanentlyInvalidatedException -import android.security.keystore.KeyProperties -import android.util.Base64 -import android.util.Log -import com.itsaky.androidide.plugins.aiagentmcp.logging.LOG_PREFIX -import java.security.GeneralSecurityException -import java.security.KeyStore -import javax.crypto.Cipher -import javax.crypto.KeyGenerator -import javax.crypto.SecretKey -import javax.crypto.spec.GCMParameterSpec +import com.itsaky.androidide.plugins.security.KeystoreSecretStore -private const val TAG = "$LOG_PREFIX.SecureTokenStore" +/** Unique to this plugin and fixed across releases; see [KeystoreSecretStore] for why both matter. */ +private const val ALIAS = "cotg_ai_mcp_token_v1" /** - * AES/GCM encryption for the bearer tokens of configured MCP servers, keyed by a hardware-backed - * Android Keystore secret. Only ciphertext is written to SharedPreferences, so a copied prefs file - * (root, `adb backup`, forensic dump) is useless without this device's Keystore. + * This plugin's binding of [KeystoreSecretStore]: the bearer tokens and extra headers of configured + * MCP servers, encrypted under this plugin's own Keystore alias. * - * The [ALIAS] must stay stable across releases — a token encrypted under one alias cannot be read - * under another — and is deliberately this plugin's own: every plugin runs in the host's process - * and UID and therefore shares one Keystore, so a shared alias would let this plugin's recovery - * path destroy a backend plugin's stored key as a side effect. + * The store is the IDE's, from plugin-api, and callers use it directly. A forwarding object per + * method would only be a second copy of its contract to keep in step — the thing this file owns is + * the alias. */ -object SecureTokenStore { - - /** - * What was found under a preference key. - * - * Three outcomes rather than a nullable String: "nothing stored" and "stored but no longer - * readable on this device" lead to opposite advice, and collapsing them is what tells a user - * their token was refused when it was never sent. - */ - sealed interface Stored { - - /** Nothing is stored under the key. */ - data object Absent : Stored - - /** The stored value, decrypted. */ - data class Value(val plain: String) : Stored - - /** Something is stored, but this device's Keystore can no longer open it. */ - data object Unreadable : Stored - } - - private const val KEYSTORE = "AndroidKeyStore" - private const val ALIAS = "cotg_ai_mcp_token_v1" - private const val TRANSFORM = "AES/GCM/NoPadding" - private const val IV_LEN = 12 - private const val TAG_BITS = 128 - - /** Marks a stored value as ciphertext; anything without it is treated as legacy plaintext. */ - const val ENC_PREFIX = "enc:v1:" - - /** - * Encrypts [plain] into a self-describing string: [ENC_PREFIX] + base64(iv | ciphertext). - * - * The key is not auth-bound, so a credential change does not invalidate it; an alias an OEM - * Keystore drops anyway is regenerated once before retrying. - * - * @param plain the value to encrypt. - * @return the ciphertext to store. - * @throws GeneralSecurityException on any other Keystore or cipher failure, so the caller can - * tell the user instead of crashing the IDE on Save. - */ - @Throws(GeneralSecurityException::class) - fun encrypt(plain: String): String = try { - encryptWith(getOrCreateKey(), plain) - } catch (e: KeyPermanentlyInvalidatedException) { - Log.w(TAG, "Keystore key invalidated; regenerating and retrying encrypt", e) - deleteKey() - encryptWith(getOrCreateKey(), plain) - } - - /** - * Reads a stored value back. - * @param stored the stored string, ciphertext or legacy plaintext. - * @return the plaintext, or null when a ciphertext value cannot be decrypted — the Keystore key - * was lost, and the user has to enter the token again. - */ - fun decrypt(stored: String?): String? { - if (stored == null) return null - if (!stored.startsWith(ENC_PREFIX)) return stored - return try { - val combined = Base64.decode(stored.removePrefix(ENC_PREFIX), Base64.NO_WRAP) - val iv = combined.copyOfRange(0, IV_LEN) - val ciphertext = combined.copyOfRange(IV_LEN, combined.size) - val cipher = Cipher.getInstance(TRANSFORM) - cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(TAG_BITS, iv)) - // Zeroed once the String is built; see the note in encryptWith about the String. - val plainBytes = cipher.doFinal(ciphertext) - try { - String(plainBytes, Charsets.UTF_8) - } finally { - plainBytes.fill(0) - } - } catch (e: Exception) { - Log.w(TAG, "Failed to decrypt a stored MCP token", e) - null - } - } - - /** - * Stores [plain] under [key], encrypted; an empty value removes the entry instead. - * - * Keystore IPC plus AES/GCM, so call this off the main thread. - * - * @param prefs where to store it. - * @param key the preference key. - * @param plain the token, or empty to forget it. - * @return true when the value was stored (or removed), false when encryption failed. - */ - fun write(prefs: SharedPreferences?, key: String, plain: String): Boolean { - val editor = prefs?.edit() ?: return false - if (plain.isBlank()) { - editor.remove(key).apply() - return true - } - return try { - editor.putString(key, encrypt(plain)).apply() - true - } catch (e: Exception) { - Log.e(TAG, "Could not encrypt a token for '$key'", e) - false - } - } - - /** - * Reads [key] from [prefs], upgrading a legacy plaintext value to ciphertext in place. - * - * @param prefs where the value lives. - * @param key the preference key. - * @return what was found: nothing, the plaintext, or a value that cannot be decrypted here. - */ - fun readAndMigrate(prefs: SharedPreferences?, key: String): Stored { - val stored = prefs?.getString(key, null) ?: return Stored.Absent - if (stored.startsWith(ENC_PREFIX)) { - // A lost Keystore alias — restore onto new hardware, an OEM reset, a re-enrolled screen - // lock — is not the same as an absent token, and must not be reported as one. - return decrypt(stored)?.let(Stored::Value) ?: Stored.Unreadable - } - val plain = stored.trim() - if (plain.isEmpty()) return Stored.Value(plain) - try { - prefs.edit().putString(key, encrypt(plain)).apply() - Log.i(TAG, "Upgraded a legacy plaintext token to ciphertext") - } catch (e: Exception) { - Log.w(TAG, "Could not upgrade a legacy plaintext token to ciphertext", e) - } - return Stored.Value(plain) - } - - private fun getOrCreateKey(): SecretKey { - val store = KeyStore.getInstance(KEYSTORE).apply { load(null) } - (store.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey } - val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE) - generator.init( - KeyGenParameterSpec.Builder( - ALIAS, - KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT - ) - .setBlockModes(KeyProperties.BLOCK_MODE_GCM) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .build() - ) - return generator.generateKey() - } - - private fun deleteKey() { - try { - KeyStore.getInstance(KEYSTORE).apply { load(null) }.deleteEntry(ALIAS) - } catch (e: Exception) { - Log.w(TAG, "Failed to delete Keystore alias $ALIAS", e) - } - } - - private fun encryptWith(key: SecretKey, plain: String): String { - val cipher = Cipher.getInstance(TRANSFORM) - cipher.init(Cipher.ENCRYPT_MODE, key) - val iv = cipher.iv - // Zeroed straight after the cipher reads it. The String itself cannot be: every API this - // token passes through — SharedPreferences, JSONObject, setRequestProperty — takes one, so - // a CharArray here would only move the immutable copy one frame away. - val plainBytes = plain.toByteArray(Charsets.UTF_8) - val ciphertext = try { - cipher.doFinal(plainBytes) - } finally { - plainBytes.fill(0) - } - val combined = ByteArray(iv.size + ciphertext.size) - System.arraycopy(iv, 0, combined, 0, iv.size) - System.arraycopy(ciphertext, 0, combined, iv.size, ciphertext.size) - return ENC_PREFIX + Base64.encodeToString(combined, Base64.NO_WRAP) - } -} +val secureTokenStore = KeystoreSecretStore(ALIAS) diff --git a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/security/UnavailableSecretException.kt b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/security/UnavailableSecretException.kt new file mode 100644 index 00000000..cb3355cd --- /dev/null +++ b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/security/UnavailableSecretException.kt @@ -0,0 +1,14 @@ +package com.itsaky.androidide.plugins.aiagentmcp.security + +import java.io.IOException + +/** + * A stored credential this device's Keystore would not open just now. + * + * Its own type rather than an [UnreadableSecretException], because the two lead to opposite advice: + * the credential here is very likely intact — the Keystore was not ready, or a binder call failed — + * and the only useful thing to say is to try again, not to enter the credential over. + * + * @param detail what could not be read, for logcat; the user sees the formatted sentence instead. + */ +class UnavailableSecretException(detail: String) : IOException(detail) diff --git a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpServerStore.kt b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpServerStore.kt index 75e60950..e00e6b38 100644 --- a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpServerStore.kt +++ b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpServerStore.kt @@ -4,9 +4,11 @@ import android.content.SharedPreferences import android.util.Log import com.itsaky.androidide.plugins.aiagentmcp.logging.LOG_PREFIX import com.itsaky.androidide.plugins.aiagentmcp.plugin.McpPlugin -import com.itsaky.androidide.plugins.aiagentmcp.security.SecureTokenStore +import com.itsaky.androidide.plugins.aiagentmcp.security.UnavailableSecretException import com.itsaky.androidide.plugins.aiagentmcp.security.UnreadableSecretException +import com.itsaky.androidide.plugins.aiagentmcp.security.secureTokenStore import com.itsaky.androidide.plugins.aiagentmcp.transport.McpHeaders +import com.itsaky.androidide.plugins.security.KeystoreSecretStore import java.util.UUID import java.util.concurrent.CopyOnWriteArrayList import org.json.JSONArray @@ -164,10 +166,11 @@ object McpServerStore { * * @param id the server the token belongs to. * @param token the token, or blank to remove it. - * @return true when it was stored. + * @return true once it is on disk, or removed for a blank one; false when encrypting it or the + * write itself failed, which is what the pane must not report as saved. */ fun setToken(id: String, token: String): Boolean { - val stored = SecureTokenStore.write(prefs(), KEY_TOKEN_PREFIX + id, token) + val stored = secureTokenStore.write(prefs(), KEY_TOKEN_PREFIX + id, token) // Like every other mutator: a new credential has to reach the agent, or it keeps calling // with the old one until something else happens to touch the store. fireChanged() @@ -180,10 +183,11 @@ object McpServerStore { * Keystore work, so call this off the main thread. * * @param id the server. - * @return what is stored: nothing, the token, or a token this device can no longer read. + * @return what is stored: nothing, the token, a token this device can no longer read, or one + * the keystore would not open just now. */ - fun token(id: String): SecureTokenStore.Stored = - SecureTokenStore.readAndMigrate(prefs(), KEY_TOKEN_PREFIX + id) + fun token(id: String): KeystoreSecretStore.Stored = + secureTokenStore.readAndMigrate(prefs(), KEY_TOKEN_PREFIX + id) /** True when a token is stored for [id], without decrypting it. */ fun hasToken(id: String): Boolean = prefs()?.contains(KEY_TOKEN_PREFIX + id) == true @@ -200,13 +204,18 @@ object McpServerStore { * @return the headers in the order they were entered; empty when there are none. */ fun headers(id: String): Map { - val stored = SecureTokenStore.readAndMigrate(prefs(), KEY_HEADERS_PREFIX + id) - if (stored is SecureTokenStore.Stored.Unreadable) { + val stored = secureTokenStore.readAndMigrate(prefs(), KEY_HEADERS_PREFIX + id) + if (stored is KeystoreSecretStore.Stored.Unreadable) { // Same failure as an unreadable token, and reported the same way: sending the request // without them would look like the server refusing a credential that is still correct. throw UnreadableSecretException("The stored headers for '$id' cannot be decrypted.") } - val raw = (stored as? SecureTokenStore.Stored.Value)?.plain ?: return emptyMap() + if (stored is KeystoreSecretStore.Stored.Unavailable) { + // Kept apart from the above: these headers are very likely intact, so the caller is + // told to retry instead of asking the user to enter them again. + throw UnavailableSecretException("The stored headers for '$id' could not be read just now.") + } + val raw = (stored as? KeystoreSecretStore.Stored.Value)?.plain ?: return emptyMap() return try { val json = JSONObject(raw) val parsed = LinkedHashMap() @@ -225,24 +234,32 @@ object McpServerStore { * * @param id the server the headers belong to. * @param headers the headers to store; unusable pairs are dropped. - * @return true when they were stored. + * @return true once they are on disk, or removed for an empty map; false when encrypting them + * or the write itself failed. Forgetting them on a store that was never opened is success: + * there is nothing on disk to remove. */ fun setHeaders(id: String, headers: Map): Boolean { val clean = McpHeaders.sanitize(headers) val key = KEY_HEADERS_PREFIX + id - if (clean.isEmpty()) { - prefs()?.edit()?.remove(key)?.apply() - fireChanged() - return true + // The empty map goes through write() as a blank, which removes the entry, rather than + // through a bare remove/apply: the token's clear is synchronous and says whether it landed, + // and headers left on disk for a credential the user just forgot are the same leak. + val payload = if (clean.isEmpty()) { + "" + } else { + val json = JSONObject() + clean.forEach { (name, value) -> json.put(name, value) } + json.toString() } - val json = JSONObject() - clean.forEach { (name, value) -> json.put(name, value) } - val stored = SecureTokenStore.write(prefs(), key, json.toString()) + // A clear with no preferences behind it has nothing to remove, so it succeeded; only a + // write with something to store has genuinely failed. + val prefs = prefs() ?: return clean.isEmpty() + val stored = secureTokenStore.write(prefs, key, payload) fireChanged() return stored } - /** How many extra headers are configured for [id], without decrypting them. */ + /** Whether any extra header is configured for [id], without decrypting them. */ fun hasHeaders(id: String): Boolean = prefs()?.contains(KEY_HEADERS_PREFIX + id) == true /** diff --git a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpSettingsFragment.kt b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpSettingsFragment.kt index 281fe248..9cc7da16 100644 --- a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpSettingsFragment.kt +++ b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpSettingsFragment.kt @@ -164,6 +164,10 @@ class McpSettingsFragment : Fragment() { // Unknown, never Absent, until the decrypt answers: guessing is what let an http:// URL // be saved over a token that was still stored and still sent. var credential = if (existing == null) Credential.ABSENT else Credential.UNKNOWN + // What the header rows on screen are: the stored set, or not that. Anything but + // [Headers.KNOWN] means an empty list is "never drawn" rather than "none", and a typed row + // is no basis for replacing a set the dialog was in no position to show. + var headersState = if (existing == null) Headers.KNOWN else Headers.UNKNOWN nameField.setText(server.name) urlField.setText(server.url) @@ -179,7 +183,11 @@ class McpSettingsFragment : Fragment() { clearButton.setOnClickListener { clearButton.isEnabled = false viewModel.clearCredential(server.id) { cleared -> - if (cleared) credential = Credential.ABSENT + if (cleared) { + credential = Credential.ABSENT + // Nothing is stored now, so the empty list on screen is the stored truth again. + headersState = Headers.KNOWN + } whileDialogShown { clearButton.isEnabled = !cleared clearButton.visibility = if (cleared) View.GONE else View.VISIBLE @@ -207,18 +215,40 @@ class McpSettingsFragment : Fragment() { whileDialogShown { // The stored token is never shown: it is decrypted only to be sent. An empty // field on an existing server means "leave it alone", which the placeholder - // says aloud — unless nothing on this device can read it any more. - if (form.secretsUnreadable) { - tokenField.hint = getString(R.string.mcp_hint_token_unreadable) - status.text = getString(R.string.mcp_secrets_unreadable) - } else if (form.hasToken) { - tokenField.hint = getString(R.string.mcp_hint_token_stored) + // says aloud — unless nothing on this device can read it any more. The hint + // speaks for the token alone; headers that failed have their own sentence. + tokenField.hint = getString( + when { + form.tokenUnreadable -> R.string.mcp_hint_token_unreadable + form.tokenUnavailable -> R.string.mcp_hint_token_unavailable + form.hasToken -> R.string.mcp_hint_token_stored + else -> R.string.mcp_hint_token + } + ) + // The status line covers whichever credential failed. Unreadable first: a + // credential that has to be entered again is the worse news. + when { + form.tokenUnreadable || form.headersUnreadable -> + status.text = getString(R.string.mcp_secrets_unreadable) + form.tokenUnavailable || form.headersUnavailable -> + status.text = getString(R.string.mcp_secrets_unavailable) } // Offered only once something is known to be stored; there is nothing to // clear otherwise, and an unreadable secret is exactly what it is for. clearButton.visibility = if (credential == Credential.PRESENT) View.VISIBLE else View.GONE - renderHeaders(view, form.headers) + // Recorded beside the render that draws them, not behind the credential guard + // above: these rows appear whatever that guard decided, and a deletion made + // once they are on screen must not be dropped as "never drawn". + headersState = when { + form.headersKnown -> Headers.KNOWN + form.headersUnavailable -> Headers.UNAVAILABLE + else -> Headers.UNREADABLE + } + // Only the stored set replaces what is on screen. A failed decrypt has nothing + // to draw, and clearing the list would take with it any row typed while it was + // in flight — rows the save guard below then refuses to let be retyped. + if (form.headersKnown) renderHeaders(view, form.headers) } } } @@ -236,7 +266,7 @@ class McpSettingsFragment : Fragment() { return@setOnClickListener } val validation = - validate(candidate, tokenField.text.toString(), credential, headers) + validate(candidate, tokenField.text.toString(), credential, headers, headersState) if (validation != null) { status.text = validation return@setOnClickListener @@ -247,7 +277,7 @@ class McpSettingsFragment : Fragment() { viewModel.save( candidate, viewModel.tokenToStore(tokenField.text.toString()), - headers, + viewModel.headersToStore(headers, headersState == Headers.KNOWN), ) { saved, failure -> server = saved if (tokenField.text.isNotBlank() || headers.isNotEmpty()) { @@ -292,7 +322,8 @@ class McpSettingsFragment : Fragment() { serverDialog = dialog dialog.setOnDismissListener { serverDialog = null } dialog.setOnShowListener { - dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener { + val saveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE) ?: return@setOnShowListener + saveButton.setOnClickListener { val candidate = server.copy( name = nameField.text.toString().trim(), url = urlField.text.toString().trim(), @@ -304,17 +335,29 @@ class McpSettingsFragment : Fragment() { return@setOnClickListener } val problem = - validate(candidate, tokenField.text.toString(), credential, headers) + validate(candidate, tokenField.text.toString(), credential, headers, headersState) if (problem != null) { status.text = problem return@setOnClickListener } + saveButton.isEnabled = false viewModel.save( candidate, viewModel.tokenToStore(tokenField.text.toString()), - headers, - ) { _, _ -> } - dialog.dismiss() + viewModel.headersToStore(headers, headersState == Headers.KNOWN), + ) { _, failure -> + // Dismissed only once the credential is stored, as Connect already does: a + // keystore that will not encrypt would otherwise lose the token behind a + // dialog that closed as though it had saved. + if (failure == null) { + whileDialogShown { dialog.dismiss() } + return@save + } + whileDialogShown { + status.text = failure + saveButton.isEnabled = true + } + } } } dialog.show() @@ -507,12 +550,21 @@ class McpSettingsFragment : Fragment() { typedToken: String, credential: Credential, headers: Map, + headersState: Headers, ): String? = when { server.name.isBlank() -> getString(R.string.mcp_name_required) server.url.isBlank() -> getString(R.string.mcp_url_required) !server.url.startsWith("http://") && !server.url.startsWith("https://") -> getString(R.string.mcp_url_scheme_invalid) !McpHeaders.isSendableToken(typedToken.trim()) -> getString(R.string.mcp_token_illegal) + // Saving a typed row would replace the whole stored set, so it is refused while the dialog + // cannot show what that set is — each state with the advice that actually helps it. + headers.isNotEmpty() && headersState == Headers.UNKNOWN -> + getString(R.string.mcp_credentials_loading) + headers.isNotEmpty() && headersState == Headers.UNAVAILABLE -> + getString(R.string.mcp_headers_unavailable_no_replace) + headers.isNotEmpty() && headersState == Headers.UNREADABLE -> + getString(R.string.mcp_headers_unreadable_no_replace) server.url.startsWith("http://") && credential == Credential.UNKNOWN -> getString(R.string.mcp_credentials_loading) server.url.startsWith("http://") && @@ -539,6 +591,28 @@ class McpSettingsFragment : Fragment() { ABSENT, } + /** + * What the header rows on screen are, which decides what saving them may do. + * + * The host's own four-way secret read, as this dialog sees it: only [KNOWN] rows may replace + * the stored set, and the three others each need their own sentence rather than one "could not + * be read" that sends a user with intact headers off to enter them again. + */ + private enum class Headers { + + /** The decrypt has not answered yet. */ + UNKNOWN, + + /** Stored headers this device's Keystore can no longer open. Permanent: clear and re-enter. */ + UNREADABLE, + + /** Stored headers the Keystore would not answer for this time. Transient: retry. */ + UNAVAILABLE, + + /** The rows on screen are the stored set — a new server, a successful read, or just cleared. */ + KNOWN, + } + /** * Runs [action] only while the dialog it draws on is still on screen. * diff --git a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpSettingsViewModel.kt b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpSettingsViewModel.kt index 6b0c41d5..9e5f1ee1 100644 --- a/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpSettingsViewModel.kt +++ b/ai-agent-mcp/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpSettingsViewModel.kt @@ -8,9 +8,10 @@ import com.itsaky.androidide.plugins.aiagentmcp.R import com.itsaky.androidide.plugins.aiagentmcp.client.McpConnections import com.itsaky.androidide.plugins.aiagentmcp.client.McpTool import com.itsaky.androidide.plugins.aiagentmcp.errors.McpErrorFormatter -import com.itsaky.androidide.plugins.aiagentmcp.security.SecureTokenStore +import com.itsaky.androidide.plugins.aiagentmcp.security.UnavailableSecretException import com.itsaky.androidide.plugins.aiagentmcp.security.UnreadableSecretException import com.itsaky.androidide.plugins.aiagentmcp.tools.McpToolCatalog +import com.itsaky.androidide.plugins.security.KeystoreSecretStore import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -46,14 +47,26 @@ class McpSettingsViewModel( * @property hasHeaders whether headers are stored, from key presence rather than a decrypt: * [headers] comes back empty for headers this device can no longer read, and "stored but * unreadable" has to count as a stored credential or the control that clears it hides. - * @property secretsUnreadable whether a stored token or header cannot be decrypted on this - * device, which the field has to say aloud: it looks stored, but nothing can send it. + * @property tokenUnreadable whether the stored token cannot be decrypted on this device, which + * the field has to say aloud: it looks stored, but nothing can send it. + * @property tokenUnavailable whether the keystore merely would not answer for the token, which + * the field says differently: it is intact and the read is worth repeating. + * @property headersKnown whether [headers] is what is stored. False when the decrypt failed, in + * which case an empty list on screen means "not shown" and must not be saved over them. + * @property headersUnreadable why the header decrypt failed, when it did: true when nothing on + * this device can read them again. + * @property headersUnavailable why the header decrypt failed, when it did: true for a keystore + * that merely would not answer, so the dialog can say "try again" rather than "start over". * @property headers the extra headers configured for the server. */ data class FormState( val hasToken: Boolean, val hasHeaders: Boolean, - val secretsUnreadable: Boolean, + val tokenUnreadable: Boolean, + val tokenUnavailable: Boolean, + val headersKnown: Boolean, + val headersUnreadable: Boolean, + val headersUnavailable: Boolean, val headers: Map, ) @@ -81,16 +94,28 @@ class McpSettingsViewModel( viewModelScope.launch { val state = withContext(Dispatchers.IO) { val token = McpServerStore.token(id) + var headersUnreadable = false + var headersUnavailable = false val headers = try { McpServerStore.headers(id) } catch (e: UnreadableSecretException) { + headersUnreadable = true + null + } catch (e: UnavailableSecretException) { + headersUnavailable = true null } + // Reported per credential, never folded together: a token that decrypts beside + // headers that do not must not put "could not be read" on the token's own field, + // which is the mis-attribution this pane exists to avoid. FormState( hasToken = McpServerStore.hasToken(id), hasHeaders = McpServerStore.hasHeaders(id), - secretsUnreadable = - token is SecureTokenStore.Stored.Unreadable || headers == null, + tokenUnreadable = token is KeystoreSecretStore.Stored.Unreadable, + tokenUnavailable = token is KeystoreSecretStore.Stored.Unavailable, + headersKnown = headers != null, + headersUnreadable = headersUnreadable, + headersUnavailable = headersUnavailable, headers = headers.orEmpty(), ) } @@ -105,6 +130,22 @@ class McpSettingsViewModel( */ fun tokenToStore(typed: String): String? = typed.trim().takeIf { it.isNotEmpty() } + /** + * What the header rows mean when saving, given whether the stored ones could be read. + * + * The token's rule, applied to headers: the rows replace what is stored only when the dialog + * drew what is stored. Otherwise they mean nothing about it — [setHeaders] replaces the whole + * map, so writing one typed row over a set nobody could read would delete headers the user was + * just told were intact. The dialog refuses such a save instead, and [clearCredential] is the + * way back to none. + * + * @param collected the header rows as they currently stand. + * @param headersKnown whether those rows reflect what is stored. + * @return the rows to store, replacing the stored set, or null to leave it alone. + */ + fun headersToStore(collected: Map, headersKnown: Boolean): Map? = + if (headersKnown) collected else null + /** * Stores the edited name and URL of a server and, when given, its token. * @@ -113,20 +154,21 @@ class McpSettingsViewModel( * * @param server the server to store. * @param token the token to store, or null to leave the stored one alone. - * @param headers the extra headers to store, replacing whatever was there. + * @param headers the extra headers to store, replacing whatever was there, or null to leave the + * stored ones alone — see [headersToStore]. * @param onDone receives the merged record and a status sentence, null when everything worked. */ fun save( server: McpServer, token: String?, - headers: Map = emptyMap(), + headers: Map? = emptyMap(), onDone: (McpServer, String?) -> Unit, ) { viewModelScope.launch { val outcome = withContext(Dispatchers.IO) { val merged = McpServerStore.saveDetails(server) val tokenStored = token?.let { McpServerStore.setToken(server.id, it.trim()) } ?: true - val headersStored = McpServerStore.setHeaders(server.id, headers) + val headersStored = headers?.let { McpServerStore.setHeaders(server.id, it) } ?: true // A credential change has to invalidate the session, or the old one keeps working. McpConnections.invalidate(server.id) val failure = when { diff --git a/ai-agent-mcp/src/main/res/values/strings.xml b/ai-agent-mcp/src/main/res/values/strings.xml index 39a48b28..987321b2 100644 --- a/ai-agent-mcp/src/main/res/values/strings.xml +++ b/ai-agent-mcp/src/main/res/values/strings.xml @@ -24,6 +24,7 @@ Leave empty if the server needs none Stored — type to replace it Stored but unreadable — type it again + Stored, but unreadable right now — try again Connect Save Cancel @@ -42,6 +43,7 @@ The stored token and headers for this server have been removed. Still reading this server\'s stored credentials — try again in a moment. The saved token or headers for this server can no longer be read on this device. Enter them again. + The saved token or headers for this server could not be read just now. They are still stored — close this and try again in a moment. Connecting… Connected to %1$s. It listed %2$d tools below — new ones start switched off. Connected to %1$s, which offers no tools. @@ -65,6 +67,7 @@ The call to %1$s was cancelled. %1$s redirected the request to another address, so it was not sent — it carries your token. Check the endpoint URL. %1$s\'s saved token can no longer be read on this device. Open MCP server settings and enter it again. + %1$s\'s saved credentials could not be read just now. They are still stored — try again in a moment. Could not reach %1$s. Could not reach %1$s: %2$s \'%1$s\' is no longer offered by any configured MCP server. @@ -83,6 +86,8 @@ A header value cannot contain line breaks. That header is already listed above. Remove one of the two. The extra headers could not be encrypted, so they were not saved. + The headers already saved for this server couldn\'t be read just now, so a new one can\'t replace them. They are still stored — try again in a moment. + The headers already saved for this server can no longer be read on this device, so a new one can\'t replace them. Use Clear credential to replace them all. Fix the marked header before saving. Back diff --git a/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/client/McpSessionLifecycleTest.kt b/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/client/McpSessionLifecycleTest.kt index bee9d7f7..01ecb8b7 100644 --- a/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/client/McpSessionLifecycleTest.kt +++ b/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/client/McpSessionLifecycleTest.kt @@ -1,14 +1,18 @@ package com.itsaky.androidide.plugins.aiagentmcp.client import com.itsaky.androidide.plugins.aiagentmcp.transport.McpHttpClient +import com.itsaky.androidide.plugins.aiagentmcp.transport.McpHttpException import java.net.HttpURLConnection +import org.json.JSONArray import org.json.JSONObject import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test /** - * Whether `notifications/initialized` is sent, which the negotiated revision alone decides. + * What a session does across calls: whether `notifications/initialized` is sent, and whether the + * handshake it paid for is then kept alive rather than repeated or silently lost. * * Reading an absent `Mcp-Session-Id` as "stateless" left a conforming 2025-06-18 server without * the notification, so its next `tools/list` answered "not initialized" and the user saw no tools. @@ -18,15 +22,38 @@ class McpSessionLifecycleTest { private companion object { const val ENDPOINT = "https://example.test/mcp" const val NOTIFICATION = "notifications/initialized" + const val INITIALIZE = "initialize" + const val LIST_TOOLS = "tools/list" + const val SESSION = "s-1" + const val TOOL = "search" } - /** Answers `initialize` with [protocolVersion], recording the methods it was asked for. */ + /** + * Answers `initialize` with [protocolVersion], recording the methods it was asked for. + * + * @param protocolVersion the revision the handshake reports back. + * @param sessionId the session the server assigns, or null for one that keeps no state. + */ private class FakeHttpClient( private val protocolVersion: String, private val sessionId: String? = null, ) : McpHttpClient() { - val methods = mutableListOf() + /** One request the client made, with the session it carried. */ + data class Request(val method: String, val sessionId: String?) + + val requests = mutableListOf() + + /** Sessions the client ended with a DELETE. */ + val deletedSessions = mutableListOf() + + /** The method to answer `404` for, standing in for a session the server forgot. */ + var expiringMethod: String? = null + + /** How many more times [expiringMethod] answers `404` before it starts working. */ + var expiriesLeft = 0 + + val methods: List get() = requests.map { it.method } override fun post( url: String, @@ -38,15 +65,34 @@ class McpSessionLifecycleTest { onConnected: (HttpURLConnection) -> Unit, ): Response { val method = body.optString("method") - methods += method - if (method != "initialize") return Response(null, this.sessionId) - val result = JSONObject().put("protocolVersion", this.protocolVersion) + requests += Request(method, sessionId) + if (method == expiringMethod && expiriesLeft > 0) { + expiriesLeft-- + throw McpHttpException(HttpURLConnection.HTTP_NOT_FOUND, "session expired") + } + val result = when (method) { + INITIALIZE -> JSONObject().put("protocolVersion", this.protocolVersion) + LIST_TOOLS -> JSONObject().put( + "tools", + JSONArray().put(JSONObject().put("name", TOOL)) + ) + else -> return Response(null, this.sessionId) + } val document = JSONObject() .put("jsonrpc", "2.0") .put("id", body.opt("id")) .put("result", result) return Response(document.toString(), this.sessionId) } + + override fun deleteSession( + url: String, + token: String, + sessionId: String, + extraHeaders: Map, + ) { + deletedSessions += sessionId + } } private fun sessionOn(http: McpHttpClient) = @@ -58,16 +104,16 @@ class McpSessionLifecycleTest { sessionOn(http).initialize() - assertEquals(listOf("initialize", NOTIFICATION), http.methods) + assertEquals(listOf(INITIALIZE, NOTIFICATION), http.methods) } @Test fun givenAStatefulRevisionAndASessionHeader_whenInitializing_thenTheNotificationIsSent() { - val http = FakeHttpClient(McpSession.PREFERRED_PROTOCOL_VERSION, sessionId = "s-1") + val http = FakeHttpClient(McpSession.PREFERRED_PROTOCOL_VERSION, sessionId = SESSION) sessionOn(http).initialize() - assertEquals(listOf("initialize", NOTIFICATION), http.methods) + assertEquals(listOf(INITIALIZE, NOTIFICATION), http.methods) } @Test @@ -76,7 +122,7 @@ class McpSessionLifecycleTest { sessionOn(http).initialize() - assertEquals(listOf("initialize"), http.methods) + assertEquals(listOf(INITIALIZE), http.methods) } @Test @@ -87,4 +133,60 @@ class McpSessionLifecycleTest { assertTrue("an unparseable revision must fail safe", NOTIFICATION in http.methods) } + + @Test + fun givenAnInitializedSession_whenMoreCallsFollow_thenTheHandshakeIsNotRepeated() { + val http = FakeHttpClient(McpSession.PREFERRED_PROTOCOL_VERSION, sessionId = SESSION) + val session = sessionOn(http) + + session.initialize() + session.listTools() + session.listTools() + + // The handshake is what a kept-alive session buys; paying it per call is the regression. + assertEquals(1, http.methods.count { it == INITIALIZE }) + assertEquals(2, http.methods.count { it == LIST_TOOLS }) + } + + @Test + fun givenAServerAssignedSession_whenACallFollows_thenItCarriesTheSessionHeader() { + val http = FakeHttpClient(McpSession.PREFERRED_PROTOCOL_VERSION, sessionId = SESSION) + + sessionOn(http).listTools() + + assertNull("the handshake itself has no session yet", http.requests.first().sessionId) + assertTrue( + "every later request must carry the assigned session", + http.requests.drop(1).all { it.sessionId == SESSION } + ) + } + + @Test + fun givenAnExpiredSession_whenTheServerAnswers404_thenItReInitializesAndRetriesOnce() { + val http = FakeHttpClient(McpSession.PREFERRED_PROTOCOL_VERSION, sessionId = SESSION).apply { + expiringMethod = LIST_TOOLS + expiriesLeft = 1 + } + + val tools = sessionOn(http).listTools() + + assertEquals( + listOf(INITIALIZE, NOTIFICATION, LIST_TOOLS, INITIALIZE, NOTIFICATION, LIST_TOOLS), + http.methods + ) + assertEquals(listOf(TOOL), tools.map { it.name }) + } + + @Test + fun givenAClosedSession_whenItIsUsedAgain_thenTheServerSessionIsEndedAndTheHandshakeRepeats() { + val http = FakeHttpClient(McpSession.PREFERRED_PROTOCOL_VERSION, sessionId = SESSION) + val session = sessionOn(http) + + session.listTools() + session.close() + session.listTools() + + assertEquals(listOf(SESSION), http.deletedSessions) + assertEquals(2, http.methods.count { it == INITIALIZE }) + } } diff --git a/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/errors/McpErrorFormatterTest.kt b/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/errors/McpErrorFormatterTest.kt index 2c310285..cb837d72 100644 --- a/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/errors/McpErrorFormatterTest.kt +++ b/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/errors/McpErrorFormatterTest.kt @@ -1,6 +1,7 @@ package com.itsaky.androidide.plugins.aiagentmcp.errors import com.itsaky.androidide.plugins.aiagentmcp.client.McpProtocolException +import com.itsaky.androidide.plugins.aiagentmcp.security.UnavailableSecretException import com.itsaky.androidide.plugins.aiagentmcp.security.UnreadableSecretException import com.itsaky.androidide.plugins.aiagentmcp.transport.McpHttpException import com.itsaky.androidide.plugins.aiagentmcp.transport.McpRedirectException @@ -27,6 +28,15 @@ class McpErrorFormatterTest { assertEquals(McpFailure.SecretUnreadable, failure) } + @Test + fun givenAKeystoreThatWouldNotAnswer_whenClassified_thenItIsRetryableRatherThanAReachFailure() { + // Kept apart from the arm above and from the generic one: nothing was sent, so "Could not + // reach X" sends the user hunting a network problem when the answer is to try again. + val failure = McpErrorFormatter.classify(UnavailableSecretException("keystore busy")) + + assertEquals(McpFailure.SecretUnavailable, failure) + } + @Test fun givenARefusedRedirect_whenClassified_thenItIsItsOwnFailureRatherThanAGenericOne() { // "Could not reach X: " would read as a network problem; nothing was sent, and the diff --git a/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/plugin/McpPluginScopeTest.kt b/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/plugin/McpPluginScopeTest.kt new file mode 100644 index 00000000..beeddf7e --- /dev/null +++ b/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/plugin/McpPluginScopeTest.kt @@ -0,0 +1,155 @@ +package com.itsaky.androidide.plugins.aiagentmcp.plugin + +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.aiagentmcp.testing.FakeSharedPreferences +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Job +import java.util.concurrent.CyclicBarrier +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Whether [McpPlugin] leaves a coroutine scope running past the lifecycle edge that ended it. + * + * The scope carries the cold-start `tools/list` refresh, which registers sessions in + * `McpConnections` and fills `McpToolCatalog`. Left running past `deactivate`, it repopulates both + * straight after they were cleared, leaving sockets nothing can reach — and a host that activates + * twice would orphan the first scope entirely. + */ +class McpPluginScopeTest { + + private val prefs = FakeSharedPreferences() + private lateinit var context: PluginContext + private lateinit var plugin: McpPlugin + + @Before + fun setUp() { + context = mockk(relaxed = true) + every { context.getPluginSharedPreferences(any()) } returns prefs + plugin = McpPlugin() + plugin.initialize(context) + } + + @After + fun tearDown() { + plugin.dispose() + } + + @Test + fun givenAnActivatedPlugin_whenActivatedAgain_thenThePreviousScopeIsCancelled() { + plugin.activate() + val first = requireNotNull(plugin.scopeJob) + + plugin.activate() + + val second = requireNotNull(plugin.scopeJob) + assertTrue("the orphaned scope must not outlive the activation", first.isCancelled) + assertNotSame("a second activation gets a scope of its own", first, second) + assertScopeUsable(second) + } + + @Test + fun givenAnActivatedPlugin_whenDeactivated_thenTheScopeIsCancelled() { + plugin.activate() + val job = requireNotNull(plugin.scopeJob) + + plugin.deactivate() + + assertTrue("an in-flight refresh must not survive deactivation", job.isCancelled) + } + + @Test + fun givenAnActivatedPlugin_whenDisposed_thenTheScopeIsCancelled() { + plugin.activate() + val job = requireNotNull(plugin.scopeJob) + + plugin.dispose() + + assertTrue("dispose must leave nothing running", job.isCancelled) + } + + @Test + fun givenADeactivatedPlugin_whenActivatedAgain_thenItGetsALiveScope() { + plugin.activate() + plugin.deactivate() + + plugin.activate() + + assertScopeUsable(requireNotNull(plugin.scopeJob)) + } + + /** + * What the swap onto [McpPlugin.stoppedScope] buys, under two lifecycle calls at once. + * + * `activate` and `deactivate` each read the scope field, install their own, and cancel what + * they displaced. Cancelling the field in place instead — the shape before this commit — leaves + * a cancelled scope installed as though the plugin were activated, so the scope an activation + * handed its `tools/list` refresh to is left running with nothing holding it: a socket the + * plugin can no longer reach, repopulating a catalogue that was just cleared. + * + * So whatever order the two land in, the scope an activation launched on is either the one + * still installed (and live) or cancelled. Never a live orphan, and never a cancelled scope + * left installed as though the plugin were activated. + * + * What this does *not* pin is [McpPlugin.lifecycleLock]: removing the `synchronized` from + * `swapScope` leaves every case here green, because the read-then-write window is too narrow + * for these threads to land inside. Reverting the swap fails it deterministically — including + * on the sequential activate-then-deactivate case above, which is the failure it really covers. + */ + @Test + fun givenTwoLifecycleCallsAtOnce_whenTheyInterleave_thenNoScopeIsOrphaned() { + val threads = Executors.newFixedThreadPool(2) + try { + repeat(ITERATIONS) { iteration -> + val barrier = CyclicBarrier(2) + // Both orders: which call reaches the field first is the whole question. + val activateFirst = iteration % 2 == 0 + val first = threads.submit { + barrier.await(TIMEOUT_SECONDS, TimeUnit.SECONDS) + if (activateFirst) plugin.activate() else plugin.deactivate() + } + val second = threads.submit { + barrier.await(TIMEOUT_SECONDS, TimeUnit.SECONDS) + if (activateFirst) plugin.deactivate() else plugin.activate() + } + first.get(TIMEOUT_SECONDS, TimeUnit.SECONDS) + second.get(TIMEOUT_SECONDS, TimeUnit.SECONDS) + + val installed = plugin.scopeJob + val launched = requireNotNull(plugin.activationJob) + assertEquals( + "the scope an activation launched on must be the installed one or cancelled", + installed !== launched, + launched.isCancelled, + ) + } + } finally { + threads.shutdownNow() + } + } + + /** + * Asserts a scope can still take work, which a cancelled one cannot. + * @param job the activation scope's job. + */ + private fun assertScopeUsable(job: Job) { + assertFalse("the current activation's scope must be live", job.isCancelled) + assertTrue("the current activation's scope must accept work", job.isActive) + } + + private companion object { + /** Enough interleavings to catch a lost swap; the whole loop runs in well under a second. */ + const val ITERATIONS = 400 + + /** Generous: a thread that never arrives is a deadlock, not a slow machine. */ + const val TIMEOUT_SECONDS = 10L + } +} diff --git a/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpServerStoreLockTest.kt b/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpServerStoreLockTest.kt new file mode 100644 index 00000000..8e7532f2 --- /dev/null +++ b/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpServerStoreLockTest.kt @@ -0,0 +1,124 @@ +package com.itsaky.androidide.plugins.aiagentmcp.settings + +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.aiagentmcp.plugin.McpPlugin +import com.itsaky.androidide.plugins.aiagentmcp.testing.FakeSharedPreferences +import io.mockk.every +import io.mockk.mockk +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Whether [McpServerStore]'s lock actually serialises the read-modify-write of the server list. + * + * The list is one JSON blob, so every mutator reads it whole and puts it back whole. A refresh on + * `McpPlugin`'s scope and a toggle on the screen's dispatcher do interleave in practice, and the + * loser's write simply vanishes — the switch stays on while `enabledTools` on disk no longer holds + * it, which reads to the user as the Agent ignoring a tool they enabled. + */ +class McpServerStoreLockTest { + + private companion object { + /** Enough concurrent writers to lose one, few enough to stay fast. */ + const val WRITERS = 8 + + /** Widens the read-modify-write window; see [FakeSharedPreferences.readDelayMillis]. */ + const val READ_DELAY_MS = 2L + + const val JOIN_TIMEOUT_SECONDS = 30L + } + + private val prefs = FakeSharedPreferences() + private lateinit var plugin: McpPlugin + private lateinit var serverId: String + + @Before + fun setUp() { + // Through the plugin's own lifecycle rather than a hook on the store: `initialize` is how + // the host hands over the preferences [McpServerStore] then reads, so the test drives the + // same path the device does and the store keeps no test-only surface. + val context = mockk(relaxed = true) + every { context.getPluginSharedPreferences(any()) } returns prefs + plugin = McpPlugin() + plugin.initialize(context) + serverId = McpServerStore.saveDetails( + McpServerStore.newServer("Docs", "https://example.test/mcp") + ).id + } + + @After + fun tearDown() { + plugin.dispose() + } + + @Test + fun givenConcurrentToolToggles_whenTheyInterleave_thenNoWriteIsLost() { + val tools = (1..WRITERS).map { "tool_$it" } + McpServerStore.setKnownTools(serverId, tools) + prefs.readDelayMillis = READ_DELAY_MS + + runTogether(tools) { McpServerStore.setToolEnabled(serverId, it, true) } + + assertEquals(tools.toSet(), McpServerStore.server(serverId)?.enabledTools) + } + + @Test + fun givenAToggleAndAWholeServerSwitchAtOnce_whenTheyInterleave_thenNeitherIsLost() { + val tools = (1..WRITERS).map { "tool_$it" } + McpServerStore.setKnownTools(serverId, tools) + prefs.readDelayMillis = READ_DELAY_MS + + runTogether(tools + "disable") { work -> + if (work == "disable") { + McpServerStore.setEnabled(serverId, false) + } else { + McpServerStore.setToolEnabled(serverId, work, true) + } + } + + val stored = McpServerStore.server(serverId) + assertEquals(tools.toSet(), stored?.enabledTools) + assertFalse("the whole-server switch must survive the toggles", stored?.enabled ?: true) + } + + /** + * Runs [work] on one thread per item, all released at once. + * + * @param items one item per thread. + * @param work what each thread does with its item. + */ + private fun runTogether(items: List, work: (String) -> Unit) { + val start = CountDownLatch(1) + val done = CountDownLatch(items.size) + val threads = items.map { item -> + // Daemons, and joined in a `finally` below: a writer wedged on the store's lock must not + // outlive the assertion that noticed it and hold the Gradle test worker's JVM open. + Thread { + start.await() + try { + work(item) + } finally { + done.countDown() + } + }.apply { + isDaemon = true + start() + } + } + try { + start.countDown() + assertTrue( + "the writers did not finish", + done.await(JOIN_TIMEOUT_SECONDS, TimeUnit.SECONDS) + ) + } finally { + threads.forEach { it.join(TimeUnit.SECONDS.toMillis(JOIN_TIMEOUT_SECONDS)) } + } + } +} diff --git a/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpSettingsHeadersToStoreTest.kt b/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpSettingsHeadersToStoreTest.kt new file mode 100644 index 00000000..7e3ddb30 --- /dev/null +++ b/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpSettingsHeadersToStoreTest.kt @@ -0,0 +1,43 @@ +package com.itsaky.androidide.plugins.aiagentmcp.settings + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * What the header rows mean when the dialog saves them. + * + * `setHeaders` replaces the whole stored map, so this predicate is the only thing standing between + * a keystore that would not answer and a user losing headers they were just told were intact: the + * rows on screen may replace what is stored only when the dialog drew what is stored. + */ +class McpSettingsHeadersToStoreTest { + + private val viewModel = McpSettingsViewModel { null } + + private val typed = mapOf("X-Api-Key" to "abc") + + @Test + fun givenTheStoredHeadersWereDrawn_whenSavingRows_thenTheyReplaceThem() { + assertEquals(typed, viewModel.headersToStore(typed, headersKnown = true)) + } + + @Test + fun givenTheStoredHeadersWereDrawn_whenSavingNoRows_thenTheyAreDeleted() { + // The user removed every row they could see, so an empty map is the intended replacement. + assertEquals(emptyMap(), viewModel.headersToStore(emptyMap(), headersKnown = true)) + } + + @Test + fun givenTheStoredHeadersCouldNotBeRead_whenSavingNoRows_thenTheyAreLeftAlone() { + // An empty list here means "never drawn", not "none": nothing may be written over them. + assertNull(viewModel.headersToStore(emptyMap(), headersKnown = false)) + } + + @Test + fun givenTheStoredHeadersCouldNotBeRead_whenSavingATypedRow_thenTheyAreStillLeftAlone() { + // The regression this pins: one typed row used to be an implicit replacement of a set the + // user never saw, which deleted the rest of it. The dialog refuses such a save instead. + assertNull(viewModel.headersToStore(typed, headersKnown = false)) + } +} diff --git a/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/testing/FakeSharedPreferences.kt b/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/testing/FakeSharedPreferences.kt new file mode 100644 index 00000000..ef47fbca --- /dev/null +++ b/ai-agent-mcp/src/test/kotlin/com/itsaky/androidide/plugins/aiagentmcp/testing/FakeSharedPreferences.kt @@ -0,0 +1,118 @@ +package com.itsaky.androidide.plugins.aiagentmcp.testing + +import android.content.SharedPreferences +import java.util.concurrent.ConcurrentHashMap + +/** + * An in-memory [SharedPreferences], so the settings store can be exercised off a device. + * + * The seam this plugs into exists for one reason — showing that `McpServerStore`'s lock actually + * serialises a read-modify-write — so [readDelayMillis] is here too: a real preferences read costs + * a lock and a file, and a race that needs microseconds of window is not a race a test would ever + * catch on an in-memory map. + */ +class FakeSharedPreferences : SharedPreferences { + + // Typed, not String-keyed: every getter and putter is backed by this one map, so a test that + // stores a flag or a timestamp through the fake reads back what it wrote rather than the + // default. A no-op putter would give it a green run that proved nothing. + private val values = ConcurrentHashMap() + + /** How long a read blocks, widening the window a missing lock would lose a write in. */ + @Volatile + var readDelayMillis: Long = 0 + + override fun getAll(): MutableMap = HashMap(values) + + override fun getString(key: String, defValue: String?): String? { + if (readDelayMillis > 0) Thread.sleep(readDelayMillis) + return values[key] as? String ?: defValue + } + + @Suppress("UNCHECKED_CAST") + override fun getStringSet(key: String, defValues: MutableSet?): MutableSet? = + (values[key] as? Set)?.toMutableSet() ?: defValues + + override fun getInt(key: String, defValue: Int): Int = values[key] as? Int ?: defValue + + override fun getLong(key: String, defValue: Long): Long = values[key] as? Long ?: defValue + + override fun getFloat(key: String, defValue: Float): Float = values[key] as? Float ?: defValue + + override fun getBoolean(key: String, defValue: Boolean): Boolean = + values[key] as? Boolean ?: defValue + + override fun contains(key: String): Boolean = values.containsKey(key) + + override fun edit(): SharedPreferences.Editor = FakeEditor() + + override fun registerOnSharedPreferenceChangeListener( + listener: SharedPreferences.OnSharedPreferenceChangeListener? + ) = Unit + + override fun unregisterOnSharedPreferenceChangeListener( + listener: SharedPreferences.OnSharedPreferenceChangeListener? + ) = Unit + + /** Batches edits and applies them at once, as the real editor does. */ + private inner class FakeEditor : SharedPreferences.Editor { + + private val puts = LinkedHashMap() + private val removals = LinkedHashSet() + private var cleared = false + + override fun putString(key: String, value: String?): SharedPreferences.Editor { + if (value == null) removals += key else puts[key] = value + return this + } + + override fun putStringSet( + key: String, + values: MutableSet? + ): SharedPreferences.Editor { + if (values == null) removals += key else puts[key] = LinkedHashSet(values) + return this + } + + override fun putInt(key: String, value: Int): SharedPreferences.Editor { + puts[key] = value + return this + } + + override fun putLong(key: String, value: Long): SharedPreferences.Editor { + puts[key] = value + return this + } + + override fun putFloat(key: String, value: Float): SharedPreferences.Editor { + puts[key] = value + return this + } + + override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor { + puts[key] = value + return this + } + + override fun remove(key: String): SharedPreferences.Editor { + removals += key + return this + } + + override fun clear(): SharedPreferences.Editor { + cleared = true + return this + } + + override fun commit(): Boolean { + if (cleared) values.clear() + removals.forEach { values.remove(it) } + values.putAll(puts) + return true + } + + override fun apply() { + commit() + } + } +} diff --git a/ai-agent-openai/README.md b/ai-agent-openai/README.md index 86476f22..d79bb0f5 100644 --- a/ai-agent-openai/README.md +++ b/ai-agent-openai/README.md @@ -97,14 +97,15 @@ after configuring OpenAI cannot put that bearer token on the network in the clea A key stored before the origin was recorded is still sent, since it cannot be shown to belong elsewhere. The connection test applies the same rule. -`security/SecureApiKeyStore.kt` is this plugin's **own copy**, under its own -Keystore alias (`cotg_ai_openai_key_v1`). It is deliberately not shared with -ai-agent-gemini's copy: every plugin runs in the host app's process and UID and -therefore shares one Keystore, so a shared alias would let one plugin's +`security/SecureApiKeyStore.kt` holds only this plugin's Keystore alias +(`cotg_ai_openai_key_v1`); the AES/GCM itself is the IDE's `KeystoreSecretStore` +(`plugin-api`, since **26.36** — hence this plugin's `min_ide_version`), so there +is one implementation in the process rather than a copy per plugin. The **alias** +is deliberately not shared: every plugin runs in the host app's process and UID +and therefore shares one Keystore, so a shared alias would let one plugin's invalidated-key recovery (`deleteEntry`) destroy the other backend's stored key. -The two never read each other's ciphertext, so they have no reason to share an -alias — and there is therefore nothing to keep in parity. Extracting the shared -*source* is tracked separately. +The plugins never read each other's ciphertext, so they have no reason to share +one. ## Installation @@ -135,7 +136,7 @@ root of `com/itsaky/androidide/plugins/aiagentopenai/`. - `backend/SseChunk.kt` — one line of the token stream (pure) - `backend/ChatModelFilter.kt` — keeps non-chat models out of the picker (pure) - `errors/OpenAiErrorFormatter.kt` — turns a failure into one translated sentence -- `security/SecureApiKeyStore.kt` — AES/GCM at rest +- `security/SecureApiKeyStore.kt` — this plugin's Keystore alias, over the IDE's `KeystoreSecretStore` - `preferences/OpenAiPreferences.kt` — this plugin's settings store - `prompt/OpenAiSystemPrompt.kt` — the system prompt this cloud model is given - `settings/BaseUrlPolicy.kt` — URL normalization and the cleartext rule (pure) diff --git a/ai-agent-openai/ai-agent-openai.html b/ai-agent-openai/ai-agent-openai.html index 4508ae41..8403d0f9 100644 --- a/ai-agent-openai/ai-agent-openai.html +++ b/ai-agent-openai/ai-agent-openai.html @@ -118,8 +118,9 @@

Technical architecture

OpenAiErrorFormatterClassifies a failure (unknown model, rate limit, spent balance, refused key, outage, server not running) so it can be reported as one translated sentence. - SecureApiKeyStoreAES/GCM encryption of the API - key, under this plugin's own Keystore alias. + SecureApiKeyStoreBinds this plugin's own + Keystore alias to the IDE's KeystoreSecretStore, which + AES/GCM-encrypts the API key.

No third-party HTTP SDK. Plugins run in the host IDE's classloader where okhttp3 resolves to the host's older OkHttp — a mismatch that diff --git a/ai-agent-openai/build.gradle.kts b/ai-agent-openai/build.gradle.kts index 9fa1882e..9cd03321 100644 --- a/ai-agent-openai/build.gradle.kts +++ b/ai-agent-openai/build.gradle.kts @@ -71,9 +71,8 @@ dependencies { testImplementation("org.json:json:20231013") } -// This plugin carries its own copy of SecureApiKeyStore under its own Keystore alias. No parity -// check against ai-agent-gemini's copy: the two never read each other's ciphertext, and a shared -// alias would let one plugin's invalidation recovery delete the other plugin's key. +// No SecureApiKeyStore parity check any more: the AES/GCM core is the host's KeystoreSecretStore +// (plugin-api), so there is one implementation in the process rather than copies to keep in step. // AAR metadata checks are disabled by convention for these application-as-library plugins. tasks.matching { diff --git a/ai-agent-openai/src/main/AndroidManifest.xml b/ai-agent-openai/src/main/AndroidManifest.xml index aefa3899..8684f20e 100644 --- a/ai-agent-openai/src/main/AndroidManifest.xml +++ b/ai-agent-openai/src/main/AndroidManifest.xml @@ -33,12 +33,12 @@ android:name="plugin.author" android:value="App Dev for All" /> - + + android:value="26.36" /> stored.plain.trim().takeIf { it.isNotBlank() } + KeystoreSecretStore.Stored.Absent -> null + // Reported here rather than passed on as "no key": generation fails either way, but a + // lost Keystore entry needs the key entering again, and the log is all that says so. + KeystoreSecretStore.Stored.Unreadable -> { + logger.warn( + "ApiKeyCache: the saved API key cannot be decrypted on this device; " + + "it has to be entered again in settings" + ) + null + } + // Transient, so it returns without caching: the key is very likely intact, and caching + // this answer would freeze "no key" until the stored value itself changed. + KeystoreSecretStore.Stored.Unavailable -> { + logger.warn( + "ApiKeyCache: the keystore could not be reached to read the saved API key; " + + "retrying on the next read" + ) + return null + } + } val raw = prefs?.getString(prefKey, null) cached = raw?.let { it to plain } return plain diff --git a/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/security/SecureApiKeyStore.kt b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/security/SecureApiKeyStore.kt index 5f383bd4..e6ae6667 100644 --- a/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/security/SecureApiKeyStore.kt +++ b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/security/SecureApiKeyStore.kt @@ -1,143 +1,17 @@ package com.itsaky.androidide.plugins.aiagentopenai.security -import android.content.SharedPreferences -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyPermanentlyInvalidatedException -import android.security.keystore.KeyProperties -import android.util.Base64 -import android.util.Log -import com.itsaky.androidide.plugins.aiagentopenai.logging.LOG_PREFIX -import java.security.GeneralSecurityException -import java.security.KeyStore -import javax.crypto.Cipher -import javax.crypto.KeyGenerator -import javax.crypto.SecretKey -import javax.crypto.spec.GCMParameterSpec +import com.itsaky.androidide.plugins.security.KeystoreSecretStore + +/** Unique to this plugin and fixed across releases; see [KeystoreSecretStore] for why both matter. */ +private const val ALIAS = "cotg_ai_openai_key_v1" /** - * AES/GCM encryption for this plugin's API key, keyed by a hardware-backed Android Keystore secret. - * Only ciphertext is written to SharedPreferences, so a copied prefs file (root, `adb backup`, - * forensic dump) is useless without this device's Keystore. - * - * The [ALIAS] must stay stable across releases: a key encrypted under one alias cannot be read - * under another, so changing it silently invalidates every stored key. + * This plugin's binding of [KeystoreSecretStore]: its API key, encrypted under this plugin's own + * Keystore alias. * - * It is also deliberately **this plugin's own** alias, not the one ai-agent-gemini uses. Every - * plugin runs in the host app's process and UID and therefore shares one Keystore, so a shared - * alias would let [deleteKey] — the recovery path for an invalidated key — destroy the other - * backend's stored key as a side effect. The two plugins never read each other's ciphertext, so - * they have no reason to share. + * The store is the IDE's, from plugin-api, and callers use it directly. A forwarding object per + * method would only be a second copy of its contract to keep in step — and one that had to pick a + * single answer for "absent" and "no longer decryptable", which callers here do not share. The + * thing this file owns is the alias. */ -object SecureApiKeyStore { - private const val TAG = "$LOG_PREFIX.SecureApiKeyStore" - private const val KEYSTORE = "AndroidKeyStore" - private const val ALIAS = "cotg_ai_openai_key_v1" - private const val TRANSFORM = "AES/GCM/NoPadding" - private const val IV_LEN = 12 - private const val TAG_BITS = 128 - - /** Marks a stored value as ciphertext; anything without it is treated as legacy plaintext. */ - const val ENC_PREFIX = "enc:v1:" - - private fun getOrCreateKey(): SecretKey { - val ks = KeyStore.getInstance(KEYSTORE).apply { load(null) } - (ks.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey } - val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE) - generator.init( - KeyGenParameterSpec.Builder( - ALIAS, - KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT - ) - .setBlockModes(KeyProperties.BLOCK_MODE_GCM) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .build() - ) - return generator.generateKey() - } - - private fun deleteKey() { - try { - KeyStore.getInstance(KEYSTORE).apply { load(null) }.deleteEntry(ALIAS) - } catch (e: Exception) { - Log.w(TAG, "Failed to delete Keystore alias $ALIAS", e) - } - } - - private fun encryptWith(key: SecretKey, plain: String): String { - val cipher = Cipher.getInstance(TRANSFORM) - cipher.init(Cipher.ENCRYPT_MODE, key) - val iv = cipher.iv - val ciphertext = cipher.doFinal(plain.toByteArray(Charsets.UTF_8)) - val combined = ByteArray(iv.size + ciphertext.size) - System.arraycopy(iv, 0, combined, 0, iv.size) - System.arraycopy(ciphertext, 0, combined, iv.size, ciphertext.size) - return ENC_PREFIX + Base64.encodeToString(combined, Base64.NO_WRAP) - } - - /** - * Encrypt [plain] into a self-describing string: [ENC_PREFIX] + base64(iv | ciphertext). - * - * The key is not auth-bound, so a credential change does not invalidate it; an alias an - * OEM Keystore drops anyway is regenerated once before retrying. - * - * @param plain the value to encrypt - * @throws GeneralSecurityException on any other Keystore/cipher failure, so the caller can - * inform the user instead of crashing the IDE on Save - */ - @Throws(GeneralSecurityException::class) - fun encrypt(plain: String): String { - return try { - encryptWith(getOrCreateKey(), plain) - } catch (e: KeyPermanentlyInvalidatedException) { - Log.w(TAG, "Keystore key invalidated; regenerating and retrying encrypt", e) - deleteKey() - encryptWith(getOrCreateKey(), plain) - } - } - - /** - * Return the plaintext for a stored value, handling both formats transparently: - * an [ENC_PREFIX] value is decrypted; anything else is returned unchanged as - * legacy plaintext (use [readAndMigrate] to upgrade it in place). Returns - * null if a ciphertext value can't be decrypted — e.g. the Keystore key was - * lost or invalidated — in which case the user must re-enter the key. - */ - fun decrypt(stored: String?): String? { - if (stored == null) return null - if (!stored.startsWith(ENC_PREFIX)) return stored - return try { - val combined = Base64.decode(stored.removePrefix(ENC_PREFIX), Base64.NO_WRAP) - val iv = combined.copyOfRange(0, IV_LEN) - val ciphertext = combined.copyOfRange(IV_LEN, combined.size) - val cipher = Cipher.getInstance(TRANSFORM) - cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(TAG_BITS, iv)) - String(cipher.doFinal(ciphertext), Charsets.UTF_8) - } catch (e: Exception) { - Log.w(TAG, "Failed to decrypt stored API key", e) - null - } - } - - /** - * Read [key] from [prefs], upgrading a legacy plaintext value to ciphertext in place. - * - * The value is trimmed on migration, so the stored, displayed and sent forms all agree. - * - * Keystore IPC + AES/GCM, so call this off the main thread. - * - * @return the trimmed plaintext value, or null when nothing is stored or decryption failed. - */ - fun readAndMigrate(prefs: SharedPreferences?, key: String): String? { - val stored = prefs?.getString(key, null) ?: return null - if (stored.startsWith(ENC_PREFIX)) return decrypt(stored) - val plain = stored.trim() - if (plain.isEmpty()) return plain - try { - prefs.edit().putString(key, encrypt(plain)).apply() - Log.i(TAG, "Upgraded legacy plaintext value for '$key' to ciphertext") - } catch (e: Exception) { - Log.w(TAG, "Could not upgrade legacy plaintext value for '$key' to ciphertext", e) - } - return plain - } -} +val secureApiKeyStore = KeystoreSecretStore(ALIAS) diff --git a/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiSettingsFragment.kt b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiSettingsFragment.kt index fab7f2f6..936c2038 100644 --- a/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiSettingsFragment.kt +++ b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiSettingsFragment.kt @@ -33,6 +33,7 @@ import com.itsaky.androidide.plugins.PluginContext import com.itsaky.androidide.plugins.aiagentopenai.R import com.itsaky.androidide.plugins.aiagentopenai.plugin.OpenAiPlugin import com.itsaky.androidide.plugins.base.PluginFragmentHelper +import com.itsaky.androidide.plugins.security.KeystoreSecretStore import com.itsaky.androidide.plugins.services.IdeTooltipService import kotlinx.coroutines.launch import java.text.SimpleDateFormat @@ -347,20 +348,39 @@ class OpenAiSettingsFragment : Fragment() { } viewLifecycleOwner.lifecycleScope.launch { - val savedApiKey = viewModel.getApiKey() + val stored = viewModel.getApiKey() + val savedApiKey = (stored as? KeystoreSecretStore.Stored.Value)?.plain val hasKey = !savedApiKey.isNullOrBlank() - updateUiState(isEditing = !hasKey) - if (hasKey) { + // A keystore that would not answer this time leaves the key on disk and intact, so the + // pane stays dressed as configured. Opening edit mode instead would make it identical + // to a fresh install, and for a server that needs no key a blank Save from there runs + // clearApiKey() over the key this same read just called recoverable. + val keptConfigured = + !hasKey && + stored is KeystoreSecretStore.Stored.Unavailable && + viewModel.hasStoredApiKey() + updateUiState(isEditing = !hasKey && !keptConfigured) + if (hasKey || keptConfigured) { statusTextView.text = savedApiKeyStatusText() - } else { + } + if (!hasKey) { apiKeyInput.setText("") - // A stored-but-undecryptable key also reads as null; warn as the Edit path does. - if (viewModel.hasStoredApiKey()) { + // Only for a key that is there and will not decrypt; an empty box alone looks like + // data loss. Nothing stored at all is the ordinary first run and says nothing. + if (stored is KeystoreSecretStore.Stored.Unreadable) { Toast.makeText( requireContext(), getString(R.string.msg_api_key_unreadable), Toast.LENGTH_LONG ).show() + } else if (stored is KeystoreSecretStore.Stored.Unavailable) { + // Said differently from the above: the key is still there and intact, so this + // must not send the user off to find and type it again. + Toast.makeText( + requireContext(), + getString(R.string.msg_api_key_unavailable), + Toast.LENGTH_LONG + ).show() } } } @@ -566,20 +586,33 @@ class OpenAiSettingsFragment : Fragment() { editButton.setOnClickListener { editButton.isEnabled = false viewLifecycleOwner.lifecycleScope.launch { - val apiKey = try { + val stored = try { viewModel.getApiKey() } finally { editButton.isEnabled = true } - // null = a key IS stored but won't decrypt; an empty box alone looks like data loss. - if (apiKey == null) { + // A key that is stored and will not decrypt; an empty box alone looks like data + // loss. Told apart from "nothing stored" here, which this button rarely sees but + // must not report as a lost Keystore entry when it does. + if (stored is KeystoreSecretStore.Stored.Unavailable) { + // Said differently from an unreadable key: this one is still there and intact, + // so the pane stays as it is rather than opening an empty field the user would + // Save over it — it must not send them off to find and type it again. + Toast.makeText( + requireContext(), + getString(R.string.msg_api_key_unavailable), + Toast.LENGTH_LONG + ).show() + return@launch + } + if (stored is KeystoreSecretStore.Stored.Unreadable) { Toast.makeText( requireContext(), getString(R.string.msg_api_key_unreadable), Toast.LENGTH_LONG ).show() } - revealEditMode(apiKey.orEmpty()) + revealEditMode((stored as? KeystoreSecretStore.Stored.Value)?.plain?.trim().orEmpty()) } } @@ -731,12 +764,28 @@ class OpenAiSettingsFragment : Fragment() { // Tests what is on screen: a typo is worth catching before it is saved. val url = urlInput.text.toString().trim().ifEmpty { viewModel.getBaseUrl() } val typedKey = apiKeyInput.text.toString().trim() - val key = if (apiKeyLayout.visibility == View.VISIBLE && typedKey.isNotEmpty()) { + val useTyped = apiKeyLayout.visibility == View.VISIBLE && typedKey.isNotEmpty() + // Scoped to the URL under test: probing a LAN server must not hand it the key the + // user entered for OpenAI. + val stored = if (useTyped) null else viewModel.getApiKeyFor(url) + // A keystore that would not answer is not "no key stored": the key is intact and + // the pane above still reads "saved on ...". Testing without it would render the + // 401 as a refused key, or ask for one in a field that is not even shown. + if (stored is KeystoreSecretStore.Stored.Unavailable) { + showStatus( + statusText, + getString(R.string.msg_api_key_unavailable_for_test), + R.drawable.ic_key_unchecked + ) + testButton.isEnabled = true + return@launch + } + // Absent and Unreadable do share one answer here — no key to send — and the read + // that opened the pane has already said which of the two it was. + val key = if (useTyped) { typedKey } else { - // Scoped to the URL under test: probing a LAN server must not hand it the key - // the user entered for OpenAI. - viewModel.getApiKeyFor(url).orEmpty() + (stored as? KeystoreSecretStore.Stored.Value)?.plain?.trim().orEmpty() } // A server with no anonymous access can only answer 401 without a key, and // reporting that as "the server refused this key" when there is no key sends the diff --git a/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiSettingsViewModel.kt b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiSettingsViewModel.kt index 22a810f8..dfee07f2 100644 --- a/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiSettingsViewModel.kt +++ b/ai-agent-openai/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiSettingsViewModel.kt @@ -10,7 +10,8 @@ import com.itsaky.androidide.plugins.PluginLogger import com.itsaky.androidide.plugins.aiagentopenai.backend.OpenAiBackend import com.itsaky.androidide.plugins.aiagentopenai.logging.LOG_PREFIX import com.itsaky.androidide.plugins.aiagentopenai.preferences.OpenAiPreferences -import com.itsaky.androidide.plugins.aiagentopenai.security.SecureApiKeyStore +import com.itsaky.androidide.plugins.aiagentopenai.security.secureApiKeyStore +import com.itsaky.androidide.plugins.security.KeystoreSecretStore import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers @@ -244,7 +245,7 @@ class OpenAiSettingsViewModel( } /** - * Encrypts [apiKey] via [SecureApiKeyStore] and persists only the ciphertext, off the main + * Encrypts [apiKey] via [secureApiKeyStore] and persists only the ciphertext, off the main * thread. Nothing is written on failure. * * @param apiKey the plaintext key to store (trimmed before encryption) @@ -261,7 +262,7 @@ class OpenAiSettingsViewModel( return@withContext false } val encrypted = try { - SecureApiKeyStore.encrypt(apiKey.trim()) + secureApiKeyStore.encrypt(apiKey.trim()) } catch (e: Exception) { logger?.error("$TAG: failed to encrypt API key", e) return@withContext false @@ -288,30 +289,39 @@ class OpenAiSettingsViewModel( /** * Decrypt the stored key off the main thread (Keystore IPC + AES/GCM), upgrading a plaintext * value to ciphertext in passing. + * + * @return what is on disk: nothing, the key, a key this device's Keystore can no longer open, + * or one it would not open just now. Those are not the same — a lost Keystore entry has to be + * entered again, a keystore that did not answer only retried — so the caller says which. */ - suspend fun getApiKey(): String? = withContext(ioDispatcher) { - SecureApiKeyStore.readAndMigrate(prefs(), OpenAiPreferences.KEY_API_KEY) + suspend fun getApiKey(): KeystoreSecretStore.Stored = withContext(ioDispatcher) { + secureApiKeyStore.readAndMigrate(prefs(), OpenAiPreferences.KEY_API_KEY) } /** - * The stored key, but only when it was saved for [baseUrl]. + * What is stored for [baseUrl] — the whole four-way read, not a key-or-null. * * What the connection test sends: testing a LAN server must not hand it the key the user - * entered for OpenAI. A key stored before the origin was recorded is returned, matching the - * backend's own rule. + * entered for OpenAI, so a key saved for another origin reads as absent. A key stored before + * the origin was recorded is returned, matching the backend's own rule. * - * @return the plaintext key, or null when none is stored or it belongs to another server + * @return the read, with a key belonging to another server reported as + * [KeystoreSecretStore.Stored.Absent] — the test's answer for it is the same as for nothing + * stored. [KeystoreSecretStore.Stored.Unavailable] is *not* that answer: the key is intact, + * so the caller retries instead of testing without one. */ - suspend fun getApiKeyFor(baseUrl: String): String? { + suspend fun getApiKeyFor(baseUrl: String): KeystoreSecretStore.Stored { val savedFor = prefs()?.getString(OpenAiPreferences.KEY_API_KEY_URL, null) - if (savedFor != null && !BaseUrlPolicy.sameOrigin(savedFor, baseUrl)) return null + if (savedFor != null && !BaseUrlPolicy.sameOrigin(savedFor, baseUrl)) { + return KeystoreSecretStore.Stored.Absent + } return getApiKey() } /** - * True when a key is present on disk, whether or not it can still be decrypted. Lets the UI - * tell "nothing was saved" from "the Keystore entry is gone" — [getApiKey] is null for both. - * Raw pref only, so no Keystore IPC and safe on the main thread. + * True when a key is present on disk, whether or not it can still be decrypted: what the key + * block is dressed from, which must not collapse the moment a Keystore entry is lost. Raw pref + * only, so no Keystore IPC and safe on the main thread — which [getApiKey] is not. */ fun hasStoredApiKey(): Boolean = !prefs()?.getString(OpenAiPreferences.KEY_API_KEY, null).isNullOrBlank() diff --git a/ai-agent-openai/src/main/res/values/strings.xml b/ai-agent-openai/src/main/res/values/strings.xml index f13a8291..daab0802 100644 --- a/ai-agent-openai/src/main/res/values/strings.xml +++ b/ai-agent-openai/src/main/res/values/strings.xml @@ -69,6 +69,8 @@ API Key saved on: %s API Key saved and verified on: %s The stored API key could not be read on this device. Please enter it again. + The device keystore could not be reached, so the stored API key could not be read. It is still saved — please try again in a moment. + The device keystore could not be reached, so the saved API key could not be read for this test. It is still saved — please try the test again in a moment. Couldn\'t save the API key on this device. Please try again. Checking this key with the server… Verified, your API key works diff --git a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentReplyRenderer.kt b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentReplyRenderer.kt index d2568d7e..f98fe320 100644 --- a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentReplyRenderer.kt +++ b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentReplyRenderer.kt @@ -12,6 +12,29 @@ import com.itsaky.androidide.plugins.aicore.tool.respondMessageOf */ object AgentReplyRenderer { + /** + * Whether this turn only repeats the calls that already succeeded, so its bubble is dropped. + * + * A turn carrying the terminal call is never a duplicate, whatever else it repeats: that bubble + * is the only place the answer is rendered. `AgentLoop` runs the real calls first and so never + * reaches `onFinalAnswer`, and dropping the turn here would end the run "completed" with + * nothing on screen. The match is the loose one a handler is routed by, so a backend answering + * `Respond` is recognised as terminal too. + * + * @param toolCalls the calls parsed out of this turn. + * @param lastSucceededCalls the calls this run last executed successfully, null when none did. + * @param terminalTool the name of the answer-carrying pseudo-tool (`respond`). + * @return true when the turn adds nothing and should not reach the transcript. + */ + fun isDuplicateTurn( + toolCalls: List, + lastSucceededCalls: List?, + terminalTool: String, + ): Boolean { + if (toolCalls.any { isTerminalToolName(it.name, terminalTool) }) return false + return toolCalls.isNotEmpty() && toolCalls == lastSucceededCalls + } + /** * Renders one model turn. * @param rawText the model's raw reply. diff --git a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModel.kt b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModel.kt index eb63fa7e..d16e7835 100644 --- a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModel.kt +++ b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModel.kt @@ -1004,8 +1004,7 @@ class ChatViewModel( // Per-run flag (set by executeToolCalls), not a session-wide scan. val lastToolFailed = lastToolFailedThisRun - val realCalls = toolCalls.filterNot { it.name == RESPOND_TOOL } - if (realCalls.isNotEmpty() && realCalls == lastSucceededCalls) { + if (AgentReplyRenderer.isDuplicateTurn(toolCalls, lastSucceededCalls, RESPOND_TOOL)) { viewModelScope.launch(Dispatchers.Main) { _messages.value = _messages.value.filter { it.id != agentMessageId } } diff --git a/ai-core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentReplyRendererTest.kt b/ai-core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentReplyRendererTest.kt index fad0e4b0..dcde3399 100644 --- a/ai-core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentReplyRendererTest.kt +++ b/ai-core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentReplyRendererTest.kt @@ -2,6 +2,8 @@ package com.itsaky.androidide.plugins.aicore.viewmodel import com.itsaky.androidide.plugins.aicore.tool.ToolCall import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test /** @@ -124,4 +126,49 @@ class AgentReplyRendererTest { fun givenNothingAtAll_whenRendered_thenTheFallbackIsShown() { assertEquals(NO_RESPONSE, render(" ")) } + + @Test + fun givenARepeatedCallBesideACapitalisedRespond_whenChecked_thenTheAnswerIsKept() { + // The turn repeats a call that already succeeded, but it also carries the answer, and this + // bubble is the only place that text is ever rendered: dropping it ends the run + // "completed" with nothing on screen. `Respond` counts as terminal by the loose match. + val succeeded = listOf(ToolCall("read_file", mapOf("file_path" to "A.kt"))) + val repeated = succeeded + ToolCall("Respond", mapOf("message" to "Here is the answer")) + + assertFalse(AgentReplyRenderer.isDuplicateTurn(repeated, succeeded, TERMINAL)) + } + + @Test + fun givenOnlyRepeatedRealCalls_whenChecked_thenTheTurnIsADuplicate() { + // Nothing terminal, nothing new: the bubble would say what the last one already did. + val succeeded = listOf(ToolCall("read_file", mapOf("file_path" to "A.kt"))) + + assertTrue(AgentReplyRenderer.isDuplicateTurn(succeeded.toList(), succeeded, TERMINAL)) + } + + @Test + fun givenADifferentCall_whenChecked_thenTheTurnIsNotADuplicate() { + val succeeded = listOf(ToolCall("read_file", mapOf("file_path" to "A.kt"))) + val next = listOf(ToolCall("read_file", mapOf("file_path" to "B.kt"))) + + assertFalse(AgentReplyRenderer.isDuplicateTurn(next, succeeded, TERMINAL)) + } + + @Test + fun givenOnlyATerminalCall_whenChecked_thenTheAnswerIsNeverDroppedAsADuplicate() { + // The answer-carrying turn has no real calls left after the filter, so it must fall through + // even when the run's last real call is what it is reporting on. + val succeeded = listOf(ToolCall("read_file", mapOf("file_path" to "A.kt"))) + + assertFalse( + AgentReplyRenderer.isDuplicateTurn(respond("message" to "Done."), succeeded, TERMINAL) + ) + } + + @Test + fun givenNothingSucceededYet_whenChecked_thenTheTurnIsNotADuplicate() { + val calls = listOf(ToolCall("read_file", mapOf("file_path" to "A.kt"))) + + assertFalse(AgentReplyRenderer.isDuplicateTurn(calls, null, TERMINAL)) + } }