diff --git a/src/plugins/bundle-commands.test.ts b/src/plugins/bundle-commands.test.ts index 28560e3b6c69..78b589db076d 100644 --- a/src/plugins/bundle-commands.test.ts +++ b/src/plugins/bundle-commands.test.ts @@ -8,6 +8,11 @@ import type { PluginManifestRecord } from "./manifest-registry.js"; const mocks = vi.hoisted(() => ({ plugins: [] as PluginManifestRecord[], + warn: vi.fn(), +})); + +vi.mock("../logging/subsystem.js", () => ({ + createSubsystemLogger: () => ({ warn: mocks.warn }), })); vi.mock("./manifest-registry.js", () => ({ @@ -36,6 +41,7 @@ const tempDirs: string[] = []; afterEach(async () => { mocks.plugins = []; + mocks.warn.mockReset(); await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); }); @@ -224,4 +230,46 @@ describe("loadEnabledClaudeBundleCommands", () => { }, ); }); + + it("warns and skips oversized bundle commands without dropping siblings", async () => { + const homeDir = await createTempDir("openclaw-bundle-commands-oversized-"); + const workspaceDir = await createTempDir("openclaw-bundle-commands-oversized-ws-"); + + await writeClaudeBundleCommandFixture({ + homeDir, + pluginId: "oversized-test", + commands: [ + { + relativePath: "commands/normal.md", + contents: [ + "---", + "description: Normal command that should be loaded", + "---", + "This is a normal command.", + ], + }, + ], + }); + + const pluginRoot = resolveBundlePluginRoot(homeDir, "oversized-test"); + const oversizedFilePath = path.join(pluginRoot, "commands", "oversized.md"); + await fs.mkdir(path.dirname(oversizedFilePath), { recursive: true }); + const oversizedContent = Buffer.alloc(1 * 1024 * 1024 + 1, "x"); + await fs.writeFile(oversizedFilePath, oversizedContent); + + const commands = loadEnabledClaudeBundleCommands({ + workspaceDir, + cfg: { + plugins: { + entries: { "oversized-test": { enabled: true } }, + }, + }, + }); + + expect(commands.map((entry) => entry.rawName)).toEqual(["normal"]); + expect(mocks.warn).toHaveBeenCalledOnce(); + const warning = String(mocks.warn.mock.calls[0]?.[0]); + expect(warning).toContain(oversizedFilePath); + expect(warning).toContain("1048576"); + }); }); diff --git a/src/plugins/bundle-commands.ts b/src/plugins/bundle-commands.ts index 634fd88fc1b9..da90ce0e23fe 100644 --- a/src/plugins/bundle-commands.ts +++ b/src/plugins/bundle-commands.ts @@ -10,7 +10,10 @@ import { 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 { @@ -33,6 +36,9 @@ type ClaudeBundleCommandSpec = { sourceFilePath: string; }; +const BUNDLE_COMMAND_MAX_BYTES = 1 * 1024 * 1024; +const log = createSubsystemLogger("plugins/bundle-commands"); + function readClaudeBundleManifest(rootDir: string): Record { const result = readRootJsonObjectSync({ rootDir, @@ -103,8 +109,11 @@ function loadBundleCommandsFromRoot(params: { for (const filePath of listMarkdownFilesRecursive(params.commandRoot)) { let raw: string; try { - raw = fs.readFileSync(filePath, "utf-8"); - } catch { + 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);