Skip to content
Closed
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
42 changes: 13 additions & 29 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,9 +304,8 @@ plugin. The check is an entry whose `jarPath` still exists.
### A settings section can offer to install the plugin that serves it

Two sections render a panel that belongs to a plugin - `Editor` and `Language servers`, both
editor-tab. (`Settings > AI Providers` was a third, served by secret-manager, until that section
moved into the plugin's own panel; Settings search reaches it through a **panel signpost** now, and
that entry is filtered on the panel being registered rather than explaining its absence.) Both used
editor-tab. AI provider search results use a **panel signpost** to the AI Gateway panel, filtered
on that panel being registered rather than explaining its absence. Both used
to say "isn't loaded yet" for every reason there was no panel, which is true of exactly one of
them. A plugin that was never
installed, or that the user switched off, does not arrive however long they look at it.
Expand Down Expand Up @@ -460,7 +459,7 @@ taking `first().replacementDisplayName` told the user their panel moved somewher
`supabase/functions/boss-ai` serves multiple configured models through one
authenticated endpoint. Configuration and accounting tables are service-role-only;
every inference rechecks live per-model permissions and atomically reserves usage.
Secret Manager owns BOSS AI authentication and discovery. It requests a single-use ticket
AI Gateway owns BOSS AI authentication and discovery. It requests a single-use ticket
through the existing authenticated RPC API and exchanges it with the edge function.
The database derives identity from auth.uid(); only the service role can redeem tickets.
No BOSS AI-specific desktop host registration or shared vault definition is required.
Expand Down Expand Up @@ -537,46 +536,31 @@ implementation lives in `desktopMain` (it speaks HTTP), so `DefaultPlugin` reads
self-healing. Nothing has ever read it from that file - it is an **environment variable**, and
the priority order above does not apply to it.

- **AI providers** (chat, agents, plugin AI features) are owned entirely by the
**secret-manager** plugin, in the **AI section of its own panel**. The host has no provider
list and no settings section for one; it relays the plugin's through
`PluginContext.llmProvider`. See that plugin's `AGENTS.md`.
- **AI providers** (chat, agents, plugin AI features) are owned entirely by the **ai-gateway**
plugin. Its panel owns provider setup, enabled models, usage, and token accounting. Secret
Manager owns only encrypted storage and the human grant UI.

The host used to render `Settings > AI Providers` from that plugin, through a
`LlmProviderAPIAccess` singleton. Both are gone: the credentials live in that panel's vault,
so the page that manages them belongs beside them rather than two clicks away in another
window, and the singleton existed only to give host composables a plugin handle. What remains
is `DefaultPlugin.llmProvider`, which resolves against **its own** instance's registry -
deliberately never through a singleton, because `DefaultPlugin` is per window and a shared
cached reference would hand window 1's plugins whatever window 2 registered.
Provider credentials cross the host through `SecretAccessProvider`, bound to the calling
plugin or exact MCP tool. Ownerless legacy secrets are invisible to execution principals until
a human explicitly grants use. Grants never confer update or delete rights. The broad
`SecretDataProvider` remains restricted to trusted human surfaces.

A stale `boss://settings?section=LLM_PROVIDERS` deep link now resolves to
`SettingsDeepLink.Unresolved`, so the window opens on its default section rather than
failing.

Settings search still answers for `api key`, `anthropic`, `claude` and the rest: a curated
**panel signpost** (`panelSignpost` in `SettingsSearchEntries.kt`) opens the Secret Manager
panel and raises the main window. It is the only search entry that navigates out of the
**panel signpost** (`panelSignpost` in `SettingsSearchEntries.kt`) opens the AI Gateway panel
and raises the main window. It is the only search entry that navigates out of the
Settings window. The delegated-section keywords could not have covered this - a panel is not a
settings page, so nothing merges it into the index at query time.

