refactor: move plugin setup and memory capabilities to registries

This commit is contained in:
Peter Steinberger 2026-04-05 14:43:29 +01:00
parent 695c9c887b
commit 629baf5fa7
No known key found for this signature in database
76 changed files with 1300 additions and 298 deletions

View File

@ -711,6 +711,13 @@
"lines"
]
},
"update_plan": {
"emoji": "🗺️",
"title": "Update Plan",
"detailKeys": [
"explanation"
]
},
"web_search": {
"emoji": "🔎",
"title": "Web Search",

View File

@ -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;
});
},
});

View File

@ -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));
},
});

View File

@ -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),
});
},
});

View File

@ -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());
},
});

View File

@ -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<string, unknown> {
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;
});
},
});

View File

@ -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);
});
},
});

View File

@ -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),
});
},
});

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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({

View File

@ -18,6 +18,7 @@
}
],
"contracts": {
"memoryEmbeddingProviders": ["ollama"],
"webSearchProviders": ["ollama"]
},
"configSchema": {

View File

@ -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,
},
},
};
},
};

View File

@ -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<string, unknown> {
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<string, unknown>)
: {};
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));
},
});

View File

@ -0,0 +1,25 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
function isRecord(value: unknown): value is Record<string, unknown> {
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<string, unknown> | undefined;
if (
isRecord(web?.x_search) ||
(isRecord(pluginConfig) &&
(isRecord(pluginConfig.xSearch) || isRecord(pluginConfig.codeExecution)))
) {
return "xai tool configured";
}
return null;
});
},
});

View File

@ -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,

View File

@ -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<string, FallbackCliBackendPolicy> = {
[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<string, FallbackCliBackendPolicy> = {};
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 {

View File

@ -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 = {

View File

@ -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";

View File

@ -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";

View File

@ -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;

View File

@ -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<string>([
@ -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;

View File

@ -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";

View File

@ -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,

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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",

View File

@ -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;

View File

@ -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<string, unknown>)
: {};
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<string, unknown> | 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) {

View File

@ -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<string, unknown>, 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.<provider>",
legacyRules: ELEVENLABS_TALK_LEGACY_CONFIG_RULES,
apply: (raw, changes) => {
migrateLegacyTalkFields(raw, changes);
if (!hasLegacyTalkFields(raw.talk)) {
return;
}
runPluginSetupLegacyConfigMigrations({ raw, changes });
},
}),
defineLegacyConfigMigration({

View File

@ -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<string, unknown> | 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<PluginAutoEnableCandidate, { kind: "browser-configured" }>["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<string, unknown> | 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;

View File

@ -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);
}

View File

@ -270,6 +270,9 @@ describe("config schema", () => {
planTool: true,
},
});
if (!parsed) {
throw new Error("expected parsed tools config");
}
expect(parsed?.experimental?.planTool).toBe(true);
});

View File

@ -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<string> {
function resolveAutoExplicitProviders(cfg: ReturnType<typeof loadConfig>): Set<string> {
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<typeof loadConfig>;
}): { 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: {

View File

@ -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 {

View File

@ -76,6 +76,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
videoGenerationProviders: [],
webFetchProviders: [],
webSearchProviders: [],
memoryEmbeddingProviders: [],
gatewayHandlers: {},
httpRoutes: [],
cliRegistrars: [],

View File

@ -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,

View File

@ -313,6 +313,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({
videoGenerationProviders: [],
webFetchProviders: [],
webSearchProviders: [],
memoryEmbeddingProviders: [],
gatewayHandlers: {},
httpRoutes: [],
cliRegistrars: [],

View File

@ -3,7 +3,7 @@
export {
getMemoryEmbeddingProvider,
listMemoryEmbeddingProviders,
} from "../plugins/memory-embedding-providers.js";
} from "../plugins/memory-embedding-provider-runtime.js";
export type {
MemoryEmbeddingBatchChunk,
MemoryEmbeddingBatchOptions,

View File

@ -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";

View File

@ -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,

View File

@ -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";

View File

@ -0,0 +1 @@
export { resolveMantleBearerToken } from "../../extensions/amazon-bedrock-mantle/discovery.js";

View File

@ -0,0 +1,22 @@
type FacadeModule = typeof import("@openclaw/anthropic-vertex/api.js");
import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js";
function loadFacadeModule(): FacadeModule {
return loadBundledPluginPublicSurfaceModuleSync<FacadeModule>({
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"];

View File

@ -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);
});
});

View File

@ -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<void>;
resolveSandboxNoVncToken?: (token: string) => { noVncPort: number; password?: string } | null;
}): Promise<BrowserBridge>;
stopBrowserBridgeServer(server: Server): Promise<void>;
};
function loadFacadeModule(): BrowserBridgeFacadeModule {
return loadActivatedBundledPluginPublicSurfaceModuleSync<BrowserBridgeFacadeModule>({
dirName: "browser",
artifactBasename: "runtime-api.js",
});
}
export async function startBrowserBridgeServer(
params: Parameters<BrowserBridgeFacadeModule["startBrowserBridgeServer"]>[0],
): Promise<BrowserBridge> {
return await loadFacadeModule().startBrowserBridgeServer(params);
}
export async function stopBrowserBridgeServer(server: Server): Promise<void> {
await loadFacadeModule().stopBrowserBridgeServer(server);
}

View File

@ -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;

View File

@ -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":"/"}');
});
});

