Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/codex-marketplace-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'agent-bundle': minor
---

Author the Codex marketplace entry (`codex.marketplace` displayName, category, and documented `policy.installation` / `policy.authentication` values, with the category following the plugin interface category; `installation: NOT_AVAILABLE` fails the build with `codex.marketplace.policy.installation.not-installable` because live `codex plugin add` refuses it and the emitted `INSTALL.md` / `installBundle()` run that command), admit every documented marketplace source form (local string or object, Git root `url`, `git-subdir`, `npm`) in the pinned marketplace schema with structurally validated Git and registry URLs, and publish dated four-state Codex distribution rows for marketplace discovery, sources, cache layout, enable state, `codex plugin` / `codex plugin marketplace` JSON contracts, feature flags, managed `requirements.toml`, `allow_managed_hooks_only`, `restrict_to_allowed_sources`, and workspace publishing, backed by live codex-cli 0.147.0 probes.
18 changes: 18 additions & 0 deletions packages/agent-bundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,24 @@ a `matcher` on `UserPromptSubmit` or `Stop`, and a `codex:WebSearch`-style hoste
Hook trust (review by current hash in `/hooks`, plugin hooks skipped until trusted, managed hooks
immutable) is host-owned and is recorded as unavailable rather than claimed.

The Codex artifact is also its own repo marketplace: `.agents/plugins/marketplace.json` carries one
local `./` entry whose `category` follows the plugin's interface category and whose
`policy.installation` / `policy.authentication` default to `AVAILABLE` / `ON_INSTALL`.
`codex.marketplace` authors the picker `displayName`, the `category`, and the documented policy
values, except `installation: NOT_AVAILABLE`, which fails the build
(`codex.marketplace.policy.installation.not-installable`) because live `codex plugin add` refuses
such entries and that is exactly the command the emitted `INSTALL.md` and `installBundle()` run.
The pinned marketplace schema also admits Git root (`url`), `git-subdir`, and `npm` sources for
validating real-world marketplaces (Git and registry URLs must carry a syntactically valid
host (DNS, IPv4, or bracketed IPv6), port, and path rather than a bare scheme prefix, and
npm `version` must be a semver version, range, or dist-tag, and Git `ref` must satisfy
`git check-ref-format`), but the adapter never emits them. Personal and legacy
`.claude-plugin/marketplace.json` discovery, the `~/.codex/plugins/cache` layout, `config.toml`
enable state, `features.plugins` / `features.hooks`, inline `[hooks]` TOML, `requirements.toml`
managed hooks, `allow_managed_hooks_only`, and `restrict_to_allowed_sources` are host- or
admin-owned and are recorded in `codex-0.147.0.json` (`distribution`) with dated evidence instead of
being claimed.

