codex-plugin-doctor 1.57.0 → 1.59.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.
Files changed (55) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +20 -6
  3. package/dist/compatibility/claude-desktop-install-preview.js +4 -4
  4. package/dist/compatibility/cline-install-preview.js +4 -4
  5. package/dist/compatibility/compatibility-matrix.js +13 -17
  6. package/dist/compatibility/cursor-install-preview.js +4 -4
  7. package/dist/compatibility/windsurf-install-preview.js +4 -4
  8. package/dist/core/doctor-size.js +16 -1
  9. package/dist/core/init-plugin.js +2 -2
  10. package/dist/core/inspector-bridge.js +8 -13
  11. package/dist/core/mcp-config-normalizer.d.ts +18 -0
  12. package/dist/core/mcp-config-normalizer.js +50 -0
  13. package/dist/core/output-contract.js +76 -0
  14. package/dist/core/package-path.d.ts +1 -0
  15. package/dist/core/package-path.js +25 -0
  16. package/dist/core/plugin-components.d.ts +7 -0
  17. package/dist/core/plugin-components.js +169 -0
  18. package/dist/core/plugin-hooks.d.ts +2 -0
  19. package/dist/core/plugin-hooks.js +190 -0
  20. package/dist/core/runtime-plan.js +97 -16
  21. package/dist/core/runtime-probe.js +3 -8
  22. package/dist/core/shell-completion.js +29 -0
  23. package/dist/core/submission-assets.d.ts +6 -0
  24. package/dist/core/submission-assets.js +430 -0
  25. package/dist/core/submission-preflight.d.ts +34 -0
  26. package/dist/core/submission-preflight.js +289 -0
  27. package/dist/core/submission-ruleset.d.ts +56 -0
  28. package/dist/core/submission-ruleset.js +38 -0
  29. package/dist/core/submission-skill-metadata.d.ts +9 -0
  30. package/dist/core/submission-skill-metadata.js +301 -0
  31. package/dist/core/validate-plugin.js +39 -17
  32. package/dist/domain/types.d.ts +34 -0
  33. package/dist/domain/types.js +13 -1
  34. package/dist/index.d.ts +5 -0
  35. package/dist/index.js +5 -0
  36. package/dist/mcp/generic-mcp-doctor.js +45 -29
  37. package/dist/reporting/render-submission-report.d.ts +5 -0
  38. package/dist/reporting/render-submission-report.js +68 -0
  39. package/dist/rules/rule-catalog.js +130 -4
  40. package/dist/run-cli.js +96 -0
  41. package/dist/security/security-audit.d.ts +1 -0
  42. package/dist/security/security-audit.js +104 -7
  43. package/examples/README.md +94 -94
  44. package/examples/codex-doctor-generic-mcp/.mcp.json +10 -10
  45. package/examples/codex-doctor-generic-mcp/server.js +1 -1
  46. package/examples/codex-doctor-risky/.codex-plugin/plugin.json +7 -7
  47. package/examples/codex-doctor-risky/.mcp.json +12 -12
  48. package/examples/codex-doctor-risky/mock-server.js +1 -1
  49. package/examples/codex-doctor-runtime/.codex-plugin/plugin.json +8 -8
  50. package/examples/codex-doctor-runtime/.mcp.json +9 -9
  51. package/examples/codex-doctor-runtime/mock-server.js +237 -237
  52. package/examples/codex-doctor-runtime/skills/context-check/SKILL.md +7 -7
  53. package/examples/codex-doctor-starter/.codex-plugin/plugin.json +7 -7
  54. package/examples/codex-doctor-starter/skills/repo-scan/SKILL.md +7 -7
  55. package/package.json +5 -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
@@ -131,8 +131,21 @@ codex-plugin-doctor registry preflight path/to/server.json --allow-network --req
131
131
  Preflight checks the exact npm package version, then the exact Registry version
132
132
  and latest Registry record on fixed public hosts. An existing exact Registry
133
133
  version is an immutable collision and must be replaced by a version bump. The
