Files
openclaw/src/plugins/bundle-commands.ts
cxbAsDev 7bf263b8b6 Bound plugin bundle command file reads with size cap (#110594)
* Bound bundle command file reads with size cap

* fix: use buffer.toString for readRegularFileSync result

* fix: log oversized bundle command file diagnostic instead of silent skip

The catch block now captures the error and emits a console.warn with the file path and error detail, so upgrades do not silently remove oversized installed commands.

* test: verify oversized bundle command file is skipped and siblings continue

PR #110594: Add focused regression coverage:
- Test: an oversized bundle command markdown file (>1 MB) is skipped via catch + continue
- Test: normal sibling command files still load correctly
- Verifies console.warn diagnostic is emitted for the oversized file

* refactor(plugins): log rejected bundle commands

Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com>

* style(plugins): format bundle warning

Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-18 23:03:00 +01:00

193 lines
5.9 KiB
TypeScript

// Bundles plugin command metadata for package output.
import fs from "node:fs";
import path from "node:path";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import {
parseFrontmatterBlock,
stripFrontmatterBlock,
} from "../../packages/markdown-core/src/frontmatter.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { formatErrorMessage } from "../infra/errors.js";
import { readRootJsonObjectSync } from "../infra/json-files.js";
import { readRegularFileSync } from "../infra/regular-file.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { isPathInsideWithRealpath } from "../security/scan-paths.js";
import { parseFrontmatterBool } from "../shared/frontmatter.js";
import {
CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH,
mergeBundlePathLists,
normalizeBundlePathList,
} from "./bundle-manifest.js";
import {
hasExplicitPluginConfig,
normalizePluginsConfig,
resolveEffectivePluginActivationState,
} from "./config-state.js";
import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry-contributions.js";
type ClaudeBundleCommandSpec = {
pluginId: string;
rawName: string;
description: string;
promptTemplate: string;
sourceFilePath: string;
};
const BUNDLE_COMMAND_MAX_BYTES = 1 * 1024 * 1024;
const log = createSubsystemLogger("plugins/bundle-commands");
function readClaudeBundleManifest(rootDir: string): Record<string, unknown> {
const result = readRootJsonObjectSync({
rootDir,
relativePath: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH,
boundaryLabel: "plugin root",
rejectHardlinks: true,
});
return result.ok ? result.value : {};
}
function resolveClaudeCommandRootDirs(rootDir: string): string[] {
const raw = readClaudeBundleManifest(rootDir);
const declared = normalizeBundlePathList(raw.commands);
const defaults = fs.existsSync(path.join(rootDir, "commands")) ? ["commands"] : [];
return mergeBundlePathLists(defaults, declared);
}
function listMarkdownFilesRecursive(rootDir: string): string[] {
const pending = [rootDir];
const files: string[] = [];
while (pending.length > 0) {
const current = pending.pop();
if (!current) {
continue;
}
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.name.startsWith(".")) {
continue;
}
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
pending.push(fullPath);
continue;
}
if (entry.isFile() && normalizeOptionalLowercaseString(entry.name)?.endsWith(".md")) {
files.push(fullPath);
}
}
}
return files.toSorted((a, b) => a.localeCompare(b));
}
function toDefaultCommandName(rootDir: string, filePath: string): string {
const relativePath = path.relative(rootDir, filePath);
const withoutExt = relativePath.replace(/\.[^.]+$/u, "");
return withoutExt.split(path.sep).join(":");
}
function toDefaultDescription(rawName: string, promptTemplate: string): string {
const firstLine = promptTemplate
.split(/\r?\n/u)
.map((line) => line.trim())
.find(Boolean);
return firstLine || rawName;
}
function loadBundleCommandsFromRoot(params: {
pluginId: string;
commandRoot: string;
}): ClaudeBundleCommandSpec[] {
const entries: ClaudeBundleCommandSpec[] = [];
for (const filePath of listMarkdownFilesRecursive(params.commandRoot)) {
let raw: string;
try {
raw = readRegularFileSync({ filePath, maxBytes: BUNDLE_COMMAND_MAX_BYTES }).buffer.toString(
"utf-8",
);
} catch (error) {
log.warn(`skipping unreadable bundle command file ${filePath}: ${formatErrorMessage(error)}`);
continue;
}
const frontmatter = parseFrontmatterBlock(raw);
if (!parseFrontmatterBool(frontmatter["user-invocable"], true)) {
continue;
}
const promptTemplate = stripFrontmatterBlock(raw);
if (!promptTemplate) {
continue;
}
const rawName =
normalizeOptionalString(frontmatter.name) ||
toDefaultCommandName(params.commandRoot, filePath);
if (!rawName) {
continue;
}
const description =
normalizeOptionalString(frontmatter.description) ||
toDefaultDescription(rawName, promptTemplate);
entries.push({
pluginId: params.pluginId,
rawName,
description,
promptTemplate,
sourceFilePath: filePath,
});
}
return entries;
}
export function loadEnabledClaudeBundleCommands(params: {
workspaceDir: string;
cfg?: OpenClawConfig;
}): ClaudeBundleCommandSpec[] {
if (!hasExplicitPluginConfig(params.cfg?.plugins)) {
return [];
}
const registry = loadPluginManifestRegistryForPluginRegistry({
workspaceDir: params.workspaceDir,
config: params.cfg,
includeDisabled: true,
});
const normalizedPlugins = normalizePluginsConfig(params.cfg?.plugins);
const commands: ClaudeBundleCommandSpec[] = [];
for (const record of registry.plugins) {
if (
record.format !== "bundle" ||
record.bundleFormat !== "claude" ||
!(record.bundleCapabilities ?? []).includes("commands")
) {
continue;
}
const activationState = resolveEffectivePluginActivationState({
id: record.id,
origin: record.origin,
config: normalizedPlugins,
rootConfig: params.cfg,
});
if (!activationState.activated) {
continue;
}
for (const relativeRoot of resolveClaudeCommandRootDirs(record.rootDir)) {
const commandRoot = path.resolve(record.rootDir, relativeRoot);
if (!fs.existsSync(commandRoot)) {
continue;
}
if (!isPathInsideWithRealpath(record.rootDir, commandRoot, { requireRealpath: true })) {
continue;
}
commands.push(...loadBundleCommandsFromRoot({ pluginId: record.id, commandRoot }));
}
}
return commands;
}