fix(discovery): rooted relative plugin mcp command and cwd at config dir

Discovered plugin .mcp.json stdio servers launched relative command/cwd
values against the session cwd instead of the plugin's config directory,
breaking bundled ChatGPT/Codex plugins (e.g. Computer Use) with ENOENT
when spawning ./... from an unrelated cwd.

The claude-plugins and omp-plugins providers now resolve relative cwd and
path-like command (./ or ../) against the .mcp.json directory via a shared
resolvePluginStdioPaths helper; bare executables such as npx are left
untouched so PATH lookup still works.

Fixes #5330
This commit is contained in:
roboomp
2026-07-14 18:35:54 +00:00
parent 0a98aa252b
commit 6467a3119e
7 changed files with 119 additions and 6 deletions
+1
View File
@@ -26,6 +26,7 @@
### Fixed
- Fixed inconsistent history rendering when toggling the display setting for compacted items
- Fixed discovered plugin `.mcp.json` stdio servers launching relative `command`/`cwd` values against the session cwd instead of the plugin's config directory, which broke bundled ChatGPT/Codex plugins such as Computer Use (`ENOENT` spawning `./…` from an unrelated cwd). Relative `cwd` and path-like `command` (`./`, `../`) discovered by the `claude-plugins`/`omp-plugins` providers now resolve against the `.mcp.json` directory; bare executables like `npx` are left untouched. ([#5330](https://github.com/can1357/oh-my-pi/issues/5330))
- Fixed configured `retry.fallbackChains` never engaging on non-retryable provider errors (e.g. "Cloud Code Assist API returned an empty response"): a hard error on a model covered by a fallback chain now switches to the next candidate instead of failing the turn, while still never backoff-retrying the failing model itself
- Fixed transcript rebuilds (compaction, `/compact`, and toggling history display) repainting content below stale scrollback when collapsing history; rebuilds now correctly clear the scrollback buffer when history is collapsed
- Improved auto-compaction to automatically drop images and elide content when context is tight, and added persistent warning badges to the compaction divider when manual intervention is required
@@ -24,7 +24,7 @@ import {
scanSkillsFromDir,
} from "./helpers";
import { substitutePluginRoot } from "./substitute-plugin-root";
import { resolvePluginStdioPaths, substitutePluginRoot } from "./substitute-plugin-root";
const PROVIDER_ID = "claude-plugins";
const DISPLAY_NAME = "Claude Code Marketplace";
@@ -441,14 +441,20 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
continue;
}
const namespacedName = root.plugin ? `${root.plugin}:${serverName}` : serverName;
const substitutedCommand =
raw.command !== undefined ? substitutePluginRoot(raw.command, root.path) : undefined;
const substitutedCwd = raw.cwd !== undefined ? substitutePluginRoot(raw.cwd, root.path) : undefined;
// Root relative command/cwd at the plugin's config directory, not the
// session cwd (MCP stdio spawning resolves relative values there).
const rooted = resolvePluginStdioPaths({ command: substitutedCommand, cwd: substitutedCwd }, root.path);
const server: MCPServer = {
name: namespacedName,
...(raw.enabled !== undefined && { enabled: raw.enabled }),
...(raw.timeout !== undefined && { timeout: raw.timeout }),
...(raw.command !== undefined && { command: substitutePluginRoot(raw.command, root.path) }),
...(rooted.command !== undefined && { command: rooted.command }),
...(raw.args !== undefined && { args: substitutePluginRoot(raw.args, root.path) }),
...(raw.env !== undefined && { env: substitutePluginRoot(raw.env, root.path) }),
...(raw.cwd !== undefined && { cwd: substitutePluginRoot(raw.cwd, root.path) }),
...(rooted.cwd !== undefined && { cwd: rooted.cwd }),
...(raw.url !== undefined && { url: expandEnvVarsDeep(raw.url) }),
...(raw.headers !== undefined && { headers: expandEnvVarsDeep(raw.headers) }),
...(raw.auth !== undefined && { auth: raw.auth }),
@@ -30,6 +30,7 @@ import { type CustomTool, toolCapability } from "../capability/tool";
import type { LoadContext, LoadResult } from "../capability/types";
import { buildRuleFromMarkdown, createSourceMeta, loadFilesFromDir, scanSkillsFromDir } from "./helpers";
import { listOmpExtensionRoots, type OmpExtensionRoot } from "./omp-extension-roots";
import { resolvePluginStdioPaths } from "./substitute-plugin-root";
const PROVIDER_ID = "omp-plugins";
const DISPLAY_NAME = "OMP Extension Packages";
@@ -301,14 +302,17 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
warnings.push(`[omp-plugins] Skipping MCP server "${serverName}" in ${mcpPath}: missing command or url`);
continue;
}
// Root relative command/cwd at the plugin's config directory, not the
// session cwd (MCP stdio spawning resolves relative values there).
const rooted = resolvePluginStdioPaths({ command: cfg.command, cwd: cfg.cwd }, root.path);
items.push({
name: serverName,
...(cfg.enabled !== undefined && { enabled: cfg.enabled }),
...(cfg.timeout !== undefined && { timeout: cfg.timeout }),
...(cfg.command !== undefined && { command: cfg.command }),
...(rooted.command !== undefined && { command: rooted.command }),
...(cfg.args !== undefined && { args: cfg.args }),
...(cfg.env !== undefined && { env: cfg.env }),
...(cfg.cwd !== undefined && { cwd: cfg.cwd }),
...(rooted.cwd !== undefined && { cwd: rooted.cwd }),
...(cfg.url !== undefined && { url: cfg.url }),
...(cfg.headers !== undefined && { headers: cfg.headers }),
...(cfg.auth !== undefined && { auth: cfg.auth }),
@@ -1,3 +1,5 @@
import * as path from "node:path";
/**
* Recursively substitute ${CLAUDE_PLUGIN_ROOT} and ${OMP_PLUGIN_ROOT}
* with the actual plugin root path in strings, arrays, and plain objects.
@@ -27,3 +29,33 @@ export function substitutePluginRoot<T>(value: T, rootPath: string): T {
}
return value;
}
/**
* Rebase relative filesystem values in a discovered plugin stdio config against
* the directory of the `.mcp.json` that declared them.
*
* External plugin configs (bundled ChatGPT/Codex plugins, Claude marketplace
* plugins) express `command`/`cwd` relative to their own config file, but MCP
* stdio spawning roots relative values at the session cwd — so a plugin shipping
* `command: "./bin/server"`, `cwd: "."` launches from the wrong directory and
* fails with ENOENT. This resolves those against `configDir` instead:
*
* - relative `cwd` → resolved against `configDir`;
* - path-like `command` (`./`, `../`, or the Windows `.\`/`..\` forms) →
* resolved against `configDir`;
* - bare executables (`npx`, `uvx`, …) and absolute paths are left untouched.
*/
export function resolvePluginStdioPaths(
config: { command?: string; cwd?: string },
configDir: string,
): { command?: string; cwd?: string } {
const resolved: { command?: string; cwd?: string } = {};
if (config.cwd !== undefined) {
resolved.cwd = path.isAbsolute(config.cwd) ? config.cwd : path.resolve(configDir, config.cwd);
}
if (config.command !== undefined) {
const isPathLike = /^\.\.?[/\\]/.test(config.command);
resolved.command = isPathLike ? path.resolve(configDir, config.command) : config.command;
}
return resolved;
}
@@ -1,5 +1,5 @@
<system-notice cause="fork">
The conversation above belongs to your parent session.
The conversation above belongs to your parent session.
You are a fork created solely to handle the user's request below.
Your parent agent is still working on the original task — that responsibility is
@@ -453,6 +453,53 @@ describe("listClaudePluginRoots", () => {
}
});
test("resolves relative path-like command and cwd against the plugin config directory", async () => {
const pluginsDir = path.join(tempDir, ".claude", "plugins");
const pluginPath = path.join(tempDir, "plugins", "computer-use");
await fs.mkdir(pluginsDir, { recursive: true });
await fs.mkdir(pluginPath, { recursive: true });
await fs.writeFile(
path.join(pluginsDir, "installed_plugins.json"),
JSON.stringify({
version: 2,
plugins: {
"computer-use@openai-bundled": [
{
scope: "user",
installPath: pluginPath,
version: "1.0.0",
installedAt: "2026-06-01T00:00:00Z",
lastUpdated: "2026-06-01T00:00:00Z",
},
],
},
}),
);
await fs.writeFile(
path.join(pluginPath, ".mcp.json"),
JSON.stringify({
mcpServers: {
"computer-use": { command: "./bin/SkyComputerUseClient", args: ["mcp"], cwd: "." },
bare: { command: "npx", args: ["-y", "@some/mcp"] },
},
}),
);
// Session cwd is deliberately outside the plugin directory.
const result = await loadCapability<MCPServer>(mcpCapability.id, {
cwd: path.join(tempDir, "elsewhere"),
providers: ["claude-plugins"],
});
const local = result.all.find(item => item.name === "computer-use:computer-use");
const bare = result.all.find(item => item.name === "computer-use:bare");
expect(local?.command).toBe(path.join(pluginPath, "bin", "SkyComputerUseClient"));
expect(local?.cwd).toBe(pluginPath);
// Bare executables must keep resolving through PATH, not the plugin dir.
expect(bare?.command).toBe("npx");
expect(bare?.cwd).toBeUndefined();
});
test("reads slash commands directory from plugin manifest slash-commands field", async () => {
const pluginsDir = path.join(tempDir, ".claude", "plugins");
const pluginPath = path.join(tempDir, "plugins", "manifest-commands");
@@ -188,6 +188,29 @@ test(".mcp.json with bare entries (no command/url) records a warning and is skip
expect((result.warnings ?? []).some(w => w.includes('"broken"'))).toBe(true);
});
test("relative path-like command and cwd resolve against the plugin config directory", async () => {
writeFile(
path.join(ext, ".mcp.json"),
JSON.stringify({
mcpServers: {
local: { command: "./bin/server", args: ["mcp"], cwd: "." },
bare: { command: "npx", args: ["-y", "@some/mcp"] },
},
}),
);
writeFile(path.join(project, ".omp", "settings.json"), JSON.stringify({ extensions: [ext] }));
const servers = await loadFromPlugin<{ name: string; command?: string; cwd?: string }>(mcpCapability.id, ctx());
const local = servers.find(s => s.name === "local");
const bare = servers.find(s => s.name === "bare");
// Path-like command and "." cwd rebase onto the .mcp.json directory (ext),
// not the session cwd (project). Bare executables are left untouched.
expect(local?.command).toBe(path.join(ext, "bin", "server"));
expect(local?.cwd).toBe(ext);
expect(bare?.command).toBe("npx");
expect(bare?.cwd).toBeUndefined();
});
test("installed plugins under `<plugins>/node_modules/` are surfaced (e.g. via `omp plugin link`/`install`)", async () => {
// Simulate what `plugin install` / `plugin link` produces: a plugins root
// with `package.json#dependencies` and a populated `node_modules/<pkg>/`.