Files
oh-my-pi/packages/coding-agent/src/discovery/substitute-plugin-root.ts
T
roboomp 0d0df064e0 fix(discovery): gated cwd-based command rooting to codex importer
The previous follow-up rooted path-like commands at the resolved cwd in
the shared resolvePluginStdioPaths helper, which regressed plugin
.mcp.json semantics: plugin commands are relative to the plugin package
root, so a plugin shipping ./bin/server with cwd="work" would resolve to
<pkg>/work/bin/server and ENOENT. Add a commandBase parameter defaulting
to "config-dir" (the plugin contract) and pass "cwd" only from the Codex
importer, where the OS resolves the relative command against the spawned
process cwd.

Fixes #5561
2026-07-15 10:05:08 +00:00

78 lines
3.3 KiB
TypeScript

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.
*/
// Use concatenation to avoid noTemplateCurlyInString lint rule on literal placeholder names
const CLAUDE_VAR = "$" + "{CLAUDE_PLUGIN_ROOT}";
const OMP_VAR = "$" + "{OMP_PLUGIN_ROOT}";
export function substitutePluginRoot<T>(value: T, rootPath: string): T {
if (typeof value === "string") {
return value.replaceAll(CLAUDE_VAR, rootPath).replaceAll(OMP_VAR, rootPath) as T;
}
if (Array.isArray(value)) {
return value.map(v => substitutePluginRoot(v, rootPath)) as T;
}
if (value && typeof value === "object") {
const result: Record<string, unknown> = Object.create(null);
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
Object.defineProperty(result, k, {
value: substitutePluginRoot(v, rootPath),
enumerable: true,
writable: true,
configurable: true,
});
}
return result as T;
}
return value;
}
/**
* Where a relative, path-like `command` is rooted by {@link resolvePluginStdioPaths}.
*
* - `"config-dir"` (default): the directory of the config file that declared the
* server — the plugin package root for `.mcp.json`. A plugin can ship its
* executable at the package root (`command: "./bin/server"`) yet run from a
* data subdir (`cwd: "work"`); the command stays `<pkg>/bin/server`.
* - `"cwd"`: the rooted `cwd`, falling back to the config dir when no `cwd` is
* set. This matches how the OS resolves a relative command against the
* subprocess's working directory, which is the Codex `config.toml` contract:
* `cwd = "server"`, `command = "./bin/mcp"` → `<configDir>/server/bin/mcp`.
*/
export type StdioCommandBase = "config-dir" | "cwd";
/**
* Rebase relative filesystem values in a discovered stdio server config against
* the directory of the config file (`.mcp.json`/`config.toml`) that declared them.
*
* External 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 server 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 the base selected by `commandBase` (see {@link StdioCommandBase});
* - bare executables (`npx`, `uvx`, …) and absolute paths are left untouched.
*/
export function resolvePluginStdioPaths(
config: { command?: string; cwd?: string },
configDir: string,
commandBase: StdioCommandBase = "config-dir",
): { command?: string; cwd?: string } {
const resolved: { command?: string; cwd?: string } = {};
if (typeof config.cwd === "string") {
resolved.cwd = path.isAbsolute(config.cwd) ? config.cwd : path.resolve(configDir, config.cwd);
}
if (config.command !== undefined) {
const isPathLike = /^\.\.?[/\\]/.test(config.command);
const base = commandBase === "cwd" ? (resolved.cwd ?? configDir) : configDir;
resolved.command = isPathLike ? path.resolve(base, config.command) : config.command;
}
return resolved;
}