codex-plugin-doctor 1.58.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.
@@ -0,0 +1,289 @@
1
+ import { lstat, readFile, realpath, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { validateSubmissionAssets } from "./submission-assets.js";
4
+ import { submissionManualChecks, submissionRuleset } from "./submission-ruleset.js";
5
+ import { validateSubmissionSkillMetadata } from "./submission-skill-metadata.js";
6
+ const packageNamePattern = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
7
+ const semverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
8
+ const singleLineUnsupported = /[\u0000-\u001F\u007F\u2028\u2029\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/u;
9
+ const multilineUnsupported = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u2028\u2029\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/u;
10
+ const knownInterfaceFields = new Set([
11
+ "displayName", "shortDescription", "longDescription", "developerName", "category",
12
+ "capabilities", "websiteURL", "supportURL", "privacyPolicyURL", "termsOfServiceURL",
13
+ "brandColor", "brandColorDark", "defaultPrompt", "composerIcon", "logo", "screenshots"
14
+ ]);
15
+ const maxSubmissionManifestBytes = 1024 * 1024;
16
+ function isRecord(value) {
17
+ return typeof value === "object" && value !== null && !Array.isArray(value);
18
+ }
19
+ function finding(id, severity, message, evidence) {
20
+ return evidence === undefined ? { id, severity, message } : { id, severity, message, evidence };
21
+ }
22
+ function invalidField(field, evidence) {
23
+ return finding(`plugin.submission.interface.${field}`, "fail", "Listing field is invalid.", {
24
+ field,
25
+ ...evidence
26
+ });
27
+ }
28
+ function hasValidText(value, maximum, multiline = false) {
29
+ return typeof value === "string"
30
+ && value.trim().length > 0
31
+ && value.length <= maximum
32
+ && !(multiline ? multilineUnsupported : singleLineUnsupported).test(value);
33
+ }
34
+ function validateRequiredText(source, property, field, limit, multiline, findings) {
35
+ if (!hasValidText(source[property], limit, multiline)) {
36
+ findings.push(invalidField(field, { limit }));
37
+ }
38
+ }
39
+ function validatePackage(manifest, findings) {
40
+ const name = manifest.name;
41
+ if (typeof name !== "string" || name.length > submissionRuleset.limits.packageName || !packageNamePattern.test(name)) {
42
+ findings.push(finding("plugin.submission.package.name", "fail", "Package name is invalid.", {
43
+ field: "name", limit: submissionRuleset.limits.packageName
44
+ }));
45
+ }
46
+ const version = manifest.version;
47
+ if (typeof version !== "string" || version.length > submissionRuleset.limits.version || !semverPattern.test(version)) {
48
+ findings.push(finding("plugin.submission.package.version", "fail", "Package version is invalid.", {
49
+ field: "version", limit: submissionRuleset.limits.version
50
+ }));
51
+ }
52
+ }
53
+ function validateCapabilities(value, findings) {
54
+ if (value === undefined) {
55
+ return;
56
+ }
57
+ if (!Array.isArray(value) || value.length > submissionRuleset.limits.capabilities) {
58
+ findings.push(invalidField("capabilities", { limit: submissionRuleset.limits.capabilities }));
59
+ }
60
+ if (!Array.isArray(value)) {
61
+ return;
62
+ }
63
+ if (value.some((capability) => !hasValidText(capability, submissionRuleset.limits.capability))) {
64
+ findings.push(invalidField("capability", { limit: submissionRuleset.limits.capability }));
65
+ }
66
+ }
67
+ function normalizePrompt(prompt) {
68
+ return prompt.normalize("NFKC").trim().replace(/\s+/gu, " ");
69
+ }
70
+ function validateDefaultPrompt(value, findings) {
71
+ if (value === undefined) {
72
+ return;
73
+ }
74
+ const prompts = typeof value === "string" ? [value] : value;
75
+ if (!Array.isArray(prompts) || prompts.length > submissionRuleset.limits.starterPrompts) {
76
+ findings.push(invalidField("default_prompt", { limit: submissionRuleset.limits.starterPrompts }));
77
+ return;
78
+ }
79
+ const normalized = new Set();
80
+ const invalid = prompts.some((prompt) => {
81
+ if (!hasValidText(prompt, submissionRuleset.limits.starterPrompt) || prompt.includes("@")) {
82
+ return true;
83
+ }
84
+ const normalizedPrompt = normalizePrompt(prompt);
85
+ if (normalized.has(normalizedPrompt)) {
86
+ return true;
87
+ }
88
+ normalized.add(normalizedPrompt);
89
+ return false;
90
+ });
91
+ if (invalid) {
92
+ findings.push(invalidField("default_prompt", { limit: submissionRuleset.limits.starterPrompt }));
93
+ }
94
+ }
95
+ function isValidHttpsUrl(value) {
96
+ if (typeof value !== "string" || value.length === 0 || value.length > submissionRuleset.limits.url
97
+ || value.trim() !== value || singleLineUnsupported.test(value)) {
98
+ return false;
99
+ }
100
+ try {
101
+ const parsed = new URL(value);
102
+ return parsed.protocol === "https:" && parsed.hostname.length > 0
103
+ && parsed.username.length === 0 && parsed.password.length === 0 && parsed.hash.length === 0;
104
+ }
105
+ catch {
106
+ return false;
107
+ }
108
+ }
109
+ function validateListing(manifest, targetType) {
110
+ const findings = [];
111
+ validatePackage(manifest, findings);
112
+ const listing = manifest.interface;
113
+ if (!isRecord(listing)) {
114
+ findings.push(finding("plugin.submission.interface.required", "fail", "Listing interface mapping is required.", { field: "interface" }));
115
+ return findings;
116
+ }
117
+ validateRequiredText(listing, "displayName", "display_name", submissionRuleset.limits.displayName, false, findings);
118
+ validateRequiredText(listing, "shortDescription", "short_description", submissionRuleset.limits.shortDescription, false, findings);
119
+ validateRequiredText(listing, "longDescription", "long_description", submissionRuleset.limits.longDescription, true, findings);
120
+ validateRequiredText(listing, "developerName", "developer_name", submissionRuleset.limits.developerName, false, findings);
121
+ validateRequiredText(listing, "category", "category", 80, false, findings);
122
+ if (typeof listing.category === "string" && !submissionRuleset.categories.includes(listing.category)) {
123
+ findings.push(invalidField("category"));
124
+ }
125
+ validateCapabilities(listing.capabilities, findings);
126
+ validateDefaultPrompt(listing.defaultPrompt, findings);
127
+ if (targetType === "mcp-backed") {
128
+ for (const property of ["websiteURL", "supportURL", "privacyPolicyURL", "termsOfServiceURL"]) {
129
+ if (!isValidHttpsUrl(listing[property])) {
130
+ findings.push(finding("plugin.submission.interface.url", "fail", "Required listing URL is invalid.", {
131
+ field: property, limit: submissionRuleset.limits.url
132
+ }));
133
+ }
134
+ }
135
+ }
136
+ for (const key of Object.keys(listing)) {
137
+ if (!knownInterfaceFields.has(key)) {
138
+ findings.push(finding("plugin.submission.interface.unknown_field", "warn", "Listing interface contains an unsupported field.", { field: key }));
139
+ }
140
+ }
141
+ return findings;
142
+ }
143
+ function isWithin(rootPath, candidatePath) {
144
+ const relative = path.relative(rootPath, candidatePath);
145
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
146
+ }
147
+ async function validateApp(rootPath, declaration) {
148
+ if (declaration === undefined) {
149
+ return [];
150
+ }
151
+ const invalid = () => [finding("plugin.submission.component.app", "fail", "App declaration must reference a contained parseable root file.", { path: ".app.json" })];
152
+ const invalidPath = () => [finding("plugin.submission.app.invalid_path", "fail", "App declaration resolves outside the package.", { path: ".app.json" })];
153
+ if (declaration !== "./.app.json") {
154
+ return invalid();
155
+ }
156
+ const candidatePath = path.resolve(rootPath, ".app.json");
157
+ try {
158
+ const [canonicalRoot, canonicalCandidate, details] = await Promise.all([
159
+ realpath(rootPath),
160
+ realpath(candidatePath),
161
+ stat(candidatePath)
162
+ ]);
163
+ if (!isWithin(canonicalRoot, canonicalCandidate)) {
164
+ return invalidPath();
165
+ }
166
+ if (!details.isFile() || details.size > 5 * 1024 * 1024) {
167
+ return invalid();
168
+ }
169
+ JSON.parse(await readFile(candidatePath, "utf8"));
170
+ return [];
171
+ }
172
+ catch {
173
+ return invalid();
174
+ }
175
+ }
176
+ function checkStatus(findings) {
177
+ if (findings.some((item) => item.severity === "fail")) {
178
+ return "fail";
179
+ }
180
+ return findings.some((item) => item.severity === "warn") ? "warn" : "pass";
181
+ }
182
+ function check(id, findings) {
183
+ return { id, status: checkStatus(findings), findingIds: findings.map((item) => item.id) };
184
+ }
185
+ function checklist(targetType) {
186
+ return submissionManualChecks.map((item) => ({
187
+ id: item.id,
188
+ label: item.label,
189
+ state: item.mcpOnly && targetType === "skills-only" ? "not_applicable" : "required"
190
+ }));
191
+ }
192
+ function invalidPackageReport() {
193
+ const findings = [finding("plugin.submission.package.invalid", "fail", "Plugin manifest is missing or invalid.")];
194
+ const checks = [check("listing", findings), check("components", []), check("assets", []), check("skills", [])];
195
+ return {
196
+ schemaVersion: "1.0.0",
197
+ rulesetVersion: submissionRuleset.version,
198
+ targetType: "skills-only",
199
+ status: "fail",
200
+ readiness: "blocked",
201
+ summary: { passed: 3, warnings: 0, blockers: 1, manualChecks: 3 },
202
+ checks,
203
+ findings,
204
+ manualChecklist: checklist("skills-only")
205
+ };
206
+ }
207
+ function oversizedPackageReport() {
208
+ const findings = [finding("plugin.submission.package.too_large", "fail", "Plugin manifest exceeds the submission preflight size limit.", {
209
+ path: ".codex-plugin/plugin.json", limit: maxSubmissionManifestBytes
210
+ })];
211
+ const checks = [check("listing", findings), check("components", []), check("assets", []), check("skills", [])];
212
+ return {
213
+ schemaVersion: "1.0.0",
214
+ rulesetVersion: submissionRuleset.version,
215
+ targetType: "skills-only",
216
+ status: "fail",
217
+ readiness: "blocked",
218
+ summary: { passed: 3, warnings: 0, blockers: 1, manualChecks: 3 },
219
+ checks,
220
+ findings,
221
+ manualChecklist: checklist("skills-only")
222
+ };
223
+ }
224
+ async function discoverSubmissionPackage(targetPath) {
225
+ const rootPath = path.resolve(targetPath);
226
+ const manifestPath = path.join(rootPath, ".codex-plugin", "plugin.json");
227
+ try {
228
+ const linkDetails = await lstat(manifestPath);
229
+ if (!linkDetails.isFile())
230
+ return null;
231
+ const details = await stat(manifestPath);
232
+ if (details.size > maxSubmissionManifestBytes)
233
+ return "too_large";
234
+ const content = new TextDecoder("utf-8", { fatal: true }).decode(await readFile(manifestPath));
235
+ return { rootPath, manifestPath, manifest: JSON.parse(content) };
236
+ }
237
+ catch {
238
+ return null;
239
+ }
240
+ }
241
+ export async function buildSubmissionPreflight(targetPath) {
242
+ if (typeof targetPath !== "string") {
243
+ return invalidPackageReport();
244
+ }
245
+ const discovered = await discoverSubmissionPackage(targetPath);
246
+ if (discovered === "too_large") {
247
+ return oversizedPackageReport();
248
+ }
249
+ if (!discovered || !isRecord(discovered.manifest)) {
250
+ return invalidPackageReport();
251
+ }
252
+ const manifest = discovered.manifest;
253
+ const targetType = manifest.mcpServers !== undefined || manifest.apps !== undefined
254
+ ? "mcp-backed"
255
+ : "skills-only";
256
+ const listingFindings = validateListing(manifest, targetType);
257
+ const componentFindings = await validateApp(discovered.rootPath, manifest.apps);
258
+ const assetFindings = (await validateSubmissionAssets(discovered)).findings;
259
+ const skillFindings = (await validateSubmissionSkillMetadata(discovered, targetType)).findings;
260
+ const interfaceValue = manifest.interface;
261
+ if (targetType === "skills-only" && isRecord(interfaceValue) && interfaceValue.screenshots !== undefined) {
262
+ componentFindings.push(finding("plugin.submission.component.excluded", "fail", "Screenshots are excluded for skills-only submissions.", { field: "screenshots" }));
263
+ }
264
+ const findings = [...listingFindings, ...componentFindings, ...assetFindings, ...skillFindings];
265
+ const checks = [
266
+ check("listing", listingFindings),
267
+ check("components", componentFindings),
268
+ check("assets", assetFindings),
269
+ check("skills", skillFindings)
270
+ ];
271
+ const blockers = findings.filter((item) => item.severity === "fail").length;
272
+ const manualChecklist = checklist(targetType);
273
+ return {
274
+ schemaVersion: "1.0.0",
275
+ rulesetVersion: submissionRuleset.version,
276
+ targetType,
277
+ status: blockers > 0 ? "fail" : "pass",
278
+ readiness: blockers > 0 ? "blocked" : "manual_review_required",
279
+ summary: {
280
+ passed: checks.filter((item) => item.status === "pass").length,
281
+ warnings: findings.filter((item) => item.severity === "warn").length,
282
+ blockers,
283
+ manualChecks: manualChecklist.filter((item) => item.state === "required").length
284
+ },
285
+ checks,
286
+ findings,
287
+ manualChecklist
288
+ };
289
+ }
@@ -0,0 +1,56 @@
1
+ export declare const submissionRuleset: Readonly<{
2
+ version: "openai-directory-2026-08-15";
3
+ reviewedAt: "2026-08-15";
4
+ sources: readonly string[];
5
+ limits: Readonly<{
6
+ packageName: 64;
7
+ version: 64;
8
+ displayName: 30;
9
+ shortDescription: 30;
10
+ longDescription: 4000;
11
+ developerName: 80;
12
+ capabilities: 20;
13
+ capability: 120;
14
+ starterPrompts: 3;
15
+ starterPrompt: 128;
16
+ url: 1024;
17
+ }>;
18
+ categories: readonly string[];
19
+ }>;
20
+ export declare const submissionManualChecks: readonly (Readonly<{
21
+ id: "developer-business-identity";
22
+ label: "Developer and business identity";
23
+ mcpOnly: false;
24
+ }> | Readonly<{
25
+ id: "attestations";
26
+ label: "Required attestations";
27
+ mcpOnly: false;
28
+ }> | Readonly<{
29
+ id: "skill-safety-scan";
30
+ label: "Skill safety scan";
31
+ mcpOnly: false;
32
+ }> | Readonly<{
33
+ id: "demo-video";
34
+ label: "Demo video";
35
+ mcpOnly: true;
36
+ }> | Readonly<{
37
+ id: "tool-tests";
38
+ label: "Exactly 5 positive and 3 negative tool tests";
39
+ mcpOnly: true;
40
+ }> | Readonly<{
41
+ id: "release-notes";
42
+ label: "Release notes";
43
+ mcpOnly: true;
44
+ }> | Readonly<{
45
+ id: "production-domain-verification";
46
+ label: "Production domain verification and current tool scan";
47
+ mcpOnly: true;
48
+ }> | Readonly<{
49
+ id: "tool-annotations";
50
+ label: "Tool annotations and justifications";
51
+ mcpOnly: true;
52
+ }> | Readonly<{
53
+ id: "oauth-reviewer-credentials";
54
+ label: "OAuth reviewer credentials";
55
+ mcpOnly: true;
56
+ }>)[];
@@ -0,0 +1,38 @@
1
+ export const submissionRuleset = Object.freeze({
2
+ version: "openai-directory-2026-08-15",
3
+ reviewedAt: "2026-08-15",
4
+ sources: Object.freeze([
5
+ "https://developers.openai.com/plugins/build/plugins",
6
+ "https://developers.openai.com/plugins/deploy/app-review",
7
+ "https://developers.openai.com/plugins/deploy/submission-errors"
8
+ ]),
9
+ limits: Object.freeze({
10
+ packageName: 64,
11
+ version: 64,
12
+ displayName: 30,
13
+ shortDescription: 30,
14
+ longDescription: 4000,
15
+ developerName: 80,
16
+ capabilities: 20,
17
+ capability: 120,
18
+ starterPrompts: 3,
19
+ starterPrompt: 128,
20
+ url: 1024
21
+ }),
22
+ categories: Object.freeze([
23
+ "Productivity", "Creativity", "Developer Tools", "Business & Operations",
24
+ "Data & Analytics", "Communication", "Education & Research", "Security",
25
+ "Finance", "Healthcare", "Travel", "Entertainment", "Other"
26
+ ])
27
+ });
28
+ export const submissionManualChecks = Object.freeze([
29
+ Object.freeze({ id: "developer-business-identity", label: "Developer and business identity", mcpOnly: false }),
30
+ Object.freeze({ id: "attestations", label: "Required attestations", mcpOnly: false }),
31
+ Object.freeze({ id: "skill-safety-scan", label: "Skill safety scan", mcpOnly: false }),
32
+ Object.freeze({ id: "demo-video", label: "Demo video", mcpOnly: true }),
33
+ Object.freeze({ id: "tool-tests", label: "Exactly 5 positive and 3 negative tool tests", mcpOnly: true }),
34
+ Object.freeze({ id: "release-notes", label: "Release notes", mcpOnly: true }),
35
+ Object.freeze({ id: "production-domain-verification", label: "Production domain verification and current tool scan", mcpOnly: true }),
36
+ Object.freeze({ id: "tool-annotations", label: "Tool annotations and justifications", mcpOnly: true }),
37
+ Object.freeze({ id: "oauth-reviewer-credentials", label: "OAuth reviewer credentials", mcpOnly: true })
38
+ ]);
@@ -0,0 +1,9 @@
1
+ import type { DiscoveredPackage } from "../domain/types.js";
2
+ import type { SubmissionFinding } from "./submission-preflight.js";
3
+ type TargetType = "skills-only" | "mcp-backed";
4
+ export interface SubmissionSkillMetadataResult {
5
+ findings: SubmissionFinding[];
6
+ skillCount: number;
7
+ }
8
+ export declare function validateSubmissionSkillMetadata(discoveredPackage: DiscoveredPackage, targetType: TargetType): Promise<SubmissionSkillMetadataResult>;
9
+ export {};