From 629baf5fa7f6c24c3dbdcc5b075ba9457537eb0b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Apr 2026 14:43:29 +0100 Subject: [PATCH] refactor: move plugin setup and memory capabilities to registries --- .../OpenClawKit/Resources/tool-display.json | 7 + extensions/acpx/setup-api.ts | 18 + extensions/amazon-bedrock/setup-api.ts | 18 + extensions/anthropic-vertex/setup-api.ts | 16 + extensions/anthropic/setup-api.ts | 11 + extensions/browser/setup-api.ts | 58 +++ extensions/elevenlabs/setup-api.ts | 21 + extensions/google/setup-api.ts | 18 + .../memory-core/src/memory/embeddings.ts | 22 +- .../src/memory/manager-sync-ops.ts | 6 +- .../src/memory/provider-adapters.ts | 27 -- extensions/ollama/index.ts | 2 + extensions/ollama/openclaw.plugin.json | 1 + .../ollama/src/memory-embedding-adapter.ts | 28 ++ extensions/voice-call/setup-api.ts | 50 +++ extensions/xai/setup-api.ts | 25 ++ src/agents/anthropic-vertex-stream.ts | 2 +- src/agents/cli-backends.ts | 40 +- src/agents/cli-output.ts | 2 +- src/agents/cli-runner/helpers.ts | 2 +- src/agents/memory-search.ts | 2 +- src/agents/model-auth-env.ts | 22 +- src/agents/models-config.providers.policy.ts | 37 +- src/agents/models-config.providers.ts | 2 +- src/agents/sandbox/browser-bridges.ts | 2 +- src/agents/sandbox/browser.ts | 10 +- src/agents/sandbox/context.ts | 10 +- src/agents/sandbox/manage.ts | 2 +- src/agents/sandbox/prune.ts | 2 +- src/agents/tool-display-config.ts | 5 + src/commands/doctor-browser.ts | 4 +- src/commands/doctor-legacy-config.ts | 50 +-- src/config/legacy.migrations.runtime.ts | 21 +- src/config/plugin-auto-enable.shared.ts | 130 ++---- src/config/plugin-auto-enable.test-helpers.ts | 2 + src/config/schema.test.ts | 3 + src/gateway/embeddings-http.ts | 23 +- src/gateway/server-methods/agents.ts | 2 +- src/gateway/server-plugins.test.ts | 1 + src/gateway/session-reset-service.ts | 2 +- src/gateway/test-helpers.mocks.ts | 1 + src/memory-host-sdk/engine-embeddings.ts | 2 +- src/memory-host-sdk/host/embeddings-ollama.ts | 4 +- src/node-host/invoke.ts | 2 +- src/node-host/runner.ts | 2 +- src/plugin-sdk/amazon-bedrock-mantle.ts | 1 + src/plugin-sdk/anthropic-vertex.ts | 22 + src/plugin-sdk/browser-bridge.test.ts | 58 +++ src/plugin-sdk/browser-bridge.ts | 47 ++- src/plugin-sdk/browser-host-inspection.ts | 6 +- src/plugin-sdk/browser-node-host.test.ts | 35 ++ src/plugin-sdk/browser-node-host.ts | 16 + src/plugin-sdk/ollama-runtime.ts | 20 + src/plugin-sdk/ollama.ts | 12 + src/plugins/api-builder.ts | 11 + src/plugins/bundled-capability-runtime.ts | 13 + .../capability-provider-runtime.test.ts | 2 + src/plugins/capability-provider-runtime.ts | 3 + src/plugins/captured-registration.ts | 7 + src/plugins/channel-plugin-ids.ts | 1 + ...memory-embedding-provider.contract.test.ts | 30 +- src/plugins/contracts/testkit.ts | 2 + src/plugins/loader.ts | 8 + src/plugins/manifest-registry.ts | 5 +- src/plugins/manifest.ts | 3 + .../memory-embedding-provider-runtime.test.ts | 73 ++++ .../memory-embedding-provider-runtime.ts | 34 ++ src/plugins/registry-empty.ts | 1 + src/plugins/registry.ts | 49 ++- src/plugins/runtime.test.ts | 2 + src/plugins/setup-registry.ts | 377 ++++++++++++++++++ src/plugins/status.test-helpers.ts | 2 + src/plugins/types.ts | 25 ++ src/security/audit.ts | 12 +- src/test-utils/channel-plugins.ts | 1 + test/helpers/plugins/plugin-api.ts | 3 + 76 files changed, 1300 insertions(+), 298 deletions(-) create mode 100644 extensions/acpx/setup-api.ts create mode 100644 extensions/amazon-bedrock/setup-api.ts create mode 100644 extensions/anthropic-vertex/setup-api.ts create mode 100644 extensions/anthropic/setup-api.ts create mode 100644 extensions/browser/setup-api.ts create mode 100644 extensions/elevenlabs/setup-api.ts create mode 100644 extensions/google/setup-api.ts create mode 100644 extensions/ollama/src/memory-embedding-adapter.ts create mode 100644 extensions/voice-call/setup-api.ts create mode 100644 extensions/xai/setup-api.ts create mode 100644 src/plugin-sdk/amazon-bedrock-mantle.ts create mode 100644 src/plugin-sdk/anthropic-vertex.ts create mode 100644 src/plugin-sdk/browser-bridge.test.ts create mode 100644 src/plugin-sdk/browser-node-host.test.ts create mode 100644 src/plugin-sdk/browser-node-host.ts create mode 100644 src/plugin-sdk/ollama-runtime.ts create mode 100644 src/plugin-sdk/ollama.ts create mode 100644 src/plugins/memory-embedding-provider-runtime.test.ts create mode 100644 src/plugins/memory-embedding-provider-runtime.ts create mode 100644 src/plugins/setup-registry.ts diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json index 52bd890e716..9e699f1721e 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json @@ -711,6 +711,13 @@ "lines" ] }, + "update_plan": { + "emoji": "πŸ—ΊοΈ", + "title": "Update Plan", + "detailKeys": [ + "explanation" + ] + }, "web_search": { "emoji": "πŸ”Ž", "title": "Web Search", diff --git a/extensions/acpx/setup-api.ts b/extensions/acpx/setup-api.ts new file mode 100644 index 00000000000..9272369a63b --- /dev/null +++ b/extensions/acpx/setup-api.ts @@ -0,0 +1,18 @@ +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; + +export default definePluginEntry({ + id: "acpx", + name: "ACPX Setup", + description: "Lightweight ACPX setup hooks", + register(api) { + api.registerAutoEnableProbe(({ config }) => { + const backendRaw = + typeof config.acp?.backend === "string" ? config.acp.backend.trim().toLowerCase() : ""; + const configured = + config.acp?.enabled === true || + config.acp?.dispatch?.enabled === true || + backendRaw === "acpx"; + return configured && (!backendRaw || backendRaw === "acpx") ? "ACP runtime configured" : null; + }); + }, +}); diff --git a/extensions/amazon-bedrock/setup-api.ts b/extensions/amazon-bedrock/setup-api.ts new file mode 100644 index 00000000000..ebc4b64f41a --- /dev/null +++ b/extensions/amazon-bedrock/setup-api.ts @@ -0,0 +1,18 @@ +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import { migrateAmazonBedrockLegacyConfig } from "./config-api.js"; +import { resolveBedrockConfigApiKey } from "./discovery.js"; + +export default definePluginEntry({ + id: "amazon-bedrock", + name: "Amazon Bedrock Setup", + description: "Lightweight Amazon Bedrock setup hooks", + register(api) { + api.registerProvider({ + id: "amazon-bedrock", + label: "Amazon Bedrock", + auth: [], + resolveConfigApiKey: ({ env }) => resolveBedrockConfigApiKey(env), + }); + api.registerConfigMigration((config) => migrateAmazonBedrockLegacyConfig(config)); + }, +}); diff --git a/extensions/anthropic-vertex/setup-api.ts b/extensions/anthropic-vertex/setup-api.ts new file mode 100644 index 00000000000..4f4102c2b37 --- /dev/null +++ b/extensions/anthropic-vertex/setup-api.ts @@ -0,0 +1,16 @@ +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import { resolveAnthropicVertexConfigApiKey } from "./region.js"; + +export default definePluginEntry({ + id: "anthropic-vertex", + name: "Anthropic Vertex Setup", + description: "Lightweight Anthropic Vertex setup hooks", + register(api) { + api.registerProvider({ + id: "anthropic-vertex", + label: "Anthropic Vertex", + auth: [], + resolveConfigApiKey: ({ env }) => resolveAnthropicVertexConfigApiKey(env), + }); + }, +}); diff --git a/extensions/anthropic/setup-api.ts b/extensions/anthropic/setup-api.ts new file mode 100644 index 00000000000..0f5ea4d3d21 --- /dev/null +++ b/extensions/anthropic/setup-api.ts @@ -0,0 +1,11 @@ +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import { buildAnthropicCliBackend } from "./cli-backend.js"; + +export default definePluginEntry({ + id: "anthropic", + name: "Anthropic Setup", + description: "Lightweight Anthropic setup hooks", + register(api) { + api.registerCliBackend(buildAnthropicCliBackend()); + }, +}); diff --git a/extensions/browser/setup-api.ts b/extensions/browser/setup-api.ts new file mode 100644 index 00000000000..23a9821fa8a --- /dev/null +++ b/extensions/browser/setup-api.ts @@ -0,0 +1,58 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function listContainsBrowser(value: unknown): boolean { + return ( + Array.isArray(value) && + value.some((entry) => typeof entry === "string" && entry.trim().toLowerCase() === "browser") + ); +} + +function toolPolicyReferencesBrowser(value: unknown): boolean { + return ( + isRecord(value) && (listContainsBrowser(value.allow) || listContainsBrowser(value.alsoAllow)) + ); +} + +function hasBrowserToolReference(config: OpenClawConfig): boolean { + if (toolPolicyReferencesBrowser(config.tools)) { + return true; + } + const agentList = config.agents?.list; + return Array.isArray(agentList) + ? agentList.some((entry) => isRecord(entry) && toolPolicyReferencesBrowser(entry.tools)) + : false; +} + +export default definePluginEntry({ + id: "browser", + name: "Browser Setup", + description: "Lightweight Browser setup hooks", + register(api) { + api.registerAutoEnableProbe(({ config }) => { + if ( + config.browser?.enabled === false || + config.plugins?.entries?.browser?.enabled === false + ) { + return null; + } + if (Object.prototype.hasOwnProperty.call(config, "browser")) { + return "browser configured"; + } + if ( + config.plugins?.entries && + Object.prototype.hasOwnProperty.call(config.plugins.entries, "browser") + ) { + return "browser plugin configured"; + } + if (hasBrowserToolReference(config)) { + return "browser tool referenced"; + } + return null; + }); + }, +}); diff --git a/extensions/elevenlabs/setup-api.ts b/extensions/elevenlabs/setup-api.ts new file mode 100644 index 00000000000..efb20d1fbe6 --- /dev/null +++ b/extensions/elevenlabs/setup-api.ts @@ -0,0 +1,21 @@ +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import { migrateElevenLabsLegacyTalkConfig } from "./config-compat.js"; + +export default definePluginEntry({ + id: "elevenlabs", + name: "ElevenLabs Setup", + description: "Lightweight ElevenLabs setup hooks", + register(api) { + api.registerLegacyConfigMigration((raw, changes) => { + const migrated = migrateElevenLabsLegacyTalkConfig(raw); + if (migrated.changes.length === 0) { + return; + } + for (const key of Object.keys(raw)) { + delete raw[key]; + } + Object.assign(raw, migrated.config); + changes.push(...migrated.changes); + }); + }, +}); diff --git a/extensions/google/setup-api.ts b/extensions/google/setup-api.ts new file mode 100644 index 00000000000..869eb280378 --- /dev/null +++ b/extensions/google/setup-api.ts @@ -0,0 +1,18 @@ +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import { normalizeGoogleProviderConfig } from "./api.js"; + +export default definePluginEntry({ + id: "google", + name: "Google Setup", + description: "Lightweight Google setup hooks", + register(api) { + api.registerProvider({ + id: "google", + label: "Google AI Studio", + hookAliases: ["google-antigravity", "google-vertex"], + auth: [], + normalizeConfig: ({ provider, providerConfig }) => + normalizeGoogleProviderConfig(provider, providerConfig), + }); + }, +}); diff --git a/extensions/memory-core/src/memory/embeddings.ts b/extensions/memory-core/src/memory/embeddings.ts index a98672c7a44..e1f007ad33b 100644 --- a/extensions/memory-core/src/memory/embeddings.ts +++ b/extensions/memory-core/src/memory/embeddings.ts @@ -12,10 +12,7 @@ import { type MemoryEmbeddingProviderCreateOptions, type MemoryEmbeddingProviderRuntime, } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings"; -import { - canAutoSelectLocal, - getBuiltinMemoryEmbeddingProviderAdapter, -} from "./provider-adapters.js"; +import { canAutoSelectLocal } from "./provider-adapters.js"; export { DEFAULT_GEMINI_EMBEDDING_MODEL, @@ -61,8 +58,11 @@ function shouldContinueAutoSelection( return adapter.shouldContinueAutoSelection?.(err) ?? false; } -function getAdapter(id: string): MemoryEmbeddingProviderAdapter { - const adapter = getMemoryEmbeddingProvider(id); +function getAdapter( + id: string, + config?: MemoryEmbeddingProviderCreateOptions["config"], +): MemoryEmbeddingProviderAdapter { + const adapter = getMemoryEmbeddingProvider(id, config); if (!adapter) { throw new Error(`Unknown memory embedding provider: ${id}`); } @@ -72,7 +72,7 @@ function getAdapter(id: string): MemoryEmbeddingProviderAdapter { function listAutoSelectAdapters( options: CreateEmbeddingProviderOptions, ): MemoryEmbeddingProviderAdapter[] { - return listMemoryEmbeddingProviders() + return listMemoryEmbeddingProviders(options.config) .filter((adapter) => typeof adapter.autoSelectPriority === "number") .filter((adapter) => adapter.id === "local" ? canAutoSelectLocal(options.local?.modelPath) : true, @@ -98,9 +98,9 @@ function resolveProviderModel( export function resolveEmbeddingProviderFallbackModel( providerId: string, fallbackSourceModel: string, + config?: MemoryEmbeddingProviderCreateOptions["config"], ): string { - const adapter = - getMemoryEmbeddingProvider(providerId) ?? getBuiltinMemoryEmbeddingProviderAdapter(providerId); + const adapter = getMemoryEmbeddingProvider(providerId, config); return adapter?.defaultModel ?? fallbackSourceModel; } @@ -153,13 +153,13 @@ export async function createEmbeddingProvider( }; } - const primaryAdapter = getAdapter(options.provider); + const primaryAdapter = getAdapter(options.provider, options.config); try { return await createWithAdapter(primaryAdapter, options); } catch (primaryErr) { const reason = formatProviderError(primaryAdapter, primaryErr); if (options.fallback && options.fallback !== "none" && options.fallback !== options.provider) { - const fallbackAdapter = getAdapter(options.fallback); + const fallbackAdapter = getAdapter(options.fallback, options.config); try { const fallbackResult = await createWithAdapter(fallbackAdapter, { ...options, diff --git a/extensions/memory-core/src/memory/manager-sync-ops.ts b/extensions/memory-core/src/memory/manager-sync-ops.ts index c10c5dc0c3d..a85aadcf54d 100644 --- a/extensions/memory-core/src/memory/manager-sync-ops.ts +++ b/extensions/memory-core/src/memory/manager-sync-ops.ts @@ -1119,7 +1119,11 @@ export abstract class MemoryManagerSyncOps { } const fallbackFrom = this.provider.id as EmbeddingProviderId; - const fallbackModel = resolveEmbeddingProviderFallbackModel(fallback, this.settings.model); + const fallbackModel = resolveEmbeddingProviderFallbackModel( + fallback, + this.settings.model, + this.cfg, + ); const fallbackResult = await createEmbeddingProvider({ config: this.cfg, diff --git a/extensions/memory-core/src/memory/provider-adapters.ts b/extensions/memory-core/src/memory/provider-adapters.ts index e802b13ec84..f242ea405f9 100644 --- a/extensions/memory-core/src/memory/provider-adapters.ts +++ b/extensions/memory-core/src/memory/provider-adapters.ts @@ -3,7 +3,6 @@ import { DEFAULT_GEMINI_EMBEDDING_MODEL, DEFAULT_LOCAL_MODEL, DEFAULT_MISTRAL_EMBEDDING_MODEL, - DEFAULT_OLLAMA_EMBEDDING_MODEL, DEFAULT_OPENAI_EMBEDDING_MODEL, DEFAULT_VOYAGE_EMBEDDING_MODEL, OPENAI_BATCH_ENDPOINT, @@ -11,7 +10,6 @@ import { createGeminiEmbeddingProvider, createLocalEmbeddingProvider, createMistralEmbeddingProvider, - createOllamaEmbeddingProvider, createOpenAiEmbeddingProvider, createVoyageEmbeddingProvider, hasNonTextEmbeddingParts, @@ -289,29 +287,6 @@ const mistralAdapter: MemoryEmbeddingProviderAdapter = { }, }; -const ollamaAdapter: MemoryEmbeddingProviderAdapter = { - id: "ollama", - defaultModel: DEFAULT_OLLAMA_EMBEDDING_MODEL, - transport: "remote", - create: async (options) => { - const { provider, client } = await createOllamaEmbeddingProvider({ - ...options, - provider: "ollama", - fallback: "none", - }); - return { - provider, - runtime: { - id: "ollama", - cacheKeyData: { - provider: "ollama", - model: client.model, - }, - }, - }; - }, -}; - const localAdapter: MemoryEmbeddingProviderAdapter = { id: "local", defaultModel: DEFAULT_LOCAL_MODEL, @@ -344,7 +319,6 @@ export const builtinMemoryEmbeddingProviderAdapters = [ geminiAdapter, voyageAdapter, mistralAdapter, - ollamaAdapter, ] as const; const builtinMemoryEmbeddingProviderAdapterById = new Map( @@ -403,7 +377,6 @@ export { DEFAULT_GEMINI_EMBEDDING_MODEL, DEFAULT_LOCAL_MODEL, DEFAULT_MISTRAL_EMBEDDING_MODEL, - DEFAULT_OLLAMA_EMBEDDING_MODEL, DEFAULT_OPENAI_EMBEDDING_MODEL, DEFAULT_VOYAGE_EMBEDDING_MODEL, canAutoSelectLocal, diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts index 68ae925ba5f..3b915dcce77 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -18,6 +18,7 @@ import { DEFAULT_OLLAMA_EMBEDDING_MODEL, createOllamaEmbeddingProvider, } from "./src/embedding-provider.js"; +import { ollamaMemoryEmbeddingProviderAdapter } from "./src/memory-embedding-adapter.js"; import { resolveOllamaApiBase } from "./src/provider-models.js"; import { createConfiguredOllamaCompatStreamWrapper, @@ -57,6 +58,7 @@ export default definePluginEntry({ name: "Ollama Provider", description: "Bundled Ollama provider plugin", register(api: OpenClawPluginApi) { + api.registerMemoryEmbeddingProvider(ollamaMemoryEmbeddingProviderAdapter); const pluginConfig = (api.pluginConfig ?? {}) as OllamaPluginConfig; api.registerWebSearchProvider(createOllamaWebSearchProvider()); api.registerProvider({ diff --git a/extensions/ollama/openclaw.plugin.json b/extensions/ollama/openclaw.plugin.json index a44ef5956cd..ca97b8c3764 100644 --- a/extensions/ollama/openclaw.plugin.json +++ b/extensions/ollama/openclaw.plugin.json @@ -18,6 +18,7 @@ } ], "contracts": { + "memoryEmbeddingProviders": ["ollama"], "webSearchProviders": ["ollama"] }, "configSchema": { diff --git a/extensions/ollama/src/memory-embedding-adapter.ts b/extensions/ollama/src/memory-embedding-adapter.ts new file mode 100644 index 00000000000..eccc9e03773 --- /dev/null +++ b/extensions/ollama/src/memory-embedding-adapter.ts @@ -0,0 +1,28 @@ +import type { MemoryEmbeddingProviderAdapter } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings"; +import { + DEFAULT_OLLAMA_EMBEDDING_MODEL, + createOllamaEmbeddingProvider, +} from "./embedding-provider.js"; + +export const ollamaMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapter = { + id: "ollama", + defaultModel: DEFAULT_OLLAMA_EMBEDDING_MODEL, + transport: "remote", + create: async (options) => { + const { provider, client } = await createOllamaEmbeddingProvider({ + ...options, + provider: "ollama", + fallback: "none", + }); + return { + provider, + runtime: { + id: "ollama", + cacheKeyData: { + provider: "ollama", + model: client.model, + }, + }, + }; + }, +}; diff --git a/extensions/voice-call/setup-api.ts b/extensions/voice-call/setup-api.ts new file mode 100644 index 00000000000..3c7503c64da --- /dev/null +++ b/extensions/voice-call/setup-api.ts @@ -0,0 +1,50 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import { migrateVoiceCallLegacyConfigInput } from "./config-api.js"; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function migrateVoiceCallPluginConfig(config: OpenClawConfig): { + config: OpenClawConfig; + changes: string[]; +} | null { + const rawVoiceCallConfig = config.plugins?.entries?.["voice-call"]?.config; + if (!isRecord(rawVoiceCallConfig)) { + return null; + } + const migration = migrateVoiceCallLegacyConfigInput({ + value: rawVoiceCallConfig, + configPathPrefix: "plugins.entries.voice-call.config", + }); + if (migration.changes.length === 0) { + return null; + } + const plugins = structuredClone(config.plugins ?? {}); + const entries = { ...plugins.entries }; + const existingVoiceCallEntry = isRecord(entries["voice-call"]) + ? (entries["voice-call"] as Record) + : {}; + entries["voice-call"] = { + ...existingVoiceCallEntry, + config: migration.config, + }; + plugins.entries = entries; + return { + config: { + ...config, + plugins, + }, + changes: migration.changes, + }; +} + +export default definePluginEntry({ + id: "voice-call", + name: "Voice Call Setup", + description: "Lightweight Voice Call setup hooks", + register(api) { + api.registerConfigMigration((config) => migrateVoiceCallPluginConfig(config)); + }, +}); diff --git a/extensions/xai/setup-api.ts b/extensions/xai/setup-api.ts new file mode 100644 index 00000000000..9e7a0382d48 --- /dev/null +++ b/extensions/xai/setup-api.ts @@ -0,0 +1,25 @@ +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export default definePluginEntry({ + id: "xai", + name: "xAI Setup", + description: "Lightweight xAI setup hooks", + register(api) { + api.registerAutoEnableProbe(({ config }) => { + const pluginConfig = config.plugins?.entries?.xai?.config; + const web = config.tools?.web as Record | undefined; + if ( + isRecord(web?.x_search) || + (isRecord(pluginConfig) && + (isRecord(pluginConfig.xSearch) || isRecord(pluginConfig.codeExecution))) + ) { + return "xai tool configured"; + } + return null; + }); + }, +}); diff --git a/src/agents/anthropic-vertex-stream.ts b/src/agents/anthropic-vertex-stream.ts index 3e056b937a5..d8777d40203 100644 --- a/src/agents/anthropic-vertex-stream.ts +++ b/src/agents/anthropic-vertex-stream.ts @@ -4,7 +4,7 @@ import { streamAnthropic, type AnthropicOptions, type Model } from "@mariozechne import { resolveAnthropicVertexClientRegion, resolveAnthropicVertexProjectId, -} from "../../extensions/anthropic-vertex/api.js"; +} from "../plugin-sdk/anthropic-vertex.js"; import { applyAnthropicPayloadPolicyToParams, resolveAnthropicPayloadPolicy, diff --git a/src/agents/cli-backends.ts b/src/agents/cli-backends.ts index e543cd20292..a40c75356df 100644 --- a/src/agents/cli-backends.ts +++ b/src/agents/cli-backends.ts @@ -1,11 +1,7 @@ -import { - CLAUDE_CLI_BACKEND_ID, - buildAnthropicCliBackend, - normalizeClaudeBackendConfig, -} from "../../extensions/anthropic/cli-backend-api.js"; import type { OpenClawConfig } from "../config/config.js"; import type { CliBackendConfig } from "../config/types.js"; import { resolveRuntimeCliBackends } from "../plugins/cli-backends.runtime.js"; +import { resolvePluginSetupCliBackend } from "../plugins/setup-registry.js"; import { normalizeProviderId } from "./model-selection.js"; export type ResolvedCliBackend = { @@ -15,7 +11,10 @@ export type ResolvedCliBackend = { pluginId?: string; }; -export { normalizeClaudeBackendConfig }; +export function normalizeClaudeBackendConfig(config: CliBackendConfig): CliBackendConfig { + const normalizeConfig = resolveFallbackCliBackendPolicy("claude-cli")?.normalizeConfig; + return normalizeConfig ? normalizeConfig(config) : config; +} type FallbackCliBackendPolicy = { bundleMcp: boolean; @@ -23,19 +22,26 @@ type FallbackCliBackendPolicy = { normalizeConfig?: (config: CliBackendConfig) => CliBackendConfig; }; -const FALLBACK_CLI_BACKEND_POLICIES: Record = { - [CLAUDE_CLI_BACKEND_ID]: { - // Claude CLI consumes explicit MCP config overlays even when the runtime - // plugin registry is not initialized yet (for example direct runner tests - // or narrow non-gateway entrypoints). - bundleMcp: true, - baseConfig: buildAnthropicCliBackend().config, - normalizeConfig: normalizeClaudeBackendConfig, - }, -}; +const FALLBACK_CLI_BACKEND_POLICIES: Record = {}; + +function resolveSetupCliBackendPolicy(provider: string): FallbackCliBackendPolicy | undefined { + const entry = resolvePluginSetupCliBackend({ + backend: provider, + }); + if (!entry) { + return undefined; + } + return { + // Setup-registered backends keep narrow CLI paths generic even when the + // runtime plugin registry has not booted yet. + bundleMcp: entry.backend.bundleMcp === true, + baseConfig: entry.backend.config, + normalizeConfig: entry.backend.normalizeConfig, + }; +} function resolveFallbackCliBackendPolicy(provider: string): FallbackCliBackendPolicy | undefined { - return FALLBACK_CLI_BACKEND_POLICIES[provider]; + return FALLBACK_CLI_BACKEND_POLICIES[provider] ?? resolveSetupCliBackendPolicy(provider); } function normalizeBackendKey(key: string): string { diff --git a/src/agents/cli-output.ts b/src/agents/cli-output.ts index 66f114830b2..405f807d2c2 100644 --- a/src/agents/cli-output.ts +++ b/src/agents/cli-output.ts @@ -1,5 +1,5 @@ -import { isClaudeCliProvider } from "../../extensions/anthropic/api.js"; import type { CliBackendConfig } from "../config/types.js"; +import { isClaudeCliProvider } from "../plugin-sdk/anthropic-cli.js"; import { isRecord } from "../utils.js"; type CliUsage = { diff --git a/src/agents/cli-runner/helpers.ts b/src/agents/cli-runner/helpers.ts index f9a08c465bb..f65790116c0 100644 --- a/src/agents/cli-runner/helpers.ts +++ b/src/agents/cli-runner/helpers.ts @@ -5,13 +5,13 @@ import path from "node:path"; import type { AgentTool } from "@mariozechner/pi-agent-core"; import type { ImageContent } from "@mariozechner/pi-ai"; import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; -import { isClaudeCliProvider } from "../../../extensions/anthropic/api.js"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { CliBackendConfig } from "../../config/types.js"; import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js"; import { MAX_IMAGE_BYTES } from "../../media/constants.js"; import { extensionForMime } from "../../media/mime.js"; +import { isClaudeCliProvider } from "../../plugin-sdk/anthropic-cli.js"; import { buildTtsSystemPromptHint } from "../../tts/tts.js"; import { buildModelAliasLines } from "../model-alias-lines.js"; import { resolveDefaultModelForAgent } from "../model-selection.js"; diff --git a/src/agents/memory-search.ts b/src/agents/memory-search.ts index ec0e8c10178..b56375cd977 100644 --- a/src/agents/memory-search.ts +++ b/src/agents/memory-search.ts @@ -9,7 +9,7 @@ import { supportsMemoryMultimodalEmbeddings, type MemoryMultimodalSettings, } from "../memory-host-sdk/multimodal.js"; -import { getMemoryEmbeddingProvider } from "../plugins/memory-embedding-providers.js"; +import { getMemoryEmbeddingProvider } from "../plugins/memory-embedding-provider-runtime.js"; import { clampInt, clampNumber, resolveUserPath } from "../utils.js"; import { resolveAgentConfig } from "./agent-scope.js"; diff --git a/src/agents/model-auth-env.ts b/src/agents/model-auth-env.ts index 37ada8503c0..632e091ad87 100644 --- a/src/agents/model-auth-env.ts +++ b/src/agents/model-auth-env.ts @@ -1,6 +1,6 @@ import { getEnvApiKey } from "@mariozechner/pi-ai"; -import { hasAnthropicVertexAvailableAuth } from "../../extensions/anthropic-vertex/api.js"; import { getShellEnvAppliedKeys } from "../infra/shell-env.js"; +import { resolvePluginSetupProvider } from "../plugins/setup-registry.js"; import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; import { resolveProviderEnvApiKeyCandidates } from "./model-auth-env-vars.js"; import { GCP_VERTEX_CREDENTIALS_MARKER } from "./model-auth-markers.js"; @@ -44,13 +44,21 @@ export function resolveEnvApiKey( return { apiKey: envKey, source: "gcloud adc" }; } - if (normalized === "anthropic-vertex") { - // Vertex AI uses GCP credentials (SA JSON or ADC), not API keys. - // Return a sentinel so the model resolver still treats this provider as available. - if (hasAnthropicVertexAvailableAuth(env)) { - return { apiKey: GCP_VERTEX_CREDENTIALS_MARKER, source: "gcloud adc" }; + const setupProvider = resolvePluginSetupProvider({ + provider: normalized, + env, + }); + if (setupProvider?.resolveConfigApiKey) { + const resolved = setupProvider.resolveConfigApiKey({ + provider: normalized, + env, + }); + if (resolved?.trim()) { + return { + apiKey: resolved, + source: resolved === GCP_VERTEX_CREDENTIALS_MARKER ? "gcloud adc" : "env", + }; } - return null; } return null; diff --git a/src/agents/models-config.providers.policy.ts b/src/agents/models-config.providers.policy.ts index 85477b619fd..035a5cdd915 100644 --- a/src/agents/models-config.providers.policy.ts +++ b/src/agents/models-config.providers.policy.ts @@ -1,15 +1,10 @@ -import { resolveMantleBearerToken } from "../../extensions/amazon-bedrock-mantle/discovery.js"; -import { resolveBedrockConfigApiKey } from "../../extensions/amazon-bedrock/api.js"; -import { resolveAnthropicVertexConfigApiKey } from "../../extensions/anthropic-vertex/region.js"; -import { - normalizeGoogleProviderConfig, - shouldNormalizeGoogleProviderConfig, -} from "../../extensions/google/api.js"; import { MODEL_APIS } from "../config/types.models.js"; +import { resolveMantleBearerToken } from "../plugin-sdk/amazon-bedrock-mantle.js"; import { applyProviderNativeStreamingUsageCompatWithPlugin, normalizeProviderConfigWithPlugin, } from "../plugins/provider-runtime.js"; +import { resolvePluginSetupProvider } from "../plugins/setup-registry.js"; import type { ProviderConfig } from "./models-config.providers.secrets.js"; const GENERIC_PROVIDER_APIS = new Set([ @@ -59,8 +54,15 @@ export function normalizeProviderSpecificConfig( providerKey: string, provider: ProviderConfig, ): ProviderConfig { - if (shouldNormalizeGoogleProviderConfig(providerKey, provider)) { - return normalizeGoogleProviderConfig(providerKey, provider); + const setupProvider = resolvePluginSetupProvider({ + provider: resolveProviderPluginLookupKey(providerKey, provider), + }); + const setupNormalized = setupProvider?.normalizeConfig?.({ + provider: providerKey, + providerConfig: provider, + }); + if (setupNormalized && setupNormalized !== provider) { + return setupNormalized; } const runtimeProviderKey = resolveProviderPluginLookupKey(providerKey, provider); if (!PROVIDERS_WITH_RUNTIME_NORMALIZE_CONFIG.has(runtimeProviderKey)) { @@ -84,19 +86,20 @@ export function resolveProviderConfigApiKeyResolver( providerKey: string, provider?: ProviderConfig, ): ((env: NodeJS.ProcessEnv) => string | undefined) | undefined { - if (providerKey.trim() === "amazon-bedrock") { + const setupProvider = resolvePluginSetupProvider({ + provider: resolveProviderPluginLookupKey(providerKey, provider), + }); + const resolveSetupConfigApiKey = setupProvider?.resolveConfigApiKey; + if (resolveSetupConfigApiKey) { return (env) => { - const resolved = resolveBedrockConfigApiKey(env); + const resolved = resolveSetupConfigApiKey({ + provider: providerKey, + env, + }); return resolved?.trim() || undefined; }; } const runtimeProviderKey = resolveProviderPluginLookupKey(providerKey, provider).trim(); - if (runtimeProviderKey === "anthropic-vertex") { - return (env) => { - const resolved = resolveAnthropicVertexConfigApiKey(env); - return resolved?.trim() || undefined; - }; - } if (runtimeProviderKey === "amazon-bedrock-mantle") { return (env) => resolveMantleBearerToken(env)?.trim() ? "AWS_BEARER_TOKEN_BEDROCK" : undefined; diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 036cc535eab..22685ded2d9 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -10,4 +10,4 @@ export type { } from "./models-config.providers.secrets.js"; export { applyNativeStreamingUsageCompat } from "./models-config.providers.policy.js"; export { enforceSourceManagedProviderSecrets } from "./models-config.providers.source-managed.js"; -export { resolveOllamaApiBase } from "../../extensions/ollama/api.js"; +export { resolveOllamaApiBase } from "../plugin-sdk/ollama.js"; diff --git a/src/agents/sandbox/browser-bridges.ts b/src/agents/sandbox/browser-bridges.ts index 594342258e5..11b772e9cf0 100644 --- a/src/agents/sandbox/browser-bridges.ts +++ b/src/agents/sandbox/browser-bridges.ts @@ -1,4 +1,4 @@ -import type { BrowserBridge } from "../../../extensions/browser/runtime-api.js"; +import type { BrowserBridge } from "../../plugin-sdk/browser-bridge.js"; export const BROWSER_BRIDGES = new Map< string, diff --git a/src/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts index 561792905f7..4502914a220 100644 --- a/src/agents/sandbox/browser.ts +++ b/src/agents/sandbox/browser.ts @@ -1,14 +1,16 @@ import crypto from "node:crypto"; +import { deriveDefaultBrowserCdpPortRange } from "../../config/port-defaults.js"; +import { + startBrowserBridgeServer, + stopBrowserBridgeServer, +} from "../../plugin-sdk/browser-bridge.js"; import { DEFAULT_BROWSER_EVALUATE_ENABLED, DEFAULT_OPENCLAW_BROWSER_COLOR, DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, resolveProfile, - startBrowserBridgeServer, - stopBrowserBridgeServer, type ResolvedBrowserConfig, -} from "../../../extensions/browser/runtime-api.js"; -import { deriveDefaultBrowserCdpPortRange } from "../../config/port-defaults.js"; +} from "../../plugin-sdk/browser-config.js"; import { defaultRuntime } from "../../runtime.js"; import { BROWSER_BRIDGES } from "./browser-bridges.js"; import { computeSandboxBrowserConfigHash } from "./config-hash.js"; diff --git a/src/agents/sandbox/context.ts b/src/agents/sandbox/context.ts index 5fda001f4fb..72b6a39615f 100644 --- a/src/agents/sandbox/context.ts +++ b/src/agents/sandbox/context.ts @@ -1,12 +1,12 @@ import fs from "node:fs/promises"; -import { - DEFAULT_BROWSER_EVALUATE_ENABLED, - ensureBrowserControlAuth, - resolveBrowserControlAuth, -} from "../../../extensions/browser/runtime-api.js"; import type { OpenClawConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; import { getRemoteSkillEligibility } from "../../infra/skills-remote.js"; +import { DEFAULT_BROWSER_EVALUATE_ENABLED } from "../../plugin-sdk/browser-config.js"; +import { + ensureBrowserControlAuth, + resolveBrowserControlAuth, +} from "../../plugin-sdk/browser-control-auth.js"; import { defaultRuntime } from "../../runtime.js"; import { resolveUserPath } from "../../utils.js"; import { syncSkillsToWorkspace } from "../skills.js"; diff --git a/src/agents/sandbox/manage.ts b/src/agents/sandbox/manage.ts index 4969e1fec73..ecca5131914 100644 --- a/src/agents/sandbox/manage.ts +++ b/src/agents/sandbox/manage.ts @@ -1,5 +1,5 @@ -import { stopBrowserBridgeServer } from "../../../extensions/browser/runtime-api.js"; import { loadConfig } from "../../config/config.js"; +import { stopBrowserBridgeServer } from "../../plugin-sdk/browser-bridge.js"; import { getSandboxBackendManager } from "./backend.js"; import { BROWSER_BRIDGES } from "./browser-bridges.js"; import { dockerSandboxBackendManager } from "./docker-backend.js"; diff --git a/src/agents/sandbox/prune.ts b/src/agents/sandbox/prune.ts index 00126d2992c..0dcdb090e79 100644 --- a/src/agents/sandbox/prune.ts +++ b/src/agents/sandbox/prune.ts @@ -1,5 +1,5 @@ -import { stopBrowserBridgeServer } from "../../../extensions/browser/runtime-api.js"; import { loadConfig } from "../../config/config.js"; +import { stopBrowserBridgeServer } from "../../plugin-sdk/browser-bridge.js"; import { defaultRuntime } from "../../runtime.js"; import { getSandboxBackendManager } from "./backend.js"; import { BROWSER_BRIDGES } from "./browser-bridges.js"; diff --git a/src/agents/tool-display-config.ts b/src/agents/tool-display-config.ts index 4e6948500f2..1c0867c4b2d 100644 --- a/src/agents/tool-display-config.ts +++ b/src/agents/tool-display-config.ts @@ -471,6 +471,11 @@ export const TOOL_DISPLAY_CONFIG: ToolDisplayConfig = { title: "Memory Get", detailKeys: ["path", "from", "lines"], }, + update_plan: { + emoji: "πŸ—ΊοΈ", + title: "Update Plan", + detailKeys: ["explanation"], + }, web_search: { emoji: "πŸ”Ž", title: "Web Search", diff --git a/src/commands/doctor-browser.ts b/src/commands/doctor-browser.ts index 80865eee776..c376b834d64 100644 --- a/src/commands/doctor-browser.ts +++ b/src/commands/doctor-browser.ts @@ -1,9 +1,9 @@ +import type { OpenClawConfig } from "../config/config.js"; import { parseBrowserMajorVersion, readBrowserVersion, resolveGoogleChromeExecutableForPlatform, -} from "../../extensions/browser/runtime-api.js"; -import type { OpenClawConfig } from "../config/config.js"; +} from "../plugin-sdk/browser-host-inspection.js"; import { note } from "../terminal/note.js"; const CHROME_MCP_MIN_MAJOR = 144; diff --git a/src/commands/doctor-legacy-config.ts b/src/commands/doctor-legacy-config.ts index ed28ed13fc7..ebea77f3179 100644 --- a/src/commands/doctor-legacy-config.ts +++ b/src/commands/doctor-legacy-config.ts @@ -1,6 +1,4 @@ import { isDeepStrictEqual } from "node:util"; -import { migrateAmazonBedrockLegacyConfig } from "../../extensions/amazon-bedrock/config-api.js"; -import { migrateVoiceCallLegacyConfigInput } from "../../extensions/voice-call/config-api.js"; import { normalizeProviderId } from "../agents/model-selection.js"; import { shouldMoveSingleAccountChannelKey } from "../channels/plugins/setup-helpers.js"; import type { OpenClawConfig } from "../config/config.js"; @@ -10,7 +8,11 @@ import { migrateLegacyWebSearchConfig } from "../config/legacy-web-search.js"; import { migrateLegacyXSearchConfig } from "../config/legacy-x-search.js"; import { normalizeTalkSection } from "../config/talk.js"; import { DEFAULT_GOOGLE_API_BASE_URL } from "../infra/google-api-base-url.js"; -import { normalizeCompatibilityConfig as normalizeElevenLabsCompatibilityConfig } from "../plugin-sdk/elevenlabs.js"; +import { + ELEVENLABS_TALK_PROVIDER_ID, + normalizeCompatibilityConfig as normalizeElevenLabsCompatibilityConfig, +} from "../plugin-sdk/elevenlabs.js"; +import { runPluginSetupConfigMigrations } from "../plugins/setup-registry.js"; import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): { @@ -86,37 +88,6 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): { }; }; - const normalizeVoiceCallLegacyConfig = () => { - const rawVoiceCallConfig = next.plugins?.entries?.["voice-call"]?.config; - if (!isRecord(rawVoiceCallConfig)) { - return; - } - - const migration = migrateVoiceCallLegacyConfigInput({ - value: rawVoiceCallConfig, - configPathPrefix: "plugins.entries.voice-call.config", - }); - if (migration.changes.length === 0) { - return; - } - - const plugins = structuredClone(next.plugins ?? {}); - const entries = { ...plugins.entries }; - const existingVoiceCallEntry = isRecord(entries["voice-call"]) - ? (entries["voice-call"] as Record) - : {}; - entries["voice-call"] = { - ...existingVoiceCallEntry, - config: migration.config, - }; - plugins.entries = entries; - next = { - ...next, - plugins, - }; - changes.push(...migration.changes); - }; - const seedMissingDefaultAccountsFromSingleAccountBase = () => { const channels = next.channels as Record | undefined; if (!channels) { @@ -188,11 +159,12 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): { seedMissingDefaultAccountsFromSingleAccountBase(); normalizeLegacyBrowserProfiles(); - normalizeVoiceCallLegacyConfig(); - const bedrockMigration = migrateAmazonBedrockLegacyConfig(next); - if (bedrockMigration.changes.length > 0) { - next = bedrockMigration.config; - changes.push(...bedrockMigration.changes); + const setupMigration = runPluginSetupConfigMigrations({ + config: next, + }); + if (setupMigration.changes.length > 0) { + next = setupMigration.config; + changes.push(...setupMigration.changes); } const webSearchMigration = migrateLegacyWebSearchConfig(next); if (webSearchMigration.changes.length > 0) { diff --git a/src/config/legacy.migrations.runtime.ts b/src/config/legacy.migrations.runtime.ts index 22513895fe3..0e6e9ce0de7 100644 --- a/src/config/legacy.migrations.runtime.ts +++ b/src/config/legacy.migrations.runtime.ts @@ -1,7 +1,8 @@ import { ELEVENLABS_TALK_LEGACY_CONFIG_RULES, - migrateElevenLabsLegacyTalkConfig, + hasLegacyTalkFields, } from "../plugin-sdk/elevenlabs.js"; +import { runPluginSetupLegacyConfigMigrations } from "../plugins/setup-registry.js"; import { buildDefaultControlUiAllowedOrigins, hasConfiguredControlUiAllowedOrigins, @@ -154,19 +155,6 @@ function hasLegacyAgentListSandboxPerSession(value: unknown): boolean { } return value.some((agent) => hasLegacySandboxPerSession(getRecord(agent)?.sandbox)); } - -function migrateLegacyTalkFields(raw: Record, changes: string[]): void { - const migrated = migrateElevenLabsLegacyTalkConfig(raw); - if (migrated.changes.length === 0) { - return; - } - for (const key of Object.keys(raw)) { - delete raw[key]; - } - Object.assign(raw, migrated.config); - changes.push(...migrated.changes); -} - function hasLegacyPluginEntryTtsProviderKeys(value: unknown): boolean { const entries = getRecord(value); if (!entries) { @@ -346,7 +334,10 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME: LegacyConfigMigrationSpec[] = [ describe: "Move legacy Talk flat fields into talk.providers.", legacyRules: ELEVENLABS_TALK_LEGACY_CONFIG_RULES, apply: (raw, changes) => { - migrateLegacyTalkFields(raw, changes); + if (!hasLegacyTalkFields(raw.talk)) { + return; + } + runPluginSetupLegacyConfigMigrations({ raw, changes }); }, }), defineLegacyConfigMigration({ diff --git a/src/config/plugin-auto-enable.shared.ts b/src/config/plugin-auto-enable.shared.ts index 47f95471727..add9addf594 100644 --- a/src/config/plugin-auto-enable.shared.ts +++ b/src/config/plugin-auto-enable.shared.ts @@ -10,6 +10,7 @@ import { type PluginManifestRegistry, } from "../plugins/manifest-registry.js"; import { resolveOwningPluginIdsForModelRef } from "../plugins/providers.js"; +import { resolvePluginSetupAutoEnableReasons } from "../plugins/setup-registry.js"; import { isRecord } from "../utils.js"; import { isChannelConfigured } from "./channel-configured.js"; import type { OpenClawConfig } from "./config.js"; @@ -23,11 +24,6 @@ export type PluginAutoEnableCandidate = kind: "channel-configured"; channelId: string; } - | { - pluginId: "browser"; - kind: "browser-configured"; - source: "browser-configured" | "browser-plugin-configured" | "browser-tool-referenced"; - } | { pluginId: string; kind: "provider-auth-configured"; @@ -56,8 +52,9 @@ export type PluginAutoEnableCandidate = kind: "plugin-tool-configured"; } | { - pluginId: "acpx"; - kind: "acp-runtime-configured"; + pluginId: string; + kind: "setup-auto-enable"; + reason: string; }; export type PluginAutoEnableResult = { @@ -182,15 +179,15 @@ function hasPluginOwnedWebFetchConfig(cfg: OpenClawConfig, pluginId: string): bo } function hasPluginOwnedToolConfig(cfg: OpenClawConfig, pluginId: string): boolean { - if (pluginId !== "xai") { - return false; - } const pluginConfig = cfg.plugins?.entries?.xai?.config; const web = cfg.tools?.web as Record | undefined; - return Boolean( - isRecord(web?.x_search) || - (isRecord(pluginConfig) && - (isRecord(pluginConfig.xSearch) || isRecord(pluginConfig.codeExecution))), + return ( + pluginId === "xai" && + Boolean( + isRecord(web?.x_search) || + (isRecord(pluginConfig) && + (isRecord(pluginConfig.xSearch) || isRecord(pluginConfig.codeExecution))), + ) ); } @@ -278,53 +275,6 @@ function hasConfiguredWebFetchPluginEntry(cfg: OpenClawConfig): boolean { ); } -function listContainsBrowser(value: unknown): boolean { - return ( - Array.isArray(value) && - value.some((entry) => typeof entry === "string" && entry.trim().toLowerCase() === "browser") - ); -} - -function toolPolicyReferencesBrowser(value: unknown): boolean { - return ( - isRecord(value) && (listContainsBrowser(value.allow) || listContainsBrowser(value.alsoAllow)) - ); -} - -function hasBrowserToolReference(cfg: OpenClawConfig): boolean { - if (toolPolicyReferencesBrowser(cfg.tools)) { - return true; - } - const agentList = cfg.agents?.list; - return Array.isArray(agentList) - ? agentList.some((entry) => isRecord(entry) && toolPolicyReferencesBrowser(entry.tools)) - : false; -} - -function hasExplicitBrowserPluginEntry(cfg: OpenClawConfig): boolean { - return Boolean( - cfg.plugins?.entries && Object.prototype.hasOwnProperty.call(cfg.plugins.entries, "browser"), - ); -} - -function resolveBrowserAutoEnableSource( - cfg: OpenClawConfig, -): Extract["source"] | null { - if (cfg.browser?.enabled === false || cfg.plugins?.entries?.browser?.enabled === false) { - return null; - } - if (Object.prototype.hasOwnProperty.call(cfg, "browser")) { - return "browser-configured"; - } - if (hasExplicitBrowserPluginEntry(cfg)) { - return "browser-plugin-configured"; - } - if (hasBrowserToolReference(cfg)) { - return "browser-tool-referenced"; - } - return null; -} - function configMayNeedPluginManifestRegistry(cfg: OpenClawConfig): boolean { const pluginEntries = cfg.plugins?.entries; if ( @@ -364,15 +314,6 @@ export function configMayNeedPluginAutoEnable( if (hasPotentialConfiguredChannels(cfg, env)) { return true; } - if (resolveBrowserAutoEnableSource(cfg)) { - return true; - } - if (cfg.acp?.enabled === true || cfg.acp?.dispatch?.enabled === true) { - return true; - } - if (typeof cfg.acp?.backend === "string" && cfg.acp.backend.trim().length > 0) { - return true; - } if (cfg.auth?.profiles && Object.keys(cfg.auth.profiles).length > 0) { return true; } @@ -383,11 +324,18 @@ export function configMayNeedPluginAutoEnable( return true; } const web = cfg.tools?.web as Record | undefined; - return ( + if ( isRecord(web?.x_search) || - isRecord(cfg.plugins?.entries?.xai?.config) || hasConfiguredWebSearchPluginEntry(cfg) || hasConfiguredWebFetchPluginEntry(cfg) + ) { + return true; + } + return ( + resolvePluginSetupAutoEnableReasons({ + config: cfg, + env, + }).length > 0 ); } @@ -397,16 +345,6 @@ export function resolvePluginAutoEnableCandidateReason( switch (candidate.kind) { case "channel-configured": return `${candidate.channelId} configured`; - case "browser-configured": - switch (candidate.source) { - case "browser-configured": - return "browser configured"; - case "browser-plugin-configured": - return "browser plugin configured"; - case "browser-tool-referenced": - return "browser tool referenced"; - } - break; case "provider-auth-configured": return `${candidate.providerId} auth configured`; case "provider-model-configured": @@ -419,8 +357,8 @@ export function resolvePluginAutoEnableCandidateReason( return `${candidate.pluginId} web fetch configured`; case "plugin-tool-configured": return `${candidate.pluginId} tool configured`; - case "acp-runtime-configured": - return "ACP runtime configured"; + case "setup-auto-enable": + return candidate.reason; } } @@ -438,11 +376,6 @@ export function resolveConfiguredPluginAutoEnableCandidates(params: { } } - const browserSource = resolveBrowserAutoEnableSource(params.config); - if (browserSource) { - changes.push({ pluginId: "browser", kind: "browser-configured", source: browserSource }); - } - for (const [providerId, pluginId] of Object.entries( resolveAutoEnableProviderPluginIds(params.registry), )) { @@ -498,16 +431,15 @@ export function resolveConfiguredPluginAutoEnableCandidates(params: { } } - const backendRaw = - typeof params.config.acp?.backend === "string" - ? params.config.acp.backend.trim().toLowerCase() - : ""; - const acpConfigured = - params.config.acp?.enabled === true || - params.config.acp?.dispatch?.enabled === true || - backendRaw === "acpx"; - if (acpConfigured && (!backendRaw || backendRaw === "acpx")) { - changes.push({ pluginId: "acpx", kind: "acp-runtime-configured" }); + for (const entry of resolvePluginSetupAutoEnableReasons({ + config: params.config, + env: params.env, + })) { + changes.push({ + pluginId: entry.pluginId, + kind: "setup-auto-enable", + reason: entry.reason, + }); } return changes; diff --git a/src/config/plugin-auto-enable.test-helpers.ts b/src/config/plugin-auto-enable.test-helpers.ts index 1a772c6195a..09930623871 100644 --- a/src/config/plugin-auto-enable.test-helpers.ts +++ b/src/config/plugin-auto-enable.test-helpers.ts @@ -5,6 +5,7 @@ import { clearPluginManifestRegistryCache, type PluginManifestRegistry, } from "../plugins/manifest-registry.js"; +import { clearPluginSetupRegistryCache } from "../plugins/setup-registry.js"; import { cleanupTrackedTempDirs, makeTrackedTempDir, @@ -16,6 +17,7 @@ const tempDirs: string[] = []; export function resetPluginAutoEnableTestState(): void { clearPluginDiscoveryCache(); clearPluginManifestRegistryCache(); + clearPluginSetupRegistryCache(); cleanupTrackedTempDirs(tempDirs); } diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index a15267fc913..33ae81e4b33 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -270,6 +270,9 @@ describe("config schema", () => { planTool: true, }, }); + if (!parsed) { + throw new Error("expected parsed tools config"); + } expect(parsed?.experimental?.planTool).toBe(true); }); diff --git a/src/gateway/embeddings-http.ts b/src/gateway/embeddings-http.ts index 9a8e2e2672c..0c83e86b15c 100644 --- a/src/gateway/embeddings-http.ts +++ b/src/gateway/embeddings-http.ts @@ -7,8 +7,10 @@ import { logWarn } from "../logger.js"; import { getMemoryEmbeddingProvider, listMemoryEmbeddingProviders, - type MemoryEmbeddingProvider, - type MemoryEmbeddingProviderAdapter, +} from "../plugins/memory-embedding-provider-runtime.js"; +import type { + MemoryEmbeddingProvider, + MemoryEmbeddingProviderAdapter, } from "../plugins/memory-embedding-providers.js"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; import type { ResolvedGatewayAuth } from "./auth.js"; @@ -87,9 +89,9 @@ function formatErrorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } -function resolveAutoExplicitProviders(): Set { +function resolveAutoExplicitProviders(cfg: ReturnType): Set { return new Set( - listMemoryEmbeddingProviders() + listMemoryEmbeddingProviders(cfg) .filter((adapter) => adapter.allowExplicitWhenConfiguredAuto) .map((adapter) => adapter.id), ); @@ -131,7 +133,7 @@ async function createConfiguredEmbeddingProvider(params: { }; if (params.provider === "auto") { - const adapters = listMemoryEmbeddingProviders() + const adapters = listMemoryEmbeddingProviders(params.cfg) .filter((adapter) => typeof adapter.autoSelectPriority === "number") .toSorted( (a, b) => @@ -154,7 +156,7 @@ async function createConfiguredEmbeddingProvider(params: { throw new Error("No embeddings provider available."); } - const adapter = getMemoryEmbeddingProvider(params.provider); + const adapter = getMemoryEmbeddingProvider(params.provider, params.cfg); if (!adapter) { throw new Error(`Unknown memory embedding provider: ${params.provider}`); } @@ -168,6 +170,7 @@ async function createConfiguredEmbeddingProvider(params: { function resolveEmbeddingsTarget(params: { requestModel: string; configuredProvider: EmbeddingProviderRequest; + cfg: ReturnType; }): { provider: EmbeddingProviderRequest; model: string } | { errorMessage: string } { const raw = params.requestModel.trim(); const slash = raw.indexOf("/"); @@ -182,7 +185,7 @@ function resolveEmbeddingsTarget(params: { } if (params.configuredProvider === "auto") { - const safeAutoExplicitProviders = resolveAutoExplicitProviders(); + const safeAutoExplicitProviders = resolveAutoExplicitProviders(params.cfg); if (provider === "auto") { return { provider: "auto", model }; } @@ -268,7 +271,11 @@ export async function handleOpenAiEmbeddingsHttpRequest( const memorySearch = resolveMemorySearchConfig(cfg, agentId); const configuredProvider = memorySearch?.provider ?? "openai"; const overrideModel = getHeader(req, "x-openclaw-model")?.trim() || memorySearch?.model || ""; - const target = resolveEmbeddingsTarget({ requestModel: overrideModel, configuredProvider }); + const target = resolveEmbeddingsTarget({ + requestModel: overrideModel, + configuredProvider, + cfg, + }); if ("errorMessage" in target) { sendJson(res, 400, { error: { diff --git a/src/gateway/server-methods/agents.ts b/src/gateway/server-methods/agents.ts index 66dc251fba6..8fd00f58ccd 100644 --- a/src/gateway/server-methods/agents.ts +++ b/src/gateway/server-methods/agents.ts @@ -1,6 +1,5 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { movePathToTrash } from "../../../extensions/browser/runtime-api.js"; import { listAgentIds, resolveAgentDir, @@ -36,6 +35,7 @@ import { } from "../../infra/fs-safe.js"; import { assertNoPathAliasEscape } from "../../infra/path-alias-guards.js"; import { isNotFoundPathError } from "../../infra/path-guards.js"; +import { movePathToTrash } from "../../plugin-sdk/browser-maintenance.js"; import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js"; import { resolveUserPath } from "../../utils.js"; import { diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 96760410dd9..075d2dd39df 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -76,6 +76,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({ videoGenerationProviders: [], webFetchProviders: [], webSearchProviders: [], + memoryEmbeddingProviders: [], gatewayHandlers: {}, httpRoutes: [], cliRegistrars: [], diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index 9d660e4f120..93e5f61e451 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -2,7 +2,6 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent"; -import { closeTrackedBrowserTabsForSessions } from "../../extensions/browser/runtime-api.js"; import { getAcpSessionManager } from "../acp/control-plane/manager.js"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { clearBootstrapSnapshot } from "../agents/bootstrap-cache.js"; @@ -23,6 +22,7 @@ import { resolveSessionFilePath, resolveSessionFilePathOptions } from "../config import { logVerbose } from "../globals.js"; import { createInternalHookEvent, triggerInternalHook } from "../hooks/internal-hooks.js"; import { getSessionBindingService } from "../infra/outbound/session-binding-service.js"; +import { closeTrackedBrowserTabsForSessions } from "../plugin-sdk/browser-maintenance.js"; import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; import { isSubagentSessionKey, diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 00ebbd3bed9..c4bb6c80e56 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -313,6 +313,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({ videoGenerationProviders: [], webFetchProviders: [], webSearchProviders: [], + memoryEmbeddingProviders: [], gatewayHandlers: {}, httpRoutes: [], cliRegistrars: [], diff --git a/src/memory-host-sdk/engine-embeddings.ts b/src/memory-host-sdk/engine-embeddings.ts index bdd388529b6..278a692e9e9 100644 --- a/src/memory-host-sdk/engine-embeddings.ts +++ b/src/memory-host-sdk/engine-embeddings.ts @@ -3,7 +3,7 @@ export { getMemoryEmbeddingProvider, listMemoryEmbeddingProviders, -} from "../plugins/memory-embedding-providers.js"; +} from "../plugins/memory-embedding-provider-runtime.js"; export type { MemoryEmbeddingBatchChunk, MemoryEmbeddingBatchOptions, diff --git a/src/memory-host-sdk/host/embeddings-ollama.ts b/src/memory-host-sdk/host/embeddings-ollama.ts index 0a505edcd6f..61af79c7330 100644 --- a/src/memory-host-sdk/host/embeddings-ollama.ts +++ b/src/memory-host-sdk/host/embeddings-ollama.ts @@ -1,5 +1,5 @@ -export type { OllamaEmbeddingClient } from "../../../extensions/ollama/runtime-api.js"; +export type { OllamaEmbeddingClient } from "../../plugin-sdk/ollama-runtime.js"; export { createOllamaEmbeddingProvider, DEFAULT_OLLAMA_EMBEDDING_MODEL, -} from "../../../extensions/ollama/runtime-api.js"; +} from "../../plugin-sdk/ollama-runtime.js"; diff --git a/src/node-host/invoke.ts b/src/node-host/invoke.ts index 88ff398e13b..989dda4af2b 100644 --- a/src/node-host/invoke.ts +++ b/src/node-host/invoke.ts @@ -1,7 +1,6 @@ import { spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -import { runBrowserProxyCommand } from "../../extensions/browser/runtime-api.js"; import { GatewayClient } from "../gateway/client.js"; import { ensureExecApprovals, @@ -20,6 +19,7 @@ import { type ExecHostResponse, } from "../infra/exec-host.js"; import { sanitizeHostExecEnv } from "../infra/host-env-security.js"; +import { runBrowserProxyCommand } from "../plugin-sdk/browser-node-host.js"; import { buildSystemRunApprovalPlan, handleSystemRunInvoke } from "./invoke-system-run.js"; import type { ExecEventPayload, diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index a58ac7e0bdf..53f4f839b94 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -1,4 +1,3 @@ -import { resolveBrowserConfig } from "../../extensions/browser/runtime-api.js"; import { loadConfig, type OpenClawConfig } from "../config/config.js"; import { GatewayClient } from "../gateway/client.js"; import { resolveGatewayConnectionAuth } from "../gateway/connection-auth.js"; @@ -12,6 +11,7 @@ import { NODE_SYSTEM_RUN_COMMANDS, } from "../infra/node-commands.js"; import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; +import { resolveBrowserConfig } from "../plugin-sdk/browser-config.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { VERSION } from "../version.js"; import { ensureNodeHostConfig, saveNodeHostConfig, type NodeHostGatewayConfig } from "./config.js"; diff --git a/src/plugin-sdk/amazon-bedrock-mantle.ts b/src/plugin-sdk/amazon-bedrock-mantle.ts new file mode 100644 index 00000000000..96af27f1956 --- /dev/null +++ b/src/plugin-sdk/amazon-bedrock-mantle.ts @@ -0,0 +1 @@ +export { resolveMantleBearerToken } from "../../extensions/amazon-bedrock-mantle/discovery.js"; diff --git a/src/plugin-sdk/anthropic-vertex.ts b/src/plugin-sdk/anthropic-vertex.ts new file mode 100644 index 00000000000..e3a562a2ed0 --- /dev/null +++ b/src/plugin-sdk/anthropic-vertex.ts @@ -0,0 +1,22 @@ +type FacadeModule = typeof import("@openclaw/anthropic-vertex/api.js"); +import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js"; + +function loadFacadeModule(): FacadeModule { + return loadBundledPluginPublicSurfaceModuleSync({ + dirName: "anthropic-vertex", + artifactBasename: "api.js", + }); +} + +export const resolveAnthropicVertexClientRegion: FacadeModule["resolveAnthropicVertexClientRegion"] = + ((...args) => + loadFacadeModule().resolveAnthropicVertexClientRegion( + ...args, + )) as FacadeModule["resolveAnthropicVertexClientRegion"]; + +export const resolveAnthropicVertexProjectId: FacadeModule["resolveAnthropicVertexProjectId"] = (( + ...args +) => + loadFacadeModule().resolveAnthropicVertexProjectId( + ...args, + )) as FacadeModule["resolveAnthropicVertexProjectId"]; diff --git a/src/plugin-sdk/browser-bridge.test.ts b/src/plugin-sdk/browser-bridge.test.ts new file mode 100644 index 00000000000..10cab5c46c2 --- /dev/null +++ b/src/plugin-sdk/browser-bridge.test.ts @@ -0,0 +1,58 @@ +import type { Server } from "node:http"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const loadActivatedBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn()); + +vi.mock("./facade-runtime.js", () => ({ + loadActivatedBundledPluginPublicSurfaceModuleSync, +})); + +describe("browser bridge facade", () => { + beforeEach(() => { + loadActivatedBundledPluginPublicSurfaceModuleSync.mockReset(); + }); + + it("stays cold until a bridge function is called", async () => { + await import("./browser-bridge.js"); + + expect(loadActivatedBundledPluginPublicSurfaceModuleSync).not.toHaveBeenCalled(); + }); + + it("delegates bridge lifecycle calls through the activated runtime facade", async () => { + const bridge = { + server: {} as Server, + port: 19001, + baseUrl: "http://127.0.0.1:19001", + state: { + resolved: { + enabled: true, + }, + }, + }; + const startBrowserBridgeServer = vi.fn(async () => bridge); + const stopBrowserBridgeServer = vi.fn(async () => undefined); + loadActivatedBundledPluginPublicSurfaceModuleSync.mockReturnValue({ + startBrowserBridgeServer, + stopBrowserBridgeServer, + }); + + const facade = await import("./browser-bridge.js"); + + await expect( + facade.startBrowserBridgeServer({ + resolved: bridge.state.resolved as never, + authToken: "token", + }), + ).resolves.toEqual(bridge); + await expect(facade.stopBrowserBridgeServer(bridge.server)).resolves.toBeUndefined(); + expect(loadActivatedBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledWith({ + dirName: "browser", + artifactBasename: "runtime-api.js", + }); + expect(startBrowserBridgeServer).toHaveBeenCalledWith({ + resolved: bridge.state.resolved, + authToken: "token", + }); + expect(stopBrowserBridgeServer).toHaveBeenCalledWith(bridge.server); + }); +}); diff --git a/src/plugin-sdk/browser-bridge.ts b/src/plugin-sdk/browser-bridge.ts index 9209549ddd4..0cbda463c9f 100644 --- a/src/plugin-sdk/browser-bridge.ts +++ b/src/plugin-sdk/browser-bridge.ts @@ -1,5 +1,42 @@ -export type { BrowserBridge } from "../../extensions/browser/browser-bridge.js"; -export { - startBrowserBridgeServer, - stopBrowserBridgeServer, -} from "../../extensions/browser/browser-bridge.js"; +import type { Server } from "node:http"; +import type { ResolvedBrowserConfig } from "./browser-config.js"; +import { loadActivatedBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js"; + +export type BrowserBridge = { + server: Server; + port: number; + baseUrl: string; + state: { + resolved: ResolvedBrowserConfig; + }; +}; + +type BrowserBridgeFacadeModule = { + startBrowserBridgeServer(params: { + resolved: ResolvedBrowserConfig; + host?: string; + port?: number; + authToken?: string; + authPassword?: string; + onEnsureAttachTarget?: (profile: unknown) => Promise; + resolveSandboxNoVncToken?: (token: string) => { noVncPort: number; password?: string } | null; + }): Promise; + stopBrowserBridgeServer(server: Server): Promise; +}; + +function loadFacadeModule(): BrowserBridgeFacadeModule { + return loadActivatedBundledPluginPublicSurfaceModuleSync({ + dirName: "browser", + artifactBasename: "runtime-api.js", + }); +} + +export async function startBrowserBridgeServer( + params: Parameters[0], +): Promise { + return await loadFacadeModule().startBrowserBridgeServer(params); +} + +export async function stopBrowserBridgeServer(server: Server): Promise { + await loadFacadeModule().stopBrowserBridgeServer(server); +} diff --git a/src/plugin-sdk/browser-host-inspection.ts b/src/plugin-sdk/browser-host-inspection.ts index a44e676d9ba..3c8a5dcb48f 100644 --- a/src/plugin-sdk/browser-host-inspection.ts +++ b/src/plugin-sdk/browser-host-inspection.ts @@ -2,7 +2,11 @@ import { execFileSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { BrowserExecutable } from "../../extensions/browser/browser-runtime-api.js"; + +export type BrowserExecutable = { + kind: "chrome" | "chromium" | "edge" | "canary"; + path: string; +}; const CHROME_VERSION_RE = /\b(\d+)(?:\.\d+){1,3}\b/g; diff --git a/src/plugin-sdk/browser-node-host.test.ts b/src/plugin-sdk/browser-node-host.test.ts new file mode 100644 index 00000000000..69c483cff70 --- /dev/null +++ b/src/plugin-sdk/browser-node-host.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const loadActivatedBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn()); + +vi.mock("./facade-runtime.js", () => ({ + loadActivatedBundledPluginPublicSurfaceModuleSync, +})); + +describe("browser node-host facade", () => { + beforeEach(() => { + loadActivatedBundledPluginPublicSurfaceModuleSync.mockReset(); + }); + + it("stays cold until the proxy command is called", async () => { + await import("./browser-node-host.js"); + + expect(loadActivatedBundledPluginPublicSurfaceModuleSync).not.toHaveBeenCalled(); + }); + + it("delegates the proxy command through the activated runtime facade", async () => { + const runBrowserProxyCommand = vi.fn(async () => '{"ok":true}'); + loadActivatedBundledPluginPublicSurfaceModuleSync.mockReturnValue({ + runBrowserProxyCommand, + }); + + const facade = await import("./browser-node-host.js"); + + await expect(facade.runBrowserProxyCommand('{"path":"/"}')).resolves.toBe('{"ok":true}'); + expect(loadActivatedBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledWith({ + dirName: "browser", + artifactBasename: "runtime-api.js", + }); + expect(runBrowserProxyCommand).toHaveBeenCalledWith('{"path":"/"}'); + }); +}); diff --git a/src/plugin-sdk/browser-node-host.ts b/src/plugin-sdk/browser-node-host.ts new file mode 100644 index 00000000000..578c09f811a --- /dev/null +++ b/src/plugin-sdk/browser-node-host.ts @@ -0,0 +1,16 @@ +import { loadActivatedBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js"; + +type BrowserNodeHostFacadeModule = { + runBrowserProxyCommand(paramsJSON?: string | null): Promise; +}; + +function loadFacadeModule(): BrowserNodeHostFacadeModule { + return loadActivatedBundledPluginPublicSurfaceModuleSync({ + dirName: "browser", + artifactBasename: "runtime-api.js", + }); +} + +export async function runBrowserProxyCommand(paramsJSON?: string | null): Promise { + return await loadFacadeModule().runBrowserProxyCommand(paramsJSON); +} diff --git a/src/plugin-sdk/ollama-runtime.ts b/src/plugin-sdk/ollama-runtime.ts new file mode 100644 index 00000000000..2a424b5d51f --- /dev/null +++ b/src/plugin-sdk/ollama-runtime.ts @@ -0,0 +1,20 @@ +type FacadeModule = typeof import("@openclaw/ollama/runtime-api.js"); +import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js"; + +function loadFacadeModule(): FacadeModule { + return loadBundledPluginPublicSurfaceModuleSync({ + dirName: "ollama", + artifactBasename: "runtime-api.js", + }); +} + +export type OllamaEmbeddingClient = import("@openclaw/ollama/runtime-api.js").OllamaEmbeddingClient; +export const DEFAULT_OLLAMA_EMBEDDING_MODEL: FacadeModule["DEFAULT_OLLAMA_EMBEDDING_MODEL"] = + loadFacadeModule().DEFAULT_OLLAMA_EMBEDDING_MODEL; + +export const createOllamaEmbeddingProvider: FacadeModule["createOllamaEmbeddingProvider"] = (( + ...args +) => + loadFacadeModule().createOllamaEmbeddingProvider( + ...args, + )) as FacadeModule["createOllamaEmbeddingProvider"]; diff --git a/src/plugin-sdk/ollama.ts b/src/plugin-sdk/ollama.ts new file mode 100644 index 00000000000..973a5d6f69e --- /dev/null +++ b/src/plugin-sdk/ollama.ts @@ -0,0 +1,12 @@ +type FacadeModule = typeof import("@openclaw/ollama/api.js"); +import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js"; + +function loadFacadeModule(): FacadeModule { + return loadBundledPluginPublicSurfaceModuleSync({ + dirName: "ollama", + artifactBasename: "api.js", + }); +} + +export const resolveOllamaApiBase: FacadeModule["resolveOllamaApiBase"] = ((...args) => + loadFacadeModule().resolveOllamaApiBase(...args)) as FacadeModule["resolveOllamaApiBase"]; diff --git a/src/plugins/api-builder.ts b/src/plugins/api-builder.ts index e5fb64fe321..b6880fcf914 100644 --- a/src/plugins/api-builder.ts +++ b/src/plugins/api-builder.ts @@ -26,6 +26,9 @@ export type BuildPluginApiParams = { | "registerCli" | "registerService" | "registerCliBackend" + | "registerConfigMigration" + | "registerLegacyConfigMigration" + | "registerAutoEnableProbe" | "registerProvider" | "registerSpeechProvider" | "registerRealtimeTranscriptionProvider" @@ -56,6 +59,10 @@ const noopRegisterGatewayMethod: OpenClawPluginApi["registerGatewayMethod"] = () const noopRegisterCli: OpenClawPluginApi["registerCli"] = () => {}; const noopRegisterService: OpenClawPluginApi["registerService"] = () => {}; const noopRegisterCliBackend: OpenClawPluginApi["registerCliBackend"] = () => {}; +const noopRegisterConfigMigration: OpenClawPluginApi["registerConfigMigration"] = () => {}; +const noopRegisterLegacyConfigMigration: OpenClawPluginApi["registerLegacyConfigMigration"] = + () => {}; +const noopRegisterAutoEnableProbe: OpenClawPluginApi["registerAutoEnableProbe"] = () => {}; const noopRegisterProvider: OpenClawPluginApi["registerProvider"] = () => {}; const noopRegisterSpeechProvider: OpenClawPluginApi["registerSpeechProvider"] = () => {}; const noopRegisterRealtimeTranscriptionProvider: OpenClawPluginApi["registerRealtimeTranscriptionProvider"] = @@ -104,6 +111,10 @@ export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi registerCli: handlers.registerCli ?? noopRegisterCli, registerService: handlers.registerService ?? noopRegisterService, registerCliBackend: handlers.registerCliBackend ?? noopRegisterCliBackend, + registerConfigMigration: handlers.registerConfigMigration ?? noopRegisterConfigMigration, + registerLegacyConfigMigration: + handlers.registerLegacyConfigMigration ?? noopRegisterLegacyConfigMigration, + registerAutoEnableProbe: handlers.registerAutoEnableProbe ?? noopRegisterAutoEnableProbe, registerProvider: handlers.registerProvider ?? noopRegisterProvider, registerSpeechProvider: handlers.registerSpeechProvider ?? noopRegisterSpeechProvider, registerRealtimeTranscriptionProvider: diff --git a/src/plugins/bundled-capability-runtime.ts b/src/plugins/bundled-capability-runtime.ts index 8e442401742..1fe0906ff90 100644 --- a/src/plugins/bundled-capability-runtime.ts +++ b/src/plugins/bundled-capability-runtime.ts @@ -129,6 +129,7 @@ function createCapabilityPluginRecord(params: { videoGenerationProviderIds: [], webFetchProviderIds: [], webSearchProviderIds: [], + memoryEmbeddingProviderIds: [], gatewayMethods: [], cliCommands: [], services: [], @@ -292,6 +293,9 @@ export function loadBundledCapabilityRuntimeRegistry(params: { ); record.webFetchProviderIds.push(...captured.webFetchProviders.map((entry) => entry.id)); record.webSearchProviderIds.push(...captured.webSearchProviders.map((entry) => entry.id)); + record.memoryEmbeddingProviderIds.push( + ...captured.memoryEmbeddingProviders.map((entry) => entry.id), + ); record.toolNames.push(...captured.tools.map((entry) => entry.name)); registry.cliBackends?.push( @@ -384,6 +388,15 @@ export function loadBundledCapabilityRuntimeRegistry(params: { rootDir: record.rootDir, })), ); + registry.memoryEmbeddingProviders.push( + ...captured.memoryEmbeddingProviders.map((provider) => ({ + pluginId: record.id, + pluginName: record.name, + provider, + source: record.source, + rootDir: record.rootDir, + })), + ); registry.tools.push( ...captured.tools.map((tool) => ({ pluginId: record.id, diff --git a/src/plugins/capability-provider-runtime.test.ts b/src/plugins/capability-provider-runtime.test.ts index 7654b30883e..cebf55caa26 100644 --- a/src/plugins/capability-provider-runtime.test.ts +++ b/src/plugins/capability-provider-runtime.test.ts @@ -103,6 +103,7 @@ function setBundledCapabilityFixture(contractKey: string) { function expectCompatChainApplied(params: { key: + | "memoryEmbeddingProviders" | "speechProviders" | "realtimeTranscriptionProviders" | "realtimeVoiceProviders" @@ -205,6 +206,7 @@ describe("resolvePluginCapabilityProviders", () => { }); it.each([ + ["memoryEmbeddingProviders", "memoryEmbeddingProviders"], ["speechProviders", "speechProviders"], ["realtimeTranscriptionProviders", "realtimeTranscriptionProviders"], ["realtimeVoiceProviders", "realtimeVoiceProviders"], diff --git a/src/plugins/capability-provider-runtime.ts b/src/plugins/capability-provider-runtime.ts index 497d569d028..8a19a606a63 100644 --- a/src/plugins/capability-provider-runtime.ts +++ b/src/plugins/capability-provider-runtime.ts @@ -8,6 +8,7 @@ import { loadPluginManifestRegistry } from "./manifest-registry.js"; import type { PluginRegistry } from "./registry.js"; type CapabilityProviderRegistryKey = + | "memoryEmbeddingProviders" | "speechProviders" | "realtimeTranscriptionProviders" | "realtimeVoiceProviders" @@ -16,6 +17,7 @@ type CapabilityProviderRegistryKey = | "videoGenerationProviders"; type CapabilityContractKey = + | "memoryEmbeddingProviders" | "speechProviders" | "realtimeTranscriptionProviders" | "realtimeVoiceProviders" @@ -27,6 +29,7 @@ type CapabilityProviderForKey = PluginRegistry[K][number] extends { provider: infer T } ? T : never; const CAPABILITY_CONTRACT_KEY: Record = { + memoryEmbeddingProviders: "memoryEmbeddingProviders", speechProviders: "speechProviders", realtimeTranscriptionProviders: "realtimeTranscriptionProviders", realtimeVoiceProviders: "realtimeVoiceProviders", diff --git a/src/plugins/captured-registration.ts b/src/plugins/captured-registration.ts index 1b665596800..811d55e00f1 100644 --- a/src/plugins/captured-registration.ts +++ b/src/plugins/captured-registration.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../config/config.js"; import { buildPluginApi } from "./api-builder.js"; +import type { MemoryEmbeddingProviderAdapter } from "./memory-embedding-providers.js"; import type { PluginRuntime } from "./runtime/types.js"; import type { AnyAgentTool, @@ -37,6 +38,7 @@ export type CapturedPluginRegistration = { videoGenerationProviders: VideoGenerationProviderPlugin[]; webFetchProviders: WebFetchProviderPlugin[]; webSearchProviders: WebSearchProviderPlugin[]; + memoryEmbeddingProviders: MemoryEmbeddingProviderAdapter[]; tools: AnyAgentTool[]; }; @@ -55,6 +57,7 @@ export function createCapturedPluginRegistration(params?: { const videoGenerationProviders: VideoGenerationProviderPlugin[] = []; const webFetchProviders: WebFetchProviderPlugin[] = []; const webSearchProviders: WebSearchProviderPlugin[] = []; + const memoryEmbeddingProviders: MemoryEmbeddingProviderAdapter[] = []; const tools: AnyAgentTool[] = []; const noopLogger = { info() {}, @@ -75,6 +78,7 @@ export function createCapturedPluginRegistration(params?: { videoGenerationProviders, webFetchProviders, webSearchProviders, + memoryEmbeddingProviders, tools, api: buildPluginApi({ id: "captured-plugin-registration", @@ -139,6 +143,9 @@ export function createCapturedPluginRegistration(params?: { registerWebSearchProvider(provider: WebSearchProviderPlugin) { webSearchProviders.push(provider); }, + registerMemoryEmbeddingProvider(adapter: MemoryEmbeddingProviderAdapter) { + memoryEmbeddingProviders.push(adapter); + }, registerTool(tool) { if (typeof tool !== "function") { tools.push(tool); diff --git a/src/plugins/channel-plugin-ids.ts b/src/plugins/channel-plugin-ids.ts index 3ba9b234e3e..27ae50a7da0 100644 --- a/src/plugins/channel-plugin-ids.ts +++ b/src/plugins/channel-plugin-ids.ts @@ -18,6 +18,7 @@ function hasRuntimeContractSurface(plugin: PluginManifestRecord): boolean { plugin.contracts?.videoGenerationProviders?.length || plugin.contracts?.webFetchProviders?.length || plugin.contracts?.webSearchProviders?.length || + plugin.contracts?.memoryEmbeddingProviders?.length || hasKind(plugin.kind, "memory"), ); } diff --git a/src/plugins/contracts/memory-embedding-provider.contract.test.ts b/src/plugins/contracts/memory-embedding-provider.contract.test.ts index d304b23711b..29bf190dcd3 100644 --- a/src/plugins/contracts/memory-embedding-provider.contract.test.ts +++ b/src/plugins/contracts/memory-embedding-provider.contract.test.ts @@ -3,7 +3,7 @@ import { getRegisteredMemoryEmbeddingProvider } from "../memory-embedding-provid import { createPluginRegistryFixture, registerVirtualTestPlugin } from "./testkit.js"; describe("memory embedding provider registration", () => { - it("only allows memory plugins to register adapters", () => { + it("rejects non-memory plugins that did not declare the capability contract", () => { const { config, registry } = createPluginRegistryFixture(); registerVirtualTestPlugin({ @@ -24,12 +24,38 @@ describe("memory embedding provider registration", () => { expect.arrayContaining([ expect.objectContaining({ pluginId: "not-memory", - message: "only memory plugins can register memory embedding providers", + message: + "plugin must own memory slot or declare contracts.memoryEmbeddingProviders for adapter: forbidden", }), ]), ); }); + it("allows non-memory plugins that declare the capability contract", () => { + const { config, registry } = createPluginRegistryFixture(); + + registerVirtualTestPlugin({ + registry, + config, + id: "ollama", + name: "Ollama", + contracts: { + memoryEmbeddingProviders: ["ollama"], + }, + register(api) { + api.registerMemoryEmbeddingProvider({ + id: "ollama", + create: async () => ({ provider: null }), + }); + }, + }); + + expect(getRegisteredMemoryEmbeddingProvider("ollama")).toEqual({ + adapter: expect.objectContaining({ id: "ollama" }), + ownerPluginId: "ollama", + }); + }); + it("records the owning memory plugin id for registered adapters", () => { const { config, registry } = createPluginRegistryFixture(); diff --git a/src/plugins/contracts/testkit.ts b/src/plugins/contracts/testkit.ts index dfe16096a7c..2b75bdafbda 100644 --- a/src/plugins/contracts/testkit.ts +++ b/src/plugins/contracts/testkit.ts @@ -89,6 +89,7 @@ export function registerVirtualTestPlugin(params: { name: string; source?: string; kind?: PluginRecord["kind"]; + contracts?: PluginRecord["contracts"]; register(this: void, api: OpenClawPluginApi): void; }) { registerTestPlugin({ @@ -99,6 +100,7 @@ export function registerVirtualTestPlugin(params: { name: params.name, source: params.source ?? `/virtual/${params.id}/index.ts`, ...(params.kind ? { kind: params.kind } : {}), + ...(params.contracts ? { contracts: params.contracts } : {}), }), register: params.register, }); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 21af709ae59..91149aeafde 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -28,6 +28,7 @@ import { discoverOpenClawPlugins } from "./discovery.js"; import { initializeGlobalHookRunner } from "./hook-runner-global.js"; import { clearPluginInteractiveHandlers } from "./interactive-registry.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; +import type { PluginManifestContracts } from "./manifest.js"; import { clearMemoryEmbeddingProviders, listRegisteredMemoryEmbeddingProviders, @@ -565,6 +566,7 @@ function createPluginRecord(params: { enabled: boolean; activationState?: PluginActivationState; configSchema: boolean; + contracts?: PluginManifestContracts; }): PluginRecord { return { id: params.id, @@ -597,6 +599,7 @@ function createPluginRecord(params: { videoGenerationProviderIds: [], webFetchProviderIds: [], webSearchProviderIds: [], + memoryEmbeddingProviderIds: [], gatewayMethods: [], cliCommands: [], services: [], @@ -606,6 +609,7 @@ function createPluginRecord(params: { configSchema: params.configSchema, configUiHints: undefined, configJsonSchema: undefined, + contracts: params.contracts, }; } @@ -1185,6 +1189,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi enabled: false, activationState, configSchema: Boolean(manifestRecord.configSchema), + contracts: manifestRecord.contracts, }); record.status = "disabled"; record.error = `overridden by ${existingOrigin} plugin`; @@ -1217,6 +1222,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi enabled: enableState.enabled, activationState, configSchema: Boolean(manifestRecord.configSchema), + contracts: manifestRecord.contracts, }); record.kind = manifestRecord.kind; record.configUiHints = manifestRecord.configUiHints; @@ -1743,6 +1749,7 @@ export async function loadOpenClawPluginCliRegistry( enabled: false, activationState, configSchema: Boolean(manifestRecord.configSchema), + contracts: manifestRecord.contracts, }); record.status = "disabled"; record.error = `overridden by ${existingOrigin} plugin`; @@ -1775,6 +1782,7 @@ export async function loadOpenClawPluginCliRegistry( enabled: enableState.enabled, activationState, configSchema: Boolean(manifestRecord.configSchema), + contracts: manifestRecord.contracts, }); record.kind = manifestRecord.kind; record.configUiHints = manifestRecord.configUiHints; diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 47b6f399d57..ea89534d741 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -29,7 +29,10 @@ import type { PluginOrigin, } from "./types.js"; -type PluginManifestContractListKey = "webFetchProviders" | "webSearchProviders"; +type PluginManifestContractListKey = + | "memoryEmbeddingProviders" + | "webFetchProviders" + | "webSearchProviders"; type SeenIdEntry = { candidate: PluginCandidate; diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index c45fcb49f01..97145bf5949 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -71,6 +71,7 @@ export type PluginManifest = { }; export type PluginManifestContracts = { + memoryEmbeddingProviders?: string[]; speechProviders?: string[]; realtimeTranscriptionProviders?: string[]; realtimeVoiceProviders?: string[]; @@ -151,6 +152,7 @@ function normalizeManifestContracts(value: unknown): PluginManifestContracts | u return undefined; } + const memoryEmbeddingProviders = normalizeStringList(value.memoryEmbeddingProviders); const speechProviders = normalizeStringList(value.speechProviders); const realtimeTranscriptionProviders = normalizeStringList(value.realtimeTranscriptionProviders); const realtimeVoiceProviders = normalizeStringList(value.realtimeVoiceProviders); @@ -161,6 +163,7 @@ function normalizeManifestContracts(value: unknown): PluginManifestContracts | u const webSearchProviders = normalizeStringList(value.webSearchProviders); const tools = normalizeStringList(value.tools); const contracts = { + ...(memoryEmbeddingProviders.length > 0 ? { memoryEmbeddingProviders } : {}), ...(speechProviders.length > 0 ? { speechProviders } : {}), ...(realtimeTranscriptionProviders.length > 0 ? { realtimeTranscriptionProviders } : {}), ...(realtimeVoiceProviders.length > 0 ? { realtimeVoiceProviders } : {}), diff --git a/src/plugins/memory-embedding-provider-runtime.test.ts b/src/plugins/memory-embedding-provider-runtime.test.ts new file mode 100644 index 00000000000..d62bc004edf --- /dev/null +++ b/src/plugins/memory-embedding-provider-runtime.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearMemoryEmbeddingProviders, + registerMemoryEmbeddingProvider, + type MemoryEmbeddingProviderAdapter, +} from "./memory-embedding-providers.js"; + +const mocks = vi.hoisted(() => ({ + resolvePluginCapabilityProviders: vi.fn< + typeof import("./capability-provider-runtime.js").resolvePluginCapabilityProviders + >(() => []), +})); + +vi.mock("./capability-provider-runtime.js", () => ({ + resolvePluginCapabilityProviders: mocks.resolvePluginCapabilityProviders, +})); + +let runtimeModule: typeof import("./memory-embedding-provider-runtime.js"); + +function createCapabilityAdapter(id: string): MemoryEmbeddingProviderAdapter { + return { + id, + create: async () => ({ provider: null }), + }; +} + +beforeEach(async () => { + clearMemoryEmbeddingProviders(); + mocks.resolvePluginCapabilityProviders.mockReset(); + mocks.resolvePluginCapabilityProviders.mockReturnValue([]); + runtimeModule = await import("./memory-embedding-provider-runtime.js"); +}); + +afterEach(() => { + clearMemoryEmbeddingProviders(); +}); + +describe("memory embedding provider runtime resolution", () => { + it("prefers registered adapters over capability fallback adapters", () => { + registerMemoryEmbeddingProvider({ + id: "registered", + create: async () => ({ provider: null }), + }); + mocks.resolvePluginCapabilityProviders.mockReturnValue([createCapabilityAdapter("capability")]); + + expect(runtimeModule.listMemoryEmbeddingProviders().map((adapter) => adapter.id)).toEqual([ + "registered", + ]); + expect(runtimeModule.getMemoryEmbeddingProvider("registered")?.id).toBe("registered"); + expect(mocks.resolvePluginCapabilityProviders).not.toHaveBeenCalled(); + }); + + it("falls back to declared capability adapters when the registry is cold", () => { + mocks.resolvePluginCapabilityProviders.mockReturnValue([createCapabilityAdapter("ollama")]); + + expect(runtimeModule.listMemoryEmbeddingProviders().map((adapter) => adapter.id)).toEqual([ + "ollama", + ]); + expect(runtimeModule.getMemoryEmbeddingProvider("ollama")?.id).toBe("ollama"); + expect(mocks.resolvePluginCapabilityProviders).toHaveBeenCalledTimes(2); + }); + + it("does not consult capability fallback once runtime adapters are registered", () => { + registerMemoryEmbeddingProvider({ + id: "openai", + create: async () => ({ provider: null }), + }); + mocks.resolvePluginCapabilityProviders.mockReturnValue([createCapabilityAdapter("ollama")]); + + expect(runtimeModule.getMemoryEmbeddingProvider("ollama")).toBeUndefined(); + expect(mocks.resolvePluginCapabilityProviders).not.toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/memory-embedding-provider-runtime.ts b/src/plugins/memory-embedding-provider-runtime.ts new file mode 100644 index 00000000000..11a775052fe --- /dev/null +++ b/src/plugins/memory-embedding-provider-runtime.ts @@ -0,0 +1,34 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { resolvePluginCapabilityProviders } from "./capability-provider-runtime.js"; +import { + getRegisteredMemoryEmbeddingProvider, + listRegisteredMemoryEmbeddingProviders, + type MemoryEmbeddingProviderAdapter, +} from "./memory-embedding-providers.js"; + +export function listMemoryEmbeddingProviders( + cfg?: OpenClawConfig, +): MemoryEmbeddingProviderAdapter[] { + const registered = listRegisteredMemoryEmbeddingProviders(); + if (registered.length > 0) { + return registered.map((entry) => entry.adapter); + } + return resolvePluginCapabilityProviders({ + key: "memoryEmbeddingProviders", + cfg, + }); +} + +export function getMemoryEmbeddingProvider( + id: string, + cfg?: OpenClawConfig, +): MemoryEmbeddingProviderAdapter | undefined { + const registered = getRegisteredMemoryEmbeddingProvider(id); + if (registered) { + return registered.adapter; + } + if (listRegisteredMemoryEmbeddingProviders().length > 0) { + return undefined; + } + return listMemoryEmbeddingProviders(cfg).find((adapter) => adapter.id === id); +} diff --git a/src/plugins/registry-empty.ts b/src/plugins/registry-empty.ts index c2da276eedb..754c2c64cec 100644 --- a/src/plugins/registry-empty.ts +++ b/src/plugins/registry-empty.ts @@ -18,6 +18,7 @@ export function createEmptyPluginRegistry(): PluginRegistry { videoGenerationProviders: [], webFetchProviders: [], webSearchProviders: [], + memoryEmbeddingProviders: [], gatewayHandlers: {}, gatewayMethodScopes: {}, httpRoutes: [], diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 4cf51ab1c82..8ccb722eed2 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -17,8 +17,10 @@ import type { PluginActivationSource } from "./config-state.js"; import { normalizePluginHttpPath } from "./http-path.js"; import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js"; import { registerPluginInteractiveHandler } from "./interactive-registry.js"; +import type { PluginManifestContracts } from "./manifest.js"; import { getRegisteredMemoryEmbeddingProvider, + type MemoryEmbeddingProviderAdapter, registerMemoryEmbeddingProvider, } from "./memory-embedding-providers.js"; import { @@ -160,6 +162,8 @@ export type PluginWebFetchProviderRegistration = PluginOwnedProviderRegistration; export type PluginWebSearchProviderRegistration = PluginOwnedProviderRegistration; +export type PluginMemoryEmbeddingProviderRegistration = + PluginOwnedProviderRegistration; export type PluginHookRegistration = { pluginId: string; @@ -230,6 +234,7 @@ export type PluginRecord = { videoGenerationProviderIds: string[]; webFetchProviderIds: string[]; webSearchProviderIds: string[]; + memoryEmbeddingProviderIds: string[]; gatewayMethods: string[]; cliCommands: string[]; services: string[]; @@ -239,6 +244,7 @@ export type PluginRecord = { configSchema: boolean; configUiHints?: Record; configJsonSchema?: Record; + contracts?: PluginManifestContracts; memorySlotSelected?: boolean; }; @@ -259,6 +265,7 @@ export type PluginRegistry = { videoGenerationProviders: PluginVideoGenerationProviderRegistration[]; webFetchProviders: PluginWebFetchProviderRegistration[]; webSearchProviders: PluginWebSearchProviderRegistration[]; + memoryEmbeddingProviders: PluginMemoryEmbeddingProviderRegistration[]; gatewayHandlers: GatewayRequestHandlers; gatewayMethodScopes?: Partial>; httpRoutes: PluginHttpRouteRegistration[]; @@ -1208,26 +1215,29 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerMemoryRuntime(runtime); }, registerMemoryEmbeddingProvider: (adapter) => { - if (!hasKind(record.kind, "memory")) { + if (hasKind(record.kind, "memory")) { + if ( + Array.isArray(record.kind) && + record.kind.length > 1 && + !record.memorySlotSelected + ) { + pushDiagnostic({ + level: "warn", + pluginId: record.id, + source: record.source, + message: + "dual-kind plugin not selected for memory slot; skipping memory embedding provider registration", + }); + return; + } + } else if ( + !(record.contracts?.memoryEmbeddingProviders ?? []).includes(adapter.id) + ) { pushDiagnostic({ level: "error", pluginId: record.id, source: record.source, - message: "only memory plugins can register memory embedding providers", - }); - return; - } - if ( - Array.isArray(record.kind) && - record.kind.length > 1 && - !record.memorySlotSelected - ) { - pushDiagnostic({ - level: "warn", - pluginId: record.id, - source: record.source, - message: - "dual-kind plugin not selected for memory slot; skipping memory embedding provider registration", + message: `plugin must own memory slot or declare contracts.memoryEmbeddingProviders for adapter: ${adapter.id}`, }); return; } @@ -1247,6 +1257,13 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerMemoryEmbeddingProvider(adapter, { ownerPluginId: record.id, }); + registry.memoryEmbeddingProviders.push({ + pluginId: record.id, + pluginName: record.name, + provider: adapter, + source: record.source, + rootDir: record.rootDir, + }); }, on: (hookName, handler, opts) => registerTypedHook(record, hookName, handler, opts, params.hookPolicy), diff --git a/src/plugins/runtime.test.ts b/src/plugins/runtime.test.ts index 1a460aa1f50..442b9c03da8 100644 --- a/src/plugins/runtime.test.ts +++ b/src/plugins/runtime.test.ts @@ -206,6 +206,7 @@ describe("setActivePluginRegistry", () => { videoGenerationProviderIds: [], webFetchProviderIds: [], webSearchProviderIds: [], + memoryEmbeddingProviderIds: [], gatewayMethods: [], cliCommands: [], services: [], @@ -235,6 +236,7 @@ describe("setActivePluginRegistry", () => { videoGenerationProviderIds: [], webFetchProviderIds: [], webSearchProviderIds: [], + memoryEmbeddingProviderIds: [], gatewayMethods: [], cliCommands: [], services: [], diff --git a/src/plugins/setup-registry.ts b/src/plugins/setup-registry.ts new file mode 100644 index 00000000000..358db2fa96d --- /dev/null +++ b/src/plugins/setup-registry.ts @@ -0,0 +1,377 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createJiti } from "jiti"; +import { normalizeProviderId } from "../agents/provider-id.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { buildPluginApi } from "./api-builder.js"; +import { discoverOpenClawPlugins } from "./discovery.js"; +import { loadPluginManifestRegistry } from "./manifest-registry.js"; +import { resolvePluginCacheInputs } from "./roots.js"; +import type { PluginRuntime } from "./runtime/types.js"; +import { + buildPluginLoaderAliasMap, + buildPluginLoaderJitiOptions, + shouldPreferNativeJiti, +} from "./sdk-alias.js"; +import type { + CliBackendPlugin, + OpenClawPluginModule, + PluginConfigMigration, + PluginLegacyConfigMigration, + PluginLogger, + PluginSetupAutoEnableProbe, + ProviderPlugin, +} from "./types.js"; + +const SETUP_API_EXTENSIONS = [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"] as const; +const CURRENT_MODULE_PATH = fileURLToPath(import.meta.url); +const RUNNING_FROM_BUILT_ARTIFACT = + CURRENT_MODULE_PATH.includes(`${path.sep}dist${path.sep}`) || + CURRENT_MODULE_PATH.includes(`${path.sep}dist-runtime${path.sep}`); + +type SetupProviderEntry = { + pluginId: string; + provider: ProviderPlugin; +}; + +type SetupCliBackendEntry = { + pluginId: string; + backend: CliBackendPlugin; +}; + +type SetupConfigMigrationEntry = { + pluginId: string; + migrate: PluginConfigMigration; +}; + +type SetupLegacyConfigMigrationEntry = { + pluginId: string; + migrate: PluginLegacyConfigMigration; +}; + +type SetupAutoEnableProbeEntry = { + pluginId: string; + probe: PluginSetupAutoEnableProbe; +}; + +type PluginSetupRegistry = { + providers: SetupProviderEntry[]; + cliBackends: SetupCliBackendEntry[]; + configMigrations: SetupConfigMigrationEntry[]; + legacyConfigMigrations: SetupLegacyConfigMigrationEntry[]; + autoEnableProbes: SetupAutoEnableProbeEntry[]; +}; + +type SetupAutoEnableReason = { + pluginId: string; + reason: string; +}; + +const EMPTY_RUNTIME = {} as PluginRuntime; +const NOOP_LOGGER: PluginLogger = { + info() {}, + warn() {}, + error() {}, +}; + +const jitiLoaders = new Map>(); +const setupRegistryCache = new Map(); + +export function clearPluginSetupRegistryCache(): void { + setupRegistryCache.clear(); +} + +function getJiti(modulePath: string) { + const aliasMap = buildPluginLoaderAliasMap(modulePath, process.argv[1], import.meta.url); + const cacheKey = JSON.stringify({ + tryNative: shouldPreferNativeJiti(modulePath), + aliasMap: Object.entries(aliasMap).toSorted(([left], [right]) => left.localeCompare(right)), + }); + const cached = jitiLoaders.get(cacheKey); + if (cached) { + return cached; + } + const loader = createJiti(modulePath, buildPluginLoaderJitiOptions(aliasMap)); + jitiLoaders.set(cacheKey, loader); + return loader; +} + +function buildSetupRegistryCacheKey(params: { + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): string { + const { roots, loadPaths } = resolvePluginCacheInputs({ + workspaceDir: params.workspaceDir, + env: params.env, + }); + return JSON.stringify({ + roots, + loadPaths, + }); +} + +function resolveSetupApiPath(rootDir: string): string | null { + const orderedExtensions = RUNNING_FROM_BUILT_ARTIFACT + ? SETUP_API_EXTENSIONS + : ([...SETUP_API_EXTENSIONS.slice(3), ...SETUP_API_EXTENSIONS.slice(0, 3)] as const); + for (const extension of orderedExtensions) { + const candidate = path.join(rootDir, `setup-api${extension}`); + if (fs.existsSync(candidate)) { + return candidate; + } + } + return null; +} + +function resolveRegister(mod: OpenClawPluginModule): { + definition?: { id?: string }; + register?: (api: ReturnType) => void | Promise; +} { + if (typeof mod === "function") { + return { register: mod }; + } + if (mod && typeof mod === "object" && typeof mod.register === "function") { + return { + definition: mod as { id?: string }, + register: mod.register.bind(mod), + }; + } + return {}; +} + +function matchesProvider(provider: ProviderPlugin, providerId: string): boolean { + const normalized = normalizeProviderId(providerId); + if (normalizeProviderId(provider.id) === normalized) { + return true; + } + return [...(provider.aliases ?? []), ...(provider.hookAliases ?? [])].some( + (alias) => normalizeProviderId(alias) === normalized, + ); +} + +export function resolvePluginSetupRegistry(params?: { + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): PluginSetupRegistry { + const env = params?.env ?? process.env; + const cacheKey = buildSetupRegistryCacheKey({ + workspaceDir: params?.workspaceDir, + env, + }); + const cached = setupRegistryCache.get(cacheKey); + if (cached) { + return cached; + } + + const providers: SetupProviderEntry[] = []; + const cliBackends: SetupCliBackendEntry[] = []; + const configMigrations: SetupConfigMigrationEntry[] = []; + const legacyConfigMigrations: SetupLegacyConfigMigrationEntry[] = []; + const autoEnableProbes: SetupAutoEnableProbeEntry[] = []; + const providerKeys = new Set(); + const cliBackendKeys = new Set(); + + const discovery = discoverOpenClawPlugins({ + workspaceDir: params?.workspaceDir, + env, + cache: true, + }); + const manifestRegistry = loadPluginManifestRegistry({ + workspaceDir: params?.workspaceDir, + env, + cache: true, + candidates: discovery.candidates, + diagnostics: discovery.diagnostics, + }); + + for (const record of manifestRegistry.plugins) { + const setupSource = resolveSetupApiPath(record.rootDir); + if (!setupSource) { + continue; + } + + let mod: OpenClawPluginModule; + try { + mod = getJiti(setupSource)(setupSource) as OpenClawPluginModule; + } catch { + continue; + } + + const resolved = resolveRegister((mod as { default?: OpenClawPluginModule }).default ?? mod); + if (!resolved.register) { + continue; + } + if (resolved.definition?.id && resolved.definition.id !== record.id) { + continue; + } + + const api = buildPluginApi({ + id: record.id, + name: record.name ?? record.id, + version: record.version, + description: record.description, + source: setupSource, + rootDir: record.rootDir, + registrationMode: "setup-only", + config: {} as OpenClawConfig, + runtime: EMPTY_RUNTIME, + logger: NOOP_LOGGER, + resolvePath: (input) => input, + handlers: { + registerProvider(provider) { + const key = `${record.id}:${normalizeProviderId(provider.id)}`; + if (providerKeys.has(key)) { + return; + } + providerKeys.add(key); + providers.push({ + pluginId: record.id, + provider, + }); + }, + registerCliBackend(backend) { + const key = `${record.id}:${normalizeProviderId(backend.id)}`; + if (cliBackendKeys.has(key)) { + return; + } + cliBackendKeys.add(key); + cliBackends.push({ + pluginId: record.id, + backend, + }); + }, + registerConfigMigration(migrate) { + configMigrations.push({ + pluginId: record.id, + migrate, + }); + }, + registerLegacyConfigMigration(migrate) { + legacyConfigMigrations.push({ + pluginId: record.id, + migrate, + }); + }, + registerAutoEnableProbe(probe) { + autoEnableProbes.push({ + pluginId: record.id, + probe, + }); + }, + }, + }); + + try { + const result = resolved.register(api); + if (result && typeof result.then === "function") { + // Keep setup registration sync-only. + } + } catch { + continue; + } + } + + const registry = { + providers, + cliBackends, + configMigrations, + legacyConfigMigrations, + autoEnableProbes, + } satisfies PluginSetupRegistry; + setupRegistryCache.set(cacheKey, registry); + return registry; +} + +export function resolvePluginSetupProvider(params: { + provider: string; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): ProviderPlugin | undefined { + return resolvePluginSetupRegistry(params).providers.find((entry) => + matchesProvider(entry.provider, params.provider), + )?.provider; +} + +export function resolvePluginSetupCliBackend(params: { + backend: string; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): SetupCliBackendEntry | undefined { + const normalized = normalizeProviderId(params.backend); + return resolvePluginSetupRegistry(params).cliBackends.find( + (entry) => normalizeProviderId(entry.backend.id) === normalized, + ); +} + +export function runPluginSetupConfigMigrations(params: { + config: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): { + config: OpenClawConfig; + changes: string[]; +} { + let next = params.config; + const changes: string[] = []; + + for (const entry of resolvePluginSetupRegistry(params).configMigrations) { + const migration = entry.migrate(next); + if (!migration || migration.changes.length === 0) { + continue; + } + next = migration.config; + changes.push(...migration.changes); + } + + return { config: next, changes }; +} + +export function runPluginSetupLegacyConfigMigrations(params: { + raw: Record; + changes: string[]; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): void { + for (const entry of resolvePluginSetupRegistry(params).legacyConfigMigrations) { + entry.migrate(params.raw, params.changes); + } +} + +export function resolvePluginSetupAutoEnableReasons(params: { + config: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): SetupAutoEnableReason[] { + const env = params.env ?? process.env; + const reasons: SetupAutoEnableReason[] = []; + const seen = new Set(); + + for (const entry of resolvePluginSetupRegistry({ + workspaceDir: params.workspaceDir, + env, + }).autoEnableProbes) { + const raw = entry.probe({ + config: params.config, + env, + }); + const values = Array.isArray(raw) ? raw : raw ? [raw] : []; + for (const reason of values) { + const normalized = reason.trim(); + if (!normalized) { + continue; + } + const key = `${entry.pluginId}:${normalized}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + reasons.push({ + pluginId: entry.pluginId, + reason: normalized, + }); + } + } + + return reasons; +} diff --git a/src/plugins/status.test-helpers.ts b/src/plugins/status.test-helpers.ts index 8ee1f857884..2f7fb5b4636 100644 --- a/src/plugins/status.test-helpers.ts +++ b/src/plugins/status.test-helpers.ts @@ -58,6 +58,7 @@ export function createPluginRecord( videoGenerationProviderIds: [], webFetchProviderIds: [], webSearchProviderIds: [], + memoryEmbeddingProviderIds: [], gatewayMethods: [], cliCommands: [], services: [], @@ -123,6 +124,7 @@ export function createPluginLoadResult( videoGenerationProviders: [], webFetchProviders: [], webSearchProviders: [], + memoryEmbeddingProviders: [], tools: [], hooks: [], typedHooks: [], diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 1cf9ebca70a..063c7f82f45 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -1988,6 +1988,25 @@ export type OpenClawPluginModule = export type PluginRegistrationMode = "full" | "setup-only" | "setup-runtime" | "cli-metadata"; +export type PluginConfigMigration = (config: OpenClawConfig) => + | { + config: OpenClawConfig; + changes: string[]; + } + | null + | undefined; + +export type PluginLegacyConfigMigration = (raw: Record, changes: string[]) => void; + +export type PluginSetupAutoEnableContext = { + config: OpenClawConfig; + env: NodeJS.ProcessEnv; +}; + +export type PluginSetupAutoEnableProbe = ( + ctx: PluginSetupAutoEnableContext, +) => string | string[] | null | undefined; + /** Main registration API injected into native plugin entry files. */ export type OpenClawPluginApi = { id: string; @@ -2049,6 +2068,12 @@ export type OpenClawPluginApi = { registerService: (service: OpenClawPluginService) => void; /** Register a text-only CLI backend used by the local CLI runner. */ registerCliBackend: (backend: CliBackendPlugin) => void; + /** Register a lightweight config migration that can run before plugin runtime loads. */ + registerConfigMigration: (migrate: PluginConfigMigration) => void; + /** Register a lightweight raw legacy-config migration for pre-schema config repair. */ + registerLegacyConfigMigration: (migrate: PluginLegacyConfigMigration) => void; + /** Register a lightweight config probe that can auto-enable this plugin generically. */ + registerAutoEnableProbe: (probe: PluginSetupAutoEnableProbe) => void; /** Register a native model/provider plugin (text inference capability). */ registerProvider: (provider: ProviderPlugin) => void; /** Register a speech synthesis provider (speech capability). */ diff --git a/src/security/audit.ts b/src/security/audit.ts index 85b541b1e8e..d5540622fca 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -1,11 +1,5 @@ import { isIP } from "node:net"; import path from "node:path"; -import { - redactCdpUrl, - resolveBrowserConfig, - resolveBrowserControlAuth, - resolveProfile, -} from "../../extensions/browser/runtime-api.js"; import { resolveSandboxConfigForAgent } from "../agents/sandbox.js"; import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; import type { listChannelPlugins } from "../channels/plugins/index.js"; @@ -23,6 +17,12 @@ import { import { listRiskyConfiguredSafeBins } from "../infra/exec-safe-bin-semantics.js"; import { normalizeTrustedSafeBinDirs } from "../infra/exec-safe-bin-trust.js"; import { isBlockedHostnameOrIp, isPrivateNetworkAllowedByPolicy } from "../infra/net/ssrf.js"; +import { + redactCdpUrl, + resolveBrowserConfig, + resolveProfile, +} from "../plugin-sdk/browser-config.js"; +import { resolveBrowserControlAuth } from "../plugin-sdk/browser-control-auth.js"; import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; import { formatPermissionDetail, diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index 74c7162b3f0..0a1ff189317 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -34,6 +34,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl videoGenerationProviders: [], webFetchProviders: [], webSearchProviders: [], + memoryEmbeddingProviders: [], gatewayHandlers: {}, httpRoutes: [], cliRegistrars: [], diff --git a/test/helpers/plugins/plugin-api.ts b/test/helpers/plugins/plugin-api.ts index c3812a9d3a9..88ca60b8921 100644 --- a/test/helpers/plugins/plugin-api.ts +++ b/test/helpers/plugins/plugin-api.ts @@ -18,6 +18,9 @@ export function createTestPluginApi(api: TestPluginApiInput): OpenClawPluginApi registerCli() {}, registerService() {}, registerCliBackend() {}, + registerConfigMigration() {}, + registerLegacyConfigMigration() {}, + registerAutoEnableProbe() {}, registerProvider() {}, registerSpeechProvider() {}, registerRealtimeTranscriptionProvider() {},