refactor: dedupe plugin config helpers

This commit is contained in:
Peter Steinberger
2026-04-06 16:50:36 +01:00
parent a830f4de4b
commit 8aeee0dc6d
8 changed files with 795 additions and 648 deletions

View File

@@ -0,0 +1,191 @@
import { normalizeChatChannelId } from "../channels/ids.js";
import type { OpenClawConfig } from "../config/config.js";
import { defaultSlotIdForKey } from "./slots.js";
export type NormalizedPluginsConfig = {
enabled: boolean;
allow: string[];
deny: string[];
loadPaths: string[];
slots: {
memory?: string | null;
};
entries: Record<
string,
{
enabled?: boolean;
hooks?: {
allowPromptInjection?: boolean;
};
subagent?: {
allowModelOverride?: boolean;
allowedModels?: string[];
hasAllowedModelsConfig?: boolean;
};
config?: unknown;
}
>;
};
export type NormalizePluginId = (id: string) => string;
export const identityNormalizePluginId: NormalizePluginId = (id) => id.trim();
function normalizeList(value: unknown, normalizePluginId: NormalizePluginId): string[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((entry) => (typeof entry === "string" ? normalizePluginId(entry) : ""))
.filter(Boolean);
}
function normalizeSlotValue(value: unknown): string | null | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
if (trimmed.toLowerCase() === "none") {
return null;
}
return trimmed;
}
function normalizePluginEntries(
entries: unknown,
normalizePluginId: NormalizePluginId,
): NormalizedPluginsConfig["entries"] {
if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
return {};
}
const normalized: NormalizedPluginsConfig["entries"] = {};
for (const [key, value] of Object.entries(entries)) {
const normalizedKey = normalizePluginId(key);
if (!normalizedKey) {
continue;
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
normalized[normalizedKey] = {};
continue;
}
const entry = value as Record<string, unknown>;
const hooksRaw = entry.hooks;
const hooks =
hooksRaw && typeof hooksRaw === "object" && !Array.isArray(hooksRaw)
? {
allowPromptInjection: (hooksRaw as { allowPromptInjection?: unknown })
.allowPromptInjection,
}
: undefined;
const normalizedHooks =
hooks && typeof hooks.allowPromptInjection === "boolean"
? {
allowPromptInjection: hooks.allowPromptInjection,
}
: undefined;
const subagentRaw = entry.subagent;
const subagent =
subagentRaw && typeof subagentRaw === "object" && !Array.isArray(subagentRaw)
? {
allowModelOverride: (subagentRaw as { allowModelOverride?: unknown })
.allowModelOverride,
hasAllowedModelsConfig: Array.isArray(
(subagentRaw as { allowedModels?: unknown }).allowedModels,
),
allowedModels: Array.isArray((subagentRaw as { allowedModels?: unknown }).allowedModels)
? ((subagentRaw as { allowedModels?: unknown }).allowedModels as unknown[])
.map((model) => (typeof model === "string" ? model.trim() : ""))
.filter(Boolean)
: undefined,
}
: undefined;
const normalizedSubagent =
subagent &&
(typeof subagent.allowModelOverride === "boolean" ||
subagent.hasAllowedModelsConfig ||
(Array.isArray(subagent.allowedModels) && subagent.allowedModels.length > 0))
? {
...(typeof subagent.allowModelOverride === "boolean"
? { allowModelOverride: subagent.allowModelOverride }
: {}),
...(subagent.hasAllowedModelsConfig ? { hasAllowedModelsConfig: true } : {}),
...(Array.isArray(subagent.allowedModels) && subagent.allowedModels.length > 0
? { allowedModels: subagent.allowedModels }
: {}),
}
: undefined;
normalized[normalizedKey] = {
...normalized[normalizedKey],
enabled:
typeof entry.enabled === "boolean" ? entry.enabled : normalized[normalizedKey]?.enabled,
hooks: normalizedHooks ?? normalized[normalizedKey]?.hooks,
subagent: normalizedSubagent ?? normalized[normalizedKey]?.subagent,
config: "config" in entry ? entry.config : normalized[normalizedKey]?.config,
};
}
return normalized;
}
export function normalizePluginsConfigWithResolver(
config?: OpenClawConfig["plugins"],
normalizePluginId: NormalizePluginId = identityNormalizePluginId,
): NormalizedPluginsConfig {
const memorySlot = normalizeSlotValue(config?.slots?.memory);
return {
enabled: config?.enabled !== false,
allow: normalizeList(config?.allow, normalizePluginId),
deny: normalizeList(config?.deny, normalizePluginId),
loadPaths: normalizeList(config?.load?.paths, identityNormalizePluginId),
slots: {
memory: memorySlot === undefined ? defaultSlotIdForKey("memory") : memorySlot,
},
entries: normalizePluginEntries(config?.entries, normalizePluginId),
};
}
export function hasExplicitPluginConfig(plugins?: OpenClawConfig["plugins"]): boolean {
if (!plugins) {
return false;
}
if (typeof plugins.enabled === "boolean") {
return true;
}
if (Array.isArray(plugins.allow) && plugins.allow.length > 0) {
return true;
}
if (Array.isArray(plugins.deny) && plugins.deny.length > 0) {
return true;
}
if (plugins.load?.paths && Array.isArray(plugins.load.paths) && plugins.load.paths.length > 0) {
return true;
}
if (plugins.slots && Object.keys(plugins.slots).length > 0) {
return true;
}
if (plugins.entries && Object.keys(plugins.entries).length > 0) {
return true;
}
return false;
}
export function isBundledChannelEnabledByChannelConfig(
cfg: OpenClawConfig | undefined,
pluginId: string,
): boolean {
if (!cfg) {
return false;
}
const channelId = normalizeChatChannelId(pluginId);
if (!channelId) {
return false;
}
const channels = cfg.channels as Record<string, unknown> | undefined;
const entry = channels?.[channelId];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return false;
}
return (entry as Record<string, unknown>).enabled === true;
}

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
hasExplicitPluginConfig,
isBundledChannelEnabledByChannelConfig,
normalizePluginsConfigWithResolver,
} from "./config-policy.js";
describe("normalizePluginsConfigWithResolver", () => {
it("uses the provided plugin id resolver for allow deny and entry keys", () => {
const normalized = normalizePluginsConfigWithResolver(
{
allow: [" alpha "],
deny: [" beta "],
entries: {
" gamma ": {
enabled: true,
},
},
},
(id) => id.trim().toUpperCase(),
);
expect(normalized.allow).toEqual(["ALPHA"]);
expect(normalized.deny).toEqual(["BETA"]);
expect(normalized.entries).toHaveProperty("GAMMA");
});
});
describe("hasExplicitPluginConfig", () => {
it("detects explicit config from slots and entry keys", () => {
expect(hasExplicitPluginConfig({ slots: { memory: "none" } })).toBe(true);
expect(hasExplicitPluginConfig({ entries: { foo: {} } })).toBe(true);
expect(hasExplicitPluginConfig({})).toBe(false);
});
});
describe("isBundledChannelEnabledByChannelConfig", () => {
it("only treats enabled channel entries as bundled plugin enablement", () => {
const cfg = {
channels: {
telegram: { enabled: true },
slack: { enabled: false },
},
} as OpenClawConfig;
expect(isBundledChannelEnabledByChannelConfig(cfg, "telegram")).toBe(true);
expect(isBundledChannelEnabledByChannelConfig(cfg, "slack")).toBe(false);
expect(isBundledChannelEnabledByChannelConfig(cfg, "not-a-channel")).toBe(false);
});
});

