feat(plugins): support the Agent Plugins bundle format (#120115)

* feat(plugins): support the Agent Plugins bundle format

* docs(plugins): document the Agent Plugins bundle format

* test(agents): preserve agent bundle runtime discovery

* fix(plugins): isolate Agent Plugins data-dir failures and align MCP support reporting

* docs(plugins): list Agent Plugins in the canonical plugin-format guides

* fix(plugins): gate Agent Plugins detection on schema, pure inspection, root-relative cwd

* fix(plugins): record Agent Plugins data-dir ownership explicitly

* docs(plugins): cover Agent Plugins in the CLI install detection guide

* fix(plugins): carry Agent Plugins data-dir and transport contracts through external MCP projections
This commit is contained in:
Peter Steinberger
2026-08-07 02:55:08 -07:00
committed by GitHub
parent 06d5e4457f
commit f4387b7a5e
43 changed files with 1886 additions and 205 deletions
+3 -2
View File
@@ -64,7 +64,7 @@ Bundled plugins ship with OpenClaw. Some are enabled by default (for example bun
Native OpenClaw plugins 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.
`plugins list` shows `Format: openclaw` or `Format: bundle`. Verbose list/info output also shows the bundle subtype (`agent (Agent Plugins)`, `codex`, `claude`, or `cursor`) plus detected bundle capabilities.
</Note>
## Author
@@ -308,6 +308,7 @@ openclaw plugins install <plugin-name> --marketplace ./my-marketplace
For local paths and archives, OpenClaw auto-detects:
- native OpenClaw plugins (`openclaw.plugin.json`)
- Agent Plugins bundles (root `plugin.json` declaring the [Agent Plugins](https://agent-plugins.org) `$schema`)
- Codex-compatible bundles (`.codex-plugin/plugin.json`)
- Claude-compatible bundles (`.claude-plugin/plugin.json`, or the default Claude component layout when that manifest file is absent)
- Cursor-compatible bundles (`.cursor-plugin/plugin.json`)
@@ -321,7 +322,7 @@ top-level script files as local helpers. List standalone files explicitly in
`plugins.load.paths` instead.
<Note>
Compatible bundles install into the normal plugin root and participate in the same list/info/enable/disable flow. Today, bundle skills, Claude command-skills, Claude `settings.json` defaults, Claude `.lsp.json` / manifest-declared `lspServers` 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.
Compatible bundles install into the normal plugin root and participate in the same list/info/enable/disable flow. Today, bundle skills, bundle MCP servers, Agent Plugins skills/MCP (with the `PLUGIN_ROOT`/`PLUGIN_DATA` subprocess contract), Claude command-skills, Claude `settings.json` defaults, Claude `.lsp.json` / manifest-declared `lspServers` 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. See [Plugin bundles](/plugins/bundles) for the per-format mapping.
</Note>
Use `-l`/`--link` to point at a local plugin directory without copying it (adds
+54 -15
View File
@@ -1,15 +1,16 @@
---
summary: "Install and use Codex, Claude, and Cursor bundles as OpenClaw plugins"
summary: "Install and use Agent Plugins, Codex, Claude, and Cursor bundles as OpenClaw plugins"
read_when:
- You want to install a Codex, Claude, or Cursor-compatible bundle
- You want to install an Agent Plugins, Codex, Claude, or Cursor-compatible bundle
- You need to understand how OpenClaw maps bundle content into native features
- You are debugging bundle detection or missing capabilities
title: "Plugin bundles"
---
OpenClaw can install plugins from three external ecosystems: **Codex**, **Claude**,
and **Cursor**. These are called **bundles** - content and metadata packs that
OpenClaw maps into native features like skills, hooks, and MCP tools.
OpenClaw can install plugins from four external ecosystems: the vendor-neutral
[**Agent Plugins**](https://agent-plugins.org) standard, plus **Codex**,
**Claude**, and **Cursor**. These are called **bundles** - content and metadata
packs that OpenClaw maps into native features like skills, hooks, and MCP tools.
<Info>
Bundles are **not** the same as native OpenClaw plugins. Native plugins run
@@ -19,11 +20,11 @@ OpenClaw maps into native features like skills, hooks, and MCP tools.
## Why bundles exist
Many useful plugins are published in Codex, Claude, or Cursor format. Instead
of requiring authors to rewrite them as native OpenClaw plugins, OpenClaw
detects these formats and maps their supported content into the native feature
set. You can install a Claude command pack or a Codex skill bundle and use it
immediately.
Many useful plugins are published in the Agent Plugins, Codex, Claude, or
Cursor format. Instead of requiring authors to rewrite them as native OpenClaw
plugins, OpenClaw detects these formats and maps their supported content into
the native feature set. You can install an Agent Plugins package, a Claude
command pack, or a Codex skill bundle and use it immediately.
## Install a bundle
@@ -51,8 +52,8 @@ immediately.
openclaw plugins inspect <id>
```
Bundles show `Format: bundle` plus a `Bundle format:` value of `codex`,
`claude`, or `cursor`.
Bundles show `Format: bundle` plus a `Bundle format:` value of
`agent (Agent Plugins)`, `codex`, `claude`, or `cursor`.
</Step>
@@ -79,6 +80,7 @@ is detected but not yet wired.
| Commands | `commands/` and `.cursor/commands/` treated as skill roots | Claude, Cursor |
| Hook packs | OpenClaw-style `HOOK.md` + `handler.ts` layouts | Codex |
| MCP tools | Bundle MCP config merged into embedded OpenClaw settings; supported stdio and HTTP servers loaded | All formats |
| Env contract | `PLUGIN_ROOT` and `PLUGIN_DATA` env vars plus placeholder expansion for stdio MCP servers | Agent Plugins |
| LSP servers | Claude `.lsp.json` and manifest-declared `lspServers` merged into embedded OpenClaw LSP defaults | Claude |
| Settings | Claude `settings.json` imported as embedded OpenClaw defaults | Claude |
@@ -210,6 +212,38 @@ These are recognized and shown in diagnostics, but OpenClaw does not run them:
## Bundle formats
<AccordionGroup>
<Accordion title="Agent Plugins bundles">
Marker: `plugin.json` at the package root, per the open
[Agent Plugins 1.0.0 standard](https://agent-plugins.org)
Optional content: `skills/`, `mcp.json`
Format behavior:
- The manifest is strict JSON (not JSON5). OpenClaw requires a non-empty
`name`; other manifest fields are optional and unknown fields are ignored
- Immediate child directories of `skills/` that contain a `SKILL.md` load as
skills; children without one are skipped with a warning, and deeper
directories are not scanned
- `mcp.json` must declare the 1.0.0 `$schema` and an `mcpServers` object
only; `stdio`, `streamable-http`, and legacy `sse` transports are
supported
- stdio servers launch with `PLUGIN_ROOT` (the plugin root) and
`PLUGIN_DATA` (a persistent per-plugin data directory OpenClaw creates
under its state dir) in their environment; `${PLUGIN_ROOT}` and
`${PLUGIN_DATA}` placeholders expand in `args`, `env` values, and `cwd`
in a single pass
- A stdio `command` must be a bare executable name or a `./`-relative path
inside the plugin; `cwd` must stay inside `PLUGIN_ROOT` or `PLUGIN_DATA`
- Invalid `mcp.json` disables MCP for the plugin with a diagnostic while
skills keep loading; invalid individual server entries are skipped
- `.mcp.json` (dot-prefixed) and inline manifest `mcpServers` are **not**
read for this format; the standard's closed schema wins
- Reverse-domain client directories and manifest `extensions` namespaces
are ignored, as the standard allows for incremental adoption
</Accordion>
<Accordion title="Codex bundles">
Markers: `.codex-plugin/plugin.json`
@@ -253,10 +287,15 @@ These are recognized and shown in diagnostics, but OpenClaw does not run them:
OpenClaw checks for native plugin format first:
1. `openclaw.plugin.json` or a valid `package.json` with `openclaw.extensions` - treated as a **native plugin**
2. Bundle markers (`.codex-plugin/`, `.claude-plugin/`, or default Claude/Cursor layout) - treated as a **bundle**
2. Client-specific bundle markers (`.codex-plugin/`, `.cursor-plugin/`, `.claude-plugin/`) - treated as a **bundle** in that format
3. A root `plugin.json` - treated as an **Agent Plugins bundle**
4. Default manifestless Claude layout (`skills/`, `commands/`, `.mcp.json`, ...) - treated as a **Claude bundle**
If a directory contains both, OpenClaw uses the native path. This prevents
dual-format packages from being partially installed as bundles.
If a package carries both a client-specific marker and a root `plugin.json`,
the client-specific format wins so its richer mappings (commands, hooks,
settings) are preserved. If a directory contains both a native manifest and
bundle markers, OpenClaw uses the native path. This prevents dual-format
packages from being partially installed as bundles.
## Runtime dependencies and cleanup
+2 -1
View File
@@ -6,10 +6,11 @@ read_when:
title: "Plugin manifest"
---
This page covers the **native OpenClaw plugin manifest**, `openclaw.plugin.json`. For compatible bundle layouts (Codex, Claude, Cursor), see [Plugin bundles](/plugins/bundles).
This page covers the **native OpenClaw plugin manifest**, `openclaw.plugin.json`. For compatible bundle layouts (Agent Plugins, Codex, Claude, Cursor), see [Plugin bundles](/plugins/bundles).
Compatible bundle formats use their own manifest files instead:
- Agent Plugins bundle: `plugin.json` at the package root, per the open [Agent Plugins standard](https://agent-plugins.org)
- Codex bundle: `.codex-plugin/plugin.json`
- Claude bundle: `.claude-plugin/plugin.json`, or the default Claude component layout with no manifest
- Cursor bundle: `.cursor-plugin/plugin.json`
+4 -4
View File
@@ -228,10 +228,10 @@ paths.
OpenClaw recognizes two plugin formats:
| Format | How it loads | Use when |
| ---------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| Native OpenClaw plugin | `openclaw.plugin.json` plus a runtime module loaded in process | You are installing or building OpenClaw-specific runtime capabilities |
| Compatible bundle | Codex, Claude, or Cursor plugin layout mapped into OpenClaw plugin inventory | You are reusing compatible skills, commands, hooks, or bundle metadata |
| Format | How it loads | Use when |
| ---------------------- | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| Native OpenClaw plugin | `openclaw.plugin.json` plus a runtime module loaded in process | You are installing or building OpenClaw-specific runtime capabilities |
| Compatible bundle | Agent Plugins, Codex, Claude, or Cursor plugin layout mapped into OpenClaw plugin inventory | You are reusing compatible skills, commands, hooks, or bundle metadata |
Both formats appear in `openclaw plugins list`, `openclaw plugins inspect`,
`openclaw plugins enable`, and `openclaw plugins disable`. See
@@ -6,6 +6,7 @@ import { loadSessionMcpConfig } from "./agent-bundle-mcp-runtime-config.js";
const mocks = vi.hoisted(() => ({
loadCount: 0,
diagnostics: [] as Array<{ pluginId: string; message: string }>,
prepareDataDirsByServer: {} as Record<string, { pluginId: string; dataDir: string }>,
}));
vi.mock("./embedded-agent-mcp.js", () => ({
@@ -22,6 +23,7 @@ vi.mock("./embedded-agent-mcp.js", () => ({
return {
diagnostics: structuredClone(mocks.diagnostics),
mcpServers: servers,
prepareDataDirsByServer: structuredClone(mocks.prepareDataDirsByServer),
};
},
}));
@@ -29,10 +31,41 @@ vi.mock("./embedded-agent-mcp.js", () => ({
afterEach(() => {
mocks.loadCount = 0;
mocks.diagnostics = [];
mocks.prepareDataDirsByServer = {};
clearPluginMetadataLifecycleCaches();
});
describe("session MCP config discovery cache", () => {
it("keeps Agent Plugins launch ownership out of fingerprints and filtered partitions", () => {
const cfg = {
mcp: { servers: { alpha: { command: "alpha" }, beta: { command: "beta" } } },
};
mocks.prepareDataDirsByServer = {
alpha: { pluginId: "agent-plugin", dataDir: "/state/one" },
beta: { pluginId: "agent-plugin", dataDir: "/state/two" },
};
const first = loadSessionMcpConfig({ workspaceDir: "/ownership-workspace", cfg });
const filtered = loadSessionMcpConfig({
workspaceDir: "/ownership-workspace",
cfg,
includeServerNames: new Set(["alpha"]),
});
expect(first.loaded.prepareDataDirsByServer).toEqual({
alpha: { pluginId: "agent-plugin", dataDir: "/state/one" },
beta: { pluginId: "agent-plugin", dataDir: "/state/two" },
});
expect(filtered.loaded.prepareDataDirsByServer).toEqual({
alpha: { pluginId: "agent-plugin", dataDir: "/state/one" },
});
clearPluginMetadataLifecycleCaches();
mocks.prepareDataDirsByServer = {
alpha: { pluginId: "agent-plugin", dataDir: "/different/state" },
};
const changedOwnership = loadSessionMcpConfig({ workspaceDir: "/ownership-workspace", cfg });
expect(changedOwnership.fingerprint).toBe(first.fingerprint);
});
it("reuses immutable discovery across full and filtered catalog preparation", () => {
const cfg = {
mcp: {
@@ -265,6 +265,11 @@ export function loadSessionMcpConfig(params: {
includeServerNames: params.includeServerNames,
excludeServerNames: params.excludeServerNames,
});
const prepareDataDirsByServer = Object.fromEntries(
Object.entries(discovery.loaded.prepareDataDirsByServer ?? {}).filter(([serverName]) =>
Object.hasOwn(mcpServers, serverName),
),
);
const fingerprintServers = params.redactConnectionServerNames?.size
? redactMcpServersForFingerprint(mcpServers, params.redactConnectionServerNames)
: mcpServers;
@@ -272,6 +277,9 @@ export function loadSessionMcpConfig(params: {
loaded: {
...discovery.loaded,
mcpServers,
// Launch ownership is not serialized or fingerprinted; the injected env path already
// participates in the server fingerprint and this sidecar only authorizes mkdir.
prepareDataDirsByServer,
},
fingerprint: createCatalogFingerprint({
servers: fingerprintServers,
@@ -0,0 +1,255 @@
/** Proves an installed Agent Plugins bundle can launch and execute a real stdio MCP tool. */
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { afterEach, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { loadEnabledBundleMcpConfig } from "../plugins/bundle-mcp.js";
import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js";
import { getPluginToolMeta } from "../plugins/tools.js";
import { withEnvAsync } from "../test-utils/env.js";
import {
disposeAllSessionMcpRuntimes,
getOrCreateSessionMcpRuntime,
materializeBundleMcpToolsForRun,
} from "./agent-bundle-mcp-tools.js";
const tempDirs: string[] = [];
const PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
const MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
afterEach(async () => {
await disposeAllSessionMcpRuntimes();
clearPluginMetadataLifecycleCaches();
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
async function writeProbeServer(filePath: string): Promise<void> {
await fs.writeFile(
filePath,
`import { existsSync } from "node:fs";
let buffer = "";
function send(message) {
process.stdout.write(JSON.stringify(message) + "\\n");
}
function handle(message) {
if (message.method === "initialize") {
send({
jsonrpc: "2.0",
id: message.id,
result: {
protocolVersion: message.params?.protocolVersion ?? "2025-11-25",
capabilities: { tools: {} },
serverInfo: { name: "agent-bundle-probe", version: "1.0.0" },
},
});
return;
}
if (message.method === "notifications/initialized") {
return;
}
if (message.method === "tools/list") {
send({
jsonrpc: "2.0",
id: message.id,
result: {
tools: [{
name: "weather_probe",
description: "Reports the Agent Plugins subprocess contract.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
}],
},
});
return;
}
if (message.method === "tools/call") {
send({
jsonrpc: "2.0",
id: message.id,
result: {
content: [{
type: "text",
text: JSON.stringify({
pluginRoot: process.env.PLUGIN_ROOT,
pluginData: process.env.PLUGIN_DATA,
pluginDataExists: existsSync(process.env.PLUGIN_DATA ?? ""),
argv: process.argv.slice(2),
}),
}],
isError: false,
},
});
}
}
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
buffer += chunk;
while (true) {
const newline = buffer.indexOf("\\n");
if (newline < 0) break;
const line = buffer.slice(0, newline).replace(/\\r$/, "");
buffer = buffer.slice(newline + 1);
if (line.trim()) handle(JSON.parse(line));
}
});
function shutdown() {
process.exit(0);
}
process.stdin.on("end", shutdown);
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
`,
"utf8",
);
}
it("discovers an installed Agent Plugins bundle and executes its real stdio tool", async () => {
const stateDir = await fs.realpath(
await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-bundle-runtime-")),
);
tempDirs.push(stateDir);
const pluginId = "agent-bundle-probe";
const pluginRoot = path.join(stateDir, "extensions", pluginId);
const workspaceDir = path.join(stateDir, "workspace");
const bundledPluginsDir = path.join(stateDir, "bundled-plugins-disabled");
const serverPath = path.join(pluginRoot, "probe-server.mjs");
const expandedMarkerPath = path.join(pluginRoot, "expanded-marker.txt");
await fs.mkdir(pluginRoot, { recursive: true });
await fs.mkdir(workspaceDir, { recursive: true });
await fs.mkdir(bundledPluginsDir, { recursive: true });
await writeProbeServer(serverPath);
await fs.writeFile(
path.join(pluginRoot, "plugin.json"),
JSON.stringify({ $schema: PLUGIN_SCHEMA, name: pluginId }),
"utf8",
);
await fs.writeFile(
path.join(pluginRoot, "mcp.json"),
JSON.stringify({
$schema: MCP_SCHEMA,
mcpServers: {
weatherProbe: {
type: "stdio",
command: "node",
args: ["${PLUGIN_ROOT}/probe-server.mjs", "${PLUGIN_ROOT}/expanded-marker.txt"],
},
},
}),
"utf8",
);
const cfg: OpenClawConfig = {
plugins: { entries: { [pluginId]: { enabled: true } } },
};
await withEnvAsync(
{
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_HOME: undefined,
OPENCLAW_CONFIG_PATH: undefined,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledPluginsDir,
},
async () => {
clearPluginMetadataLifecycleCaches();
const loaded = loadEnabledBundleMcpConfig({ workspaceDir, cfg });
expect(loaded.diagnostics).toStrictEqual([]);
const loadedServer = expectDefined(
loaded.config.mcpServers.weatherProbe,
"Agent Plugins weather probe server",
);
const loadedEnv = expectDefined(
isRecord(loadedServer.env) ? loadedServer.env : undefined,
"Agent Plugins injected environment",
);
const expectedPluginRoot = await fs.realpath(pluginRoot);
const expectedPluginData = path.join(stateDir, "plugin-data", pluginId);
await expect(fs.stat(expectedPluginData)).rejects.toMatchObject({ code: "ENOENT" });
expect(loadedServer.args).toEqual([serverPath, expandedMarkerPath]);
expect(loadedEnv).toMatchObject({
PLUGIN_ROOT: expectedPluginRoot,
PLUGIN_DATA: expectedPluginData,
});
const runtime = await getOrCreateSessionMcpRuntime({
sessionId: "agent-bundle-boundary",
sessionKey: "agent:test:agent-bundle-boundary",
workspaceDir,
cfg,
});
const materialized = await materializeBundleMcpToolsForRun({ runtime });
try {
expect(await fs.realpath(expectedPluginData)).toBe(expectedPluginData);
expect((await fs.stat(expectedPluginData)).isDirectory()).toBe(true);
const tool = expectDefined(
materialized.tools.find((entry) => entry.name === "weatherProbe__weather_probe"),
"materialized Agent Plugins weather probe tool",
);
expect(getPluginToolMeta(tool)).toMatchObject({
pluginId: "bundle-mcp",
mcp: { serverName: "weatherProbe", toolName: "weather_probe" },
});
const result = await tool.execute("agent-bundle-boundary-call", {}, undefined, undefined);
const text = result.content.find((entry) => entry.type === "text")?.text;
const payload = JSON.parse(expectDefined(text, "weather probe text result")) as unknown;
expect(isRecord(payload)).toBe(true);
if (!isRecord(payload)) {
return;
}
expect(payload).toEqual({
pluginRoot: expectedPluginRoot,
pluginData: expectedPluginData,
pluginDataExists: true,
argv: [expandedMarkerPath],
});
} finally {
await materialized.dispose();
await disposeAllSessionMcpRuntimes();
}
await fs.rm(expectedPluginData, { recursive: true });
await fs.writeFile(expectedPluginData, "Agent data-dir collision", "utf8");
const userDataPath = path.join(stateDir, "user-configured-data-file");
await fs.writeFile(userDataPath, "user-owned path", "utf8");
clearPluginMetadataLifecycleCaches();
const collisionRuntime = await getOrCreateSessionMcpRuntime({
sessionId: "agent-bundle-boundary-collision",
sessionKey: "agent:test:agent-bundle-boundary-collision",
workspaceDir,
cfg: {
...cfg,
mcp: {
servers: {
userProbe: {
command: "node",
args: [serverPath, expandedMarkerPath],
env: { PLUGIN_ROOT: pluginRoot, PLUGIN_DATA: userDataPath },
},
},
},
},
});
try {
const catalog = await collisionRuntime.getCatalog();
expect(Object.keys(catalog.servers)).toEqual(["userProbe"]);
expect(catalog.tools.map((tool) => `${tool.serverName}:${tool.toolName}`)).toEqual([
"userProbe:weather_probe",
]);
expect(catalog.diagnostics).toEqual([
expect.objectContaining({
serverName: "weatherProbe",
message: expect.stringMatching(/unable to prepare PLUGIN_DATA.*EEXIST/iu),
}),
]);
expect((await fs.stat(expectedPluginData)).isFile()).toBe(true);
expect((await fs.stat(userDataPath)).isFile()).toBe(true);
} finally {
await disposeAllSessionMcpRuntimes();
}
},
);
});
+22 -14
View File
@@ -27,20 +27,28 @@ import type { SessionMcpRuntime } from "./agent-bundle-mcp-types.js";
import { writeExecutable } from "./bundle-mcp-shared.test-harness.js";
import { updateMcpAppModelContext } from "./mcp-app-model-context.js";
vi.mock("./embedded-agent-mcp.js", () => ({
loadEmbeddedAgentMcpConfig: (params: {
cfg?: { mcp?: { servers?: Record<string, unknown> } };
toolOverrides?: { mcpServers?: Record<string, boolean> };
}) => ({
diagnostics: [],
mcpServers: Object.fromEntries(
Object.entries(params.cfg?.mcp?.servers ?? {}).filter(([name]) => {
const overrides = params.toolOverrides?.mcpServers;
return !(overrides && Object.hasOwn(overrides, name) && overrides[name] === false);
}),
),
}),
}));
vi.mock("./embedded-agent-mcp.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./embedded-agent-mcp.js")>();
return {
loadEmbeddedAgentMcpConfig: (
params: Parameters<typeof actual.loadEmbeddedAgentMcpConfig>[0],
) => {
if (params.cfg?.plugins?.entries?.["agent-bundle-probe"]?.enabled === true) {
return actual.loadEmbeddedAgentMcpConfig(params);
}
return {
diagnostics: [],
prepareDataDirsByServer: {},
mcpServers: Object.fromEntries(
Object.entries(params.cfg?.mcp?.servers ?? {}).filter(([name]) => {
const overrides = params.toolOverrides?.mcpServers;
return !(overrides && Object.hasOwn(overrides, name) && overrides[name] === false);
}),
),
};
},
};
});
const tempDirs: string[] = [];
const tempDirTracker = useAutoCleanupTempDirTracker(afterEach);
+4
View File
@@ -684,9 +684,13 @@ export function createSessionMcpRuntime(params: {
const transportSource = override
? applyMcpConnectionOverride(rawServer, override)
: rawServer;
const dataDirOwnership = Object.hasOwn(loaded.prepareDataDirsByServer ?? {}, serverName)
? loaded.prepareDataDirsByServer?.[serverName]
: undefined;
const resolved = resolveMcpTransport(serverName, transportSource, {
cfg: params.cfg,
agentDir: params.agentDir,
prepareDataDir: dataDirOwnership?.dataDir,
});
if (!resolved) {
continue;
+17
View File
@@ -13,6 +13,9 @@ const mocks = vi.hoisted(() => ({
},
},
diagnostics: [],
prepareDataDirsByServer: {
bundleProbe: { pluginId: "bundle-probe", dataDir: "/state/plugin-data/bundle-probe" },
},
},
}));
@@ -45,6 +48,19 @@ describe("loadMergedBundleMcpConfig", () => {
transport: "streamable-http",
url: "https://mcp.example.com/mcp",
});
expect(merged.prepareDataDirsByServer).toStrictEqual({});
});
it("preserves Agent Plugins launch ownership for unshadowed bundle servers", () => {
const merged = loadMergedBundleMcpConfig({
workspaceDir: "/workspace",
mapConfiguredServer: (server) => ({ ...server, mapped: true }),
});
expect(merged.config.mcpServers.bundleProbe).toMatchObject({ mapped: true });
expect(merged.prepareDataDirsByServer).toEqual({
bundleProbe: { pluginId: "bundle-probe", dataDir: "/state/plugin-data/bundle-probe" },
});
});
it("maps OpenClaw transports to downstream CLI types when requested", () => {
@@ -96,6 +112,7 @@ describe("loadMergedBundleMcpConfig", () => {
});
expect(merged.config.mcpServers).not.toHaveProperty("bundleProbe");
expect(merged.prepareDataDirsByServer).toStrictEqual({});
});
it.each([
+42 -1
View File
@@ -2,12 +2,15 @@
* Merges bundled plugin MCP servers with user-configured MCP servers for agent
* runtimes.
*/
import fs from "node:fs";
import { normalizeConfiguredMcpServers } from "../config/mcp-config-normalize.js";
import type { SessionToolOverrides } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { formatErrorMessage } from "../infra/errors.js";
import {
loadEnabledBundleMcpConfig,
type BundleMcpConfig,
type BundleMcpDataDirOwnership,
type BundleMcpDiagnostic,
type BundleMcpServerConfig,
} from "../plugins/bundle-mcp.js";
@@ -16,6 +19,7 @@ import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
type MergedBundleMcpConfig = {
config: BundleMcpConfig;
diagnostics: BundleMcpDiagnostic[];
prepareDataDirsByServer: Record<string, BundleMcpDataDirOwnership>;
};
type BundleMcpServerMapper = (server: BundleMcpServerConfig, name: string) => BundleMcpServerConfig;
@@ -27,6 +31,31 @@ const OPENCLAW_TRANSPORT_TO_CLI_BUNDLE_TYPE: Record<string, string> = {
stdio: "stdio",
};
export function prepareOwnedBundleMcpDataDirs(params: {
config: BundleMcpConfig;
prepareDataDirsByServer: Record<string, BundleMcpDataDirOwnership>;
}): MergedBundleMcpConfig {
const mcpServers = { ...params.config.mcpServers };
const prepareDataDirsByServer: Record<string, BundleMcpDataDirOwnership> = {};
const diagnostics: BundleMcpDiagnostic[] = [];
for (const [serverName, ownership] of Object.entries(params.prepareDataDirsByServer)) {
if (!Object.hasOwn(mcpServers, serverName)) {
continue;
}
try {
fs.mkdirSync(ownership.dataDir, { recursive: true });
prepareDataDirsByServer[serverName] = ownership;
} catch (error) {
delete mcpServers[serverName];
diagnostics.push({
pluginId: ownership.pluginId,
message: `unable to prepare PLUGIN_DATA directory "${ownership.dataDir}" for MCP server "${serverName}": ${formatErrorMessage(error)}`,
});
}
}
return { config: { mcpServers }, diagnostics, prepareDataDirsByServer };
}
/**
* User config stores OpenClaw MCP transport names, while CLI backends such as
* Claude Code and Gemini expect a downstream `type` field. Keep this adapter
@@ -84,12 +113,23 @@ export function loadMergedBundleMcpConfig(params: {
),
);
const mapConfiguredServer = params.mapConfiguredServer ?? ((server) => server);
const prepareDataDirsByServer = Object.fromEntries(
Object.entries(bundleMcp.prepareDataDirsByServer ?? {}).filter(
([name]) =>
Object.hasOwn(enabledBundleMcp, name) && !Object.hasOwn(enabledConfiguredMcp, name),
),
);
return {
config: {
// OpenClaw config is the owner-managed layer, so it overrides bundle defaults.
mcpServers: {
...enabledBundleMcp,
...Object.fromEntries(
Object.entries(enabledBundleMcp).map(([name, server]) => [
name,
mapConfiguredServer(server as BundleMcpServerConfig, name),
]),
),
...Object.fromEntries(
Object.entries(enabledConfiguredMcp).map(([name, server]) => [
name,
@@ -99,5 +139,6 @@ export function loadMergedBundleMcpConfig(params: {
} satisfies BundleMcpConfig["mcpServers"],
},
diagnostics: bundleMcp.diagnostics,
prepareDataDirsByServer,
};
}
+80 -1
View File
@@ -2,7 +2,13 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { writeClaudeBundleManifest } from "../../plugins/bundle-mcp.test-support.js";
import {
resolveBundlePluginRoot,
writeBundleTextFiles,
writeClaudeBundleManifest,
} from "../../plugins/bundle-mcp.test-support.js";
import { clearPluginMetadataLifecycleCaches } from "../../plugins/plugin-metadata-lifecycle.js";
import { withEnvAsync } from "../../test-utils/env.js";
import { prepareCliBundleMcpCaptureAttempt, prepareCliBundleMcpConfig } from "./bundle-mcp.js";
import {
cliBundleMcpHarness,
@@ -108,6 +114,79 @@ describe("prepareCliBundleMcpConfig", () => {
await prepared.cleanup?.();
});
it("carries Agent Plugins data-dir and transport contracts into external projections", async () => {
const pluginId = "agent-cli-projection";
const pluginRoot = resolveBundlePluginRoot(cliBundleMcpHarness.bundleProbeHomeDir, pluginId);
await writeBundleTextFiles(pluginRoot, {
"plugin.json": JSON.stringify({
$schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
name: pluginId,
}),
"mcp.json": JSON.stringify({
$schema: "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
mcpServers: {
local: { type: "stdio", command: "node" },
remote: { type: "streamable-http", url: "https://example.test/mcp" },
legacy: { type: "sse", url: "https://example.test/sse" },
},
}),
});
const agentDataDir = path.join(
cliBundleMcpHarness.bundleProbeHomeDir,
".openclaw",
"plugin-data",
pluginId,
);
const userDataPath = path.join(
cliBundleMcpHarness.bundleProbeHomeDir,
"user-data-must-not-exist",
);
clearPluginMetadataLifecycleCaches();
const prepared = await withEnvAsync(
{ HOME: cliBundleMcpHarness.bundleProbeHomeDir },
async () =>
await prepareCliBundleMcpConfig({
enabled: true,
mode: "gemini-system-settings",
backend: { command: "gemini" },
workspaceDir: cliBundleMcpHarness.bundleProbeWorkspaceDir,
config: {
plugins: { entries: { [pluginId]: { enabled: true } } },
mcp: {
servers: {
user: {
command: "node",
env: { PLUGIN_ROOT: "/user/plugin", PLUGIN_DATA: userDataPath },
},
},
},
},
}),
);
expect((await fs.stat(agentDataDir)).isDirectory()).toBe(true);
await expect(fs.stat(userDataPath)).rejects.toMatchObject({ code: "ENOENT" });
const raw = JSON.parse(
await fs.readFile(prepared.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH as string, "utf8"),
) as {
mcpServers?: Record<string, { type?: string; transport?: string; url?: string }>;
};
expect(raw.mcpServers?.remote).toMatchObject({
type: "http",
url: "https://example.test/mcp",
});
expect(raw.mcpServers?.legacy).toMatchObject({
type: "sse",
url: "https://example.test/sse",
});
expect(raw.mcpServers?.remote?.transport).toBeUndefined();
expect(raw.mcpServers?.legacy?.transport).toBeUndefined();
await prepared.cleanup?.();
await fs.rm(pluginRoot, { recursive: true, force: true });
clearPluginMetadataLifecycleCaches();
});
it("projects session MCP tool denials into Claude disallowed tools", async () => {
const workspaceDir = await cliBundleMcpHarness.tempHarness.createTempDir(
"openclaw-cli-bundle-mcp-deny-",
+18 -5
View File
@@ -21,7 +21,11 @@ import {
import type { CliBackendConfig } from "../../plugins/cli-backend.types.js";
import type { CliBundleMcpMode } from "../../plugins/types.js";
import { isRecord } from "../bundle-mcp-adapter.js";
import { loadMergedBundleMcpConfig, toCliBundleMcpServerConfig } from "../bundle-mcp-config.js";
import {
loadMergedBundleMcpConfig,
prepareOwnedBundleMcpDataDirs,
toCliBundleMcpServerConfig,
} from "../bundle-mcp-config.js";
import { resolveMcpBearerBundleConfig } from "../mcp-auth-profile.js";
import {
findClaudeMcpConfigPaths,
@@ -375,8 +379,12 @@ export async function prepareCliBundleMcpConfig(params: {
params.warn?.(`bundle MCP skipped for ${diagnostic.pluginId}: ${diagnostic.message}`);
}
mergedConfig = applyMergePatch(mergedConfig, bundleConfig.config) as BundleMcpConfig;
const prepareDataDirsByServer = { ...bundleConfig.prepareDataDirsByServer };
if (params.additionalConfig) {
mergedConfig = applyMergePatch(mergedConfig, params.additionalConfig) as BundleMcpConfig;
for (const serverName of Object.keys(params.additionalConfig.mcpServers)) {
delete prepareDataDirsByServer[serverName];
}
}
const resolvedBearerConfig = await resolveMcpBearerBundleConfig({
config: mergedConfig,
@@ -390,13 +398,18 @@ export async function prepareCliBundleMcpConfig(params: {
),
});
const preparedDataDirs = prepareOwnedBundleMcpDataDirs({
config: applyMcpServerOverrides(resolvedBearerConfig.config, params.toolOverrides?.mcpServers),
prepareDataDirsByServer,
});
for (const diagnostic of preparedDataDirs.diagnostics) {
params.warn?.(`bundle MCP skipped for ${diagnostic.pluginId}: ${diagnostic.message}`);
}
return await prepareModeSpecificBundleMcpConfig({
mode,
backend: params.backend,
mergedConfig: applyMcpServerOverrides(
resolvedBearerConfig.config,
params.toolOverrides?.mcpServers,
),
mergedConfig: preparedDataDirs.config,
env: resolvedBearerConfig.env,
mcpToolsDeny: params.toolOverrides?.mcpToolsDeny,
webSearchEnabled: params.toolOverrides?.webSearch,
+42 -1
View File
@@ -1,5 +1,8 @@
// Covers conversion from OpenClaw bundle-MCP config into Codex app-server
// thread config patches.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildCodexMcpServersConfig, loadCodexBundleMcpThreadConfig } from "./codex-mcp-config.js";
import { testing as resolverTesting } from "./mcp-connection-resolver.js";
@@ -12,6 +15,7 @@ const mocks = vi.hoisted(() => ({
diagnostics: [],
},
}));
const tempDirs: string[] = [];
vi.mock("../plugins/bundle-mcp.js", () => ({
loadEnabledBundleMcpConfig: () => mocks.bundleMcp,
@@ -26,8 +30,9 @@ beforeEach(() => {
};
});
afterEach(() => {
afterEach(async () => {
resolverTesting.setMcpServerConnectionResolversForTest();
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
describe("buildCodexMcpServersConfig", () => {
@@ -85,6 +90,42 @@ describe("buildCodexMcpServersConfig", () => {
});
describe("loadCodexBundleMcpThreadConfig", () => {
it("prepares Agent Plugins data dirs before projecting Codex thread config", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-agent-mcp-"));
tempDirs.push(tempDir);
const dataDir = path.join(tempDir, "plugin-data");
const collisionPath = path.join(tempDir, "plugin-data-collision");
await fs.writeFile(collisionPath, "not a directory", "utf8");
Object.assign(mocks.bundleMcp, {
config: {
mcpServers: {
weather: {
command: "node",
env: { PLUGIN_DATA: dataDir },
},
broken: { command: "node", env: { PLUGIN_DATA: collisionPath } },
},
},
diagnostics: [],
prepareDataDirsByServer: {
weather: { pluginId: "weather-plugin", dataDir },
broken: { pluginId: "broken-plugin", dataDir: collisionPath },
},
});
const loaded = loadCodexBundleMcpThreadConfig({ workspaceDir: "/workspace" });
expect((await fs.stat(dataDir)).isDirectory()).toBe(true);
expect(loaded.configPatch?.mcp_servers.weather).toMatchObject({ command: "node" });
expect(loaded.configPatch?.mcp_servers.broken).toBeUndefined();
expect(loaded.diagnostics).toEqual([
expect.objectContaining({
pluginId: "broken-plugin",
message: expect.stringMatching(/unable to prepare PLUGIN_DATA.*EEXIST/iu),
}),
]);
});
it("loads enabled bundled MCP servers as a Codex thread config patch", () => {
mocks.bundleMcp = {
config: {
+10 -3
View File
@@ -18,6 +18,7 @@ import {
normalizeBundleMcpServerConfig,
normalizeStringRecord,
} from "./bundle-mcp-adapter.js";
import { prepareOwnedBundleMcpDataDirs } from "./bundle-mcp-config.js";
import type {
CodexBundleMcpThreadConfig,
CodexMcpServersConfig,
@@ -229,7 +230,7 @@ export function loadCodexBundleMcpThreadConfig(
});
const configuredMcp = normalizeConfiguredMcpServers(params.cfg?.mcp?.servers);
const serverOverrides = params.toolOverrides?.mcpServers;
const mcpServers = buildCodexMcpServersConfig({
const effectiveConfig: BundleMcpConfig = {
mcpServers: Object.fromEntries(
Object.entries(bundleMcp.config.mcpServers)
.filter(([name]) => {
@@ -246,10 +247,16 @@ export function loadCodexBundleMcpThreadConfig(
applyCodexSessionMcpToolDenials(name, server, params.toolOverrides),
]),
),
};
const preparedDataDirs = prepareOwnedBundleMcpDataDirs({
config: effectiveConfig,
prepareDataDirsByServer: bundleMcp.prepareDataDirsByServer ?? {},
});
const diagnostics = [...bundleMcp.diagnostics, ...preparedDataDirs.diagnostics];
const mcpServers = buildCodexMcpServersConfig(preparedDataDirs.config);
if (Object.keys(mcpServers).length === 0) {
return {
diagnostics: bundleMcp.diagnostics,
diagnostics,
evaluated: true,
};
}
@@ -257,7 +264,7 @@ export function loadCodexBundleMcpThreadConfig(
configPatch: {
mcp_servers: mcpServers,
},
diagnostics: bundleMcp.diagnostics,
diagnostics,
evaluated: true,
fingerprint: fingerprintCodexMcpServersConfig(mcpServers),
};
+7 -1
View File
@@ -6,13 +6,18 @@ import type { SessionToolOverrides } from "../config/sessions/types.js";
* the launchable server map plus diagnostics for the caller.
*/
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { BundleMcpDiagnostic, BundleMcpServerConfig } from "../plugins/bundle-mcp.js";
import type {
BundleMcpDataDirOwnership,
BundleMcpDiagnostic,
BundleMcpServerConfig,
} from "../plugins/bundle-mcp.js";
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
import { loadMergedBundleMcpConfig } from "./bundle-mcp-config.js";
type EmbeddedAgentMcpConfig = {
mcpServers: Record<string, BundleMcpServerConfig>;
diagnostics: BundleMcpDiagnostic[];
prepareDataDirsByServer: Record<string, BundleMcpDataDirOwnership>;
};
/** Loads merged MCP server config for an embedded agent workspace. */
@@ -32,5 +37,6 @@ export function loadEmbeddedAgentMcpConfig(params: {
return {
mcpServers: bundleMcp.config.mcpServers,
diagnostics: bundleMcp.diagnostics,
prepareDataDirsByServer: bundleMcp.prepareDataDirsByServer,
};
}
+24
View File
@@ -1,6 +1,7 @@
// Exercises MCP stdio process lifecycle, JSON-RPC IO, and close escalation.
import type { SpawnOptions } from "node:child_process";
import { EventEmitter } from "node:events";
import fs from "node:fs/promises";
import { PassThrough } from "node:stream";
import { afterEach, describe, expect, it, vi } from "vitest";
import { OpenClawStdioClientTransport } from "./mcp-stdio-transport.js";
@@ -32,6 +33,7 @@ class MockChildProcess extends EventEmitter {
describe("OpenClawStdioClientTransport", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
spawnMock.mockReset();
killProcessTreeMock.mockReset();
signalProcessTreeMock.mockReset();
@@ -77,6 +79,28 @@ describe("OpenClawStdioClientTransport", () => {
expect(transport.stderr).toBeInstanceOf(PassThrough);
});
it("does not infer Agent Plugins data-dir ownership from subprocess env", async () => {
const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined);
const child = new MockChildProcess();
spawnMock.mockReturnValue(child);
const transport = new OpenClawStdioClientTransport({
command: "node",
env: { PLUGIN_ROOT: "/plugin", PLUGIN_DATA: "/user-owned-file" },
});
const started = transport.start();
child.emit("spawn");
await started;
expect(mkdirSpy).not.toHaveBeenCalled();
const options = spawnMock.mock.calls.at(0)?.[2] as SpawnOptions;
expect(options.env).toMatchObject({
PLUGIN_ROOT: "/plugin",
PLUGIN_DATA: "/user-owned-file",
});
mkdirSpy.mockRestore();
});
it("kills the process tree when graceful stdio close does not exit", async () => {
vi.useFakeTimers();
const child = new MockChildProcess();
+15
View File
@@ -2,12 +2,14 @@
* OpenClaw stdio transport wrapper for MCP server subprocesses.
*/
import { spawn, type ChildProcess } from "node:child_process";
import fs from "node:fs/promises";
import process from "node:process";
import { PassThrough } from "node:stream";
import { getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js";
import { ReadBuffer, serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
import { formatErrorMessage } from "../infra/errors.js";
import { killProcessTree, signalProcessTree } from "../process/kill-tree.js";
import { prepareOomScoreAdjustedSpawn } from "../process/linux-oom-score.js";
@@ -16,6 +18,7 @@ type OpenClawStdioServerParameters = {
args?: string[];
env?: Record<string, string>;
cwd?: string;
prepareDataDir?: string;
stderr?: "pipe" | "overlapped" | "inherit" | "ignore";
};
@@ -51,6 +54,18 @@ export class OpenClawStdioClientTransport implements Transport {
);
}
const prepareDataDir = this.serverParams.prepareDataDir?.trim();
if (prepareDataDir) {
try {
await fs.mkdir(prepareDataDir, { recursive: true });
} catch (error) {
throw new Error(
`unable to prepare PLUGIN_DATA directory "${prepareDataDir}": ${formatErrorMessage(error)}`,
{ cause: error },
);
}
}
await new Promise<void>((resolve, reject) => {
const baseEnv = {
...getDefaultEnvironment(),
+13
View File
@@ -238,4 +238,17 @@ describe("resolveMcpTransportConfig", () => {
supportsParallelToolCalls: false,
});
});
it.each([
{
name: "rejects non-HTTP URL schemes",
server: { url: "ftp://mcp.example.com/tools", transport: "streamable-http" },
},
{
name: "rejects http as a canonical transport",
server: { url: "https://mcp.example.com/http", transport: "http" },
},
])("$name", ({ server }) => {
expect(resolveMcpTransportConfig("probe", server)).toBeNull();
});
});
+50 -28
View File
@@ -136,20 +136,26 @@ function resolveHttpTransportConfig(
serverName: string,
rawServer: unknown,
transportType: HttpMcpTransportType,
logWarnings: boolean,
): ResolvedHttpMcpTransportConfig | null {
const launch = resolveHttpMcpServerLaunchConfig(rawServer, {
transportType,
onDroppedHeader: (key) => {
logWarn(
`bundle-mcp: server "${serverName}": header "${key}" has an unsupported value type and was ignored.`,
);
},
onMalformedHeaders: () => {
logWarn(
`bundle-mcp: server "${serverName}": "headers" must be a JSON object; the value was ignored.`,
);
},
});
const launch = resolveHttpMcpServerLaunchConfig(
rawServer,
logWarnings
? {
transportType,
onDroppedHeader: (key: string) => {
logWarn(
`bundle-mcp: server "${serverName}": header "${key}" has an unsupported value type and was ignored.`,
);
},
onMalformedHeaders: () => {
logWarn(
`bundle-mcp: server "${serverName}": "headers" must be a JSON object; the value was ignored.`,
);
},
}
: { transportType },
);
if (!launch.ok) {
return null;
}
@@ -190,18 +196,25 @@ function resolveHttpTransportConfig(
export function resolveMcpTransportConfig(
serverName: string,
rawServer: unknown,
options?: { logWarnings?: boolean },
): ResolvedMcpTransportConfig | null {
const logServerName = sanitizeForLog(serverName);
const logWarnings = options?.logWarnings !== false;
const requestedTransport = getRequestedTransport(rawServer);
const requestedTransportAlias = requestedTransport ? "" : getRequestedTransportAlias(rawServer);
const effectiveTransport = requestedTransport || requestedTransportAlias;
const stdioLaunch = resolveStdioMcpServerLaunchConfig(rawServer, {
onDroppedEnv: (key) => {
logWarn(
`bundle-mcp: server "${logServerName}": env "${sanitizeForLog(key)}" is blocked for stdio startup safety and was ignored.`,
);
},
});
const stdioLaunch = resolveStdioMcpServerLaunchConfig(
rawServer,
logWarnings
? {
onDroppedEnv: (key: string) => {
logWarn(
`bundle-mcp: server "${logServerName}": env "${sanitizeForLog(key)}" is blocked for stdio startup safety and was ignored.`,
);
},
}
: undefined,
);
if (stdioLaunch.ok) {
// A command-bearing server is always treated as stdio even when HTTP-ish
// aliases are present, matching existing MCP config precedence.
@@ -224,28 +237,37 @@ export function resolveMcpTransportConfig(
effectiveTransport !== "sse" &&
effectiveTransport !== "streamable-http"
) {
logWarn(
`bundle-mcp: skipped server "${logServerName}" because transport "${sanitizeForLog(effectiveTransport)}" is not supported.`,
);
if (logWarnings) {
logWarn(
`bundle-mcp: skipped server "${logServerName}" because transport "${sanitizeForLog(effectiveTransport)}" is not supported.`,
);
}
return null;
}
if (effectiveTransport === "streamable-http") {
const httpTransport = resolveHttpTransportConfig(serverName, rawServer, "streamable-http");
const httpTransport = resolveHttpTransportConfig(
serverName,
rawServer,
"streamable-http",
logWarnings,
);
if (httpTransport) {
return httpTransport;
}
}
const sseTransport = resolveHttpTransportConfig(serverName, rawServer, "sse");
const sseTransport = resolveHttpTransportConfig(serverName, rawServer, "sse", logWarnings);
if (sseTransport) {
return sseTransport;
}
const httpLaunch = resolveHttpMcpServerLaunchConfig(rawServer);
const httpReason = httpLaunch.ok ? "not an HTTP MCP server" : httpLaunch.reason;
logWarn(
`bundle-mcp: skipped server "${logServerName}" because ${stdioLaunch.reason} and ${httpReason}.`,
);
if (logWarnings) {
logWarn(
`bundle-mcp: skipped server "${logServerName}" because ${stdioLaunch.reason} and ${httpReason}.`,
);
}
return null;
}
+2 -1
View File
@@ -91,7 +91,7 @@ function buildSseEventSourceFetch(
export function resolveMcpTransport(
serverName: string,
rawServer: unknown,
options?: { cfg?: OpenClawConfig; agentDir?: string },
options?: { cfg?: OpenClawConfig; agentDir?: string; prepareDataDir?: string },
): ResolvedMcpTransport | null {
const resolved = resolveMcpTransportConfig(serverName, rawServer);
if (!resolved) {
@@ -103,6 +103,7 @@ export function resolveMcpTransport(
args: resolved.args,
env: resolved.env,
cwd: resolved.cwd,
prepareDataDir: options?.prepareDataDir,
stderr: "pipe",
});
return {
+8 -1
View File
@@ -818,7 +818,11 @@ describe("plugins cli list", () => {
cliCommands: [],
services: [],
gatewayDiscoveryServices: [],
mcpServers: [],
mcpServers: [
{ name: "local", hasStdioTransport: true },
{ name: "remote", hasStdioTransport: false },
{ name: "broken", hasStdioTransport: false, unsupported: true },
],
lspServers: [],
httpRouteCount: 0,
bundleCapabilities: [],
@@ -845,6 +849,9 @@ describe("plugins cli list", () => {
);
expect(runtimeLogs.join("\n")).toContain("ClawPack spec: 1");
expect(runtimeLogs.join("\n")).toContain("ClawPack size: 4096 bytes");
expect(runtimeLogs.join("\n")).toContain("remote");
expect(runtimeLogs.join("\n")).not.toContain("remote (unsupported transport)");
expect(runtimeLogs.join("\n")).toContain("broken (unsupported transport)");
});
it("runtime-inspects without repairing deps", async () => {
+5 -2
View File
@@ -11,6 +11,7 @@ import { defaultRuntime } from "../runtime.js";
import { shortenHomeInString, shortenHomePath } from "../utils.js";
import { formatMissingPluginMessage } from "./error-format.js";
import { quietPluginJsonLogger } from "./plugins-json-logger.js";
import { formatPluginBundleFormat } from "./plugins-list-format.js";
/** Options accepted by `openclaw plugins inspect`. */
export type PluginInspectOptions = {
@@ -299,7 +300,9 @@ export async function runPluginsInspectCommand(
}
lines.push(`${theme.muted("Format:")} ${inspect.plugin.format ?? "openclaw"}`);
if (inspect.plugin.bundleFormat) {
lines.push(`${theme.muted("Bundle format:")} ${inspect.plugin.bundleFormat}`);
lines.push(
`${theme.muted("Bundle format:")} ${formatPluginBundleFormat(inspect.plugin.bundleFormat)}`,
);
}
lines.push(`${theme.muted("Source:")} ${shortenHomeInString(inspect.plugin.source)}`);
lines.push(`${theme.muted("Origin:")} ${inspect.plugin.origin}`);
@@ -356,7 +359,7 @@ export async function runPluginsInspectCommand(
...formatInspectSection(
"MCP servers",
inspect.mcpServers.map((entry) =>
entry.hasStdioTransport ? entry.name : `${entry.name} (unsupported transport)`,
entry.unsupported ? `${entry.name} (unsupported transport)` : entry.name,
),
),
);
+13
View File
@@ -28,6 +28,19 @@ describe("formatPluginLine", () => {
expect(output).toContain("explicitly enabled: no");
});
it("labels portable bundle records as Agent Plugins", () => {
const output = formatPluginLine(
createPluginRecord({
id: "portable",
format: "bundle",
bundleFormat: "agent",
}),
true,
);
expect(output).toContain("bundle format: agent (Agent Plugins)");
});
it("sanitizes activation reasons in verbose output", () => {
const output = formatPluginLine(
createPluginRecord({
+6 -1
View File
@@ -2,9 +2,14 @@
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js";
import { theme } from "../../packages/terminal-core/src/theme.js";
import type { PluginBundleFormat } from "../plugins/manifest-types.js";
import type { PluginRecord } from "../plugins/registry.js";
import { shortenHomeInString } from "../utils.js";
export function formatPluginBundleFormat(bundleFormat: PluginBundleFormat): string {
return bundleFormat === "agent" ? "agent (Agent Plugins)" : bundleFormat;
}
export function formatPluginLine(plugin: PluginRecord, verbose = false): string {
const status =
plugin.status === "error"
@@ -34,7 +39,7 @@ export function formatPluginLine(plugin: PluginRecord, verbose = false): string
` origin: ${plugin.origin}`,
];
if (plugin.bundleFormat) {
parts.push(` bundle format: ${plugin.bundleFormat}`);
parts.push(` bundle format: ${formatPluginBundleFormat(plugin.bundleFormat)}`);
}
if (plugin.version) {
parts.push(` version: ${plugin.version}`);
@@ -10,7 +10,7 @@ import {
runPluginPayloadSmokeCheckForManifestRecords,
} from "./plugin-payload-validation.js";
type BundleFormat = "codex" | "claude" | "cursor";
type BundleFormat = "agent" | "codex" | "claude" | "cursor";
type FormatMarkedBundleInstallRecord = PluginInstallRecord & {
format: "bundle";
bundleFormat?: BundleFormat;
@@ -52,16 +52,28 @@ describe("runPluginPayloadSmokeCheck", () => {
await fs.mkdir(path.join(params.dir, "skills"), { recursive: true });
return;
}
const manifestDir =
params.format === "codex"
? ".codex-plugin"
: params.format === "cursor"
? ".cursor-plugin"
: ".claude-plugin";
await fs.mkdir(path.join(params.dir, manifestDir), { recursive: true });
const manifestRelativePath =
params.format === "agent"
? "plugin.json"
: path.join(
params.format === "codex"
? ".codex-plugin"
: params.format === "cursor"
? ".cursor-plugin"
: ".claude-plugin",
"plugin.json",
);
await fs.mkdir(path.dirname(path.join(params.dir, manifestRelativePath)), { recursive: true });
await fs.writeFile(
path.join(params.dir, manifestDir, "plugin.json"),
JSON.stringify(params.manifest ?? { name: `${params.format}-bundle` }),
path.join(params.dir, manifestRelativePath),
JSON.stringify(
params.manifest ?? {
...(params.format === "agent"
? { $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json" }
: {}),
name: `${params.format}-bundle`,
},
),
"utf8",
);
await fs.mkdir(path.join(params.dir, "skills"), { recursive: true });
@@ -201,6 +213,7 @@ describe("runPluginPayloadSmokeCheck", () => {
});
it.each([
["agent", "format"],
["codex", "clawhubFamily"],
["claude", "format"],
["cursor", "format"],
+4 -3
View File
@@ -75,6 +75,7 @@ describe("Claude bundle plugin inspect integration", () => {
args: ["hello"],
},
"test-sse-server": {
type: "sse",
url: "http://localhost:3000/sse",
},
},
@@ -219,10 +220,10 @@ describe("Claude bundle plugin inspect integration", () => {
it.each([
{
name: "inspects MCP runtime support with supported and unsupported servers",
name: "inspects MCP runtime support across stdio and HTTP transports",
kind: "mcp" as const,
supportedServerNames: ["test-stdio-server"],
unsupportedServerNames: ["test-sse-server"],
supportedServerNames: ["test-stdio-server", "test-sse-server"],
unsupportedServerNames: [],
hasSupportedKey: "hasSupportedStdioServer" as const,
},
{
+175 -4
View File
@@ -1,8 +1,9 @@
/** Tests bundle manifest parsing for Codex, Claude, Cursor, and OpenClaw formats. */
/** Tests bundle manifest parsing for Agent, Codex, Claude, Cursor, and OpenClaw formats. */
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
AGENT_BUNDLE_MANIFEST_RELATIVE_PATH,
CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH,
CODEX_BUNDLE_MANIFEST_RELATIVE_PATH,
CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH,
@@ -15,6 +16,8 @@ import {
mkdirSafeDir,
} from "./test-helpers/fs-fixtures.js";
const AGENT_BUNDLE_MANIFEST_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
type BundlePluginManifest = Extract<
ReturnType<typeof loadBundleManifest>,
{ ok: true }
@@ -38,7 +41,10 @@ function makeTempDir() {
const mkdirSafe = mkdirSafeDir;
function expectLoadedManifest(rootDir: string, bundleFormat: "codex" | "claude" | "cursor") {
function expectLoadedManifest(
rootDir: string,
bundleFormat: "agent" | "codex" | "claude" | "cursor",
) {
const result = loadBundleManifest({ rootDir, bundleFormat });
expect(result.ok).toBe(true);
if (!result.ok) {
@@ -127,7 +133,7 @@ function setupClaudeHookFixture(
function expectBundleManifest(params: {
rootDir: string;
bundleFormat: "codex" | "claude" | "cursor";
bundleFormat: "agent" | "codex" | "claude" | "cursor";
expected: ReadonlyBundleManifestExpectation;
}) {
expect(detectBundleManifestFormat(params.rootDir)).toBe(params.bundleFormat);
@@ -160,6 +166,40 @@ describe("bundle manifest parsing", () => {
});
it.each([
{
name: "detects and loads Agent Plugins bundles from the portable layout",
bundleFormat: "agent" as const,
setup: (rootDir: string) => {
setupBundleFixture({
rootDir,
dirs: ["skills/summarize"],
textFiles: {
"skills/summarize/SKILL.md": "---\nname: summarize\ndescription: Summarize\n---\n",
"mcp.json": "{",
},
manifestRelativePath: AGENT_BUNDLE_MANIFEST_RELATIVE_PATH,
manifest: {
$schema: AGENT_BUNDLE_MANIFEST_SCHEMA,
name: "Portable.Bundle",
description: "Agent Plugins fixture",
version: "1.2.3",
extensions: "ignored",
unknown: true,
},
});
},
expected: {
id: "portable-bundle",
name: "Portable.Bundle",
description: "Agent Plugins fixture",
version: "1.2.3",
bundleFormat: "agent",
skills: ["skills"],
settingsFiles: [],
hooks: [],
capabilities: ["skills", "mcpServers"],
},
},
{
name: "detects and loads Codex bundle manifests",
bundleFormat: "codex" as const,
@@ -369,6 +409,131 @@ describe("bundle manifest parsing", () => {
});
});
it("keeps client-specific and native formats ahead of portable Agent Plugins", () => {
const claudeRoot = makeTempDir();
setupBundleFixture({
rootDir: claudeRoot,
dirs: [".claude-plugin"],
jsonFiles: {
[CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH]: { name: "Claude" },
[AGENT_BUNDLE_MANIFEST_RELATIVE_PATH]: {
$schema: AGENT_BUNDLE_MANIFEST_SCHEMA,
name: "Agent",
},
},
});
expect(detectBundleManifestFormat(claudeRoot)).toBe("claude");
const nativeRoot = makeTempDir();
writeBundleFixtureFiles(nativeRoot, {
"openclaw.plugin.json": { id: "native", configSchema: { type: "object" } },
[AGENT_BUNDLE_MANIFEST_RELATIVE_PATH]: {
$schema: AGENT_BUNDLE_MANIFEST_SCHEMA,
name: "Agent",
},
});
expect(detectBundleManifestFormat(nativeRoot)).toBeNull();
const entryRoot = makeTempDir();
writeBundleFixtureFiles(entryRoot, {
"index.ts": "export default {}",
[AGENT_BUNDLE_MANIFEST_RELATIVE_PATH]: {
$schema: AGENT_BUNDLE_MANIFEST_SCHEMA,
name: "Agent",
},
});
expect(detectBundleManifestFormat(entryRoot)).toBe("agent");
});
it.each([
{
name: "wrong schema falls through to native entry detection",
manifest: { $schema: "https://wrong.example/plugin.schema.json", name: "not-agent" },
files: { "index.ts": "export default {}" },
expected: null,
},
{
name: "missing schema falls through to manifestless Claude markers",
manifest: { name: "not-agent" },
files: { "skills/example/SKILL.md": "---\ndescription: Example\n---\n" },
expected: "claude",
},
{
name: "wrong schema without fallback markers is not a bundle",
manifest: { $schema: "https://wrong.example/plugin.schema.json", name: "not-agent" },
files: {},
expected: null,
},
])("$name", ({ manifest, files, expected }) => {
const rootDir = makeTempDir();
writeBundleFixtureFiles(rootDir, {
[AGENT_BUNDLE_MANIFEST_RELATIVE_PATH]: manifest,
...files,
});
expect(detectBundleManifestFormat(rootDir)).toBe(expected);
});
it.each([
{ name: "missing schema", manifest: { name: "portable" } },
{
name: "wrong schema",
manifest: { $schema: "https://wrong.example/plugin.schema.json", name: "portable" },
},
])("rejects Agent Plugins manifests with $name", ({ manifest }) => {
const rootDir = makeTempDir();
writeBundleManifest(rootDir, AGENT_BUNDLE_MANIFEST_RELATIVE_PATH, manifest);
const result = loadBundleManifest({ rootDir, bundleFormat: "agent" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain(`expected $schema ${AGENT_BUNDLE_MANIFEST_SCHEMA}`);
}
});
it("rejects Agent Plugins manifests with missing or empty names", () => {
for (const name of [undefined, " "]) {
const rootDir = makeTempDir();
writeBundleManifest(rootDir, AGENT_BUNDLE_MANIFEST_RELATIVE_PATH, {
$schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
...(name === undefined ? {} : { name }),
});
const result = loadBundleManifest({ rootDir, bundleFormat: "agent" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("name must be a non-empty string");
}
}
});
it("requires strict JSON only for Agent Plugins manifests", () => {
const rootDir = makeTempDir();
writeBundleFixtureFile(
rootDir,
AGENT_BUNDLE_MANIFEST_RELATIVE_PATH,
'{ name: "not strict JSON", }',
);
const result = loadBundleManifest({ rootDir, bundleFormat: "agent" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("failed to parse plugin manifest");
}
});
it("does not expose Agent Plugins skills when skills is not a directory", () => {
const rootDir = makeTempDir();
writeBundleFixtureFiles(rootDir, {
[AGENT_BUNDLE_MANIFEST_RELATIVE_PATH]: {
$schema: AGENT_BUNDLE_MANIFEST_SCHEMA,
name: "portable",
},
skills: "not a directory",
});
expect(expectLoadedManifest(rootDir, "agent").skills).toStrictEqual([]);
});
it.each([
{
name: "accepts JSON5 Codex bundle manifests",
@@ -463,6 +628,11 @@ describe("bundle manifest parsing", () => {
);
it.each([
{
name: "rejects Agent Plugins manifests that parse to non-objects",
bundleFormat: "agent" as const,
manifestRelativePath: AGENT_BUNDLE_MANIFEST_RELATIVE_PATH,
},
{
name: "rejects JSON5 Codex bundle manifests that parse to non-objects",
bundleFormat: "codex" as const,
@@ -484,7 +654,8 @@ describe("bundle manifest parsing", () => {
rootDir,
dirs: [path.dirname(manifestRelativePath)],
textFiles: {
[manifestRelativePath]: "'still not an object'",
[manifestRelativePath]:
bundleFormat === "agent" ? '"still not an object"' : "'still not an object'",
},
});
+79 -3
View File
@@ -1,4 +1,5 @@
/** Reads Codex/Claude/Cursor bundle manifests into OpenClaw plugin manifest metadata. */
/** Reads Agent/Codex/Claude/Cursor bundle manifests into OpenClaw plugin manifest metadata. */
import fs from "node:fs";
import path from "node:path";
import {
normalizeLowercaseStringOrEmpty,
@@ -22,6 +23,9 @@ import { pluginScanExistsSync } from "./plugin-scan-existence-cache.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 const AGENT_BUNDLE_MANIFEST_RELATIVE_PATH = "plugin.json";
const AGENT_BUNDLE_MANIFEST_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
const MAX_AGENT_BUNDLE_MANIFEST_BYTES = 256 * 1024;
/** Normalized bundle manifest shape consumed by plugin discovery. */
type BundlePluginManifest = {
@@ -95,6 +99,8 @@ function loadBundleManifestFile(params: {
manifestRelativePath: string;
rejectHardlinks: boolean;
allowMissing?: boolean;
strictJson?: boolean;
maxBytes?: number;
}): BundleManifestFileLoadResult {
const manifestPath = path.join(params.rootDir, params.manifestRelativePath);
const result = readRootStructuredFileSync<Record<string, unknown>>({
@@ -103,7 +109,8 @@ function loadBundleManifestFile(params: {
relativePath: params.manifestRelativePath,
boundaryLabel: "plugin root",
rejectHardlinks: params.rejectHardlinks,
parse: (raw) => JSON5.parse(raw),
...(params.maxBytes !== undefined ? { maxBytes: params.maxBytes } : {}),
parse: (raw) => (params.strictJson ? JSON.parse(raw) : JSON5.parse(raw)),
validate: isRecord,
});
if (!result.ok && result.reason === "open") {
@@ -333,6 +340,25 @@ function buildCursorCapabilities(raw: Record<string, unknown>, rootDir: string):
return capabilities;
}
function resolveAgentSkillDirs(rootDir: string): string[] {
try {
return fs.statSync(path.join(rootDir, "skills")).isDirectory() ? ["skills"] : [];
} catch {
return [];
}
}
function buildAgentCapabilities(rootDir: string): string[] {
const capabilities: string[] = [];
if (resolveAgentSkillDirs(rootDir).length > 0) {
capabilities.push("skills");
}
if (pluginScanExistsSync(path.join(rootDir, "mcp.json"))) {
capabilities.push("mcpServers");
}
return capabilities;
}
export function loadBundleManifest(params: {
rootDir: string;
rootRealPath?: string;
@@ -345,13 +371,17 @@ export function loadBundleManifest(params: {
? CODEX_BUNDLE_MANIFEST_RELATIVE_PATH
: params.bundleFormat === "cursor"
? CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH
: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH;
: params.bundleFormat === "agent"
? AGENT_BUNDLE_MANIFEST_RELATIVE_PATH
: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH;
const loaded = loadBundleManifestFile({
rootDir: params.rootDir,
...(params.rootRealPath !== undefined ? { rootRealPath: params.rootRealPath } : {}),
manifestRelativePath,
rejectHardlinks,
allowMissing: params.bundleFormat === "claude",
strictJson: params.bundleFormat === "agent",
...(params.bundleFormat === "agent" ? { maxBytes: MAX_AGENT_BUNDLE_MANIFEST_BYTES } : {}),
});
if (!loaded.ok) {
return loaded;
@@ -366,6 +396,38 @@ export function loadBundleManifest(params: {
normalizeOptionalString(interfaceRecord?.shortDescription);
const version = normalizeOptionalString(raw.version);
if (params.bundleFormat === "agent") {
if (raw.$schema !== AGENT_BUNDLE_MANIFEST_SCHEMA) {
return {
ok: false,
error: `root plugin.json is not an Agent Plugins manifest; expected $schema ${AGENT_BUNDLE_MANIFEST_SCHEMA}`,
manifestPath: loaded.manifestPath,
};
}
if (!name) {
return {
ok: false,
error: "agent plugin manifest name must be a non-empty string",
manifestPath: loaded.manifestPath,
};
}
return {
ok: true,
manifest: {
id: slugifyPluginId(name, params.rootDir),
name,
description,
version,
skills: resolveAgentSkillDirs(params.rootDir),
settingsFiles: [],
hooks: [],
bundleFormat: "agent",
capabilities: buildAgentCapabilities(params.rootDir),
},
manifestPath: loaded.manifestPath,
};
}
if (params.bundleFormat === "codex") {
const skills = resolveCodexSkillDirs(raw, params.rootDir);
const hooks = resolveCodexHookDirs(raw, params.rootDir);
@@ -437,6 +499,20 @@ export function detectBundleManifestFormat(rootDir: string): PluginBundleFormat
if (pluginScanExistsSync(path.join(rootDir, PLUGIN_MANIFEST_FILENAME))) {
return null;
}
// Client-specific bundle dirs and native OpenClaw manifests take precedence;
// the portable root manifest is the fallback when neither is present.
if (pluginScanExistsSync(path.join(rootDir, AGENT_BUNDLE_MANIFEST_RELATIVE_PATH))) {
const agentManifest = loadBundleManifestFile({
rootDir,
manifestRelativePath: AGENT_BUNDLE_MANIFEST_RELATIVE_PATH,
rejectHardlinks: false,
strictJson: true,
maxBytes: MAX_AGENT_BUNDLE_MANIFEST_BYTES,
});
if (agentManifest.ok && agentManifest.raw.$schema === AGENT_BUNDLE_MANIFEST_SCHEMA) {
return "agent";
}
}
if (
DEFAULT_PLUGIN_ENTRY_CANDIDATES.some((candidate) =>
pluginScanExistsSync(path.join(rootDir, candidate)),
+331 -1
View File
@@ -6,7 +6,8 @@ import { afterEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { isRecord } from "../utils.js";
import { loadEnabledBundleLspConfig } from "./bundle-lsp.js";
import { loadEnabledBundleMcpConfig } from "./bundle-mcp.js";
import { loadBundleManifest } from "./bundle-manifest.js";
import { inspectBundleMcpRuntimeSupport, loadEnabledBundleMcpConfig } from "./bundle-mcp.js";
import {
createEnabledPluginEntries,
createBundleMcpTempHarness,
@@ -38,6 +39,10 @@ async function expectResolvedPathEqual(actual: unknown, expected: string): Promi
);
}
async function expectPathMissing(targetPath: string): Promise<void> {
await expect(fs.stat(targetPath)).rejects.toMatchObject({ code: "ENOENT" });
}
function expectNoDiagnostics(diagnostics: unknown[]) {
expect(diagnostics).toStrictEqual([]);
}
@@ -56,6 +61,25 @@ function createEnabledBundleConfig(pluginIds: string[]): OpenClawConfig {
};
}
const AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
const AGENT_MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
async function writeAgentBundle(params: {
homeDir: string;
pluginId: string;
manifest?: Record<string, unknown>;
mcp?: unknown;
textFiles?: Record<string, string>;
}) {
const pluginRoot = resolveBundlePluginRoot(params.homeDir, params.pluginId);
await writeBundleTextFiles(pluginRoot, {
"plugin.json": `${JSON.stringify({ $schema: AGENT_PLUGIN_SCHEMA, name: params.pluginId, ...params.manifest }, null, 2)}\n`,
...(params.mcp === undefined ? {} : { "mcp.json": `${JSON.stringify(params.mcp, null, 2)}\n` }),
...params.textFiles,
});
return pluginRoot;
}
async function expectInlineBundleMcpServer(params: {
loadedServer: unknown;
pluginRoot: string;
@@ -441,4 +465,310 @@ describe("loadEnabledBundleMcpConfig", () => {
},
);
});
it("loads Agent Plugins MCP config with placeholders, injected env, and canonical transports", async () => {
await withBundleHomeEnv(
tempHarness,
"openclaw-agent-bundle-mcp",
async ({ homeDir, workspaceDir }) => {
const pluginRoot = await writeAgentBundle({
homeDir,
pluginId: "portable-mcp",
mcp: {
$schema: AGENT_MCP_SCHEMA,
mcpServers: {
local: {
type: "stdio",
command: "./bin/server",
args: ["${PLUGIN_ROOT}/config.json", "${PLUGIN_DATA}/cache"],
env: {
ROOT_COPY: "${PLUGIN_ROOT}",
DATA_COPY: "${PLUGIN_DATA}",
},
cwd: "${PLUGIN_DATA}",
},
remote: {
type: "streamable-http",
url: "https://example.test/mcp",
headers: { Authorization: "Bearer test" },
},
legacy: {
type: "sse",
url: "https://example.test/sse",
},
},
},
textFiles: {
"bin/server": "#!/bin/sh\n",
"config.json": "{}\n",
},
});
const loaded = loadEnabledBundleMcpConfig({
workspaceDir,
cfg: createEnabledBundleConfig(["portable-mcp"]),
});
const local = expectDefined(loaded.config.mcpServers.local, "local agent MCP server");
const remote = expectDefined(loaded.config.mcpServers.remote, "remote agent MCP server");
const legacy = expectDefined(loaded.config.mcpServers.legacy, "legacy agent MCP server");
const localEnv = isRecord(local.env) ? local.env : {};
const localArgs = getServerArgs(local);
expectNoDiagnostics(loaded.diagnostics);
expect(local).toMatchObject({ transport: "stdio" });
expect(local.type).toBeUndefined();
await expectResolvedPathEqual(local.command, path.join(pluginRoot, "bin", "server"));
await expectResolvedPathEqual(localArgs?.[0], path.join(pluginRoot, "config.json"));
expect(localArgs?.[1]).toBe(path.join(String(localEnv.PLUGIN_DATA), "cache"));
await expectResolvedPathEqual(localEnv.PLUGIN_ROOT, pluginRoot);
const pluginDataPath = path.join(homeDir, ".openclaw", "plugin-data", "portable-mcp");
expect(localEnv.PLUGIN_DATA).toBe(pluginDataPath);
expect(local.cwd).toBe(pluginDataPath);
expect(loaded.prepareDataDirsByServer).toEqual({
local: { pluginId: "portable-mcp", dataDir: pluginDataPath },
});
await expectPathMissing(pluginDataPath);
expect(localEnv.ROOT_COPY).toBe(localEnv.PLUGIN_ROOT);
expect(localEnv.DATA_COPY).toBe(localEnv.PLUGIN_DATA);
expect(remote).toEqual({
transport: "streamable-http",
url: "https://example.test/mcp",
headers: { Authorization: "Bearer test" },
});
expect(legacy).toEqual({
transport: "sse",
url: "https://example.test/sse",
});
expect(
inspectBundleMcpRuntimeSupport({
pluginId: "portable-mcp",
rootDir: pluginRoot,
bundleFormat: "agent",
}),
).toMatchObject({
hasSupportedStdioServer: true,
supportedServerNames: ["local", "remote", "legacy"],
stdioServerNames: ["local"],
unsupportedServerNames: [],
});
},
);
});
it("resolves Agent Plugins relative cwd from the plugin root and rejects traversal", async () => {
await withBundleHomeEnv(
tempHarness,
"openclaw-agent-bundle-cwd",
async ({ homeDir, workspaceDir }) => {
const pluginRoot = await writeAgentBundle({
homeDir,
pluginId: "portable-cwd",
mcp: {
$schema: AGENT_MCP_SCHEMA,
mcpServers: {
valid: { type: "stdio", command: "node", cwd: "./child" },
relativeEscape: { type: "stdio", command: "node", cwd: "./../escape" },
placeholderEscape: {
type: "stdio",
command: "node",
cwd: "${PLUGIN_ROOT}/../escape",
},
},
},
textFiles: { "child/.keep": "" },
});
const rootRealPath = await fs.realpath(pluginRoot);
const relativeProcessCwd = path.relative(rootRealPath, await fs.realpath(process.cwd()));
expect(relativeProcessCwd === ".." || relativeProcessCwd.startsWith(`..${path.sep}`)).toBe(
true,
);
const loaded = loadEnabledBundleMcpConfig({
workspaceDir,
cfg: createEnabledBundleConfig(["portable-cwd"]),
});
expect(Object.keys(loaded.config.mcpServers)).toEqual(["valid"]);
await expectResolvedPathEqual(
loaded.config.mcpServers.valid?.cwd,
path.join(pluginRoot, "child"),
);
expect(loaded.diagnostics).toHaveLength(2);
expect(loaded.diagnostics.map((entry) => entry.message)).toEqual([
expect.stringContaining('invalid MCP server "relativeEscape"'),
expect.stringContaining('invalid MCP server "placeholderEscape"'),
]);
expect(loaded.diagnostics.every((entry) => entry.message.includes("cwd must remain"))).toBe(
true,
);
},
);
});
it("ignores dot MCP config and inline MCP fields for Agent Plugins", async () => {
await withBundleHomeEnv(
tempHarness,
"openclaw-agent-bundle-closed",
async ({ homeDir, workspaceDir }) => {
const pluginRoot = await writeAgentBundle({
homeDir,
pluginId: "closed-agent",
manifest: {
mcpServers: {
inline: { type: "stdio", command: "node" },
},
},
textFiles: {
".mcp.json": JSON.stringify({
mcpServers: { dotted: { type: "stdio", command: "node" } },
}),
},
});
const loaded = loadEnabledBundleMcpConfig({
workspaceDir,
cfg: createEnabledBundleConfig(["closed-agent"]),
});
expectNoDiagnostics(loaded.diagnostics);
expect(loaded.config.mcpServers).toStrictEqual({});
await expectPathMissing(path.join(homeDir, ".openclaw", "plugin-data", "closed-agent"));
expect(await fs.realpath(pluginRoot)).toBeTruthy();
},
);
});
it("keeps Agent Plugins inspection pure when PLUGIN_DATA collides", async () => {
await withBundleHomeEnv(
tempHarness,
"openclaw-agent-bundle-data-collision",
async ({ homeDir, workspaceDir }) => {
const pluginId = "data-dir-collision";
const pluginRoot = await writeAgentBundle({
homeDir,
pluginId,
mcp: {
$schema: AGENT_MCP_SCHEMA,
mcpServers: {
local: { type: "stdio", command: "node" },
remote: { type: "streamable-http", url: "https://example.test/mcp" },
},
},
textFiles: {
"skills/weather/SKILL.md": "---\nname: weather\ndescription: Weather skill\n---\n",
},
});
const pluginDataPath = path.join(homeDir, ".openclaw", "plugin-data", pluginId);
await fs.mkdir(path.dirname(pluginDataPath), { recursive: true });
await fs.writeFile(pluginDataPath, "directory collision", "utf8");
const loaded = loadEnabledBundleMcpConfig({
workspaceDir,
cfg: createEnabledBundleConfig([pluginId]),
});
expect(loaded.config.mcpServers).toEqual({
local: {
transport: "stdio",
command: "node",
cwd: await fs.realpath(pluginRoot),
env: {
PLUGIN_ROOT: await fs.realpath(pluginRoot),
PLUGIN_DATA: pluginDataPath,
},
},
remote: { transport: "streamable-http", url: "https://example.test/mcp" },
});
expect(loaded.diagnostics).toStrictEqual([]);
expect(loaded.prepareDataDirsByServer).toEqual({
local: { pluginId, dataDir: pluginDataPath },
});
expect(
inspectBundleMcpRuntimeSupport({
pluginId,
rootDir: pluginRoot,
bundleFormat: "agent",
}),
).toMatchObject({
supportedServerNames: ["local", "remote"],
unsupportedServerNames: [],
});
expect((await fs.stat(pluginDataPath)).isFile()).toBe(true);
const manifest = loadBundleManifest({ rootDir: pluginRoot, bundleFormat: "agent" });
expect(manifest.ok).toBe(true);
if (manifest.ok) {
expect(manifest.manifest.skills).toEqual(["skills"]);
expect(manifest.manifest.capabilities).toEqual(
expect.arrayContaining(["skills", "mcpServers"]),
);
}
},
);
});
it.each([
{ name: "malformed JSON", content: "{" },
{ name: "missing mcpServers", content: JSON.stringify({ $schema: AGENT_MCP_SCHEMA }) },
])("isolates Agent Plugins MCP failure for $name", async ({ content }) => {
await withBundleHomeEnv(
tempHarness,
"openclaw-agent-bundle-invalid",
async ({ homeDir, workspaceDir }) => {
const pluginRoot = await writeAgentBundle({
homeDir,
pluginId: "invalid-agent-mcp",
});
await fs.writeFile(path.join(pluginRoot, "mcp.json"), content, "utf-8");
const loaded = loadEnabledBundleMcpConfig({
workspaceDir,
cfg: createEnabledBundleConfig(["invalid-agent-mcp"]),
});
expect(loaded.config.mcpServers).toStrictEqual({});
expect(loaded.diagnostics).toHaveLength(1);
expect(loaded.diagnostics[0]?.pluginId).toBe("invalid-agent-mcp");
expect(loaded.diagnostics[0]?.message).toContain("mcp.json");
},
);
});
it("skips invalid Agent Plugins MCP entries while retaining valid siblings", async () => {
await withBundleHomeEnv(
tempHarness,
"openclaw-agent-bundle-entry-isolation",
async ({ homeDir, workspaceDir }) => {
await writeAgentBundle({
homeDir,
pluginId: "isolated-agent-mcp",
mcp: {
$schema: AGENT_MCP_SCHEMA,
mcpServers: {
valid: { type: "streamable-http", url: "https://example.test/mcp" },
invalid: {
type: "stdio",
command: "node --inspect",
env: { PLUGIN_ROOT: "override" },
},
},
},
});
const loaded = loadEnabledBundleMcpConfig({
workspaceDir,
cfg: createEnabledBundleConfig(["isolated-agent-mcp"]),
});
expect(loaded.config.mcpServers).toEqual({
valid: {
transport: "streamable-http",
url: "https://example.test/mcp",
},
});
expect(loaded.diagnostics).toHaveLength(1);
expect(loaded.diagnostics[0]?.message).toContain('invalid MCP server "invalid"');
},
);
});
});
+351 -55
View File
@@ -1,23 +1,26 @@
// Bundles MCP metadata exposed by plugins for package output.
import fs from "node:fs";
import path from "node:path";
import { resolveMcpTransportConfig } from "../agents/mcp-transport-config.js";
import { applyMergePatch } from "../config/merge-patch.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { readRootJsonObjectSync } from "../infra/json-files.js";
import { isRecord } from "../utils.js";
import {
inspectBundleServerRuntimeSupport,
loadEnabledBundleConfig,
readBundleJsonObject,
resolveBundleJsonOpenFailure,
} from "./bundle-config-shared.js";
import {
AGENT_BUNDLE_MANIFEST_RELATIVE_PATH,
CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH,
CODEX_BUNDLE_MANIFEST_RELATIVE_PATH,
CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH,
mergeBundlePathLists,
normalizeBundlePathList,
} from "./bundle-manifest.js";
import { encodePluginInstallDirName } from "./install-paths.js";
import { resolveActivePluginInstallRoots } from "./install-root-context.js";
import type { PluginManifestRegistry } from "./manifest-registry.js";
import type { PluginBundleFormat } from "./manifest-types.js";
@@ -27,6 +30,15 @@ export type BundleMcpConfig = {
mcpServers: Record<string, BundleMcpServerConfig>;
};
export type BundleMcpDataDirOwnership = {
pluginId: string;
dataDir: string;
};
type BundleMcpRuntimeConfig = BundleMcpConfig & {
prepareDataDirsByServer: Record<string, BundleMcpDataDirOwnership | null>;
};
export type BundleMcpDiagnostic = {
pluginId: string;
message: string;
@@ -35,26 +47,39 @@ export type BundleMcpDiagnostic = {
type EnabledBundleMcpConfigResult = {
config: BundleMcpConfig;
diagnostics: BundleMcpDiagnostic[];
prepareDataDirsByServer: Record<string, BundleMcpDataDirOwnership>;
};
type BundleMcpRuntimeSupport = {
hasSupportedStdioServer: boolean;
supportedServerNames: string[];
stdioServerNames: string[];
unsupportedServerNames: string[];
diagnostics: string[];
};
const MANIFEST_PATH_BY_FORMAT: Record<PluginBundleFormat, string> = {
agent: AGENT_BUNDLE_MANIFEST_RELATIVE_PATH,
claude: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH,
codex: CODEX_BUNDLE_MANIFEST_RELATIVE_PATH,
cursor: CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH,
};
const CLAUDE_PLUGIN_ROOT_PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}";
const AGENT_PLUGIN_ROOT_PLACEHOLDER = "${PLUGIN_ROOT}";
const AGENT_PLUGIN_DATA_PLACEHOLDER = "${PLUGIN_DATA}";
const AGENT_MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
const BUNDLE_PLACEHOLDER_PATTERN = /\$\{(?:CLAUDE_PLUGIN_ROOT|PLUGIN_ROOT|PLUGIN_DATA)\}/g;
const AGENT_MCP_TOP_LEVEL_KEYS = new Set(["$schema", "mcpServers"]);
const AGENT_STDIO_KEYS = new Set(["type", "command", "args", "env", "cwd"]);
const AGENT_HTTP_KEYS = new Set(["type", "url", "headers"]);
function resolveBundleMcpConfigPaths(params: {
raw: Record<string, unknown>;
rootDir: string;
bundleFormat: PluginBundleFormat;
}): string[] {
if (params.bundleFormat === "agent") {
return fs.existsSync(path.join(params.rootDir, "mcp.json")) ? ["mcp.json"] : [];
}
const declared = normalizeBundlePathList(params.raw.mcpServers);
const defaults = fs.existsSync(path.join(params.rootDir, ".mcp.json")) ? [".mcp.json"] : [];
if (params.bundleFormat === "claude") {
@@ -89,11 +114,21 @@ function isExplicitRelativePath(value: string): boolean {
return value === "." || value === ".." || value.startsWith("./") || value.startsWith("../");
}
function expandBundleRootPlaceholders(value: string, rootDir: string): string {
if (!value.includes(CLAUDE_PLUGIN_ROOT_PLACEHOLDER)) {
return value;
}
return value.split(CLAUDE_PLUGIN_ROOT_PLACEHOLDER).join(rootDir);
function expandBundleRootPlaceholders(params: {
value: string;
rootDir: string;
pluginDataDir?: string;
}): string {
// One replacement pass prevents placeholders introduced by substituted paths from expanding.
return params.value.replace(BUNDLE_PLACEHOLDER_PATTERN, (placeholder) => {
if (
placeholder === CLAUDE_PLUGIN_ROOT_PLACEHOLDER ||
(placeholder === AGENT_PLUGIN_ROOT_PLACEHOLDER && params.pluginDataDir)
) {
return params.rootDir;
}
return params.pluginDataDir ?? placeholder;
});
}
function normalizeBundlePath(targetPath: string): string {
@@ -108,16 +143,26 @@ function absolutizeBundleMcpServer(params: {
rootDir: string;
baseDir: string;
server: BundleMcpServerConfig;
pluginDataDir?: string;
agentFormat?: boolean;
}): BundleMcpServerConfig {
const next: BundleMcpServerConfig = { ...params.server };
if (typeof next.cwd !== "string" && typeof next.workingDirectory !== "string") {
if (
typeof next.cwd !== "string" &&
typeof next.workingDirectory !== "string" &&
(!params.agentFormat || typeof next.command === "string")
) {
next.cwd = params.baseDir;
}
const command = next.command;
if (typeof command === "string") {
const expanded = expandBundleRootPlaceholders(command, params.rootDir);
const expanded = expandBundleRootPlaceholders({
value: command,
rootDir: params.rootDir,
pluginDataDir: params.pluginDataDir,
});
next.command = isExplicitRelativePath(expanded)
? path.resolve(params.baseDir, expanded)
: normalizeExpandedAbsolutePath(expanded);
@@ -125,13 +170,21 @@ function absolutizeBundleMcpServer(params: {
const cwd = next.cwd;
if (typeof cwd === "string") {
const expanded = expandBundleRootPlaceholders(cwd, params.rootDir);
const expanded = expandBundleRootPlaceholders({
value: cwd,
rootDir: params.rootDir,
pluginDataDir: params.pluginDataDir,
});
next.cwd = path.isAbsolute(expanded) ? expanded : path.resolve(params.baseDir, expanded);
}
const workingDirectory = next.workingDirectory;
if (typeof workingDirectory === "string") {
const expanded = expandBundleRootPlaceholders(workingDirectory, params.rootDir);
const expanded = expandBundleRootPlaceholders({
value: workingDirectory,
rootDir: params.rootDir,
pluginDataDir: params.pluginDataDir,
});
next.workingDirectory = path.isAbsolute(expanded)
? path.normalize(expanded)
: path.resolve(params.baseDir, expanded);
@@ -142,7 +195,11 @@ function absolutizeBundleMcpServer(params: {
if (typeof entry !== "string") {
return entry;
}
const expanded = expandBundleRootPlaceholders(entry, params.rootDir);
const expanded = expandBundleRootPlaceholders({
value: entry,
rootDir: params.rootDir,
pluginDataDir: params.pluginDataDir,
});
if (!isExplicitRelativePath(expanded)) {
return normalizeExpandedAbsolutePath(expanded);
}
@@ -155,20 +212,199 @@ function absolutizeBundleMcpServer(params: {
Object.entries(next.env).map(([key, value]) => [
key,
typeof value === "string"
? normalizeExpandedAbsolutePath(expandBundleRootPlaceholders(value, params.rootDir))
? normalizeExpandedAbsolutePath(
expandBundleRootPlaceholders({
value,
rootDir: params.rootDir,
pluginDataDir: params.pluginDataDir,
}),
)
: value,
]),
);
}
if (params.pluginDataDir && typeof next.command === "string") {
next.env = {
...(isRecord(next.env) ? next.env : {}),
PLUGIN_ROOT: params.rootDir,
PLUGIN_DATA: params.pluginDataDir,
};
}
return next;
}
function loadBundleFileBackedMcpConfig(params: { rootDir: string; relativePath: string }): {
config: BundleMcpConfig;
function hasOnlyKeys(raw: Record<string, unknown>, allowed: ReadonlySet<string>): boolean {
return Object.keys(raw).every((key) => allowed.has(key));
}
function isStringRecord(raw: unknown): raw is Record<string, string> {
return isRecord(raw) && Object.values(raw).every((value) => typeof value === "string");
}
function isPathWithin(baseDir: string, targetPath: string): boolean {
const relative = path.relative(baseDir, targetPath);
return (
relative === "" ||
(!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`))
);
}
function isValidAgentCommand(command: unknown, rootDir: string): command is string {
if (typeof command !== "string" || command.length === 0) {
return false;
}
if (command.startsWith("./")) {
return command.length > 2 && isPathWithin(rootDir, path.resolve(rootDir, command));
}
return !/[\s/\\]/.test(command);
}
function isValidAgentCwd(cwd: unknown, rootDir: string, pluginDataDir: string): cwd is string {
if (typeof cwd !== "string") {
return false;
}
let baseDir: string;
if (cwd.startsWith("./")) {
baseDir = rootDir;
} else if (
cwd === AGENT_PLUGIN_ROOT_PLACEHOLDER ||
cwd.startsWith(`${AGENT_PLUGIN_ROOT_PLACEHOLDER}/`)
) {
baseDir = rootDir;
} else if (
cwd === AGENT_PLUGIN_DATA_PLACEHOLDER ||
cwd.startsWith(`${AGENT_PLUGIN_DATA_PLACEHOLDER}/`)
) {
baseDir = pluginDataDir;
} else {
return false;
}
const expanded = expandBundleRootPlaceholders({ value: cwd, rootDir, pluginDataDir });
return isPathWithin(baseDir, path.resolve(baseDir, expanded));
}
function validateAgentMcpServer(params: {
raw: unknown;
rootDir: string;
pluginDataDir: string;
}): { ok: true; server: BundleMcpServerConfig } | { ok: false; error: string } {
if (!isRecord(params.raw) || typeof params.raw.type !== "string") {
return { ok: false, error: "configuration must be an object with a supported type" };
}
const type = params.raw.type;
if (type === "stdio") {
if (!hasOnlyKeys(params.raw, AGENT_STDIO_KEYS)) {
return { ok: false, error: "stdio configuration contains unknown fields" };
}
if (!isValidAgentCommand(params.raw.command, params.rootDir)) {
return { ok: false, error: "stdio command must be a bare name or ./-relative path" };
}
if (
params.raw.args !== undefined &&
(!Array.isArray(params.raw.args) ||
!params.raw.args.every((entry) => typeof entry === "string"))
) {
return { ok: false, error: "stdio args must be an array of strings" };
}
if (params.raw.env !== undefined && !isStringRecord(params.raw.env)) {
return { ok: false, error: "stdio env must contain only string values" };
}
if (
isRecord(params.raw.env) &&
(Object.hasOwn(params.raw.env, "PLUGIN_ROOT") || Object.hasOwn(params.raw.env, "PLUGIN_DATA"))
) {
return { ok: false, error: "stdio env must not define PLUGIN_ROOT or PLUGIN_DATA" };
}
if (
params.raw.cwd !== undefined &&
!isValidAgentCwd(params.raw.cwd, params.rootDir, params.pluginDataDir)
) {
return { ok: false, error: "stdio cwd must remain within PLUGIN_ROOT or PLUGIN_DATA" };
}
} else if (type === "streamable-http" || type === "sse") {
if (!hasOnlyKeys(params.raw, AGENT_HTTP_KEYS)) {
return { ok: false, error: `${type} configuration contains unknown fields` };
}
if (typeof params.raw.url !== "string" || params.raw.url.length === 0) {
return { ok: false, error: `${type} url must be a non-empty string` };
}
if (params.raw.headers !== undefined && !isStringRecord(params.raw.headers)) {
return { ok: false, error: `${type} headers must contain only string values` };
}
} else {
return { ok: false, error: `unsupported type: ${type}` };
}
const server: BundleMcpServerConfig = { ...params.raw, transport: type };
delete server.type;
return { ok: true, server };
}
function resolveAgentPluginDataDir(pluginId: string): string {
return path.join(
resolveActivePluginInstallRoots().stateDir,
"plugin-data",
encodePluginInstallDirName(pluginId),
);
}
function extractAgentMcpServerMap(params: {
raw: Record<string, unknown>;
pluginId: string;
rootDir: string;
}): {
servers: Record<string, BundleMcpServerConfig>;
diagnostics: string[];
pluginDataDir?: string;
} {
if (
params.raw.$schema !== AGENT_MCP_SCHEMA ||
!hasOnlyKeys(params.raw, AGENT_MCP_TOP_LEVEL_KEYS) ||
!isRecord(params.raw.mcpServers)
) {
return {
servers: {},
diagnostics: [
`invalid mcp.json: expected only $schema=${AGENT_MCP_SCHEMA} and object mcpServers`,
],
};
}
const pluginDataDir = resolveAgentPluginDataDir(params.pluginId);
const servers: Record<string, BundleMcpServerConfig> = {};
const diagnostics: string[] = [];
for (const [serverName, raw] of Object.entries(params.raw.mcpServers)) {
const validated = validateAgentMcpServer({ raw, rootDir: params.rootDir, pluginDataDir });
if (!validated.ok) {
diagnostics.push(`invalid MCP server "${serverName}" in mcp.json: ${validated.error}`);
continue;
}
servers[serverName] = validated.server;
}
const hasStdioServer = Object.values(servers).some((server) => server.transport === "stdio");
if (!hasStdioServer) {
return { servers, diagnostics };
}
// The encoded install id makes this path stable before it exists. Creation belongs to
// stdio launch so read-only inspection never mutates plugin state.
return { servers, diagnostics, pluginDataDir };
}
function loadBundleFileBackedMcpConfig(params: {
pluginId: string;
rootDir: string;
relativePath: string;
bundleFormat: PluginBundleFormat;
}): {
config: BundleMcpRuntimeConfig;
diagnostics: string[];
} {
const rootDir = normalizeBundlePath(params.rootDir);
const rootDir =
params.bundleFormat === "agent"
? fs.realpathSync(params.rootDir)
: normalizeBundlePath(params.rootDir);
const absolutePath = path.resolve(rootDir, params.relativePath);
const result = readRootJsonObjectSync({
rootDir,
@@ -179,39 +415,63 @@ function loadBundleFileBackedMcpConfig(params: { rootDir: string; relativePath:
if (!result.ok) {
if (result.reason === "open") {
return {
config: { mcpServers: {} },
config: { mcpServers: {}, prepareDataDirsByServer: {} },
diagnostics:
result.failure.reason === "path"
? []
? params.bundleFormat === "agent"
? [`unable to read ${params.relativePath}: path`]
: []
: [`unable to read ${params.relativePath}: ${result.failure.reason}`],
};
}
return {
config: { mcpServers: {} },
config: { mcpServers: {}, prepareDataDirsByServer: {} },
diagnostics: [`unable to read ${params.relativePath}: ${result.error}`],
};
}
const servers = extractMcpServerMap(result.value);
const agentLoaded =
params.bundleFormat === "agent"
? extractAgentMcpServerMap({
raw: result.value,
pluginId: params.pluginId,
rootDir,
})
: undefined;
const servers = agentLoaded?.servers ?? extractMcpServerMap(result.value);
const baseDir = normalizeBundlePath(path.dirname(absolutePath));
return {
config: {
mcpServers: Object.fromEntries(
Object.entries(servers).map(([serverName, server]) => [
serverName,
absolutizeBundleMcpServer({ rootDir, baseDir, server }),
absolutizeBundleMcpServer({
rootDir,
baseDir,
server,
pluginDataDir: agentLoaded?.pluginDataDir,
agentFormat: params.bundleFormat === "agent",
}),
]),
),
prepareDataDirsByServer: Object.fromEntries(
Object.entries(servers).map(([serverName, server]) => [
serverName,
agentLoaded?.pluginDataDir && server.transport === "stdio"
? { pluginId: params.pluginId, dataDir: agentLoaded.pluginDataDir }
: null,
]),
),
},
diagnostics: [],
diagnostics: agentLoaded?.diagnostics ?? [],
};
}
function loadBundleInlineMcpConfig(params: {
raw: Record<string, unknown>;
baseDir: string;
}): BundleMcpConfig {
}): BundleMcpRuntimeConfig {
if (!isRecord(params.raw.mcpServers)) {
return { mcpServers: {} };
return { mcpServers: {}, prepareDataDirsByServer: {} };
}
const baseDir = normalizeBundlePath(params.baseDir);
const servers = extractMcpServerMap(params.raw.mcpServers);
@@ -222,13 +482,16 @@ function loadBundleInlineMcpConfig(params: {
absolutizeBundleMcpServer({ rootDir: baseDir, baseDir, server }),
]),
),
prepareDataDirsByServer: Object.fromEntries(
Object.keys(servers).map((serverName) => [serverName, null]),
),
};
}
function loadNativePluginMcpConfig(params: {
rootDir: string;
mcpServers: Record<string, BundleMcpServerConfig>;
}): { config: BundleMcpConfig; diagnostics: string[] } {
}): { config: BundleMcpRuntimeConfig; diagnostics: string[] } {
const rootDir = normalizeBundlePath(params.rootDir);
return {
config: {
@@ -238,6 +501,9 @@ function loadNativePluginMcpConfig(params: {
absolutizeBundleMcpServer({ rootDir, baseDir: rootDir, server }),
]),
),
prepareDataDirsByServer: Object.fromEntries(
Object.keys(params.mcpServers).map((serverName) => [serverName, null]),
),
},
diagnostics: [],
};
@@ -247,7 +513,7 @@ function loadBundleMcpConfig(params: {
pluginId: string;
rootDir: string;
bundleFormat: PluginBundleFormat;
}): { config: BundleMcpConfig; diagnostics: string[] } {
}): { config: BundleMcpRuntimeConfig; diagnostics: string[] } {
const manifestRelativePath = MANIFEST_PATH_BY_FORMAT[params.bundleFormat];
const manifestLoaded = readBundleJsonObject({
rootDir: params.rootDir,
@@ -260,10 +526,13 @@ function loadBundleMcpConfig(params: {
}),
});
if (!manifestLoaded.ok) {
return { config: { mcpServers: {} }, diagnostics: [manifestLoaded.error] };
return {
config: { mcpServers: {}, prepareDataDirsByServer: {} },
diagnostics: [manifestLoaded.error],
};
}
let merged: BundleMcpConfig = { mcpServers: {} };
let merged: BundleMcpRuntimeConfig = { mcpServers: {}, prepareDataDirsByServer: {} };
const filePaths = resolveBundleMcpConfigPaths({
raw: manifestLoaded.raw,
rootDir: params.rootDir,
@@ -272,20 +541,24 @@ function loadBundleMcpConfig(params: {
const diagnostics: string[] = [];
for (const relativePath of filePaths) {
const loaded = loadBundleFileBackedMcpConfig({
pluginId: params.pluginId,
rootDir: params.rootDir,
relativePath,
bundleFormat: params.bundleFormat,
});
diagnostics.push(...loaded.diagnostics);
merged = applyMergePatch(merged, loaded.config) as BundleMcpConfig;
merged = applyMergePatch(merged, loaded.config) as BundleMcpRuntimeConfig;
}
merged = applyMergePatch(
merged,
loadBundleInlineMcpConfig({
raw: manifestLoaded.raw,
baseDir: params.rootDir,
}),
) as BundleMcpConfig;
if (params.bundleFormat !== "agent") {
merged = applyMergePatch(
merged,
loadBundleInlineMcpConfig({
raw: manifestLoaded.raw,
baseDir: params.rootDir,
}),
) as BundleMcpRuntimeConfig;
}
return { config: merged, diagnostics };
}
@@ -295,31 +568,42 @@ export function inspectBundleMcpRuntimeSupport(params: {
rootDir: string;
bundleFormat: PluginBundleFormat;
}): BundleMcpRuntimeSupport {
const support = inspectBundleServerRuntimeSupport({
loaded: loadBundleMcpConfig(params),
resolveServers: (config) => config.mcpServers,
});
return {
hasSupportedStdioServer: support.hasSupportedServer,
supportedServerNames: support.supportedServerNames,
unsupportedServerNames: support.unsupportedServerNames,
diagnostics: support.diagnostics,
};
return inspectMcpServerRuntimeSupport(loadBundleMcpConfig(params));
}
export function inspectNativePluginMcpRuntimeSupport(params: {
rootDir: string;
mcpServers: Record<string, BundleMcpServerConfig>;
}): BundleMcpRuntimeSupport {
const support = inspectBundleServerRuntimeSupport({
loaded: loadNativePluginMcpConfig(params),
resolveServers: (config) => config.mcpServers,
});
return inspectMcpServerRuntimeSupport(loadNativePluginMcpConfig(params));
}
function inspectMcpServerRuntimeSupport(loaded: {
config: BundleMcpConfig;
diagnostics: string[];
}): BundleMcpRuntimeSupport {
const supportedServerNames: string[] = [];
const stdioServerNames: string[] = [];
const unsupportedServerNames: string[] = [];
for (const [serverName, server] of Object.entries(loaded.config.mcpServers)) {
const transport = resolveMcpTransportConfig(serverName, server, { logWarnings: false });
if (transport?.kind === "stdio") {
supportedServerNames.push(serverName);
stdioServerNames.push(serverName);
continue;
}
if (transport?.kind === "http") {
supportedServerNames.push(serverName);
continue;
}
unsupportedServerNames.push(serverName);
}
return {
hasSupportedStdioServer: support.hasSupportedServer,
supportedServerNames: support.supportedServerNames,
unsupportedServerNames: support.unsupportedServerNames,
diagnostics: support.diagnostics,
hasSupportedStdioServer: stdioServerNames.length > 0,
supportedServerNames,
stdioServerNames,
unsupportedServerNames,
diagnostics: loaded.diagnostics,
};
}
@@ -328,11 +612,14 @@ export function loadEnabledBundleMcpConfig(params: {
cfg?: OpenClawConfig;
manifestRegistry?: Pick<PluginManifestRegistry, "plugins">;
}): EnabledBundleMcpConfigResult {
return loadEnabledBundleConfig({
const loaded = loadEnabledBundleConfig({
workspaceDir: params.workspaceDir,
cfg: params.cfg,
manifestRegistry: params.manifestRegistry,
createEmptyConfig: () => ({ mcpServers: {} }),
createEmptyConfig: (): BundleMcpRuntimeConfig => ({
mcpServers: {},
prepareDataDirsByServer: {},
}),
loadBundleConfig: loadBundleMcpConfig,
loadNativePluginConfig: ({ record }) =>
record.mcpServers
@@ -343,4 +630,13 @@ export function loadEnabledBundleMcpConfig(params: {
: undefined,
createDiagnostic: (pluginId, message) => ({ pluginId, message }),
});
return {
config: { mcpServers: loaded.config.mcpServers },
diagnostics: loaded.diagnostics,
prepareDataDirsByServer: Object.fromEntries(
Object.entries(loaded.config.prepareDataDirsByServer).filter(
(entry): entry is [string, BundleMcpDataDirOwnership] => entry[1] !== null,
),
),
};
}
+15
View File
@@ -2337,6 +2337,21 @@ describe("discoverOpenClawPlugins", () => {
});
it.each([
{
name: "auto-detects Agent Plugins bundles as bundle candidates",
idHint: "portable-bundle",
bundleFormat: "agent",
setup: (stateDir: string) => {
const bundleDir = path.join(stateDir, "extensions", "portable-bundle");
createBundleRoot(bundleDir, "plugin.json", {
$schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
name: "portable-bundle",
});
mkdirSafe(path.join(bundleDir, "skills", "sample"));
return bundleDir;
},
expectRootDir: true,
},
{
name: "auto-detects Codex bundles as bundle candidates",
idHint: "sample-bundle",
+1
View File
@@ -33,6 +33,7 @@ const PLUGIN_ARCHIVE_ROOT_MARKERS = [
".codex-plugin/plugin.json",
".claude-plugin/plugin.json",
".cursor-plugin/plugin.json",
"plugin.json",
];
function pickPackageInstallCommonParams(
+39 -21
View File
@@ -146,30 +146,48 @@ describe("installPluginFromPath", () => {
},
);
it("installs Claude bundles from an archive path", async () => {
const { pluginDir, extensionsDir } = setupBundleInstallFixture({
bundleFormat: "claude",
it.each([
{
format: "agent" as const,
name: "Portable Sample",
pluginId: "portable-sample",
archiveName: "agent-bundle.tgz",
manifestPath: "plugin.json",
},
{
format: "claude" as const,
name: "Claude Sample",
});
const archivePath = path.join(suiteTempRootTracker.makeTempDir(), "claude-bundle.tgz");
pluginId: "claude-sample",
archiveName: "claude-bundle.tgz",
manifestPath: path.join(".claude-plugin", "plugin.json"),
},
])(
"installs $format bundles from an archive path",
async ({ format, name, pluginId, archiveName, manifestPath }) => {
const { pluginDir, extensionsDir } = setupBundleInstallFixture({
bundleFormat: format,
name,
});
const archivePath = path.join(suiteTempRootTracker.makeTempDir(), archiveName);
await packToArchive({
pkgDir: pluginDir,
outDir: path.dirname(archivePath),
outName: path.basename(archivePath),
});
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);
});
const result = await installPluginFromPath({
path: archivePath,
extensionsDir,
});
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.pluginId).toBe(pluginId);
expect(fs.existsSync(path.join(result.targetDir, manifestPath))).toBe(true);
},
);
it("prefers native package metadata without installing dependencies for dual-format archives", async () => {
const { nodeModulesExists, result, runCalls } = dualFormatArchiveCase;
+10
View File
@@ -3753,6 +3753,16 @@ describe("installPluginFromDir", () => {
});
it.each([
{
name: "installs Agent Plugins bundles from a local directory",
setup: () =>
setupBundleInstallFixture({
bundleFormat: "agent",
name: "Portable Sample",
}),
expectedPluginId: "portable-sample",
expectedFiles: ["plugin.json", "skills/fixture/SKILL.md"],
},
{
name: "installs Codex bundles from a local directory",
setup: () =>
+1 -1
View File
@@ -632,7 +632,7 @@ function recordBundleDiagnostics(params: {
source: params.record.source,
message:
"bundle MCP servers use unsupported transports or incomplete configs " +
`(stdio only today): ${runtimeSupport.unsupportedServerNames.join(", ")}`,
`(${runtimeSupport.unsupportedServerNames.join(", ")})`,
});
}
}
+12 -3
View File
@@ -158,7 +158,7 @@ describe("bundle plugins", () => {
expectNoUnwiredBundleDiagnostic(registry, pluginId);
});
it("warns when bundle MCP only declares unsupported non-stdio transports", () => {
it("accepts bundle HTTP MCP and warns only for incomplete configs", () => {
const stateDir = makeTempDir();
const registry = loadBundleFixture({
pluginId: "claude-mcp-url",
@@ -179,8 +179,12 @@ describe("bundle plugins", () => {
JSON.stringify({
mcpServers: {
remoteProbe: {
transport: "streamable-http",
url: "http://127.0.0.1:8787/mcp",
},
incompleteProbe: {
transport: "streamable-http",
},
},
}),
"utf-8",
@@ -195,9 +199,14 @@ describe("bundle plugins", () => {
registry.diagnostics.some(
(diag) =>
diag.pluginId === "claude-mcp-url" &&
diag.message.includes("stdio only today") &&
diag.message.includes("remoteProbe"),
diag.message.includes("unsupported transports or incomplete configs") &&
diag.message.includes("incompleteProbe"),
),
).toBe(true);
expect(
registry.diagnostics.some(
(diag) => diag.pluginId === "claude-mcp-url" && diag.message.includes("remoteProbe"),
),
).toBe(false);
});
});
+1 -1
View File
@@ -20,7 +20,7 @@ export type PluginConfigUiHint = {
export type PluginFormat = "openclaw" | "bundle";
/** Supported external bundle manifest formats. */
export type PluginBundleFormat = "codex" | "claude" | "cursor";
export type PluginBundleFormat = "agent" | "codex" | "claude" | "cursor";
/**
* Closed classification codes for plugin diagnostics. Health surfaces branch
+16 -2
View File
@@ -378,7 +378,11 @@ function expectBundleInspectState(
params: {
bundleCapabilities: readonly string[];
shape: string;
mcpServers?: readonly { name: string; hasStdioTransport: boolean }[];
mcpServers?: readonly {
name: string;
hasStdioTransport: boolean;
unsupported?: boolean;
}[];
},
) {
expect(inspect.bundleCapabilities).toEqual(params.bundleCapabilities);
@@ -1060,12 +1064,22 @@ describe("plugin status reports", () => {
rootDir: "/tmp/native-mcp",
mcpServers: {
app: { transport: "stdio", command: "node", args: ["./mcp-server.js"] },
remote: { type: "http", url: "https://example.test/mcp" },
incomplete: { transport: "streamable-http" },
invalidScheme: { transport: "streamable-http", url: "ftp://example.test/mcp" },
invalidTransport: { transport: "http", url: "https://example.test/mcp" },
},
}),
expectedId: "native-mcp",
expectedBundleCapabilities: [],
expectedShape: "non-capability",
expectedMcpServers: [{ name: "app", hasStdioTransport: true }],
expectedMcpServers: [
{ name: "app", hasStdioTransport: true },
{ name: "remote", hasStdioTransport: false },
{ name: "incomplete", hasStdioTransport: false, unsupported: true },
{ name: "invalidScheme", hasStdioTransport: false, unsupported: true },
{ name: "invalidTransport", hasStdioTransport: false, unsupported: true },
],
},
])(
"$name",
+4 -1
View File
@@ -95,6 +95,7 @@ export type PluginInspectReport = {
mcpServers: Array<{
name: string;
hasStdioTransport: boolean;
unsupported?: boolean;
}>;
lspServers: Array<{
name: string;
@@ -424,14 +425,16 @@ export function buildPluginInspectReport(params: {
})
: undefined;
if (mcpSupport) {
const stdioServerNames = new Set(mcpSupport.stdioServerNames);
mcpServers = [
...mcpSupport.supportedServerNames.map((name) => ({
name,
hasStdioTransport: true,
hasStdioTransport: stdioServerNames.has(name),
})),
...mcpSupport.unsupportedServerNames.map((name) => ({
name,
hasStdioTransport: false,
unsupported: true,
})),
];
}
+23 -15
View File
@@ -4,7 +4,7 @@ import path from "node:path";
type MakeTempDir = () => string;
type BundleFixtureFormat = "codex" | "claude" | "cursor";
type BundleFixtureFormat = "agent" | "codex" | "claude" | "cursor";
export function createBundleInstallFixtureFactory(makeTempDir: MakeTempDir) {
return function setupBundleInstallFixture(params: {
@@ -16,18 +16,24 @@ export function createBundleInstallFixtureFactory(makeTempDir: MakeTempDir) {
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 });
const manifestRelativePath =
params.bundleFormat === "agent"
? "plugin.json"
: path.join(
params.bundleFormat === "codex"
? ".codex-plugin"
: params.bundleFormat === "cursor"
? ".cursor-plugin"
: ".claude-plugin",
"plugin.json",
);
fs.mkdirSync(path.dirname(path.join(pluginDir, manifestRelativePath)), { recursive: true });
fs.writeFileSync(
path.join(manifestDir, "plugin.json"),
path.join(pluginDir, manifestRelativePath),
JSON.stringify({
...(params.bundleFormat === "agent"
? { $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json" }
: {}),
name: params.name,
description: `${params.bundleFormat} bundle fixture`,
...(params.bundleFormat === "codex" ? { skills: "skills" } : {}),
@@ -42,11 +48,13 @@ export function createBundleInstallFixtureFactory(makeTempDir: MakeTempDir) {
"utf-8",
);
}
fs.writeFileSync(
path.join(pluginDir, "skills", "SKILL.md"),
"---\ndescription: fixture\n---\n",
"utf-8",
const skillDir = path.join(
pluginDir,
"skills",
...(params.bundleFormat === "agent" ? ["fixture"] : []),
);
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, "SKILL.md"), "---\ndescription: fixture\n---\n", "utf-8");
return { pluginDir, extensionsDir: path.join(stateDir, "extensions") };
};
}
+39
View File
@@ -98,6 +98,7 @@ function createSinglePluginRegistry(params: {
pluginRoot: string;
skills: string[];
format?: "openclaw" | "bundle";
bundleFormat?: "agent" | "codex" | "claude" | "cursor";
legacyPluginIds?: string[];
}): PluginManifestRegistry {
return {
@@ -107,6 +108,7 @@ function createSinglePluginRegistry(params: {
id: "helper",
name: "Helper",
format: params.format,
bundleFormat: params.bundleFormat,
channels: [],
providers: [],
cliBackends: [],
@@ -363,6 +365,43 @@ describe("resolvePluginSkillDirs", () => {
]);
});
it("limits Agent Plugins skills to valid immediate child directories", async () => {
const workspaceDir = await tempDirs.make("openclaw-");
const pluginRoot = await tempDirs.make("openclaw-agent-bundle-");
const pluginSkillsDir = await tempDirs.make("managed-plugin-skills-");
const skillsRoot = path.join(pluginRoot, "skills");
const validSkill = path.join(skillsRoot, "valid");
const nestedSkill = path.join(skillsRoot, "group", "deep");
await fs.mkdir(validSkill, { recursive: true });
await fs.mkdir(nestedSkill, { recursive: true });
await fs.mkdir(path.join(skillsRoot, "missing"), { recursive: true });
await fs.writeFile(path.join(skillsRoot, "SKILL.md"), "root skill must be ignored\n");
await fs.writeFile(path.join(validSkill, "SKILL.md"), "valid immediate skill\n");
await fs.writeFile(path.join(nestedSkill, "SKILL.md"), "nested skill must be ignored\n");
hoisted.loadPluginManifestRegistryForInstalledIndex.mockReturnValue(
createSinglePluginRegistry({
pluginRoot,
format: "bundle",
bundleFormat: "agent",
skills: ["skills"],
}),
);
const dirs = resolvePluginSkillDirs({
workspaceDir,
pluginSkillsDir,
config: {
plugins: { entries: { helper: { enabled: true } } },
} as OpenClawConfig,
});
expect(dirs).toEqual([validSkill]);
expect(fsSync.readlinkSync(path.join(pluginSkillsDir, "valid"))).toBe(validSkill);
expect(fsSync.existsSync(path.join(pluginSkillsDir, "deep"))).toBe(false);
expect(fsSync.existsSync(path.join(pluginSkillsDir, "skills"))).toBe(false);
});
it("resolves enabled plugin skills through legacy manifest aliases", async () => {
const workspaceDir = await tempDirs.make("openclaw-");
const pluginRoot = await tempDirs.make("openclaw-legacy-plugin-");
+25 -4
View File
@@ -100,11 +100,15 @@ export function resolvePluginSkillDirs(params: {
log.warn(`plugin skill path escapes plugin root (${record.id}): ${candidate}`);
continue;
}
if (seen.has(candidate)) {
continue;
const candidates =
record.bundleFormat === "agent" ? collectAgentSkillTargets(candidate) : [candidate];
for (const resolvedCandidate of candidates) {
if (seen.has(resolvedCandidate)) {
continue;
}
seen.add(resolvedCandidate);
resolved.push(resolvedCandidate);
}
seen.add(candidate);
resolved.push(candidate);
}
}
@@ -115,6 +119,23 @@ export function resolvePluginSkillDirs(params: {
return resolved;
}
function collectAgentSkillTargets(skillsRoot: string): string[] {
const targets: string[] = [];
const entries = walkDirectorySync(skillsRoot, {
maxDepth: 1,
symlinks: "skip",
include: (entry) => entry.kind === "directory",
}).entries;
for (const entry of entries) {
if (hasPublishableSkillFile({ skillDir: entry.path, rootDir: skillsRoot })) {
targets.push(entry.path);
continue;
}
log.warn(`agent plugin skill skipped because SKILL.md is missing or invalid: ${entry.path}`);
}
return targets;
}
function resolveDefaultPluginSkillsDir(): string {
return path.join(CONFIG_DIR, "plugin-skills");
}