codex-plugin-doctor 1.57.0 → 1.58.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +3 -2
- package/dist/compatibility/claude-desktop-install-preview.js +4 -4
- package/dist/compatibility/cline-install-preview.js +4 -4
- package/dist/compatibility/compatibility-matrix.js +13 -17
- package/dist/compatibility/cursor-install-preview.js +4 -4
- package/dist/compatibility/windsurf-install-preview.js +4 -4
- package/dist/core/doctor-size.js +16 -1
- package/dist/core/init-plugin.js +2 -2
- package/dist/core/inspector-bridge.js +8 -13
- package/dist/core/mcp-config-normalizer.d.ts +18 -0
- package/dist/core/mcp-config-normalizer.js +50 -0
- package/dist/core/package-path.d.ts +1 -0
- package/dist/core/package-path.js +25 -0
- package/dist/core/plugin-components.d.ts +7 -0
- package/dist/core/plugin-components.js +169 -0
- package/dist/core/plugin-hooks.d.ts +2 -0
- package/dist/core/plugin-hooks.js +190 -0
- package/dist/core/runtime-plan.js +97 -16
- package/dist/core/runtime-probe.js +3 -8
- package/dist/core/validate-plugin.js +39 -17
- package/dist/domain/types.d.ts +34 -0
- package/dist/domain/types.js +13 -1
- package/dist/mcp/generic-mcp-doctor.js +45 -29
- package/dist/rules/rule-catalog.js +130 -4
- package/dist/security/security-audit.d.ts +1 -0
- package/dist/security/security-audit.js +104 -7
- package/examples/README.md +94 -94
- package/examples/codex-doctor-generic-mcp/.mcp.json +10 -10
- package/examples/codex-doctor-generic-mcp/server.js +1 -1
- package/examples/codex-doctor-risky/.codex-plugin/plugin.json +7 -7
- package/examples/codex-doctor-risky/.mcp.json +12 -12
- package/examples/codex-doctor-risky/mock-server.js +1 -1
- package/examples/codex-doctor-runtime/.codex-plugin/plugin.json +8 -8
- package/examples/codex-doctor-runtime/.mcp.json +9 -9
- package/examples/codex-doctor-runtime/mock-server.js +237 -237
- package/examples/codex-doctor-runtime/skills/context-check/SKILL.md +7 -7
- package/examples/codex-doctor-starter/.codex-plugin/plugin.json +7 -7
- package/examples/codex-doctor-starter/skills/repo-scan/SKILL.md +7 -7
- package/package.json +1 -1
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Furkan Tasci
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Furkan Tasci
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -480,9 +480,9 @@ jobs:
|
|
|
480
480
|
runs-on: ubuntu-latest
|
|
481
481
|
steps:
|
|
482
482
|
- uses: actions/checkout@v5
|
|
483
|
-
- uses: Esquetta/CodexPluginDoctor@v1.
|
|
483
|
+
- uses: Esquetta/CodexPluginDoctor@v1.58.0
|
|
484
484
|
with:
|
|
485
|
-
version: "1.
|
|
485
|
+
version: "1.58.0"
|
|
486
486
|
path: .
|
|
487
487
|
runtime: "true"
|
|
488
488
|
policy: codex-publish
|
|
@@ -518,6 +518,7 @@ The validator is tuned against local fixtures and real marketplace-style plugin
|
|
|
518
518
|
- [Real-World Validation Workflow](./docs/guides/real-world-validation.md)
|
|
519
519
|
- [Examples](./examples/README.md)
|
|
520
520
|
- [Rule Catalog](./docs/rules/catalog.md)
|
|
521
|
+
- [Official Plugin Components](./docs/architecture/official-plugin-components.md)
|
|
521
522
|
|
|
522
523
|
Recent validation waves covered:
|
|
523
524
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { normalizeMcpConfig } from "../core/mcp-config-normalizer.js";
|
|
3
4
|
import { getClaudeDesktopConfigPath, readMcpConfigPath } from "./compatibility-matrix.js";
|
|
4
5
|
function isRecord(value) {
|
|
5
6
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -38,16 +39,15 @@ export async function buildClaudeDesktopInstallPreview(targetPath, environment =
|
|
|
38
39
|
if (!mcpConfigPath) {
|
|
39
40
|
throw new Error("No MCP config found for install preview.");
|
|
40
41
|
}
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
if (!isRecord(servers) || Object.keys(servers).length === 0) {
|
|
42
|
+
const normalizedConfig = normalizeMcpConfig(JSON.parse(await readFile(mcpConfigPath, "utf8")));
|
|
43
|
+
if (!normalizedConfig.ok) {
|
|
44
44
|
throw new Error("MCP config does not contain a non-empty `mcpServers` object.");
|
|
45
45
|
}
|
|
46
46
|
return {
|
|
47
47
|
targetPath: rootPath,
|
|
48
48
|
configPath,
|
|
49
49
|
snippet: {
|
|
50
|
-
mcpServers: Object.fromEntries(Object.entries(servers).map(([serverName, serverConfig]) => [
|
|
50
|
+
mcpServers: Object.fromEntries(Object.entries(normalizedConfig.servers).map(([serverName, serverConfig]) => [
|
|
51
51
|
serverName,
|
|
52
52
|
normalizeServerConfig(serverConfig, rootPath)
|
|
53
53
|
]))
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { normalizeMcpConfig } from "../core/mcp-config-normalizer.js";
|
|
3
4
|
import { getClineMcpConfigPath, readMcpConfigPath } from "./compatibility-matrix.js";
|
|
4
5
|
function isRecord(value) {
|
|
5
6
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -34,16 +35,15 @@ export async function buildClineInstallPreview(targetPath, environment = {}) {
|
|
|
34
35
|
if (!mcpConfigPath) {
|
|
35
36
|
throw new Error("No MCP config found for install preview.");
|
|
36
37
|
}
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
if (!isRecord(servers) || Object.keys(servers).length === 0) {
|
|
38
|
+
const normalizedConfig = normalizeMcpConfig(JSON.parse(await readFile(mcpConfigPath, "utf8")));
|
|
39
|
+
if (!normalizedConfig.ok) {
|
|
40
40
|
throw new Error("MCP config does not contain a non-empty `mcpServers` object.");
|
|
41
41
|
}
|
|
42
42
|
return {
|
|
43
43
|
targetPath: rootPath,
|
|
44
44
|
configPath: getClineMcpConfigPath(environment),
|
|
45
45
|
snippet: {
|
|
46
|
-
mcpServers: Object.fromEntries(Object.entries(servers).map(([serverName, serverConfig]) => [
|
|
46
|
+
mcpServers: Object.fromEntries(Object.entries(normalizedConfig.servers).map(([serverName, serverConfig]) => [
|
|
47
47
|
serverName,
|
|
48
48
|
normalizeServerConfig(serverConfig, rootPath)
|
|
49
49
|
]))
|
|
@@ -2,7 +2,9 @@ import { stat } from "node:fs/promises";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { validatePlugin } from "../core/validate-plugin.js";
|
|
5
|
+
import { normalizeMcpConfig } from "../core/mcp-config-normalizer.js";
|
|
5
6
|
import { readJsonFile } from "../core/read-json-file.js";
|
|
7
|
+
import { resolveContainedPackagePath } from "../core/package-path.js";
|
|
6
8
|
async function fileExists(targetPath) {
|
|
7
9
|
try {
|
|
8
10
|
const details = await stat(targetPath);
|
|
@@ -30,16 +32,15 @@ function statusFromCheckResult(result) {
|
|
|
30
32
|
}
|
|
31
33
|
return "pass";
|
|
32
34
|
}
|
|
33
|
-
function isPathWithinRoot(rootPath, candidatePath) {
|
|
34
|
-
const relativePath = path.relative(rootPath, candidatePath);
|
|
35
|
-
return (relativePath === "" ||
|
|
36
|
-
(!relativePath.startsWith("..") && !path.isAbsolute(relativePath)));
|
|
37
|
-
}
|
|
38
35
|
export async function readMcpConfigPath(targetPath) {
|
|
39
36
|
const rootPath = path.resolve(targetPath);
|
|
40
37
|
const directMcpPath = path.join(rootPath, ".mcp.json");
|
|
41
38
|
if (await fileExists(directMcpPath)) {
|
|
42
|
-
|
|
39
|
+
const containedDirectMcpPath = await resolveContainedPackagePath(rootPath, directMcpPath);
|
|
40
|
+
if (!containedDirectMcpPath) {
|
|
41
|
+
throw new Error("MCP config path resolves outside the package root.");
|
|
42
|
+
}
|
|
43
|
+
return containedDirectMcpPath;
|
|
43
44
|
}
|
|
44
45
|
const manifestPath = path.join(rootPath, ".codex-plugin", "plugin.json");
|
|
45
46
|
if (!(await fileExists(manifestPath))) {
|
|
@@ -55,8 +56,8 @@ export async function readMcpConfigPath(targetPath) {
|
|
|
55
56
|
if (typeof manifest.mcpServers !== "string") {
|
|
56
57
|
return null;
|
|
57
58
|
}
|
|
58
|
-
const mcpConfigPath =
|
|
59
|
-
if (!
|
|
59
|
+
const mcpConfigPath = await resolveContainedPackagePath(rootPath, manifest.mcpServers);
|
|
60
|
+
if (!mcpConfigPath) {
|
|
60
61
|
throw new Error("Manifest MCP config path resolves outside the package root.");
|
|
61
62
|
}
|
|
62
63
|
return mcpConfigPath;
|
|
@@ -87,11 +88,8 @@ async function checkGenericMcp(targetPath) {
|
|
|
87
88
|
}
|
|
88
89
|
try {
|
|
89
90
|
const parsed = await readJsonFile(mcpConfigPath);
|
|
90
|
-
const
|
|
91
|
-
if (
|
|
92
|
-
servers === null ||
|
|
93
|
-
Array.isArray(servers) ||
|
|
94
|
-
Object.keys(servers).length === 0) {
|
|
91
|
+
const normalizedConfig = normalizeMcpConfig(parsed);
|
|
92
|
+
if (!normalizedConfig.ok) {
|
|
95
93
|
return {
|
|
96
94
|
client: "Generic MCP",
|
|
97
95
|
status: "fail",
|
|
@@ -122,10 +120,8 @@ async function readMcpServerNames(targetPath) {
|
|
|
122
120
|
}
|
|
123
121
|
try {
|
|
124
122
|
const parsed = await readJsonFile(mcpConfigPath);
|
|
125
|
-
const
|
|
126
|
-
return
|
|
127
|
-
? Object.keys(servers)
|
|
128
|
-
: [];
|
|
123
|
+
const normalizedConfig = normalizeMcpConfig(parsed);
|
|
124
|
+
return normalizedConfig.ok ? Object.keys(normalizedConfig.servers) : [];
|
|
129
125
|
}
|
|
130
126
|
catch {
|
|
131
127
|
return [];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { normalizeMcpConfig } from "../core/mcp-config-normalizer.js";
|
|
3
4
|
import { getCursorMcpConfigPath, readMcpConfigPath } from "./compatibility-matrix.js";
|
|
4
5
|
function isRecord(value) {
|
|
5
6
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -34,16 +35,15 @@ export async function buildCursorInstallPreview(targetPath, environment = {}) {
|
|
|
34
35
|
if (!mcpConfigPath) {
|
|
35
36
|
throw new Error("No MCP config found for install preview.");
|
|
36
37
|
}
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
if (!isRecord(servers) || Object.keys(servers).length === 0) {
|
|
38
|
+
const normalizedConfig = normalizeMcpConfig(JSON.parse(await readFile(mcpConfigPath, "utf8")));
|
|
39
|
+
if (!normalizedConfig.ok) {
|
|
40
40
|
throw new Error("MCP config does not contain a non-empty `mcpServers` object.");
|
|
41
41
|
}
|
|
42
42
|
return {
|
|
43
43
|
targetPath: rootPath,
|
|
44
44
|
configPath: await getCursorMcpConfigPath(rootPath, environment),
|
|
45
45
|
snippet: {
|
|
46
|
-
mcpServers: Object.fromEntries(Object.entries(servers).map(([serverName, serverConfig]) => [
|
|
46
|
+
mcpServers: Object.fromEntries(Object.entries(normalizedConfig.servers).map(([serverName, serverConfig]) => [
|
|
47
47
|
serverName,
|
|
48
48
|
normalizeServerConfig(serverConfig, rootPath)
|
|
49
49
|
]))
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { normalizeMcpConfig } from "../core/mcp-config-normalizer.js";
|
|
3
4
|
import { getWindsurfMcpConfigPath, readMcpConfigPath } from "./compatibility-matrix.js";
|
|
4
5
|
function isRecord(value) {
|
|
5
6
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -34,16 +35,15 @@ export async function buildWindsurfInstallPreview(targetPath, environment = {})
|
|
|
34
35
|
if (!mcpConfigPath) {
|
|
35
36
|
throw new Error("No MCP config found for install preview.");
|
|
36
37
|
}
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
if (!isRecord(servers) || Object.keys(servers).length === 0) {
|
|
38
|
+
const normalizedConfig = normalizeMcpConfig(JSON.parse(await readFile(mcpConfigPath, "utf8")));
|
|
39
|
+
if (!normalizedConfig.ok) {
|
|
40
40
|
throw new Error("MCP config does not contain a non-empty `mcpServers` object.");
|
|
41
41
|
}
|
|
42
42
|
return {
|
|
43
43
|
targetPath: rootPath,
|
|
44
44
|
configPath: getWindsurfMcpConfigPath(environment),
|
|
45
45
|
snippet: {
|
|
46
|
-
mcpServers: Object.fromEntries(Object.entries(servers).map(([serverName, serverConfig]) => [
|
|
46
|
+
mcpServers: Object.fromEntries(Object.entries(normalizedConfig.servers).map(([serverName, serverConfig]) => [
|
|
47
47
|
serverName,
|
|
48
48
|
normalizeServerConfig(serverConfig, rootPath)
|
|
49
49
|
]))
|
package/dist/core/doctor-size.js
CHANGED
|
@@ -15,6 +15,20 @@ const skippedDirs = new Set([
|
|
|
15
15
|
const largeFileThreshold = 1024 * 1024;
|
|
16
16
|
const packageSizeWarnThreshold = 10 * 1024 * 1024;
|
|
17
17
|
const packageSizeFailThreshold = 50 * 1024 * 1024;
|
|
18
|
+
function npmPackCommand() {
|
|
19
|
+
if (process.platform === "win32") {
|
|
20
|
+
const npmExecPath = process.env.npm_execpath;
|
|
21
|
+
// Only use the inherited npm CLI path when it is an absolute npm entrypoint.
|
|
22
|
+
const npmCliPath = npmExecPath && path.isAbsolute(npmExecPath) && path.basename(npmExecPath).toLowerCase() === "npm-cli.js"
|
|
23
|
+
? npmExecPath
|
|
24
|
+
: path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js");
|
|
25
|
+
return {
|
|
26
|
+
command: process.execPath,
|
|
27
|
+
args: [npmCliPath, "pack", "--dry-run"]
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return { command: "npm", args: ["pack", "--dry-run"] };
|
|
31
|
+
}
|
|
18
32
|
function formatSize(bytes) {
|
|
19
33
|
if (bytes < 1024)
|
|
20
34
|
return `${bytes} B`;
|
|
@@ -79,7 +93,8 @@ export async function buildDoctorSize(targetPath, options = {}) {
|
|
|
79
93
|
if (options.npmPack) {
|
|
80
94
|
try {
|
|
81
95
|
const packOutput = await new Promise((resolve, reject) => {
|
|
82
|
-
|
|
96
|
+
const npmPack = npmPackCommand();
|
|
97
|
+
execFile(npmPack.command, npmPack.args, { cwd: resolvedPath, timeout: 30000 }, (error, stdout, stderr) => {
|
|
83
98
|
if (error) {
|
|
84
99
|
reject(new Error(stderr.trim() || error.message));
|
|
85
100
|
return;
|
package/dist/core/init-plugin.js
CHANGED
|
@@ -162,8 +162,8 @@ export async function initPluginPackage(targetPath, options = {}) {
|
|
|
162
162
|
name: packageName,
|
|
163
163
|
version: "0.1.0",
|
|
164
164
|
description: "A Codex plugin package scaffolded by Codex Plugin Doctor.",
|
|
165
|
-
skills: "skills",
|
|
166
|
-
...(template === "skill-only" ? {} : { mcpServers: "
|
|
165
|
+
skills: "./skills",
|
|
166
|
+
...(template === "skill-only" ? {} : { mcpServers: "./.mcp.json" })
|
|
167
167
|
}, null, 2)}\n`, "utf8");
|
|
168
168
|
await writeFile(skillPath, buildSkillMarkdown(template), "utf8");
|
|
169
169
|
if (template === "mcp-stdio" || template === "full-runtime") {
|
|
@@ -1,14 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { readJsonFile } from "./read-json-file.js";
|
|
3
3
|
import { discoverPackage } from "./discover-package.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
7
|
-
function isPathWithinRoot(rootPath, candidatePath) {
|
|
8
|
-
const relativePath = path.relative(rootPath, candidatePath);
|
|
9
|
-
return (relativePath === "" ||
|
|
10
|
-
(!relativePath.startsWith("..") && !path.isAbsolute(relativePath)));
|
|
11
|
-
}
|
|
4
|
+
import { normalizeMcpConfig } from "./mcp-config-normalizer.js";
|
|
5
|
+
import { resolveContainedPackagePath } from "./package-path.js";
|
|
12
6
|
function buildFailure(targetPath, message, mcpConfigPath = null) {
|
|
13
7
|
return {
|
|
14
8
|
schemaVersion: "1.0.0",
|
|
@@ -28,9 +22,9 @@ export async function buildDoctorInspectorReport(targetPath, options = {}) {
|
|
|
28
22
|
if (!discoveredPackage?.manifest.mcpServers) {
|
|
29
23
|
return buildFailure(targetPath, "The target package does not declare an MCP server config in `.codex-plugin/plugin.json`.");
|
|
30
24
|
}
|
|
31
|
-
const mcpConfigPath =
|
|
32
|
-
if (!
|
|
33
|
-
return buildFailure(discoveredPackage.rootPath, "The target package points the MCP server config outside the package root.",
|
|
25
|
+
const mcpConfigPath = await resolveContainedPackagePath(discoveredPackage.rootPath, discoveredPackage.manifest.mcpServers);
|
|
26
|
+
if (!mcpConfigPath) {
|
|
27
|
+
return buildFailure(discoveredPackage.rootPath, "The target package points the MCP server config outside the package root.", null);
|
|
34
28
|
}
|
|
35
29
|
let parsedConfig;
|
|
36
30
|
try {
|
|
@@ -39,10 +33,11 @@ export async function buildDoctorInspectorReport(targetPath, options = {}) {
|
|
|
39
33
|
catch {
|
|
40
34
|
return buildFailure(discoveredPackage.rootPath, "The MCP server config could not be parsed as JSON.", mcpConfigPath);
|
|
41
35
|
}
|
|
42
|
-
|
|
36
|
+
const normalizedConfig = normalizeMcpConfig(parsedConfig);
|
|
37
|
+
if (!normalizedConfig.ok) {
|
|
43
38
|
return buildFailure(discoveredPackage.rootPath, "The MCP server config does not contain a valid `mcpServers` object.", mcpConfigPath);
|
|
44
39
|
}
|
|
45
|
-
const serverNames = Object.keys(
|
|
40
|
+
const serverNames = Object.keys(normalizedConfig.servers).sort();
|
|
46
41
|
const selectedServerName = options.serverName ?? serverNames[0] ?? null;
|
|
47
42
|
if (!selectedServerName || !serverNames.includes(selectedServerName)) {
|
|
48
43
|
return buildFailure(discoveredPackage.rootPath, options.serverName
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type McpServerConfig = Record<string, unknown>;
|
|
2
|
+
export type McpServerMap = Record<string, McpServerConfig>;
|
|
3
|
+
export type McpConfigLayout = "direct" | "snake_case_wrapper" | "camel_case_wrapper";
|
|
4
|
+
export type McpConfigNormalizationResult = {
|
|
5
|
+
ok: true;
|
|
6
|
+
layout: McpConfigLayout;
|
|
7
|
+
servers: McpServerMap;
|
|
8
|
+
} | {
|
|
9
|
+
ok: false;
|
|
10
|
+
reason: "ambiguous_shape";
|
|
11
|
+
field: "root";
|
|
12
|
+
} | {
|
|
13
|
+
ok: false;
|
|
14
|
+
reason: "invalid_shape";
|
|
15
|
+
field: "root" | "mcp_servers" | "mcpServers" | "server";
|
|
16
|
+
invalidServerNames?: string[];
|
|
17
|
+
};
|
|
18
|
+
export declare function normalizeMcpConfig(value: unknown): McpConfigNormalizationResult;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
function isPlainObject(value) {
|
|
2
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
3
|
+
return false;
|
|
4
|
+
}
|
|
5
|
+
const prototype = Object.getPrototypeOf(value);
|
|
6
|
+
return prototype === Object.prototype || prototype === null;
|
|
7
|
+
}
|
|
8
|
+
function normalizeServerMap(value, field, layout) {
|
|
9
|
+
if (!isPlainObject(value) || Object.keys(value).length === 0) {
|
|
10
|
+
return { ok: false, reason: "invalid_shape", field };
|
|
11
|
+
}
|
|
12
|
+
const invalidServerNames = [];
|
|
13
|
+
const servers = Object.create(null);
|
|
14
|
+
for (const [name, server] of Object.entries(value)) {
|
|
15
|
+
if (!isPlainObject(server)) {
|
|
16
|
+
invalidServerNames.push(name);
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
servers[name] = server;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (invalidServerNames.length > 0) {
|
|
23
|
+
return {
|
|
24
|
+
ok: false,
|
|
25
|
+
reason: "invalid_shape",
|
|
26
|
+
field: "server",
|
|
27
|
+
invalidServerNames: invalidServerNames.sort()
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return { ok: true, layout, servers };
|
|
31
|
+
}
|
|
32
|
+
export function normalizeMcpConfig(value) {
|
|
33
|
+
if (!isPlainObject(value)) {
|
|
34
|
+
return { ok: false, reason: "invalid_shape", field: "root" };
|
|
35
|
+
}
|
|
36
|
+
const hasSnakeCaseWrapper = Object.hasOwn(value, "mcp_servers");
|
|
37
|
+
const hasCamelCaseWrapper = Object.hasOwn(value, "mcpServers");
|
|
38
|
+
if (hasSnakeCaseWrapper && hasCamelCaseWrapper) {
|
|
39
|
+
return { ok: false, reason: "ambiguous_shape", field: "root" };
|
|
40
|
+
}
|
|
41
|
+
if (hasSnakeCaseWrapper || hasCamelCaseWrapper) {
|
|
42
|
+
if (Object.keys(value).length !== 1) {
|
|
43
|
+
return { ok: false, reason: "ambiguous_shape", field: "root" };
|
|
44
|
+
}
|
|
45
|
+
return hasSnakeCaseWrapper
|
|
46
|
+
? normalizeServerMap(value.mcp_servers, "mcp_servers", "snake_case_wrapper")
|
|
47
|
+
: normalizeServerMap(value.mcpServers, "mcpServers", "camel_case_wrapper");
|
|
48
|
+
}
|
|
49
|
+
return normalizeServerMap(value, "root", "direct");
|
|
50
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function resolveContainedPackagePath(rootPath: string, candidatePath: string): Promise<string | null>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
function isPathWithinRoot(rootPath, candidatePath) {
|
|
4
|
+
const relativePath = path.relative(rootPath, candidatePath);
|
|
5
|
+
return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
|
|
6
|
+
}
|
|
7
|
+
export async function resolveContainedPackagePath(rootPath, candidatePath) {
|
|
8
|
+
const resolvedRootPath = path.resolve(rootPath);
|
|
9
|
+
const resolvedCandidatePath = path.resolve(resolvedRootPath, candidatePath);
|
|
10
|
+
if (!isPathWithinRoot(resolvedRootPath, resolvedCandidatePath)) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
const [canonicalRootPath, canonicalCandidatePath] = await Promise.all([
|
|
15
|
+
realpath(resolvedRootPath),
|
|
16
|
+
realpath(resolvedCandidatePath)
|
|
17
|
+
]);
|
|
18
|
+
return isPathWithinRoot(canonicalRootPath, canonicalCandidatePath)
|
|
19
|
+
? resolvedCandidatePath
|
|
20
|
+
: null;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return resolvedCandidatePath;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { DiscoveredPackage, Finding } from "../domain/types.js";
|
|
2
|
+
export interface SafePackagePath {
|
|
3
|
+
path: string;
|
|
4
|
+
packagePath: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function resolveSafePackagePath(rootPath: string, value: string): Promise<SafePackagePath | null>;
|
|
7
|
+
export declare function validatePluginComponents(discoveredPackage: DiscoveredPackage): Promise<Finding[]>;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const interfaceStringFields = [
|
|
4
|
+
"displayName",
|
|
5
|
+
"shortDescription",
|
|
6
|
+
"longDescription",
|
|
7
|
+
"developerName",
|
|
8
|
+
"category",
|
|
9
|
+
"websiteURL",
|
|
10
|
+
"privacyPolicyURL",
|
|
11
|
+
"termsOfServiceURL",
|
|
12
|
+
"brandColor"
|
|
13
|
+
];
|
|
14
|
+
const interfaceUrlFields = new Set(["websiteURL", "privacyPolicyURL", "termsOfServiceURL"]);
|
|
15
|
+
const interfacePathFields = ["composerIcon", "logo"];
|
|
16
|
+
const interfaceStringArrayFields = ["capabilities", "defaultPrompt"];
|
|
17
|
+
function isPlainObject(value) {
|
|
18
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
19
|
+
}
|
|
20
|
+
function isHttpUrl(value) {
|
|
21
|
+
try {
|
|
22
|
+
const url = new URL(value);
|
|
23
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function relativePackagePath(rootPath, targetPath) {
|
|
30
|
+
return path.relative(rootPath, targetPath).split(path.sep).join("/");
|
|
31
|
+
}
|
|
32
|
+
function isWithinRoot(rootPath, candidatePath) {
|
|
33
|
+
const relativePath = path.relative(rootPath, candidatePath);
|
|
34
|
+
return relativePath === "" || (!relativePath.startsWith(`..${path.sep}`) && relativePath !== ".." && !path.isAbsolute(relativePath));
|
|
35
|
+
}
|
|
36
|
+
export async function resolveSafePackagePath(rootPath, value) {
|
|
37
|
+
if (!value.startsWith("./")) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const resolvedPath = path.resolve(rootPath, value);
|
|
41
|
+
if (!isWithinRoot(rootPath, resolvedPath)) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
const canonicalRoot = await realpath(rootPath);
|
|
46
|
+
const canonicalPath = await realpath(resolvedPath);
|
|
47
|
+
if (!isWithinRoot(canonicalRoot, canonicalPath)) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// The path may be intentionally absent; callers that require it report that separately.
|
|
53
|
+
}
|
|
54
|
+
return { path: resolvedPath, packagePath: relativePackagePath(rootPath, resolvedPath) };
|
|
55
|
+
}
|
|
56
|
+
function invalidField(field, manifestPath) {
|
|
57
|
+
return failure("plugin.manifest.invalid_field", `The plugin manifest field \`${field}\` has an invalid value.`, "Malformed optional metadata cannot be interpreted reliably by Codex clients.", `Use the official type for \`${field}\` in .codex-plugin/plugin.json.`, { manifestPath, field });
|
|
58
|
+
}
|
|
59
|
+
function invalidPath(id, field, manifestPath) {
|
|
60
|
+
return failure(id, `The plugin manifest field \`${field}\` must reference a safe package-relative path.`, "Paths outside the plugin package can expose files that are not part of the plugin bundle.", `Use a \`./\` path that remains inside the plugin package for \`${field}\`.`, { manifestPath, field });
|
|
61
|
+
}
|
|
62
|
+
function failure(id, message, impact, suggestedFix, evidence) {
|
|
63
|
+
return { id, severity: "fail", message, impact, suggestedFix, evidence };
|
|
64
|
+
}
|
|
65
|
+
async function safePath(rootPath, manifestPath, field, value, id) {
|
|
66
|
+
if (typeof value !== "string") {
|
|
67
|
+
return invalidField(field, manifestPath);
|
|
68
|
+
}
|
|
69
|
+
return (await resolveSafePackagePath(rootPath, value)) ?? invalidPath(id, field, manifestPath);
|
|
70
|
+
}
|
|
71
|
+
function validateString(value, field, manifestPath, findings) {
|
|
72
|
+
if (value !== undefined && typeof value !== "string") {
|
|
73
|
+
findings.push(invalidField(field, manifestPath));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function validateHttpUrl(value, field, manifestPath, findings) {
|
|
77
|
+
if (value === undefined)
|
|
78
|
+
return;
|
|
79
|
+
if (typeof value !== "string" || !isHttpUrl(value)) {
|
|
80
|
+
findings.push(invalidField(field, manifestPath));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function validateStringArray(value, field, manifestPath, findings) {
|
|
84
|
+
if (value !== undefined && (!Array.isArray(value) || value.some((item) => typeof item !== "string"))) {
|
|
85
|
+
findings.push(invalidField(field, manifestPath));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function validateMetadata(manifest, manifestPath) {
|
|
89
|
+
const findings = [];
|
|
90
|
+
if (manifest.author !== undefined) {
|
|
91
|
+
if (!isPlainObject(manifest.author)) {
|
|
92
|
+
findings.push(invalidField("author", manifestPath));
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
validateString(manifest.author.name, "author.name", manifestPath, findings);
|
|
96
|
+
validateString(manifest.author.email, "author.email", manifestPath, findings);
|
|
97
|
+
validateHttpUrl(manifest.author.url, "author.url", manifestPath, findings);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
validateHttpUrl(manifest.homepage, "homepage", manifestPath, findings);
|
|
101
|
+
validateHttpUrl(manifest.repository, "repository", manifestPath, findings);
|
|
102
|
+
validateString(manifest.license, "license", manifestPath, findings);
|
|
103
|
+
validateStringArray(manifest.keywords, "keywords", manifestPath, findings);
|
|
104
|
+
if (manifest.interface === undefined)
|
|
105
|
+
return findings;
|
|
106
|
+
if (!isPlainObject(manifest.interface)) {
|
|
107
|
+
findings.push(invalidField("interface", manifestPath));
|
|
108
|
+
return findings;
|
|
109
|
+
}
|
|
110
|
+
for (const field of interfaceStringFields) {
|
|
111
|
+
const value = manifest.interface[field];
|
|
112
|
+
if (interfaceUrlFields.has(field)) {
|
|
113
|
+
validateHttpUrl(value, `interface.${field}`, manifestPath, findings);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
validateString(value, `interface.${field}`, manifestPath, findings);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (const field of interfaceStringArrayFields) {
|
|
120
|
+
validateStringArray(manifest.interface[field], `interface.${field}`, manifestPath, findings);
|
|
121
|
+
}
|
|
122
|
+
return findings;
|
|
123
|
+
}
|
|
124
|
+
export async function validatePluginComponents(discoveredPackage) {
|
|
125
|
+
const { manifest, rootPath } = discoveredPackage;
|
|
126
|
+
const manifestPath = relativePackagePath(rootPath, discoveredPackage.manifestPath);
|
|
127
|
+
const findings = validateMetadata(manifest, manifestPath);
|
|
128
|
+
const componentPaths = [
|
|
129
|
+
["skills", manifest.skills],
|
|
130
|
+
["mcpServers", manifest.mcpServers]
|
|
131
|
+
];
|
|
132
|
+
if (isPlainObject(manifest.interface)) {
|
|
133
|
+
for (const field of interfacePathFields)
|
|
134
|
+
componentPaths.push([`interface.${field}`, manifest.interface[field]]);
|
|
135
|
+
const screenshots = manifest.interface.screenshots;
|
|
136
|
+
if (screenshots !== undefined && (!Array.isArray(screenshots) || screenshots.some((item) => typeof item !== "string"))) {
|
|
137
|
+
findings.push(invalidField("interface.screenshots", manifestPath));
|
|
138
|
+
}
|
|
139
|
+
else if (Array.isArray(screenshots)) {
|
|
140
|
+
screenshots.forEach((screenshot, index) => componentPaths.push([`interface.screenshots[${index}]`, screenshot]));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
for (const [field, value] of componentPaths) {
|
|
144
|
+
if (value === undefined)
|
|
145
|
+
continue;
|
|
146
|
+
const result = await safePath(rootPath, manifestPath, field, value, "plugin.manifest.invalid_path");
|
|
147
|
+
if ("id" in result)
|
|
148
|
+
findings.push(result);
|
|
149
|
+
}
|
|
150
|
+
if (manifest.apps === undefined)
|
|
151
|
+
return findings;
|
|
152
|
+
const appPath = await safePath(rootPath, manifestPath, "apps", manifest.apps, "plugin.app.invalid_path");
|
|
153
|
+
if ("id" in appPath)
|
|
154
|
+
return [...findings, appPath];
|
|
155
|
+
try {
|
|
156
|
+
if (!(await stat(appPath.path)).isFile())
|
|
157
|
+
throw new Error("not a regular file");
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return [...findings, failure("plugin.app.missing_file", "The plugin manifest points to a missing .app.json file.", "Codex cannot load an app manifest that is absent or not a regular file.", "Create the referenced app manifest as a regular JSON file inside the plugin package.", { field: "apps", path: appPath.packagePath })];
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
JSON.parse(await readFile(appPath.path, "utf8"));
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
findings.push(failure("plugin.app.invalid_json", "The referenced .app.json file is not valid JSON.", "Codex cannot parse the app manifest.", "Fix the JSON syntax in the referenced app manifest.", { field: "apps", path: appPath.packagePath }));
|
|
167
|
+
}
|
|
168
|
+
return findings;
|
|
169
|
+
}
|