feat(plugins): add compatible bundle support

This commit is contained in:
Peter Steinberger 2026-03-15 16:08:30 -07:00
parent aa1454d1a8
commit dd40741e18
No known key found for this signature in database
30 changed files with 2696 additions and 73 deletions

View File

@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
- Docs/Zalo: clarify the Marketplace-bot support matrix and config guidance so the Zalo channel docs match current Bot Creator behavior more closely. (#47552) Thanks @No898.
- Install/update: allow package-manager installs from GitHub `main` via `openclaw update --tag main`, installer `--version main`, or direct npm/pnpm git specs.
- Plugins/providers: move OpenRouter, GitHub Copilot, and OpenAI Codex provider/runtime logic into bundled plugins, including dynamic model fallback, runtime auth exchange, stream wrappers, capability hints, and cache-TTL policy.
- Plugins/bundles: add compatible Codex, Claude, and Cursor bundle discovery/install support, map bundle skills into OpenClaw skills, and apply Claude bundle `settings.json` defaults to embedded Pi with shell overrides sanitized.
### Fixes

View File

@ -1,18 +1,19 @@
---
summary: "CLI reference for `openclaw plugins` (list, install, uninstall, enable/disable, doctor)"
read_when:
- You want to install or manage in-process Gateway plugins
- You want to install or manage Gateway plugins or compatible bundles
- You want to debug plugin load failures
title: "plugins"
---
# `openclaw plugins`
Manage Gateway plugins/extensions (loaded in-process).
Manage Gateway plugins/extensions and compatible bundles.
Related:
- Plugin system: [Plugins](/tools/plugin)
- Bundle compatibility: [Plugin bundles](/plugins/bundles)
- Plugin manifest + schema: [Plugin manifest](/plugins/manifest)
- Security hardening: [Security](/gateway/security)
@ -32,9 +33,13 @@ openclaw plugins update --all
Bundled plugins ship with OpenClaw but start disabled. Use `plugins enable` to
activate them.
All plugins must ship a `openclaw.plugin.json` file with an inline JSON Schema
(`configSchema`, even if empty). Missing/invalid manifests or schemas prevent
the plugin from loading and fail config validation.
Native OpenClaw plugins must ship `openclaw.plugin.json` with an inline JSON
Schema (`configSchema`, even if empty). Compatible bundles use their own bundle
manifests instead.
`plugins list` shows `Format: openclaw` or `Format: bundle`. Verbose list/info
output also shows the bundle subtype (`codex`, `claude`, or `cursor`) plus detected bundle
capabilities.
### Install
@ -60,6 +65,20 @@ name, use an explicit scoped spec (for example `@scope/diffs`).
Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`.
For local paths and archives, OpenClaw auto-detects:
- native OpenClaw plugins (`openclaw.plugin.json`)
- Codex-compatible bundles (`.codex-plugin/plugin.json`)
- Claude-compatible bundles (`.claude-plugin/plugin.json` or the default Claude
component layout)
- Cursor-compatible bundles (`.cursor-plugin/plugin.json`)
Compatible bundles install into the normal extensions root and participate in
the same list/info/enable/disable flow. Today, bundle skills, Claude
command-skills, Claude `settings.json` defaults, Cursor command-skills, and compatible Codex hook
directories are supported; other detected bundle capabilities are shown in
diagnostics/info but are not yet wired into runtime execution.
Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):
```bash

View File

@ -1046,6 +1046,7 @@
"group": "Extensions",
"pages": [
"plugins/community",
"plugins/bundles",
"plugins/voice-call",
"plugins/zalouser",
"plugins/manifest",

View File

@ -2323,12 +2323,14 @@ See [Local Models](/gateway/local-models). TL;DR: run MiniMax M2.5 via LM Studio
```
- Loaded from `~/.openclaw/extensions`, `<workspace>/.openclaw/extensions`, plus `plugins.load.paths`.
- Discovery accepts native OpenClaw plugins plus compatible Codex bundles and Claude bundles, including manifestless Claude default-layout bundles.
- **Config changes require a gateway restart.**
- `allow`: optional allowlist (only listed plugins load). `deny` wins.
- `plugins.entries.<id>.apiKey`: plugin-level API key convenience field (when supported by the plugin).
- `plugins.entries.<id>.env`: plugin-scoped env var map.
- `plugins.entries.<id>.hooks.allowPromptInjection`: when `false`, core blocks `before_prompt_build` and ignores prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride`.
- `plugins.entries.<id>.config`: plugin-defined config object (validated by plugin schema).
- `plugins.entries.<id>.hooks.allowPromptInjection`: when `false`, core blocks `before_prompt_build` and ignores prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride`. Applies to native plugin hooks and supported bundle-provided hook directories.
- `plugins.entries.<id>.config`: plugin-defined config object (validated by native OpenClaw plugin schema when available).
- Enabled Claude bundle plugins can also contribute embedded Pi defaults from `settings.json`; OpenClaw applies those as sanitized agent settings, not as raw OpenClaw config patches.
- `plugins.slots.memory`: pick the active memory plugin id, or `"none"` to disable memory plugins.
- `plugins.slots.contextEngine`: pick the active context engine plugin id; defaults to `"legacy"` unless you install and select another engine.
- `plugins.installs`: CLI-managed install metadata used by `openclaw plugins update`.

245
docs/plugins/bundles.md Normal file
View File

@ -0,0 +1,245 @@
---
summary: "Compatible Codex/Claude bundle formats: detection, mapping, and current OpenClaw support"
read_when:
- You want to install or debug a Codex/Claude-compatible bundle
- You need to understand how OpenClaw maps bundle content into native features
- You are documenting bundle compatibility or current support limits
title: "Plugin Bundles"
---
# Plugin bundles
OpenClaw supports three **compatible bundle formats** in addition to native
OpenClaw plugins:
- Codex bundles
- Claude bundles
- Cursor bundles
OpenClaw shows both as `Format: bundle` in `openclaw plugins list`. Verbose
output and `openclaw plugins info <id>` also show the bundle subtype
(`codex`, `claude`, or `cursor`).
Related:
- Plugin system overview: [Plugins](/tools/plugin)
- CLI install/list flows: [plugins](/cli/plugins)
- Native manifest schema: [Plugin manifest](/plugins/manifest)
## What a bundle is
A bundle is a **content/metadata pack**, not a native in-process OpenClaw
plugin.
Today, OpenClaw does **not** execute bundle runtime code in-process. Instead,
it detects known bundle files, reads the metadata, and maps supported bundle
content into native OpenClaw surfaces such as skills, hook packs, and embedded
Pi settings.
That is the main trust boundary:
- native OpenClaw plugin: runtime module executes in-process
- bundle: metadata/content pack, with selective feature mapping
## Supported bundle formats
### Codex bundles
Typical markers:
- `.codex-plugin/plugin.json`
- optional `skills/`
- optional `hooks/`
- optional `.mcp.json`
- optional `.app.json`
### Claude bundles
OpenClaw supports both:
- manifest-based Claude bundles: `.claude-plugin/plugin.json`
- manifestless Claude bundles that use the default component layout
Default Claude layout markers OpenClaw recognizes:
- `skills/`
- `commands/`
- `agents/`
- `hooks/hooks.json`
- `.mcp.json`
- `.lsp.json`
- `settings.json`
### Cursor bundles
Typical markers:
- `.cursor-plugin/plugin.json`
- optional `skills/`
- optional `.cursor/commands/`
- optional `.cursor/agents/`
- optional `.cursor/rules/`
- optional `.cursor/hooks.json`
- optional `.mcp.json`
## Detection order
OpenClaw prefers native OpenClaw plugin/package layouts before bundle handling.
Practical effect:
- `openclaw.plugin.json` wins over bundle detection
- package installs with valid `package.json` + `openclaw.extensions` use the
native install path
- if a directory contains both native and bundle metadata, OpenClaw treats it
as native first
That avoids partially installing a dual-format package as a bundle and then
loading it later as a native plugin.
## Current mapping
OpenClaw normalizes bundle metadata into one internal bundle record, then maps
supported surfaces into existing native behavior.
### Supported now
#### Skills
- Codex `skills` roots load as normal OpenClaw skill roots
- Claude `skills` roots load as normal OpenClaw skill roots
- Claude `commands` roots are treated as additional skill roots
- Cursor `skills` roots load as normal OpenClaw skill roots
- Cursor `.cursor/commands` roots are treated as additional skill roots
This means Claude markdown command files work through the normal OpenClaw skill
loader. Cursor command markdown works through the same path.
#### Hook packs
- Codex `hooks` roots work **only** when they use the normal OpenClaw hook-pack
layout:
- `HOOK.md`
- `handler.ts` or `handler.js`
#### Embedded Pi settings
- Claude `settings.json` is imported as default embedded Pi settings when the
bundle is enabled
- OpenClaw sanitizes shell override keys before applying them
Sanitized keys:
- `shellPath`
- `shellCommandPrefix`
### Detected but not executed
These surfaces are detected, shown in bundle capabilities, and may appear in
diagnostics/info output, but OpenClaw does not run them yet:
- Claude `agents`
- Claude `hooks.json` automation
- Claude `mcpServers`
- Claude `lspServers`
- Claude `outputStyles`
- Cursor `.cursor/agents`
- Cursor `.cursor/hooks.json`
- Cursor `.cursor/rules`
- Cursor `mcpServers`
- Codex inline/app metadata beyond capability reporting
## Claude path behavior
Claude bundle manifests can declare custom component paths. OpenClaw treats
those paths as **additive**, not replacing defaults.
Currently recognized custom path keys:
- `skills`
- `commands`
- `agents`
- `hooks`
- `mcpServers`
- `lspServers`
- `outputStyles`
Examples:
- default `commands/` plus manifest `commands: "extra-commands"` =>
OpenClaw scans both
- default `skills/` plus manifest `skills: ["team-skills"]` =>
OpenClaw scans both
## Capability reporting
`openclaw plugins info <id>` shows bundle capabilities from the normalized
bundle record.
Supported capabilities are loaded quietly. Unsupported capabilities produce a
warning such as:
```text
bundle capability detected but not wired into OpenClaw yet: agents
```
Current exceptions:
- Claude `commands` is considered supported because it maps to skills
- Claude `settings` is considered supported because it maps to embedded Pi settings
- Cursor `commands` is considered supported because it maps to skills
- Codex `hooks` is considered supported only for OpenClaw hook-pack layouts
## Security model
Bundle support is intentionally narrower than native plugin support.
Current behavior:
- bundle discovery reads files inside the plugin root with boundary checks
- skills and hook-pack paths must stay inside the plugin root
- bundle settings files are read with the same boundary checks
- OpenClaw does not execute arbitrary bundle runtime code in-process
This makes bundle support safer by default than native plugin modules, but you
should still treat third-party bundles as trusted content for the features they
do expose.
## Install examples
```bash
openclaw plugins install ./my-codex-bundle
openclaw plugins install ./my-claude-bundle
openclaw plugins install ./my-cursor-bundle
openclaw plugins install ./my-bundle.tgz
openclaw plugins info my-bundle
```
If the directory is a native OpenClaw plugin/package, the native install path
still wins.
## Troubleshooting
### Bundle is detected but capabilities do not run
Check `openclaw plugins info <id>`.
If the capability is listed but OpenClaw says it is not wired yet, that is a
real product limit, not a broken install.
### Claude command files do not appear
Make sure the bundle is enabled and the markdown files are inside a detected
`commands` root or `skills` root.
### Claude settings do not apply
Current support is limited to embedded Pi settings from `settings.json`.
OpenClaw does not treat bundle settings as raw OpenClaw config patches.
### Claude hooks do not execute
`hooks/hooks.json` is only detected today.
If you need runnable bundle hooks today, use the normal OpenClaw hook-pack
layout through a supported Codex hook root or ship a native OpenClaw plugin.

View File

@ -8,10 +8,28 @@ title: "Plugin Manifest"
# Plugin manifest (openclaw.plugin.json)
Every plugin **must** ship a `openclaw.plugin.json` file in the **plugin root**.
OpenClaw uses this manifest to validate configuration **without executing plugin
code**. Missing or invalid manifests are treated as plugin errors and block
config validation.
This page is for the **native OpenClaw plugin manifest** only.
For compatible bundle layouts, see [Plugin bundles](/plugins/bundles).
Compatible bundle formats use different manifest files:
- Codex bundle: `.codex-plugin/plugin.json`
- Claude bundle: `.claude-plugin/plugin.json` or the default Claude component
layout without a manifest
- Cursor bundle: `.cursor-plugin/plugin.json`
OpenClaw auto-detects those bundle layouts too, but they are not validated
against the `openclaw.plugin.json` schema described here.
For compatible bundles, OpenClaw currently reads bundle metadata plus declared
skill roots, Claude command roots, Claude bundle `settings.json` defaults, and
supported hook packs when the layout matches OpenClaw runtime expectations.
Every native OpenClaw plugin **must** ship a `openclaw.plugin.json` file in the
**plugin root**. OpenClaw uses this manifest to validate configuration
**without executing plugin code**. Missing or invalid manifests are treated as
plugin errors and block config validation.
See the full plugin system guide: [Plugins](/tools/plugin).
@ -63,7 +81,7 @@ Optional keys:
## Notes
- The manifest is **required for all plugins**, including local filesystem loads.
- The manifest is **required for native OpenClaw plugins**, including local filesystem loads.
- Runtime still loads the plugin module separately; the manifest is only for
discovery + validation.
- Exclusive plugin kinds are selected through `plugins.slots.*`.

View File

@ -3,6 +3,7 @@ summary: "OpenClaw plugins/extensions: discovery, config, and safety"
read_when:
- Adding or modifying plugins/extensions
- Documenting plugin install or load rules
- Working with Codex/Claude-compatible plugin bundles
title: "Plugins"
---
@ -10,8 +11,13 @@ title: "Plugins"
## Quick start (new to plugins?)
A plugin is just a **small code module** that extends OpenClaw with extra
features (commands, tools, and Gateway RPC).
A plugin is either:
- a native **OpenClaw plugin** (`openclaw.plugin.json` + runtime module), or
- a compatible **bundle** (`.codex-plugin/plugin.json` or `.claude-plugin/plugin.json`)
Both show up under `openclaw plugins`, but only native OpenClaw plugins execute
runtime code in-process.
Most of the time, youll use plugins when you want a feature thats not built
into core OpenClaw yet (or you want to keep optional features out of your main
@ -42,6 +48,14 @@ prerelease tag such as `@beta`/`@rc` or an exact prerelease version.
See [Voice Call](/plugins/voice-call) for a concrete example plugin.
Looking for third-party listings? See [Community plugins](/plugins/community).
Need the bundle compatibility details? See [Plugin bundles](/plugins/bundles).
For compatible bundles, install from a local directory or archive:
```bash
openclaw plugins install ./my-bundle
openclaw plugins install ./my-bundle.tgz
```
## Architecture
@ -49,14 +63,15 @@ OpenClaw's plugin system has four layers:
1. **Manifest + discovery**
OpenClaw finds candidate plugins from configured paths, workspace roots,
global extension roots, and bundled extensions. Discovery reads
`openclaw.plugin.json` plus package metadata first.
global extension roots, and bundled extensions. Discovery reads native
`openclaw.plugin.json` manifests plus supported bundle manifests first.
2. **Enablement + validation**
Core decides whether a discovered plugin is enabled, disabled, blocked, or
selected for an exclusive slot such as memory.
3. **Runtime loading**
Enabled plugins are loaded in-process via jiti and register capabilities into
a central registry.
Native OpenClaw plugins are loaded in-process via jiti and register
capabilities into a central registry. Compatible bundles are normalized into
registry records without importing runtime code.
4. **Surface consumption**
The rest of OpenClaw reads the registry to expose tools, channels, provider
setup, hooks, HTTP routes, CLI commands, and services.
@ -65,22 +80,68 @@ The important design boundary:
- discovery + config validation should work from **manifest/schema metadata**
without executing plugin code
- runtime behavior comes from the plugin module's `register(api)` path
- native runtime behavior comes from the plugin module's `register(api)` path
That split lets OpenClaw validate config, explain missing/disabled plugins, and
build UI/schema hints before the full runtime is active.
## Compatible bundles
OpenClaw also recognizes two compatible external bundle layouts:
- Codex-style bundles: `.codex-plugin/plugin.json`
- Claude-style bundles: `.claude-plugin/plugin.json` or the default Claude
component layout without a manifest
- Cursor-style bundles: `.cursor-plugin/plugin.json`
They are shown in the plugin list as `format=bundle`, with a subtype of
`codex` or `claude` in verbose/info output.
See [Plugin bundles](/plugins/bundles) for the exact detection rules, mapping
behavior, and current support matrix.
Today, OpenClaw treats these as **capability packs**, not native runtime
plugins:
- supported now: bundled `skills`
- supported now: Claude `commands/` markdown roots, mapped into the normal
OpenClaw skill loader
- supported now: Claude bundle `settings.json` defaults for embedded Pi agent
settings (with shell override keys sanitized)
- supported now: Cursor `.cursor/commands/*.md` roots, mapped into the normal
OpenClaw skill loader
- supported now: Codex bundle hook directories that use the OpenClaw hook-pack
layout (`HOOK.md` + `handler.ts`/`handler.js`)
- detected but not wired yet: other declared bundle capabilities such as
agents, Claude hook automation, Cursor rules/hooks/MCP metadata, MCP/app/LSP
metadata, output styles
That means bundle install/discovery/list/info/enablement all work, and bundle
skills, Claude command-skills, Claude bundle settings defaults, and compatible
Codex hook directories load when the bundle is enabled, but bundle runtime code
is not executed in-process.
Bundle hook support is limited to the normal OpenClaw hook directory format
(`HOOK.md` plus `handler.ts`/`handler.js` under the declared hook roots).
Vendor-specific shell/JSON hook runtimes, including Claude `hooks.json`, are
only detected today and are not executed directly.
## Execution model
Plugins run **in-process** with the Gateway. They are not sandboxed. A loaded
plugin has the same process-level trust boundary as core code.
Native OpenClaw plugins run **in-process** with the Gateway. They are not
sandboxed. A loaded native plugin has the same process-level trust boundary as
core code.
Implications:
- a plugin can register tools, network handlers, hooks, and services
- a plugin bug can crash or destabilize the gateway
- a malicious plugin is equivalent to arbitrary code execution inside the
OpenClaw process
- a native plugin can register tools, network handlers, hooks, and services
- a native plugin bug can crash or destabilize the gateway
- a malicious native plugin is equivalent to arbitrary code execution inside
the OpenClaw process
Compatible bundles are safer by default because OpenClaw currently treats them
as metadata/content packs. In current releases, that mostly means bundled
skills.
Use allowlists and explicit install/load paths for non-bundled plugins. Treat
workspace plugins as development-time code, not production defaults.
@ -111,11 +172,11 @@ Important trust note:
- Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default)
- Copilot Proxy (provider auth) — local VS Code Copilot Proxy bridge; distinct from built-in `github-copilot` device login (bundled, disabled by default)
OpenClaw plugins are **TypeScript modules** loaded at runtime via jiti. **Config
validation does not execute plugin code**; it uses the plugin manifest and JSON
Schema instead. See [Plugin manifest](/plugins/manifest).
Native OpenClaw plugins are **TypeScript modules** loaded at runtime via jiti.
**Config validation does not execute plugin code**; it uses the plugin manifest
and JSON Schema instead. See [Plugin manifest](/plugins/manifest).
Plugins can register:
Native OpenClaw plugins can register:
- Gateway RPC methods
- Gateway HTTP routes
@ -129,7 +190,7 @@ Plugins can register:
- **Skills** (by listing `skills` directories in the plugin manifest)
- **Auto-reply commands** (execute without invoking the AI agent)
Plugins run **inprocess** with the Gateway, so treat them as trusted code.
Native OpenClaw plugins run **inprocess** with the Gateway, so treat them as trusted code.
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
## Provider runtime hooks
@ -268,13 +329,13 @@ api.registerProvider({
At startup, OpenClaw does roughly this:
1. discover candidate plugin roots
2. read `openclaw.plugin.json` and package metadata
2. read native or compatible bundle manifests and package metadata
3. reject unsafe candidates
4. normalize plugin config (`plugins.enabled`, `allow`, `deny`, `entries`,
`slots`, `load.paths`)
5. decide enablement for each candidate
6. load enabled modules via jiti
7. call `register(api)` and collect registrations into the plugin registry
6. load enabled native modules via jiti
7. call native `register(api)` hooks and collect registrations into the plugin registry
8. expose the registry to commands/runtime surfaces
The safety gates happen **before** runtime execution. Candidates are blocked
@ -286,13 +347,13 @@ ownership looks suspicious for non-bundled plugins.
The manifest is the control-plane source of truth. OpenClaw uses it to:
- identify the plugin
- discover declared channels/skills/config schema
- discover declared channels/skills/config schema or bundle capabilities
- validate `plugins.entries.<id>.config`
- augment Control UI labels/placeholders
- show install/catalog metadata
The runtime module is the data-plane part. It registers actual behavior such as
hooks, tools, commands, or provider flows.
For native plugins, the runtime module is the data-plane part. It registers
actual behavior such as hooks, tools, commands, or provider flows.
### What the loader caches
@ -529,9 +590,16 @@ Hardening notes:
- path ownership is suspicious for non-bundled plugins (POSIX owner is neither current uid nor root).
- Loaded non-bundled plugins without install/load-path provenance emit a warning so you can pin trust (`plugins.allow`) or install tracking (`plugins.installs`).
Each plugin must include a `openclaw.plugin.json` file in its root. If a path
points at a file, the plugin root is the file's directory and must contain the
manifest.
Each native OpenClaw plugin must include a `openclaw.plugin.json` file in its
root. If a path points at a file, the plugin root is the file's directory and
must contain the manifest.
Compatible bundles may instead provide one of:
- `.codex-plugin/plugin.json`
- `.claude-plugin/plugin.json`
Bundle directories are discovered from the same roots as native plugins.
If multiple plugins resolve to the same id, the first match in the order above
wins and lower-precedence copies are ignored.
@ -703,8 +771,9 @@ Validation rules (strict):
- Unknown plugin ids in `entries`, `allow`, `deny`, or `slots` are **errors**.
- Unknown `channels.<id>` keys are **errors** unless a plugin manifest declares
the channel id.
- Plugin config is validated using the JSON Schema embedded in
- Native plugin config is validated using the JSON Schema embedded in
`openclaw.plugin.json` (`configSchema`).
- Compatible bundles currently do not expose native OpenClaw config schemas.
- If a plugin is disabled, its config is preserved and a **warning** is emitted.
### Disabled vs missing vs invalid
@ -804,6 +873,10 @@ openclaw plugins disable <id>
openclaw plugins doctor
```
`openclaw plugins list` shows the top-level format as `openclaw` or `bundle`.
Verbose list/info output also shows bundle subtype (`codex` or `claude`) plus
detected bundle capabilities.
`plugins update` only works for npm installs tracked under `plugins.installs`.
If stored integrity metadata changes between updates, OpenClaw warns and asks for confirmation (use global `--yes` to bypass prompts).

View File

@ -0,0 +1,105 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
const hoisted = vi.hoisted(() => ({
loadPluginManifestRegistry: vi.fn(),
}));
vi.mock("../plugins/manifest-registry.js", () => ({
loadPluginManifestRegistry: (...args: unknown[]) => hoisted.loadPluginManifestRegistry(...args),
}));
const { loadEnabledBundlePiSettingsSnapshot } = await import("./pi-project-settings.js");
const tempDirs = createTrackedTempDirs();
function buildRegistry(params: {
pluginRoot: string;
settingsFiles?: string[];
}): PluginManifestRegistry {
return {
diagnostics: [],
plugins: [
{
id: "claude-bundle",
name: "Claude Bundle",
format: "bundle",
bundleFormat: "claude",
bundleCapabilities: ["settings"],
channels: [],
providers: [],
skills: [],
settingsFiles: params.settingsFiles ?? ["settings.json"],
hooks: [],
origin: "workspace",
rootDir: params.pluginRoot,
source: params.pluginRoot,
manifestPath: path.join(params.pluginRoot, ".claude-plugin", "plugin.json"),
},
],
};
}
afterEach(async () => {
hoisted.loadPluginManifestRegistry.mockReset();
await tempDirs.cleanup();
});
describe("loadEnabledBundlePiSettingsSnapshot", () => {
it("loads sanitized settings from enabled bundle plugins", async () => {
const workspaceDir = await tempDirs.make("openclaw-workspace-");
const pluginRoot = await tempDirs.make("openclaw-bundle-");
await fs.writeFile(
path.join(pluginRoot, "settings.json"),
JSON.stringify({
hideThinkingBlock: true,
shellPath: "/tmp/blocked-shell",
compaction: { keepRecentTokens: 64_000 },
}),
"utf-8",
);
hoisted.loadPluginManifestRegistry.mockReturnValue(buildRegistry({ pluginRoot }));
const snapshot = loadEnabledBundlePiSettingsSnapshot({
cwd: workspaceDir,
cfg: {
plugins: {
entries: {
"claude-bundle": { enabled: true },
},
},
},
});
expect(snapshot.hideThinkingBlock).toBe(true);
expect(snapshot.shellPath).toBeUndefined();
expect(snapshot.compaction?.keepRecentTokens).toBe(64_000);
});
it("ignores disabled bundle plugins", async () => {
const workspaceDir = await tempDirs.make("openclaw-workspace-");
const pluginRoot = await tempDirs.make("openclaw-bundle-");
await fs.writeFile(
path.join(pluginRoot, "settings.json"),
JSON.stringify({ hideThinkingBlock: true }),
"utf-8",
);
hoisted.loadPluginManifestRegistry.mockReturnValue(buildRegistry({ pluginRoot }));
const snapshot = loadEnabledBundlePiSettingsSnapshot({
cwd: workspaceDir,
cfg: {
plugins: {
entries: {
"claude-bundle": { enabled: false },
},
},
},
});
expect(snapshot).toEqual({});
});
});

View File

@ -41,6 +41,7 @@ describe("buildEmbeddedPiSettingsSnapshot", () => {
it("sanitize mode strips shell path + prefix but keeps other project settings", () => {
const snapshot = buildEmbeddedPiSettingsSnapshot({
globalSettings,
pluginSettings: {},
projectSettings,
policy: "sanitize",
});
@ -53,6 +54,7 @@ describe("buildEmbeddedPiSettingsSnapshot", () => {
it("ignore mode drops all project settings", () => {
const snapshot = buildEmbeddedPiSettingsSnapshot({
globalSettings,
pluginSettings: {},
projectSettings,
policy: "ignore",
});
@ -65,6 +67,7 @@ describe("buildEmbeddedPiSettingsSnapshot", () => {
it("trusted mode keeps project settings as-is", () => {
const snapshot = buildEmbeddedPiSettingsSnapshot({
globalSettings,
pluginSettings: {},
projectSettings,
policy: "trusted",
});
@ -73,4 +76,21 @@ describe("buildEmbeddedPiSettingsSnapshot", () => {
expect(snapshot.compaction?.reserveTokens).toBe(32_000);
expect(snapshot.hideThinkingBlock).toBe(true);
});
it("applies sanitized plugin settings before project settings", () => {
const snapshot = buildEmbeddedPiSettingsSnapshot({
globalSettings,
pluginSettings: {
shellPath: "/tmp/blocked-shell",
compaction: { keepRecentTokens: 64_000 },
hideThinkingBlock: false,
},
projectSettings,
policy: "sanitize",
});
expect(snapshot.shellPath).toBe("/bin/zsh");
expect(snapshot.compaction?.keepRecentTokens).toBe(64_000);
expect(snapshot.compaction?.reserveTokens).toBe(32_000);
expect(snapshot.hideThinkingBlock).toBe(true);
});
});

View File

@ -1,8 +1,17 @@
import fs from "node:fs";
import path from "node:path";
import { SettingsManager } from "@mariozechner/pi-coding-agent";
import type { OpenClawConfig } from "../config/config.js";
import { applyMergePatch } from "../config/merge-patch.js";
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { normalizePluginsConfig, resolveEffectiveEnableState } from "../plugins/config-state.js";
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
import { isRecord } from "../utils.js";
import { applyPiCompactionSettingsFromConfig } from "./pi-settings.js";
const log = createSubsystemLogger("embedded-pi-settings");
export const DEFAULT_EMBEDDED_PI_PROJECT_SETTINGS_POLICY = "sanitize";
export const SANITIZED_PROJECT_PI_KEYS = ["shellPath", "shellCommandPrefix"] as const;
@ -10,15 +19,97 @@ export type EmbeddedPiProjectSettingsPolicy = "trusted" | "sanitize" | "ignore";
type PiSettingsSnapshot = ReturnType<SettingsManager["getGlobalSettings"]>;
function sanitizeProjectSettings(settings: PiSettingsSnapshot): PiSettingsSnapshot {
function sanitizePiSettingsSnapshot(settings: PiSettingsSnapshot): PiSettingsSnapshot {
const sanitized = { ...settings };
// Never allow workspace-local settings to override shell execution behavior.
// Never allow plugin or workspace-local settings to override shell execution behavior.
for (const key of SANITIZED_PROJECT_PI_KEYS) {
delete sanitized[key];
}
return sanitized;
}
function sanitizeProjectSettings(settings: PiSettingsSnapshot): PiSettingsSnapshot {
return sanitizePiSettingsSnapshot(settings);
}
function loadBundleSettingsFile(params: {
rootDir: string;
relativePath: string;
}): PiSettingsSnapshot | null {
const absolutePath = path.join(params.rootDir, params.relativePath);
const opened = openBoundaryFileSync({
absolutePath,
rootPath: params.rootDir,
boundaryLabel: "plugin root",
rejectHardlinks: true,
});
if (!opened.ok) {
log.warn(`skipping unsafe bundle settings file: ${absolutePath}`);
return null;
}
try {
const raw = JSON.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown;
if (!isRecord(raw)) {
log.warn(`skipping bundle settings file with non-object JSON: ${absolutePath}`);
return null;
}
return sanitizePiSettingsSnapshot(raw as PiSettingsSnapshot);
} catch (error) {
log.warn(`failed to parse bundle settings file ${absolutePath}: ${String(error)}`);
return null;
} finally {
fs.closeSync(opened.fd);
}
}
export function loadEnabledBundlePiSettingsSnapshot(params: {
cwd: string;
cfg?: OpenClawConfig;
}): PiSettingsSnapshot {
const workspaceDir = params.cwd.trim();
if (!workspaceDir) {
return {};
}
const registry = loadPluginManifestRegistry({
workspaceDir,
config: params.cfg,
});
if (registry.plugins.length === 0) {
return {};
}
const normalizedPlugins = normalizePluginsConfig(params.cfg?.plugins);
let snapshot: PiSettingsSnapshot = {};
for (const record of registry.plugins) {
const settingsFiles = record.settingsFiles ?? [];
if (record.format !== "bundle" || settingsFiles.length === 0) {
continue;
}
const enableState = resolveEffectiveEnableState({
id: record.id,
origin: record.origin,
config: normalizedPlugins,
rootConfig: params.cfg,
});
if (!enableState.enabled) {
continue;
}
for (const relativePath of settingsFiles) {
const bundleSettings = loadBundleSettingsFile({
rootDir: record.rootDir,
relativePath,
});
if (!bundleSettings) {
continue;
}
snapshot = applyMergePatch(snapshot, bundleSettings) as PiSettingsSnapshot;
}
}
return snapshot;
}
export function resolveEmbeddedPiProjectSettingsPolicy(
cfg?: OpenClawConfig,
): EmbeddedPiProjectSettingsPolicy {
@ -31,6 +122,7 @@ export function resolveEmbeddedPiProjectSettingsPolicy(
export function buildEmbeddedPiSettingsSnapshot(params: {
globalSettings: PiSettingsSnapshot;
pluginSettings?: PiSettingsSnapshot;
projectSettings: PiSettingsSnapshot;
policy: EmbeddedPiProjectSettingsPolicy;
}): PiSettingsSnapshot {
@ -40,7 +132,11 @@ export function buildEmbeddedPiSettingsSnapshot(params: {
: params.policy === "sanitize"
? sanitizeProjectSettings(params.projectSettings)
: params.projectSettings;
return applyMergePatch(params.globalSettings, effectiveProjectSettings) as PiSettingsSnapshot;
const withPluginSettings = applyMergePatch(
params.globalSettings,
sanitizePiSettingsSnapshot(params.pluginSettings ?? {}),
) as PiSettingsSnapshot;
return applyMergePatch(withPluginSettings, effectiveProjectSettings) as PiSettingsSnapshot;
}
export function createEmbeddedPiSettingsManager(params: {
@ -50,11 +146,17 @@ export function createEmbeddedPiSettingsManager(params: {
}): SettingsManager {
const fileSettingsManager = SettingsManager.create(params.cwd, params.agentDir);
const policy = resolveEmbeddedPiProjectSettingsPolicy(params.cfg);
if (policy === "trusted") {
const pluginSettings = loadEnabledBundlePiSettingsSnapshot({
cwd: params.cwd,
cfg: params.cfg,
});
const hasPluginSettings = Object.keys(pluginSettings).length > 0;
if (policy === "trusted" && !hasPluginSettings) {
return fileSettingsManager;
}
const settings = buildEmbeddedPiSettingsSnapshot({
globalSettings: fileSettingsManager.getGlobalSettings(),
pluginSettings,
projectSettings: fileSettingsManager.getProjectSettings(),
policy,
});

View File

@ -27,6 +27,7 @@ function buildRegistry(params: { acpxRoot: string; helperRoot: string }): Plugin
channels: [],
providers: [],
skills: ["./skills"],
hooks: [],
origin: "workspace",
rootDir: params.acpxRoot,
source: params.acpxRoot,
@ -38,6 +39,7 @@ function buildRegistry(params: { acpxRoot: string; helperRoot: string }): Plugin
channels: [],
providers: [],
skills: ["./skills"],
hooks: [],
origin: "workspace",
rootDir: params.helperRoot,
source: params.helperRoot,
@ -50,6 +52,7 @@ function buildRegistry(params: { acpxRoot: string; helperRoot: string }): Plugin
function createSinglePluginRegistry(params: {
pluginRoot: string;
skills: string[];
format?: "openclaw" | "bundle";
}): PluginManifestRegistry {
return {
diagnostics: [],
@ -57,9 +60,11 @@ function createSinglePluginRegistry(params: {
{
id: "helper",
name: "Helper",
format: params.format,
channels: [],
providers: [],
skills: params.skills,
hooks: [],
origin: "workspace",
rootDir: params.pluginRoot,
source: params.pluginRoot,
@ -116,6 +121,12 @@ describe("resolvePluginSkillDirs", () => {
workspaceDir,
config: {
acp: { enabled: acpEnabled },
plugins: {
entries: {
acpx: { enabled: true },
helper: { enabled: true },
},
},
} as OpenClawConfig,
});
@ -137,7 +148,13 @@ describe("resolvePluginSkillDirs", () => {
const dirs = resolvePluginSkillDirs({
workspaceDir,
config: {} as OpenClawConfig,
config: {
plugins: {
entries: {
helper: { enabled: true },
},
},
} as OpenClawConfig,
});
expect(dirs).toEqual([path.resolve(pluginRoot, "skills")]);
@ -162,9 +179,46 @@ describe("resolvePluginSkillDirs", () => {
const dirs = resolvePluginSkillDirs({
workspaceDir,
config: {} as OpenClawConfig,
config: {
plugins: {
entries: {
helper: { enabled: true },
},
},
} as OpenClawConfig,
});
expect(dirs).toEqual([]);
});
it("resolves Claude bundle command roots through the normal plugin skill path", async () => {
const workspaceDir = await tempDirs.make("openclaw-");
const pluginRoot = await tempDirs.make("openclaw-claude-bundle-");
await fs.mkdir(path.join(pluginRoot, "commands"), { recursive: true });
await fs.mkdir(path.join(pluginRoot, "skills"), { recursive: true });
hoisted.loadPluginManifestRegistry.mockReturnValue(
createSinglePluginRegistry({
pluginRoot,
format: "bundle",
skills: ["./skills", "./commands"],
}),
);
const dirs = resolvePluginSkillDirs({
workspaceDir,
config: {
plugins: {
entries: {
helper: { enabled: true },
},
},
} as OpenClawConfig,
});
expect(dirs).toEqual([
path.resolve(pluginRoot, "skills"),
path.resolve(pluginRoot, "commands"),
]);
});
});

View File

@ -97,16 +97,21 @@ function formatPluginLine(plugin: PluginRecord, verbose = false): string {
: plugin.description,
)
: theme.muted("(no description)");
const format = plugin.format ?? "openclaw";
if (!verbose) {
return `${name}${idSuffix} ${status} - ${desc}`;
return `${name}${idSuffix} ${status} ${theme.muted(`[${format}]`)} - ${desc}`;
}
const parts = [
`${name}${idSuffix} ${status}`,
` format: ${format}`,
` source: ${theme.muted(shortenHomeInString(plugin.source))}`,
` origin: ${plugin.origin}`,
];
if (plugin.bundleFormat) {
parts.push(` bundle format: ${plugin.bundleFormat}`);
}
if (plugin.version) {
parts.push(` version: ${plugin.version}`);
}
@ -419,6 +424,7 @@ export function registerPluginsCli(program: Command) {
return {
Name: plugin.name || plugin.id,
ID: plugin.name && plugin.name !== plugin.id ? plugin.id : "",
Format: plugin.format ?? "openclaw",
Status:
plugin.status === "loaded"
? theme.success("loaded")
@ -451,6 +457,7 @@ export function registerPluginsCli(program: Command) {
columns: [
{ key: "Name", header: "Name", minWidth: 14, flex: true },
{ key: "ID", header: "ID", minWidth: 10, flex: true },
{ key: "Format", header: "Format", minWidth: 9 },
{ key: "Status", header: "Status", minWidth: 10 },
{ key: "Source", header: "Source", minWidth: 26, flex: true },
{ key: "Version", header: "Version", minWidth: 8 },
@ -499,6 +506,10 @@ export function registerPluginsCli(program: Command) {
}
lines.push("");
lines.push(`${theme.muted("Status:")} ${plugin.status}`);
lines.push(`${theme.muted("Format:")} ${plugin.format ?? "openclaw"}`);
if (plugin.bundleFormat) {
lines.push(`${theme.muted("Bundle format:")} ${plugin.bundleFormat}`);
}
lines.push(`${theme.muted("Source:")} ${shortenHomeInString(plugin.source)}`);
lines.push(`${theme.muted("Origin:")} ${plugin.origin}`);
if (plugin.version) {
@ -516,6 +527,11 @@ export function registerPluginsCli(program: Command) {
if (plugin.providerIds.length > 0) {
lines.push(`${theme.muted("Providers:")} ${plugin.providerIds.join(", ")}`);
}
if ((plugin.bundleCapabilities?.length ?? 0) > 0) {
lines.push(
`${theme.muted("Bundle capabilities:")} ${plugin.bundleCapabilities?.join(", ")}`,
);
}
if (plugin.cliCommands.length > 0) {
lines.push(`${theme.muted("CLI commands:")} ${plugin.cliCommands.join(", ")}`);
}

View File

@ -43,6 +43,35 @@ async function writePluginFixture(params: {
);
}
async function writeBundleFixture(params: {
dir: string;
format: "codex" | "claude";
name: string;
}) {
await mkdirSafe(params.dir);
const manifestDir = path.join(
params.dir,
params.format === "codex" ? ".codex-plugin" : ".claude-plugin",
);
await mkdirSafe(manifestDir);
await fs.writeFile(
path.join(manifestDir, "plugin.json"),
JSON.stringify({ name: params.name }, null, 2),
"utf-8",
);
}
async function writeManifestlessClaudeBundleFixture(params: { dir: string }) {
await mkdirSafe(params.dir);
await mkdirSafe(path.join(params.dir, "commands"));
await fs.writeFile(
path.join(params.dir, "commands", "review.md"),
"---\ndescription: fixture\n---\n",
"utf-8",
);
await fs.writeFile(path.join(params.dir, "settings.json"), '{"hideThinkingBlock":true}', "utf-8");
}
describe("config plugin validation", () => {
let fixtureRoot = "";
let suiteHome = "";
@ -50,6 +79,8 @@ describe("config plugin validation", () => {
let enumPluginDir = "";
let bluebubblesPluginDir = "";
let voiceCallSchemaPluginDir = "";
let bundlePluginDir = "";
let manifestlessClaudeBundleDir = "";
const suiteEnv = () =>
({
...process.env,
@ -103,6 +134,16 @@ describe("config plugin validation", () => {
channels: ["bluebubbles"],
schema: { type: "object" },
});
bundlePluginDir = path.join(suiteHome, "bundle-plugin");
await writeBundleFixture({
dir: bundlePluginDir,
format: "codex",
name: "Bundle Fixture",
});
manifestlessClaudeBundleDir = path.join(suiteHome, "manifestless-claude-bundle");
await writeManifestlessClaudeBundleFixture({
dir: manifestlessClaudeBundleDir,
});
voiceCallSchemaPluginDir = path.join(suiteHome, "voice-call-schema-plugin");
const voiceCallManifestPath = path.join(
process.cwd(),
@ -127,7 +168,15 @@ describe("config plugin validation", () => {
validateInSuite({
plugins: {
enabled: false,
load: { paths: [badPluginDir, bluebubblesPluginDir, voiceCallSchemaPluginDir] },
load: {
paths: [
badPluginDir,
bluebubblesPluginDir,
bundlePluginDir,
manifestlessClaudeBundleDir,
voiceCallSchemaPluginDir,
],
},
},
});
});
@ -252,6 +301,32 @@ describe("config plugin validation", () => {
}
});
it("does not require native config schemas for enabled bundle plugins", async () => {
const res = validateInSuite({
agents: { list: [{ id: "pi" }] },
plugins: {
enabled: true,
load: { paths: [bundlePluginDir] },
entries: { "bundle-fixture": { enabled: true } },
},
});
expect(res.ok).toBe(true);
});
it("accepts enabled manifestless Claude bundles without a native schema", async () => {
const res = validateInSuite({
agents: { list: [{ id: "pi" }] },
plugins: {
enabled: true,
load: { paths: [manifestlessClaudeBundleDir] },
entries: { "manifestless-claude-bundle": { enabled: true } },
},
});
expect(res.ok).toBe(true);
});
it("surfaces allowed enum values for plugin config diagnostics", async () => {
const res = validateInSuite({
agents: { list: [{ id: "pi" }] },

View File

@ -62,6 +62,7 @@ function makeRegistry(plugins: Array<{ id: string; channels: string[] }>): Plugi
channels: p.channels,
providers: [],
skills: [],
hooks: [],
origin: "config" as const,
rootDir: `/fake/${p.id}`,
source: `/fake/${p.id}/index.js`,

View File

@ -596,6 +596,9 @@ function validateConfigObjectWithPluginsBase(
});
}
}
} else if (record.format === "bundle") {
// Compatible bundles currently expose no native OpenClaw config schema.
// Treat them as schema-less capability packs rather than failing validation.
} else {
issues.push({
path: `plugins.entries.${pluginId}`,

View File

@ -0,0 +1,158 @@
import fs from "node:fs";
import fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
clearInternalHooks,
createInternalHookEvent,
triggerInternalHook,
} from "./internal-hooks.js";
import { loadInternalHooks } from "./loader.js";
import { loadWorkspaceHookEntries } from "./workspace.js";
describe("bundle plugin hooks", () => {
let fixtureRoot = "";
let caseId = 0;
let workspaceDir = "";
let previousBundledHooksDir: string | undefined;
beforeAll(async () => {
fixtureRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-hooks-"));
});
beforeEach(async () => {
clearInternalHooks();
workspaceDir = path.join(fixtureRoot, `case-${caseId++}`);
await fsp.mkdir(workspaceDir, { recursive: true });
previousBundledHooksDir = process.env.OPENCLAW_BUNDLED_HOOKS_DIR;
process.env.OPENCLAW_BUNDLED_HOOKS_DIR = "/nonexistent/bundled/hooks";
});
afterEach(() => {
clearInternalHooks();
if (previousBundledHooksDir === undefined) {
delete process.env.OPENCLAW_BUNDLED_HOOKS_DIR;
} else {
process.env.OPENCLAW_BUNDLED_HOOKS_DIR = previousBundledHooksDir;
}
});
afterAll(async () => {
await fsp.rm(fixtureRoot, { recursive: true, force: true });
});
async function writeBundleHookFixture(): Promise<string> {
const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "sample-bundle");
const hookDir = path.join(bundleRoot, "hooks", "bundle-hook");
await fsp.mkdir(path.join(bundleRoot, ".codex-plugin"), { recursive: true });
await fsp.mkdir(hookDir, { recursive: true });
await fsp.writeFile(
path.join(bundleRoot, ".codex-plugin", "plugin.json"),
JSON.stringify({
name: "Sample Bundle",
hooks: "hooks",
}),
"utf-8",
);
await fsp.writeFile(
path.join(hookDir, "HOOK.md"),
[
"---",
"name: bundle-hook",
'description: "Bundle hook"',
'metadata: {"openclaw":{"events":["command:new"]}}',
"---",
"",
"# Bundle hook",
"",
].join("\n"),
"utf-8",
);
await fsp.writeFile(
path.join(hookDir, "handler.js"),
'export default async function(event) { event.messages.push("bundle-hook-ok"); }\n',
"utf-8",
);
return bundleRoot;
}
function createConfig(enabled: boolean): OpenClawConfig {
return {
hooks: {
internal: {
enabled: true,
},
},
plugins: {
entries: {
"sample-bundle": {
enabled,
},
},
},
};
}
it("exposes enabled bundle hook dirs as plugin-managed hook entries", async () => {
const bundleRoot = await writeBundleHookFixture();
const entries = loadWorkspaceHookEntries(workspaceDir, {
config: createConfig(true),
});
expect(entries).toHaveLength(1);
expect(entries[0]?.hook.name).toBe("bundle-hook");
expect(entries[0]?.hook.source).toBe("openclaw-plugin");
expect(entries[0]?.hook.pluginId).toBe("sample-bundle");
expect(entries[0]?.hook.baseDir).toBe(
fs.realpathSync.native(path.join(bundleRoot, "hooks", "bundle-hook")),
);
expect(entries[0]?.metadata?.events).toEqual(["command:new"]);
});
it("loads and executes enabled bundle hooks through the internal hook loader", async () => {
await writeBundleHookFixture();
const count = await loadInternalHooks(createConfig(true), workspaceDir);
expect(count).toBe(1);
const event = createInternalHookEvent("command", "new", "test-session");
await triggerInternalHook(event);
expect(event.messages).toContain("bundle-hook-ok");
});
it("skips disabled bundle hooks", async () => {
await writeBundleHookFixture();
const entries = loadWorkspaceHookEntries(workspaceDir, {
config: createConfig(false),
});
expect(entries).toHaveLength(0);
});
it("does not treat Claude hooks.json bundles as OpenClaw hook packs", async () => {
const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "claude-bundle");
await fsp.mkdir(path.join(bundleRoot, ".claude-plugin"), { recursive: true });
await fsp.mkdir(path.join(bundleRoot, "hooks"), { recursive: true });
await fsp.writeFile(
path.join(bundleRoot, ".claude-plugin", "plugin.json"),
JSON.stringify({
name: "Claude Bundle",
hooks: [{ type: "command" }],
}),
"utf-8",
);
await fsp.writeFile(path.join(bundleRoot, "hooks", "hooks.json"), '{"hooks":[]}', "utf-8");
const entries = loadWorkspaceHookEntries(workspaceDir, {
config: {
hooks: { internal: { enabled: true } },
plugins: { entries: { "claude-bundle": { enabled: true } } },
},
});
expect(entries).toHaveLength(0);
});
});

95
src/hooks/plugin-hooks.ts Normal file
View File

@ -0,0 +1,95 @@
import fs from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import {
normalizePluginsConfig,
resolveEffectiveEnableState,
resolveMemorySlotDecision,
} from "../plugins/config-state.js";
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
import { isPathInsideWithRealpath } from "../security/scan-paths.js";
const log = createSubsystemLogger("hooks");
export type PluginHookDirEntry = {
dir: string;
pluginId: string;
};
export function resolvePluginHookDirs(params: {
workspaceDir: string | undefined;
config?: OpenClawConfig;
}): PluginHookDirEntry[] {
const workspaceDir = (params.workspaceDir ?? "").trim();
if (!workspaceDir) {
return [];
}
const registry = loadPluginManifestRegistry({
workspaceDir,
config: params.config,
});
if (registry.plugins.length === 0) {
return [];
}
const normalizedPlugins = normalizePluginsConfig(params.config?.plugins);
const memorySlot = normalizedPlugins.slots.memory;
let selectedMemoryPluginId: string | null = null;
const seen = new Set<string>();
const resolved: PluginHookDirEntry[] = [];
for (const record of registry.plugins) {
if (!record.hooks || record.hooks.length === 0) {
continue;
}
const enableState = resolveEffectiveEnableState({
id: record.id,
origin: record.origin,
config: normalizedPlugins,
rootConfig: params.config,
});
if (!enableState.enabled) {
continue;
}
const memoryDecision = resolveMemorySlotDecision({
id: record.id,
kind: record.kind,
slot: memorySlot,
selectedId: selectedMemoryPluginId,
});
if (!memoryDecision.enabled) {
continue;
}
if (memoryDecision.selected && record.kind === "memory") {
selectedMemoryPluginId = record.id;
}
for (const raw of record.hooks) {
const trimmed = raw.trim();
if (!trimmed) {
continue;
}
const candidate = path.resolve(record.rootDir, trimmed);
if (!fs.existsSync(candidate)) {
log.warn(`plugin hook path not found (${record.id}): ${candidate}`);
continue;
}
if (!isPathInsideWithRealpath(record.rootDir, candidate, { requireRealpath: true })) {
log.warn(`plugin hook path escapes plugin root (${record.id}): ${candidate}`);
continue;
}
if (seen.has(candidate)) {
continue;
}
seen.add(candidate);
resolved.push({
dir: candidate,
pluginId: record.id,
});
}
}
return resolved;
}

View File

@ -13,6 +13,7 @@ import {
resolveOpenClawMetadata,
resolveHookInvocationPolicy,
} from "./frontmatter.js";
import { resolvePluginHookDirs } from "./plugin-hooks.js";
import type {
Hook,
HookEligibilityContext,
@ -242,6 +243,10 @@ function loadHookEntries(
const extraDirs = extraDirsRaw
.map((d) => (typeof d === "string" ? d.trim() : ""))
.filter(Boolean);
const pluginHookDirs = resolvePluginHookDirs({
workspaceDir,
config: opts?.config,
});
const bundledHooks = bundledHooksDir
? loadHooksFromDir({
@ -256,6 +261,13 @@ function loadHookEntries(
source: "openclaw-workspace", // Extra dirs treated as workspace
});
});
const pluginHooks = pluginHookDirs.flatMap(({ dir, pluginId }) =>
loadHooksFromDir({
dir,
source: "openclaw-plugin",
pluginId,
}),
);
const managedHooks = loadHooksFromDir({
dir: managedHooksDir,
source: "openclaw-managed",
@ -266,13 +278,16 @@ function loadHookEntries(
});
const merged = new Map<string, Hook>();
// Precedence: extra < bundled < managed < workspace (workspace wins)
// Precedence: extra < bundled < plugin < managed < workspace (workspace wins)
for (const hook of extraHooks) {
merged.set(hook.name, hook);
}
for (const hook of bundledHooks) {
merged.set(hook.name, hook);
}
for (const hook of pluginHooks) {
merged.set(hook.name, hook);
}
for (const hook of managedHooks) {
merged.set(hook.name, hook);
}

View File

@ -0,0 +1,201 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH,
CODEX_BUNDLE_MANIFEST_RELATIVE_PATH,
CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH,
detectBundleManifestFormat,
loadBundleManifest,
} from "./bundle-manifest.js";
import {
cleanupTrackedTempDirs,
makeTrackedTempDir,
mkdirSafeDir,
} from "./test-helpers/fs-fixtures.js";
const tempDirs: string[] = [];
function makeTempDir() {
return makeTrackedTempDir("openclaw-bundle-manifest", tempDirs);
}
const mkdirSafe = mkdirSafeDir;
afterEach(() => {
cleanupTrackedTempDirs(tempDirs);
});
describe("bundle manifest parsing", () => {
it("detects and loads Codex bundle manifests", () => {
const rootDir = makeTempDir();
mkdirSafe(path.join(rootDir, ".codex-plugin"));
mkdirSafe(path.join(rootDir, "skills"));
mkdirSafe(path.join(rootDir, "hooks"));
fs.writeFileSync(
path.join(rootDir, CODEX_BUNDLE_MANIFEST_RELATIVE_PATH),
JSON.stringify({
name: "Sample Bundle",
description: "Codex fixture",
skills: "skills",
hooks: "hooks",
mcpServers: {
sample: {
command: "node",
args: ["server.js"],
},
},
apps: {
sample: {
title: "Sample App",
},
},
}),
"utf-8",
);
expect(detectBundleManifestFormat(rootDir)).toBe("codex");
const result = loadBundleManifest({ rootDir, bundleFormat: "codex" });
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.manifest).toMatchObject({
id: "sample-bundle",
name: "Sample Bundle",
description: "Codex fixture",
bundleFormat: "codex",
skills: ["skills"],
hooks: ["hooks"],
capabilities: expect.arrayContaining(["hooks", "skills", "mcpServers", "apps"]),
});
});
it("detects and loads Claude bundle manifests from the component layout", () => {
const rootDir = makeTempDir();
mkdirSafe(path.join(rootDir, ".claude-plugin"));
mkdirSafe(path.join(rootDir, "skill-packs", "starter"));
mkdirSafe(path.join(rootDir, "commands-pack"));
mkdirSafe(path.join(rootDir, "agents-pack"));
mkdirSafe(path.join(rootDir, "hooks-pack"));
mkdirSafe(path.join(rootDir, "mcp"));
mkdirSafe(path.join(rootDir, "lsp"));
mkdirSafe(path.join(rootDir, "styles"));
mkdirSafe(path.join(rootDir, "hooks"));
fs.writeFileSync(path.join(rootDir, "hooks", "hooks.json"), '{"hooks":[]}', "utf-8");
fs.writeFileSync(path.join(rootDir, "settings.json"), '{"hideThinkingBlock":true}', "utf-8");
fs.writeFileSync(
path.join(rootDir, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH),
JSON.stringify({
name: "Claude Sample",
description: "Claude fixture",
skills: ["skill-packs/starter"],
commands: "commands-pack",
agents: "agents-pack",
hooks: "hooks-pack",
mcpServers: "mcp",
lspServers: "lsp",
outputStyles: "styles",
}),
"utf-8",
);
expect(detectBundleManifestFormat(rootDir)).toBe("claude");
const result = loadBundleManifest({ rootDir, bundleFormat: "claude" });
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.manifest).toMatchObject({
id: "claude-sample",
name: "Claude Sample",
description: "Claude fixture",
bundleFormat: "claude",
skills: ["skill-packs/starter", "commands-pack"],
settingsFiles: ["settings.json"],
hooks: [],
capabilities: expect.arrayContaining([
"hooks",
"skills",
"commands",
"agents",
"mcpServers",
"lspServers",
"outputStyles",
"settings",
]),
});
});
it("detects and loads Cursor bundle manifests", () => {
const rootDir = makeTempDir();
mkdirSafe(path.join(rootDir, ".cursor-plugin"));
mkdirSafe(path.join(rootDir, "skills"));
mkdirSafe(path.join(rootDir, ".cursor", "commands"));
mkdirSafe(path.join(rootDir, ".cursor", "rules"));
mkdirSafe(path.join(rootDir, ".cursor", "agents"));
fs.writeFileSync(path.join(rootDir, ".cursor", "hooks.json"), '{"hooks":[]}', "utf-8");
fs.writeFileSync(
path.join(rootDir, CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH),
JSON.stringify({
name: "Cursor Sample",
description: "Cursor fixture",
mcpServers: "./.mcp.json",
}),
"utf-8",
);
fs.writeFileSync(path.join(rootDir, ".mcp.json"), '{"servers":{}}', "utf-8");
expect(detectBundleManifestFormat(rootDir)).toBe("cursor");
const result = loadBundleManifest({ rootDir, bundleFormat: "cursor" });
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.manifest).toMatchObject({
id: "cursor-sample",
name: "Cursor Sample",
description: "Cursor fixture",
bundleFormat: "cursor",
skills: ["skills", ".cursor/commands"],
hooks: [],
capabilities: expect.arrayContaining([
"skills",
"commands",
"agents",
"rules",
"hooks",
"mcpServers",
]),
});
});
it("detects manifestless Claude bundles from the default layout", () => {
const rootDir = makeTempDir();
mkdirSafe(path.join(rootDir, "commands"));
mkdirSafe(path.join(rootDir, "skills"));
fs.writeFileSync(path.join(rootDir, "settings.json"), '{"hideThinkingBlock":true}', "utf-8");
expect(detectBundleManifestFormat(rootDir)).toBe("claude");
const result = loadBundleManifest({ rootDir, bundleFormat: "claude" });
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.manifest.id).toBe(path.basename(rootDir).toLowerCase());
expect(result.manifest.skills).toEqual(["skills", "commands"]);
expect(result.manifest.settingsFiles).toEqual(["settings.json"]);
expect(result.manifest.capabilities).toEqual(
expect.arrayContaining(["skills", "commands", "settings"]),
);
});
it("does not misclassify native index plugins as manifestless Claude bundles", () => {
const rootDir = makeTempDir();
mkdirSafe(path.join(rootDir, "commands"));
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {}", "utf-8");
expect(detectBundleManifestFormat(rootDir)).toBeNull();
});
});

View File

@ -0,0 +1,441 @@
import fs from "node:fs";
import path from "node:path";
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
import { isRecord } from "../utils.js";
import { DEFAULT_PLUGIN_ENTRY_CANDIDATES, PLUGIN_MANIFEST_FILENAME } from "./manifest.js";
import type { PluginBundleFormat } from "./types.js";
export const CODEX_BUNDLE_MANIFEST_RELATIVE_PATH = ".codex-plugin/plugin.json";
export const CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH = ".claude-plugin/plugin.json";
export const CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH = ".cursor-plugin/plugin.json";
export type BundlePluginManifest = {
id: string;
name?: string;
description?: string;
version?: string;
skills: string[];
settingsFiles?: string[];
// Only include hook roots that OpenClaw can execute via HOOK.md + handler files.
hooks: string[];
bundleFormat: PluginBundleFormat;
capabilities: string[];
};
export type BundleManifestLoadResult =
| { ok: true; manifest: BundlePluginManifest; manifestPath: string }
| { ok: false; error: string; manifestPath: string };
type BundleManifestFileLoadResult =
| { ok: true; raw: Record<string, unknown>; manifestPath: string }
| { ok: false; error: string; manifestPath: string };
function normalizeString(value: unknown): string | undefined {
const trimmed = typeof value === "string" ? value.trim() : "";
return trimmed || undefined;
}
function normalizePathList(value: unknown): string[] {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed ? [trimmed] : [];
}
if (!Array.isArray(value)) {
return [];
}
return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean);
}
function normalizeBundlePathList(value: unknown): string[] {
return Array.from(new Set(normalizePathList(value)));
}
function mergeBundlePathLists(...groups: string[][]): string[] {
const merged: string[] = [];
const seen = new Set<string>();
for (const group of groups) {
for (const entry of group) {
if (seen.has(entry)) {
continue;
}
seen.add(entry);
merged.push(entry);
}
}
return merged;
}
function hasInlineCapabilityValue(value: unknown): boolean {
if (typeof value === "string") {
return value.trim().length > 0;
}
if (Array.isArray(value)) {
return value.length > 0;
}
if (isRecord(value)) {
return Object.keys(value).length > 0;
}
return value === true;
}
function slugifyPluginId(raw: string | undefined, rootDir: string): string {
const fallback = path.basename(rootDir);
const source = (raw?.trim() || fallback).toLowerCase();
const slug = source
.replace(/[^a-z0-9]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "");
return slug || "bundle-plugin";
}
function loadBundleManifestFile(params: {
rootDir: string;
manifestRelativePath: string;
rejectHardlinks: boolean;
allowMissing?: boolean;
}): BundleManifestFileLoadResult {
const manifestPath = path.join(params.rootDir, params.manifestRelativePath);
const opened = openBoundaryFileSync({
absolutePath: manifestPath,
rootPath: params.rootDir,
boundaryLabel: "plugin root",
rejectHardlinks: params.rejectHardlinks,
});
if (!opened.ok) {
if (opened.reason === "path") {
if (params.allowMissing) {
return { ok: true, raw: {}, manifestPath };
}
return { ok: false, error: `plugin manifest not found: ${manifestPath}`, manifestPath };
}
return {
ok: false,
error: `unsafe plugin manifest path: ${manifestPath} (${opened.reason})`,
manifestPath,
};
}
try {
const raw = JSON.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown;
if (!isRecord(raw)) {
return { ok: false, error: "plugin manifest must be an object", manifestPath };
}
return { ok: true, raw, manifestPath };
} catch (err) {
return {
ok: false,
error: `failed to parse plugin manifest: ${String(err)}`,
manifestPath,
};
} finally {
fs.closeSync(opened.fd);
}
}
function resolveCodexSkillDirs(raw: Record<string, unknown>, rootDir: string): string[] {
const declared = normalizeBundlePathList(raw.skills);
if (declared.length > 0) {
return declared;
}
return fs.existsSync(path.join(rootDir, "skills")) ? ["skills"] : [];
}
function resolveCodexHookDirs(raw: Record<string, unknown>, rootDir: string): string[] {
const declared = normalizeBundlePathList(raw.hooks);
if (declared.length > 0) {
return declared;
}
return fs.existsSync(path.join(rootDir, "hooks")) ? ["hooks"] : [];
}
function resolveCursorSkillsRootDirs(raw: Record<string, unknown>, rootDir: string): string[] {
const declared = normalizeBundlePathList(raw.skills);
const defaults = fs.existsSync(path.join(rootDir, "skills")) ? ["skills"] : [];
return mergeBundlePathLists(defaults, declared);
}
function resolveCursorCommandRootDirs(raw: Record<string, unknown>, rootDir: string): string[] {
const declared = normalizeBundlePathList(raw.commands);
const defaults = fs.existsSync(path.join(rootDir, ".cursor", "commands"))
? [".cursor/commands"]
: [];
return mergeBundlePathLists(defaults, declared);
}
function resolveCursorSkillDirs(raw: Record<string, unknown>, rootDir: string): string[] {
return mergeBundlePathLists(
resolveCursorSkillsRootDirs(raw, rootDir),
resolveCursorCommandRootDirs(raw, rootDir),
);
}
function resolveCursorAgentDirs(raw: Record<string, unknown>, rootDir: string): string[] {
const declared = normalizeBundlePathList(raw.subagents ?? raw.agents);
const defaults = fs.existsSync(path.join(rootDir, ".cursor", "agents")) ? [".cursor/agents"] : [];
return mergeBundlePathLists(defaults, declared);
}
function hasCursorHookCapability(raw: Record<string, unknown>, rootDir: string): boolean {
return (
hasInlineCapabilityValue(raw.hooks) ||
fs.existsSync(path.join(rootDir, ".cursor", "hooks.json"))
);
}
function hasCursorRulesCapability(raw: Record<string, unknown>, rootDir: string): boolean {
return (
hasInlineCapabilityValue(raw.rules) || fs.existsSync(path.join(rootDir, ".cursor", "rules"))
);
}
function hasCursorMcpCapability(raw: Record<string, unknown>, rootDir: string): boolean {
return hasInlineCapabilityValue(raw.mcpServers) || fs.existsSync(path.join(rootDir, ".mcp.json"));
}
function resolveClaudeComponentPaths(
raw: Record<string, unknown>,
key: string,
rootDir: string,
defaults: string[],
): string[] {
const declared = normalizeBundlePathList(raw[key]);
const existingDefaults = defaults.filter((candidate) =>
fs.existsSync(path.join(rootDir, candidate)),
);
return mergeBundlePathLists(existingDefaults, declared);
}
function resolveClaudeSkillsRootDirs(raw: Record<string, unknown>, rootDir: string): string[] {
return resolveClaudeComponentPaths(raw, "skills", rootDir, ["skills"]);
}
function resolveClaudeCommandRootDirs(raw: Record<string, unknown>, rootDir: string): string[] {
return resolveClaudeComponentPaths(raw, "commands", rootDir, ["commands"]);
}
function resolveClaudeSkillDirs(raw: Record<string, unknown>, rootDir: string): string[] {
return mergeBundlePathLists(
resolveClaudeSkillsRootDirs(raw, rootDir),
resolveClaudeCommandRootDirs(raw, rootDir),
);
}
function resolveClaudeAgentDirs(raw: Record<string, unknown>, rootDir: string): string[] {
return resolveClaudeComponentPaths(raw, "agents", rootDir, ["agents"]);
}
function resolveClaudeHookPaths(raw: Record<string, unknown>, rootDir: string): string[] {
return resolveClaudeComponentPaths(raw, "hooks", rootDir, ["hooks/hooks.json"]);
}
function resolveClaudeMcpPaths(raw: Record<string, unknown>, rootDir: string): string[] {
return resolveClaudeComponentPaths(raw, "mcpServers", rootDir, [".mcp.json"]);
}
function resolveClaudeLspPaths(raw: Record<string, unknown>, rootDir: string): string[] {
return resolveClaudeComponentPaths(raw, "lspServers", rootDir, [".lsp.json"]);
}
function resolveClaudeOutputStylePaths(raw: Record<string, unknown>, rootDir: string): string[] {
return resolveClaudeComponentPaths(raw, "outputStyles", rootDir, ["output-styles"]);
}
function resolveClaudeSettingsFiles(_raw: Record<string, unknown>, rootDir: string): string[] {
return fs.existsSync(path.join(rootDir, "settings.json")) ? ["settings.json"] : [];
}
function hasClaudeHookCapability(raw: Record<string, unknown>, rootDir: string): boolean {
return hasInlineCapabilityValue(raw.hooks) || resolveClaudeHookPaths(raw, rootDir).length > 0;
}
function buildCodexCapabilities(raw: Record<string, unknown>, rootDir: string): string[] {
const capabilities: string[] = [];
if (resolveCodexSkillDirs(raw, rootDir).length > 0) {
capabilities.push("skills");
}
if (resolveCodexHookDirs(raw, rootDir).length > 0) {
capabilities.push("hooks");
}
if (hasInlineCapabilityValue(raw.mcpServers) || fs.existsSync(path.join(rootDir, ".mcp.json"))) {
capabilities.push("mcpServers");
}
if (hasInlineCapabilityValue(raw.apps) || fs.existsSync(path.join(rootDir, ".app.json"))) {
capabilities.push("apps");
}
return capabilities;
}
function buildClaudeCapabilities(raw: Record<string, unknown>, rootDir: string): string[] {
const capabilities: string[] = [];
if (resolveClaudeSkillDirs(raw, rootDir).length > 0) {
capabilities.push("skills");
}
if (resolveClaudeCommandRootDirs(raw, rootDir).length > 0) {
capabilities.push("commands");
}
if (resolveClaudeAgentDirs(raw, rootDir).length > 0) {
capabilities.push("agents");
}
if (hasClaudeHookCapability(raw, rootDir)) {
capabilities.push("hooks");
}
if (hasInlineCapabilityValue(raw.mcpServers) || resolveClaudeMcpPaths(raw, rootDir).length > 0) {
capabilities.push("mcpServers");
}
if (hasInlineCapabilityValue(raw.lspServers) || resolveClaudeLspPaths(raw, rootDir).length > 0) {
capabilities.push("lspServers");
}
if (
hasInlineCapabilityValue(raw.outputStyles) ||
resolveClaudeOutputStylePaths(raw, rootDir).length > 0
) {
capabilities.push("outputStyles");
}
if (resolveClaudeSettingsFiles(raw, rootDir).length > 0) {
capabilities.push("settings");
}
return capabilities;
}
function buildCursorCapabilities(raw: Record<string, unknown>, rootDir: string): string[] {
const capabilities: string[] = [];
if (resolveCursorSkillDirs(raw, rootDir).length > 0) {
capabilities.push("skills");
}
if (resolveCursorCommandRootDirs(raw, rootDir).length > 0) {
capabilities.push("commands");
}
if (resolveCursorAgentDirs(raw, rootDir).length > 0) {
capabilities.push("agents");
}
if (hasCursorHookCapability(raw, rootDir)) {
capabilities.push("hooks");
}
if (hasCursorRulesCapability(raw, rootDir)) {
capabilities.push("rules");
}
if (hasCursorMcpCapability(raw, rootDir)) {
capabilities.push("mcpServers");
}
return capabilities;
}
export function loadBundleManifest(params: {
rootDir: string;
bundleFormat: PluginBundleFormat;
rejectHardlinks?: boolean;
}): BundleManifestLoadResult {
const rejectHardlinks = params.rejectHardlinks ?? true;
const manifestRelativePath =
params.bundleFormat === "codex"
? CODEX_BUNDLE_MANIFEST_RELATIVE_PATH
: params.bundleFormat === "cursor"
? CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH
: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH;
const loaded = loadBundleManifestFile({
rootDir: params.rootDir,
manifestRelativePath,
rejectHardlinks,
allowMissing: params.bundleFormat === "claude",
});
if (!loaded.ok) {
return loaded;
}
const raw = loaded.raw;
const interfaceRecord = isRecord(raw.interface) ? raw.interface : undefined;
const name = normalizeString(raw.name);
const description =
normalizeString(raw.description) ??
normalizeString(raw.shortDescription) ??
normalizeString(interfaceRecord?.shortDescription);
const version = normalizeString(raw.version);
if (params.bundleFormat === "codex") {
const skills = resolveCodexSkillDirs(raw, params.rootDir);
const hooks = resolveCodexHookDirs(raw, params.rootDir);
return {
ok: true,
manifest: {
id: slugifyPluginId(name, params.rootDir),
name,
description,
version,
skills,
settingsFiles: [],
hooks,
bundleFormat: "codex",
capabilities: buildCodexCapabilities(raw, params.rootDir),
},
manifestPath: loaded.manifestPath,
};
}
if (params.bundleFormat === "cursor") {
return {
ok: true,
manifest: {
id: slugifyPluginId(name, params.rootDir),
name,
description,
version,
skills: resolveCursorSkillDirs(raw, params.rootDir),
settingsFiles: [],
hooks: [],
bundleFormat: "cursor",
capabilities: buildCursorCapabilities(raw, params.rootDir),
},
manifestPath: loaded.manifestPath,
};
}
return {
ok: true,
manifest: {
id: slugifyPluginId(name, params.rootDir),
name,
description,
version,
skills: resolveClaudeSkillDirs(raw, params.rootDir),
settingsFiles: resolveClaudeSettingsFiles(raw, params.rootDir),
hooks: [],
bundleFormat: "claude",
capabilities: buildClaudeCapabilities(raw, params.rootDir),
},
manifestPath: loaded.manifestPath,
};
}
export function detectBundleManifestFormat(rootDir: string): PluginBundleFormat | null {
if (fs.existsSync(path.join(rootDir, CODEX_BUNDLE_MANIFEST_RELATIVE_PATH))) {
return "codex";
}
if (fs.existsSync(path.join(rootDir, CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH))) {
return "cursor";
}
if (fs.existsSync(path.join(rootDir, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH))) {
return "claude";
}
if (fs.existsSync(path.join(rootDir, PLUGIN_MANIFEST_FILENAME))) {
return null;
}
if (
DEFAULT_PLUGIN_ENTRY_CANDIDATES.some((candidate) =>
fs.existsSync(path.join(rootDir, candidate)),
)
) {
return null;
}
const manifestlessClaudeMarkers = [
path.join(rootDir, "skills"),
path.join(rootDir, "commands"),
path.join(rootDir, "agents"),
path.join(rootDir, "hooks", "hooks.json"),
path.join(rootDir, ".mcp.json"),
path.join(rootDir, ".lsp.json"),
path.join(rootDir, "settings.json"),
];
if (manifestlessClaudeMarkers.some((candidate) => fs.existsSync(candidate))) {
return "claude";
}
return null;
}

View File

@ -219,6 +219,109 @@ describe("discoverOpenClawPlugins", () => {
const ids = candidates.map((c) => c.idHint);
expect(ids).toContain("demo-plugin-dir");
});
it("auto-detects Codex bundles as bundle candidates", async () => {
const stateDir = makeTempDir();
const bundleDir = path.join(stateDir, "extensions", "sample-bundle");
mkdirSafe(path.join(bundleDir, ".codex-plugin"));
mkdirSafe(path.join(bundleDir, "skills"));
fs.writeFileSync(
path.join(bundleDir, ".codex-plugin", "plugin.json"),
JSON.stringify({
name: "Sample Bundle",
skills: "skills",
}),
"utf-8",
);
const { candidates } = await discoverWithStateDir(stateDir, {});
const bundle = candidates.find((candidate) => candidate.idHint === "sample-bundle");
expect(bundle).toBeDefined();
expect(bundle?.idHint).toBe("sample-bundle");
expect(bundle?.format).toBe("bundle");
expect(bundle?.bundleFormat).toBe("codex");
expect(bundle?.source).toBe(bundleDir);
expect(bundle?.rootDir).toBe(fs.realpathSync.native(bundleDir));
});
it("auto-detects manifestless Claude bundles from the default layout", async () => {
const stateDir = makeTempDir();
const bundleDir = path.join(stateDir, "extensions", "claude-bundle");
mkdirSafe(path.join(bundleDir, "commands"));
fs.writeFileSync(path.join(bundleDir, "settings.json"), '{"hideThinkingBlock":true}', "utf-8");
const { candidates } = await discoverWithStateDir(stateDir, {});
const bundle = candidates.find((candidate) => candidate.idHint === "claude-bundle");
expect(bundle).toBeDefined();
expect(bundle?.format).toBe("bundle");
expect(bundle?.bundleFormat).toBe("claude");
expect(bundle?.source).toBe(bundleDir);
});
it("auto-detects Cursor bundles as bundle candidates", async () => {
const stateDir = makeTempDir();
const bundleDir = path.join(stateDir, "extensions", "cursor-bundle");
mkdirSafe(path.join(bundleDir, ".cursor-plugin"));
mkdirSafe(path.join(bundleDir, ".cursor", "commands"));
fs.writeFileSync(
path.join(bundleDir, ".cursor-plugin", "plugin.json"),
JSON.stringify({
name: "Cursor Bundle",
}),
"utf-8",
);
const { candidates } = await discoverWithStateDir(stateDir, {});
const bundle = candidates.find((candidate) => candidate.idHint === "cursor-bundle");
expect(bundle).toBeDefined();
expect(bundle?.format).toBe("bundle");
expect(bundle?.bundleFormat).toBe("cursor");
expect(bundle?.source).toBe(bundleDir);
});
it("falls back to legacy index discovery when a scanned bundle sidecar is malformed", async () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "extensions", "legacy-with-bad-bundle");
mkdirSafe(path.join(pluginDir, ".claude-plugin"));
fs.writeFileSync(path.join(pluginDir, "index.ts"), "export default {}", "utf-8");
fs.writeFileSync(path.join(pluginDir, ".claude-plugin", "plugin.json"), "{", "utf-8");
const result = await discoverWithStateDir(stateDir, {});
const legacy = result.candidates.find(
(candidate) => candidate.idHint === "legacy-with-bad-bundle",
);
expect(legacy).toBeDefined();
expect(legacy?.format).toBe("openclaw");
expect(
result.diagnostics.some((entry) => entry.source?.endsWith(".claude-plugin/plugin.json")),
).toBe(true);
});
it("falls back to legacy index discovery for configured paths with malformed bundle sidecars", async () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "plugins", "legacy-with-bad-bundle");
mkdirSafe(path.join(pluginDir, ".codex-plugin"));
fs.writeFileSync(path.join(pluginDir, "index.ts"), "export default {}", "utf-8");
fs.writeFileSync(path.join(pluginDir, ".codex-plugin", "plugin.json"), "{", "utf-8");
const result = await discoverWithStateDir(stateDir, {
extraPaths: [pluginDir],
});
const legacy = result.candidates.find(
(candidate) => candidate.idHint === "legacy-with-bad-bundle",
);
expect(legacy).toBeDefined();
expect(legacy?.format).toBe("openclaw");
expect(
result.diagnostics.some((entry) => entry.source?.endsWith(".codex-plugin/plugin.json")),
).toBe(true);
});
it("blocks extension entries that escape package directory", async () => {
const stateDir = makeTempDir();
const globalExt = path.join(stateDir, "extensions", "escape-pack");

View File

@ -2,6 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
import { resolveUserPath } from "../utils.js";
import { detectBundleManifestFormat, loadBundleManifest } from "./bundle-manifest.js";
import {
DEFAULT_PLUGIN_ENTRY_CANDIDATES,
getPackageManifestMetadata,
@ -11,7 +12,7 @@ import {
} from "./manifest.js";
import { formatPosixMode, isPathInside, safeRealpathSync, safeStatSync } from "./path-safety.js";
import { resolvePluginCacheInputs, resolvePluginSourceRoots } from "./roots.js";
import type { PluginDiagnostic, PluginOrigin } from "./types.js";
import type { PluginBundleFormat, PluginDiagnostic, PluginFormat, PluginOrigin } from "./types.js";
const EXTENSION_EXTS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]);
@ -20,6 +21,8 @@ export type PluginCandidate = {
source: string;
rootDir: string;
origin: PluginOrigin;
format?: PluginFormat;
bundleFormat?: PluginBundleFormat;
workspaceDir?: string;
packageName?: string;
packageVersion?: string;
@ -354,6 +357,8 @@ function addCandidate(params: {
source: string;
rootDir: string;
origin: PluginOrigin;
format?: PluginFormat;
bundleFormat?: PluginBundleFormat;
ownershipUid?: number | null;
workspaceDir?: string;
manifest?: PackageManifest | null;
@ -382,6 +387,8 @@ function addCandidate(params: {
source: resolved,
rootDir: resolvedRoot,
origin: params.origin,
format: params.format ?? "openclaw",
bundleFormat: params.bundleFormat,
workspaceDir: params.workspaceDir,
packageName: manifest?.name?.trim() || undefined,
packageVersion: manifest?.version?.trim() || undefined,
@ -391,6 +398,48 @@ function addCandidate(params: {
});
}
function discoverBundleInRoot(params: {
rootDir: string;
origin: PluginOrigin;
ownershipUid?: number | null;
workspaceDir?: string;
candidates: PluginCandidate[];
diagnostics: PluginDiagnostic[];
seen: Set<string>;
}): "added" | "invalid" | "none" {
const bundleFormat = detectBundleManifestFormat(params.rootDir);
if (!bundleFormat) {
return "none";
}
const bundleManifest = loadBundleManifest({
rootDir: params.rootDir,
bundleFormat,
rejectHardlinks: params.origin !== "bundled",
});
if (!bundleManifest.ok) {
params.diagnostics.push({
level: "error",
message: bundleManifest.error,
source: bundleManifest.manifestPath,
});
return "invalid";
}
addCandidate({
candidates: params.candidates,
diagnostics: params.diagnostics,
seen: params.seen,
idHint: bundleManifest.manifest.id,
source: params.rootDir,
rootDir: params.rootDir,
origin: params.origin,
format: "bundle",
bundleFormat,
ownershipUid: params.ownershipUid,
workspaceDir: params.workspaceDir,
});
return "added";
}
function resolvePackageEntrySource(params: {
packageDir: string;
entryPath: string;
@ -505,6 +554,19 @@ function discoverInDirectory(params: {
continue;
}
const bundleDiscovery = discoverBundleInRoot({
rootDir: fullPath,
origin: params.origin,
ownershipUid: params.ownershipUid,
workspaceDir: params.workspaceDir,
candidates: params.candidates,
diagnostics: params.diagnostics,
seen: params.seen,
});
if (bundleDiscovery === "added") {
continue;
}
const indexFile = [...DEFAULT_PLUGIN_ENTRY_CANDIDATES]
.map((candidate) => path.join(fullPath, candidate))
.find((candidate) => fs.existsSync(candidate));
@ -609,6 +671,19 @@ function discoverFromPath(params: {
return;
}
const bundleDiscovery = discoverBundleInRoot({
rootDir: resolved,
origin: params.origin,
ownershipUid: params.ownershipUid,
workspaceDir: params.workspaceDir,
candidates: params.candidates,
diagnostics: params.diagnostics,
seen: params.seen,
});
if (bundleDiscovery === "added") {
return;
}
const indexFile = [...DEFAULT_PLUGIN_ENTRY_CANDIDATES]
.map((candidate) => path.join(resolved, candidate))
.find((candidate) => fs.existsSync(candidate));

View File

@ -5,7 +5,10 @@ import * as tar from "tar";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { safePathSegmentHashed } from "../infra/install-safe-path.js";
import * as skillScanner from "../security/skill-scanner.js";
import { expectSingleNpmPackIgnoreScriptsCall } from "../test-utils/exec-assertions.js";
import {
expectSingleNpmInstallIgnoreScriptsCall,
expectSingleNpmPackIgnoreScriptsCall,
} from "../test-utils/exec-assertions.js";
import {
expectInstallUsesIgnoreScripts,
expectIntegrityDriftRejected,
@ -235,6 +238,107 @@ function setupManifestInstallFixture(params: { manifestId: string }) {
return { pluginDir, extensionsDir: path.join(stateDir, "extensions") };
}
function setupBundleInstallFixture(params: {
bundleFormat: "codex" | "claude" | "cursor";
name: string;
}) {
const caseDir = makeTempDir();
const stateDir = path.join(caseDir, "state");
const pluginDir = path.join(caseDir, "plugin-src");
fs.mkdirSync(stateDir, { recursive: true });
fs.mkdirSync(path.join(pluginDir, "skills"), { recursive: true });
const manifestDir = path.join(
pluginDir,
params.bundleFormat === "codex"
? ".codex-plugin"
: params.bundleFormat === "cursor"
? ".cursor-plugin"
: ".claude-plugin",
);
fs.mkdirSync(manifestDir, { recursive: true });
fs.writeFileSync(
path.join(manifestDir, "plugin.json"),
JSON.stringify({
name: params.name,
description: `${params.bundleFormat} bundle fixture`,
...(params.bundleFormat === "codex" ? { skills: "skills" } : {}),
}),
"utf-8",
);
if (params.bundleFormat === "cursor") {
fs.mkdirSync(path.join(pluginDir, ".cursor", "commands"), { recursive: true });
fs.writeFileSync(
path.join(pluginDir, ".cursor", "commands", "review.md"),
"---\ndescription: fixture\n---\n",
"utf-8",
);
}
fs.writeFileSync(
path.join(pluginDir, "skills", "SKILL.md"),
"---\ndescription: fixture\n---\n",
"utf-8",
);
return { pluginDir, extensionsDir: path.join(stateDir, "extensions") };
}
function setupManifestlessClaudeInstallFixture() {
const caseDir = makeTempDir();
const stateDir = path.join(caseDir, "state");
const pluginDir = path.join(caseDir, "claude-manifestless");
fs.mkdirSync(stateDir, { recursive: true });
fs.mkdirSync(path.join(pluginDir, "commands"), { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "commands", "review.md"),
"---\ndescription: fixture\n---\n",
"utf-8",
);
fs.writeFileSync(path.join(pluginDir, "settings.json"), '{"hideThinkingBlock":true}', "utf-8");
return { pluginDir, extensionsDir: path.join(stateDir, "extensions") };
}
function setupDualFormatInstallFixture(params: { bundleFormat: "codex" | "claude" }) {
const caseDir = makeTempDir();
const stateDir = path.join(caseDir, "state");
const pluginDir = path.join(caseDir, "plugin-src");
fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true });
fs.mkdirSync(path.join(pluginDir, "skills"), { recursive: true });
const manifestDir = path.join(
pluginDir,
params.bundleFormat === "codex" ? ".codex-plugin" : ".claude-plugin",
);
fs.mkdirSync(manifestDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "@openclaw/native-dual",
version: "0.0.1",
openclaw: { extensions: ["./dist/index.js"] },
dependencies: { "left-pad": "1.3.0" },
}),
"utf-8",
);
fs.writeFileSync(
path.join(pluginDir, "openclaw.plugin.json"),
JSON.stringify({
id: "native-dual",
configSchema: { type: "object", properties: {} },
skills: ["skills"],
}),
"utf-8",
);
fs.writeFileSync(path.join(pluginDir, "dist", "index.js"), "export {};", "utf-8");
fs.writeFileSync(path.join(pluginDir, "skills", "SKILL.md"), "---\ndescription: fixture\n---\n");
fs.writeFileSync(
path.join(manifestDir, "plugin.json"),
JSON.stringify({
name: "Bundle Fallback",
...(params.bundleFormat === "codex" ? { skills: "skills" } : {}),
}),
"utf-8",
);
return { pluginDir, extensionsDir: path.join(stateDir, "extensions") };
}
async function expectArchiveInstallReservedSegmentRejection(params: {
packageName: string;
outName: string;
@ -770,6 +874,95 @@ describe("installPluginFromDir", () => {
expect(path.basename(scopedTarget)).toBe(`@${hashedFlatId}`);
expect(scopedTarget).not.toBe(flatTarget);
});
it("installs Codex bundles from a local directory", async () => {
const { pluginDir, extensionsDir } = setupBundleInstallFixture({
bundleFormat: "codex",
name: "Sample Bundle",
});
const res = await installPluginFromDir({
dirPath: pluginDir,
extensionsDir,
});
expect(res.ok).toBe(true);
if (!res.ok) {
return;
}
expect(res.pluginId).toBe("sample-bundle");
expect(fs.existsSync(path.join(res.targetDir, ".codex-plugin", "plugin.json"))).toBe(true);
expect(fs.existsSync(path.join(res.targetDir, "skills", "SKILL.md"))).toBe(true);
});
it("prefers native package installs over bundle installs for dual-format directories", async () => {
const { pluginDir, extensionsDir } = setupDualFormatInstallFixture({
bundleFormat: "codex",
});
const run = vi.mocked(runCommandWithTimeout);
run.mockResolvedValue({
code: 0,
stdout: "",
stderr: "",
signal: null,
killed: false,
termination: "exit",
});
const res = await installPluginFromDir({
dirPath: pluginDir,
extensionsDir,
});
expect(res.ok).toBe(true);
if (!res.ok) {
return;
}
expect(res.pluginId).toBe("native-dual");
expect(res.targetDir).toBe(path.join(extensionsDir, "native-dual"));
expectSingleNpmInstallIgnoreScriptsCall({
calls: run.mock.calls as Array<[unknown, { cwd?: string } | undefined]>,
expectedTargetDir: res.targetDir,
});
});
it("installs manifestless Claude bundles from a local directory", async () => {
const { pluginDir, extensionsDir } = setupManifestlessClaudeInstallFixture();
const res = await installPluginFromDir({
dirPath: pluginDir,
extensionsDir,
});
expect(res.ok).toBe(true);
if (!res.ok) {
return;
}
expect(res.pluginId).toBe("claude-manifestless");
expect(fs.existsSync(path.join(res.targetDir, "commands", "review.md"))).toBe(true);
expect(fs.existsSync(path.join(res.targetDir, "settings.json"))).toBe(true);
});
it("installs Cursor bundles from a local directory", async () => {
const { pluginDir, extensionsDir } = setupBundleInstallFixture({
bundleFormat: "cursor",
name: "Cursor Sample",
});
const res = await installPluginFromDir({
dirPath: pluginDir,
extensionsDir,
});
expect(res.ok).toBe(true);
if (!res.ok) {
return;
}
expect(res.pluginId).toBe("cursor-sample");
expect(fs.existsSync(path.join(res.targetDir, ".cursor-plugin", "plugin.json"))).toBe(true);
expect(fs.existsSync(path.join(res.targetDir, ".cursor", "commands", "review.md"))).toBe(true);
});
});
describe("installPluginFromPath", () => {
@ -801,6 +994,69 @@ describe("installPluginFromPath", () => {
expect(result.error.toLowerCase()).toMatch(/hardlink|path alias escape/);
expect(fs.readFileSync(victimPath, "utf-8")).toBe("ORIGINAL");
});
it("installs Claude bundles from an archive path", async () => {
const { pluginDir, extensionsDir } = setupBundleInstallFixture({
bundleFormat: "claude",
name: "Claude Sample",
});
const archivePath = path.join(makeTempDir(), "claude-bundle.tgz");
await packToArchive({
pkgDir: pluginDir,
outDir: path.dirname(archivePath),
outName: path.basename(archivePath),
});
const result = await installPluginFromPath({
path: archivePath,
extensionsDir,
});
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.pluginId).toBe("claude-sample");
expect(fs.existsSync(path.join(result.targetDir, ".claude-plugin", "plugin.json"))).toBe(true);
});
it("prefers native package installs over bundle installs for dual-format archives", async () => {
const { pluginDir, extensionsDir } = setupDualFormatInstallFixture({
bundleFormat: "claude",
});
const archivePath = path.join(makeTempDir(), "dual-format.tgz");
await packToArchive({
pkgDir: pluginDir,
outDir: path.dirname(archivePath),
outName: path.basename(archivePath),
});
const run = vi.mocked(runCommandWithTimeout);
run.mockResolvedValue({
code: 0,
stdout: "",
stderr: "",
signal: null,
killed: false,
termination: "exit",
});
const result = await installPluginFromPath({
path: archivePath,
extensionsDir,
});
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.pluginId).toBe("native-dual");
expect(result.targetDir).toBe(path.join(extensionsDir, "native-dual"));
expectSingleNpmInstallIgnoreScriptsCall({
calls: run.mock.calls as Array<[unknown, { cwd?: string } | undefined]>,
expectedTargetDir: result.targetDir,
});
});
});
describe("installPluginFromNpmSpec", () => {

View File

@ -31,6 +31,7 @@ import { validateRegistryNpmSpec } from "../infra/npm-registry-spec.js";
import { extensionUsesSkippedScannerPath, isPathInside } from "../security/scan-paths.js";
import * as skillScanner from "../security/skill-scanner.js";
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
import { detectBundleManifestFormat, loadBundleManifest } from "./bundle-manifest.js";
import {
loadPluginManifest,
resolvePackageExtensionEntries,
@ -253,6 +254,156 @@ export function resolvePluginInstallDir(pluginId: string, extensionsDir?: string
return targetDirResult.path;
}
async function installBundleFromSourceDir(
params: {
sourceDir: string;
} & PackageInstallCommonParams,
): Promise<InstallPluginResult | null> {
const bundleFormat = detectBundleManifestFormat(params.sourceDir);
if (!bundleFormat) {
return null;
}
const { logger, timeoutMs, mode, dryRun } = resolveTimedInstallModeOptions(params, defaultLogger);
const manifestRes = loadBundleManifest({
rootDir: params.sourceDir,
bundleFormat,
rejectHardlinks: true,
});
if (!manifestRes.ok) {
return { ok: false, error: manifestRes.error };
}
const pluginId = manifestRes.manifest.id;
const pluginIdError = validatePluginId(pluginId);
if (pluginIdError) {
return { ok: false, error: pluginIdError };
}
if (params.expectedPluginId && params.expectedPluginId !== pluginId) {
return {
ok: false,
error: `plugin id mismatch: expected ${params.expectedPluginId}, got ${pluginId}`,
code: PLUGIN_INSTALL_ERROR_CODE.PLUGIN_ID_MISMATCH,
};
}
try {
const scanSummary = await skillScanner.scanDirectoryWithSummary(params.sourceDir);
if (scanSummary.critical > 0) {
const criticalDetails = scanSummary.findings
.filter((f) => f.severity === "critical")
.map((f) => `${f.message} (${f.file}:${f.line})`)
.join("; ");
logger.warn?.(
`WARNING: Bundle "${pluginId}" contains dangerous code patterns: ${criticalDetails}`,
);
} else if (scanSummary.warn > 0) {
logger.warn?.(
`Bundle "${pluginId}" has ${scanSummary.warn} suspicious code pattern(s). Run "openclaw security audit --deep" for details.`,
);
}
} catch (err) {
logger.warn?.(
`Bundle "${pluginId}" code safety scan failed (${String(err)}). Installation continues; run "openclaw security audit --deep" after install.`,
);
}
const extensionsDir = params.extensionsDir
? resolveUserPath(params.extensionsDir)
: path.join(CONFIG_DIR, "extensions");
const targetDirResult = await resolveCanonicalInstallTarget({
baseDir: extensionsDir,
id: pluginId,
invalidNameMessage: "invalid plugin name: path traversal detected",
boundaryLabel: "extensions directory",
});
if (!targetDirResult.ok) {
return { ok: false, error: targetDirResult.error };
}
const targetDir = targetDirResult.targetDir;
const availability = await ensureInstallTargetAvailable({
mode,
targetDir,
alreadyExistsError: `plugin already exists: ${targetDir} (delete it first)`,
});
if (!availability.ok) {
return availability;
}
if (dryRun) {
return {
ok: true,
pluginId,
targetDir,
manifestName: manifestRes.manifest.name,
version: manifestRes.manifest.version,
extensions: [],
};
}
const installRes = await installPackageDir({
sourceDir: params.sourceDir,
targetDir,
mode,
timeoutMs,
logger,
copyErrorPrefix: "failed to copy plugin bundle",
hasDeps: false,
depsLogMessage: "",
});
if (!installRes.ok) {
return installRes;
}
return {
ok: true,
pluginId,
targetDir,
manifestName: manifestRes.manifest.name,
version: manifestRes.manifest.version,
extensions: [],
};
}
async function installPluginFromSourceDir(
params: {
sourceDir: string;
} & PackageInstallCommonParams,
): Promise<InstallPluginResult> {
const nativePackageDetected = await detectNativePackageInstallSource(params.sourceDir);
if (nativePackageDetected) {
return await installPluginFromPackageDir({
packageDir: params.sourceDir,
...pickPackageInstallCommonParams(params),
});
}
const bundleResult = await installBundleFromSourceDir({
sourceDir: params.sourceDir,
...pickPackageInstallCommonParams(params),
});
if (bundleResult) {
return bundleResult;
}
return await installPluginFromPackageDir({
packageDir: params.sourceDir,
...pickPackageInstallCommonParams(params),
});
}
async function detectNativePackageInstallSource(packageDir: string): Promise<boolean> {
const manifestPath = path.join(packageDir, "package.json");
if (!(await fileExists(manifestPath))) {
return false;
}
try {
const manifest = await readJsonFile<PackageManifest>(manifestPath);
return ensureOpenClawExtensions({ manifest }).ok;
} catch {
return false;
}
}
async function installPluginFromPackageDir(
params: {
packageDir: string;
@ -454,9 +605,9 @@ export async function installPluginFromArchive(
tempDirPrefix: "openclaw-plugin-",
timeoutMs,
logger,
onExtracted: async (packageDir) =>
await installPluginFromPackageDir({
packageDir,
onExtracted: async (sourceDir) =>
await installPluginFromSourceDir({
sourceDir,
...pickPackageInstallCommonParams({
extensionsDir: params.extensionsDir,
timeoutMs,
@ -483,8 +634,8 @@ export async function installPluginFromDir(
return { ok: false, error: `not a directory: ${dirPath}` };
}
return await installPluginFromPackageDir({
packageDir: dirPath,
return await installPluginFromSourceDir({
sourceDir: dirPath,
...pickPackageInstallCommonParams(params),
});
}

View File

@ -309,6 +309,131 @@ afterEach(() => {
}
});
describe("bundle plugins", () => {
it("reports Codex bundles as loaded bundle plugins without importing runtime code", () => {
const workspaceDir = makeTempDir();
const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "sample-bundle");
mkdirSafe(path.join(bundleRoot, ".codex-plugin"));
mkdirSafe(path.join(bundleRoot, "skills"));
fs.writeFileSync(
path.join(bundleRoot, ".codex-plugin", "plugin.json"),
JSON.stringify({
name: "Sample Bundle",
description: "Codex bundle fixture",
skills: "skills",
}),
"utf-8",
);
fs.writeFileSync(
path.join(bundleRoot, "skills", "SKILL.md"),
"---\ndescription: fixture\n---\n",
);
const registry = loadOpenClawPlugins({
workspaceDir,
config: {
plugins: {
entries: {
"sample-bundle": {
enabled: true,
},
},
},
},
cache: false,
});
const plugin = registry.plugins.find((entry) => entry.id === "sample-bundle");
expect(plugin?.status).toBe("loaded");
expect(plugin?.format).toBe("bundle");
expect(plugin?.bundleFormat).toBe("codex");
expect(plugin?.bundleCapabilities).toContain("skills");
});
it("treats Claude command roots and settings as supported bundle surfaces", () => {
const workspaceDir = makeTempDir();
const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "claude-skills");
mkdirSafe(path.join(bundleRoot, "commands"));
fs.writeFileSync(
path.join(bundleRoot, "commands", "review.md"),
"---\ndescription: fixture\n---\n",
);
fs.writeFileSync(path.join(bundleRoot, "settings.json"), '{"hideThinkingBlock":true}', "utf-8");
const registry = loadOpenClawPlugins({
workspaceDir,
config: {
plugins: {
entries: {
"claude-skills": {
enabled: true,
},
},
},
},
cache: false,
});
const plugin = registry.plugins.find((entry) => entry.id === "claude-skills");
expect(plugin?.status).toBe("loaded");
expect(plugin?.bundleFormat).toBe("claude");
expect(plugin?.bundleCapabilities).toEqual(
expect.arrayContaining(["skills", "commands", "settings"]),
);
expect(
registry.diagnostics.some(
(diag) =>
diag.pluginId === "claude-skills" &&
diag.message.includes("bundle capability detected but not wired"),
),
).toBe(false);
});
it("treats Cursor command roots as supported bundle skill surfaces", () => {
const workspaceDir = makeTempDir();
const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "cursor-skills");
mkdirSafe(path.join(bundleRoot, ".cursor-plugin"));
mkdirSafe(path.join(bundleRoot, ".cursor", "commands"));
fs.writeFileSync(
path.join(bundleRoot, ".cursor-plugin", "plugin.json"),
JSON.stringify({
name: "Cursor Skills",
}),
"utf-8",
);
fs.writeFileSync(
path.join(bundleRoot, ".cursor", "commands", "review.md"),
"---\ndescription: fixture\n---\n",
);
const registry = loadOpenClawPlugins({
workspaceDir,
config: {
plugins: {
entries: {
"cursor-skills": {
enabled: true,
},
},
},
},
cache: false,
});
const plugin = registry.plugins.find((entry) => entry.id === "cursor-skills");
expect(plugin?.status).toBe("loaded");
expect(plugin?.bundleFormat).toBe("cursor");
expect(plugin?.bundleCapabilities).toEqual(expect.arrayContaining(["skills", "commands"]));
expect(
registry.diagnostics.some(
(diag) =>
diag.pluginId === "cursor-skills" &&
diag.message.includes("bundle capability detected but not wired"),
),
).toBe(false);
});
});
afterAll(() => {
try {
fs.rmSync(fixtureRoot, { recursive: true, force: true });

View File

@ -32,6 +32,8 @@ import type {
OpenClawPluginDefinition,
OpenClawPluginModule,
PluginDiagnostic,
PluginBundleFormat,
PluginFormat,
PluginLogger,
} from "./types.js";
@ -317,6 +319,9 @@ function createPluginRecord(params: {
name?: string;
description?: string;
version?: string;
format?: PluginFormat;
bundleFormat?: PluginBundleFormat;
bundleCapabilities?: string[];
source: string;
rootDir?: string;
origin: PluginRecord["origin"];
@ -329,6 +334,9 @@ function createPluginRecord(params: {
name: params.name ?? params.id,
description: params.description,
version: params.version,
format: params.format ?? "openclaw",
bundleFormat: params.bundleFormat,
bundleCapabilities: params.bundleCapabilities,
source: params.source,
rootDir: params.rootDir,
origin: params.origin,
@ -785,6 +793,9 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
name: manifestRecord.name ?? pluginId,
description: manifestRecord.description,
version: manifestRecord.version,
format: manifestRecord.format,
bundleFormat: manifestRecord.bundleFormat,
bundleCapabilities: manifestRecord.bundleCapabilities,
source: candidate.source,
rootDir: candidate.rootDir,
origin: candidate.origin,
@ -810,6 +821,9 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
name: manifestRecord.name ?? pluginId,
description: manifestRecord.description,
version: manifestRecord.version,
format: manifestRecord.format,
bundleFormat: manifestRecord.bundleFormat,
bundleCapabilities: manifestRecord.bundleCapabilities,
source: candidate.source,
rootDir: candidate.rootDir,
origin: candidate.origin,
@ -841,6 +855,30 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
continue;
}
if (record.format === "bundle") {
const unsupportedCapabilities = (record.bundleCapabilities ?? []).filter(
(capability) =>
capability !== "skills" &&
capability !== "settings" &&
!(
capability === "commands" &&
(record.bundleFormat === "claude" || record.bundleFormat === "cursor")
) &&
!(capability === "hooks" && record.bundleFormat === "codex"),
);
for (const capability of unsupportedCapabilities) {
registry.diagnostics.push({
level: "warn",
pluginId: record.id,
source: record.source,
message: `bundle capability detected but not wired into OpenClaw yet: ${capability}`,
});
}
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
continue;
}
// Fast-path bundled memory plugins that are guaranteed disabled by slot policy.
// This avoids opening/importing heavy memory plugin modules that will never register.
if (candidate.origin === "bundled" && manifestRecord.kind === "memory") {

View File

@ -35,12 +35,16 @@ function createPluginCandidate(params: {
rootDir: string;
sourceName?: string;
origin: "bundled" | "global" | "workspace" | "config";
format?: "openclaw" | "bundle";
bundleFormat?: "codex" | "claude" | "cursor";
}): PluginCandidate {
return {
idHint: params.idHint,
source: path.join(params.rootDir, params.sourceName ?? "index.ts"),
rootDir: params.rootDir,
origin: params.origin,
format: params.format,
bundleFormat: params.bundleFormat,
};
}
@ -310,6 +314,148 @@ describe("loadPluginManifestRegistry", () => {
expect(countDuplicateWarnings(loadRegistry(candidates))).toBe(0);
});
it("loads Codex bundle manifests into the registry", () => {
const bundleDir = makeTempDir();
mkdirSafe(path.join(bundleDir, ".codex-plugin"));
mkdirSafe(path.join(bundleDir, "skills"));
fs.writeFileSync(
path.join(bundleDir, ".codex-plugin", "plugin.json"),
JSON.stringify({
name: "Sample Bundle",
description: "Bundle fixture",
skills: "skills",
hooks: "hooks",
}),
"utf-8",
);
mkdirSafe(path.join(bundleDir, "hooks"));
const registry = loadRegistry([
createPluginCandidate({
idHint: "sample-bundle",
rootDir: bundleDir,
origin: "global",
format: "bundle",
bundleFormat: "codex",
}),
]);
expect(registry.plugins).toHaveLength(1);
expect(registry.plugins[0]).toMatchObject({
id: "sample-bundle",
format: "bundle",
bundleFormat: "codex",
hooks: ["hooks"],
skills: ["skills"],
bundleCapabilities: expect.arrayContaining(["hooks", "skills"]),
});
});
it("loads Claude bundle manifests with command roots and settings files", () => {
const bundleDir = makeTempDir();
mkdirSafe(path.join(bundleDir, ".claude-plugin"));
mkdirSafe(path.join(bundleDir, "skill-packs", "starter"));
mkdirSafe(path.join(bundleDir, "commands-pack"));
fs.writeFileSync(path.join(bundleDir, "settings.json"), '{"hideThinkingBlock":true}', "utf-8");
fs.writeFileSync(
path.join(bundleDir, ".claude-plugin", "plugin.json"),
JSON.stringify({
name: "Claude Sample",
skills: ["skill-packs/starter"],
commands: "commands-pack",
}),
"utf-8",
);
const registry = loadRegistry([
createPluginCandidate({
idHint: "claude-sample",
rootDir: bundleDir,
origin: "global",
format: "bundle",
bundleFormat: "claude",
}),
]);
expect(registry.plugins).toHaveLength(1);
expect(registry.plugins[0]).toMatchObject({
id: "claude-sample",
format: "bundle",
bundleFormat: "claude",
skills: ["skill-packs/starter", "commands-pack"],
settingsFiles: ["settings.json"],
bundleCapabilities: expect.arrayContaining(["skills", "commands", "settings"]),
});
});
it("loads manifestless Claude bundles into the registry", () => {
const bundleDir = makeTempDir();
mkdirSafe(path.join(bundleDir, "commands"));
fs.writeFileSync(path.join(bundleDir, "settings.json"), '{"hideThinkingBlock":true}', "utf-8");
const registry = loadRegistry([
createPluginCandidate({
idHint: "manifestless-claude",
rootDir: bundleDir,
origin: "global",
format: "bundle",
bundleFormat: "claude",
}),
]);
expect(registry.plugins).toHaveLength(1);
expect(registry.plugins[0]).toMatchObject({
format: "bundle",
bundleFormat: "claude",
skills: ["commands"],
settingsFiles: ["settings.json"],
bundleCapabilities: expect.arrayContaining(["skills", "commands", "settings"]),
});
});
it("loads Cursor bundle manifests into the registry", () => {
const bundleDir = makeTempDir();
mkdirSafe(path.join(bundleDir, ".cursor-plugin"));
mkdirSafe(path.join(bundleDir, "skills"));
mkdirSafe(path.join(bundleDir, ".cursor", "commands"));
mkdirSafe(path.join(bundleDir, ".cursor", "rules"));
fs.writeFileSync(path.join(bundleDir, ".cursor", "hooks.json"), '{"hooks":[]}', "utf-8");
fs.writeFileSync(
path.join(bundleDir, ".cursor-plugin", "plugin.json"),
JSON.stringify({
name: "Cursor Sample",
mcpServers: "./.mcp.json",
}),
"utf-8",
);
fs.writeFileSync(path.join(bundleDir, ".mcp.json"), '{"servers":{}}', "utf-8");
const registry = loadRegistry([
createPluginCandidate({
idHint: "cursor-sample",
rootDir: bundleDir,
origin: "global",
format: "bundle",
bundleFormat: "cursor",
}),
]);
expect(registry.plugins).toHaveLength(1);
expect(registry.plugins[0]).toMatchObject({
id: "cursor-sample",
format: "bundle",
bundleFormat: "cursor",
skills: ["skills", ".cursor/commands"],
bundleCapabilities: expect.arrayContaining([
"skills",
"commands",
"rules",
"hooks",
"mcpServers",
]),
});
});
it("prefers higher-precedence origins for the same physical directory (config > workspace > global > bundled)", () => {
const dir = makeTempDir();
mkdirSafe(path.join(dir, "sub"));

View File

@ -1,12 +1,20 @@
import fs from "node:fs";
import type { OpenClawConfig } from "../config/config.js";
import { resolveUserPath } from "../utils.js";
import { loadBundleManifest } from "./bundle-manifest.js";
import { normalizePluginsConfig, type NormalizedPluginsConfig } from "./config-state.js";
import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js";
import { loadPluginManifest, type PluginManifest } from "./manifest.js";
import { isPathInside, safeRealpathSync } from "./path-safety.js";
import { resolvePluginCacheInputs } from "./roots.js";
import type { PluginConfigUiHint, PluginDiagnostic, PluginKind, PluginOrigin } from "./types.js";
import type {
PluginBundleFormat,
PluginConfigUiHint,
PluginDiagnostic,
PluginFormat,
PluginKind,
PluginOrigin,
} from "./types.js";
type SeenIdEntry = {
candidate: PluginCandidate;
@ -27,10 +35,15 @@ export type PluginManifestRecord = {
name?: string;
description?: string;
version?: string;
format?: PluginFormat;
bundleFormat?: PluginBundleFormat;
bundleCapabilities?: string[];
kind?: PluginKind;
channels: string[];
providers: string[];
skills: string[];
settingsFiles?: string[];
hooks: string[];
origin: PluginOrigin;
workspaceDir?: string;
rootDir: string;
@ -122,10 +135,14 @@ function buildRecord(params: {
description:
normalizeManifestLabel(params.manifest.description) ?? params.candidate.packageDescription,
version: normalizeManifestLabel(params.manifest.version) ?? params.candidate.packageVersion,
format: params.candidate.format ?? "openclaw",
bundleFormat: params.candidate.bundleFormat,
kind: params.manifest.kind,
channels: params.manifest.channels ?? [],
providers: params.manifest.providers ?? [],
skills: params.manifest.skills ?? [],
settingsFiles: [],
hooks: [],
origin: params.candidate.origin,
workspaceDir: params.candidate.workspaceDir,
rootDir: params.candidate.rootDir,
@ -137,6 +154,44 @@ function buildRecord(params: {
};
}
function buildBundleRecord(params: {
manifest: {
id: string;
name?: string;
description?: string;
version?: string;
skills: string[];
settingsFiles?: string[];
hooks: string[];
capabilities: string[];
};
candidate: PluginCandidate;
manifestPath: string;
}): PluginManifestRecord {
return {
id: params.manifest.id,
name: normalizeManifestLabel(params.manifest.name) ?? params.candidate.idHint,
description: normalizeManifestLabel(params.manifest.description),
version: normalizeManifestLabel(params.manifest.version),
format: "bundle",
bundleFormat: params.candidate.bundleFormat,
bundleCapabilities: params.manifest.capabilities,
channels: [],
providers: [],
skills: params.manifest.skills ?? [],
settingsFiles: params.manifest.settingsFiles ?? [],
hooks: params.manifest.hooks ?? [],
origin: params.candidate.origin,
workspaceDir: params.candidate.workspaceDir,
rootDir: params.candidate.rootDir,
source: params.candidate.source,
manifestPath: params.manifestPath,
schemaCacheKey: undefined,
configSchema: undefined,
configUiHints: undefined,
};
}
function matchesInstalledPluginRecord(params: {
pluginId: string;
candidate: PluginCandidate;
@ -230,7 +285,15 @@ export function loadPluginManifestRegistry(params: {
for (const candidate of candidates) {
const rejectHardlinks = candidate.origin !== "bundled";
const manifestRes = loadPluginManifest(candidate.rootDir, rejectHardlinks);
const isBundleRecord = (candidate.format ?? "openclaw") === "bundle";
const manifestRes =
isBundleRecord && candidate.bundleFormat
? loadBundleManifest({
rootDir: candidate.rootDir,
bundleFormat: candidate.bundleFormat,
rejectHardlinks,
})
: loadPluginManifest(candidate.rootDir, rejectHardlinks);
if (!manifestRes.ok) {
diagnostics.push({
level: "error",
@ -250,7 +313,7 @@ export function loadPluginManifestRegistry(params: {
});
}
const configSchema = manifest.configSchema;
const configSchema = "configSchema" in manifest ? manifest.configSchema : undefined;
const schemaCacheKey = (() => {
if (!configSchema) {
return undefined;
@ -279,13 +342,19 @@ export function loadPluginManifestRegistry(params: {
// Prefer higher-precedence origins even if candidates are passed in
// an unexpected order (config > workspace > global > bundled).
if (PLUGIN_ORIGIN_RANK[candidate.origin] < PLUGIN_ORIGIN_RANK[existing.candidate.origin]) {
records[existing.recordIndex] = buildRecord({
manifest,
candidate,
manifestPath: manifestRes.manifestPath,
schemaCacheKey,
configSchema,
});
records[existing.recordIndex] = isBundleRecord
? buildBundleRecord({
manifest: manifest as Parameters<typeof buildBundleRecord>[0]["manifest"],
candidate,
manifestPath: manifestRes.manifestPath,
})
: buildRecord({
manifest: manifest as PluginManifest,
candidate,
manifestPath: manifestRes.manifestPath,
schemaCacheKey,
configSchema,
});
seenIds.set(manifest.id, { candidate, recordIndex: existing.recordIndex });
}
continue;
@ -315,13 +384,19 @@ export function loadPluginManifestRegistry(params: {
}
records.push(
buildRecord({
manifest,
candidate,
manifestPath: manifestRes.manifestPath,
schemaCacheKey,
configSchema,
}),
isBundleRecord
? buildBundleRecord({
manifest: manifest as Parameters<typeof buildBundleRecord>[0]["manifest"],
candidate,
manifestPath: manifestRes.manifestPath,
})
: buildRecord({
manifest: manifest as PluginManifest,
candidate,
manifestPath: manifestRes.manifestPath,
schemaCacheKey,
configSchema,
}),
);
}

View File

@ -38,6 +38,8 @@ import type {
OpenClawPluginToolFactory,
PluginConfigUiHint,
PluginDiagnostic,
PluginBundleFormat,
PluginFormat,
PluginLogger,
PluginOrigin,
PluginKind,
@ -120,6 +122,9 @@ export type PluginRecord = {
name: string;
version?: string;
description?: string;
format?: PluginFormat;
bundleFormat?: PluginBundleFormat;
bundleCapabilities?: string[];
kind?: PluginKind;
source: string;
rootDir?: string;

View File

@ -800,6 +800,10 @@ export type OpenClawPluginApi = {
export type PluginOrigin = "bundled" | "global" | "workspace" | "config";
export type PluginFormat = "openclaw" | "bundle";
export type PluginBundleFormat = "codex" | "claude" | "cursor";
export type PluginDiagnostic = {
level: "warn" | "error";
message: string;