codex-plugin-doctor 1.58.0 → 1.60.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/README.md +22 -3
- package/dist/core/output-contract.js +138 -0
- package/dist/core/shell-completion.js +52 -0
- package/dist/core/submission-archive-preflight.d.ts +24 -0
- package/dist/core/submission-archive-preflight.js +259 -0
- package/dist/core/submission-archive-reader.d.ts +21 -0
- package/dist/core/submission-archive-reader.js +624 -0
- package/dist/core/submission-archive-ruleset.d.ts +58 -0
- package/dist/core/submission-archive-ruleset.js +61 -0
- package/dist/core/submission-assets.d.ts +8 -0
- package/dist/core/submission-assets.js +451 -0
- package/dist/core/submission-package-reader.d.ts +15 -0
- package/dist/core/submission-package-reader.js +274 -0
- package/dist/core/submission-preflight.d.ts +36 -0
- package/dist/core/submission-preflight.js +289 -0
- package/dist/core/submission-ruleset.d.ts +56 -0
- package/dist/core/submission-ruleset.js +38 -0
- package/dist/core/submission-skill-metadata.d.ts +11 -0
- package/dist/core/submission-skill-metadata.js +282 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/reporting/render-submission-archive-report.d.ts +4 -0
- package/dist/reporting/render-submission-archive-report.js +52 -0
- package/dist/reporting/render-submission-report.d.ts +5 -0
- package/dist/reporting/render-submission-report.js +68 -0
- package/dist/run-cli.js +202 -0
- package/package.json +8 -1
package/README.md
CHANGED
|
@@ -134,6 +134,25 @@ version is an immutable collision and must be replaced by a version bump. The
|
|
|
134
134
|
command is advisory: it never authenticates, publishes, or changes npm or
|
|
135
135
|
Registry records. See [MCP Registry Publication Preflight](./docs/architecture/mcp-registry-publication-preflight.md).
|
|
136
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
|
+
codex-plugin-doctor doctor submission archive <zip>
|
|
147
|
+
codex-plugin-doctor doctor submission archive <zip> --json --output submission-archive.json
|
|
148
|
+
codex-plugin-doctor doctor submission archive <zip> --markdown --output submission-archive.md
|
|
149
|
+
codex-plugin-doctor doctor submission archive <zip> --require-ready
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
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).
|
|
153
|
+
|
|
154
|
+
The archive variant accepts an existing skills-only ZIP and does not extract archive entries to disk, execute package code, or repair the archive. It reports malformed ZIP structures and deterministic package blockers; unsupported portal rules remain manual or `coverage: unavailable` rather than an automatic pass.
|
|
155
|
+
|
|
137
156
|
Output formats:
|
|
138
157
|
|
|
139
158
|
- human text output
|
|
@@ -480,9 +499,9 @@ jobs:
|
|
|
480
499
|
runs-on: ubuntu-latest
|
|
481
500
|
steps:
|
|
482
501
|
- uses: actions/checkout@v5
|
|
483
|
-
- uses: Esquetta/CodexPluginDoctor@v1.
|
|
502
|
+
- uses: Esquetta/CodexPluginDoctor@v1.60.0
|
|
484
503
|
with:
|
|
485
|
-
version: "1.
|
|
504
|
+
version: "1.60.0"
|
|
486
505
|
path: .
|
|
487
506
|
runtime: "true"
|
|
488
507
|
policy: codex-publish
|
|
@@ -494,7 +513,7 @@ jobs:
|
|
|
494
513
|
review-bundle-verify: "true"
|
|
495
514
|
```
|
|
496
515
|
|
|
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).
|
|
516
|
+
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
517
|
|
|
499
518
|
To self-test this repository after cloning it:
|
|
500
519
|
|
|
@@ -186,6 +186,144 @@ 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
|
+
},
|
|
265
|
+
{
|
|
266
|
+
id: "doctor.submission.archive.json",
|
|
267
|
+
command: "codex-plugin-doctor doctor submission archive <zip> --json",
|
|
268
|
+
required: [
|
|
269
|
+
"schemaVersion",
|
|
270
|
+
"rulesetVersion",
|
|
271
|
+
"status",
|
|
272
|
+
"readiness",
|
|
273
|
+
"archive",
|
|
274
|
+
"summary",
|
|
275
|
+
"archiveChecks",
|
|
276
|
+
"submission",
|
|
277
|
+
"findings",
|
|
278
|
+
"coverage",
|
|
279
|
+
"manualChecklist"
|
|
280
|
+
],
|
|
281
|
+
properties: {
|
|
282
|
+
rulesetVersion: { const: "openai-directory-archive-2026-08-23" },
|
|
283
|
+
status: { type: "string", enum: ["pass", "fail"] },
|
|
284
|
+
readiness: { type: "string", enum: ["blocked", "manual_review_required"] },
|
|
285
|
+
archive: {
|
|
286
|
+
type: "object",
|
|
287
|
+
required: ["fileName", "compressedBytes", "uncompressedBytes", "entryCount", "rootLayout"],
|
|
288
|
+
properties: {
|
|
289
|
+
fileName: { type: "string" },
|
|
290
|
+
compressedBytes: { type: "integer", minimum: 0 },
|
|
291
|
+
uncompressedBytes: { type: "integer", minimum: 0 },
|
|
292
|
+
entryCount: { type: "integer", minimum: 0 },
|
|
293
|
+
rootLayout: { type: "string", enum: ["archive-root", "single-top-level-directory", "unavailable"] }
|
|
294
|
+
},
|
|
295
|
+
additionalProperties: false
|
|
296
|
+
},
|
|
297
|
+
summary: {
|
|
298
|
+
type: "object",
|
|
299
|
+
required: ["passed", "warnings", "blockers", "manualChecks"],
|
|
300
|
+
properties: {
|
|
301
|
+
passed: { type: "integer", minimum: 0 },
|
|
302
|
+
warnings: { type: "integer", minimum: 0 },
|
|
303
|
+
blockers: { type: "integer", minimum: 0 },
|
|
304
|
+
manualChecks: { type: "integer", minimum: 0 }
|
|
305
|
+
},
|
|
306
|
+
additionalProperties: false
|
|
307
|
+
},
|
|
308
|
+
archiveChecks: { type: "array" },
|
|
309
|
+
submission: { type: ["object", "null"] },
|
|
310
|
+
findings: { type: "array" },
|
|
311
|
+
coverage: {
|
|
312
|
+
type: "array",
|
|
313
|
+
items: {
|
|
314
|
+
type: "object",
|
|
315
|
+
required: ["id", "status", "reason"],
|
|
316
|
+
properties: {
|
|
317
|
+
id: { type: "string", pattern: "^plugin\\.submission\\.archive\\." },
|
|
318
|
+
status: { type: "string", enum: ["automatic", "manual", "unavailable"] },
|
|
319
|
+
reason: { type: "string" }
|
|
320
|
+
},
|
|
321
|
+
additionalProperties: false
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
manualChecklist: { type: "array" }
|
|
325
|
+
}
|
|
326
|
+
},
|
|
189
327
|
{
|
|
190
328
|
id: "doctor.installed.check.json",
|
|
191
329
|
command: "codex-plugin-doctor check --installed --json",
|
|
@@ -17,6 +17,10 @@ const topLevelCommands = [
|
|
|
17
17
|
"explain",
|
|
18
18
|
"config"
|
|
19
19
|
];
|
|
20
|
+
const doctorCommands = ["submission"];
|
|
21
|
+
const submissionTargets = ["archive"];
|
|
22
|
+
const submissionFlags = ["--json", "--markdown", "--output", "--require-ready"];
|
|
23
|
+
const fishSubmissionCondition = "__fish_seen_subcommand_from doctor; and __fish_seen_subcommand_from submission";
|
|
20
24
|
function bashCompletion() {
|
|
21
25
|
return [
|
|
22
26
|
"_codex_plugin_doctor() {",
|
|
@@ -26,16 +30,44 @@ function bashCompletion() {
|
|
|
26
30
|
" prev=\"${COMP_WORDS[COMP_CWORD-1]}\"",
|
|
27
31
|
"",
|
|
28
32
|
` local commands="${topLevelCommands.join(" ")}"`,
|
|
33
|
+
` local doctor_commands="${doctorCommands.join(" ")}"`,
|
|
34
|
+
` local submission_targets="${submissionTargets.join(" ")}"`,
|
|
35
|
+
` local submission_flags="${submissionFlags.join(" ")}"`,
|
|
29
36
|
"",
|
|
30
37
|
" case \"${prev}\" in",
|
|
31
38
|
" codex-plugin-doctor)",
|
|
32
39
|
" COMPREPLY=( $(compgen -W \"${commands}\" -- \"${cur}\") )",
|
|
33
40
|
" return 0",
|
|
34
41
|
" ;;",
|
|
42
|
+
" doctor)",
|
|
43
|
+
" COMPREPLY=( $(compgen -W \"${doctor_commands}\" -- \"${cur}\") )",
|
|
44
|
+
" return 0",
|
|
45
|
+
" ;;",
|
|
46
|
+
" submission)",
|
|
47
|
+
" COMPREPLY=( $(compgen -W \"${submission_targets}\" -- \"${cur}\") )",
|
|
48
|
+
" return 0",
|
|
49
|
+
" ;;",
|
|
35
50
|
" esac",
|
|
36
51
|
"",
|
|
52
|
+
" if [[ ${COMP_WORDS[1]} == \"doctor\" && ${COMP_WORDS[2]} == \"submission\" && ${COMP_WORDS[3]} == \"archive\" ]]; then",
|
|
53
|
+
" case \"${cur}\" in",
|
|
54
|
+
" --*)",
|
|
55
|
+
" COMPREPLY=( $(compgen -W \"${submission_flags}\" -- \"${cur}\") )",
|
|
56
|
+
" return 0",
|
|
57
|
+
" ;;",
|
|
58
|
+
" *)",
|
|
59
|
+
" COMPREPLY=( $(compgen -f -- \"${cur}\") )",
|
|
60
|
+
" return 0",
|
|
61
|
+
" ;;",
|
|
62
|
+
" esac",
|
|
63
|
+
" fi",
|
|
64
|
+
"",
|
|
37
65
|
" case \"${cur}\" in",
|
|
38
66
|
" --*)",
|
|
67
|
+
" if [[ ${COMP_WORDS[1]} == \"doctor\" && ${COMP_WORDS[2]} == \"submission\" ]]; then",
|
|
68
|
+
" COMPREPLY=( $(compgen -W \"${submission_flags}\" -- \"${cur}\") )",
|
|
69
|
+
" return 0",
|
|
70
|
+
" fi",
|
|
39
71
|
" local flags=\"--json --output --runtime --policy --help\"",
|
|
40
72
|
" COMPREPLY=( $(compgen -W \"${flags}\" -- \"${cur}\") )",
|
|
41
73
|
" return 0",
|
|
@@ -60,9 +92,22 @@ function zshCompletion() {
|
|
|
60
92
|
" typeset -A opt_args",
|
|
61
93
|
"",
|
|
62
94
|
` local commands=(${topLevelCommands.join(" ")})`,
|
|
95
|
+
` local doctor_commands=(${doctorCommands.join(" ")})`,
|
|
96
|
+
"",
|
|
97
|
+
" if [[ \"$words[2]\" == \"doctor\" && \"$words[3]\" == \"submission\" ]]; then",
|
|
98
|
+
" _arguments -C \\",
|
|
99
|
+
" '3:archive target:(archive)' \\",
|
|
100
|
+
" '4:ZIP archive:_files' \\",
|
|
101
|
+
" '*--json[Output as JSON]' \\",
|
|
102
|
+
" '*--markdown[Output as Markdown]' \\",
|
|
103
|
+
" '*--output[Write to file]:file:_files' \\",
|
|
104
|
+
" '*--require-ready[Fail when automatic checks are blocked]'",
|
|
105
|
+
" return",
|
|
106
|
+
" fi",
|
|
63
107
|
"",
|
|
64
108
|
" _arguments -C \\",
|
|
65
109
|
" '1:command:(${commands})' \\",
|
|
110
|
+
" '2:doctor command:(${doctor_commands})' \\",
|
|
66
111
|
" '*--json[Output as JSON]' \\",
|
|
67
112
|
" '*--output[Write to file]:file:_files' \\",
|
|
68
113
|
" '*--runtime[Enable runtime probes]' \\",
|
|
@@ -80,6 +125,13 @@ function fishCompletion() {
|
|
|
80
125
|
"complete -c codex-plugin-doctor -s h -l help -d 'Show help'",
|
|
81
126
|
"complete -c codex-plugin-doctor -l json -d 'Output as JSON'",
|
|
82
127
|
"complete -c codex-plugin-doctor -l output -d 'Write to file' -r",
|
|
128
|
+
`complete -c codex-plugin-doctor -n "${fishSubmissionCondition}" -l json -d 'Output as JSON'`,
|
|
129
|
+
`complete -c codex-plugin-doctor -n "${fishSubmissionCondition}" -l markdown -d 'Output as Markdown'`,
|
|
130
|
+
`complete -c codex-plugin-doctor -n "${fishSubmissionCondition}" -l output -d 'Write to file' -r`,
|
|
131
|
+
`complete -c codex-plugin-doctor -n "${fishSubmissionCondition}" -l require-ready -d 'Fail when automatic checks are blocked'`,
|
|
132
|
+
`complete -c codex-plugin-doctor -n "${fishSubmissionCondition}; and __fish_seen_subcommand_from archive" -F`,
|
|
133
|
+
`complete -c codex-plugin-doctor -n "${fishSubmissionCondition}; and not __fish_seen_subcommand_from archive" -a "${submissionTargets.join(" ")}" -d 'ZIP archive'`,
|
|
134
|
+
`complete -c codex-plugin-doctor -n "__fish_seen_subcommand_from doctor; and not __fish_seen_subcommand_from ${doctorCommands.join(" ")}" -a "${doctorCommands.join(" ")}"`,
|
|
83
135
|
"complete -c codex-plugin-doctor -l runtime -d 'Enable runtime probes'",
|
|
84
136
|
"complete -c codex-plugin-doctor -l policy -d 'Apply policy' -x -a 'codex-publish mcp-strict security'",
|
|
85
137
|
""
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type SubmissionArchiveCoverage, type SubmissionArchiveFinding } from "./submission-archive-reader.js";
|
|
2
|
+
import { type SubmissionCheck, type SubmissionFinding, type SubmissionManualCheck, type SubmissionPreflightReport } from "./submission-preflight.js";
|
|
3
|
+
export type SubmissionArchiveRootLayout = "archive-root" | "single-top-level-directory" | "unavailable";
|
|
4
|
+
export interface SubmissionArchivePreflightReport {
|
|
5
|
+
schemaVersion: "1.0.0";
|
|
6
|
+
rulesetVersion: "openai-directory-archive-2026-08-23";
|
|
7
|
+
status: "pass" | "fail";
|
|
8
|
+
readiness: "blocked" | "manual_review_required";
|
|
9
|
+
archive: {
|
|
10
|
+
fileName: string;
|
|
11
|
+
compressedBytes: number;
|
|
12
|
+
uncompressedBytes: number;
|
|
13
|
+
entryCount: number;
|
|
14
|
+
rootLayout: SubmissionArchiveRootLayout;
|
|
15
|
+
};
|
|
16
|
+
summary: SubmissionPreflightReport["summary"];
|
|
17
|
+
archiveChecks: readonly SubmissionCheck[];
|
|
18
|
+
submission: SubmissionPreflightReport | null;
|
|
19
|
+
findings: readonly SubmissionArchiveFinding[] | readonly (SubmissionArchiveFinding | SubmissionFinding)[];
|
|
20
|
+
coverage: readonly SubmissionArchiveCoverage[];
|
|
21
|
+
manualChecklist: readonly SubmissionManualCheck[];
|
|
22
|
+
}
|
|
23
|
+
export declare function submissionArchiveExitCode(report: SubmissionArchivePreflightReport, requireReady: boolean): 0 | 1;
|
|
24
|
+
export declare function buildSubmissionArchivePreflight(archivePath: string): Promise<SubmissionArchivePreflightReport>;
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { validateSubmissionAssetsFromReader } from "./submission-assets.js";
|
|
2
|
+
import { inspectSubmissionArchive } from "./submission-archive-reader.js";
|
|
3
|
+
import { submissionArchiveRuleset } from "./submission-archive-ruleset.js";
|
|
4
|
+
import { validateSubmissionListing } from "./submission-preflight.js";
|
|
5
|
+
import { submissionManualChecks } from "./submission-ruleset.js";
|
|
6
|
+
import { validateSubmissionSkillMetadataFromReader } from "./submission-skill-metadata.js";
|
|
7
|
+
const manifestPaths = [".codex-plugin/plugin.json", ".agent-plugin/plugin.json", ".claude-plugin/plugin.json"];
|
|
8
|
+
const maxManifestBytes = 1024 * 1024;
|
|
9
|
+
function archiveFinding(id, severity, message, portalCode) {
|
|
10
|
+
return portalCode === undefined ? { id, severity, message } : { id, severity, message, portalCode };
|
|
11
|
+
}
|
|
12
|
+
function archiveCoverage() {
|
|
13
|
+
return submissionArchiveRuleset.portalRules.map(({ id, status, reason }) => ({ id, status, reason }));
|
|
14
|
+
}
|
|
15
|
+
function checkStatus(findings) {
|
|
16
|
+
return findings.some((finding) => finding.severity === "fail")
|
|
17
|
+
? "fail"
|
|
18
|
+
: findings.some((finding) => finding.severity === "warn") ? "warn" : "pass";
|
|
19
|
+
}
|
|
20
|
+
function submissionCheck(id, findings) {
|
|
21
|
+
return { id, status: checkStatus(findings), findingIds: findings.map((finding) => finding.id) };
|
|
22
|
+
}
|
|
23
|
+
function manualChecklist() {
|
|
24
|
+
return submissionManualChecks.map((item) => ({
|
|
25
|
+
id: item.id,
|
|
26
|
+
label: item.label,
|
|
27
|
+
state: item.mcpOnly ? "not_applicable" : "required"
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
function reportSummary(checks, findings, checklist) {
|
|
31
|
+
return {
|
|
32
|
+
passed: checks.filter((check) => check.status === "pass").length,
|
|
33
|
+
warnings: findings.filter((finding) => finding.severity === "warn").length,
|
|
34
|
+
blockers: findings.filter((finding) => finding.severity === "fail").length,
|
|
35
|
+
manualChecks: checklist.filter((item) => item.state === "required").length
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function normalizePath(value, allowRoot = false) {
|
|
39
|
+
if (value === "" && allowRoot)
|
|
40
|
+
return "";
|
|
41
|
+
if (typeof value !== "string" || value === "" || value.startsWith("/") || value.startsWith("\\")
|
|
42
|
+
|| /^[A-Za-z]:/u.test(value) || value.includes("\\") || /[\u0000-\u001F\u007F]/u.test(value))
|
|
43
|
+
return null;
|
|
44
|
+
const segments = value.split("/");
|
|
45
|
+
return segments.some((segment) => segment === "" || segment === "." || segment === "..") ? null : segments.join("/");
|
|
46
|
+
}
|
|
47
|
+
function discoverRoot(entries) {
|
|
48
|
+
const paths = entries.map((entry) => entry.packagePath);
|
|
49
|
+
if (paths.length === 0) {
|
|
50
|
+
return { discovery: null, findings: [archiveFinding("plugin.submission.archive.root_missing", "fail", "Archive does not contain a plugin root.")] };
|
|
51
|
+
}
|
|
52
|
+
const rootManifests = manifestPaths.filter((manifestPath) => paths.includes(manifestPath));
|
|
53
|
+
const nestedRoots = new Set();
|
|
54
|
+
for (const entryPath of paths) {
|
|
55
|
+
const separator = entryPath.indexOf("/");
|
|
56
|
+
if (separator > 0 && manifestPaths.includes(entryPath.slice(separator + 1))) {
|
|
57
|
+
nestedRoots.add(entryPath.slice(0, separator));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (rootManifests.length + nestedRoots.size > 1) {
|
|
61
|
+
return { discovery: null, findings: [archiveFinding("plugin.submission.archive.root_ambiguous", "fail", "Archive contains more than one plugin root.")] };
|
|
62
|
+
}
|
|
63
|
+
if (rootManifests.length === 1) {
|
|
64
|
+
return {
|
|
65
|
+
discovery: { rootPrefix: "", rootLayout: "archive-root", manifestPath: rootManifests[0], manifestFormat: rootManifests[0] },
|
|
66
|
+
findings: []
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (nestedRoots.size === 0) {
|
|
70
|
+
return { discovery: null, findings: [archiveFinding("plugin.submission.archive.manifest_missing", "fail", "Archive root does not contain a recognized plugin manifest.")] };
|
|
71
|
+
}
|
|
72
|
+
const hasRootFile = entries.some((entry) => !entry.packagePath.includes("/") && entry.resolvedKind === "file");
|
|
73
|
+
const topLevels = new Set(paths.map((entryPath) => entryPath.split("/", 1)[0]));
|
|
74
|
+
if (hasRootFile || nestedRoots.size !== 1 || topLevels.size !== 1) {
|
|
75
|
+
return { discovery: null, findings: [archiveFinding("plugin.submission.archive.root_siblings", "fail", "A top-level plugin directory cannot have siblings.")] };
|
|
76
|
+
}
|
|
77
|
+
const topLevel = [...nestedRoots][0];
|
|
78
|
+
const prefix = `${topLevel}/`;
|
|
79
|
+
const nestedManifests = manifestPaths.filter((manifestPath) => paths.includes(`${prefix}${manifestPath}`));
|
|
80
|
+
if (nestedManifests.length === 0) {
|
|
81
|
+
return { discovery: null, findings: [archiveFinding("plugin.submission.archive.manifest_missing", "fail", "Archive root does not contain a recognized plugin manifest.")] };
|
|
82
|
+
}
|
|
83
|
+
if (nestedManifests.length > 1) {
|
|
84
|
+
return { discovery: null, findings: [archiveFinding("plugin.submission.archive.root_ambiguous", "fail", "Archive contains more than one plugin manifest.")] };
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
discovery: {
|
|
88
|
+
rootPrefix: prefix,
|
|
89
|
+
rootLayout: "single-top-level-directory",
|
|
90
|
+
manifestPath: `${prefix}${nestedManifests[0]}`,
|
|
91
|
+
manifestFormat: nestedManifests[0]
|
|
92
|
+
},
|
|
93
|
+
findings: []
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function rootedReader(inspection, rootPrefix) {
|
|
97
|
+
if (inspection.reader === null)
|
|
98
|
+
return null;
|
|
99
|
+
const entries = inspection.entries
|
|
100
|
+
.filter((entry) => entry.packagePath.startsWith(rootPrefix))
|
|
101
|
+
.map((entry) => ({ ...entry, packagePath: entry.packagePath.slice(rootPrefix.length) }));
|
|
102
|
+
const byPath = new Map(entries.map((entry) => [entry.packagePath, entry]));
|
|
103
|
+
const reader = inspection.reader;
|
|
104
|
+
function directoryEntry(packagePath) {
|
|
105
|
+
if (entries.some((entry) => entry.packagePath.startsWith(`${packagePath}/`))) {
|
|
106
|
+
return { packagePath, kind: "directory", resolvedKind: "directory", size: 0, safeResolution: "safe" };
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
async list(directory) {
|
|
112
|
+
const normalizedDirectory = normalizePath(directory, true);
|
|
113
|
+
if (normalizedDirectory === null)
|
|
114
|
+
return [];
|
|
115
|
+
const prefix = normalizedDirectory === "" ? "" : `${normalizedDirectory}/`;
|
|
116
|
+
const children = new Map();
|
|
117
|
+
for (const entry of entries) {
|
|
118
|
+
if (!entry.packagePath.startsWith(prefix))
|
|
119
|
+
continue;
|
|
120
|
+
const remaining = entry.packagePath.slice(prefix.length);
|
|
121
|
+
if (remaining === "")
|
|
122
|
+
continue;
|
|
123
|
+
const [name] = remaining.split("/", 1);
|
|
124
|
+
const packagePath = normalizedDirectory === "" ? name : `${normalizedDirectory}/${name}`;
|
|
125
|
+
if (remaining.includes("/")) {
|
|
126
|
+
children.set(packagePath, directoryEntry(packagePath));
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
children.set(packagePath, entry);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return [...children.values()].sort((left, right) => left.packagePath.localeCompare(right.packagePath));
|
|
133
|
+
},
|
|
134
|
+
async stat(packagePath) {
|
|
135
|
+
const normalizedPath = normalizePath(packagePath);
|
|
136
|
+
return normalizedPath === null ? null : byPath.get(normalizedPath) ?? directoryEntry(normalizedPath);
|
|
137
|
+
},
|
|
138
|
+
async read(packagePath, maxBytes) {
|
|
139
|
+
const normalizedPath = normalizePath(packagePath);
|
|
140
|
+
if (normalizedPath === null || !byPath.has(normalizedPath))
|
|
141
|
+
return null;
|
|
142
|
+
return reader.read(`${rootPrefix}${normalizedPath}`, maxBytes);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
async function parseManifest(reader, manifestPath) {
|
|
147
|
+
const bytes = await reader.read(manifestPath, maxManifestBytes).catch(() => null);
|
|
148
|
+
if (bytes === null)
|
|
149
|
+
return null;
|
|
150
|
+
try {
|
|
151
|
+
const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
152
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function hasImmediateSkill(entries) {
|
|
159
|
+
return entries.some((entry) => /^skills\/[^/]+\/SKILL\.md$/u.test(entry.packagePath) && entry.resolvedKind === "file");
|
|
160
|
+
}
|
|
161
|
+
function exclusionFindings(manifest, entries) {
|
|
162
|
+
const findings = [];
|
|
163
|
+
const interfaceValue = manifest.interface;
|
|
164
|
+
if (manifest.mcpServers !== undefined || entries.some((entry) => entry.packagePath === ".mcp.json")) {
|
|
165
|
+
findings.push(archiveFinding("plugin.submission.archive.mcp_excluded", "warn", "MCP configuration requires the MCP-backed submission flow.", "mcp_configuration_excluded"));
|
|
166
|
+
}
|
|
167
|
+
if (manifest.apps !== undefined || entries.some((entry) => entry.packagePath === ".app.json")) {
|
|
168
|
+
findings.push(archiveFinding("plugin.submission.archive.app_excluded", "warn", "App configuration requires the MCP-backed submission flow.", "app_configuration_excluded"));
|
|
169
|
+
}
|
|
170
|
+
if (typeof interfaceValue === "object" && interfaceValue !== null && !Array.isArray(interfaceValue)
|
|
171
|
+
&& "screenshots" in interfaceValue && interfaceValue.screenshots !== undefined) {
|
|
172
|
+
findings.push(archiveFinding("plugin.submission.archive.screenshot_excluded", "warn", "Screenshots require the MCP-backed submission flow.", "screenshot_configuration_excluded"));
|
|
173
|
+
}
|
|
174
|
+
return findings;
|
|
175
|
+
}
|
|
176
|
+
async function nestedSubmission(manifest, reader) {
|
|
177
|
+
const targetType = "skills-only";
|
|
178
|
+
const listing = validateSubmissionListing(manifest, targetType);
|
|
179
|
+
const components = [];
|
|
180
|
+
const assets = (await validateSubmissionAssetsFromReader(manifest, reader)).findings;
|
|
181
|
+
const skills = (await validateSubmissionSkillMetadataFromReader(manifest, targetType, reader)).findings;
|
|
182
|
+
const checks = [
|
|
183
|
+
submissionCheck("listing", listing),
|
|
184
|
+
submissionCheck("components", components),
|
|
185
|
+
submissionCheck("assets", assets),
|
|
186
|
+
submissionCheck("skills", skills)
|
|
187
|
+
];
|
|
188
|
+
const findings = [...listing, ...components, ...assets, ...skills];
|
|
189
|
+
const manual = manualChecklist();
|
|
190
|
+
const summary = reportSummary(checks, findings, manual);
|
|
191
|
+
return {
|
|
192
|
+
schemaVersion: "1.0.0",
|
|
193
|
+
rulesetVersion: "openai-directory-2026-08-15",
|
|
194
|
+
targetType,
|
|
195
|
+
status: summary.blockers > 0 ? "fail" : "pass",
|
|
196
|
+
readiness: summary.blockers > 0 ? "blocked" : "manual_review_required",
|
|
197
|
+
summary,
|
|
198
|
+
checks,
|
|
199
|
+
findings,
|
|
200
|
+
manualChecklist: manual
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
export function submissionArchiveExitCode(report, requireReady) {
|
|
204
|
+
return requireReady && report.status === "fail" ? 1 : 0;
|
|
205
|
+
}
|
|
206
|
+
export async function buildSubmissionArchivePreflight(archivePath) {
|
|
207
|
+
const inspection = await inspectSubmissionArchive(archivePath);
|
|
208
|
+
const coverage = [...inspection.coverage, ...archiveCoverage()];
|
|
209
|
+
const manual = manualChecklist();
|
|
210
|
+
const archiveFindings = [...inspection.findings];
|
|
211
|
+
let rootLayout = "unavailable";
|
|
212
|
+
let submission = null;
|
|
213
|
+
if (!archiveFindings.some((finding) => finding.severity === "fail") && inspection.reader !== null) {
|
|
214
|
+
const root = discoverRoot(inspection.entries);
|
|
215
|
+
archiveFindings.push(...root.findings);
|
|
216
|
+
if (root.discovery !== null) {
|
|
217
|
+
rootLayout = root.discovery.rootLayout;
|
|
218
|
+
const reader = rootedReader(inspection, root.discovery.rootPrefix);
|
|
219
|
+
const manifest = reader === null ? null : await parseManifest(reader, root.discovery.manifestPath.slice(root.discovery.rootPrefix.length));
|
|
220
|
+
if (manifest === null || reader === null) {
|
|
221
|
+
archiveFindings.push(archiveFinding("plugin.submission.archive.manifest_missing", "fail", "Plugin manifest must be a bounded UTF-8 JSON object."));
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
const entries = inspection.entries
|
|
225
|
+
.filter((entry) => entry.packagePath.startsWith(root.discovery.rootPrefix))
|
|
226
|
+
.map((entry) => ({ ...entry, packagePath: entry.packagePath.slice(root.discovery.rootPrefix.length) }));
|
|
227
|
+
if (!hasImmediateSkill(entries)) {
|
|
228
|
+
archiveFindings.push(archiveFinding("plugin.submission.archive.skill_missing", "fail", "Archive must contain an immediate skills/<skill>/SKILL.md entrypoint."));
|
|
229
|
+
}
|
|
230
|
+
archiveFindings.push(...exclusionFindings(manifest, entries));
|
|
231
|
+
submission = await nestedSubmission(manifest, reader);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const findings = [...archiveFindings, ...(submission?.findings ?? [])];
|
|
236
|
+
const blockers = findings.filter((finding) => finding.severity === "fail").length;
|
|
237
|
+
const summary = submission === null
|
|
238
|
+
? { passed: 0, warnings: findings.filter((finding) => finding.severity === "warn").length, blockers, manualChecks: manual.filter((item) => item.state === "required").length }
|
|
239
|
+
: { ...submission.summary, warnings: findings.filter((finding) => finding.severity === "warn").length, blockers };
|
|
240
|
+
return {
|
|
241
|
+
schemaVersion: "1.0.0",
|
|
242
|
+
rulesetVersion: submissionArchiveRuleset.version,
|
|
243
|
+
status: blockers > 0 ? "fail" : "pass",
|
|
244
|
+
readiness: blockers > 0 ? "blocked" : "manual_review_required",
|
|
245
|
+
archive: {
|
|
246
|
+
fileName: inspection.fileName,
|
|
247
|
+
compressedBytes: inspection.compressedBytes,
|
|
248
|
+
uncompressedBytes: inspection.uncompressedBytes,
|
|
249
|
+
entryCount: inspection.entryCount,
|
|
250
|
+
rootLayout
|
|
251
|
+
},
|
|
252
|
+
summary,
|
|
253
|
+
archiveChecks: [],
|
|
254
|
+
submission,
|
|
255
|
+
findings,
|
|
256
|
+
coverage,
|
|
257
|
+
manualChecklist: manual
|
|
258
|
+
};
|
|
259
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { SubmissionPackageEntry, SubmissionPackageReader } from "./submission-package-reader.js";
|
|
2
|
+
import type { SubmissionFinding } from "./submission-preflight.js";
|
|
3
|
+
export interface SubmissionArchiveFinding extends SubmissionFinding {
|
|
4
|
+
id: `plugin.submission.archive.${string}`;
|
|
5
|
+
}
|
|
6
|
+
export interface SubmissionArchiveCoverage {
|
|
7
|
+
id: string;
|
|
8
|
+
status: "automatic" | "manual" | "unavailable";
|
|
9
|
+
reason: string;
|
|
10
|
+
}
|
|
11
|
+
export interface SubmissionArchiveInspection {
|
|
12
|
+
fileName: string;
|
|
13
|
+
compressedBytes: number;
|
|
14
|
+
uncompressedBytes: number;
|
|
15
|
+
entryCount: number;
|
|
16
|
+
entries: readonly SubmissionPackageEntry[];
|
|
17
|
+
findings: readonly SubmissionArchiveFinding[];
|
|
18
|
+
coverage: readonly SubmissionArchiveCoverage[];
|
|
19
|
+
reader: SubmissionPackageReader | null;
|
|
20
|
+
}
|
|
21
|
+
export declare function inspectSubmissionArchive(zipPath: string): Promise<SubmissionArchiveInspection>;
|