feat(plugins): add compatible bundle support
This commit is contained in:
parent
aa1454d1a8
commit
dd40741e18
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1046,6 +1046,7 @@
|
||||
"group": "Extensions",
|
||||
"pages": [
|
||||
"plugins/community",
|
||||
"plugins/bundles",
|
||||
"plugins/voice-call",
|
||||
"plugins/zalouser",
|
||||
"plugins/manifest",
|
||||
|
||||
@ -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
245
docs/plugins/bundles.md
Normal 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.
|
||||
@ -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.*`.
|
||||
|
||||
@ -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, you’ll use plugins when you want a feature that’s 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 **in‑process** with the Gateway, so treat them as trusted code.
|
||||
Native OpenClaw plugins run **in‑process** 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).
|
||||
|
||||
|
||||
105
src/agents/pi-project-settings.bundle.test.ts
Normal file
105
src/agents/pi-project-settings.bundle.test.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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"),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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(", ")}`);
|
||||
}
|
||||
|
||||
@ -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" }] },
|
||||
|
||||
@ -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`,
|
||||
|
||||
@ -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}`,
|
||||
|
||||
158
src/hooks/plugin-hooks.test.ts
Normal file
158
src/hooks/plugin-hooks.test.ts
Normal 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
95
src/hooks/plugin-hooks.ts
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
201
src/plugins/bundle-manifest.test.ts
Normal file
201
src/plugins/bundle-manifest.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
441
src/plugins/bundle-manifest.ts
Normal file
441
src/plugins/bundle-manifest.ts
Normal 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;
|
||||
}
|
||||
@ -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");
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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"));
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user