134
- command is advisory: it never authenticates, publishes, or changes npm or
135
- Registry records. See [MCP Registry Publication Preflight](./docs/architecture/mcp-registry-publication-preflight.md).
134
+ command is advisory: it never authenticates, publishes, or changes npm or
135
+ Registry records. See [MCP Registry Publication Preflight](./docs/architecture/mcp-registry-publication-preflight.md).
136
+
137
+ ### Public Directory Submission Preflight
138
+
139
+ Check a plugin package for deterministic public-directory submission issues without contacting a portal:
140
+
141
+ ```bash
142
+ codex-plugin-doctor doctor submission <path>
143
+ codex-plugin-doctor doctor submission <path> --json
144
+ codex-plugin-doctor doctor submission <path> --markdown
145
+ codex-plugin-doctor doctor submission <path> --require-ready
146
+ ```
147
+
148
+ The preflight is offline and non-executing: it does not submit a package, make network requests, start MCP servers, verify domains, or handle OAuth credentials. Its automatic `status` is `pass` or `fail`; a passing automatic result remains `manual_review_required` until portal-only review is complete. It never claims directory acceptance. See [Public Directory Submission Preflight](./docs/architecture/public-directory-submission-preflight.md).
136
149
 
137
150
  Output formats:
138
151
 
@@ -480,9 +493,9 @@ jobs:
480
493
  runs-on: ubuntu-latest
481
494
  steps:
482
495
  - uses: actions/checkout@v5
483
- - uses: Esquetta/CodexPluginDoctor@v1.57.0
484
- with:
485
- version: "1.57.0"
496
+ - uses: Esquetta/CodexPluginDoctor@v1.59.0
497
+ with:
498
+ version: "1.59.0"
486
499
  path: .
487
500
  runtime: "true"
488
501
  policy: codex-publish
@@ -494,7 +507,7 @@ jobs:
494
507
  review-bundle-verify: "true"
