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
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { lstat, open, readdir, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const invalidPackagePathMessage = "Invalid package path.";
|
|
4
|
+
const invalidMaxBytesMessage = "maxBytes must be a nonnegative safe integer.";
|
|
5
|
+
function packageEntryKind(stats) {
|
|
6
|
+
if (stats.isFile()) {
|
|
7
|
+
return "file";
|
|
8
|
+
}
|
|
9
|
+
if (stats.isDirectory()) {
|
|
10
|
+
return "directory";
|
|
11
|
+
}
|
|
12
|
+
if (stats.isSymbolicLink()) {
|
|
13
|
+
return "symlink";
|
|
14
|
+
}
|
|
15
|
+
return "other";
|
|
16
|
+
}
|
|
17
|
+
function resolvedEntryKind(stats) {
|
|
18
|
+
const kind = packageEntryKind(stats);
|
|
19
|
+
return kind === "symlink" ? "other" : kind;
|
|
20
|
+
}
|
|
21
|
+
function isWithinRoot(rootPath, candidatePath) {
|
|
22
|
+
const relativePath = path.relative(rootPath, candidatePath);
|
|
23
|
+
return relativePath === "" || (relativePath !== ".."
|
|
24
|
+
&& !relativePath.startsWith(`..${path.sep}`)
|
|
25
|
+
&& !path.isAbsolute(relativePath));
|
|
26
|
+
}
|
|
27
|
+
function normalizePackagePath(packagePath, options) {
|
|
28
|
+
if (typeof packagePath !== "string") {
|
|
29
|
+
throw new Error(invalidPackagePathMessage);
|
|
30
|
+
}
|
|
31
|
+
if (packagePath === "") {
|
|
32
|
+
if (options.allowRoot) {
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
throw new Error(invalidPackagePathMessage);
|
|
36
|
+
}
|
|
37
|
+
if (/[\u0000-\u001F\u007F]/u.test(packagePath)
|
|
38
|
+
|| packagePath.startsWith("/")
|
|
39
|
+
|| packagePath.startsWith("\\")
|
|
40
|
+
|| /^[a-zA-Z]:/.test(packagePath)
|
|
41
|
+
|| packagePath.includes("\\")) {
|
|
42
|
+
throw new Error(invalidPackagePathMessage);
|
|
43
|
+
}
|
|
44
|
+
const pathWithoutTrailingSlash = options.allowTrailingSlash && packagePath.endsWith("/")
|
|
45
|
+
? packagePath.slice(0, -1)
|
|
46
|
+
: packagePath;
|
|
47
|
+
const segments = pathWithoutTrailingSlash.split("/");
|
|
48
|
+
if (pathWithoutTrailingSlash === ""
|
|
49
|
+
|| segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
|
|
50
|
+
throw new Error(invalidPackagePathMessage);
|
|
51
|
+
}
|
|
52
|
+
return segments.join("/");
|
|
53
|
+
}
|
|
54
|
+
function nativePathFor(rootPath, packagePath) {
|
|
55
|
+
const candidatePath = path.resolve(rootPath, ...packagePath.split("/"));
|
|
56
|
+
return isWithinRoot(rootPath, candidatePath) ? candidatePath : null;
|
|
57
|
+
}
|
|
58
|
+
function validMaxBytes(maxBytes) {
|
|
59
|
+
return Number.isSafeInteger(maxBytes) && maxBytes >= 0;
|
|
60
|
+
}
|
|
61
|
+
export function createDirectorySubmissionPackageReader(rootPath) {
|
|
62
|
+
const nativeRootPath = path.resolve(rootPath);
|
|
63
|
+
async function canonicalRootPath() {
|
|
64
|
+
try {
|
|
65
|
+
return await realpath(nativeRootPath);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function ancestorsStayWithinRoot(packagePath, rootCanonicalPath) {
|
|
72
|
+
const segments = packagePath.split("/");
|
|
73
|
+
let ancestorPath = nativeRootPath;
|
|
74
|
+
for (const segment of segments.slice(0, -1)) {
|
|
75
|
+
ancestorPath = path.join(ancestorPath, segment);
|
|
76
|
+
try {
|
|
77
|
+
const canonicalAncestorPath = await realpath(ancestorPath);
|
|
78
|
+
if (!isWithinRoot(rootCanonicalPath, canonicalAncestorPath)) {
|
|
79
|
+
return "outside";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return "unavailable";
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return "safe";
|
|
87
|
+
}
|
|
88
|
+
async function entryFor(packagePath) {
|
|
89
|
+
const candidatePath = nativePathFor(nativeRootPath, packagePath);
|
|
90
|
+
if (candidatePath === null) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const rootCanonicalPath = await canonicalRootPath();
|
|
95
|
+
if (rootCanonicalPath === null) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const ancestorResolution = await ancestorsStayWithinRoot(packagePath, rootCanonicalPath);
|
|
99
|
+
if (ancestorResolution === "outside") {
|
|
100
|
+
return {
|
|
101
|
+
packagePath,
|
|
102
|
+
kind: "other",
|
|
103
|
+
resolvedKind: null,
|
|
104
|
+
size: 0,
|
|
105
|
+
safeResolution: "outside"
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (ancestorResolution === "unavailable") {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
const entryStats = await lstat(candidatePath);
|
|
112
|
+
const kind = packageEntryKind(entryStats);
|
|
113
|
+
try {
|
|
114
|
+
const canonicalCandidatePath = await realpath(candidatePath);
|
|
115
|
+
const safeResolution = isWithinRoot(rootCanonicalPath, canonicalCandidatePath);
|
|
116
|
+
if (!safeResolution) {
|
|
117
|
+
return {
|
|
118
|
+
packagePath,
|
|
119
|
+
kind,
|
|
120
|
+
resolvedKind: null,
|
|
121
|
+
size: entryStats.size,
|
|
122
|
+
safeResolution: "outside"
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const targetStats = await stat(candidatePath);
|
|
126
|
+
const resolvedPackagePath = path.relative(rootCanonicalPath, canonicalCandidatePath).split(path.sep).join("/");
|
|
127
|
+
return {
|
|
128
|
+
packagePath,
|
|
129
|
+
...(resolvedPackagePath === packagePath ? {} : { resolvedPackagePath }),
|
|
130
|
+
kind,
|
|
131
|
+
resolvedKind: resolvedEntryKind(targetStats),
|
|
132
|
+
size: targetStats.size,
|
|
133
|
+
safeResolution: "safe"
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return kind === "symlink"
|
|
138
|
+
? {
|
|
139
|
+
packagePath,
|
|
140
|
+
kind,
|
|
141
|
+
resolvedKind: null,
|
|
142
|
+
size: entryStats.size,
|
|
143
|
+
safeResolution: "unavailable"
|
|
144
|
+
}
|
|
145
|
+
: null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
async list(directory) {
|
|
154
|
+
const normalizedDirectory = normalizePackagePath(directory, {
|
|
155
|
+
allowRoot: true,
|
|
156
|
+
allowTrailingSlash: true
|
|
157
|
+
});
|
|
158
|
+
const directoryPath = normalizedDirectory === ""
|
|
159
|
+
? nativeRootPath
|
|
160
|
+
: nativePathFor(nativeRootPath, normalizedDirectory);
|
|
161
|
+
if (directoryPath === null) {
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
if (normalizedDirectory !== "") {
|
|
166
|
+
const directoryEntry = await entryFor(normalizedDirectory);
|
|
167
|
+
if (directoryEntry?.resolvedKind !== "directory" || directoryEntry.safeResolution !== "safe") {
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
else if (await canonicalRootPath() === null) {
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
const childNames = await readdir(directoryPath);
|
|
175
|
+
const entries = new Array(childNames.length).fill(null);
|
|
176
|
+
let nextChildIndex = 0;
|
|
177
|
+
async function readNextChild() {
|
|
178
|
+
while (nextChildIndex < childNames.length) {
|
|
179
|
+
const childIndex = nextChildIndex;
|
|
180
|
+
nextChildIndex += 1;
|
|
181
|
+
const childName = childNames[childIndex];
|
|
182
|
+
const childPackagePath = normalizedDirectory === ""
|
|
183
|
+
? childName
|
|
184
|
+
: `${normalizedDirectory}/${childName}`;
|
|
185
|
+
try {
|
|
186
|
+
const normalizedChildPath = normalizePackagePath(childPackagePath, {
|
|
187
|
+
allowRoot: false,
|
|
188
|
+
allowTrailingSlash: false
|
|
189
|
+
});
|
|
190
|
+
entries[childIndex] = await entryFor(normalizedChildPath);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
entries[childIndex] = null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const workerCount = Math.min(16, childNames.length);
|
|
198
|
+
const workers = Array.from({ length: workerCount }, async () => readNextChild());
|
|
199
|
+
await Promise.all(workers);
|
|
200
|
+
return entries
|
|
201
|
+
.filter((entry) => entry !== null)
|
|
202
|
+
.sort((left, right) => left.packagePath.localeCompare(right.packagePath));
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return [];
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
async stat(packagePath) {
|
|
209
|
+
return entryFor(normalizePackagePath(packagePath, {
|
|
210
|
+
allowRoot: false,
|
|
211
|
+
allowTrailingSlash: false
|
|
212
|
+
}));
|
|
213
|
+
},
|
|
214
|
+
async read(packagePath, maxBytes) {
|
|
215
|
+
const normalizedPackagePath = normalizePackagePath(packagePath, {
|
|
216
|
+
allowRoot: false,
|
|
217
|
+
allowTrailingSlash: false
|
|
218
|
+
});
|
|
219
|
+
if (!validMaxBytes(maxBytes)) {
|
|
220
|
+
throw new Error(invalidMaxBytesMessage);
|
|
221
|
+
}
|
|
222
|
+
const entry = await entryFor(normalizedPackagePath);
|
|
223
|
+
if (entry?.resolvedKind !== "file" || entry.safeResolution !== "safe" || entry.size > maxBytes) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
const candidatePath = nativePathFor(nativeRootPath, normalizedPackagePath);
|
|
227
|
+
if (candidatePath === null) {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
let fileHandle = null;
|
|
231
|
+
try {
|
|
232
|
+
fileHandle = await open(candidatePath, "r");
|
|
233
|
+
const handleStats = await fileHandle.stat();
|
|
234
|
+
if (!handleStats.isFile()
|
|
235
|
+
|| !Number.isSafeInteger(handleStats.size)
|
|
236
|
+
|| handleStats.size < 0
|
|
237
|
+
|| handleStats.size > maxBytes) {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
const rootCanonicalPath = await canonicalRootPath();
|
|
241
|
+
const canonicalCandidatePath = await realpath(candidatePath);
|
|
242
|
+
const pathStats = await stat(candidatePath);
|
|
243
|
+
if (rootCanonicalPath === null
|
|
244
|
+
|| !isWithinRoot(rootCanonicalPath, canonicalCandidatePath)
|
|
245
|
+
|| !pathStats.isFile()
|
|
246
|
+
|| handleStats.dev !== pathStats.dev
|
|
247
|
+
|| handleStats.ino !== pathStats.ino) {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
const content = Buffer.alloc(handleStats.size);
|
|
251
|
+
let offset = 0;
|
|
252
|
+
while (offset < content.length) {
|
|
253
|
+
const { bytesRead } = await fileHandle.read(content, offset, content.length - offset, offset);
|
|
254
|
+
if (bytesRead === 0) {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
offset += bytesRead;
|
|
258
|
+
}
|
|
259
|
+
const probe = Buffer.alloc(1);
|
|
260
|
+
const { bytesRead: extraBytesRead } = await fileHandle.read(probe, 0, 1, offset);
|
|
261
|
+
if (extraBytesRead !== 0) {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
return Uint8Array.from(content);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
await fileHandle?.close().catch(() => undefined);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface SubmissionFinding {
|
|
2
|
+
id: `plugin.submission.${string}`;
|
|
3
|
+
severity: "warn" | "fail";
|
|
4
|
+
message: string;
|
|
5
|
+
portalCode?: string;
|
|
6
|
+
evidence?: Record<string, string | number | boolean | null>;
|
|
7
|
+
}
|
|
8
|
+
export interface SubmissionCheck {
|
|
9
|
+
id: "listing" | "components" | "assets" | "skills";
|
|
10
|
+
status: "pass" | "warn" | "fail";
|
|
11
|
+
findingIds: string[];
|
|
12
|
+
}
|
|
13
|
+
export interface SubmissionManualCheck {
|
|
14
|
+
id: string;
|
|
15
|
+
label: string;
|
|
16
|
+
state: "required" | "not_applicable";
|
|
17
|
+
}
|
|
18
|
+
export interface SubmissionPreflightReport {
|
|
19
|
+
schemaVersion: "1.0.0";
|
|
20
|
+
rulesetVersion: "openai-directory-2026-08-15";
|
|
21
|
+
targetType: "skills-only" | "mcp-backed";
|
|
22
|
+
status: "pass" | "fail";
|
|
23
|
+
readiness: "blocked" | "manual_review_required";
|
|
24
|
+
summary: {
|
|
25
|
+
passed: number;
|
|
26
|
+
warnings: number;
|
|
27
|
+
blockers: number;
|
|
28
|
+
manualChecks: number;
|
|
29
|
+
};
|
|
30
|
+
checks: SubmissionCheck[];
|
|
31
|
+
findings: SubmissionFinding[];
|
|
32
|
+
manualChecklist: SubmissionManualCheck[];
|
|
33
|
+
}
|
|
34
|
+
export type SubmissionTargetType = SubmissionPreflightReport["targetType"];
|
|
35
|
+
export declare function validateSubmissionListing(manifest: Record<string, unknown>, targetType: SubmissionTargetType): SubmissionFinding[];
|
|
36
|
+
export declare function buildSubmissionPreflight(targetPath: string): Promise<SubmissionPreflightReport>;
|
|
@@ -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
|
+
export function validateSubmissionListing(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 = validateSubmissionListing(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
|
+
}>)[];
|