mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 09:41:11 +00:00
refactor: dedupe plugin config helpers
This commit is contained in:
191
src/plugins/config-normalization-shared.ts
Normal file
191
src/plugins/config-normalization-shared.ts
Normal 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;
|
||||
}
|
||||
51
src/plugins/config-policy.test.ts
Normal file
51
src/plugins/config-policy.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user