mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
06d5e4457f
commit
f4387b7a5e
+3
-2
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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-",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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'",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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(", ")})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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") };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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-");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user