**There is deliberately no version floor on secret-manager.** The AI section exists in that
plugin only from 1.2.19, and nothing in the host gates on it: secret-manager is not in the
`system_plugins` manifest, so no `min_version` applies, and plugin updates surface in the
Toolbox rather than installing themselves. A user on 1.2.18 who takes this host build gets the
Secret Manager panel with no AI section in it. That is accepted rather than overlooked, and it
is a weaker case than `RetiredPlugins.minReplacementVersion`, which names a release because
getting it wrong **deletes** the user's only secrets panel. Here nothing is deleted and nothing
is lost - the credentials stay in the vault, `PluginContext.llmProvider` keeps serving them to
every plugin that asks, and updating the plugin restores the page. The floor would have to be
enforced somewhere, and the only mechanism the host has for that is refusing to load the
plugin, which would take the vault down with it.
- **AI self-healing / repair** is the one credential the host still resolves itself, because
`SelfHealingSettingsManager` runs before any window or plugin exists and so cannot reach the
plugin's store. It reads `AI_REPAIR_API_KEY`, then the provider's own variable
(`ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / …), then the legacy `~/.boss/llm_settings.json` and
its `.migrated` sibling - all as **env vars / files, never local.properties**. A key rotated
in the Secret Manager panel's AI section does not reach it.
in the AI Gateway panel does not reach it.

There are NO credential fallbacks in source (public repo). Packaged builds get
the JxBrowser license and Supabase settings baked in by the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ object PanelIds {

// Admin/Security panels
val SECRET_MANAGER = PanelId("secret-manager", 2)
val AI_GATEWAY = PanelId("ai-gateway", 25)
val ADMIN_ROLE_MANAGEMENT = PanelId("admin-role-management", 2)
val ROLE_CREATION = PanelId("role-creation", 2)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import ai.rever.boss.plugin.api.PanelInfo
import ai.rever.boss.plugin.api.PanelRegistry
import ai.rever.boss.plugin.api.PerformanceDataProvider
import ai.rever.boss.plugin.api.PluginContext
import ai.rever.boss.plugin.api.PluginLoaderDelegate
import ai.rever.boss.plugin.api.PluginManifest
import ai.rever.boss.plugin.api.PluginSandboxRef
import ai.rever.boss.plugin.api.PluginStorageFactory
Expand All @@ -43,7 +44,10 @@ import ai.rever.boss.plugin.api.ProjectSearchProvider
import ai.rever.boss.plugin.api.RoleManagementProvider
import ai.rever.boss.plugin.api.RunConfigurationDataProvider
import ai.rever.boss.plugin.api.ScreenCaptureProvider
import ai.rever.boss.plugin.api.SecretAccessProvider
import ai.rever.boss.plugin.api.SecretDataProvider
import ai.rever.boss.plugin.api.SecretGrantManager
import ai.rever.boss.plugin.api.SecretPrincipalData
import ai.rever.boss.plugin.api.SemanticTokenProvider
import ai.rever.boss.plugin.api.SettingsProvider
import ai.rever.boss.plugin.api.SplitViewOperations
Expand All @@ -59,10 +63,20 @@ import ai.rever.boss.plugin.api.UserManagementProvider
import ai.rever.boss.plugin.api.WorkspaceDataProvider
import ai.rever.boss.plugin.api.ZoomSettingsProvider
import ai.rever.boss.plugin.browser.BrowserService
import ai.rever.boss.services.supabase.SecretAccessProviderImpl
import ai.rever.boss.services.supabase.SecretExecutionPrincipal
import ai.rever.boss.services.supabase.SecretGrantManagerImpl
import com.arkivanov.decompose.ComponentContext
import kotlinx.coroutines.CoroutineScope
import java.util.concurrent.ConcurrentHashMap

private const val SECRET_MANAGER_PLUGIN_ID = "ai.rever.boss.plugin.dynamic.secretmanager"
private val HUMAN_SECRET_PROVIDER_PLUGINS =
setOf(
SECRET_MANAGER_PLUGIN_ID,
"ai.rever.boss.plugin.dynamic.fluckbrowser",
)

/**
* Registry of all registrations made by dynamic plugins.
*
Expand Down Expand Up @@ -333,7 +347,27 @@ class TrackingPluginContext(
override val gitDataProvider: GitDataProvider? get() = delegate.gitDataProvider
override val projectSearchProvider: ProjectSearchProvider? get() = delegate.projectSearchProvider
override val fileSystemDataProvider: FileSystemDataProvider? get() = delegate.fileSystemDataProvider
override val secretDataProvider: SecretDataProvider? get() = delegate.secretDataProvider

// The broad provider is a human-vault surface. Ordinary plugins must not be able to list it;
// they receive the principal-scoped provider below. Browser filling is a trusted human action
// that writes directly into the page and Secret Manager is the human administration UI.
override val secretDataProvider: SecretDataProvider?
get() = delegate.secretDataProvider.takeIf { pluginId in HUMAN_SECRET_PROVIDER_PLUGINS }

override val secretAccessProvider: SecretAccessProvider by lazy {
SecretAccessProviderImpl(SecretExecutionPrincipal.plugin(pluginId))
}

private val scopedSecretGrantManager: SecretGrantManager? by lazy {
if (pluginId == SECRET_MANAGER_PLUGIN_ID) {
SecretGrantManagerImpl(::secretPrincipalCatalog)
} else {
null
}
}

override val secretGrantManager: SecretGrantManager?
get() = scopedSecretGrantManager
override val llmProvider: LlmProvider? get() = delegate.llmProvider
override val brokeredCredentialProvider: BrokeredCredentialProvider?
get() = delegate.brokeredCredentialProvider
Expand All @@ -344,7 +378,15 @@ class TrackingPluginContext(
override val authDataProvider: AuthDataProvider? get() = delegate.authDataProvider
override val userManagementProvider: UserManagementProvider? get() = delegate.userManagementProvider
override val roleManagementProvider: RoleManagementProvider? get() = delegate.roleManagementProvider
override val supabaseDataProvider: SupabaseDataProvider? get() = delegate.supabaseDataProvider
override val supabaseDataProvider: SupabaseDataProvider? by lazy {
delegate.supabaseDataProvider?.let { provider ->
if (pluginId == SECRET_MANAGER_PLUGIN_ID) {
provider
} else {
SecretSafeSupabaseDataProvider(provider)
}
}
}

override val panelEventProvider: PanelEventProvider? get() = delegate.panelEventProvider
override val settingsProvider: SettingsProvider? get() = delegate.settingsProvider
Expand All @@ -358,6 +400,37 @@ class TrackingPluginContext(
// Plugin Store API key provider - delegate to underlying context
override val pluginStoreApiKeyProvider: PluginStoreApiKeyProvider? get() = delegate.pluginStoreApiKeyProvider

private fun secretPrincipalCatalog(): List<SecretPrincipalData> {
val plugins =
runCatching {
delegate
.getPluginAPI(PluginLoaderDelegate::class.java)
?.getLoadedPlugins()
.orEmpty()
.filter { it.isEnabled && it.healthy && !it.isIncompatible }
.map {
SecretPrincipalData(
principalType = "plugin",
principalId = it.pluginId,
displayName = it.displayName,
description = it.description.takeIf(String::isNotBlank),
)
}
}.getOrDefault(emptyList())
val tools =
runCatching {
delegate.mcpToolRegistry?.allTools?.value.orEmpty().map {
SecretPrincipalData(
principalType = "mcp_tool",
principalId = "${it.providerId}/${it.definition.name}",
displayName = it.definition.name,
description = it.definition.description,
)
}
}.getOrDefault(emptyList())
return plugins + tools
}

// Tab update provider factory - delegate to underlying context
override val tabUpdateProviderFactory: TabUpdateProviderFactory? get() = delegate.tabUpdateProviderFactory

Expand Down Expand Up @@ -503,8 +576,16 @@ class TrackingPluginContext(
delegate.unregisterStatusBarItem(itemId)
}

// Plugin-to-plugin API access - delegate to underlying context
override fun <T : Any> getPluginAPI(apiClass: Class<T>): T? = delegate.getPluginAPI(apiClass)
// Plugin-to-plugin API access must preserve the same secret boundary as the typed properties.
// Otherwise a plugin could ask the registry for the broad provider and bypass its bound principal.
override fun <T : Any> getPluginAPI(apiClass: Class<T>): T? =
when (apiClass) {
SecretDataProvider::class.java -> secretDataProvider?.let(apiClass::cast)
SecretAccessProvider::class.java -> apiClass.cast(secretAccessProvider)
SecretGrantManager::class.java -> secretGrantManager?.let(apiClass::cast)
SupabaseDataProvider::class.java -> supabaseDataProvider?.let(apiClass::cast)
else -> delegate.getPluginAPI(apiClass)
}

override fun registerPluginAPI(api: Any) = delegate.registerPluginAPI(api)

Expand Down Expand Up @@ -557,6 +638,42 @@ class TrackingPluginContext(
}
}

/**
* Prevents ordinary plugins from reaching secret tables or RPC functions through the generic
* database escape hatch. Secret operations must use [SecretAccessProvider], which binds every
* request to the host-derived plugin or MCP-tool principal.
*/
internal class SecretSafeSupabaseDataProvider(
private val delegate: SupabaseDataProvider,
) : SupabaseDataProvider {
override suspend fun select(
table: String,
columns: String,
filters: List<ai.rever.boss.plugin.api.QueryFilter>,
range: ai.rever.boss.plugin.api.QueryRange?,
): Result<String> =
if (table.isSecretSurface()) {
Result.failure(SecurityException("Secret tables are available only through SecretAccessProvider"))
} else {
delegate.select(table, columns, filters, range)
}

override suspend fun rpc(
function: String,
parameters: String,
): Result<String> =
if (function.isSecretSurface()) {
Result.failure(SecurityException("Secret RPCs are available only through SecretAccessProvider"))
} else {
delegate.rpc(function, parameters)
}

private fun String.isSecretSurface(): Boolean {
val normalized = lowercase().filter { it.isLetterOrDigit() || it == '_' }
return normalized.contains("secret") || normalized.contains("credential")
}
}

/**
* Binds [PluginStorageFactory.createStorage] to the plugin that owns this context,
* ignoring whatever pluginId the caller passes in.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import ai.rever.boss.plugin.api.McpToolResult
import ai.rever.boss.plugin.api.RegisteredMcpTool
import ai.rever.boss.plugin.logging.LogSanitizer
import ai.rever.boss.plugin.pathutils.BossDirectories
import ai.rever.boss.services.supabase.SecretExecutionPrincipal
import ai.rever.boss.services.supabase.SecretExecutionPrincipalContext
import ai.rever.boss.utils.atomicWriteText
import ai.rever.boss.utils.logging.BossLogger
import ai.rever.boss.utils.logging.LogCategory
Expand Down Expand Up @@ -1039,7 +1041,16 @@ internal class McpToolRegistryCore(
@Suppress("TooGenericExceptionCaught") // Plugin handlers may throw any implementation-specific exception.
private suspend fun executeUncapped(tool: RegisteredMcpTool, args: McpToolArgs): McpToolResult =
try {
withTimeout(invokeTimeoutMs) { tool.definition.handler.call(args) }
// Narrow any SecretAccessProvider captured by the plugin to this exact MCP tool for
// the full handler coroutine. The identity comes from the host registry entry, never
// from model-controlled arguments or a provider-supplied display name.
withContext(
SecretExecutionPrincipalContext(
SecretExecutionPrincipal.mcpTool(tool.providerId, tool.definition.name),
),
) {
withTimeout(invokeTimeoutMs) { tool.definition.handler.call(args) }
}
} catch (_: TimeoutCancellationException) {
McpToolResult("Tool '${tool.definition.name}' timed out after ${invokeTimeoutMs / 1000}s", isError = true)
} catch (cancelled: CancellationException) {
Expand Down
Loading
Loading