fix: harden cold plugin metadata paths

This commit is contained in:
Shakker
2026-04-26 05:47:54 +01:00
parent 9c8245b178
commit 3ea20d1413
6 changed files with 149 additions and 16 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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);
});
});

View File

@@ -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,

View File

@@ -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<string, { enabled?: boolean }>;
};
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", () => ({

View File

@@ -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<typeof listChannelPlugins>[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