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.
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.58.0
484
- with:
485
- version: "1.58.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
 
@@ -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",
@@ -17,6 +17,9 @@ const topLevelCommands = [
17
17
  "explain",
18
18
  "config"
19
19
  ];
20
+ const doctorCommands = ["submission"];
21
+ const submissionFlags = ["--json", "--markdown", "--output", "--require-ready"];
22
+ const fishSubmissionCondition = "__fish_seen_subcommand_from doctor; and __fish_seen_subcommand_from submission";
20
23
  function bashCompletion() {
21
24
  return [
22
25
  "_codex_plugin_doctor() {",
@@ -26,16 +29,26 @@ function bashCompletion() {
26
29
  " prev=\"${COMP_WORDS[COMP_CWORD-1]}\"",
27
30
  "",
28
31
  ` local commands="${topLevelCommands.join(" ")}"`,
32
+ ` local doctor_commands="${doctorCommands.join(" ")}"`,
33
+ ` local submission_flags="${submissionFlags.join(" ")}"`,
29
34
  "",
30
35
  " case \"${prev}\" in",
31
36
  " codex-plugin-doctor)",
32
37
  " COMPREPLY=( $(compgen -W \"${commands}\" -- \"${cur}\") )",
33
38
  " return 0",
34
39
  " ;;",
40
+ " doctor)",
41
+ " COMPREPLY=( $(compgen -W \"${doctor_commands}\" -- \"${cur}\") )",
42
+ " return 0",
43
+ " ;;",
35
44
  " esac",
36
45
  "",
37
46
  " case \"${cur}\" in",
38
47
  " --*)",
48
+ " if [[ ${COMP_WORDS[1]} == \"doctor\" && ${COMP_WORDS[2]} == \"submission\" ]]; then",
49
+ " COMPREPLY=( $(compgen -W \"${submission_flags}\" -- \"${cur}\") )",
50
+ " return 0",
51
+ " fi",
39
52
  " local flags=\"--json --output --runtime --policy --help\"",
40
53
  " COMPREPLY=( $(compgen -W \"${flags}\" -- \"${cur}\") )",
41
54
  " return 0",
@@ -60,9 +73,20 @@ function zshCompletion() {
60
73
  " typeset -A opt_args",
61
74
  "",
62
75
  ` local commands=(${topLevelCommands.join(" ")})`,
76
+ ` local doctor_commands=(${doctorCommands.join(" ")})`,
77
+ "",
78
+ " if [[ \"$words[2]\" == \"doctor\" && \"$words[3]\" == \"submission\" ]]; then",
79
+ " _arguments -C \\",
80
+ " '*--json[Output as JSON]' \\",
81
+ " '*--markdown[Output as Markdown]' \\",
82
+ " '*--output[Write to file]:file:_files' \\",
83
+ " '*--require-ready[Fail when automatic checks are blocked]'",
84
+ " return",
85
+ " fi",
63
86
  "",
64
87
  " _arguments -C \\",
65
88
  " '1:command:(${commands})' \\",
89
+ " '2:doctor command:(${doctor_commands})' \\",
66
90
  " '*--json[Output as JSON]' \\",
67
91
  " '*--output[Write to file]:file:_files' \\",
68
92
  " '*--runtime[Enable runtime probes]' \\",
@@ -80,6 +104,11 @@ function fishCompletion() {
80
104
  "complete -c codex-plugin-doctor -s h -l help -d 'Show help'",
81
105
  "complete -c codex-plugin-doctor -l json -d 'Output as JSON'",
82
106
  "complete -c codex-plugin-doctor -l output -d 'Write to file' -r",
107
+ `complete -c codex-plugin-doctor -n "${fishSubmissionCondition}" -l json -d 'Output as JSON'`,
108
+ `complete -c codex-plugin-doctor -n "${fishSubmissionCondition}" -l markdown -d 'Output as Markdown'`,
109
+ `complete -c codex-plugin-doctor -n "${fishSubmissionCondition}" -l output -d 'Write to file' -r`,
110
+ `complete -c codex-plugin-doctor -n "${fishSubmissionCondition}" -l require-ready -d 'Fail when automatic checks are blocked'`,
111
+ `complete -c codex-plugin-doctor -n "__fish_seen_subcommand_from doctor; and not __fish_seen_subcommand_from ${doctorCommands.join(" ")}" -a "${doctorCommands.join(" ")}"`,
83
112
  "complete -c codex-plugin-doctor -l runtime -d 'Enable runtime probes'",
84
113
  "complete -c codex-plugin-doctor -l policy -d 'Apply policy' -x -a 'codex-publish mcp-strict security'",
85
114
  ""
@@ -0,0 +1,6 @@
1
+ import type { DiscoveredPackage } from "../domain/types.js";
2
+ import type { SubmissionFinding } from "./submission-preflight.js";
3
+ export interface SubmissionAssetResult {
4
+ findings: SubmissionFinding[];
5
+ }
6
+ export declare function validateSubmissionAssets(discoveredPackage: DiscoveredPackage): Promise<SubmissionAssetResult>;
@@ -0,0 +1,430 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { inflateSync } from "node:zlib";
4
+ import { XMLParser, XMLValidator } from "fast-xml-parser";
5
+ import { resolveSafePackagePath } from "./plugin-components.js";
6
+ const maxAssetBytes = 5 * 1024 * 1024;
7
+ const MAX_DECODED_PNG_BYTES = 72 * 1024 * 1024;
8
+ const minimumDimension = 48;
9
+ const maximumDimension = 4096;
10
+ const assetFields = ["logo", "composerIcon"];
11
+ const extensions = new Map([[".png", "png"], [".jpg", "jpeg"], [".jpeg", "jpeg"], [".webp", "webp"], [".svg", "svg"]]);
12
+ const sofMarkers = new Set([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]);
13
+ const numeric = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
14
+ function finding(id, message, evidence) {
15
+ return { id, severity: "fail", message, evidence };
16
+ }
17
+ function assetEvidence(field, packagePath, format, dimensions) {
18
+ return {
19
+ field,
20
+ ...(packagePath === undefined ? {} : { path: packagePath }),
21
+ ...(format === undefined ? {} : { format }),
22
+ ...(dimensions === undefined ? {} : dimensions)
23
+ };
24
+ }
25
+ function crc32(buffer) {
26
+ let crc = 0xffffffff;
27
+ for (const byte of buffer) {
28
+ crc ^= byte;
29
+ for (let bit = 0; bit < 8; bit += 1) {
30
+ crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
31
+ }
32
+ }
33
+ return (crc ^ 0xffffffff) >>> 0;
34
+ }
35
+ function pngScanlines(width, height, bitDepth, channels, interlace) {
36
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1
37
+ || width > maximumDimension || height > maximumDimension) {
38
+ return null;
39
+ }
40
+ const bytesPerRow = (pixels) => Math.ceil((pixels * bitDepth * channels) / 8);
41
+ if (interlace === 0)
42
+ return Array.from({ length: height }, () => bytesPerRow(width));
43
+ if (interlace !== 1)
44
+ return null;
45
+ const passes = [[0, 0, 8, 8], [4, 0, 8, 8], [0, 4, 4, 8], [2, 0, 4, 4], [0, 2, 2, 4], [1, 0, 2, 2], [0, 1, 1, 2]];
46
+ const rows = [];
47
+ for (const [left, top, horizontal, vertical] of passes) {
48
+ const passWidth = width <= left ? 0 : Math.ceil((width - left) / horizontal);
49
+ const passHeight = height <= top ? 0 : Math.ceil((height - top) / vertical);
50
+ for (let row = 0; row < passHeight; row += 1)
51
+ rows.push(bytesPerRow(passWidth));
52
+ }
53
+ return rows;
54
+ }
55
+ function validPngEncoding(bitDepth, colorType, compression, filter, interlace) {
56
+ const supportedBitDepths = {
57
+ 0: [1, 2, 4, 8, 16], 2: [8, 16], 3: [1, 2, 4, 8], 4: [8, 16], 6: [8, 16]
58
+ };
59
+ return compression === 0 && filter === 0 && (interlace === 0 || interlace === 1)
60
+ && supportedBitDepths[colorType]?.includes(bitDepth) === true;
61
+ }
62
+ function readPng(buffer) {
63
+ if (buffer.length < 33 || ![137, 80, 78, 71, 13, 10, 26, 10].every((byte, index) => buffer[index] === byte)) {
64
+ return null;
65
+ }
66
+ const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
67
+ let dimensions = null;
68
+ let scanlines = null;
69
+ const idatParts = [];
70
+ let idatBytes = 0;
71
+ let offset = 8;
72
+ for (let chunks = 0; chunks < 128 && offset + 12 <= buffer.length; chunks += 1) {
73
+ const length = view.getUint32(offset);
74
+ const typeOffset = offset + 4;
75
+ const dataOffset = offset + 8;
76
+ if (length > buffer.length - dataOffset - 4)
77
+ return null;
78
+ const dataEnd = dataOffset + length;
79
+ if (crc32(buffer.slice(typeOffset, dataEnd)) !== view.getUint32(dataEnd))
80
+ return null;
81
+ const type = fourCc(buffer, typeOffset);
82
+ if (offset === 8) {
83
+ if (type !== "IHDR" || length !== 13)
84
+ return null;
85
+ dimensions = { width: view.getUint32(dataOffset), height: view.getUint32(dataOffset + 4) };
86
+ const bitDepth = buffer[dataOffset + 8];
87
+ const colorType = buffer[dataOffset + 9];
88
+ if (!validPngEncoding(bitDepth, colorType, buffer[dataOffset + 10], buffer[dataOffset + 11], buffer[dataOffset + 12]))
89
+ return null;
90
+ const channels = colorType === 0 || colorType === 3 ? 1 : colorType === 2 ? 3 : colorType === 4 ? 2 : 4;
91
+ scanlines = pngScanlines(dimensions.width, dimensions.height, bitDepth, channels, buffer[dataOffset + 12]);
92
+ if (scanlines === null)
93
+ return null;
94
+ }
95
+ else if (type === "IDAT") {
96
+ idatParts.push(buffer.slice(dataOffset, dataEnd));
97
+ idatBytes += length;
98
+ }
99
+ else if (type === "IEND") {
100
+ if (length !== 0 || dimensions === null || scanlines === null || idatBytes === 0 || dataEnd + 4 !== buffer.length)
101
+ return null;
102
+ const expectedBytes = scanlines.reduce((total, rowBytes) => total + rowBytes + 1, 0);
103
+ if (expectedBytes > MAX_DECODED_PNG_BYTES)
104
+ return null;
105
+ const idat = new Uint8Array(idatBytes);
106
+ let idatOffset = 0;
107
+ for (const part of idatParts) {
108
+ idat.set(part, idatOffset);
109
+ idatOffset += part.length;
110
+ }
111
+ try {
112
+ const output = inflateSync(idat, { maxOutputLength: expectedBytes });
113
+ if (output.length !== expectedBytes)
114
+ return null;
115
+ let outputOffset = 0;
116
+ for (const rowBytes of scanlines) {
117
+ if (output[outputOffset] > 4)
118
+ return null;
119
+ outputOffset += rowBytes + 1;
120
+ }
121
+ return outputOffset === output.length ? dimensions : null;
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ }
127
+ offset = dataEnd + 4;
128
+ }
129
+ return null;
130
+ }
131
+ function readJpeg(buffer) {
132
+ if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) {
133
+ return null;
134
+ }
135
+ const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
136
+ let dimensions = null;
137
+ let scans = 0;
138
+ let offset = 2;
139
+ for (let steps = 0; steps < 512 && offset < buffer.length; steps += 1) {
140
+ if (buffer[offset] !== 0xff)
141
+ return null;
142
+ while (offset < buffer.length && buffer[offset] === 0xff) {
143
+ offset += 1;
144
+ }
145
+ if (offset >= buffer.length) {
146
+ return null;
147
+ }
148
+ const marker = buffer[offset++];
149
+ if (marker === 0xd9) {
150
+ return dimensions !== null && scans > 0 && offset === buffer.length ? dimensions : null;
151
+ }
152
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
153
+ continue;
154
+ }
155
+ if (offset + 2 > buffer.length) {
156
+ return null;
157
+ }
158
+ const length = view.getUint16(offset);
159
+ if (length < 2 || offset + length > buffer.length) {
160
+ return null;
161
+ }
162
+ if (sofMarkers.has(marker)) {
163
+ const start = offset + 2;
164
+ if (start + 5 > offset + length) {
165
+ return null;
166
+ }
167
+ dimensions = { height: view.getUint16(start + 1), width: view.getUint16(start + 3) };
168
+ }
169
+ if (marker === 0xda) {
170
+ if (dimensions === null)
171
+ return null;
172
+ const componentCount = buffer[offset + 2];
173
+ if (componentCount < 1 || length !== 6 + (componentCount * 2))
174
+ return null;
175
+ scans += 1;
176
+ offset += length;
177
+ for (let entropySteps = 0; entropySteps < buffer.length && offset < buffer.length; entropySteps += 1) {
178
+ if (buffer[offset] !== 0xff) {
179
+ offset += 1;
180
+ continue;
181
+ }
182
+ if (offset + 1 >= buffer.length)
183
+ return null;
184
+ const entropyMarker = buffer[offset + 1];
185
+ if (entropyMarker === 0x00 || (entropyMarker >= 0xd0 && entropyMarker <= 0xd7)) {
186
+ offset += 2;
187
+ continue;
188
+ }
189
+ break;
190
+ }
191
+ continue;
192
+ }
193
+ offset += length;
194
+ }
195
+ return null;
196
+ }
197
+ function fourCc(buffer, offset) {
198
+ return String.fromCharCode(...buffer.slice(offset, offset + 4));
199
+ }
200
+ function readWebp(buffer) {
201
+ if (buffer.length < 20 || fourCc(buffer, 0) !== "RIFF" || fourCc(buffer, 8) !== "WEBP") {
202
+ return null;
203
+ }
204
+ const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
205
+ const riffSize = view.getUint32(4, true);
206
+ if (riffSize !== buffer.length - 8) {
207
+ return null;
208
+ }
209
+ let dimensions = null;
210
+ let offset = 12;
211
+ for (let chunks = 0; chunks < 128 && offset + 8 <= buffer.length; chunks += 1) {
212
+ const chunkType = fourCc(buffer, offset);
213
+ const chunkLength = view.getUint32(offset + 4, true);
214
+ const payload = offset + 8;
215
+ const paddedLength = chunkLength + (chunkLength % 2);
216
+ if (paddedLength > buffer.length - payload)
217
+ return null;
218
+ let detected = null;
219
+ if (chunkType === "VP8X") {
220
+ if (chunkLength < 10)
221
+ return null;
222
+ detected = {
223
+ width: 1 + buffer[payload + 4] + (buffer[payload + 5] << 8) + (buffer[payload + 6] << 16),
224
+ height: 1 + buffer[payload + 7] + (buffer[payload + 8] << 8) + (buffer[payload + 9] << 16)
225
+ };
226
+ }
227
+ else if (chunkType === "VP8L") {
228
+ if (chunkLength < 5 || buffer[payload] !== 0x2f)
229
+ return null;
230
+ const packed = view.getUint32(payload + 1, true);
231
+ detected = { width: (packed & 0x3fff) + 1, height: ((packed >>> 14) & 0x3fff) + 1 };
232
+ }
233
+ else if (chunkType === "VP8 ") {
234
+ if (chunkLength < 10 || buffer[payload + 3] !== 0x9d || buffer[payload + 4] !== 0x01 || buffer[payload + 5] !== 0x2a)
235
+ return null;
236
+ detected = { width: view.getUint16(payload + 6, true) & 0x3fff, height: view.getUint16(payload + 8, true) & 0x3fff };
237
+ }
238
+ dimensions ??= detected;
239
+ offset = payload + paddedLength;
240
+ }
241
+ return offset === buffer.length ? dimensions : null;
242
+ }
243
+ function rasterAsset(buffer) {
244
+ const candidates = [
245
+ ["png", readPng], ["jpeg", readJpeg], ["webp", readWebp]
246
+ ];
247
+ for (const [format, reader] of candidates) {
248
+ const dimensions = reader(buffer);
249
+ if (dimensions !== null)
250
+ return { format, dimensions };
251
+ }
252
+ return null;
253
+ }
254
+ function isRecord(value) {
255
+ return typeof value === "object" && value !== null && !Array.isArray(value);
256
+ }
257
+ function numberFrom(value) {
258
+ if (typeof value !== "string" || !numeric.test(value))
259
+ return null;
260
+ const number = Number(value);
261
+ return Number.isFinite(number) ? number : null;
262
+ }
263
+ function normalizeCssEscapes(source) {
264
+ if (source.length > maxAssetBytes)
265
+ return null;
266
+ let normalized = "";
267
+ for (let index = 0; index < source.length; index += 1) {
268
+ if (source[index] !== "\\") {
269
+ normalized += source[index];
270
+ continue;
271
+ }
272
+ index += 1;
273
+ if (index >= source.length)
274
+ return null;
275
+ if (/[0-9A-Fa-f]/u.test(source[index])) {
276
+ let digits = "";
277
+ while (digits.length < 6 && index < source.length && /[0-9A-Fa-f]/u.test(source[index])) {
278
+ digits += source[index++];
279
+ }
280
+ const codePoint = Number.parseInt(digits, 16);
281
+ if (codePoint === 0 || codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff))
282
+ return null;
283
+ normalized += String.fromCodePoint(codePoint);
284
+ if (source[index] === "\r" && source[index + 1] === "\n")
285
+ index += 1;
286
+ else if (/[ \t\r\n\f]/u.test(source[index] ?? ""))
287
+ index += 1;
288
+ index -= 1;
289
+ continue;
290
+ }
291
+ normalized += source[index];
292
+ }
293
+ return normalized;
294
+ }
295
+ function isSafeCss(source) {
296
+ const css = normalizeCssEscapes(source);
297
+ if (css === null || /@\s*import\b/iu.test(css))
298
+ return false;
299
+ for (const match of css.matchAll(/url\s*\(\s*(?:(["'])(.*?)\1|([^)]*))\s*\)/giu)) {
300
+ if (!/^#[^\s]*$/u.test((match[2] ?? match[3] ?? "").trim()))
301
+ return false;
302
+ }
303
+ return true;
304
+ }
305
+ function inspectSvgValue(value, context = undefined) {
306
+ if (typeof value === "string") {
307
+ if (context === "css")
308
+ return isSafeCss(value);
309
+ return context !== "href" || value.trim() === "" || /^#[^\s]*$/u.test(value.trim());
310
+ }
311
+ if (Array.isArray(value))
312
+ return value.every((item) => inspectSvgValue(item, context));
313
+ if (!isRecord(value))
314
+ return true;
315
+ return Object.entries(value).every(([key, item]) => {
316
+ const childContext = context === "css" || key === "style" || key === "@_style"
317
+ ? "css"
318
+ : key === "@_href" || key === "@_xlink:href" ? "href" : undefined;
319
+ return inspectSvgValue(item, childContext);
320
+ });
321
+ }
322
+ function svgDimensions(content) {
323
+ if (/<!DOCTYPE|<!ENTITY/iu.test(content)) {
324
+ return null;
325
+ }
326
+ if (XMLValidator.validate(content) !== true)
327
+ return null;
328
+ try {
329
+ const parsed = new XMLParser({ ignoreAttributes: false, processEntities: false }).parse(content);
330
+ if (!isRecord(parsed) || !isRecord(parsed.svg))
331
+ return null;
332
+ const root = parsed.svg;
333
+ if (!inspectSvgValue(root))
334
+ return null;
335
+ const viewBox = root["@_viewBox"];
336
+ if (typeof viewBox === "string") {
337
+ const parts = viewBox.trim().split(/[\s,]+/u).map(numberFrom);
338
+ if (parts.length !== 4 || parts.some((part) => part === null))
339
+ return null;
340
+ return { width: parts[2], height: parts[3] };
341
+ }
342
+ const width = numberFrom(root["@_width"]);
343
+ const height = numberFrom(root["@_height"]);
344
+ return width === null || height === null ? null : { width, height };
345
+ }
346
+ catch {
347
+ return null;
348
+ }
349
+ }
350
+ function dimensionFinding(field, packagePath, format, dimensions) {
351
+ const evidence = assetEvidence(field, packagePath, format, dimensions);
352
+ if (!Number.isInteger(dimensions.width) || !Number.isInteger(dimensions.height) || dimensions.width <= 0 || dimensions.height <= 0) {
353
+ return finding("plugin.submission.asset.dimensions", "Asset dimensions must be positive integers.", evidence);
354
+ }
355
+ if (dimensions.width !== dimensions.height) {
356
+ return finding("plugin.submission.asset.not_square", "Asset dimensions must be square.", evidence);
357
+ }
358
+ if (dimensions.width < minimumDimension || dimensions.width > maximumDimension) {
359
+ return finding("plugin.submission.asset.dimensions", "Asset dimensions are outside the allowed range.", { ...evidence, limit: minimumDimension });
360
+ }
361
+ return null;
362
+ }
363
+ async function validateAsset(rootPath, field, value) {
364
+ if (value === undefined) {
365
+ return [finding("plugin.submission.asset.required", "Branding asset is required.", assetEvidence(field))];
366
+ }
367
+ if (typeof value !== "string") {
368
+ return [finding("plugin.submission.asset.invalid_path", "Branding asset path is invalid.", assetEvidence(field))];
369
+ }
370
+ const resolved = await resolveSafePackagePath(rootPath, value);
371
+ if (resolved === null) {
372
+ return [finding("plugin.submission.asset.invalid_path", "Branding asset path is invalid.", assetEvidence(field))];
373
+ }
374
+ const extension = extensions.get(path.extname(resolved.packagePath).toLowerCase());
375
+ if (extension === undefined) {
376
+ return [finding("plugin.submission.asset.unsupported_format", "Branding asset format is unsupported.", assetEvidence(field, resolved.packagePath))];
377
+ }
378
+ let details;
379
+ try {
380
+ details = await stat(resolved.path);
381
+ }
382
+ catch {
383
+ return [finding("plugin.submission.asset.missing", "Branding asset is missing.", assetEvidence(field, resolved.packagePath, extension))];
384
+ }
385
+ if (!details.isFile()) {
386
+ return [finding("plugin.submission.asset.unsupported_format", "Branding asset must be a regular file.", assetEvidence(field, resolved.packagePath, extension))];
387
+ }
388
+ if (details.size > maxAssetBytes) {
389
+ return [finding("plugin.submission.asset.too_large", "Branding asset exceeds the size limit.", { ...assetEvidence(field, resolved.packagePath, extension), limit: maxAssetBytes })];
390
+ }
391
+ let content;
392
+ try {
393
+ content = await readFile(resolved.path);
394
+ }
395
+ catch {
396
+ return [finding("plugin.submission.asset.missing", "Branding asset cannot be read.", assetEvidence(field, resolved.packagePath, extension))];
397
+ }
398
+ if (extension === "svg") {
399
+ let source;
400
+ try {
401
+ source = new TextDecoder("utf-8", { fatal: true }).decode(content);
402
+ }
403
+ catch {
404
+ return [finding("plugin.submission.asset.unsafe_svg", "SVG must be valid UTF-8.", assetEvidence(field, resolved.packagePath, extension))];
405
+ }
406
+ const dimensions = svgDimensions(source);
407
+ if (dimensions === null) {
408
+ return [finding("plugin.submission.asset.unsafe_svg", "SVG is unsafe or lacks valid dimensions.", assetEvidence(field, resolved.packagePath, extension))];
409
+ }
410
+ const invalidDimensions = dimensionFinding(field, resolved.packagePath, extension, dimensions);
411
+ return invalidDimensions === null ? [] : [invalidDimensions];
412
+ }
413
+ const decoded = rasterAsset(content);
414
+ if (decoded === null) {
415
+ return [finding("plugin.submission.asset.decode_failed", "Branding asset could not be decoded.", assetEvidence(field, resolved.packagePath, extension))];
416
+ }
417
+ if (decoded.format !== extension) {
418
+ return [finding("plugin.submission.asset.extension_mismatch", "Branding asset extension does not match its content.", assetEvidence(field, resolved.packagePath, decoded.format, decoded.dimensions))];
419
+ }
420
+ const invalidDimensions = dimensionFinding(field, resolved.packagePath, decoded.format, decoded.dimensions);
421
+ return invalidDimensions === null ? [] : [invalidDimensions];
422
+ }
423
+ export async function validateSubmissionAssets(discoveredPackage) {
424
+ const listing = discoveredPackage.manifest.interface;
425
+ const interfaceValues = isRecord(listing) ? listing : {};
426
+ const findings = [];
427
+ for (const field of assetFields)
428
+ findings.push(...await validateAsset(discoveredPackage.rootPath, field, interfaceValues[field]));
429
+ return { findings };
430
+ }
@@ -0,0 +1,34 @@
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 declare function buildSubmissionPreflight(targetPath: string): Promise<SubmissionPreflightReport>;