Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 19 additions & 9 deletions CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Each app's source of truth is something other than a stable schema URL:
| `cursor` | Glean-authored schemas in `gleanwork/cursor-plugins/schemas/` | **No upstream.** The schema `$id` (`https://cursor.com/schemas/cursor-plugin/...`) 500s; no Cursor-published schema found. |
| `antigravity` | Antigravity CLI plugin docs (`plugin.json`, optional `mcp_config.json`) | **No.** Defined by product docs and observed CLI layout, not a published schema. |
| `copilot` | [`github/copilot-plugins`](https://github.com/github/copilot-plugins) — a Claude-marketplace-derived format | **Structural.** Copilot shares the Claude marketplace base but extends entries (`skills[]`, `mcpServers` as a path), which `claude plugin validate` rejects — so conformance is asserted structurally against the official format. |
| `codex` | [OpenAI Codex CLI plugin docs](https://developers.openai.com/codex/plugins/build) (`.codex-plugin/plugin.json` + `.agents/plugins/marketplace.json`) | **No published schema.** Defined by product docs; conformance is asserted structurally against the documented format (retrieved 2026-06-17). |
| `codex` | [OpenAI Codex CLI plugin docs](https://developers.openai.com/codex/plugins/build) (`.codex-plugin/plugin.json` + `.agents/plugins/marketplace.json`) | **No published schema.** Defined by product docs; conformance is asserted structurally against the documented format (retrieved 2026-07-26). |

## Oracles the harness uses

Expand Down Expand Up @@ -48,14 +48,24 @@ against a temp fixture via [`bintastic`](https://github.com/scalvert/bintastic).
`tests/core.test.ts` (required `plugin.json` fields present; optional
`mcp_config.json` written when MCP servers are present). Antigravity CLI does
not expose a published schema to validate against.
- **codex** — asserted structurally in `tests/conformance.test.ts` against the
[documented Codex plugin format](https://developers.openai.com/codex/plugins/build):
a repo-scoped `.agents/plugins/marketplace.json` (`{ name, interface, plugins }`)
plus a per-plugin `.codex-plugin/plugin.json` (`{ name, version, description,
skills }`) and optional `.mcp.json`. No published JSON Schema exists; the test
pins the documented shape and confirms a per-plugin `entry` passthrough lands in
the marketplace entry. Codex shares no marketplace path with the other targets,
so it needs no separate output root.
- **codex** — asserted structurally in `tests/conformance.test.ts` and
`tests/core.test.ts` against the
[documented Codex plugin format](https://developers.openai.com/codex/plugins/build)
(re-verified 2026-07-26 via direct fetch, twice, for consistency): a
repo-scoped `.agents/plugins/marketplace.json` (`{ name, interface, plugins }`,
no `owner` field) plus a per-plugin `.codex-plugin/plugin.json` where only
`name` is required — `version`/`description`/`skills`/`hooks`/`mcpServers` are
optional pointers to bundled components. Every marketplace entry must carry
`policy.installation`, `policy.authentication`, and `category`; pluginpack has
no way to infer these, so the base entry stays guess-free and `validateOutput`
errors clearly if an author never supplies them via the per-plugin `entry`
passthrough. An entry's `source` is a bare string only for local plugins (the
only shape pluginpack itself ever emits); a `url`/`git-subdir`/`npm` source
added via `entry` is a structured object with an inner `source` discriminator
(e.g. `{ source: "git-subdir", url, path, ref }`), validated by shape rather
than requiring a local directory to exist. No published JSON Schema exists.
Codex shares no marketplace path with the other targets, so it needs no
separate output root.

## Update-check hook facts

Expand Down
6 changes: 5 additions & 1 deletion src/targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,11 @@ export async function emitCopilot(
return artifact(target, outDir, files);
}

/** Emits the Codex target's plugins and marketplace manifest. */
/**
* @deprecated Legacy emitter, superseded by `src/targets/codex.ts` via the
* registry in `src/targets/registry.ts`. Kept only until every target has
* migrated (see `src/adapters.ts`).
*/
export async function emitCodex(
project: ResolvedProject,
target: TargetName,
Expand Down
308 changes: 308 additions & 0 deletions src/targets/codex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
import path from "node:path";
import { isSafeRelativePath, toPosix } from "../fs.js";
import { stripUndefined } from "./shared.js";
import {
error,
pathExistsSync,
readJson,
validateFrontmatter,
validateHooksShape,
validateMarketplaceBasics,
validateReferencedManifestPaths,
} from "./validation-shared.js";
import type { ValidationIssue } from "../types.js";
import type { PluginTargetDefinition } from "./types.js";

const pluginNamePattern = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/;
const sourceKinds = new Set(["local", "url", "git-subdir", "npm"]);

/**
* Resolves a marketplace entry's local plugin directory, or `null` when the
* entry points somewhere pluginpack's own output doesn't contain (a remote
* `url`/`git-subdir`/`npm` source, added via the `entry` passthrough for a
* plugin this build doesn't itself emit). `null` means "nothing local left
* to validate," not "invalid" — shape validation already happened in
* `validateCodexEntry`.
*/
function resolveLocalPluginDir(root: string, source: unknown): string | null {
if (typeof source === "string") {
return path.join(root, source);
}
if (
source &&
typeof source === "object" &&
(source as Record<string, unknown>).source === "local" &&
typeof (source as Record<string, unknown>).path === "string"
) {
return path.join(root, (source as Record<string, unknown>).path as string);
}
return null;
}

/**
* Validates one Codex marketplace entry. `source` may be a bare string (a
* local relative path — the only shape pluginpack itself ever emits) or a
* structured object with an inner `source` discriminator (`"local"`,
* `"url"`, `"git-subdir"`, `"npm"`) for entries an author adds via the
* `entry` passthrough to describe a plugin hosted elsewhere. Every entry,
* regardless of source shape, must carry `policy.installation`,
* `policy.authentication`, and `category` — see `citations`.
*/
function validateCodexEntry(
entry: Record<string, unknown>,
index: number,
root: string,
issues: ValidationIssue[],
): string | null {
if (!entry || typeof entry !== "object") {
error(issues, `plugins[${index}] must be an object.`);
return null;
}
if (typeof entry.name !== "string" || !pluginNamePattern.test(entry.name)) {
error(
issues,
`plugins[${index}].name must be lowercase and use only alphanumerics, hyphens, and periods.`,
);
return null;
}
const { name } = entry;
if (typeof entry.source === "string") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: validateCodexEntry does not return null when entry.source is invalid (unsafe path, missing local directory, or malformed structured source), so validateOutput still attempts to resolve and read the plugin manifest using the invalid source, unlike the legacy validatePluginEntry and shared validateBareStringSourceEntry helpers which bail out on source errors.

Suggested fix: Update validateCodexEntry to mirror validateBareStringSourceEntry and the legacy validatePluginEntry contract by returning null immediately after any source-related error (unsafe relative path, missing local directory, invalid source.source discriminator, missing required fields for local/url/git-subdir/npm), so that callers like codex.validateOutput skip further filesystem validation for that entry.

🔧 Tag @ glean-for-engineering to fix or click here to fix in Glean

💬 Help us improve! Was this comment helpful? React with 👍 or 👎

if (!isSafeRelativePath(entry.source)) {
error(issues, `${name}: source must be a safe relative path.`);
} else if (
!entry.source.startsWith("http") &&
!pathExistsSync(path.join(root, entry.source))
) {
error(issues, `${name}: source directory is missing: ${entry.source}`);
}
} else if (entry.source && typeof entry.source === "object") {
const source = entry.source as Record<string, unknown>;
if (typeof source.source !== "string" || !sourceKinds.has(source.source)) {
error(
issues,
`${name}: source.source must be one of ${[...sourceKinds].join(", ")}.`,
);
} else if (source.source === "local" && typeof source.path !== "string") {
error(issues, `${name}: a "local" source requires a "path".`);
} else if (
(source.source === "url" || source.source === "git-subdir") &&
typeof source.url !== "string"
) {
error(issues, `${name}: a "${source.source}" source requires a "url".`);
} else if (source.source === "npm" && typeof source.package !== "string") {
error(issues, `${name}: an "npm" source requires a "package".`);
}
} else {
error(issues, `${name}: source must be a string or a structured object.`);
}
const policy = entry.policy as Record<string, unknown> | undefined;
if (!policy || typeof policy.installation !== "string") {
error(
issues,
`${name}: entry is missing required field "policy.installation".`,
);
}
if (!policy || typeof policy.authentication !== "string") {
error(
issues,
`${name}: entry is missing required field "policy.authentication".`,
);
}
if (typeof entry.category !== "string" || !entry.category) {
error(issues, `${name}: entry is missing required field "category".`);
}
return name;
}

/** OpenAI Codex CLI plugin target — see `citations` for source facts. */
export const codex: PluginTargetDefinition = {
name: "codex",

defaultComponents: ["skills", "hooks", "scripts", "assets"],

resolvePluginPath: (pluginName, pluginConfig, targetConfig) =>
pluginConfig.path ??
toPosix(path.join(targetConfig.pluginRoot ?? "plugins", pluginName)),

buildPluginManifest: ({
metadata,
version,
pluginName,
pluginConfig,
componentDirs,
mcpServers,
}) => {
const manifest: Record<string, unknown> = {
name: pluginName,
version: pluginConfig.version ?? version,
description: pluginConfig.description ?? metadata?.description,
author: metadata?.author,
homepage: metadata?.homepage,
repository: metadata?.repository,
license: metadata?.license,
keywords: metadata?.keywords,
};
if (componentDirs.has("skills")) {
manifest.skills = "./skills/";
}
if (componentDirs.has("hooks")) {
manifest.hooks = "./hooks/hooks.json";
}
if (mcpServers) {
manifest.mcpServers = "./.mcp.json";
}
return stripUndefined(manifest);
},
manifestPaths: (pluginPath) => [
path.join(pluginPath, ".codex-plugin", "plugin.json"),
],

// Author-supplied `policy`/`category` land here via the per-plugin `entry`
// passthrough (see engine.ts's deepMerge) — pluginpack has no way to infer
// installation/authentication policy on its own, so the base entry stays
// guess-free and validateOutput errors clearly if they're never supplied.
buildMarketplaceEntry: ({ pluginName, pluginPath, pluginConfig, manifest }) =>
stripUndefined({
name: pluginName,
source: `./${pluginPath}`,
description:
pluginConfig.description ??
(manifest?.description as string | undefined),
version:
pluginConfig.version ?? (manifest?.version as string | undefined),
}),

buildMarketplaceManifest: ({ project, plugins }) =>
stripUndefined({
name: project.config.name,
interface: {
displayName:
project.config.metadata?.displayName ?? project.config.name,
},
plugins,
}),
marketplacePaths: () => [path.join(".agents", "plugins", "marketplace.json")],

mcpConfigPath: (pluginPath) => path.join(pluginPath, ".mcp.json"),
hooksPath: (pluginPath) => path.join(pluginPath, "hooks", "hooks.json"),

validateManifest: (manifest, pluginName, issues) => {
if (typeof manifest.name !== "string" || !manifest.name) {
error(
issues,
`${pluginName}: plugin.json is missing required field "name".`,
);
}
},
validateMarketplaceEntry: validateCodexEntry,

validateOutput: async (root, issues) => {
const marketplacePath = path.join(
root,
".agents",
"plugins",
"marketplace.json",
);
const marketplace = await readJson(
marketplacePath,
"Marketplace manifest",
issues,
);
if (!marketplace) {
return;
}
validateMarketplaceBasics(marketplace, issues);
const plugins = Array.isArray(marketplace.plugins)
? marketplace.plugins
: [];
if (plugins.length === 0) {
error(issues, 'Marketplace "plugins" must be a non-empty array.');
return;
}
for (const [index, entry] of plugins.entries()) {
const pluginName = codex.validateMarketplaceEntry(
entry,
index,
root,
issues,
);
if (!pluginName) {
continue;
}
const pluginDir = resolveLocalPluginDir(root, entry.source);
if (!pluginDir) {
continue;
}
const manifest = await readJson(
path.join(pluginDir, ".codex-plugin", "plugin.json"),
`${pluginName} plugin manifest`,
issues,
);
if (!manifest) {
continue;
}
if (manifest.name !== pluginName) {
error(
issues,
`${pluginName}: marketplace entry name does not match plugin.json name ("${manifest.name}").`,
);
}
codex.validateManifest(manifest, pluginName, issues);
await validateReferencedManifestPaths(
pluginDir,
pluginName,
manifest,
["skills", "hooks", "mcpServers"],
issues,
);
await validateHooksShape(
pluginDir,
pluginName,
"hooks/hooks.json",
issues,
);
await validateFrontmatter(pluginDir, pluginName, "codex", issues);
}
},

installSnippet: {
userConfigurable: true,
build: ({ repository }) => ({
kind: "command",
snippet: `codex plugin marketplace add ${repository}`,
note: "Installs the marketplace; individual plugins are then installed from Codex's plugin picker.",
}),
citation: {
claim: "codex plugin marketplace add syntax",
documentationUrl: "https://learn.chatgpt.com/codex/developer-commands",
verifiedAt: "2026-07-25",
},
},

citations: [
{
claim:
'plugin.json requires only "name"; version/description/author etc. are optional',
documentationUrl: "https://developers.openai.com/codex/plugins/build",
verifiedAt: "2026-07-26",
},
{
claim:
"marketplace entries require policy.installation, policy.authentication, and category",
documentationUrl: "https://developers.openai.com/codex/plugins/build",
verifiedAt: "2026-07-26",
},
{
claim:
'a marketplace entry\'s source is a bare string only for local plugins; url/git-subdir/npm sources are structured objects with an inner "source" discriminator',
documentationUrl: "https://developers.openai.com/codex/plugins/build",
verifiedAt: "2026-07-26",
},
{
claim:
"marketplace.json's top level is { name, interface, plugins }, with no owner field",
documentationUrl: "https://developers.openai.com/codex/plugins/build",
verifiedAt: "2026-07-26",
},
],
};
2 changes: 2 additions & 0 deletions src/targets/registry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { antigravity } from "./antigravity.js";
import { claude } from "./claude.js";
import { codex } from "./codex.js";
import { copilot } from "./copilot.js";
import { cursor } from "./cursor.js";
import type { TargetName } from "../types.js";
Expand All @@ -17,4 +18,5 @@ export const targets: Partial<Record<TargetName, PluginTargetDefinition>> = {
antigravity,
cursor,
claude,
codex,
};
7 changes: 6 additions & 1 deletion src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,12 @@ export async function validateClaude(
}
}

/** Validates a built Codex target's output directory. */
/**
* @deprecated Legacy validator, superseded by `src/targets/codex.ts`'s
* `PluginTargetDefinition.validateOutput` via the registry in
* `src/targets/registry.ts`. Kept only until every target has migrated (see
* `src/adapters.ts`).
*/
export async function validateCodex(
root: string,
issues: ValidationIssue[],
Expand Down
Loading