495
508
  ```
496
509
 
497
- The action writes `codex-plugin-doctor-summary.md`, `codex-plugin-doctor-report.json`, `codex-plugin-doctor-action-manifest.json`, optional `codex-plugin-doctor.sarif`, optional validation corpus and quality metrics reports, optional `output-contract.json`, and optional signed `review-bundle/` files to `codex-plugin-doctor-reports`, appends the Markdown report to the GitHub Actions step summary, uploads the report directory as an artifact, and then returns the real validation exit code. Review bundle generation requires a signing key environment variable such as `CODEX_PLUGIN_DOCTOR_SIGNING_KEY`. For runtime probing, SARIF output, corpus quality regression gates, corpus and contract artifacts, review bundle artifacts, installed plugin cache checks, CI policy presets, and pinned release examples, see [GitHub Action Usage](./docs/guides/github-action.md).
510
+ The action writes `codex-plugin-doctor-summary.md`, `codex-plugin-doctor-report.json`, `codex-plugin-doctor-action-manifest.json`, optional `codex-plugin-doctor.sarif`, optional validation corpus and quality metrics reports, optional `output-contract.json`, and optional signed `review-bundle/` files to `codex-plugin-doctor-reports`, appends the Markdown report to the GitHub Actions step summary, uploads the report directory as an artifact, and then returns the real validation exit code. Set `submission: "true"` to add offline submission preflight reports; set `require-submission-ready: "true"` only with that opt-in to make automatic blockers fail the job. Review bundle generation requires a signing key environment variable such as `CODEX_PLUGIN_DOCTOR_SIGNING_KEY`. For runtime probing, SARIF output, corpus quality regression gates, corpus and contract artifacts, review bundle artifacts, installed plugin cache checks, CI policy presets, and pinned release examples, see [GitHub Action Usage](./docs/guides/github-action.md).
498
511
 
499
512
  To self-test this repository after cloning it:
500
513
 
@@ -518,6 +531,7 @@ The validator is tuned against local fixtures and real marketplace-style plugin
518
531
  - [Real-World Validation Workflow](./docs/guides/real-world-validation.md)
519
532
  - [Examples](./examples/README.md)
520
533
  - [Rule Catalog](./docs/rules/catalog.md)
534
+ - [Official Plugin Components](./docs/architecture/official-plugin-components.md)
521
535
 
522
536
  Recent validation waves covered:
523
537
 
@@ -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 parsed = JSON.parse(await readFile(mcpConfigPath, "utf8"));
42
- const servers = parsed.mcpServers;
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 parsed = JSON.parse(await readFile(mcpConfigPath, "utf8"));
38
- const servers = parsed.mcpServers;
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
- return directMcpPath;
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 = path.resolve(rootPath, manifest.mcpServers);
59
- if (!isPathWithinRoot(rootPath, mcpConfigPath)) {
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 servers = parsed.mcpServers;
91
- if (typeof servers !== "object" ||
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 servers = parsed.mcpServers;
126
- return typeof servers === "object" && servers !== null && !Array.isArray(servers)
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 parsed = JSON.parse(await readFile(mcpConfigPath, "utf8"));
38
- const servers = parsed.mcpServers;
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 parsed = JSON.parse(await readFile(mcpConfigPath, "utf8"));
38
- const servers = parsed.mcpServers;
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
  ]))
@@ -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
- execFile("npm", ["pack", "--dry-run"], { cwd: resolvedPath, timeout: 30000, shell: process.platform === "win32" }, (error, stdout, stderr) => {
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;
@@ -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: ".mcp.json" })
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
- function isPlainObject(value) {
5
- return typeof value === "object" && value !== null && !Array.isArray(value);
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 = path.resolve(discoveredPackage.rootPath, discoveredPackage.manifest.mcpServers);
32
- if (!isPathWithinRoot(discoveredPackage.rootPath, mcpConfigPath)) {
33
- return buildFailure(discoveredPackage.rootPath, "The target package points the MCP server config outside the package root.", mcpConfigPath);
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
- if (!isPlainObject(parsedConfig) || !isPlainObject(parsedConfig.mcpServers)) {
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(parsedConfig.mcpServers).sort();
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
+ }
@@ -186,6 +186,82 @@ const publicSchemaDefinitions = [
186
186
  }
187
187
  }
188
188
  },
189
+ {
190
+ id: "doctor.submission.json",
191
+ command: "codex-plugin-doctor doctor submission <path> --json",
192
+ required: [
193
+ "schemaVersion",
194
+ "rulesetVersion",
195
+ "targetType",
196
+ "status",
197
+ "readiness",
198
+ "summary",
199
+ "checks",
200
+ "findings",
201
+ "manualChecklist"
202
+ ],
203
+ properties: {
204
+ rulesetVersion: { const: "openai-directory-2026-08-15" },
205
+ targetType: { type: "string", enum: ["skills-only", "mcp-backed"] },
206
+ status: { type: "string", enum: ["pass", "fail"] },
207
+ readiness: { type: "string", enum: ["blocked", "manual_review_required"] },
208
+ summary: {
209
+ type: "object",
210
+ required: ["passed", "warnings", "blockers", "manualChecks"],
211
+ properties: {
212
+ passed: { type: "integer", minimum: 0 },
213
+ warnings: { type: "integer", minimum: 0 },
214
+ blockers: { type: "integer", minimum: 0 },
215
+ manualChecks: { type: "integer", minimum: 0 }
216
+ },
217
+ additionalProperties: false
218
+ },
219
+ checks: {
220
+ type: "array",
221
+ items: {
222
+ type: "object",
223
+ required: ["id", "status", "findingIds"],
224
+ properties: {
225
+ id: { type: "string", enum: ["listing", "components", "assets", "skills"] },
226
+ status: { type: "string", enum: ["pass", "warn", "fail"] },
227
+ findingIds: { type: "array", items: { type: "string" } }
228
+ },
229
+ additionalProperties: false
230
+ }
231
+ },
232
+ findings: {
233
+ type: "array",
234
+ items: {
235
+ type: "object",
236
+ required: ["id", "severity", "message"],
237
+ properties: {
238
+ id: { type: "string", pattern: "^plugin\\.submission\\." },
239
+ severity: { type: "string", enum: ["warn", "fail"] },
240
+ message: { type: "string" },
241
+ portalCode: { type: "string" },
242
+ evidence: {
243
+ type: "object",
244
+ additionalProperties: { type: ["string", "number", "boolean", "null"] }
245
+ }
246
+ },
247
+ additionalProperties: false
248
+ }
249
+ },
250
+ manualChecklist: {
251
+ type: "array",
252
+ items: {
253
+ type: "object",
254
+ required: ["id", "label", "state"],
255
+ properties: {
256
+ id: { type: "string" },
257
+ label: { type: "string" },
258
+ state: { type: "string", enum: ["required", "not_applicable"] }
259
+ },
260
+ additionalProperties: false
261
+ }
262
+ }
263
+ }
264
+ },
189
265
  {
190
266
  id: "doctor.installed.check.json",
191
267
  command: "codex-plugin-doctor check --installed --json",
@@ -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[]>;