import fs from "node:fs"; import path from "node:path"; import JSON5 from "json5"; import { MANIFEST_KEY } from "../compat/legacy-names.js"; import { matchBoundaryFileOpenFailure, openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { isRecord } from "../utils.js"; import type { PluginConfigUiHint, PluginKind } from "./types.js"; export const PLUGIN_MANIFEST_FILENAME = "openclaw.plugin.json"; export const PLUGIN_MANIFEST_FILENAMES = [PLUGIN_MANIFEST_FILENAME] as const; export type PluginManifestChannelConfig = { schema: Record; uiHints?: Record; label?: string; description?: string; preferOver?: string[]; }; export type PluginManifest = { id: string; configSchema: Record; enabledByDefault?: boolean; /** Legacy plugin ids that should normalize to this plugin id. */ legacyPluginIds?: string[]; /** Provider ids that should auto-enable this plugin when referenced in auth/config/models. */ autoEnableWhenConfiguredProviders?: string[]; kind?: PluginKind | PluginKind[]; channels?: string[]; providers?: string[]; /** Cheap startup activation lookup for plugin-owned CLI inference backends. */ cliBackends?: string[]; /** Cheap provider-auth env lookup without booting plugin runtime. */ providerAuthEnvVars?: Record; /** * Cheap onboarding/auth-choice metadata used by config validation, CLI help, * and non-runtime auth-choice routing before provider runtime loads. */ providerAuthChoices?: PluginManifestProviderAuthChoice[]; skills?: string[]; name?: string; description?: string; version?: string; uiHints?: Record; /** * Static capability ownership snapshot used for manifest-driven discovery, * compat wiring, and contract coverage without importing plugin runtime. */ contracts?: PluginManifestContracts; channelConfigs?: Record; }; export type PluginManifestContracts = { speechProviders?: string[]; mediaUnderstandingProviders?: string[]; imageGenerationProviders?: string[]; webFetchProviders?: string[]; webSearchProviders?: string[]; tools?: string[]; }; export type PluginManifestProviderAuthChoice = { /** Provider id owned by this manifest entry. */ provider: string; /** Provider auth method id that this choice should dispatch to. */ method: string; /** Stable auth-choice id used by onboarding and other CLI auth flows. */ choiceId: string; /** Optional user-facing choice label/hint for grouped onboarding UI. */ choiceLabel?: string; choiceHint?: string; /** Legacy choice ids that should point users at this replacement choice. */ deprecatedChoiceIds?: string[]; /** Optional grouping metadata for auth-choice pickers. */ groupId?: string; groupLabel?: string; groupHint?: string; /** Optional CLI flag metadata for one-flag auth flows such as API keys. */ optionKey?: string; cliFlag?: string; cliOption?: string; cliDescription?: string; /** * Interactive onboarding surfaces where this auth choice should appear. * Defaults to `["text-inference"]` when omitted. */ onboardingScopes?: PluginManifestOnboardingScope[]; }; export type PluginManifestOnboardingScope = "text-inference" | "image-generation"; export type PluginManifestLoadResult = | { ok: true; manifest: PluginManifest; manifestPath: string } | { ok: false; error: string; manifestPath: string }; function normalizeStringList(value: unknown): string[] { if (!Array.isArray(value)) { return []; } return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean); } function normalizeStringListRecord(value: unknown): Record | undefined { if (!isRecord(value)) { return undefined; } const normalized: Record = {}; for (const [key, rawValues] of Object.entries(value)) { const providerId = typeof key === "string" ? key.trim() : ""; if (!providerId) { continue; } const values = normalizeStringList(rawValues); if (values.length === 0) { continue; } normalized[providerId] = values; } return Object.keys(normalized).length > 0 ? normalized : undefined; } function normalizeManifestContracts(value: unknown): PluginManifestContracts | undefined { if (!isRecord(value)) { return undefined; } const speechProviders = normalizeStringList(value.speechProviders); const mediaUnderstandingProviders = normalizeStringList(value.mediaUnderstandingProviders); const imageGenerationProviders = normalizeStringList(value.imageGenerationProviders); const webFetchProviders = normalizeStringList(value.webFetchProviders); const webSearchProviders = normalizeStringList(value.webSearchProviders); const tools = normalizeStringList(value.tools); const contracts = { ...(speechProviders.length > 0 ? { speechProviders } : {}), ...(mediaUnderstandingProviders.length > 0 ? { mediaUnderstandingProviders } : {}), ...(imageGenerationProviders.length > 0 ? { imageGenerationProviders } : {}), ...(webFetchProviders.length > 0 ? { webFetchProviders } : {}), ...(webSearchProviders.length > 0 ? { webSearchProviders } : {}), ...(tools.length > 0 ? { tools } : {}), } satisfies PluginManifestContracts; return Object.keys(contracts).length > 0 ? contracts : undefined; } function normalizeProviderAuthChoices( value: unknown, ): PluginManifestProviderAuthChoice[] | undefined { if (!Array.isArray(value)) { return undefined; } const normalized: PluginManifestProviderAuthChoice[] = []; for (const entry of value) { if (!isRecord(entry)) { continue; } const provider = typeof entry.provider === "string" ? entry.provider.trim() : ""; const method = typeof entry.method === "string" ? entry.method.trim() : ""; const choiceId = typeof entry.choiceId === "string" ? entry.choiceId.trim() : ""; if (!provider || !method || !choiceId) { continue; } const choiceLabel = typeof entry.choiceLabel === "string" ? entry.choiceLabel.trim() : ""; const choiceHint = typeof entry.choiceHint === "string" ? entry.choiceHint.trim() : ""; const deprecatedChoiceIds = normalizeStringList(entry.deprecatedChoiceIds); const groupId = typeof entry.groupId === "string" ? entry.groupId.trim() : ""; const groupLabel = typeof entry.groupLabel === "string" ? entry.groupLabel.trim() : ""; const groupHint = typeof entry.groupHint === "string" ? entry.groupHint.trim() : ""; const optionKey = typeof entry.optionKey === "string" ? entry.optionKey.trim() : ""; const cliFlag = typeof entry.cliFlag === "string" ? entry.cliFlag.trim() : ""; const cliOption = typeof entry.cliOption === "string" ? entry.cliOption.trim() : ""; const cliDescription = typeof entry.cliDescription === "string" ? entry.cliDescription.trim() : ""; const onboardingScopes = normalizeStringList(entry.onboardingScopes).filter( (scope): scope is PluginManifestOnboardingScope => scope === "text-inference" || scope === "image-generation", ); normalized.push({ provider, method, choiceId, ...(choiceLabel ? { choiceLabel } : {}), ...(choiceHint ? { choiceHint } : {}), ...(deprecatedChoiceIds.length > 0 ? { deprecatedChoiceIds } : {}), ...(groupId ? { groupId } : {}), ...(groupLabel ? { groupLabel } : {}), ...(groupHint ? { groupHint } : {}), ...(optionKey ? { optionKey } : {}), ...(cliFlag ? { cliFlag } : {}), ...(cliOption ? { cliOption } : {}), ...(cliDescription ? { cliDescription } : {}), ...(onboardingScopes.length > 0 ? { onboardingScopes } : {}), }); } return normalized.length > 0 ? normalized : undefined; } function normalizeChannelConfigs( value: unknown, ): Record | undefined { if (!isRecord(value)) { return undefined; } const normalized: Record = {}; for (const [key, rawEntry] of Object.entries(value)) { const channelId = typeof key === "string" ? key.trim() : ""; if (!channelId || !isRecord(rawEntry)) { continue; } const schema = isRecord(rawEntry.schema) ? rawEntry.schema : null; if (!schema) { continue; } const uiHints = isRecord(rawEntry.uiHints) ? (rawEntry.uiHints as Record) : undefined; const label = typeof rawEntry.label === "string" ? rawEntry.label.trim() : ""; const description = typeof rawEntry.description === "string" ? rawEntry.description.trim() : ""; const preferOver = normalizeStringList(rawEntry.preferOver); normalized[channelId] = { schema, ...(uiHints ? { uiHints } : {}), ...(label ? { label } : {}), ...(description ? { description } : {}), ...(preferOver.length > 0 ? { preferOver } : {}), }; } return Object.keys(normalized).length > 0 ? normalized : undefined; } export function resolvePluginManifestPath(rootDir: string): string { for (const filename of PLUGIN_MANIFEST_FILENAMES) { const candidate = path.join(rootDir, filename); if (fs.existsSync(candidate)) { return candidate; } } return path.join(rootDir, PLUGIN_MANIFEST_FILENAME); } function parsePluginKind(raw: unknown): PluginKind | PluginKind[] | undefined { if (typeof raw === "string") { return raw as PluginKind; } if (Array.isArray(raw) && raw.length > 0 && raw.every((k) => typeof k === "string")) { return raw.length === 1 ? (raw[0] as PluginKind) : (raw as PluginKind[]); } return undefined; } export function loadPluginManifest( rootDir: string, rejectHardlinks = true, ): PluginManifestLoadResult { const manifestPath = resolvePluginManifestPath(rootDir); const opened = openBoundaryFileSync({ absolutePath: manifestPath, rootPath: rootDir, boundaryLabel: "plugin root", rejectHardlinks, }); if (!opened.ok) { return matchBoundaryFileOpenFailure(opened, { path: () => ({ ok: false, error: `plugin manifest not found: ${manifestPath}`, manifestPath, }), fallback: (failure) => ({ ok: false, error: `unsafe plugin manifest path: ${manifestPath} (${failure.reason})`, manifestPath, }), }); } let raw: unknown; try { raw = JSON5.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown; } catch (err) { return { ok: false, error: `failed to parse plugin manifest: ${String(err)}`, manifestPath, }; } finally { fs.closeSync(opened.fd); } if (!isRecord(raw)) { return { ok: false, error: "plugin manifest must be an object", manifestPath }; } const id = typeof raw.id === "string" ? raw.id.trim() : ""; if (!id) { return { ok: false, error: "plugin manifest requires id", manifestPath }; } const configSchema = isRecord(raw.configSchema) ? raw.configSchema : null; if (!configSchema) { return { ok: false, error: "plugin manifest requires configSchema", manifestPath }; } const kind = parsePluginKind(raw.kind); const enabledByDefault = raw.enabledByDefault === true; const legacyPluginIds = normalizeStringList(raw.legacyPluginIds); const autoEnableWhenConfiguredProviders = normalizeStringList( raw.autoEnableWhenConfiguredProviders, ); const name = typeof raw.name === "string" ? raw.name.trim() : undefined; const description = typeof raw.description === "string" ? raw.description.trim() : undefined; const version = typeof raw.version === "string" ? raw.version.trim() : undefined; const channels = normalizeStringList(raw.channels); const providers = normalizeStringList(raw.providers); const cliBackends = normalizeStringList(raw.cliBackends); const providerAuthEnvVars = normalizeStringListRecord(raw.providerAuthEnvVars); const providerAuthChoices = normalizeProviderAuthChoices(raw.providerAuthChoices); const skills = normalizeStringList(raw.skills); const contracts = normalizeManifestContracts(raw.contracts); const channelConfigs = normalizeChannelConfigs(raw.channelConfigs); let uiHints: Record | undefined; if (isRecord(raw.uiHints)) { uiHints = raw.uiHints as Record; } return { ok: true, manifest: { id, configSchema, ...(enabledByDefault ? { enabledByDefault } : {}), ...(legacyPluginIds.length > 0 ? { legacyPluginIds } : {}), ...(autoEnableWhenConfiguredProviders.length > 0 ? { autoEnableWhenConfiguredProviders } : {}), kind, channels, providers, cliBackends, providerAuthEnvVars, providerAuthChoices, skills, name, description, version, uiHints, contracts, channelConfigs, }, manifestPath, }; } // package.json "openclaw" metadata (used for setup/catalog) export type PluginPackageChannel = { id?: string; label?: string; selectionLabel?: string; detailLabel?: string; docsPath?: string; docsLabel?: string; blurb?: string; order?: number; aliases?: readonly string[]; preferOver?: readonly string[]; systemImage?: string; selectionDocsPrefix?: string; selectionDocsOmitLabel?: boolean; selectionExtras?: readonly string[]; markdownCapable?: boolean; showConfigured?: boolean; quickstartAllowFrom?: boolean; forceAccountBinding?: boolean; preferSessionLookupForAnnounceTarget?: boolean; }; export type PluginPackageInstall = { npmSpec?: string; localPath?: string; defaultChoice?: "npm" | "local"; minHostVersion?: string; }; export type OpenClawPackageStartup = { /** * Opt-in for channel plugins whose `setupEntry` fully covers the gateway * startup surface needed before the server starts listening. */ deferConfiguredChannelFullLoadUntilAfterListen?: boolean; }; export type OpenClawPackageManifest = { extensions?: string[]; setupEntry?: string; channel?: PluginPackageChannel; install?: PluginPackageInstall; startup?: OpenClawPackageStartup; }; export const DEFAULT_PLUGIN_ENTRY_CANDIDATES = [ "index.ts", "index.js", "index.mjs", "index.cjs", ] as const; export type PackageExtensionResolution = | { status: "ok"; entries: string[] } | { status: "missing"; entries: [] } | { status: "empty"; entries: [] }; export type ManifestKey = typeof MANIFEST_KEY; export type PackageManifest = { name?: string; version?: string; description?: string; } & Partial>; export function getPackageManifestMetadata( manifest: PackageManifest | undefined, ): OpenClawPackageManifest | undefined { if (!manifest) { return undefined; } return manifest[MANIFEST_KEY]; } export function resolvePackageExtensionEntries( manifest: PackageManifest | undefined, ): PackageExtensionResolution { const raw = getPackageManifestMetadata(manifest)?.extensions; if (!Array.isArray(raw)) { return { status: "missing", entries: [] }; } const entries = raw .map((entry) => (typeof entry === "string" ? entry.trim() : "")) .filter(Boolean); if (entries.length === 0) { return { status: "empty", entries: [] }; } return { status: "ok", entries }; }