Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/debug.yml
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,13 @@ jobs:
run: |
flox activate -d flox/base -- ./gradlew :plugin-api:apiCheck --no-daemon

# testDebugUnitTest, not testV8DebugUnitTest: :plugin-api declares no product flavors, so the
# v8 task every other module has does not exist here. Named as its own step rather than left
# to jacocoAggregateReport, which is not on the PR path at all.
- name: Run plugin-api unit tests
run: |
flox activate -d flox/base -- ./gradlew :plugin-api:testDebugUnitTest --no-daemon

# Combined with a compile task on purpose: spotlessCheck alone never pulls a
# source-tree-writing task into the graph, so the implicit-dependency failure ADFA-5244 fixed
# cannot reproduce in a standalone run. Without this step a regression of that exclude is
Expand Down
67 changes: 44 additions & 23 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -462,11 +462,24 @@ tasks.named("sonarqube") {
tasks.register<JacocoReport>("jacocoAggregateReport") {
val excludedProjects = emptySet<String>()

// Depend only on testV8DebugUnitTest tasks in subprojects
// (variant directory, unit-test task) pairs, v8 first so a flavored module keeps using it. Two
// entries because the module set is not uniform: every flavored module builds `v8Debug`, but a
// flavorless one such as `:plugin-api` only builds `debug`. Collecting the v8 names alone dropped
// such a module from this report silently - `mapNotNull` returned nothing for it, so its tests
// never ran here and its classes counted for nothing.
val coverageVariants =
listOf(
"v8Debug" to "testV8DebugUnitTest",
"debug" to "testDebugUnitTest",
)

// One unit-test task per subproject, not one per variant: a module has only ever one of these.
dependsOn(
subprojects
.filterNot { it.name in excludedProjects }
.mapNotNull { it.tasks.findByName("testV8DebugUnitTest") },
.mapNotNull { subproj ->
coverageVariants.firstNotNullOfOrNull { (_, task) -> subproj.tasks.findByName(task) }
},
)

reports {
Expand All @@ -483,41 +496,49 @@ tasks.register<JacocoReport>("jacocoAggregateReport") {
"**/*Test*.*",
)

// Collect kotlin and java class directories for v8Debug and v8DebugUnitTest variant
// Kotlin and java class directories, for each variant a module might have built. Absent
// directories contribute nothing, so listing both shapes per module is safe.
val classDirs =
subprojects
.filterNot { it.name in excludedProjects }
.flatMap { subproj ->
listOf(
fileTree(subproj.layout.buildDirectory.dir("tmp/kotlin-classes/v8Debug")) {
exclude(fileFilter)
},
fileTree(subproj.layout.buildDirectory.dir("tmp/kotlin-classes/v8DebugUnitTest")) {
exclude(fileFilter)
},
fileTree(subproj.layout.buildDirectory.dir("classes/java/v8Debug")) {
exclude(fileFilter)
},
fileTree(subproj.layout.buildDirectory.dir("intermediates/javac/v8DebugUnitTest/classes")) {
exclude(fileFilter)
},
)
coverageVariants.flatMap { (variant, _) ->
listOf(
fileTree(subproj.layout.buildDirectory.dir("tmp/kotlin-classes/$variant")) {
exclude(fileFilter)
},
fileTree(subproj.layout.buildDirectory.dir("tmp/kotlin-classes/${variant}UnitTest")) {
exclude(fileFilter)
},
fileTree(subproj.layout.buildDirectory.dir("classes/java/$variant")) {
exclude(fileFilter)
},
fileTree(subproj.layout.buildDirectory.dir("intermediates/javac/${variant}UnitTest/classes")) {
exclude(fileFilter)
},
)
}
}

// Collect source directories
// Untouched on purpose, though it names only `src/main/java` while nine modules keep sources under
// `src/main/kotlin`: this property is inert for this task. Emptying it outright still renders full
// Kotlin source in the HTML and leaves the XML byte-identical, so adding the Kotlin roots here
// buys nothing. Measured, not assumed - do not "fix" it without re-measuring.
val sourceDirs =
subprojects
.filterNot { it.name in excludedProjects }
.map { it.file("src/main/java") }

// Collect execution data (.exec files)
// Collect execution data (.exec files), for each variant a module might have run.
val execFiles =
subprojects
.filterNot { it.name in excludedProjects }
.map { subproj ->
subproj.layout.buildDirectory.file(
"outputs/unit_test_code_coverage/v8DebugUnitTest/testV8DebugUnitTest.exec",
)
.flatMap { subproj ->
coverageVariants.map { (variant, task) ->
subproj.layout.buildDirectory.file(
"outputs/unit_test_code_coverage/${variant}UnitTest/$task.exec",
)
}
}

classDirectories.setFrom(classDirs)
Expand Down
46 changes: 46 additions & 0 deletions docs/PLUGIN_API_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,52 @@ milestone. **[verified]** = read from the checked-in ABI dump. **[reconstructed]
against an older `plugin-api` — the latter gets `AbstractMethodError`. Only the
host's own implementers benefit, and they are recompiled with it.

- **added — Keystore-backed secret storage for plugins** _(ADFA-5269)_ **[verified]**
A plugin that stores a credential can encrypt it with AES/GCM under a hardware-backed
Android Keystore key instead of carrying its own copy of the cipher. Three AI plugins
had already grown near-identical copies that were starting to diverge (only one zeroed
its plaintext buffers, only one told "no secret stored" apart from "stored but no longer
decryptable"), and the host's own `git-core/CryptoManager` is a fourth. Duplicating the
source into each `.cgp` would not fix that: `compileOnly` against the host means one
implementation in the process, loaded by the host's class loader.
`KeystoreSecretStore(alias)` (`encrypt`, `decrypt`, `write`, `readAndMigrate`) and
`KeystoreSecretStore.Stored` / `.Absent` / `.Value` / `.Unreadable` / `.Unavailable`. The
`enc:v1:` marker it writes is deliberately **not** part of the surface: the on-disk format
is the store's business, both formats are handled for the caller, and keeping it private is
what leaves room for an `enc:v2:` later.
Additions to the **class** are additive, because a plugin instantiates it rather than
implementing it. `Stored` is the exception, and the one part of this entry that cannot grow
quietly: it is a public **sealed** interface, so a fifth case makes every plugin's exhaustive
`when` fail to compile, and a `.cgp` already built against four throws
`NoWhenBranchMatchedException` at runtime. Any new `Stored` case needs a `breaking` row —
including the `enc:v2:` state the paragraph above leaves room for, if it ever surfaces.
`encrypt` throws `GeneralSecurityException` and nothing else: whatever the Keystore actually
raises (`IOException` from `KeyStore.load`, the unchecked `ProviderException` from keygen) is
wrapped in one, so catching the documented type is enough — an unwrapped throwable here
reaches the host's crash handler as an IDE crash, not your plugin's error path.
`readAndMigrate` keeps a lost key (`Unreadable` — ask the user for the secret again) apart
from a Keystore that would not answer (`Unavailable` — retry), so a transient failure does
not cost the user a credential that is still perfectly readable. That split covers the cipher
step as well as key acquisition, and it enumerates the *permanent* failures rather than the
transient ones: a wrong key, an altered payload, a malformed one, an invalidated or
unrecoverable key are `Unreadable`, and anything else the Keystore surfaces — a dead binder, a
busy backend — is `Unavailable`. (Asking the platform is not an option here: `BackendBusyException`
is API 31+ and `KeyStoreException.isTransientFailure()` API 33+, against `minSdk 28`.)
A blank stored value is no credential, and all three entry points agree about the same bytes on
disk: `write` forgets one rather than storing it, `readAndMigrate` purges it and reports
`Absent`, `decrypt` returns null. (`encrypt`/`decrypt` used as a bare codec still round-trip
`""`; the rule is about what is *stored*.)
Every one of the four methods does Keystore binder IPC, so **call them off the main thread** —
`decrypt` and `write` additionally share one alias-scoped lock, and `write` holds it across a
synchronous flush.
The `alias` is a constructor parameter and must stay **distinct per
plugin**: plugins share the host's process, UID and therefore its Keystore, so a shared
alias would let one plugin's invalidated-key recovery (`deleteEntry`) destroy another's
stored secret. It must also stay stable across releases, since a secret encrypted under
one alias cannot be read under another. `readAndMigrate` re-encrypts a legacy plaintext
value in place, so a plugin adopting this keeps working for users who configured a
credential before it existed.

### 26.33 — 2026-08-12
- **added — Plugin-contributed agent tools** _(ADFA-2592)_ **[verified]**
Any `.cgp` can add tools to the AI agent, whose tool set was previously fixed at
Expand Down
3 changes: 2 additions & 1 deletion docs/plugin-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ The surface a plugin binds to is broader than one module. All of the following a
- Extension interfaces plugins **implement**: `UIExtension`, `EditorExtension`, `EditorTabExtension`, `DocumentationExtension`, `BuildActionExtension`, `SnippetExtension`, `ProjectExtension`, `FileOpenExtension`, `SettingsExtension`.
- IDE service interfaces plugins **call** (via `ServiceRegistry.get(X::class.java)`): `IdeProjectService`, `IdeEditorService`, `IdeFileService`, `IdeEnvironmentService`, `IdeArchiveService`, `IdeBuildService`, `IdeUIService`, `IdeEditorTabService`, `IdeTooltipService`, `IdeThemeService`, `IdeFeatureFlagService`, `IdeCommandService`, `IdeTemplateService`, `IdeSnippetService`, `IdeSidebarService`.
- Cross-plugin service interfaces, where **one plugin implements what another calls** (via `SharedServices`): `LlmInferenceService` — implemented by ai-core, called by every AI plugin — together with the types nested in it that a *backend* plugin implements (`LlmBackend`, `HistoryCapableBackend`, `ToolCallingBackend`, `CancellableBackend`, `ConfigurableBackend`) and the value types either side constructs (`ChatMessage`, `LlmConfig`, `LlmResponse`, `SystemPromptRequest`, `ToolDefinition`, `ToolCallRequest`). Also `ToolSourceRegistry` — implemented by ai-core, called by any plugin contributing tools to the agent — with `ToolSource` and `ToolSpec`, which a *contributing* plugin implements, `ToolInvocation`, which ai-core constructs and passes to `ToolSource.invoke`, and `ToolOutcome`, which the source returns.
- Utility classes plugins **instantiate**: `KeystoreSecretStore` (AES/GCM over the Android Keystore, alias supplied by the caller). Host-side implementation rather than an interface, so plugins share one copy in the process instead of compiling their own.
- Data classes plugins **construct** (e.g. `MenuItem`, `TabItem`, `EditorTabItem`, `NavigationItem`, `ToolbarAction`, `FabAction`, `PluginBuildAction`, `SnippetContribution`, `PluginTooltipEntry`, `PluginSettingsEntry`).
- Enums / sealed types plugins **reference**: `PluginPermission`, `ShowAsAction`, `ArchiveFormat`, `BuildActionCategory`, `ToolbarActionIds`, `CommandSpec`, `CommandResult`, `ExtractResult`.
- Enums / sealed types plugins **reference**: `PluginPermission`, `ShowAsAction`, `ArchiveFormat`, `BuildActionCategory`, `ToolbarActionIds`, `CommandSpec`, `CommandResult`, `ExtractResult`, `KeystoreSecretStore.Stored`. Sealed, so a plugin `when`s over the cases exhaustively — adding one is a **breaking** change, not an additive one.
- **Wire/format contracts outside the module:**
- Manifest `<meta-data>` keys — `plugin.id`, `plugin.name`, `plugin.version`, `plugin.description`, `plugin.author`, `plugin.main_class`, `plugin.min_ide_version`, `plugin.max_ide_version`, `plugin.permissions`, `plugin.sidebar_items`, `plugin.icon_day`, `plugin.icon_night`. Matched **by string** — a rename silently breaks every plugin.
- Permission **key strings** (`filesystem.read`, `filesystem.write`, `network.access`, `system.commands`, `ide.settings`, `project.structure`, `native.code`, `ide.environment.write`) — also matched by string.
Expand Down
43 changes: 43 additions & 0 deletions plugin-api/api/plugin-api.api
Original file line number Diff line number Diff line change
Expand Up @@ -1213,6 +1213,49 @@ public final class com/itsaky/androidide/plugins/extensions/UIExtension$DefaultI
public static fun getToolbarActions (Lcom/itsaky/androidide/plugins/extensions/UIExtension;)Ljava/util/List;
}

public final class com/itsaky/androidide/plugins/security/KeystoreSecretStore {
public fun <init> (Ljava/lang/String;)V
public final fun decrypt (Ljava/lang/String;)Ljava/lang/String;
public final fun encrypt (Ljava/lang/String;)Ljava/lang/String;
public final fun readAndMigrate (Landroid/content/SharedPreferences;Ljava/lang/String;)Lcom/itsaky/androidide/plugins/security/KeystoreSecretStore$Stored;
public final fun write (Landroid/content/SharedPreferences;Ljava/lang/String;Ljava/lang/String;)Z
}

public abstract interface class com/itsaky/androidide/plugins/security/KeystoreSecretStore$Stored {
}

public final class com/itsaky/androidide/plugins/security/KeystoreSecretStore$Stored$Absent : com/itsaky/androidide/plugins/security/KeystoreSecretStore$Stored {
public static final field INSTANCE Lcom/itsaky/androidide/plugins/security/KeystoreSecretStore$Stored$Absent;
public fun equals (Ljava/lang/Object;)Z
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public final class com/itsaky/androidide/plugins/security/KeystoreSecretStore$Stored$Unavailable : com/itsaky/androidide/plugins/security/KeystoreSecretStore$Stored {
public static final field INSTANCE Lcom/itsaky/androidide/plugins/security/KeystoreSecretStore$Stored$Unavailable;
public fun equals (Ljava/lang/Object;)Z
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public final class com/itsaky/androidide/plugins/security/KeystoreSecretStore$Stored$Unreadable : com/itsaky/androidide/plugins/security/KeystoreSecretStore$Stored {
public static final field INSTANCE Lcom/itsaky/androidide/plugins/security/KeystoreSecretStore$Stored$Unreadable;
public fun equals (Ljava/lang/Object;)Z
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public final class com/itsaky/androidide/plugins/security/KeystoreSecretStore$Stored$Value : com/itsaky/androidide/plugins/security/KeystoreSecretStore$Stored {
public fun <init> (Ljava/lang/String;)V
public final fun component1 ()Ljava/lang/String;
public final fun copy (Ljava/lang/String;)Lcom/itsaky/androidide/plugins/security/KeystoreSecretStore$Stored$Value;
public static synthetic fun copy$default (Lcom/itsaky/androidide/plugins/security/KeystoreSecretStore$Stored$Value;Ljava/lang/String;ILjava/lang/Object;)Lcom/itsaky/androidide/plugins/security/KeystoreSecretStore$Stored$Value;
public fun equals (Ljava/lang/Object;)Z
public final fun getPlain ()Ljava/lang/String;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public final class com/itsaky/androidide/plugins/services/ArchiveFormat : java/lang/Enum {
public static final field GZIP Lcom/itsaky/androidide/plugins/services/ArchiveFormat;
public static final field TAR Lcom/itsaky/androidide/plugins/services/ArchiveFormat;
Expand Down
10 changes: 10 additions & 0 deletions plugin-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,18 @@ dependencies {

api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")

// Logging goes through SLF4J, not android.util.Log. Not `compileOnly` like the Android artifacts
// above: these classes run in the host's process, which already resolves slf4j-api and the IDE's
// provider via :common -> :logger, so declaring it keeps the compile and runtime views the same.
implementation(libs.tooling.slf4j)

// Test dependencies
testImplementation("junit:junit:4.13.2")

// KeystoreSecretStore reaches for android.util.Base64 and a real SharedPreferences, so its tests
// need the framework on the JVM.
testImplementation(libs.tests.robolectric)
Comment thread
jatezzz marked this conversation as resolved.
testImplementation(libs.tests.google.truth)
}

tasks.register<Copy>("createPluginApiJar") {
Expand Down
Loading
Loading