diff --git a/CHANGELOG.md b/CHANGELOG.md index 6acb2fd82fb..af21fcd7c45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 0b054f5a4aa..4d9d1e8e80d 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -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 diff --git a/docs/docs.json b/docs/docs.json index 8855a7335d6..229699ec37e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1046,6 +1046,7 @@ "group": "Extensions", "pages": [ "plugins/community", + "plugins/bundles", "plugins/voice-call", "plugins/zalouser", "plugins/manifest", diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index a72ad7d76da..78e58edc085 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2323,12 +2323,14 @@ See [Local Models](/gateway/local-models). TL;DR: run MiniMax M2.5 via LM Studio ``` - Loaded from `~/.openclaw/extensions`, `/.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..apiKey`: plugin-level API key convenience field (when supported by the plugin). - `plugins.entries..env`: plugin-scoped env var map. -- `plugins.entries..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..config`: plugin-defined config object (validated by plugin schema). +- `plugins.entries..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..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`. diff --git a/docs/plugins/bundles.md b/docs/plugins/bundles.md new file mode 100644 index 00000000000..1756baca71d --- /dev/null +++ b/docs/plugins/bundles.md @@ -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 ` 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 ` 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 `. + +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. diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index d23f036880a..9c266744b71 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -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.*`. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index dbbd1c03d39..d9026e5e4fc 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -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..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.` 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 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). diff --git a/src/agents/pi-project-settings.bundle.test.ts b/src/agents/pi-project-settings.bundle.test.ts new file mode 100644 index 00000000000..d297b1ef3a1 --- /dev/null +++ b/src/agents/pi-project-settings.bundle.test.ts @@ -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({}); + }); +}); diff --git a/src/agents/pi-project-settings.test.ts b/src/agents/pi-project-settings.test.ts index 07f86421f84..92d676b8427 100644 --- a/src/agents/pi-project-settings.test.ts +++ b/src/agents/pi-project-settings.test.ts @@ -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); + }); }); diff --git a/src/agents/pi-project-settings.ts b/src/agents/pi-project-settings.ts index 7ddd9b6a1e9..8e08d11bca7 100644 --- a/src/agents/pi-project-settings.ts +++ b/src/agents/pi-project-settings.ts @@ -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; -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, }); diff --git a/src/agents/skills/plugin-skills.test.ts b/src/agents/skills/plugin-skills.test.ts index fd3abd6d07d..9edcd463c22 100644 --- a/src/agents/skills/plugin-skills.test.ts +++ b/src/agents/skills/plugin-skills.test.ts @@ -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"), + ]); + }); }); diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index e77d7026875..d090fe7d83d 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -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(", ")}`); } diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index f7f5539eb5a..efb84acdacf 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -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" }] }, diff --git a/src/config/plugin-auto-enable.test.ts b/src/config/plugin-auto-enable.test.ts index 1de11be4a1e..c289417ce53 100644 --- a/src/config/plugin-auto-enable.test.ts +++ b/src/config/plugin-auto-enable.test.ts @@ -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`, diff --git a/src/config/validation.ts b/src/config/validation.ts index 1486ea07182..e97bd8cbedf 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -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}`, diff --git a/src/hooks/plugin-hooks.test.ts b/src/hooks/plugin-hooks.test.ts new file mode 100644 index 00000000000..333c3a3cf39 --- /dev/null +++ b/src/hooks/plugin-hooks.test.ts @@ -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 { + 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); + }); +}); diff --git a/src/hooks/plugin-hooks.ts b/src/hooks/plugin-hooks.ts new file mode 100644 index 00000000000..298749d2245 --- /dev/null +++ b/src/hooks/plugin-hooks.ts @@ -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(); + 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; +} diff --git a/src/hooks/workspace.ts b/src/hooks/workspace.ts index 56e2fc05339..d22c0183ce3 100644 --- a/src/hooks/workspace.ts +++ b/src/hooks/workspace.ts @@ -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(); - // 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); } diff --git a/src/plugins/bundle-manifest.test.ts b/src/plugins/bundle-manifest.test.ts new file mode 100644 index 00000000000..f1ad13035ee --- /dev/null +++ b/src/plugins/bundle-manifest.test.ts @@ -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(); + }); +}); diff --git a/src/plugins/bundle-manifest.ts b/src/plugins/bundle-manifest.ts new file mode 100644 index 00000000000..981eb9fd3a6 --- /dev/null +++ b/src/plugins/bundle-manifest.ts @@ -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; 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(); + 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, 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, 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, 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, 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, rootDir: string): string[] { + return mergeBundlePathLists( + resolveCursorSkillsRootDirs(raw, rootDir), + resolveCursorCommandRootDirs(raw, rootDir), + ); +} + +function resolveCursorAgentDirs(raw: Record, 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, rootDir: string): boolean { + return ( + hasInlineCapabilityValue(raw.hooks) || + fs.existsSync(path.join(rootDir, ".cursor", "hooks.json")) + ); +} + +function hasCursorRulesCapability(raw: Record, rootDir: string): boolean { + return ( + hasInlineCapabilityValue(raw.rules) || fs.existsSync(path.join(rootDir, ".cursor", "rules")) + ); +} + +function hasCursorMcpCapability(raw: Record, rootDir: string): boolean { + return hasInlineCapabilityValue(raw.mcpServers) || fs.existsSync(path.join(rootDir, ".mcp.json")); +} + +function resolveClaudeComponentPaths( + raw: Record, + 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, rootDir: string): string[] { + return resolveClaudeComponentPaths(raw, "skills", rootDir, ["skills"]); +} + +function resolveClaudeCommandRootDirs(raw: Record, rootDir: string): string[] { + return resolveClaudeComponentPaths(raw, "commands", rootDir, ["commands"]); +} + +function resolveClaudeSkillDirs(raw: Record, rootDir: string): string[] { + return mergeBundlePathLists( + resolveClaudeSkillsRootDirs(raw, rootDir), + resolveClaudeCommandRootDirs(raw, rootDir), + ); +} + +function resolveClaudeAgentDirs(raw: Record, rootDir: string): string[] { + return resolveClaudeComponentPaths(raw, "agents", rootDir, ["agents"]); +} + +function resolveClaudeHookPaths(raw: Record, rootDir: string): string[] { + return resolveClaudeComponentPaths(raw, "hooks", rootDir, ["hooks/hooks.json"]); +} + +function resolveClaudeMcpPaths(raw: Record, rootDir: string): string[] { + return resolveClaudeComponentPaths(raw, "mcpServers", rootDir, [".mcp.json"]); +} + +function resolveClaudeLspPaths(raw: Record, rootDir: string): string[] { + return resolveClaudeComponentPaths(raw, "lspServers", rootDir, [".lsp.json"]); +} + +function resolveClaudeOutputStylePaths(raw: Record, rootDir: string): string[] { + return resolveClaudeComponentPaths(raw, "outputStyles", rootDir, ["output-styles"]); +} + +function resolveClaudeSettingsFiles(_raw: Record, rootDir: string): string[] { + return fs.existsSync(path.join(rootDir, "settings.json")) ? ["settings.json"] : []; +} + +function hasClaudeHookCapability(raw: Record, rootDir: string): boolean { + return hasInlineCapabilityValue(raw.hooks) || resolveClaudeHookPaths(raw, rootDir).length > 0; +} + +function buildCodexCapabilities(raw: Record, 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, 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, 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; +} diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index 1069c223b1e..a61c21e4125 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -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"); diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 0ccf10831a9..c102ffc80c7 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -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; +}): "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)); diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index db2fcfaf8f9..c6c09042c84 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -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", () => { diff --git a/src/plugins/install.ts b/src/plugins/install.ts index ab87377d32e..e6b66381970 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -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 { + 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 { + 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 { + const manifestPath = path.join(packageDir, "package.json"); + if (!(await fileExists(manifestPath))) { + return false; + } + + try { + const manifest = await readJsonFile(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), }); } diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 939e9a9f56c..eec2cf4f410 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -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 }); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 1549835d60a..319b0ae90d7 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -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") { diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 214c9b3b23f..a05576bc96d 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -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")); diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 285b3042004..b0f98b3beef 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -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[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[0]["manifest"], + candidate, + manifestPath: manifestRes.manifestPath, + }) + : buildRecord({ + manifest: manifest as PluginManifest, + candidate, + manifestPath: manifestRes.manifestPath, + schemaCacheKey, + configSchema, + }), ); } diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 8d1e5f92eb0..d754d928f15 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -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; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 19542b44c2d..4cb6ef92ee4 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -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;