View File

@@ -1,6 +1,13 @@
import { normalizeChatChannelId } from "../channels/ids.js";
import type { OpenClawConfig } from "../config/config.js";
import { defaultSlotIdForKey, hasKind } from "./slots.js";
import {
hasExplicitPluginConfig as hasExplicitPluginConfigShared,
identityNormalizePluginId,
isBundledChannelEnabledByChannelConfig as isBundledChannelEnabledByChannelConfigShared,
normalizePluginsConfigWithResolver as normalizePluginsConfigWithResolverShared,
type NormalizePluginId,
type NormalizedPluginsConfig as SharedNormalizedPluginsConfig,
} from "./config-normalization-shared.js";
import { hasKind } from "./slots.js";
import type { PluginKind, PluginOrigin } from "./types.js";
export type PluginActivationSource = "disabled" | "explicit" | "auto" | "default";
@@ -13,148 +20,13 @@ export type PluginActivationState = {
reason?: string;
};
export type NormalizedPluginsConfig = {
enabled: boolean;
allow: string[];
deny: string[];
loadPaths: string[];
slots: {
memory?: string | null;
};
entries: Record<
string,
{
enabled?: boolean;
hooks?: {
allowPromptInjection?: boolean;
};
subagent?: {
allowModelOverride?: boolean;
allowedModels?: string[];
hasAllowedModelsConfig?: boolean;
};
config?: unknown;
}
>;
};
type NormalizePluginId = (id: string) => string;
const identityNormalizePluginId: NormalizePluginId = (id) => id.trim();
const normalizeList = (value: unknown, normalizePluginId: NormalizePluginId): string[] => {
if (!Array.isArray(value)) {
return [];
}
return value
.map((entry) => (typeof entry === "string" ? normalizePluginId(entry) : ""))
.filter(Boolean);
};
const normalizeSlotValue = (value: unknown): string | null | undefined => {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
if (trimmed.toLowerCase() === "none") {
return null;
}
return trimmed;
};
const normalizePluginEntries = (
entries: unknown,
normalizePluginId: NormalizePluginId,
): NormalizedPluginsConfig["entries"] => {
if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
return {};
}
const normalized: NormalizedPluginsConfig["entries"] = {};
for (const [key, value] of Object.entries(entries)) {
const normalizedKey = normalizePluginId(key);
if (!normalizedKey) {
continue;
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
normalized[normalizedKey] = {};
continue;
}
const entry = value as Record<string, unknown>;
const hooksRaw = entry.hooks;
const hooks =
hooksRaw && typeof hooksRaw === "object" && !Array.isArray(hooksRaw)
? {
allowPromptInjection: (hooksRaw as { allowPromptInjection?: unknown })
.allowPromptInjection,
}
: undefined;
const normalizedHooks =
hooks && typeof hooks.allowPromptInjection === "boolean"
? {
allowPromptInjection: hooks.allowPromptInjection,
}
: undefined;
const subagentRaw = entry.subagent;
const subagent =
subagentRaw && typeof subagentRaw === "object" && !Array.isArray(subagentRaw)
? {
allowModelOverride: (subagentRaw as { allowModelOverride?: unknown })
.allowModelOverride,
hasAllowedModelsConfig: Array.isArray(
(subagentRaw as { allowedModels?: unknown }).allowedModels,
),
allowedModels: Array.isArray((subagentRaw as { allowedModels?: unknown }).allowedModels)
? ((subagentRaw as { allowedModels?: unknown }).allowedModels as unknown[])
.map((model) => (typeof model === "string" ? model.trim() : ""))
.filter(Boolean)
: undefined,
}
: undefined;
const normalizedSubagent =
subagent &&
(typeof subagent.allowModelOverride === "boolean" ||
subagent.hasAllowedModelsConfig ||
(Array.isArray(subagent.allowedModels) && subagent.allowedModels.length > 0))
? {
...(typeof subagent.allowModelOverride === "boolean"
? { allowModelOverride: subagent.allowModelOverride }
: {}),
...(subagent.hasAllowedModelsConfig ? { hasAllowedModelsConfig: true } : {}),
...(Array.isArray(subagent.allowedModels) && subagent.allowedModels.length > 0
? { allowedModels: subagent.allowedModels }
: {}),
}
: undefined;
normalized[normalizedKey] = {
...normalized[normalizedKey],
enabled:
typeof entry.enabled === "boolean" ? entry.enabled : normalized[normalizedKey]?.enabled,
hooks: normalizedHooks ?? normalized[normalizedKey]?.hooks,
subagent: normalizedSubagent ?? normalized[normalizedKey]?.subagent,
config: "config" in entry ? entry.config : normalized[normalizedKey]?.config,
};
}
return normalized;
};
export type NormalizedPluginsConfig = SharedNormalizedPluginsConfig;
export function normalizePluginsConfigWithResolver(
config?: OpenClawConfig["plugins"],
normalizePluginId: NormalizePluginId = identityNormalizePluginId,
): NormalizedPluginsConfig {
const memorySlot = normalizeSlotValue(config?.slots?.memory);
return {
enabled: config?.enabled !== false,
allow: normalizeList(config?.allow, normalizePluginId),
deny: normalizeList(config?.deny, normalizePluginId),
loadPaths: normalizeList(config?.load?.paths, identityNormalizePluginId),
slots: {
memory: memorySlot === undefined ? defaultSlotIdForKey("memory") : memorySlot,
},
entries: normalizePluginEntries(config?.entries, normalizePluginId),
};
return normalizePluginsConfigWithResolverShared(config, normalizePluginId);
}
function resolveExplicitPluginSelection(params: {
@@ -319,28 +191,7 @@ export function resolvePluginActivationState(params: {
};
}
export function hasExplicitPluginConfig(plugins?: OpenClawConfig["plugins"]): boolean {
if (!plugins) {
return false;
}
if (typeof plugins.enabled === "boolean") {
return true;
}
if (Array.isArray(plugins.allow) && plugins.allow.length > 0) {
return true;
}
if (Array.isArray(plugins.deny) && plugins.deny.length > 0) {
return true;
}
if (plugins.load?.paths && Array.isArray(plugins.load.paths) && plugins.load.paths.length > 0) {
return true;
}
if (plugins.slots && Object.keys(plugins.slots).length > 0) {
return true;
}
if (plugins.entries && Object.keys(plugins.entries).length > 0) {
return true;
}
return false;
return hasExplicitPluginConfigShared(plugins);
}
export function resolveEnableState(
id: string,
@@ -361,19 +212,7 @@ export function isBundledChannelEnabledByChannelConfig(
cfg: OpenClawConfig | undefined,
pluginId: string,
): boolean {
if (!cfg) {
return false;
}
const channelId = normalizeChatChannelId(pluginId);
if (!channelId) {
return false;
}
const channels = cfg.channels as Record<string, unknown> | undefined;
const entry = channels?.[channelId];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return false;
}
return (entry as Record<string, unknown>).enabled === true;
return isBundledChannelEnabledByChannelConfigShared(cfg, pluginId);
}
export function resolveEffectiveEnableState(params: {

View File

@@ -1,7 +1,12 @@
import { normalizeChatChannelId } from "../channels/registry.js";
import type { OpenClawConfig } from "../config/config.js";
import {
hasExplicitPluginConfig as hasExplicitPluginConfigShared,
isBundledChannelEnabledByChannelConfig as isBundledChannelEnabledByChannelConfigShared,
normalizePluginsConfigWithResolver,
type NormalizedPluginsConfig as SharedNormalizedPluginsConfig,
} from "./config-normalization-shared.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import { defaultSlotIdForKey, hasKind } from "./slots.js";
import { hasKind } from "./slots.js";
import type { PluginKind, PluginOrigin } from "./types.js";
export type PluginActivationSource = "disabled" | "explicit" | "auto" | "default";
@@ -46,30 +51,7 @@ export type PluginActivationConfigSource = {
rootConfig?: OpenClawConfig;
};
export type NormalizedPluginsConfig = {
enabled: boolean;
allow: string[];
deny: string[];
loadPaths: string[];
slots: {
memory?: string | null;
};
entries: Record<
string,
{
enabled?: boolean;
hooks?: {
allowPromptInjection?: boolean;
};
subagent?: {
allowModelOverride?: boolean;
allowedModels?: string[];
hasAllowedModelsConfig?: boolean;
};
config?: unknown;
}
>;
};
export type NormalizedPluginsConfig = SharedNormalizedPluginsConfig;
let bundledPluginAliasLookupCache: ReadonlyMap<string, string> | undefined;
@@ -100,29 +82,6 @@ export function normalizePluginId(id: string): string {
return getBundledPluginAliasLookup().get(trimmed.toLowerCase()) ?? trimmed;
}
const normalizeList = (value: unknown): string[] => {
if (!Array.isArray(value)) {
return [];
}
return value
.map((entry) => (typeof entry === "string" ? normalizePluginId(entry) : ""))
.filter(Boolean);
};
const normalizeSlotValue = (value: unknown): string | null | undefined => {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
if (trimmed.toLowerCase() === "none") {
return null;
}
return trimmed;
};
const PLUGIN_ACTIVATION_REASON_BY_CAUSE: Record<PluginActivationCause, string> = {
"enabled-in-config": "enabled in config",
"bundled-channel-enabled-in-config": "channel enabled in config",
@@ -159,92 +118,10 @@ function toPluginActivationState(decision: PluginActivationDecision): PluginActi
};
}
const normalizePluginEntries = (entries: unknown): NormalizedPluginsConfig["entries"] => {
if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
return {};
}
const normalized: NormalizedPluginsConfig["entries"] = {};
for (const [key, value] of Object.entries(entries)) {
const normalizedKey = normalizePluginId(key);
if (!normalizedKey) {
continue;
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
normalized[normalizedKey] = {};
continue;
}
const entry = value as Record<string, unknown>;
const hooksRaw = entry.hooks;
const hooks =
hooksRaw && typeof hooksRaw === "object" && !Array.isArray(hooksRaw)
? {
allowPromptInjection: (hooksRaw as { allowPromptInjection?: unknown })
.allowPromptInjection,
}
: undefined;
const normalizedHooks =
hooks && typeof hooks.allowPromptInjection === "boolean"
? {
allowPromptInjection: hooks.allowPromptInjection,
}
: undefined;
const subagentRaw = entry.subagent;
const subagent =
subagentRaw && typeof subagentRaw === "object" && !Array.isArray(subagentRaw)
? {
allowModelOverride: (subagentRaw as { allowModelOverride?: unknown })
.allowModelOverride,
hasAllowedModelsConfig: Array.isArray(
(subagentRaw as { allowedModels?: unknown }).allowedModels,
),
allowedModels: Array.isArray((subagentRaw as { allowedModels?: unknown }).allowedModels)
? ((subagentRaw as { allowedModels?: unknown }).allowedModels as unknown[])
.map((model) => (typeof model === "string" ? model.trim() : ""))
.filter(Boolean)
: undefined,
}
: undefined;
const normalizedSubagent =
subagent &&
(typeof subagent.allowModelOverride === "boolean" ||
subagent.hasAllowedModelsConfig ||
(Array.isArray(subagent.allowedModels) && subagent.allowedModels.length > 0))
? {
...(typeof subagent.allowModelOverride === "boolean"
? { allowModelOverride: subagent.allowModelOverride }
: {}),
...(subagent.hasAllowedModelsConfig ? { hasAllowedModelsConfig: true } : {}),
...(Array.isArray(subagent.allowedModels) && subagent.allowedModels.length > 0
? { allowedModels: subagent.allowedModels }
: {}),
}
: undefined;
normalized[normalizedKey] = {
...normalized[normalizedKey],
enabled:
typeof entry.enabled === "boolean" ? entry.enabled : normalized[normalizedKey]?.enabled,
hooks: normalizedHooks ?? normalized[normalizedKey]?.hooks,
subagent: normalizedSubagent ?? normalized[normalizedKey]?.subagent,
config: "config" in entry ? entry.config : normalized[normalizedKey]?.config,
};
}
return normalized;
};
export const normalizePluginsConfig = (
config?: OpenClawConfig["plugins"],
): NormalizedPluginsConfig => {
const memorySlot = normalizeSlotValue(config?.slots?.memory);
return {
enabled: config?.enabled !== false,
allow: normalizeList(config?.allow),
deny: normalizeList(config?.deny),
loadPaths: normalizeList(config?.load?.paths),
slots: {
memory: memorySlot === undefined ? defaultSlotIdForKey("memory") : memorySlot,
},
entries: normalizePluginEntries(config?.entries),
};
return normalizePluginsConfigWithResolver(config, normalizePluginId);
};
export function createPluginActivationSource(params: {
@@ -263,30 +140,8 @@ const hasExplicitMemorySlot = (plugins?: OpenClawConfig["plugins"]) =>
const hasExplicitMemoryEntry = (plugins?: OpenClawConfig["plugins"]) =>
Boolean(plugins?.entries && Object.prototype.hasOwnProperty.call(plugins.entries, "memory-core"));
export const hasExplicitPluginConfig = (plugins?: OpenClawConfig["plugins"]) => {
if (!plugins) {
return false;
}
if (typeof plugins.enabled === "boolean") {
return true;
}
if (Array.isArray(plugins.allow) && plugins.allow.length > 0) {
return true;
}
if (Array.isArray(plugins.deny) && plugins.deny.length > 0) {
return true;
}
if (plugins.load?.paths && Array.isArray(plugins.load.paths) && plugins.load.paths.length > 0) {
return true;
}
if (plugins.slots && Object.keys(plugins.slots).length > 0) {
return true;
}
if (plugins.entries && Object.keys(plugins.entries).length > 0) {
return true;
}
return false;
};
export const hasExplicitPluginConfig = (plugins?: OpenClawConfig["plugins"]) =>
hasExplicitPluginConfigShared(plugins);
export function applyTestPluginDefaults(
cfg: OpenClawConfig,
@@ -535,19 +390,7 @@ export function isBundledChannelEnabledByChannelConfig(
cfg: OpenClawConfig | undefined,
pluginId: string,
): boolean {
if (!cfg) {
return false;
}
const channelId = normalizeChatChannelId(pluginId);
if (!channelId) {
return false;
}
const channels = cfg.channels as Record<string, unknown> | undefined;
const entry = channels?.[channelId];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return false;
}
return (entry as Record<string, unknown>).enabled === true;
return isBundledChannelEnabledByChannelConfigShared(cfg, pluginId);
}
export function resolveEffectiveEnableState(params: {

View File

@@ -118,6 +118,22 @@ type PluginBindingGlobalState = {
approvalsLoaded: boolean;
};
type PluginConversationBindingState = {
ref: ConversationRef;
record:
| {
bindingId: string;
conversation: ConversationRef;
boundAt: number;
metadata?: Record<string, unknown>;
targetSessionKey: string;
}
| null
| undefined;
binding: PluginConversationBinding | null;
isLegacyForeignBinding: boolean;
};
const pluginBindingGlobalStateKey = Symbol.for("openclaw.plugins.binding.global-state");
const pluginBindingGlobalState = resolveGlobalSingleton<PluginBindingGlobalState>(
pluginBindingGlobalStateKey,
@@ -217,6 +233,42 @@ function buildPluginBindingSessionKey(params: {
return `${PLUGIN_BINDING_SESSION_PREFIX}:${params.pluginId}:${hash}`;
}
function buildPluginBindingIdentity(params: PluginBindingIdentity): PluginBindingIdentity {
return {
pluginId: params.pluginId,
pluginName: params.pluginName,
pluginRoot: params.pluginRoot,
};
}
function logPluginBindingLifecycleEvent(params: {
event:
| "migrating legacy record"
| "auto-refresh"
| "auto-approved"
| "requested"
| "detached"
| "denied"
| "approved";
pluginId: string;
pluginRoot: string;
channel: string;
accountId: string;
conversationId: string;
decision?: PluginBindingApprovalDecision;
}): void {
const parts = [
`plugin binding ${params.event}`,
`plugin=${params.pluginId}`,
`root=${params.pluginRoot}`,
...(params.decision ? [`decision=${params.decision}`] : []),
`channel=${params.channel}`,
`account=${params.accountId}`,
`conversation=${params.conversationId}`,
];
log.info(parts.join(" "));
}
function isLegacyPluginBindingRecord(params: {
record:
| {
@@ -432,6 +484,89 @@ export function toPluginConversationBinding(
};
}
function withConversationBindingContext(
binding: PluginConversationBinding,
conversation: PluginBindingConversation,
): PluginConversationBinding {
return {
...binding,
parentConversationId: conversation.parentConversationId,
threadId: conversation.threadId,
};
}
function resolvePluginConversationBindingState(params: {
conversation: PluginBindingConversation;
}): PluginConversationBindingState {
const ref = toConversationRef(params.conversation);
const record = resolveConversationBindingRecord(ref);
const binding = toPluginConversationBinding(record);
return {
ref,
record,
binding,
isLegacyForeignBinding: isLegacyPluginBindingRecord({ record }),
};
}
function resolveOwnedPluginConversationBinding(params: {
pluginRoot: string;
conversation: PluginBindingConversation;
}): PluginConversationBinding | null {
const state = resolvePluginConversationBindingState({
conversation: params.conversation,
});
if (!state.binding || state.binding.pluginRoot !== params.pluginRoot) {
return null;
}
return withConversationBindingContext(state.binding, params.conversation);
}
function bindConversationFromIdentity(params: {
identity: PluginBindingIdentity;
conversation: PluginBindingConversation;
summary?: string;
detachHint?: string;
}): Promise<PluginConversationBinding> {
return bindConversationNow({
identity: buildPluginBindingIdentity(params.identity),
conversation: params.conversation,
summary: params.summary,
detachHint: params.detachHint,
});
}
function bindConversationFromRequest(
request: Pick<
PendingPluginBindingRequest,
"pluginId" | "pluginName" | "pluginRoot" | "conversation" | "summary" | "detachHint"
>,
): Promise<PluginConversationBinding> {
return bindConversationFromIdentity({
identity: buildPluginBindingIdentity(request),
conversation: request.conversation,
summary: request.summary,
detachHint: request.detachHint,
});
}
function buildApprovalEntryFromRequest(
request: Pick<
PendingPluginBindingRequest,
"pluginRoot" | "pluginId" | "pluginName" | "conversation"
>,
approvedAt = Date.now(),
): PluginBindingApprovalEntry {
return {
pluginRoot: request.pluginRoot,
pluginId: request.pluginId,
pluginName: request.pluginName,
channel: request.conversation.channel,
accountId: request.conversation.accountId,
approvedAt,
};
}
async function bindConversationNow(params: {
identity: PluginBindingIdentity;
conversation: PluginBindingConversation;
@@ -462,11 +597,7 @@ async function bindConversationNow(params: {
if (!binding) {
throw new Error("plugin binding was created without plugin metadata");
}
return {
...binding,
parentConversationId: params.conversation.parentConversationId,
threadId: params.conversation.threadId,
};
return withConversationBindingContext(binding, params.conversation);
}
function buildApprovalMessage(request: PendingPluginBindingRequest): string {
@@ -595,17 +726,19 @@ export async function requestPluginConversationBinding(params: {
binding: PluginConversationBindingRequestParams | undefined;
}): Promise<PluginConversationBindingRequestResult> {
const conversation = normalizeConversation(params.conversation);
const ref = toConversationRef(conversation);
const existing = resolveConversationBindingRecord(ref);
const existingPluginBinding = toPluginConversationBinding(existing);
const existingLegacyPluginBinding = isLegacyPluginBindingRecord({
record: existing,
const state = resolvePluginConversationBindingState({
conversation,
});
if (existing && !existingPluginBinding) {
if (existingLegacyPluginBinding) {
log.info(
`plugin binding migrating legacy record plugin=${params.pluginId} root=${params.pluginRoot} channel=${ref.channel} account=${ref.accountId} conversation=${ref.conversationId}`,
);
if (state.record && !state.binding) {
if (state.isLegacyForeignBinding) {
logPluginBindingLifecycleEvent({
event: "migrating legacy record",
pluginId: params.pluginId,
pluginRoot: params.pluginRoot,
channel: state.ref.channel,
accountId: state.ref.accountId,
conversationId: state.ref.conversationId,
});
} else {
return {
status: "error",
@@ -614,50 +747,52 @@ export async function requestPluginConversationBinding(params: {
};
}
}
if (existingPluginBinding && existingPluginBinding.pluginRoot !== params.pluginRoot) {
if (state.binding && state.binding.pluginRoot !== params.pluginRoot) {
return {
status: "error",
message: `This conversation is already bound by plugin "${existingPluginBinding.pluginName ?? existingPluginBinding.pluginId}".`,
message: `This conversation is already bound by plugin "${state.binding.pluginName ?? state.binding.pluginId}".`,
};
}
if (existingPluginBinding && existingPluginBinding.pluginRoot === params.pluginRoot) {
const rebound = await bindConversationNow({
identity: {
pluginId: params.pluginId,
pluginName: params.pluginName,
pluginRoot: params.pluginRoot,
},
if (state.binding && state.binding.pluginRoot === params.pluginRoot) {
const rebound = await bindConversationFromIdentity({
identity: buildPluginBindingIdentity(params),
conversation,
summary: params.binding?.summary,
detachHint: params.binding?.detachHint,
});
log.info(
`plugin binding auto-refresh plugin=${params.pluginId} root=${params.pluginRoot} channel=${ref.channel} account=${ref.accountId} conversation=${ref.conversationId}`,
);
logPluginBindingLifecycleEvent({
event: "auto-refresh",
pluginId: params.pluginId,
pluginRoot: params.pluginRoot,
channel: state.ref.channel,
accountId: state.ref.accountId,
conversationId: state.ref.conversationId,
});
return { status: "bound", binding: rebound };
}
if (
hasPersistentApproval({
pluginRoot: params.pluginRoot,
channel: ref.channel,
accountId: ref.accountId,
channel: state.ref.channel,
accountId: state.ref.accountId,
})
) {
const bound = await bindConversationNow({
identity: {
pluginId: params.pluginId,
pluginName: params.pluginName,
pluginRoot: params.pluginRoot,
},
const bound = await bindConversationFromIdentity({
identity: buildPluginBindingIdentity(params),
conversation,
summary: params.binding?.summary,
detachHint: params.binding?.detachHint,
});
log.info(
`plugin binding auto-approved plugin=${params.pluginId} root=${params.pluginRoot} channel=${ref.channel} account=${ref.accountId} conversation=${ref.conversationId}`,
);
logPluginBindingLifecycleEvent({
event: "auto-approved",
pluginId: params.pluginId,
pluginRoot: params.pluginRoot,
channel: state.ref.channel,
accountId: state.ref.accountId,
conversationId: state.ref.conversationId,
});
return { status: "bound", binding: bound };
}
@@ -673,9 +808,14 @@ export async function requestPluginConversationBinding(params: {
detachHint: params.binding?.detachHint?.trim() || undefined,
};
pendingRequests.set(request.id, request);
log.info(
`plugin binding requested plugin=${params.pluginId} root=${params.pluginRoot} channel=${ref.channel} account=${ref.accountId} conversation=${ref.conversationId}`,
);
logPluginBindingLifecycleEvent({
event: "requested",
pluginId: params.pluginId,
pluginRoot: params.pluginRoot,
channel: state.ref.channel,
accountId: state.ref.accountId,
conversationId: state.ref.conversationId,
});
return {
status: "pending",
approvalId: request.id,
@@ -687,35 +827,29 @@ export async function getCurrentPluginConversationBinding(params: {
pluginRoot: string;
conversation: PluginBindingConversation;
}): Promise<PluginConversationBinding | null> {
const record = resolveConversationBindingRecord(toConversationRef(params.conversation));
const binding = toPluginConversationBinding(record);
if (!binding || binding.pluginRoot !== params.pluginRoot) {
return null;
}
return {
...binding,
parentConversationId: params.conversation.parentConversationId,
threadId: params.conversation.threadId,
};
return resolveOwnedPluginConversationBinding(params);
}
export async function detachPluginConversationBinding(params: {
pluginRoot: string;
conversation: PluginBindingConversation;
}): Promise<{ removed: boolean }> {
const ref = toConversationRef(params.conversation);
const record = resolveConversationBindingRecord(ref);
const binding = toPluginConversationBinding(record);
if (!binding || binding.pluginRoot !== params.pluginRoot) {
const binding = resolveOwnedPluginConversationBinding(params);
if (!binding) {
return { removed: false };
}
await unbindConversationBindingRecord({
bindingId: binding.bindingId,
reason: "plugin-detach",
});
log.info(
`plugin binding detached plugin=${binding.pluginId} root=${binding.pluginRoot} channel=${binding.channel} account=${binding.accountId} conversation=${binding.conversationId}`,
);
logPluginBindingLifecycleEvent({
event: "detached",
pluginId: binding.pluginId,
pluginRoot: binding.pluginRoot,
channel: binding.channel,
accountId: binding.accountId,
conversationId: binding.conversationId,
});
return { removed: true };
}
@@ -742,34 +876,29 @@ export async function resolvePluginConversationBindingApproval(params: {
decision: "deny",
request,
});
log.info(
`plugin binding denied plugin=${request.pluginId} root=${request.pluginRoot} channel=${request.conversation.channel} account=${request.conversation.accountId} conversation=${request.conversation.conversationId}`,
);
logPluginBindingLifecycleEvent({
event: "denied",
pluginId: request.pluginId,
pluginRoot: request.pluginRoot,
channel: request.conversation.channel,
accountId: request.conversation.accountId,
conversationId: request.conversation.conversationId,
});
return { status: "denied", request };
}
if (params.decision === "allow-always") {
await addPersistentApproval({
pluginRoot: request.pluginRoot,
pluginId: request.pluginId,
pluginName: request.pluginName,
channel: request.conversation.channel,
accountId: request.conversation.accountId,
approvedAt: Date.now(),
});
await addPersistentApproval(buildApprovalEntryFromRequest(request));
}
const binding = await bindConversationNow({
identity: {
pluginId: request.pluginId,
pluginName: request.pluginName,
pluginRoot: request.pluginRoot,
},
conversation: request.conversation,
summary: request.summary,
detachHint: request.detachHint,
const binding = await bindConversationFromRequest(request);
logPluginBindingLifecycleEvent({
event: "approved",
pluginId: request.pluginId,
pluginRoot: request.pluginRoot,
decision: params.decision,
channel: request.conversation.channel,
accountId: request.conversation.accountId,
conversationId: request.conversation.conversationId,
});
log.info(
`plugin binding approved plugin=${request.pluginId} root=${request.pluginRoot} decision=${params.decision} channel=${request.conversation.channel} account=${request.conversation.accountId} conversation=${request.conversation.conversationId}`,
);
dispatchPluginConversationBindingResolved({
status: "approved",
binding,

View File

@@ -277,6 +277,71 @@ function pickFileInstallCommonParams(params: FileInstallCommonParams): FileInsta
};
}
type PreparedInstallTarget = {
targetPath: string;
effectiveMode: "install" | "update";
};
async function ensureInstallTargetAvailableForMode(params: {
runtime: Awaited<ReturnType<typeof loadPluginInstallRuntime>>;
targetPath: string;
mode: "install" | "update";
}): Promise<{ ok: true } | { ok: false; error: string }> {
return await params.runtime.ensureInstallTargetAvailable({
mode: params.mode,
targetDir: params.targetPath,
alreadyExistsError: `plugin already exists: ${params.targetPath} (delete it first)`,
});
}
async function resolvePreparedDirectoryInstallTarget(params: {
runtime: Awaited<ReturnType<typeof loadPluginInstallRuntime>>;
pluginId: string;
extensionsDir?: string;
requestedMode: "install" | "update";
nameEncoder?: (pluginId: string) => string;
}): Promise<{ ok: true; target: PreparedInstallTarget } | { ok: false; error: string }> {
const targetDirResult = await resolvePluginInstallTarget({
runtime: params.runtime,
pluginId: params.pluginId,
extensionsDir: params.extensionsDir,
nameEncoder: params.nameEncoder,
});
if (!targetDirResult.ok) {
return targetDirResult;
}
return {
ok: true,
target: {
targetPath: targetDirResult.targetDir,
effectiveMode: await resolveEffectiveInstallMode({
runtime: params.runtime,
requestedMode: params.requestedMode,
targetPath: targetDirResult.targetDir,
}),
},
};
}
async function runInstallSourceScan(params: {
subject: string;
scan: () => Promise<InstallSecurityScanResult | undefined>;
}): Promise<Extract<InstallPluginResult, { ok: false }> | null> {
try {
const scanResult = await params.scan();
if (scanResult?.blocked) {
return buildBlockedInstallResult({ blocked: scanResult.blocked });
}
return null;
} catch (err) {
return {
ok: false,
error: `${params.subject} installation blocked: code safety scan failed (${String(err)}). Run "openclaw security audit --deep" for details.`,
code: PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED,
};
}
}
async function installPluginDirectoryIntoExtensions(params: {
sourceDir: string;
pluginId: string;
@@ -309,10 +374,10 @@ async function installPluginDirectoryIntoExtensions(params: {
}
targetDir = targetDirResult.targetDir;
}
const availability = await runtime.ensureInstallTargetAvailable({
const availability = await ensureInstallTargetAvailableForMode({
runtime,
targetPath: targetDir,
mode: params.mode,
targetDir,
alreadyExistsError: `plugin already exists: ${targetDir} (delete it first)`,
});
if (!availability.ok) {
return availability;
@@ -438,40 +503,32 @@ async function installBundleFromSourceDir(
};
}
const targetDirResult = await resolvePluginInstallTarget({
const targetResult = await resolvePreparedDirectoryInstallTarget({
runtime,
pluginId,
extensionsDir: params.extensionsDir,
});
if (!targetDirResult.ok) {
return { ok: false, error: targetDirResult.error };
}
const effectiveMode = await resolveEffectiveInstallMode({
runtime,
requestedMode: mode,
targetPath: targetDirResult.targetDir,
});
if (!targetResult.ok) {
return { ok: false, error: targetResult.error };
}
try {
const scanResult = await runtime.scanBundleInstallSource({
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
sourceDir: params.sourceDir,
pluginId,
logger,
requestKind: params.installPolicyRequest?.kind,
requestedSpecifier: params.installPolicyRequest?.requestedSpecifier,
mode: effectiveMode,
version: manifestRes.manifest.version,
});
if (scanResult?.blocked) {
return buildBlockedInstallResult({ blocked: scanResult.blocked });
}
} catch (err) {
return {
ok: false,
error: `Bundle "${pluginId}" installation blocked: code safety scan failed (${String(err)}). Run "openclaw security audit --deep" for details.`,
code: PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED,
};
const scanResult = await runInstallSourceScan({
subject: `Bundle "${pluginId}"`,
scan: async () =>
await runtime.scanBundleInstallSource({
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
sourceDir: params.sourceDir,
pluginId,
logger,
requestKind: params.installPolicyRequest?.kind,
requestedSpecifier: params.installPolicyRequest?.requestedSpecifier,
mode: targetResult.target.effectiveMode,
version: manifestRes.manifest.version,
}),
});
if (scanResult) {
return scanResult;
}
return await installPluginDirectoryIntoExtensions({
@@ -480,11 +537,11 @@ async function installBundleFromSourceDir(
manifestName: manifestRes.manifest.name,
version: manifestRes.manifest.version,
extensions: [],
targetDir: targetDirResult.targetDir,
targetDir: targetResult.target.targetPath,
extensionsDir: params.extensionsDir,
logger,
timeoutMs,
mode: effectiveMode,
mode: targetResult.target.effectiveMode,
dryRun,
copyErrorPrefix: "failed to copy plugin bundle",
hasDeps: false,
@@ -633,43 +690,36 @@ async function installPluginFromPackageDir(
};
}
const targetDirResult = await resolvePluginInstallTarget({
const targetResult = await resolvePreparedDirectoryInstallTarget({
runtime,
pluginId,
extensionsDir: params.extensionsDir,
requestedMode: mode,
nameEncoder: encodePluginInstallDirName,
});
if (!targetDirResult.ok) {
return { ok: false, error: targetDirResult.error };
if (!targetResult.ok) {
return { ok: false, error: targetResult.error };
}
const effectiveMode = await resolveEffectiveInstallMode({
runtime,
requestedMode: mode,
targetPath: targetDirResult.targetDir,
const scanResult = await runInstallSourceScan({
subject: `Plugin "${pluginId}"`,
scan: async () =>
await runtime.scanPackageInstallSource({
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
packageDir: params.packageDir,
pluginId,
logger,
extensions,
requestKind: params.installPolicyRequest?.kind,
requestedSpecifier: params.installPolicyRequest?.requestedSpecifier,
mode: targetResult.target.effectiveMode,
packageName: pkgName || undefined,
manifestId: manifestPluginId,
version: typeof manifest.version === "string" ? manifest.version : undefined,
}),
});
try {
const scanResult = await runtime.scanPackageInstallSource({
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
packageDir: params.packageDir,
pluginId,
logger,
extensions,
requestKind: params.installPolicyRequest?.kind,
requestedSpecifier: params.installPolicyRequest?.requestedSpecifier,
mode: effectiveMode,
packageName: pkgName || undefined,
manifestId: manifestPluginId,
version: typeof manifest.version === "string" ? manifest.version : undefined,
});
if (scanResult?.blocked) {
return buildBlockedInstallResult({ blocked: scanResult.blocked });
}
} catch (err) {
return {
ok: false,
error: `Plugin "${pluginId}" installation blocked: code safety scan failed (${String(err)}). Run "openclaw security audit --deep" for details.`,
code: PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED,
};
if (scanResult) {
return scanResult;
}
const deps = manifest.dependencies ?? {};
@@ -679,11 +729,11 @@ async function installPluginFromPackageDir(
manifestName: pkgName || undefined,
version: typeof manifest.version === "string" ? manifest.version : undefined,
extensions,
targetDir: targetDirResult.targetDir,
targetDir: targetResult.target.targetPath,
extensionsDir: params.extensionsDir,
logger,
timeoutMs,
mode: effectiveMode,
mode: targetResult.target.effectiveMode,
dryRun,
copyErrorPrefix: "failed to copy plugin",
hasDeps: Object.keys(deps).length > 0,
@@ -807,57 +857,56 @@ export async function installPluginFromFile(params: {
return { ok: false, error: pluginIdError };
}
const targetFile = path.join(extensionsDir, `${safeFileName(pluginId)}${path.extname(filePath)}`);
const effectiveMode = await resolveEffectiveInstallMode({
runtime,
requestedMode: mode,
const preparedTarget: PreparedInstallTarget = {
targetPath: targetFile,
});
effectiveMode: await resolveEffectiveInstallMode({
runtime,
requestedMode: mode,
targetPath: targetFile,
}),
};
const availability = await runtime.ensureInstallTargetAvailable({
mode: effectiveMode,
targetDir: targetFile,
alreadyExistsError: `plugin already exists: ${targetFile} (delete it first)`,
const availability = await ensureInstallTargetAvailableForMode({
runtime,
targetPath: preparedTarget.targetPath,
mode: preparedTarget.effectiveMode,
});
if (!availability.ok) {
return availability;
}
if (dryRun) {
return buildFileInstallResult(pluginId, targetFile);
return buildFileInstallResult(pluginId, preparedTarget.targetPath);
}
try {
const scanResult = await runtime.scanFileInstallSource({
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
filePath,
logger,
mode: effectiveMode,
pluginId,
requestedSpecifier: installPolicyRequest.requestedSpecifier,
});
if (scanResult?.blocked) {
return buildBlockedInstallResult({ blocked: scanResult.blocked });
}
} catch (err) {
return {
ok: false,
error: `Plugin file "${pluginId}" installation blocked: code safety scan failed (${String(err)}). Run "openclaw security audit --deep" for details.`,
code: PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED,
};
const scanResult = await runInstallSourceScan({
subject: `Plugin file "${pluginId}"`,
scan: async () =>
await runtime.scanFileInstallSource({
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
filePath,
logger,
mode: preparedTarget.effectiveMode,
pluginId,
requestedSpecifier: installPolicyRequest.requestedSpecifier,
}),
});
if (scanResult) {
return scanResult;
}
logger.info?.(`Installing to ${targetFile}`);
logger.info?.(`Installing to ${preparedTarget.targetPath}`);
try {
await runtime.writeFileFromPathWithinRoot({
rootDir: extensionsDir,
relativePath: path.basename(targetFile),
relativePath: path.basename(preparedTarget.targetPath),
sourcePath: filePath,
});
} catch (err) {
return { ok: false, error: String(err) };
}
return buildFileInstallResult(pluginId, targetFile);
return buildFileInstallResult(pluginId, preparedTarget.targetPath);
}
export async function installPluginFromNpmSpec(

View File

@@ -479,35 +479,64 @@ async function loadMarketplace(params: {
logger?: MarketplaceLogger;
timeoutMs?: number;
}): Promise<{ ok: true; marketplace: LoadedMarketplace } | { ok: false; error: string }> {
const loadResolvedLocalMarketplace = async (
local: ResolvedLocalMarketplaceSource,
sourceLabel: string,
): Promise<{ ok: true; marketplace: LoadedMarketplace } | { ok: false; error: string }> => {
const raw = await fs.readFile(local.manifestPath, "utf-8");
const parsed = parseMarketplaceManifest(raw, local.manifestPath);
const loadMarketplaceFromManifestFile = async (params: {
manifestPath: string;
sourceLabel: string;
rootDir: string;
origin: MarketplaceManifestOrigin;
cleanup?: () => Promise<void>;
}): Promise<{ ok: true; marketplace: LoadedMarketplace } | { ok: false; error: string }> => {
const raw = await fs.readFile(params.manifestPath, "utf-8");
const parsed = parseMarketplaceManifest(raw, params.manifestPath);
if (!parsed.ok) {
await params.cleanup?.();
return parsed;
}
const validated = await validateMarketplaceManifest({
manifest: parsed.manifest,
sourceLabel: local.manifestPath,
rootDir: local.rootDir,
origin: "local",
sourceLabel: params.sourceLabel,
rootDir: params.rootDir,
origin: params.origin,
});
if (!validated.ok) {
await params.cleanup?.();
return validated;
}
return {
ok: true,
marketplace: {
manifest: validated.manifest,
rootDir: local.rootDir,
sourceLabel,
origin: "local",
rootDir: params.rootDir,
sourceLabel: params.sourceLabel,
origin: params.origin,
cleanup: params.cleanup,
},
};
};
const loadResolvedLocalMarketplace = async (
local: ResolvedLocalMarketplaceSource,
sourceLabel: string,
): Promise<{ ok: true; marketplace: LoadedMarketplace } | { ok: false; error: string }> =>
loadMarketplaceFromManifestFile({
manifestPath: local.manifestPath,
sourceLabel,
rootDir: local.rootDir,
origin: "local",
});
const resolveClonedMarketplaceManifestPath = async (
rootDir: string,
): Promise<string | undefined> => {
for (const candidate of MARKETPLACE_MANIFEST_CANDIDATES) {
const next = path.join(rootDir, candidate);
if (await pathExists(next)) {
return next;
}
}
return undefined;
};
const knownMarketplaces = await readClaudeKnownMarketplaces();
const known = knownMarketplaces[params.source];
if (known) {
@@ -546,46 +575,19 @@ async function loadMarketplace(params: {
return cloned;
}
let manifestPath: string | undefined;
for (const candidate of MARKETPLACE_MANIFEST_CANDIDATES) {
const next = path.join(cloned.rootDir, candidate);
if (await pathExists(next)) {
manifestPath = next;
break;
}
}
const manifestPath = await resolveClonedMarketplaceManifestPath(cloned.rootDir);
if (!manifestPath) {
await cloned.cleanup();
return { ok: false, error: `marketplace manifest not found in ${cloned.label}` };
}
const raw = await fs.readFile(manifestPath, "utf-8");
const parsed = parseMarketplaceManifest(raw, manifestPath);
if (!parsed.ok) {
await cloned.cleanup();
return parsed;
}
const validated = await validateMarketplaceManifest({
manifest: parsed.manifest,
return await loadMarketplaceFromManifestFile({
manifestPath,
sourceLabel: cloned.label,
rootDir: cloned.rootDir,
origin: "remote",
cleanup: cloned.cleanup,
});
if (!validated.ok) {
await cloned.cleanup();
return validated;
}
return {
ok: true,
marketplace: {
manifest: validated.manifest,
rootDir: cloned.rootDir,
sourceLabel: cloned.label,
origin: "remote",
cleanup: cloned.cleanup,
},
};
}
function resolveSafeMarketplaceDownloadFileName(url: string, fallback: string): string {

View File

@@ -1,5 +1,9 @@
import type { PluginDiagnostic, ProviderAuthMethod, ProviderPlugin } from "./types.js";
type ProviderWizardSetup = NonNullable<NonNullable<ProviderPlugin["wizard"]>["setup"]>;
type ProviderWizardModelPicker = NonNullable<NonNullable<ProviderPlugin["wizard"]>["modelPicker"]>;
type ProviderWizardModelAllowlist = NonNullable<ProviderWizardSetup["modelAllowlist"]>;
function pushProviderDiagnostic(params: {
level: PluginDiagnostic["level"];
pluginId: string;
@@ -63,14 +67,104 @@ function normalizeProviderOAuthProfileIdRepairs(
return normalized.length > 0 ? normalized : undefined;
}
function resolveWizardMethodId(params: {
providerId: string;
pluginId: string;
source: string;
auth: ProviderAuthMethod[];
methodId: string | undefined;
metadataKind: "setup" | "model-picker";
pushDiagnostic: (diag: PluginDiagnostic) => void;
}): string | undefined {
if (!params.methodId) {
return undefined;
}
if (params.auth.some((method) => method.id === params.methodId)) {
return params.methodId;
}
pushProviderDiagnostic({
level: "warn",
pluginId: params.pluginId,
source: params.source,
message: `provider "${params.providerId}" ${params.metadataKind} method "${params.methodId}" not found; falling back to available methods`,
pushDiagnostic: params.pushDiagnostic,
});
return undefined;
}
function buildNormalizedModelAllowlist(
modelAllowlist: ProviderWizardModelAllowlist | undefined,
): ProviderWizardModelAllowlist | undefined {
if (!modelAllowlist) {
return undefined;
}
const allowedKeys = normalizeTextList(modelAllowlist.allowedKeys);
const initialSelections = normalizeTextList(modelAllowlist.initialSelections);
const message = normalizeText(modelAllowlist.message);
if (!allowedKeys && !initialSelections && !message) {
return undefined;
}
return {
...(allowedKeys ? { allowedKeys } : {}),
...(initialSelections ? { initialSelections } : {}),
...(message ? { message } : {}),
};
}
function buildNormalizedWizardSetup(params: {
setup: ProviderWizardSetup;
methodId: string | undefined;
}): ProviderWizardSetup {
const choiceId = normalizeText(params.setup.choiceId);
const choiceLabel = normalizeText(params.setup.choiceLabel);
const choiceHint = normalizeText(params.setup.choiceHint);
const groupId = normalizeText(params.setup.groupId);
const groupLabel = normalizeText(params.setup.groupLabel);
const groupHint = normalizeText(params.setup.groupHint);
const onboardingScopes = normalizeOnboardingScopes(params.setup.onboardingScopes);
const modelAllowlist = buildNormalizedModelAllowlist(params.setup.modelAllowlist);
return {
...(choiceId ? { choiceId } : {}),
...(choiceLabel ? { choiceLabel } : {}),
...(choiceHint ? { choiceHint } : {}),
...(typeof params.setup.assistantPriority === "number" &&
Number.isFinite(params.setup.assistantPriority)
? { assistantPriority: params.setup.assistantPriority }
: {}),
...(params.setup.assistantVisibility === "manual-only" ||
params.setup.assistantVisibility === "visible"
? { assistantVisibility: params.setup.assistantVisibility }
: {}),
...(groupId ? { groupId } : {}),
...(groupLabel ? { groupLabel } : {}),
...(groupHint ? { groupHint } : {}),
...(params.methodId ? { methodId: params.methodId } : {}),
...(onboardingScopes ? { onboardingScopes } : {}),
...(modelAllowlist ? { modelAllowlist } : {}),
};
}
function buildNormalizedModelPicker(
modelPicker: ProviderWizardModelPicker,
methodId: string | undefined,
): ProviderWizardModelPicker {
const label = normalizeText(modelPicker.label);
const hint = normalizeText(modelPicker.hint);
return {
...(label ? { label } : {}),
...(hint ? { hint } : {}),
...(methodId ? { methodId } : {}),
};
}
function normalizeProviderWizardSetup(params: {
providerId: string;
pluginId: string;
source: string;
auth: ProviderAuthMethod[];
setup: NonNullable<ProviderPlugin["wizard"]>["setup"];
setup: ProviderWizardSetup;
pushDiagnostic: (diag: PluginDiagnostic) => void;
}): NonNullable<ProviderPlugin["wizard"]>["setup"] {
}): ProviderWizardSetup | undefined {
const hasAuthMethods = params.auth.length > 0;
if (!params.setup) {
return undefined;
@@ -85,67 +179,19 @@ function normalizeProviderWizardSetup(params: {
});
return undefined;
}
const methodId = normalizeText(params.setup.methodId);
if (methodId && !params.auth.some((method) => method.id === methodId)) {
pushProviderDiagnostic({
level: "warn",
pluginId: params.pluginId,
source: params.source,
message: `provider "${params.providerId}" setup method "${methodId}" not found; falling back to available methods`,
pushDiagnostic: params.pushDiagnostic,
});
}
return {
...(normalizeText(params.setup.choiceId)
? { choiceId: normalizeText(params.setup.choiceId) }
: {}),
...(normalizeText(params.setup.choiceLabel)
? { choiceLabel: normalizeText(params.setup.choiceLabel) }
: {}),
...(normalizeText(params.setup.choiceHint)
? { choiceHint: normalizeText(params.setup.choiceHint) }
: {}),
...(typeof params.setup.assistantPriority === "number" &&
Number.isFinite(params.setup.assistantPriority)
? { assistantPriority: params.setup.assistantPriority }
: {}),
...(params.setup.assistantVisibility === "manual-only" ||
params.setup.assistantVisibility === "visible"
? { assistantVisibility: params.setup.assistantVisibility }
: {}),
...(normalizeText(params.setup.groupId)
? { groupId: normalizeText(params.setup.groupId) }
: {}),
...(normalizeText(params.setup.groupLabel)
? { groupLabel: normalizeText(params.setup.groupLabel) }
: {}),
...(normalizeText(params.setup.groupHint)
? { groupHint: normalizeText(params.setup.groupHint) }
: {}),
...(methodId && params.auth.some((method) => method.id === methodId) ? { methodId } : {}),
...(normalizeOnboardingScopes(params.setup.onboardingScopes)
? { onboardingScopes: normalizeOnboardingScopes(params.setup.onboardingScopes) }
: {}),
...(params.setup.modelAllowlist
? {
modelAllowlist: {
...(normalizeTextList(params.setup.modelAllowlist.allowedKeys)
? { allowedKeys: normalizeTextList(params.setup.modelAllowlist.allowedKeys) }
: {}),
...(normalizeTextList(params.setup.modelAllowlist.initialSelections)
? {
initialSelections: normalizeTextList(
params.setup.modelAllowlist.initialSelections,
),
}
: {}),
...(normalizeText(params.setup.modelAllowlist.message)
? { message: normalizeText(params.setup.modelAllowlist.message) }
: {}),
},
}
: {}),
};
const methodId = resolveWizardMethodId({
providerId: params.providerId,
pluginId: params.pluginId,
source: params.source,
auth: params.auth,
methodId: normalizeText(params.setup.methodId),
metadataKind: "setup",
pushDiagnostic: params.pushDiagnostic,
});
return buildNormalizedWizardSetup({
setup: params.setup,
methodId,
});
}
function normalizeProviderAuthMethods(params: {
@@ -181,14 +227,17 @@ function normalizeProviderAuthMethods(params: {
continue;
}
seenMethodIds.add(methodId);
const wizard = normalizeProviderWizardSetup({
providerId: params.providerId,
pluginId: params.pluginId,
source: params.source,
auth: [{ ...method, id: methodId }],
setup: method.wizard,
pushDiagnostic: params.pushDiagnostic,
});
const wizardSetup = method.wizard;
const wizard = wizardSetup
? normalizeProviderWizardSetup({
providerId: params.providerId,
pluginId: params.pluginId,
source: params.source,
auth: [{ ...method, id: methodId }],
setup: wizardSetup,
pushDiagnostic: params.pushDiagnostic,
})
: undefined;
normalized.push({
...method,
id: methodId,
@@ -214,9 +263,6 @@ function normalizeProviderWizard(params: {
}
const hasAuthMethods = params.auth.length > 0;
const hasMethod = (methodId: string | undefined) =>
Boolean(methodId && params.auth.some((method) => method.id === methodId));
const normalizeSetup = () => {
const setup = params.wizard?.setup;
if (!setup) {
@@ -247,21 +293,18 @@ function normalizeProviderWizard(params: {
});
return undefined;
}
const methodId = normalizeText(modelPicker.methodId);
if (methodId && !hasMethod(methodId)) {
pushProviderDiagnostic({
level: "warn",
return buildNormalizedModelPicker(
modelPicker,
resolveWizardMethodId({
providerId: params.providerId,
pluginId: params.pluginId,
source: params.source,
message: `provider "${params.providerId}" model-picker method "${methodId}" not found; falling back to available methods`,
auth: params.auth,
methodId: normalizeText(modelPicker.methodId),
metadataKind: "model-picker",
pushDiagnostic: params.pushDiagnostic,
});
}
return {
...(normalizeText(modelPicker.label) ? { label: normalizeText(modelPicker.label) } : {}),
...(normalizeText(modelPicker.hint) ? { hint: normalizeText(modelPicker.hint) } : {}),
...(methodId && hasMethod(methodId) ? { methodId } : {}),
};
}),
);
};
const setup = normalizeSetup();