agent-bundle also owns the npm-facing package build: `bin` entries become self-executing
`dist/bin/<name>.js` bundles (shebang, executable bit, generated `main(argv)` envelope) and the
optional `lib` entry becomes `dist/<stem>.js` with declarations (resolving `typescript` from the
Expand Down
149 changes: 149 additions & 0 deletions packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json

Large diffs are not rendered by default.

175 changes: 169 additions & 6 deletions packages/agent-bundle/src/adapters/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,21 @@ export interface CodexAuthorConfig {
readonly url?: string;
}

/** Documented install and authentication policy for the emitted local marketplace entry. */
export interface CodexMarketplacePolicyConfig {
readonly authentication?: 'ON_INSTALL' | 'ON_USE';
readonly installation?: 'AVAILABLE' | 'INSTALLED_BY_DEFAULT' | 'NOT_AVAILABLE';
}

/** Authored fields of the emitted `.agents/plugins/marketplace.json`; the source stays the local plugin root. */
export interface CodexMarketplaceConfig {
/** Marketplace-entry category; defaults to the plugin's interface category. */
readonly category?: string;
/** Marketplace picker title; defaults to the plugin name. */
readonly displayName?: string;
readonly policy?: CodexMarketplacePolicyConfig;
}

/** Codex-only authored package metadata and install-surface config layered onto the generated manifest. */
export interface CodexHostConfig extends AgentBundleHostConfig {
/** Registered MCP connection mappings emitted to the root `.app.json` compatibility document. */
Expand All @@ -100,6 +115,7 @@ export interface CodexHostConfig extends AgentBundleHostConfig {
readonly interface?: CodexInterfaceConfig;
readonly keywords?: readonly string[];
readonly license?: string;
readonly marketplace?: CodexMarketplaceConfig;
readonly repository?: string;
}

Expand Down Expand Up @@ -159,7 +175,7 @@ const hookContract = Object.freeze({
wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Codex'),
} satisfies TargetHookContract);
const metadata = Object.freeze({
adapterRevision: '1.7.0',
adapterRevision: '1.8.0',
observedVersion: capabilityTable.observedCliVersion,
schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion),
});
Expand All @@ -180,6 +196,7 @@ const tableCapability = (row: { readonly reason?: string; readonly state: string
};

const hookContractTable = capabilityTable.hooks.contract;
const distributionTable = capabilityTable.distribution;
const codexReleaseHookEvents: readonly string[] = capabilityTable.hooks.releaseEvents;
const codexHookRules = Object.freeze({
additionalContextEvents: hookContractTable.additionalContextLimit.additionalContextEvents as readonly string[],
Expand Down Expand Up @@ -808,6 +825,125 @@ const planCodexApps = (model: NormalizedPlugin): CodexAppsPlan => {
};
};

const marketplaceTable = capabilityTable.marketplace;
const marketplaceAuthenticationPolicies: readonly string[] = marketplaceTable.policy.authentication;
const marketplaceInstallationPolicies: readonly string[] = marketplaceTable.policy.installation;
const marketplaceNotInstallablePolicy: string = marketplaceTable.policy.notInstallable;
const marketplaceConfigFields = Object.freeze(['category', 'displayName', 'policy']);
const marketplacePolicyFields = Object.freeze(['authentication', 'installation']);

interface CodexMarketplacePlan {
readonly category: string;
readonly diagnostics: readonly Diagnostic[];
readonly displayName: string;
readonly policy: { readonly authentication: string; readonly installation: string };
readonly sourceInputs: readonly string[];
}

const planCodexMarketplace = (model: NormalizedPlugin, interfaceCategory: string): CodexMarketplacePlan => {
const defaults = {
category: interfaceCategory,
diagnostics: [] as readonly Diagnostic[],
displayName: model.metadata.name,
policy: { authentication: 'ON_INSTALL', installation: 'AVAILABLE' },
sourceInputs: [] as readonly string[],
};
const extension = model.extensions[codexName];
const declared = extension !== undefined && isPlainDataRecord(extension.value)
? extension.value['marketplace']
: undefined;
if (declared === undefined || extension === undefined) return defaults;
const inputs = sourceInputs(extension.provenance.sourcePath);
if (!isPlainDataRecord(declared)) {
return {
...defaults,
diagnostics: [errorDiagnostic(
'codex.marketplace.invalid',
'Codex marketplace must be a plain object containing only category, displayName, and policy.',
)],
sourceInputs: inputs,
};
}
const diagnostics: Diagnostic[] = [];
for (const key of Object.keys(declared)) {
if (!marketplaceConfigFields.includes(key)) {
diagnostics.push(errorDiagnostic(
'codex.marketplace.field.unknown',
`Codex marketplace field ${JSON.stringify(key)} is not documented; the emitted local marketplace supports category, displayName, and policy.`,
));
}
}
let category = defaults.category;
let displayName = defaults.displayName;
for (const [field, code] of [['category', 'category'], ['displayName', 'display-name']] as const) {
const authored = declared[field];
if (authored === undefined) continue;
if (!isNonemptyString(authored)) {
diagnostics.push(errorDiagnostic(
`codex.marketplace.${code}.invalid`,
`Codex marketplace ${field} must be a nonempty string.`,
));
} else if (field === 'category') {
category = authored;
} else {
displayName = authored;
}
}
const policy = { ...defaults.policy };
const declaredPolicy = declared['policy'];
if (declaredPolicy !== undefined) {
if (!isPlainDataRecord(declaredPolicy)) {
diagnostics.push(errorDiagnostic(
'codex.marketplace.policy.invalid',
'Codex marketplace policy must be a plain object containing installation and authentication.',
));
} else {
for (const key of Object.keys(declaredPolicy)) {
if (!marketplacePolicyFields.includes(key)) {
diagnostics.push(errorDiagnostic(
'codex.marketplace.policy.field.unknown',
`Codex marketplace policy field ${JSON.stringify(key)} is not documented.`,
));
}
}
const authentication = declaredPolicy['authentication'];
if (authentication !== undefined) {
if (typeof authentication !== 'string' || !marketplaceAuthenticationPolicies.includes(authentication)) {
diagnostics.push(errorDiagnostic(
'codex.marketplace.policy.authentication.invalid',
`Codex marketplace policy.authentication must be one of ${marketplaceAuthenticationPolicies.join(', ')}.`,
));
} else {
policy.authentication = authentication;
}
}
const installation = declaredPolicy['installation'];
if (installation !== undefined) {
if (typeof installation !== 'string' || !marketplaceInstallationPolicies.includes(installation)) {
diagnostics.push(errorDiagnostic(
'codex.marketplace.policy.installation.invalid',
`Codex marketplace policy.installation must be one of ${marketplaceInstallationPolicies.join(', ')}.`,
));
} else if (installation === marketplaceNotInstallablePolicy) {
// The emitted marketplace exists to install this artifact: INSTALL.md and
// installBundle() both run `codex plugin add`, which the host refuses for
// NOT_AVAILABLE entries, so a self-installing bundle cannot honestly carry it.
diagnostics.push(errorDiagnostic(
'codex.marketplace.policy.installation.not-installable',
`Codex marketplace policy.installation ${marketplaceNotInstallablePolicy} makes the emitted bundle refuse its own codex plugin add install path; use ${
marketplaceInstallationPolicies.filter((value) => value !== marketplaceNotInstallablePolicy).join(' or ')
}.`,
));
} else {
policy.installation = installation;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Account for NOT_AVAILABLE in the installation flow

When an author selects the newly accepted NOT_AVAILABLE policy, the marketplace correctly marks this plugin as unavailable for installation, but both the generated instructions (src/install/surface.ts:44-45) and installPublicCli (src/install/install.ts:222-230) still unconditionally run codex plugin add. Thus a valid configuration produces a bundle whose advertised and programmatic installation path is rejected by the host; either reject this policy for self-installing bundles or make those installation surfaces policy-aware.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed with a live probe: codex-cli 0.147.0 accepts codex plugin marketplace add for a NOT_AVAILABLE entry and then refuses codex plugin add demo@demo-market with plugin demo is not available for install in marketplace demo-market — the exact command INSTALL.md and installBundle() run. Fixed in a809d0d: the adapter now rejects an authored installation: NOT_AVAILABLE with codex.marketplace.policy.installation.not-installable (schema still documents the enum value; the capability row records the probe and the rejection), so the self-installing artifact can no longer advertise an install path the host refuses. AVAILABLE and INSTALLED_BY_DEFAULT remain admitted, with tests for both sides.

}
}
}
}
if (diagnostics.length > 0) return { ...defaults, diagnostics, sourceInputs: inputs };
return { category, diagnostics, displayName, policy, sourceInputs: inputs };
};

const hasLeadingPluginRoot = (value: string): boolean =>
value === pathTokens.pluginRoot || value.startsWith(`${pathTokens.pluginRoot}/`);

Expand Down Expand Up @@ -1051,18 +1187,26 @@ export const planCodexArtifacts = (
const pluginValidator = pluginValidatorFor(mcpRelativePath);
diagnostics.push(...schemaDiagnostics('plugin', pluginValidator(plugin), pluginValidator.errors));

const interfaceCategory = interfacePlan.value['category'];
const marketplacePlan = planCodexMarketplace(
model,
typeof interfaceCategory === 'string' ? interfaceCategory : generatedInterface.category,
);
diagnostics.push(...marketplacePlan.diagnostics);
const marketplace = {
interface: { displayName: model.metadata.name },
interface: { displayName: marketplacePlan.displayName },
name: `${model.metadata.name}-marketplace`,
plugins: [{
category: 'Productivity',
category: marketplacePlan.category,
name: model.metadata.name,
policy: { authentication: 'ON_INSTALL', installation: 'AVAILABLE' },
policy: marketplacePlan.policy,
source: { path: './', source: 'local' },
}],
};
const marketplaceValid = validateMarketplace(marketplace);
diagnostics.push(...schemaDiagnostics('marketplace', marketplaceValid, validateMarketplace.errors));
const marketplaceValid = marketplacePlan.diagnostics.length === 0 && validateMarketplace(marketplace);
if (marketplacePlan.diagnostics.length === 0) {
diagnostics.push(...schemaDiagnostics('marketplace', marketplaceValid, validateMarketplace.errors));
}

const basePlan = standardPluginArtifactPlan({
additionalPluginSourceInputs: sourceInputs(
Expand All @@ -1085,6 +1229,7 @@ export const planCodexArtifacts = (
isSelected,
marketplace,
marketplaceRelativePath: codexArtifactPaths.marketplace,
marketplaceSourceInputs: sourceInputs(...marketplacePlan.sourceInputs, ...interfacePlan.sourceInputs),
marketplaceValid,
mcp,
mcpRelativePath,
Expand Down Expand Up @@ -1158,6 +1303,24 @@ export const codexAdapter: TargetAdapter = Object.freeze({
),
install: supportedCapability(evidence),
marketplace: supportedCapability(evidence),
allowManagedHooksOnly: tableCapability(distributionTable.allowManagedHooksOnly),
featureHooks: tableCapability(distributionTable.featureHooks),
featurePlugins: tableCapability(distributionTable.featurePlugins),
inlineHooksToml: tableCapability(distributionTable.inlineHooksToml),
installCacheLayout: tableCapability(distributionTable.installCacheLayout),
legacyClaudeMarketplaceCompatibility: tableCapability(distributionTable.legacyClaudeMarketplaceCompatibility),
managedRequirements: tableCapability(distributionTable.managedRequirements),
marketplaceCategory: tableCapability(distributionTable.marketplaceCategory),
marketplaceCliLifecycle: tableCapability(distributionTable.marketplaceCliLifecycle),
marketplaceInterface: tableCapability(distributionTable.marketplaceInterface),
marketplacePolicy: tableCapability(distributionTable.marketplacePolicy),
marketplaceSources: tableCapability(distributionTable.marketplaceSources),
personalMarketplaceDiscovery: tableCapability(distributionTable.personalMarketplaceDiscovery),
pluginCliLifecycle: tableCapability(distributionTable.pluginCliLifecycle),
pluginEnableState: tableCapability(distributionTable.pluginEnableState),
repoMarketplaceDiscovery: tableCapability(distributionTable.repoMarketplaceDiscovery),
restrictToAllowedSources: tableCapability(distributionTable.restrictToAllowedSources),
workspacePublishing: tableCapability(distributionTable.workspacePublishing),
hooks: supportedCapability(evidence),
hookAdditionalContextLimit: tableCapability(hookContractTable.additionalContextLimit),
hookAsyncCommands: tableCapability(hookContractTable.asyncCommandHooks),
Expand Down
45 changes: 39 additions & 6 deletions packages/agent-bundle/src/adapters/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,26 @@ const mcpPolicyUnifiedReason =
'The MCP approval policy is enforced by the Codex host at install time; the pinned Claude and Cursor contracts publish no shared per-plugin MCP policy surface.';
const hookContractUnifiedReason =
'The unified bundle emits the Codex-only hook handler contract, but the pinned Claude and Cursor hook contracts declare no shared handler-type, timeout, matcher, or trust surface.';
const distributionUnifiedReason =
'The unified bundle emits the Codex-only marketplace and install-policy surface, but the pinned Claude and Cursor contracts declare no shared marketplace source, cache, enable-state, feature-flag, or managed-requirements surface.';
const codexDistributionCapabilities = [
'allowManagedHooksOnly',
'featureHooks',
'featurePlugins',
'inlineHooksToml',
'installCacheLayout',
'legacyClaudeMarketplaceCompatibility',
'managedRequirements',
'marketplaceCategory',
'marketplaceInterface',
'marketplacePolicy',
'marketplaceSources',
'personalMarketplaceDiscovery',
'pluginEnableState',
'repoMarketplaceDiscovery',
'restrictToAllowedSources',
'workspacePublishing',
] as const;
const codexHookContractCapabilities = [
'hookAdditionalContextLimit',
'hookAsyncCommands',
Expand Down Expand Up @@ -208,7 +228,7 @@ const artifactValidation = deepFreeze({
});

const metadata = Object.freeze({
adapterRevision: '1.22.0',
adapterRevision: '1.23.0',
observedVersion: `${claudeAdapter.metadata.observedVersion}+${codexAdapter.metadata.observedVersion}+${cursorAdapter.metadata.observedVersion}`,
// Metadata schemas must exactly match the validation contract: each host's
// documents, with one shared Claude-format hook schema (the pinned Codex
Expand Down Expand Up @@ -601,15 +621,22 @@ const componentCapabilities = Object.freeze(Object.fromEntries(
]),
));

const codexHookContractUnifiedCapabilities = Object.freeze(Object.fromEntries(
codexHookContractCapabilities.map((capability) => [
const codexHookContractUnifiedCapabilities = Object.freeze(Object.fromEntries([
...codexHookContractCapabilities.map((capability) => [
capability,
intersectCapabilityStates(
codexAdapter.capabilities[capability]!,
unavailableCapability(hookContractUnifiedReason),
),
]),
));
...codexDistributionCapabilities.map((capability) => [
capability,
intersectCapabilityStates(
codexAdapter.capabilities[capability]!,
unavailableCapability(distributionUnifiedReason),
),
]),
]));

const agentCapabilities = Object.freeze(Object.fromEntries(
Object.keys(claudeCapabilityTable.plugin.agents).map((rowName) => {
Expand Down Expand Up @@ -764,7 +791,10 @@ export const pluginAdapter: TargetAdapter = Object.freeze({
),
),
marketplaceCliLifecycle: intersectCapabilityStates(
claudeAdapter.capabilities.marketplaceCliLifecycle!,
intersectCapabilityStates(
claudeAdapter.capabilities.marketplaceCliLifecycle!,
codexAdapter.capabilities.marketplaceCliLifecycle!,
),
unavailableCapability(
'The unified bundle emits host marketplace documents but cannot add, list, remove, or update marketplaces as one cross-host lifecycle transaction.',
),
Expand Down Expand Up @@ -855,7 +885,10 @@ export const pluginAdapter: TargetAdapter = Object.freeze({
'The unified bundle emits Claude-only output styles, but the pinned Codex and Cursor contracts declare no shared output styles surface.',
),
pluginCliLifecycle: intersectCapabilityStates(
claudeAdapter.capabilities.pluginCliLifecycle!,
intersectCapabilityStates(
claudeAdapter.capabilities.pluginCliLifecycle!,
codexAdapter.capabilities.pluginCliLifecycle!,
),
unavailableCapability(
'The unified bundle emits host artifacts but cannot run Claude-only plugin creation, installation, state, inspection, update, or release commands.',
),
Expand Down
Loading
Loading