From f4387b7a5effd63fe2c0f05495175b9eacd12cec Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 7 Aug 2026 02:55:08 -0700 Subject: [PATCH] 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 --- docs/cli/plugins.md | 5 +- docs/plugins/bundles.md | 69 ++- docs/plugins/manifest.md | 3 +- docs/tools/plugin.md | 8 +- .../agent-bundle-mcp-runtime-config.test.ts | 33 ++ src/agents/agent-bundle-mcp-runtime-config.ts | 8 + ...nt-bundle-mcp-runtime.agent-bundle.test.ts | 255 +++++++++++ src/agents/agent-bundle-mcp-runtime.test.ts | 36 +- src/agents/agent-bundle-mcp-runtime.ts | 4 + src/agents/bundle-mcp-config.test.ts | 17 + src/agents/bundle-mcp-config.ts | 43 +- src/agents/cli-runner/bundle-mcp.test.ts | 81 +++- src/agents/cli-runner/bundle-mcp.ts | 23 +- src/agents/codex-mcp-config.test.ts | 43 +- src/agents/codex-mcp-config.ts | 13 +- src/agents/embedded-agent-mcp.ts | 8 +- src/agents/mcp-stdio-transport.test.ts | 24 ++ src/agents/mcp-stdio-transport.ts | 15 + src/agents/mcp-transport-config.test.ts | 13 + src/agents/mcp-transport-config.ts | 78 ++-- src/agents/mcp-transport.ts | 3 +- src/cli/plugins-cli.list.test.ts | 9 +- src/cli/plugins-inspect-command.ts | 7 +- src/cli/plugins-list-format.test.ts | 13 + src/cli/plugins-list-format.ts | 7 +- .../plugin-payload-validation.test.ts | 33 +- src/plugins/bundle-claude-inspect.test.ts | 7 +- src/plugins/bundle-manifest.test.ts | 179 +++++++- src/plugins/bundle-manifest.ts | 82 +++- src/plugins/bundle-mcp.test.ts | 332 +++++++++++++- src/plugins/bundle-mcp.ts | 406 +++++++++++++++--- src/plugins/discovery.test.ts | 15 + src/plugins/install-package.ts | 1 + src/plugins/install.path.test.ts | 60 ++- src/plugins/install.test.ts | 10 + src/plugins/loader-runtime-candidate.ts | 2 +- src/plugins/loader.bundle.test.ts | 15 +- src/plugins/manifest-types.ts | 2 +- src/plugins/status.test.ts | 18 +- src/plugins/status.ts | 5 +- src/plugins/test-helpers/install-fixtures.ts | 38 +- src/skills/loading/plugin-skills.test.ts | 39 ++ src/skills/loading/plugin-skills.ts | 29 +- 43 files changed, 1886 insertions(+), 205 deletions(-) create mode 100644 src/agents/agent-bundle-mcp-runtime.agent-bundle.test.ts diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index ed0d8ad5df79..b8178f9e8bda 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -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. ## Author @@ -308,6 +308,7 @@ openclaw plugins install --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. -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. Use `-l`/`--link` to point at a local plugin directory without copying it (adds diff --git a/docs/plugins/bundles.md b/docs/plugins/bundles.md index 145b733e88ca..67a6781cb64c 100644 --- a/docs/plugins/bundles.md +++ b/docs/plugins/bundles.md @@ -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. 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 ``` - 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`. @@ -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 + + 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 + + + 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 diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index f41c6c58e0b5..9c513bfdf4de 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -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` diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index d0e5da1c219d..fc9693a9414d 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -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 diff --git a/src/agents/agent-bundle-mcp-runtime-config.test.ts b/src/agents/agent-bundle-mcp-runtime-config.test.ts index d88fe5c9c788..e5e86b9dde66 100644 --- a/src/agents/agent-bundle-mcp-runtime-config.test.ts +++ b/src/agents/agent-bundle-mcp-runtime-config.test.ts @@ -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, })); 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: { diff --git a/src/agents/agent-bundle-mcp-runtime-config.ts b/src/agents/agent-bundle-mcp-runtime-config.ts index 3340a5469a78..61c5fb89e5aa 100644 --- a/src/agents/agent-bundle-mcp-runtime-config.ts +++ b/src/agents/agent-bundle-mcp-runtime-config.ts @@ -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, diff --git a/src/agents/agent-bundle-mcp-runtime.agent-bundle.test.ts b/src/agents/agent-bundle-mcp-runtime.agent-bundle.test.ts new file mode 100644 index 000000000000..3f285b4a6ead --- /dev/null +++ b/src/agents/agent-bundle-mcp-runtime.agent-bundle.test.ts @@ -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 { + 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(); + } + }, + ); +}); diff --git a/src/agents/agent-bundle-mcp-runtime.test.ts b/src/agents/agent-bundle-mcp-runtime.test.ts index 59cbcf3f733e..747ef0df3df1 100644 --- a/src/agents/agent-bundle-mcp-runtime.test.ts +++ b/src/agents/agent-bundle-mcp-runtime.test.ts @@ -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 } }; - toolOverrides?: { mcpServers?: Record }; - }) => ({ - 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(); + return { + loadEmbeddedAgentMcpConfig: ( + params: Parameters[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); diff --git a/src/agents/agent-bundle-mcp-runtime.ts b/src/agents/agent-bundle-mcp-runtime.ts index 34dbb8be965e..03c122b24382 100644 --- a/src/agents/agent-bundle-mcp-runtime.ts +++ b/src/agents/agent-bundle-mcp-runtime.ts @@ -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; diff --git a/src/agents/bundle-mcp-config.test.ts b/src/agents/bundle-mcp-config.test.ts index 0de8a9f88522..9c56790f25df 100644 --- a/src/agents/bundle-mcp-config.test.ts +++ b/src/agents/bundle-mcp-config.test.ts @@ -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([ diff --git a/src/agents/bundle-mcp-config.ts b/src/agents/bundle-mcp-config.ts index 27cef8af8379..16babeb9cb39 100644 --- a/src/agents/bundle-mcp-config.ts +++ b/src/agents/bundle-mcp-config.ts @@ -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; }; type BundleMcpServerMapper = (server: BundleMcpServerConfig, name: string) => BundleMcpServerConfig; @@ -27,6 +31,31 @@ const OPENCLAW_TRANSPORT_TO_CLI_BUNDLE_TYPE: Record = { stdio: "stdio", }; +export function prepareOwnedBundleMcpDataDirs(params: { + config: BundleMcpConfig; + prepareDataDirsByServer: Record; +}): MergedBundleMcpConfig { + const mcpServers = { ...params.config.mcpServers }; + const prepareDataDirsByServer: Record = {}; + 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, }; } diff --git a/src/agents/cli-runner/bundle-mcp.test.ts b/src/agents/cli-runner/bundle-mcp.test.ts index 8de19aa17137..5b8b3ae09f20 100644 --- a/src/agents/cli-runner/bundle-mcp.test.ts +++ b/src/agents/cli-runner/bundle-mcp.test.ts @@ -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; + }; + 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-", diff --git a/src/agents/cli-runner/bundle-mcp.ts b/src/agents/cli-runner/bundle-mcp.ts index e51bba5613ff..2670e36fbbe5 100644 --- a/src/agents/cli-runner/bundle-mcp.ts +++ b/src/agents/cli-runner/bundle-mcp.ts @@ -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, diff --git a/src/agents/codex-mcp-config.test.ts b/src/agents/codex-mcp-config.test.ts index 83b409bef236..fd4e86d90d46 100644 --- a/src/agents/codex-mcp-config.test.ts +++ b/src/agents/codex-mcp-config.test.ts @@ -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: { diff --git a/src/agents/codex-mcp-config.ts b/src/agents/codex-mcp-config.ts index a35df467f2cd..bd8656ff362d 100644 --- a/src/agents/codex-mcp-config.ts +++ b/src/agents/codex-mcp-config.ts @@ -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), }; diff --git a/src/agents/embedded-agent-mcp.ts b/src/agents/embedded-agent-mcp.ts index 5466a5f8cdda..e347883ae715 100644 --- a/src/agents/embedded-agent-mcp.ts +++ b/src/agents/embedded-agent-mcp.ts @@ -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; diagnostics: BundleMcpDiagnostic[]; + prepareDataDirsByServer: Record; }; /** 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, }; } diff --git a/src/agents/mcp-stdio-transport.test.ts b/src/agents/mcp-stdio-transport.test.ts index 09b48f8c94a8..7d72dc14931e 100644 --- a/src/agents/mcp-stdio-transport.test.ts +++ b/src/agents/mcp-stdio-transport.test.ts @@ -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(); diff --git a/src/agents/mcp-stdio-transport.ts b/src/agents/mcp-stdio-transport.ts index dadb5b0e8616..e30811bcc500 100644 --- a/src/agents/mcp-stdio-transport.ts +++ b/src/agents/mcp-stdio-transport.ts @@ -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; 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((resolve, reject) => { const baseEnv = { ...getDefaultEnvironment(), diff --git a/src/agents/mcp-transport-config.test.ts b/src/agents/mcp-transport-config.test.ts index 82b9242caa35..d4e8d5a7b167 100644 --- a/src/agents/mcp-transport-config.test.ts +++ b/src/agents/mcp-transport-config.test.ts @@ -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(); + }); }); diff --git a/src/agents/mcp-transport-config.ts b/src/agents/mcp-transport-config.ts index 3e3200dcceed..37e2ee4436fb 100644 --- a/src/agents/mcp-transport-config.ts +++ b/src/agents/mcp-transport-config.ts @@ -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; } diff --git a/src/agents/mcp-transport.ts b/src/agents/mcp-transport.ts index fce1aea83bc1..5e4613071291 100644 --- a/src/agents/mcp-transport.ts +++ b/src/agents/mcp-transport.ts @@ -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 { diff --git a/src/cli/plugins-cli.list.test.ts b/src/cli/plugins-cli.list.test.ts index 7586b02c8243..9f026c730029 100644 --- a/src/cli/plugins-cli.list.test.ts +++ b/src/cli/plugins-cli.list.test.ts @@ -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 () => { diff --git a/src/cli/plugins-inspect-command.ts b/src/cli/plugins-inspect-command.ts index f258d4e6e2f3..fa67027c0c49 100644 --- a/src/cli/plugins-inspect-command.ts +++ b/src/cli/plugins-inspect-command.ts @@ -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, ), ), ); diff --git a/src/cli/plugins-list-format.test.ts b/src/cli/plugins-list-format.test.ts index 42a26298fbbd..3f8dfb4b5f69 100644 --- a/src/cli/plugins-list-format.test.ts +++ b/src/cli/plugins-list-format.test.ts @@ -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({ diff --git a/src/cli/plugins-list-format.ts b/src/cli/plugins-list-format.ts index d80554afaee0..ff731b0449bd 100644 --- a/src/cli/plugins-list-format.ts +++ b/src/cli/plugins-list-format.ts @@ -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}`); diff --git a/src/cli/update-cli/plugin-payload-validation.test.ts b/src/cli/update-cli/plugin-payload-validation.test.ts index 46d01907c12e..db550669515c 100644 --- a/src/cli/update-cli/plugin-payload-validation.test.ts +++ b/src/cli/update-cli/plugin-payload-validation.test.ts @@ -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"], diff --git a/src/plugins/bundle-claude-inspect.test.ts b/src/plugins/bundle-claude-inspect.test.ts index e6a43bd14c40..f7714c98d0ea 100644 --- a/src/plugins/bundle-claude-inspect.test.ts +++ b/src/plugins/bundle-claude-inspect.test.ts @@ -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, }, { diff --git a/src/plugins/bundle-manifest.test.ts b/src/plugins/bundle-manifest.test.ts index 40287d0405f5..c8481d8e3864 100644 --- a/src/plugins/bundle-manifest.test.ts +++ b/src/plugins/bundle-manifest.test.ts @@ -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, { 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'", }, }); diff --git a/src/plugins/bundle-manifest.ts b/src/plugins/bundle-manifest.ts index ee9fe2d20198..9477286445c9 100644 --- a/src/plugins/bundle-manifest.ts +++ b/src/plugins/bundle-manifest.ts @@ -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>({ @@ -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, 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)), diff --git a/src/plugins/bundle-mcp.test.ts b/src/plugins/bundle-mcp.test.ts index 8580f1fac582..59ab035f9239 100644 --- a/src/plugins/bundle-mcp.test.ts +++ b/src/plugins/bundle-mcp.test.ts @@ -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 { + 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; + mcp?: unknown; + textFiles?: Record; +}) { + 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"'); + }, + ); + }); }); diff --git a/src/plugins/bundle-mcp.ts b/src/plugins/bundle-mcp.ts index 2366bd9a70b9..def854b023b6 100644 --- a/src/plugins/bundle-mcp.ts +++ b/src/plugins/bundle-mcp.ts @@ -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; }; +export type BundleMcpDataDirOwnership = { + pluginId: string; + dataDir: string; +}; + +type BundleMcpRuntimeConfig = BundleMcpConfig & { + prepareDataDirsByServer: Record; +}; + export type BundleMcpDiagnostic = { pluginId: string; message: string; @@ -35,26 +47,39 @@ export type BundleMcpDiagnostic = { type EnabledBundleMcpConfigResult = { config: BundleMcpConfig; diagnostics: BundleMcpDiagnostic[]; + prepareDataDirsByServer: Record; }; type BundleMcpRuntimeSupport = { hasSupportedStdioServer: boolean; supportedServerNames: string[]; + stdioServerNames: string[]; unsupportedServerNames: string[]; diagnostics: string[]; }; const MANIFEST_PATH_BY_FORMAT: Record = { + 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; 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, allowed: ReadonlySet): boolean { + return Object.keys(raw).every((key) => allowed.has(key)); +} + +function isStringRecord(raw: unknown): raw is Record { + 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; + pluginId: string; + rootDir: string; +}): { + servers: Record; + 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 = {}; + 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; 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; -}): { 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; }): 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; }): 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, + ), + ), + }; } diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index 0167b111cdee..c7dca442ce04 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -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", diff --git a/src/plugins/install-package.ts b/src/plugins/install-package.ts index 031464e76253..78e3b6b8aab6 100644 --- a/src/plugins/install-package.ts +++ b/src/plugins/install-package.ts @@ -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( diff --git a/src/plugins/install.path.test.ts b/src/plugins/install.path.test.ts index 84e1bc51b68e..f1609dc887de 100644 --- a/src/plugins/install.path.test.ts +++ b/src/plugins/install.path.test.ts @@ -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; diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 21238bded534..46cae51a98cf 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -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: () => diff --git a/src/plugins/loader-runtime-candidate.ts b/src/plugins/loader-runtime-candidate.ts index 4d73e5ac2346..e6efb83ec1ad 100644 --- a/src/plugins/loader-runtime-candidate.ts +++ b/src/plugins/loader-runtime-candidate.ts @@ -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(", ")})`, }); } } diff --git a/src/plugins/loader.bundle.test.ts b/src/plugins/loader.bundle.test.ts index 338d8635921d..703328601d9b 100644 --- a/src/plugins/loader.bundle.test.ts +++ b/src/plugins/loader.bundle.test.ts @@ -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); }); }); diff --git a/src/plugins/manifest-types.ts b/src/plugins/manifest-types.ts index 43b8a144eb7b..f4c50eca00fe 100644 --- a/src/plugins/manifest-types.ts +++ b/src/plugins/manifest-types.ts @@ -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 diff --git a/src/plugins/status.test.ts b/src/plugins/status.test.ts index 8a4c08f9b98c..55047e13fa81 100644 --- a/src/plugins/status.test.ts +++ b/src/plugins/status.test.ts @@ -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", diff --git a/src/plugins/status.ts b/src/plugins/status.ts index 954f9203c556..c1e92566bcce 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -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, })), ]; } diff --git a/src/plugins/test-helpers/install-fixtures.ts b/src/plugins/test-helpers/install-fixtures.ts index 5f4d2e1dc0ba..5d46b6459bb9 100644 --- a/src/plugins/test-helpers/install-fixtures.ts +++ b/src/plugins/test-helpers/install-fixtures.ts @@ -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") }; }; } diff --git a/src/skills/loading/plugin-skills.test.ts b/src/skills/loading/plugin-skills.test.ts index f71aa9065137..303232fa2fca 100644 --- a/src/skills/loading/plugin-skills.test.ts +++ b/src/skills/loading/plugin-skills.test.ts @@ -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-"); diff --git a/src/skills/loading/plugin-skills.ts b/src/skills/loading/plugin-skills.ts index a42b5425f333..5482df83d2cf 100644 --- a/src/skills/loading/plugin-skills.ts +++ b/src/skills/loading/plugin-skills.ts @@ -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"); }