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:
@@ -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>/`.
|
||||
|
||||
Reference in New Issue
Block a user