From 7a7728db131d9398275bd2438d71fd159d7461d7 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 26 Apr 2026 07:33:58 +0100 Subject: [PATCH] fix: keep native command auto defaults cold --- docs/plugins/manifest.md | 12 ++++ extensions/discord/package.json | 4 ++ extensions/slack/package.json | 4 ++ extensions/telegram/package.json | 4 ++ src/channels/plugins/read-only.ts | 70 +++++++++++++++++++ src/config/commands.test.ts | 27 +++++++ src/config/commands.ts | 39 ++++++++--- .../bundled-channel-config-metadata.ts | 3 + src/plugins/command-registry-state.ts | 8 ++- src/plugins/installed-plugin-index.test.ts | 8 +++ src/plugins/installed-plugin-index.ts | 18 ++++- .../manifest-registry-installed.test.ts | 53 ++++++++++++++ src/plugins/manifest-registry-installed.ts | 46 +++++++++++- src/plugins/manifest-registry.ts | 32 +++++++++ src/plugins/manifest.ts | 29 ++++++++ src/security/audit-plugins-trust.ts | 2 + 16 files changed, 345 insertions(+), 14 deletions(-) diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index f1703ac2107..dfb9c1add81 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -524,6 +524,12 @@ Non-bundled plugins that declare `channels[]` should also declare matching cold-path config schema, setup, and Control UI surfaces cannot know the channel-owned option shape until plugin runtime executes. +`channelConfigs..commands.nativeCommandsAutoEnabled` and +`nativeSkillsAutoEnabled` can declare static `auto` defaults for command config +checks that run before channel runtime loads. Bundled channels can also publish +the same defaults through `package.json#openclaw.channel.commands` alongside +their other package-owned channel catalog metadata. + ```json { "channelConfigs": { @@ -543,6 +549,10 @@ channel-owned option shape until plugin runtime executes. }, "label": "Matrix", "description": "Matrix homeserver connection", + "commands": { + "nativeCommandsAutoEnabled": true, + "nativeSkillsAutoEnabled": true + }, "preferOver": ["matrix-legacy"] } } @@ -557,6 +567,7 @@ Each channel entry can include: | `uiHints` | `Record` | Optional UI labels/placeholders/sensitive hints for that channel config section. | | `label` | `string` | Channel label merged into picker and inspect surfaces when runtime metadata is not ready. | | `description` | `string` | Short channel description for inspect and catalog surfaces. | +| `commands` | `object` | Static native command and native skill auto-defaults for pre-runtime config checks. | | `preferOver` | `string[]` | Legacy or lower-priority plugin ids this channel should outrank in selection surfaces. | ### Replacing another channel plugin @@ -792,6 +803,7 @@ Important examples: | `openclaw.setupEntry` | Lightweight setup-only entrypoint used during onboarding, deferred channel startup, and read-only channel status/SecretRef discovery. Must stay inside the plugin package directory. | | `openclaw.runtimeSetupEntry` | Declares the built JavaScript setup entrypoint for installed packages. Must stay inside the plugin package directory. | | `openclaw.channel` | Cheap channel catalog metadata like labels, docs paths, aliases, and selection copy. | +| `openclaw.channel.commands` | Static native command and native skill auto-default metadata used by config, audit, and command-list surfaces before channel runtime loads. | | `openclaw.channel.configuredState` | Lightweight configured-state checker metadata that can answer "does env-only setup already exist?" without loading the full channel runtime. | | `openclaw.channel.persistedAuthState` | Lightweight persisted-auth checker metadata that can answer "is anything already signed in?" without loading the full channel runtime. | | `openclaw.install.npmSpec` / `openclaw.install.localPath` | Install/update hints for bundled and externally published plugins. | diff --git a/extensions/discord/package.json b/extensions/discord/package.json index 90936b1964f..c966766cefa 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -40,6 +40,10 @@ "blurb": "very well supported right now.", "systemImage": "bubble.left.and.bubble.right", "markdownCapable": true, + "commands": { + "nativeCommandsAutoEnabled": true, + "nativeSkillsAutoEnabled": true + }, "configuredState": { "specifier": "./configured-state", "exportName": "hasDiscordConfiguredState" diff --git a/extensions/slack/package.json b/extensions/slack/package.json index 4cfba8cedcd..d4069fab67b 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -28,6 +28,10 @@ "blurb": "supported (Socket Mode).", "systemImage": "number", "markdownCapable": true, + "commands": { + "nativeCommandsAutoEnabled": false, + "nativeSkillsAutoEnabled": false + }, "configuredState": { "specifier": "./configured-state", "exportName": "hasSlackConfiguredState" diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index ff534031f18..a988bdf7924 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -38,6 +38,10 @@ "https://openclaw.ai" ], "markdownCapable": true, + "commands": { + "nativeCommandsAutoEnabled": true, + "nativeSkillsAutoEnabled": true + }, "configuredState": { "specifier": "./configured-state", "exportName": "hasTelegramConfiguredState" diff --git a/src/channels/plugins/read-only.ts b/src/channels/plugins/read-only.ts index 0342ca5df31..35c8375d636 100644 --- a/src/channels/plugins/read-only.ts +++ b/src/channels/plugins/read-only.ts @@ -15,6 +15,7 @@ import type { loadOpenClawPlugins as loadOpenClawPluginsType } from "../../plugi import type { PluginManifestRecord } from "../../plugins/manifest-registry.js"; import { loadPluginManifestRegistryForPluginRegistry } from "../../plugins/plugin-registry.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; +import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { sanitizeForLog } from "../../terminal/ansi.js"; import { getBundledChannelSetupPlugin } from "./bundled.js"; import { listChannelPlugins } from "./registry.js"; @@ -72,6 +73,10 @@ type ReadOnlyChannelPluginResolution = { missingConfiguredChannelIds: string[]; }; type ManifestChannelConfigRecord = NonNullable[string]; +type ChannelCommandDefaults = Pick< + NonNullable, + "nativeCommandsAutoEnabled" | "nativeSkillsAutoEnabled" +>; function addChannelPlugins( byId: Map, @@ -125,6 +130,26 @@ function normalizeManifestText(value: string | undefined, fallback: string): str return sanitizeForLog(value?.trim() || fallback).trim(); } +function normalizeChannelCommandDefaults( + value: ChannelCommandDefaults | undefined, +): ChannelCommandDefaults | undefined { + if (!value) { + return undefined; + } + const nativeCommandsAutoEnabled = + typeof value.nativeCommandsAutoEnabled === "boolean" + ? value.nativeCommandsAutoEnabled + : undefined; + const nativeSkillsAutoEnabled = + typeof value.nativeSkillsAutoEnabled === "boolean" ? value.nativeSkillsAutoEnabled : undefined; + return nativeCommandsAutoEnabled !== undefined || nativeSkillsAutoEnabled !== undefined + ? { + ...(nativeCommandsAutoEnabled !== undefined ? { nativeCommandsAutoEnabled } : {}), + ...(nativeSkillsAutoEnabled !== undefined ? { nativeSkillsAutoEnabled } : {}), + } + : undefined; +} + function rebindChannelConfig( cfg: OpenClawConfig, sourceChannelId: string, @@ -258,6 +283,9 @@ function buildManifestChannelPlugin(params: { channelConfig?.description ?? catalogMeta?.blurb, params.record.description || "", ); + const commands = normalizeChannelCommandDefaults( + channelConfig?.commands ?? catalogMeta?.commands, + ); return { id: params.channelId, meta: { @@ -273,6 +301,7 @@ function buildManifestChannelPlugin(params: { : {}), }, capabilities: { chatTypes: ["direct"] }, + ...(commands ? { commands } : {}), ...(channelConfig ? { configSchema: { @@ -318,6 +347,47 @@ function canUseManifestChannelPlugin(record: PluginManifestRecord, channelId: st return record.channelCatalogMeta?.id === channelId; } +export function resolveReadOnlyChannelCommandDefaults( + channelId: string, + options: { + env?: NodeJS.ProcessEnv; + stateDir?: string; + workspaceDir?: string; + } = {}, +): ChannelCommandDefaults | undefined { + const normalizedChannelId = normalizeOptionalString(channelId) ?? ""; + if (!normalizedChannelId || !isSafeManifestChannelId(normalizedChannelId)) { + return undefined; + } + const registry = loadPluginManifestRegistryForPluginRegistry({ + stateDir: options.stateDir, + workspaceDir: options.workspaceDir, + env: options.env ?? process.env, + includeDisabled: true, + }); + for (const record of registry.plugins) { + if (!record.channels.includes(normalizedChannelId)) { + continue; + } + const channelConfigValue = record.channelConfigs + ? readOwnRecordValue(record.channelConfigs as Record, normalizedChannelId) + : undefined; + const channelConfig = + channelConfigValue && + typeof channelConfigValue === "object" && + !Array.isArray(channelConfigValue) + ? (channelConfigValue as ManifestChannelConfigRecord) + : undefined; + const commands = normalizeChannelCommandDefaults( + channelConfig?.commands ?? record.channelCatalogMeta?.commands, + ); + if (commands) { + return commands; + } + } + return undefined; +} + function rebindChannelPluginConfig( config: ChannelPlugin["config"], sourceChannelId: string, diff --git a/src/config/commands.test.ts b/src/config/commands.test.ts index ba3d5c8a6b2..f6650cacd2d 100644 --- a/src/config/commands.test.ts +++ b/src/config/commands.test.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { beforeEach, describe, expect, it } from "vitest"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; @@ -100,6 +101,32 @@ describe("resolveNativeSkillsEnabled", () => { ).toBe(false); }); + it("uses package channel metadata for bundled auto defaults before runtime loads", () => { + setActivePluginRegistry(createTestRegistry([])); + const env = { + ...process.env, + OPENCLAW_BUNDLED_PLUGINS_DIR: path.resolve("extensions"), + OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY: "1", + OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", + OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", + }; + + expect( + resolveNativeSkillsEnabled({ + providerId: "discord", + globalSetting: "auto", + env, + }), + ).toBe(true); + expect( + resolveNativeCommandsEnabled({ + providerId: "slack", + globalSetting: "auto", + env, + }), + ).toBe(false); + }); + it("honors explicit provider settings", () => { expect( resolveNativeSkillsEnabled({ diff --git a/src/config/commands.ts b/src/config/commands.ts index 69c2349d268..e91ab650be6 100644 --- a/src/config/commands.ts +++ b/src/config/commands.ts @@ -1,30 +1,43 @@ -import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js"; +import { getLoadedChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js"; +import { resolveReadOnlyChannelCommandDefaults } from "../channels/plugins/read-only.js"; import type { ChannelId } from "../channels/plugins/types.public.js"; +import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import type { NativeCommandsSetting } from "./types.js"; export { isCommandFlagEnabled, isRestartEnabled, type CommandFlagKey } from "./commands.flags.js"; function resolveAutoDefault( providerId: ChannelId | undefined, kind: "native" | "nativeSkills", + options?: { + env?: NodeJS.ProcessEnv; + stateDir?: string; + workspaceDir?: string; + autoDefault?: boolean; + }, ): boolean { - const id = normalizeChannelId(providerId); + const id = normalizeChannelId(providerId) ?? normalizeOptionalLowercaseString(providerId); if (!id) { return false; } - const plugin = getChannelPlugin(id); - if (!plugin) { - return false; + if (typeof options?.autoDefault === "boolean") { + return options.autoDefault; } + const commandDefaults = + getLoadedChannelPlugin(id)?.commands ?? resolveReadOnlyChannelCommandDefaults(id, options); if (kind === "native") { - return plugin.commands?.nativeCommandsAutoEnabled === true; + return commandDefaults?.nativeCommandsAutoEnabled === true; } - return plugin.commands?.nativeSkillsAutoEnabled === true; + return commandDefaults?.nativeSkillsAutoEnabled === true; } export function resolveNativeSkillsEnabled(params: { providerId: ChannelId; providerSetting?: NativeCommandsSetting; globalSetting?: NativeCommandsSetting; + env?: NodeJS.ProcessEnv; + stateDir?: string; + workspaceDir?: string; + autoDefault?: boolean; }): boolean { return resolveNativeCommandSetting({ ...params, kind: "nativeSkills" }); } @@ -33,6 +46,10 @@ export function resolveNativeCommandsEnabled(params: { providerId: ChannelId; providerSetting?: NativeCommandsSetting; globalSetting?: NativeCommandsSetting; + env?: NodeJS.ProcessEnv; + stateDir?: string; + workspaceDir?: string; + autoDefault?: boolean; }): boolean { return resolveNativeCommandSetting({ ...params, kind: "native" }); } @@ -42,8 +59,12 @@ function resolveNativeCommandSetting(params: { providerSetting?: NativeCommandsSetting; globalSetting?: NativeCommandsSetting; kind?: "native" | "nativeSkills"; + env?: NodeJS.ProcessEnv; + stateDir?: string; + workspaceDir?: string; + autoDefault?: boolean; }): boolean { - const { providerId, providerSetting, globalSetting, kind = "native" } = params; + const { providerId, providerSetting, globalSetting, kind = "native", ...options } = params; const setting = providerSetting === undefined ? globalSetting : providerSetting; if (setting === true) { return true; @@ -51,7 +72,7 @@ function resolveNativeCommandSetting(params: { if (setting === false) { return false; } - return resolveAutoDefault(providerId, kind); + return resolveAutoDefault(providerId, kind, options); } export function isNativeCommandsExplicitlyDisabled(params: { diff --git a/src/plugins/bundled-channel-config-metadata.ts b/src/plugins/bundled-channel-config-metadata.ts index a74e3ac32a6..50ee9942884 100644 --- a/src/plugins/bundled-channel-config-metadata.ts +++ b/src/plugins/bundled-channel-config-metadata.ts @@ -176,6 +176,9 @@ export function collectBundledChannelConfigs(params: { : preferOver.length > 0 ? { preferOver } : {}), + ...((existing?.commands ?? channelMeta?.commands) + ? { commands: existing?.commands ?? channelMeta?.commands } + : {}), }; } diff --git a/src/plugins/command-registry-state.ts b/src/plugins/command-registry-state.ts index f64cdacbfa4..5cf85408b5b 100644 --- a/src/plugins/command-registry-state.ts +++ b/src/plugins/command-registry-state.ts @@ -1,4 +1,5 @@ -import { getChannelPlugin } from "../channels/plugins/index.js"; +import { getLoadedChannelPlugin } from "../channels/plugins/index.js"; +import { resolveReadOnlyChannelCommandDefaults } from "../channels/plugins/read-only.js"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import type { OpenClawPluginCommandDefinition } from "./types.js"; @@ -90,7 +91,10 @@ export function getPluginCommandSpecs(provider?: string): Array<{ const providerName = normalizeOptionalLowercaseString(provider); if ( providerName && - getChannelPlugin(providerName)?.commands?.nativeCommandsAutoEnabled !== true + ( + getLoadedChannelPlugin(providerName)?.commands ?? + resolveReadOnlyChannelCommandDefaults(providerName) + )?.nativeCommandsAutoEnabled !== true ) { return []; } diff --git a/src/plugins/installed-plugin-index.test.ts b/src/plugins/installed-plugin-index.test.ts index d8a13557979..14d06879758 100644 --- a/src/plugins/installed-plugin-index.test.ts +++ b/src/plugins/installed-plugin-index.test.ts @@ -143,6 +143,10 @@ function createRichPluginFixture(params: { packageVersion?: string } = {}) { label: "Demo", blurb: "Demo channel", preferOver: ["legacy-demo"], + commands: { + nativeCommandsAutoEnabled: true, + nativeSkillsAutoEnabled: false, + }, }, install: { npmSpec: "@vendor/demo-plugin@1.2.3", @@ -195,6 +199,10 @@ describe("installed plugin index", () => { label: "Demo", blurb: "Demo channel", preferOver: ["legacy-demo"], + commands: { + nativeCommandsAutoEnabled: true, + nativeSkillsAutoEnabled: false, + }, }, compat: [ "activation-channel-hint", diff --git a/src/plugins/installed-plugin-index.ts b/src/plugins/installed-plugin-index.ts index 5106b30a2e4..ca2e63af6f1 100644 --- a/src/plugins/installed-plugin-index.ts +++ b/src/plugins/installed-plugin-index.ts @@ -69,7 +69,7 @@ export type InstalledPluginInstallRecordInfo = Pick< export type InstalledPluginPackageChannelInfo = Pick< PluginPackageChannel, - "id" | "label" | "blurb" | "preferOver" + "id" | "label" | "blurb" | "preferOver" | "commands" >; export type InstalledPluginIndexRecord = { @@ -317,11 +317,27 @@ function normalizePackageChannel( const label = normalizeStringField(channel?.label); const blurb = normalizeStringField(channel?.blurb); const preferOver = normalizeStringListField(channel?.preferOver); + const commands = + channel?.commands && + typeof channel.commands === "object" && + !Array.isArray(channel.commands) && + (typeof channel.commands.nativeCommandsAutoEnabled === "boolean" || + typeof channel.commands.nativeSkillsAutoEnabled === "boolean") + ? { + ...(typeof channel.commands.nativeCommandsAutoEnabled === "boolean" + ? { nativeCommandsAutoEnabled: channel.commands.nativeCommandsAutoEnabled } + : {}), + ...(typeof channel.commands.nativeSkillsAutoEnabled === "boolean" + ? { nativeSkillsAutoEnabled: channel.commands.nativeSkillsAutoEnabled } + : {}), + } + : undefined; return { id, ...(label ? { label } : {}), ...(blurb ? { blurb } : {}), ...(preferOver ? { preferOver } : {}), + ...(commands ? { commands } : {}), }; } diff --git a/src/plugins/manifest-registry-installed.test.ts b/src/plugins/manifest-registry-installed.test.ts index 3285ccd3384..5ffaad9b772 100644 --- a/src/plugins/manifest-registry-installed.test.ts +++ b/src/plugins/manifest-registry-installed.test.ts @@ -142,6 +142,59 @@ describe("loadPluginManifestRegistryForInstalledIndex", () => { ]); }); + it("hydrates package channel command metadata while reconstructing from an older index", () => { + const rootDir = makeTempDir(); + writePlugin(rootDir, "installed", "installed-"); + fs.writeFileSync( + path.join(rootDir, "package.json"), + JSON.stringify({ + openclaw: { + channel: { + id: "installed", + label: "Installed", + commands: { + nativeCommandsAutoEnabled: true, + nativeSkillsAutoEnabled: false, + }, + }, + }, + }), + "utf8", + ); + + const index = createIndex(rootDir); + const registry = loadPluginManifestRegistryForInstalledIndex({ + index: { + ...index, + plugins: [ + { + ...index.plugins[0], + packageChannel: { + id: "installed", + label: "Installed", + }, + packageJson: { + path: "package.json", + hash: "old-index-hash", + }, + }, + ], + }, + env: { + OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", + OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", + OPENCLAW_VERSION: "2026.4.25", + VITEST: "true", + }, + includeDisabled: true, + }); + + expect(registry.plugins[0]?.channelCatalogMeta?.commands).toEqual({ + nativeCommandsAutoEnabled: true, + nativeSkillsAutoEnabled: false, + }); + }); + it("round-trips bundle metadata through the persisted index before reconstruction", async () => { const stateDir = makeTempDir(); const rootDir = makeTempDir(); diff --git a/src/plugins/manifest-registry-installed.ts b/src/plugins/manifest-registry-installed.ts index ba825739609..747cf048727 100644 --- a/src/plugins/manifest-registry-installed.ts +++ b/src/plugins/manifest-registry-installed.ts @@ -5,7 +5,12 @@ import type { PluginCandidate } from "./discovery.js"; import type { InstalledPluginIndex, InstalledPluginIndexRecord } from "./installed-plugin-index.js"; import { extractPluginInstallRecordsFromInstalledPluginIndex } from "./installed-plugin-index.js"; import { loadPluginManifestRegistry, type PluginManifestRegistry } from "./manifest-registry.js"; -import { DEFAULT_PLUGIN_ENTRY_CANDIDATES } from "./manifest.js"; +import { + DEFAULT_PLUGIN_ENTRY_CANDIDATES, + getPackageManifestMetadata, + type OpenClawPackageManifest, + type PackageManifest, +} from "./manifest.js"; function resolveInstalledPluginRootDir(record: InstalledPluginIndexRecord): string { return record.rootDir || path.dirname(record.manifestPath || process.cwd()); @@ -22,8 +27,45 @@ function resolveFallbackPluginSource(record: InstalledPluginIndexRecord): string return path.join(rootDir, DEFAULT_PLUGIN_ENTRY_CANDIDATES[0]); } +function resolveInstalledPackageManifest( + record: InstalledPluginIndexRecord, +): OpenClawPackageManifest | undefined { + if (!record.packageChannel) { + return undefined; + } + if (record.packageChannel.commands) { + return { channel: record.packageChannel }; + } + const rootDir = resolveInstalledPluginRootDir(record); + const packageJsonPath = record.packageJson?.path + ? path.resolve(rootDir, record.packageJson.path) + : undefined; + if (!packageJsonPath) { + return { channel: record.packageChannel }; + } + const relative = path.relative(rootDir, packageJsonPath); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + return { channel: record.packageChannel }; + } + try { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as PackageManifest; + const packageManifest = getPackageManifestMetadata(packageJson); + return { + channel: { + ...record.packageChannel, + ...(packageManifest?.channel?.commands + ? { commands: packageManifest.channel.commands } + : {}), + }, + }; + } catch { + return { channel: record.packageChannel }; + } +} + function toPluginCandidate(record: InstalledPluginIndexRecord): PluginCandidate { const rootDir = resolveInstalledPluginRootDir(record); + const packageManifest = resolveInstalledPackageManifest(record); return { idHint: record.pluginId, source: record.source ?? resolveFallbackPluginSource(record), @@ -34,7 +76,7 @@ function toPluginCandidate(record: InstalledPluginIndexRecord): PluginCandidate ...(record.bundleFormat ? { bundleFormat: record.bundleFormat } : {}), ...(record.packageName ? { packageName: record.packageName } : {}), ...(record.packageVersion ? { packageVersion: record.packageVersion } : {}), - ...(record.packageChannel ? { packageManifest: { channel: record.packageChannel } } : {}), + ...(packageManifest ? { packageManifest } : {}), packageDir: rootDir, }; } diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index cf8210bfd5a..5e20d3039f1 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -32,6 +32,7 @@ import { type PluginManifestActivation, type PluginManifestConfigContracts, type PluginManifest, + type PluginManifestChannelCommandDefaults, type PluginManifestChannelConfig, type PluginManifestContracts, type PluginManifestMediaUnderstandingProviderMetadata, @@ -148,6 +149,7 @@ export type PluginManifestRecord = { label?: string; blurb?: string; preferOver?: readonly string[]; + commands?: PluginManifestChannelCommandDefaults; }; }; @@ -220,6 +222,29 @@ function normalizePreferredPluginIds(raw: unknown): string[] | undefined { return normalizeOptionalTrimmedStringList(raw); } +function normalizePackageChannelCommands( + commands: unknown, +): PluginManifestChannelCommandDefaults | undefined { + if (!commands || typeof commands !== "object" || Array.isArray(commands)) { + return undefined; + } + const record = commands as Record; + const nativeCommandsAutoEnabled = + typeof record.nativeCommandsAutoEnabled === "boolean" + ? record.nativeCommandsAutoEnabled + : undefined; + const nativeSkillsAutoEnabled = + typeof record.nativeSkillsAutoEnabled === "boolean" + ? record.nativeSkillsAutoEnabled + : undefined; + return nativeCommandsAutoEnabled !== undefined || nativeSkillsAutoEnabled !== undefined + ? { + ...(nativeCommandsAutoEnabled !== undefined ? { nativeCommandsAutoEnabled } : {}), + ...(nativeSkillsAutoEnabled !== undefined ? { nativeSkillsAutoEnabled } : {}), + } + : undefined; +} + function mergePackageChannelMetaIntoChannelConfigs(params: { channelConfigs?: Record; packageChannel?: OpenClawPackageManifest["channel"]; @@ -243,6 +268,8 @@ function mergePackageChannelMetaIntoChannelConfigs(params: { existing.description ?? normalizeOptionalString(params.packageChannel?.blurb) ?? ""; const preferOver = existing.preferOver ?? normalizePreferredPluginIds(params.packageChannel?.preferOver); + const commands = + existing.commands ?? normalizePackageChannelCommands(params.packageChannel?.commands); const merged: Record = Object.create(null); for (const [key, value] of Object.entries(params.channelConfigs)) { @@ -255,6 +282,7 @@ function mergePackageChannelMetaIntoChannelConfigs(params: { ...(label ? { label } : {}), ...(description ? { description } : {}), ...(preferOver?.length ? { preferOver } : {}), + ...(commands ? { commands } : {}), }; return merged; } @@ -270,6 +298,9 @@ function buildRecord(params: { channelConfigs: params.manifest.channelConfigs, packageChannel: params.candidate.packageManifest?.channel, }); + const packageChannelCommands = normalizePackageChannelCommands( + params.candidate.packageManifest?.channel?.commands, + ); return { id: params.manifest.id, name: normalizeOptionalString(params.manifest.name) ?? params.candidate.packageName, @@ -335,6 +366,7 @@ function buildRecord(params: { ...(params.candidate.packageManifest.channel.preferOver ? { preferOver: params.candidate.packageManifest.channel.preferOver } : {}), + ...(packageChannelCommands ? { commands: packageChannelCommands } : {}), }, } : {}), diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index b9663b1ff09..52be7ccb505 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -40,6 +40,12 @@ export type PluginManifestChannelConfig = { label?: string; description?: string; preferOver?: string[]; + commands?: PluginManifestChannelCommandDefaults; +}; + +export type PluginManifestChannelCommandDefaults = { + nativeCommandsAutoEnabled?: boolean; + nativeSkillsAutoEnabled?: boolean; }; export type PluginManifestModelSupport = { @@ -820,6 +826,7 @@ function normalizeChannelConfigs( const label = normalizeOptionalString(rawEntry.label) ?? ""; const description = normalizeOptionalString(rawEntry.description) ?? ""; const preferOver = normalizeTrimmedStringList(rawEntry.preferOver); + const commandDefaults = normalizeManifestChannelCommandDefaults(rawEntry.commands); normalized[channelId] = { schema, ...(uiHints ? { uiHints } : {}), @@ -827,11 +834,32 @@ function normalizeChannelConfigs( ...(label ? { label } : {}), ...(description ? { description } : {}), ...(preferOver.length > 0 ? { preferOver } : {}), + ...(commandDefaults ? { commands: commandDefaults } : {}), }; } return Object.keys(normalized).length > 0 ? normalized : undefined; } +function normalizeManifestChannelCommandDefaults( + value: unknown, +): PluginManifestChannelCommandDefaults | undefined { + if (!isRecord(value)) { + return undefined; + } + const nativeCommandsAutoEnabled = + typeof value.nativeCommandsAutoEnabled === "boolean" + ? value.nativeCommandsAutoEnabled + : undefined; + const nativeSkillsAutoEnabled = + typeof value.nativeSkillsAutoEnabled === "boolean" ? value.nativeSkillsAutoEnabled : undefined; + return nativeCommandsAutoEnabled !== undefined || nativeSkillsAutoEnabled !== undefined + ? { + ...(nativeCommandsAutoEnabled !== undefined ? { nativeCommandsAutoEnabled } : {}), + ...(nativeSkillsAutoEnabled !== undefined ? { nativeSkillsAutoEnabled } : {}), + } + : undefined; +} + export function resolvePluginManifestPath(rootDir: string): string { for (const filename of PLUGIN_MANIFEST_FILENAMES) { const candidate = path.join(rootDir, filename); @@ -1012,6 +1040,7 @@ export type PluginPackageChannel = { quickstartAllowFrom?: boolean; forceAccountBinding?: boolean; preferSessionLookupForAnnounceTarget?: boolean; + commands?: PluginManifestChannelCommandDefaults; configuredState?: { specifier?: string; exportName?: string; diff --git a/src/security/audit-plugins-trust.ts b/src/security/audit-plugins-trust.ts index 83130f4ac6d..c52d88aa175 100644 --- a/src/security/audit-plugins-trust.ts +++ b/src/security/audit-plugins-trust.ts @@ -342,6 +342,8 @@ export async function collectPluginsTrustFindings(params: { | boolean | undefined, globalSetting: params.cfg.commands?.nativeSkills, + stateDir: params.stateDir, + autoDefault: plugin.commands?.nativeSkillsAutoEnabled === true, }); }), )