diff --git a/CHANGELOG.md b/CHANGELOG.md index 1de7e95411a..6edf678db5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,8 @@ Docs: https://docs.openclaw.ai ### Fixes +- Plugins/CLI: preserve manifest name, description, format, and source metadata in cold `openclaw plugins list` output without importing plugin runtime. Thanks @shakkernerd. +- Security/audit: read channel exposure and plugin allowlist ownership from read-only plugin index metadata so cold audits do not depend on loaded channel runtime. Thanks @shakkernerd. - Agents/groups: treat clean empty assistant stops as silent `NO_REPLY` only for always-on groups where silent replies are allowed, while keeping direct and mention-gated sessions on the incomplete-turn retry path. Thanks @MagnaAI. - macOS/Node: keep native remote app nodes from advertising `browser.proxy`, start browser-capable CLI node services through the restored diff --git a/src/channels/plugins/read-only.ts b/src/channels/plugins/read-only.ts index 904c9a7b67c..0342ca5df31 100644 --- a/src/channels/plugins/read-only.ts +++ b/src/channels/plugins/read-only.ts @@ -58,6 +58,7 @@ function loadPluginLoaderModule(): PluginLoaderModule { type ReadOnlyChannelPluginOptions = { env?: NodeJS.ProcessEnv; + stateDir?: string; workspaceDir?: string; activationSourceConfig?: OpenClawConfig; includePersistedAuthState?: boolean; @@ -607,6 +608,7 @@ export function resolveReadOnlyChannelPluginsForConfig( const workspaceDir = resolveReadOnlyWorkspaceDir(cfg, options); const manifestRecords = loadPluginManifestRegistryForPluginRegistry({ config: cfg, + stateDir: options.stateDir, workspaceDir, env, cache: options.cache, diff --git a/src/plugins/status.registry-snapshot.test.ts b/src/plugins/status.registry-snapshot.test.ts new file mode 100644 index 00000000000..245e0fd9c8f --- /dev/null +++ b/src/plugins/status.registry-snapshot.test.ts @@ -0,0 +1,83 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { clearPluginDiscoveryCache } from "./discovery.js"; +import { clearPluginManifestRegistryCache } from "./manifest-registry.js"; +import { buildPluginRegistrySnapshotReport } from "./status.js"; + +const tempDirs: string[] = []; + +function makeTempDir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-status-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + clearPluginDiscoveryCache(); + clearPluginManifestRegistryCache(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("buildPluginRegistrySnapshotReport", () => { + it("reconstructs list metadata from indexed manifests without importing plugin runtime", () => { + const pluginDir = makeTempDir(); + const runtimeMarker = path.join(pluginDir, "runtime-loaded.txt"); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "@example/openclaw-indexed-demo", + version: "9.8.7", + openclaw: { extensions: ["./index.cjs"] }, + }), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify({ + id: "indexed-demo", + name: "Indexed Demo", + description: "Manifest-backed list metadata", + version: "1.2.3", + providers: ["indexed-provider"], + commandAliases: [{ name: "indexed-demo" }], + configSchema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + }), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "index.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(runtimeMarker)}, "loaded", "utf-8");\nmodule.exports = { id: "indexed-demo", register() {} };\n`, + "utf-8", + ); + + const report = buildPluginRegistrySnapshotReport({ + config: { + plugins: { + load: { paths: [pluginDir] }, + }, + }, + }); + + const plugin = report.plugins.find((entry) => entry.id === "indexed-demo"); + expect(plugin).toMatchObject({ + id: "indexed-demo", + name: "Indexed Demo", + description: "Manifest-backed list metadata", + version: "9.8.7", + format: "openclaw", + providerIds: ["indexed-provider"], + commands: ["indexed-demo"], + source: fs.realpathSync(path.join(pluginDir, "index.cjs")), + status: "loaded", + }); + expect(fs.existsSync(runtimeMarker)).toBe(false); + }); +}); diff --git a/src/plugins/status.ts b/src/plugins/status.ts index ed74812a0ab..423ef68c1f7 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -159,12 +159,19 @@ function buildPluginRecordFromInstalledIndex( plugin: import("./installed-plugin-index.js").InstalledPluginIndexRecord, manifest?: PluginManifestRecord, ): PluginRecord { + const format = plugin.format ?? manifest?.format ?? "openclaw"; + const bundleFormat = plugin.bundleFormat ?? manifest?.bundleFormat; return { id: plugin.pluginId, - name: plugin.pluginId, - ...(plugin.packageVersion ? { version: plugin.packageVersion } : {}), - format: "openclaw", - source: plugin.manifestPath, + name: manifest?.name ?? plugin.packageName ?? plugin.pluginId, + ...(plugin.packageVersion || manifest?.version + ? { version: plugin.packageVersion ?? manifest?.version } + : {}), + ...(manifest?.description ? { description: manifest.description } : {}), + format, + ...(bundleFormat ? { bundleFormat } : {}), + ...(manifest?.kind ? { kind: manifest.kind } : {}), + source: plugin.source ?? plugin.manifestPath, rootDir: plugin.rootDir, origin: plugin.origin, enabled: plugin.enabled, diff --git a/src/security/audit-plugins-trust.test.ts b/src/security/audit-plugins-trust.test.ts index fda78e2c9ae..ff3d9cc236b 100644 --- a/src/security/audit-plugins-trust.test.ts +++ b/src/security/audit-plugins-trust.test.ts @@ -38,6 +38,34 @@ vi.mock("../infra/package-update-utils.js", () => ({ vi.mock("../plugins/config-state.js", () => ({ normalizePluginId: (id: string) => id, + resolveEffectiveEnableState: (params: { + config?: { + enabled?: boolean; + deny?: string[]; + allow?: string[]; + entries?: Record; + }; + id: string; + enabledByDefault?: boolean; + }) => { + const entry = params.config?.entries?.[params.id]; + const denied = params.config?.deny?.includes(params.id) === true; + const allowed = + !params.config?.allow?.length || + params.config.allow.includes(params.id) || + params.config.allow.includes("group:plugins"); + const enabled = + params.config?.enabled !== false && + !denied && + allowed && + entry?.enabled !== false && + (entry?.enabled === true || params.enabledByDefault === true); + return { + enabled, + activated: enabled, + reason: enabled ? "enabled" : "disabled", + }; + }, normalizePluginsConfig: ( config: | { @@ -55,11 +83,8 @@ vi.mock("../plugins/config-state.js", () => ({ }), })); -vi.mock("../channels/plugins/index.js", () => ({ - getChannelPlugin: (id: string) => mockChannelPlugins.find((plugin) => plugin.id === id), - getLoadedChannelPlugin: () => undefined, - listChannelPlugins: () => mockChannelPlugins, - normalizeChannelId: (id: unknown) => (typeof id === "string" && id ? id : null), +vi.mock("../channels/plugins/read-only.js", () => ({ + listReadOnlyChannelPluginsForConfig: () => mockChannelPlugins, })); vi.mock("../channels/read-only-account-inspect.js", () => ({ diff --git a/src/security/audit-plugins-trust.ts b/src/security/audit-plugins-trust.ts index 8e3de3a990a..83130f4ac6d 100644 --- a/src/security/audit-plugins-trust.ts +++ b/src/security/audit-plugins-trust.ts @@ -1,18 +1,22 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { listChannelPlugins } from "../channels/plugins/index.js"; +import { listReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js"; +import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js"; import { resolveNativeSkillsEnabled } from "../config/commands.js"; import type { OpenClawConfig } from "../config/config.js"; import type { AgentToolsConfig } from "../config/types.tools.js"; import { readInstalledPackageVersion } from "../infra/package-update-utils.js"; -import { normalizePluginId, normalizePluginsConfig } from "../plugins/config-state.js"; +import { normalizePluginsConfig } from "../plugins/config-state.js"; import { loadInstalledPluginIndexInstallRecords } from "../plugins/installed-plugin-index-record-reader.js"; +import { + createPluginRegistryIdNormalizer, + loadPluginRegistrySnapshot, +} from "../plugins/plugin-registry.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import type { SecurityAuditFinding } from "./audit.types.js"; type SandboxToolPolicy = import("../agents/sandbox/types.js").SandboxToolPolicy; -type ChannelPlugin = ReturnType[number]; type PluginTrustPolicyDeps = { isToolAllowedByPolicies: typeof import("../agents/tool-policy-match.js").isToolAllowedByPolicies; @@ -282,17 +286,24 @@ export async function collectPluginsTrustFindings(params: { if (allowConfigured) { const installedPluginIds = new Set(pluginDirs.map((dir) => path.basename(dir).toLowerCase())); - const bundledPluginIds = new Set(listChannelPlugins().map((p) => p.id.toLowerCase())); + const pluginIndex = loadPluginRegistrySnapshot({ + config: params.cfg, + stateDir: params.stateDir, + }); + const normalizePluginId = createPluginRegistryIdNormalizer(pluginIndex); + const indexedPluginIds = new Set( + pluginIndex.plugins.map((plugin) => plugin.pluginId.toLowerCase()), + ); const phantomEntries = allow.filter((entry) => { if (typeof entry !== "string" || entry === "group:plugins") { return false; } const lower = entry.toLowerCase(); - if (installedPluginIds.has(lower) || bundledPluginIds.has(lower)) { + if (installedPluginIds.has(lower) || indexedPluginIds.has(lower)) { return false; } const canonicalId = normalizeOptionalLowercaseString(normalizePluginId(entry)) ?? ""; - return !canonicalId || !bundledPluginIds.has(canonicalId); + return !canonicalId || !indexedPluginIds.has(canonicalId); }); if (phantomEntries.length > 0) { findings.push({ @@ -309,9 +320,12 @@ export async function collectPluginsTrustFindings(params: { } if (!allowConfigured) { + const channelPlugins = listReadOnlyChannelPluginsForConfig(params.cfg, { + stateDir: params.stateDir, + }); const skillCommandsLikelyExposed = ( await Promise.all( - listChannelPlugins().map(async (plugin) => { + channelPlugins.map(async (plugin) => { if ( plugin.capabilities.nativeCommands !== true && plugin.commands?.nativeSkillsAutoEnabled !== true