View File

@ -0,0 +1,16 @@
import { loadActivatedBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js";
type BrowserNodeHostFacadeModule = {
runBrowserProxyCommand(paramsJSON?: string | null): Promise<string>;
};
function loadFacadeModule(): BrowserNodeHostFacadeModule {
return loadActivatedBundledPluginPublicSurfaceModuleSync<BrowserNodeHostFacadeModule>({
dirName: "browser",
artifactBasename: "runtime-api.js",
});
}
export async function runBrowserProxyCommand(paramsJSON?: string | null): Promise<string> {
return await loadFacadeModule().runBrowserProxyCommand(paramsJSON);
}

View File

@ -0,0 +1,20 @@
type FacadeModule = typeof import("@openclaw/ollama/runtime-api.js");
import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js";
function loadFacadeModule(): FacadeModule {
return loadBundledPluginPublicSurfaceModuleSync<FacadeModule>({
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"];

12
src/plugin-sdk/ollama.ts Normal file
View File

@ -0,0 +1,12 @@
type FacadeModule = typeof import("@openclaw/ollama/api.js");
import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js";
function loadFacadeModule(): FacadeModule {
return loadBundledPluginPublicSurfaceModuleSync<FacadeModule>({
dirName: "ollama",
artifactBasename: "api.js",
});
}
export const resolveOllamaApiBase: FacadeModule["resolveOllamaApiBase"] = ((...args) =>
loadFacadeModule().resolveOllamaApiBase(...args)) as FacadeModule["resolveOllamaApiBase"];

View File

@ -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:

View File

@ -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,

View File

@ -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"],

View File

@ -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<K extends CapabilityProviderRegistryKey> =
PluginRegistry[K][number] extends { provider: infer T } ? T : never;
const CAPABILITY_CONTRACT_KEY: Record<CapabilityProviderRegistryKey, CapabilityContractKey> = {
memoryEmbeddingProviders: "memoryEmbeddingProviders",
speechProviders: "speechProviders",
realtimeTranscriptionProviders: "realtimeTranscriptionProviders",
realtimeVoiceProviders: "realtimeVoiceProviders",

View File

@ -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);

View File

@ -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"),
);
}

View File

@ -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();

View File

@ -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,
});

View File

@ -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;

View File

@ -29,7 +29,10 @@ import type {
PluginOrigin,
} from "./types.js";
type PluginManifestContractListKey = "webFetchProviders" | "webSearchProviders";
type PluginManifestContractListKey =
| "memoryEmbeddingProviders"
| "webFetchProviders"
| "webSearchProviders";
type SeenIdEntry = {
candidate: PluginCandidate;

View File

@ -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 } : {}),

View File

@ -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();
});
});

View File

@ -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);
}

View File

@ -18,6 +18,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
videoGenerationProviders: [],
webFetchProviders: [],
webSearchProviders: [],
memoryEmbeddingProviders: [],
gatewayHandlers: {},
gatewayMethodScopes: {},
httpRoutes: [],

View File

@ -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<WebFetchProviderPlugin>;
export type PluginWebSearchProviderRegistration =
PluginOwnedProviderRegistration<WebSearchProviderPlugin>;
export type PluginMemoryEmbeddingProviderRegistration =
PluginOwnedProviderRegistration<MemoryEmbeddingProviderAdapter>;
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<string, PluginConfigUiHint>;
configJsonSchema?: Record<string, unknown>;
contracts?: PluginManifestContracts;
memorySlotSelected?: boolean;
};
@ -259,6 +265,7 @@ export type PluginRegistry = {
videoGenerationProviders: PluginVideoGenerationProviderRegistration[];
webFetchProviders: PluginWebFetchProviderRegistration[];
webSearchProviders: PluginWebSearchProviderRegistration[];
memoryEmbeddingProviders: PluginMemoryEmbeddingProviderRegistration[];
gatewayHandlers: GatewayRequestHandlers;
gatewayMethodScopes?: Partial<Record<string, OperatorScope>>;
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),

View File

@ -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: [],

View File

@ -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<string, ReturnType<typeof createJiti>>();
const setupRegistryCache = new Map<string, PluginSetupRegistry>();
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<typeof buildPluginApi>) => void | Promise<void>;
} {
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<string>();
const cliBackendKeys = new Set<string>();
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<string, unknown>;
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<string>();
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;
}

View File

@ -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: [],

View File

@ -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<string, unknown>, 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). */

View File

@ -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,

View File

@ -34,6 +34,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl
videoGenerationProviders: [],
webFetchProviders: [],
webSearchProviders: [],
memoryEmbeddingProviders: [],
gatewayHandlers: {},
httpRoutes: [],
cliRegistrars: [],

View File

@ -18,6 +18,9 @@ export function createTestPluginApi(api: TestPluginApiInput): OpenClawPluginApi
registerCli() {},
registerService() {},
registerCliBackend() {},
registerConfigMigration() {},
registerLegacyConfigMigration() {},
registerAutoEnableProbe() {},
registerProvider() {},
registerSpeechProvider() {},
registerRealtimeTranscriptionProvider() {},