diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 17ba18f..59382f7 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -4,6 +4,18 @@ "displayName": "Paper" }, "plugins": [ + { + "name": "paper", + "source": { + "source": "local", + "path": "./plugins/paper" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Design" + }, { "name": "paper-desktop", "source": { diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bc69c52..a94fa20 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,3 +19,9 @@ jobs: - run: bun run build - run: bun ./scripts/validate-mcpb-artifact.mjs + + # The ZIP uploaded to the OpenAI plugin submission portal + - uses: actions/upload-artifact@v4 + with: + name: paper-chatgpt-plugin + path: dist/paper-chatgpt-plugin.zip diff --git a/README.md b/README.md index d6cea51..f915c8f 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,14 @@ Use Paper with your favorite agent harness. Are we missing one, you have feedback for us, or just want to chat? Join us on our [Discord](https://discord.gg/xqBrwhuh2J) or [Slack](https://paper-community.slack.com/join/shared_invite/zt-430qekrpz-EzbDXbpIHulMPVBB02CwVA) communities. +## ChatGPT and Codex + +The `paper` plugin connects to Paper's hosted MCP server (`https://mcp.paper.design`), so no desktop install is needed. + +- Search for "Paper" in ChatGPT Plugins once it is listed in the directory. +- Before then, add it as a custom MCP server: Plugins > plus button > Add custom MCP server, URL `https://mcp.paper.design`, OAuth authentication. +- Maintainers: `bun run pack:chatgpt` writes `dist/paper-chatgpt-plugin.zip` for the OpenAI plugin submission portal. + ## Cursor ```sh diff --git a/package.json b/package.json index 0cf24c6..7d5169f 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,9 @@ "type": "module", "private": true, "scripts": { - "build": "bun ./scripts/validate-cursor-schema.mjs && bun ./scripts/validate-cursor-structure.mjs && bun ./scripts/validate-codex.mjs && bun ./scripts/validate-mcpb.mjs && bun run pack:mcpb", + "build": "bun ./scripts/validate-cursor-schema.mjs && bun ./scripts/validate-cursor-structure.mjs && bun ./scripts/validate-codex.mjs && bun ./scripts/validate-agent-plugins.mjs && bun ./scripts/validate-mcpb.mjs && bun run pack:mcpb && bun run pack:chatgpt", "pack:mcpb": "bun ./claude-extensions/paper-desktop/scripts/pack.ts", + "pack:chatgpt": "bun ./scripts/pack-chatgpt.ts", "test": "bun run build" }, "devDependencies": { diff --git a/plugins/paper/README.md b/plugins/paper/README.md new file mode 100644 index 0000000..1ae6010 --- /dev/null +++ b/plugins/paper/README.md @@ -0,0 +1,27 @@ +# Paper for ChatGPT and Codex + +Paper connects your designs, agents, code, and data on a single canvas built on web standards. This plugin connects ChatGPT (and Codex) to Paper's hosted MCP server at `https://mcp.paper.design`, so your agent can read and write your design files after you sign in with your Paper account. + +No desktop install is required: everything runs through the remote server. + +**Features** + +- **Read designs**: Inspect artboard structure, screenshots, computed styles, JSX output, and text content. +- **Write to the canvas**: Create artboards, add or replace HTML nodes, update styles, set text, and duplicate elements from a prompt. +- **Export**: Export artboards as images or PDFs. +- **Design systems**: Read and write design tokens. + +**Install** + +- From the Plugin Directory: search for "Paper" in ChatGPT Plugins and install it, then sign in with your Paper account when prompted. +- Manually, before it is listed: in ChatGPT go to Plugins, select the plus button, then Add custom MCP server. Use `https://mcp.paper.design` as the server URL with OAuth authentication. + +**Package layout** + +This folder is a portable [Agent Plugins](https://agent-plugins.org) package: + +- `plugin.json`: identity and listing metadata (OpenAI-specific fields under `extensions.com.openai`). +- `mcp.json`: the remote MCP server, strictly per the Agent Plugins schema. OAuth is not declared here: Codex's parser rejects servers with extra keys, and clients discover the authorization server from the MCP endpoint itself (401 + `resource_metadata`). Pick OAuth in the portal's Connect panel when submitting. +- `assets/`: listing icon. + +Run `bun run pack:chatgpt` from the repository root to produce the ZIP uploaded to the OpenAI plugin submission portal. The ZIP contains only `plugin.json`, `mcp.json`, and the referenced icons; this README is for the repo, not the listing. diff --git a/plugins/paper/assets/logo.png b/plugins/paper/assets/logo.png new file mode 100644 index 0000000..c42fea9 Binary files /dev/null and b/plugins/paper/assets/logo.png differ diff --git a/plugins/paper/mcp.json b/plugins/paper/mcp.json new file mode 100644 index 0000000..bcfe8f9 --- /dev/null +++ b/plugins/paper/mcp.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "paper": { + "type": "streamable-http", + "url": "https://mcp.paper.design" + } + } +} diff --git a/plugins/paper/plugin.json b/plugins/paper/plugin.json new file mode 100644 index 0000000..99a09f9 --- /dev/null +++ b/plugins/paper/plugin.json @@ -0,0 +1,107 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "paper", + "version": "1.0.0", + "description": "Design on a canvas that ChatGPT can read and write to — built on web standards.", + "author": { + "name": "Paper", + "email": "team@paper.design", + "url": "https://paper.design" + }, + "homepage": "https://paper.design", + "repository": "https://github.com/paper-design/agent-plugins", + "license": "MIT", + "keywords": [ + "paper", + "design", + "ui", + "canvas", + "html", + "css", + "design-to-code", + "code-to-design", + "prototyping", + "mcp", + "design-system" + ], + "extensions": { + "com.openai": { + "interface": { + "displayName": "Paper", + "shortDescription": "Design UI on a live canvas", + "longDescription": "Paper is a professional design tool built on HTML and CSS. With this plugin, ChatGPT can read your Paper files — structure, styles, screenshots, and text — and write back to the canvas in real time. Use it to sketch new screens from a prompt, iterate on existing designs, generate UI from a brief or design system, export artboards, and keep design work moving without leaving the conversation.\n\nSign in with your Paper account to connect. ChatGPT can only access files that your account can access.", + "developerName": "Paper", + "category": "Design", + "capabilities": ["Read", "Write"], + "websiteURL": "https://paper.design", + "supportURL": "https://paper.design/support", + "privacyPolicyURL": "https://paper.design/legal/privacy", + "termsOfServiceURL": "https://paper.design/legal/tos", + "defaultPrompt": [ + "Design a settings page for a mobile app in Paper", + "Take a screenshot of my selected artboard in Paper and suggest layout improvements", + "Create a design system sticker sheet in Paper with my brand colors and type scale" + ], + "brandColor": "#81ADEC", + "composerIcon": "./assets/logo.png", + "logo": "./assets/logo.png" + }, + "review": { + "test_cases": { + "positive": [ + { + "description": "Read the structure of a Paper file from its URL", + "prompt": "Open this Paper file and tell me what artboards it has: https://app.paper.design/file/", + "tools_triggered": "open_file, get_basic_info", + "expected_behavior": "Opens the file and lists its pages and artboards with their names and dimensions." + }, + { + "description": "Screenshot an artboard for review", + "prompt": "Take a screenshot of the first artboard in my Paper file and describe what you see.", + "tools_triggered": "get_basic_info, get_screenshot", + "expected_behavior": "Returns a rendered screenshot of the artboard and describes its content." + }, + { + "description": "Create a new design on the canvas", + "prompt": "In my Paper file, create a new 390 by 844 artboard with a simple login screen: logo, email and password fields, and a primary button.", + "tools_triggered": "get_font_family_info, create_artboard, write_html, get_screenshot, finish_working_on_nodes", + "expected_behavior": "A new artboard appears in the file containing the described login screen, and ChatGPT confirms with a screenshot." + }, + { + "description": "Edit text in an existing design", + "prompt": "Change the heading on the login screen in my Paper file to say 'Welcome back'.", + "tools_triggered": "find_nodes, set_text_content, finish_working_on_nodes", + "expected_behavior": "The heading text node is updated to 'Welcome back' and no other content changes." + }, + { + "description": "Inspect styles for handoff", + "prompt": "What are the exact colors, font sizes, and spacing used by the primary button on the login screen in my Paper file?", + "tools_triggered": "find_nodes, get_computed_styles", + "expected_behavior": "Reports the button's computed values (colors, font size, padding, radius) read from the design rather than guessed from a screenshot." + } + ], + "negative": [ + { + "description": "Access to a file the account cannot see", + "prompt": "Open the Paper file at https://app.paper.design/file/ and list its artboards." + }, + { + "description": "Unsupported payment or plan change", + "prompt": "Use Paper to upgrade my team to a paid plan and charge my card." + }, + { + "description": "Request unrelated to design", + "prompt": "Use Paper to book me a flight to Lisbon next Tuesday." + } + ] + }, + "demo_recording_url": "https://paper.design/TODO-demo-recording", + "commerce": false, + "commerce_description": "This plugin does not sell products or process payments." + }, + "publication": { + "release_notes": "Initial release: read and write Paper files from ChatGPT through Paper's remote MCP server." + } + } + } +} diff --git a/schemas/agent-plugins/mcp.schema.json b/schemas/agent-plugins/mcp.schema.json new file mode 100644 index 0000000..a9139a4 --- /dev/null +++ b/schemas/agent-plugins/mcp.schema.json @@ -0,0 +1,120 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "title": "Agent Plugins MCP Configuration", + "description": "Machine-readable schema for mcp.json in Agent Plugins 1.0.0. The Agent Plugins specification defines additional semantic and operational requirements.", + "type": "object", + "properties": { + "$schema": { + "const": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "description": "Canonical identifier of the MCP configuration schema for the Agent Plugins version targeted by this document." + }, + "mcpServers": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/server" + } + } + }, + "required": ["$schema", "mcpServers"], + "additionalProperties": false, + "$defs": { + "server": { + "title": "MCP server", + "oneOf": [ + { + "$ref": "#/$defs/stdioServer" + }, + { + "$ref": "#/$defs/streamableHttpServer" + }, + { + "$ref": "#/$defs/sseServer" + } + ] + }, + "stdioServer": { + "title": "stdio MCP server", + "type": "object", + "properties": { + "type": { + "const": "stdio" + }, + "command": { + "type": "string", + "minLength": 1, + "description": "Executable token. Resolution rules are defined by the Agent Plugins specification." + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "type": "object", + "propertyNames": { + "not": { + "enum": ["PLUGIN_ROOT", "PLUGIN_DATA"] + } + }, + "additionalProperties": { + "type": "string" + } + }, + "cwd": { + "type": "string", + "pattern": "^(?:\\./|\\$\\{PLUGIN_ROOT\\}(?:/|$)|\\$\\{PLUGIN_DATA\\}(?:/|$))", + "description": "Plugin-relative, PLUGIN_ROOT-rooted, or PLUGIN_DATA-rooted working directory. Filesystem containment is validated separately." + } + }, + "required": ["type", "command"], + "additionalProperties": false + }, + "streamableHttpServer": { + "title": "Streamable HTTP MCP server", + "type": "object", + "properties": { + "type": { + "const": "streamable-http" + }, + "url": { + "type": "string", + "minLength": 1, + "description": "MCP endpoint URL. URL semantics are defined by the Agent Plugins specification." + }, + "headers": { + "$ref": "#/$defs/headers" + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + "sseServer": { + "title": "Legacy HTTP+SSE MCP server", + "type": "object", + "properties": { + "type": { + "const": "sse" + }, + "url": { + "type": "string", + "minLength": 1, + "description": "MCP endpoint URL. URL semantics are defined by the Agent Plugins specification." + }, + "headers": { + "$ref": "#/$defs/headers" + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + "headers": { + "title": "HTTP headers", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } +} diff --git a/schemas/agent-plugins/plugin.schema.json b/schemas/agent-plugins/plugin.schema.json new file mode 100644 index 0000000..8fed0e1 --- /dev/null +++ b/schemas/agent-plugins/plugin.schema.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "title": "Agent Plugins Manifest", + "description": "Machine-readable schema for plugin.json in Agent Plugins 1.0.0. The Agent Plugins specification defines additional semantic and operational requirements.", + "type": "object", + "properties": { + "$schema": { + "const": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "description": "Canonical identifier of the plugin manifest schema for the Agent Plugins version targeted by this document." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^(?!.*(?:--|\\.\\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$", + "description": "Human-readable plugin name." + }, + "version": { + "type": "string" + }, + "description": { + "type": "string" + }, + "author": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "additionalProperties": false + }, + "homepage": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "license": { + "type": "string" + }, + "keywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "extensions": { + "type": "object", + "description": "Client-specific manifest data keyed by reverse-domain extension namespace. Agent Plugins assigns no semantics to namespace object contents.", + "additionalProperties": { + "type": "object" + } + } + }, + "required": ["$schema", "name"], + "additionalProperties": false +} diff --git a/scripts/pack-chatgpt.ts b/scripts/pack-chatgpt.ts new file mode 100644 index 0000000..eea13ae --- /dev/null +++ b/scripts/pack-chatgpt.ts @@ -0,0 +1,96 @@ +#!/usr/bin/env bun +/** + * Packs the portable `plugins/paper` package into the ZIP uploaded to the OpenAI plugin + * submission portal (ChatGPT + Codex directory). + * + * Only what the portal reads is staged: plugin.json, mcp.json, the icons plugin.json + * references, and skills/ if the plugin has one (portable packages discover it by path). + * The README is for people browsing the repo; the listing text comes from plugin.json. + * Timestamps and modes are normalized so rebuilds are bit-identical, matching the .mcpb + * pack script. + */ +import { chmod, cp, mkdir, readdir, rm, stat, utimes } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { $ } from "bun"; + +/** DOS-era zip epoch — fixed so rebuilds are bit-identical across runs. */ +const ZIP_EPOCH = new Date(Date.UTC(1980, 0, 1, 0, 0, 0)); +const FILE_MODE = 0o644; +const DIR_MODE = 0o755; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); +const pluginDir = join(repoRoot, "plugins/paper"); +const distDir = join(repoRoot, "dist"); +const stageDir = join(distDir, "stage-chatgpt"); +const outFile = join(distDir, "paper-chatgpt-plugin.zip"); + +type Interface = Record; +type Manifest = { + name: string; + version?: string; + extensions?: { "com.openai"?: { interface?: Interface } }; +}; + +const manifest = (await Bun.file(join(pluginDir, "plugin.json")).json()) as Manifest; +const iface = manifest.extensions?.["com.openai"]?.interface ?? {}; + +// Every icon the listing references must ship in the ZIP +const iconFields = ["logo", "logoDark", "composerIcon", "composerIconDark"]; +const assetPaths = iconFields + .map((field) => iface[field]) + .filter((value): value is string => typeof value === "string"); + +const files = ["plugin.json", "mcp.json", ...assetPaths]; +if (existsSync(join(pluginDir, "skills"))) { + files.push("skills"); +} + +async function normalizeStaging(dir: string) { + await chmod(dir, DIR_MODE); + await utimes(dir, ZIP_EPOCH, ZIP_EPOCH); + for (const name of await readdir(dir)) { + const path = join(dir, name); + if ((await stat(path)).isDirectory()) { + await normalizeStaging(path); + } else { + await chmod(path, FILE_MODE); + await utimes(path, ZIP_EPOCH, ZIP_EPOCH); + } + } +} + +await rm(stageDir, { recursive: true, force: true }); +await rm(outFile, { force: true }); +await mkdir(stageDir, { recursive: true }); + +for (const rel of files) { + if (rel.includes("..") || rel.startsWith("/")) { + console.error(`Refusing to pack unsafe path: ${rel}`); + process.exit(1); + } + const src = join(pluginDir, rel); + const dest = join(stageDir, rel); + await mkdir(dirname(dest), { recursive: true }); + await cp(src, dest, { recursive: true }); +} + +await normalizeStaging(stageDir); + +// TZ=UTC so DOS timestamps in the zip are timezone-independent (CI is UTC). +const result = await $`zip -r -X ${outFile} ${files}` + .cwd(stageDir) + .env({ ...process.env, TZ: "UTC" }) + .nothrow(); + +if (result.exitCode !== 0) { + console.error(result.stderr.toString() || result.stdout.toString()); + process.exit(result.exitCode ?? 1); +} + +await rm(stageDir, { recursive: true, force: true }); + +const listing = await $`unzip -l ${outFile}`.text(); +console.log(listing.trimEnd()); +console.log(`\nWrote ${outFile} (${manifest.name}@${manifest.version ?? "unversioned"})`); diff --git a/scripts/validate-agent-plugins.mjs b/scripts/validate-agent-plugins.mjs new file mode 100644 index 0000000..a2a664c --- /dev/null +++ b/scripts/validate-agent-plugins.mjs @@ -0,0 +1,408 @@ +#!/usr/bin/env node + +/** + * Validates portable Agent Plugins packages (root plugin.json + mcp.json) against the + * published 1.0.0 schemas, then applies the limits OpenAI's plugin submission portal + * enforces on the extensions["com.openai"] listing data + * (https://developers.openai.com/plugins/deploy/submission). + * + * Plugins are discovered from .agents/plugins/marketplace.json; entries without a root + * plugin.json (Codex compatibility layout) are left to validate-codex.mjs. + */ + +import { readFileSync, existsSync, openSync, readSync, closeSync } from "fs"; +import { resolve, dirname, relative, extname } from "path"; +import { fileURLToPath } from "url"; +import Ajv2020 from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, ".."); + +let errors = 0; +let warnings = 0; + +function fail(message) { + console.error(`ERROR: ${message}`); + errors++; +} + +function warn(message) { + console.warn(`WARN: ${message}`); + warnings++; +} + +function loadJSON(path, context) { + let raw; + try { + raw = readFileSync(path, "utf-8"); + } catch { + fail(`${context}: file not found at ${relative(root, path)}`); + return null; + } + try { + return JSON.parse(raw); + } catch (err) { + fail(`${context}: invalid JSON — ${err.message}`); + return null; + } +} + +// Verbatim snapshots of https://agent-plugins.org/schemas/1.0.0/{plugin,mcp}.schema.json, the version +// our manifests' $schema names (and the only one Codex accepts). Re-download them if that URL ever moves. +const ajv = new Ajv2020({ allErrors: true, strict: false }); +addFormats(ajv); +const validatePluginSchema = ajv.compile( + loadJSON(resolve(root, "schemas/agent-plugins/plugin.schema.json"), "plugin schema") +); +const validateMcpSchema = ajv.compile( + loadJSON(resolve(root, "schemas/agent-plugins/mcp.schema.json"), "mcp schema") +); + +function reportSchemaErrors(label, validate) { + for (const err of validate.errors ?? []) { + const detail = + err.keyword === "additionalProperties" + ? `${err.message}: "${err.params.additionalProperty}"` + : err.message; + console.error(` ${err.instancePath || "/"}: ${detail}`); + } +} + +// --- Submission limits (OpenAI plugin submission field reference) --- + +const LIMITS = { + displayName: 30, + shortDescription: 30, + longDescription: 4000, + developerName: 80, + url: 1024, + defaultPromptCount: 3, + defaultPromptLength: 128, + capabilitiesCount: 20, + capabilityLength: 120, +}; +const REQUIRED_LISTING_URLS = ["websiteURL", "supportURL", "privacyPolicyURL", "termsOfServiceURL"]; +const REQUIRED_POSITIVE_CASES = 5; +const REQUIRED_NEGATIVE_CASES = 3; +const MIN_ICON_PX = 48; +const MAX_ICON_PX = 4096; +const MAX_ICON_BYTES = 5 * 1024 * 1024; +const ICON_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp", ".svg"]); + +function isRelativePluginPath(value) { + if (typeof value !== "string" || !value.startsWith("./")) return false; + const segments = value.slice(2).split("/"); + return segments.every((s) => s.length > 0 && s !== "." && s !== ".."); +} + +function isHttpsUrl(value) { + try { + const url = new URL(value); + return url.protocol === "https:" && url.username === "" && url.password === ""; + } catch { + return false; + } +} + +function checkMaxLength(label, field, value, max) { + if (typeof value !== "string") { + fail(`${label}: ${field} must be a string`); + return; + } + if (value.length > max) { + fail(`${label}: ${field} is ${value.length} characters, submission limit is ${max}`); + } +} + +/** Reads a PNG's IHDR so icon dimensions can be checked without an image library. */ +function readPngSize(path) { + const header = Buffer.alloc(24); + const fd = openSync(path, "r"); + try { + readSync(fd, header, 0, 24, 0); + } finally { + closeSync(fd); + } + const isPng = header.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); + if (!isPng) return null; + return { width: header.readUInt32BE(16), height: header.readUInt32BE(20) }; +} + +function readSvgSize(path) { + const svg = readFileSync(path, "utf-8"); + const viewBox = svg.match(/viewBox\s*=\s*"[^"]*?\s*([\d.]+)\s+([\d.]+)\s*"/i); + if (viewBox) { + return { width: Number(viewBox[1]), height: Number(viewBox[2]) }; + } + const width = svg.match(/]*\swidth\s*=\s*"([\d.]+)(px)?"/i); + const height = svg.match(/]*\sheight\s*=\s*"([\d.]+)(px)?"/i); + if (width && height) { + return { width: Number(width[1]), height: Number(height[1]) }; + } + return null; +} + +function validateIcon(pluginDir, label, field, value) { + if (!isRelativePluginPath(value)) { + fail(`${label}: interface.${field} must be a "./"-relative path inside the plugin — got "${value}"`); + return; + } + const path = resolve(pluginDir, value.slice(2)); + if (!existsSync(path)) { + fail(`${label}: interface.${field} references missing file "${value}"`); + return; + } + const ext = extname(path).toLowerCase(); + if (!ICON_EXTENSIONS.has(ext)) { + fail(`${label}: interface.${field} must be PNG, JPEG, WebP, or SVG — got "${ext}"`); + return; + } + const bytes = readFileSync(path).byteLength; + if (bytes > MAX_ICON_BYTES) { + fail(`${label}: interface.${field} is ${bytes} bytes, limit is ${MAX_ICON_BYTES}`); + } + + let size = null; + if (ext === ".png") size = readPngSize(path); + if (ext === ".svg") size = readSvgSize(path); + if (size === null) { + warn(`${label}: interface.${field} dimensions not checked (unsupported for "${ext}" here)`); + return; + } + if (size.width !== size.height) { + fail(`${label}: interface.${field} must be square — got ${size.width}x${size.height}`); + } + if (size.width < MIN_ICON_PX) { + fail(`${label}: interface.${field} must be at least ${MIN_ICON_PX}px — got ${size.width}px`); + } + if (ext !== ".svg" && size.width > MAX_ICON_PX) { + fail(`${label}: interface.${field} must be at most ${MAX_ICON_PX}px — got ${size.width}px`); + } +} + +function validateInterface(pluginDir, label, iface) { + checkMaxLength(label, "interface.displayName", iface.displayName, LIMITS.displayName); + checkMaxLength(label, "interface.shortDescription", iface.shortDescription, LIMITS.shortDescription); + checkMaxLength(label, "interface.longDescription", iface.longDescription, LIMITS.longDescription); + checkMaxLength(label, "interface.developerName", iface.developerName, LIMITS.developerName); + + if (typeof iface.category !== "string" || iface.category.length === 0) { + fail(`${label}: interface.category is required`); + } + + if (iface.capabilities !== undefined) { + if (!Array.isArray(iface.capabilities)) { + fail(`${label}: interface.capabilities must be an array`); + } else { + if (iface.capabilities.length > LIMITS.capabilitiesCount) { + fail(`${label}: interface.capabilities has ${iface.capabilities.length} entries, limit is ${LIMITS.capabilitiesCount}`); + } + for (const [i, capability] of iface.capabilities.entries()) { + checkMaxLength(label, `interface.capabilities[${i}]`, capability, LIMITS.capabilityLength); + } + } + } + + // All four listing URLs are required for MCP review, and must be HTTPS + for (const field of REQUIRED_LISTING_URLS) { + const value = iface[field]; + if (value === undefined) { + fail(`${label}: interface.${field} is required for MCP review`); + continue; + } + checkMaxLength(label, `interface.${field}`, value, LIMITS.url); + if (!isHttpsUrl(value)) { + fail(`${label}: interface.${field} must be an HTTPS URL without credentials — got "${value}"`); + } else if (/todo/i.test(value)) { + warn(`${label}: interface.${field} is a placeholder ("${value}"); fill it in before submitting`); + } + } + + if (iface.defaultPrompt !== undefined) { + const prompts = Array.isArray(iface.defaultPrompt) ? iface.defaultPrompt : [iface.defaultPrompt]; + if (prompts.length > LIMITS.defaultPromptCount) { + fail(`${label}: interface.defaultPrompt has ${prompts.length} entries, limit is ${LIMITS.defaultPromptCount}`); + } + if (new Set(prompts).size !== prompts.length) { + fail(`${label}: interface.defaultPrompt entries must be unique`); + } + for (const [i, prompt] of prompts.entries()) { + checkMaxLength(label, `interface.defaultPrompt[${i}]`, prompt, LIMITS.defaultPromptLength); + if (typeof prompt === "string" && prompt.includes("@")) { + fail(`${label}: interface.defaultPrompt[${i}] must not contain an @mention`); + } + } + } + + for (const field of ["brandColor", "brandColorDark"]) { + if (iface[field] !== undefined && !/^#[0-9a-fA-F]{6}$/.test(iface[field])) { + fail(`${label}: interface.${field} must be #RRGGBB — got "${iface[field]}"`); + } + } + + // The dashboard requires a primary icon before submission; Codex validation requires both + for (const field of ["logo", "composerIcon"]) { + if (iface[field] === undefined) { + fail(`${label}: interface.${field} is required`); + continue; + } + validateIcon(pluginDir, label, field, iface[field]); + } + for (const field of ["logoDark", "composerIconDark"]) { + if (iface[field] !== undefined) { + validateIcon(pluginDir, label, field, iface[field]); + } + } +} + +function validateReview(label, review) { + const cases = review.test_cases; + if (cases === undefined || cases === null) { + warn(`${label}: review.test_cases not declared; initial MCP review needs them in the portal`); + } else { + const positive = Array.isArray(cases.positive) ? cases.positive : []; + const negative = Array.isArray(cases.negative) ? cases.negative : []; + if (positive.length !== REQUIRED_POSITIVE_CASES) { + warn(`${label}: review.test_cases.positive has ${positive.length} cases; initial MCP review requires exactly ${REQUIRED_POSITIVE_CASES}`); + } + if (negative.length !== REQUIRED_NEGATIVE_CASES) { + warn(`${label}: review.test_cases.negative has ${negative.length} cases; initial MCP review requires exactly ${REQUIRED_NEGATIVE_CASES}`); + } + for (const [i, testCase] of positive.entries()) { + for (const field of ["description", "prompt", "tools_triggered", "expected_behavior"]) { + if (typeof testCase[field] !== "string" || testCase[field].length === 0) { + fail(`${label}: review.test_cases.positive[${i}].${field} is required`); + } + } + } + for (const [i, testCase] of negative.entries()) { + for (const field of ["description", "prompt"]) { + if (typeof testCase[field] !== "string" || testCase[field].length === 0) { + fail(`${label}: review.test_cases.negative[${i}].${field} is required`); + } + } + } + } + + if (review.demo_recording_url === undefined) { + warn(`${label}: review.demo_recording_url not declared; required for MCP review`); + } else if (!isHttpsUrl(review.demo_recording_url)) { + fail(`${label}: review.demo_recording_url must be an HTTPS URL`); + } else if (/todo/i.test(review.demo_recording_url)) { + warn(`${label}: review.demo_recording_url is a placeholder; fill it in before submitting`); + } + + // Credentials and reviewer notes are entered in the portal, never shipped in the package + for (const field of ["test_credentials", "reviewer_instructions"]) { + if (review[field] !== undefined) { + fail(`${label}: review.${field} is rejected by the package upload; enter it in the portal instead`); + } + } +} + +function validateMcpConfig(pluginDir, label) { + const mcpPath = resolve(pluginDir, "mcp.json"); + if (!existsSync(mcpPath)) return; + const mcp = loadJSON(mcpPath, `${label} mcp.json`); + if (!mcp) return; + + // Strict schema validation on purpose. OpenAI's docs show auth settings under a server's + // extensions["com.openai"], but Codex's Agent Plugins parser deserializes servers with + // deny_unknown_fields and drops any server carrying extra keys, so a Codex install would + // end up with no Paper tools. OAuth is discovered from the server at connect time instead + // (401 + resource_metadata), and the submission portal exposes the auth method itself. + if (!validateMcpSchema(mcp)) { + fail(`${label}: mcp.json failed schema validation:`); + reportSchemaErrors(label, validateMcpSchema); + } + + const servers = mcp.mcpServers ?? {}; + const serverNames = Object.keys(servers); + if (serverNames.length !== 1) { + warn(`${label}: mcp.json declares ${serverNames.length} servers; the portal connects one per plugin and plugin-level review cases require exactly one`); + } + + for (const [name, server] of Object.entries(servers)) { + const serverLabel = `${label} server "${name}"`; + + if (server.type !== "streamable-http") { + fail(`${serverLabel}: public submission requires a remote "streamable-http" server — got "${server.type}"`); + } + if (typeof server.url === "string" && !isHttpsUrl(server.url)) { + fail(`${serverLabel}: url must be a public HTTPS endpoint — got "${server.url}"`); + } + // Also what a fixed credential would look like, and credentials never belong in the package + if (server.headers !== undefined && Object.keys(server.headers).length > 0) { + fail(`${serverLabel}: public submission rejects nonempty headers`); + } + } +} + +function validatePortablePlugin(pluginDir, marketplaceName) { + const label = `Plugin "${marketplaceName}"`; + const manifest = loadJSON(resolve(pluginDir, "plugin.json"), `${label} plugin.json`); + if (!manifest) return; + + if (!validatePluginSchema(manifest)) { + fail(`${label}: plugin.json failed schema validation:`); + reportSchemaErrors(label, validatePluginSchema); + } + + if (manifest.name !== marketplaceName) { + fail(`${label}: marketplace name does not match plugin.json name "${manifest.name}"`); + } + if (typeof manifest.version !== "string") { + warn(`${label}: version is optional in the schema but submissions and updates need an explicit semantic version`); + } + + const openai = manifest.extensions?.["com.openai"]; + if (openai === undefined) { + warn(`${label}: no extensions["com.openai"]; listing metadata will be derived from root fields`); + } else { + // App references and lifecycle hooks make a ZIP ineligible for public submission + for (const field of ["apps", "hooks"]) { + if (openai[field] !== undefined) { + fail(`${label}: extensions["com.openai"].${field} is not accepted for public directory submission`); + } + } + if (openai.interface === undefined) { + fail(`${label}: extensions["com.openai"].interface is required for submission`); + } else { + validateInterface(pluginDir, label, openai.interface); + } + if (openai.review !== undefined) { + validateReview(label, openai.review); + } else { + warn(`${label}: no review block; test cases and demo recording must be entered in the portal`); + } + } + + validateMcpConfig(pluginDir, label); +} + +// --- Main --- + +const marketplace = loadJSON(resolve(root, ".agents/plugins/marketplace.json"), "Marketplace"); +let validated = 0; +for (const entry of marketplace?.plugins ?? []) { + const sourcePath = typeof entry.source === "string" ? entry.source : entry.source?.path; + if (typeof sourcePath !== "string" || !sourcePath.startsWith("./")) continue; + const pluginDir = resolve(root, sourcePath.slice(2)); + if (!existsSync(resolve(pluginDir, "plugin.json"))) continue; + validatePortablePlugin(pluginDir, entry.name); + validated++; +} + +if (validated === 0) { + warn("No portable Agent Plugins packages found in .agents/plugins/marketplace.json"); +} + +if (errors > 0) { + console.error(`\nAgent Plugins validation failed with ${errors} error(s).`); + process.exit(1); +} else if (warnings > 0) { + console.log(`Agent Plugins validation passed with ${warnings} warning(s).`); +} else { + console.log("Agent Plugins validation passed."); +} diff --git a/scripts/validate-codex.mjs b/scripts/validate-codex.mjs index ff3cba7..99d0437 100644 --- a/scripts/validate-codex.mjs +++ b/scripts/validate-codex.mjs @@ -152,8 +152,18 @@ const MAX_DEFAULT_PROMPT_COUNT = 3; const MAX_DEFAULT_PROMPT_LENGTH = 128; function validatePluginManifest(pluginDir, marketplaceName) { - const manifestPath = resolve(pluginDir, ".codex-plugin", "plugin.json"); const label = `Plugin "${marketplaceName}"`; + + // Portable Agent Plugins packages keep plugin.json at the root, with the OpenAI + // overlay (interface, skills, ...) under extensions["com.openai"]. Codex reads + // that in place of .codex-plugin/plugin.json when present. + const portableManifestPath = resolve(pluginDir, "plugin.json"); + if (existsSync(portableManifestPath)) { + validatePortablePluginManifest(pluginDir, label, marketplaceName); + return; + } + + const manifestPath = resolve(pluginDir, ".codex-plugin", "plugin.json"); const manifest = loadJSON(manifestPath, `${label} manifest`); if (!manifest) return; @@ -195,6 +205,46 @@ function validatePluginManifest(pluginDir, marketplaceName) { validateSkills(pluginDir, label, manifest.skills); } +function validatePortablePluginManifest(pluginDir, label, marketplaceName) { + const manifest = loadJSON(resolve(pluginDir, "plugin.json"), `${label} manifest`); + if (!manifest) return; + + if (manifest.name !== marketplaceName) { + fail( + `${label}: marketplace name does not match plugin.json name "${manifest.name}"` + ); + } + + // Portable packages discover components from fixed paths, so the package must + // ship at least one of them to do anything. + const hasMcpConfig = existsSync(resolve(pluginDir, "mcp.json")); + const hasSkills = existsSync(resolve(pluginDir, "skills")); + if (!hasMcpConfig && !hasSkills) { + fail(`${label}: portable package has neither mcp.json nor a skills/ directory`); + } + + const openai = manifest.extensions?.["com.openai"]; + if (openai === undefined) { + warn(`${label}: no extensions["com.openai"] overlay; Codex will derive listing text from root metadata`); + return; + } + + // Portable packages must not redeclare the fixed component paths in the overlay + for (const field of ["skills", "mcpServers"]) { + if (openai[field] !== undefined) { + fail( + `${label}: extensions["com.openai"].${field} is ignored for portable packages; use the root skills/ or mcp.json instead` + ); + } + } + + if (openai.interface) { + validatePluginInterface(pluginDir, label, openai.interface); + } + + validateSkills(pluginDir, label, undefined); +} + function validatePluginInterface(pluginDir, label, iface) { // Validate asset paths for (const field of ["composerIcon", "logo"]) {