diff --git a/.changeset/codex-interface-policy.md b/.changeset/codex-interface-policy.md new file mode 100644 index 000000000..a3a9c2715 --- /dev/null +++ b/.changeset/codex-interface-policy.md @@ -0,0 +1,5 @@ +--- +'agent-bundle': minor +--- + +Support authored Codex interface metadata and registered MCP app mappings, and publish capability evidence for plugin policy and compatibility environment surfaces. diff --git a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json index 5dccec1a8..d98e1d753 100644 --- a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json +++ b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json @@ -48,6 +48,84 @@ }, "observedCliVersion": "0.147.0", "plugin": { + "apps": { + "registeredMcpMappings": { + "evidence": [ + "retrieved 2026-09-02: https://developers.openai.com/plugins/build/plugins documents a root `.app.json` file that maps registered MCP server connections, a manifest `apps` pointer to `./.app.json`, and registration technical IDs beginning with `plugin_asdk_app`." + ], + "state": "supported" + } + }, + "hookEnvironment": { + "claudePluginData": { + "evidence": [ + "retrieved 2026-09-02: https://learn.chatgpt.com/docs/hooks states that Codex exports `CLAUDE_PLUGIN_DATA` to plugin hook commands as a compatibility alias for the plugin writable data directory." + ], + "state": "supported" + }, + "claudePluginRoot": { + "evidence": [ + "retrieved 2026-09-02: https://learn.chatgpt.com/docs/hooks states that Codex exports `CLAUDE_PLUGIN_ROOT` to plugin hook commands as a compatibility alias for the installed plugin root." + ], + "state": "supported" + }, + "pluginData": { + "evidence": [ + "retrieved 2026-09-02: https://learn.chatgpt.com/docs/hooks states that Codex-specific `PLUGIN_DATA` points to the plugin's writable data directory for plugin hook commands." + ], + "state": "supported" + }, + "pluginRoot": { + "evidence": [ + "retrieved 2026-09-02: https://learn.chatgpt.com/docs/hooks states that Codex-specific `PLUGIN_ROOT` points to the installed plugin root for plugin hook commands; the Codex adapter uses `${PLUGIN_ROOT}` for generated hook wrapper commands." + ], + "state": "supported" + } + }, + "interface": { + "assets": { + "evidence": [ + "retrieved 2026-09-02: https://developers.openai.com/plugins/build/plugins documents `composerIcon`, `logo`, and `screenshots` as interface presentation fields, requires plugin-root-relative paths, and specifies PNG files under `./assets/` for screenshots; the pinned 0.147.0 sample spec also documents `logoDark`." + ], + "state": "supported" + }, + "brandColor": { + "evidence": [ + "retrieved 2026-09-02: https://developers.openai.com/plugins/build/plugins documents `interface.brandColor` as the plugin card theme color and shows a six-digit hexadecimal value." + ], + "state": "supported" + }, + "categoryCapabilities": { + "evidence": [ + "retrieved 2026-09-02: https://developers.openai.com/plugins/build/plugins documents `interface.category` as the category bucket and `interface.capabilities` as the implementation-derived capability list; it does not enumerate category values." + ], + "state": "supported" + }, + "descriptions": { + "evidence": [ + "retrieved 2026-09-02: https://developers.openai.com/plugins/build/plugins documents `interface.shortDescription` for compact views and `interface.longDescription` for details screens." + ], + "state": "supported" + }, + "identity": { + "evidence": [ + "retrieved 2026-09-02: https://developers.openai.com/plugins/build/plugins documents `interface.displayName` as the user-facing plugin title and `interface.developerName` as the human-readable publisher." + ], + "state": "supported" + }, + "starterPrompts": { + "evidence": [ + "retrieved 2026-09-02: the pinned Codex 0.147.0 plugin JSON sample spec documents `interface.defaultPrompt` as one to three starter strings and caps each prompt at 128 characters." + ], + "state": "supported" + }, + "urls": { + "evidence": [ + "retrieved 2026-09-02: https://developers.openai.com/plugins/build/plugins documents `websiteURL`, `privacyPolicyURL`, and `termsOfServiceURL` as external interface links." + ], + "state": "supported" + } + }, "manifest": ".codex-plugin/plugin.json", "marketplace": ".agents/plugins/marketplace.json", "manifestPackage": { @@ -119,6 +197,29 @@ "state": "unavailable" } }, + "mcpServerPolicy": { + "approvalModes": { + "evidence": [ + "retrieved 2026-09-02: https://developers.openai.com/plugins/build/plugins documents `default_tools_approval_mode = \"prompt\"` and per-tool `approval_mode = \"approve\"` under the user's `[plugins.\"my-plugin\".mcp_servers.docs]` configuration." + ], + "reason": "Plugin MCP approval modes are host-owned user configuration under `plugins..mcp_servers.`; the compiler emits plugin artifacts and does not write the user's config.toml.", + "state": "unavailable" + }, + "enabled": { + "evidence": [ + "retrieved 2026-09-02: https://developers.openai.com/plugins/build/plugins documents `enabled = true` under `[plugins.\"my-plugin\".mcp_servers.docs]` in the user's Codex configuration." + ], + "reason": "Plugin MCP enabled state is host-owned user configuration under `plugins..mcp_servers.`; the compiler emits plugin artifacts and does not write the user's config.toml.", + "state": "unavailable" + }, + "tools": { + "evidence": [ + "retrieved 2026-09-02: https://developers.openai.com/plugins/build/plugins documents `enabled_tools = [\"search\"]` and a `[plugins.\"my-plugin\".mcp_servers.docs.tools.search]` policy table in the user's Codex configuration." + ], + "reason": "Plugin MCP tool selection is host-owned user configuration under `plugins..mcp_servers.`; the compiler emits plugin artifacts and does not write the user's config.toml.", + "state": "unavailable" + } + }, "skills": true }, "validation": { diff --git a/packages/agent-bundle/src/adapters/codex.ts b/packages/agent-bundle/src/adapters/codex.ts index 18f0dec90..55df42b10 100644 --- a/packages/agent-bundle/src/adapters/codex.ts +++ b/packages/agent-bundle/src/adapters/codex.ts @@ -5,6 +5,7 @@ import type { ValidateFunction } from 'ajv/dist/2020.js'; import { createTargetDiagnostics } from './diagnostics.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { readMcpTransport, unsupportedMcpTransportDiagnostic } from '../core/mcp-transport.ts'; +import { dataArrayValues } from '../core/strict-json.ts'; import { pathTokens, type AgentBundleConfig, @@ -34,6 +35,7 @@ import { validatedNativeHookDocument, type TargetHookContract, } from './hook-contract.ts'; +import appSchema from './schemas/codex/app.schema.json' with { type: 'json' }; import schemaProvenance from './schemas/codex/PROVENANCE.json' with { type: 'json' }; import hooksSchema from './schemas/codex/hooks.schema.json' with { type: 'json' }; import marketplaceSchema from './schemas/codex/marketplace.schema.json' with { type: 'json' }; @@ -43,6 +45,7 @@ import { createAdapterValidator, hasPathToken, schemaDescriptorsFrom, + sourceInputs, standardArtifactLayout, standardPluginArtifactPlan, validateJsonSchemaDocument, @@ -54,9 +57,27 @@ import { import { withInstallSurface } from '../install/surface.ts'; import { deepFreeze } from '../core/freeze.ts'; +export interface CodexInterfaceConfig { + readonly brandColor?: string; + readonly capabilities?: readonly string[]; + readonly category?: string; + readonly composerIcon?: string; + readonly defaultPrompt?: readonly string[]; + readonly developerName?: string; + readonly displayName?: string; + readonly logo?: string; + readonly logoDark?: string; + readonly longDescription?: string; + readonly privacyPolicyURL?: string; + readonly screenshots?: readonly string[]; + readonly shortDescription?: string; + readonly termsOfServiceURL?: string; + readonly websiteURL?: string; +} -export interface CodexConfigExtension { - codex?: CodexHostConfig; +export interface CodexRegisteredAppConfig { + /** Exact technical identifier returned by the registered MCP connection workflow. */ + readonly id: string; } /** Documented publisher identity in `.codex-plugin/plugin.json`. */ @@ -66,15 +87,23 @@ export interface CodexAuthorConfig { readonly url?: string; } -/** Codex-only authored package metadata layered onto the generated manifest. */ +/** 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. */ + readonly apps?: Readonly>; readonly author?: CodexAuthorConfig; readonly homepage?: string; + /** Install-surface metadata merged over the compiler's generated defaults. */ + readonly interface?: CodexInterfaceConfig; readonly keywords?: readonly string[]; readonly license?: string; readonly repository?: string; } +export interface CodexConfigExtension { + codex?: CodexHostConfig; +} + declare module '../core/types.ts' { interface AgentBundleConfigExtensions { codex?: CodexHostConfig; @@ -85,12 +114,14 @@ const codexName = 'codex'; /** Codex's conventional artifact document paths, shared with the unified bundle adapter. */ export const codexArtifactPaths = Object.freeze({ + apps: '.app.json', hooksManifest: 'hooks/hooks.json', marketplace: '.agents/plugins/marketplace.json', mcp: '.mcp.json', plugin: '.codex-plugin/plugin.json', }); const validator = createAdapterValidator(); +const validateApps = validator.compile(appSchema); const validatePlugin = validator.compile(pluginSchema); const validateMcp = validator.compile(mcpSchema); const validateMarketplace = validator.compile(marketplaceSchema); @@ -125,7 +156,7 @@ const hookContract = Object.freeze({ wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Codex'), } satisfies TargetHookContract); const metadata = Object.freeze({ - adapterRevision: '1.4.0', + adapterRevision: '1.5.0', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); @@ -133,12 +164,14 @@ const evidence = capabilityEvidence(codexName, metadata); const artifactValidation = deepFreeze({ documents: [ + Object.freeze({ path: '.app.json', required: false, schema: 'app' }), Object.freeze({ path: 'hooks/hooks.json', required: false, schema: 'hooks' }), Object.freeze({ path: '.agents/plugins/marketplace.json', required: false, schema: 'marketplace' }), Object.freeze({ path: '.mcp.json', required: false, schema: 'mcp' }), Object.freeze({ path: '.codex-plugin/plugin.json', required: true, schema: 'plugin' }), ], schemas: [ + Object.freeze({ name: 'app', validate: validateJsonSchemaDocument(validateApps) }), Object.freeze({ name: 'hooks', validate: validateJsonSchemaDocument(validateHooks) }), Object.freeze({ name: 'marketplace', validate: validateJsonSchemaDocument(validateMarketplace) }), Object.freeze({ name: 'mcp', validate: validateJsonSchemaDocument(validateMcp) }), @@ -178,6 +211,25 @@ const isAbsoluteUrl = (value: unknown): value is string => { } }; +const codexInterfaceFields = Object.freeze([ + 'brandColor', + 'capabilities', + 'category', + 'composerIcon', + 'defaultPrompt', + 'developerName', + 'displayName', + 'logo', + 'logoDark', + 'longDescription', + 'privacyPolicyURL', + 'screenshots', + 'shortDescription', + 'termsOfServiceURL', + 'websiteURL', +]); + + const isEmail = (value: unknown): value is string => isNonemptyString(value) && /^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(value); @@ -325,6 +377,257 @@ const planCodexManifestMetadata = (model: NormalizedPlugin): CodexManifestMetada }; }; +const pluginInternalPath = (value: unknown): value is string => { + if (!isNonemptyString(value) || !value.startsWith('./') || value === './' || value.includes('\\') || value.includes('\0')) { + return false; + } + const pluginRoot = '/agent-bundle-plugin-root'; + const resolved = posix.resolve(pluginRoot, value.slice(2)); + return resolved.startsWith(`${pluginRoot}/`); +}; + +interface CodexInterfacePlan { + readonly diagnostics: readonly Diagnostic[]; + readonly sourceInputs: readonly string[]; + readonly value: Readonly>; +} + +const planCodexInterface = ( + model: NormalizedPlugin, + generated: Readonly>, +): CodexInterfacePlan => { + const extension = model.extensions[codexName]; + const declared = extension !== undefined && isPlainDataRecord(extension.value) + ? extension.value['interface'] + : undefined; + if (declared === undefined || extension === undefined) { + return { diagnostics: [], sourceInputs: [], value: generated }; + } + if (!isPlainDataRecord(declared)) { + return { + diagnostics: [errorDiagnostic( + 'codex.interface.invalid', + 'Codex interface must be a plain object containing only documented interface fields.', + )], + sourceInputs: sourceInputs(extension.provenance.sourcePath), + value: generated, + }; + } + + const diagnostics: Diagnostic[] = []; + for (const key of Object.keys(declared)) { + if (!codexInterfaceFields.includes(key)) { + diagnostics.push(errorDiagnostic( + 'codex.interface.field.unknown', + `Codex interface field ${JSON.stringify(key)} is not documented.`, + )); + } + } + const value: Record = { ...generated }; + const stringFields = [ + ['displayName', 'display-name'], + ['shortDescription', 'short-description'], + ['longDescription', 'long-description'], + ['developerName', 'developer-name'], + ['category', 'category'], + ] as const; + for (const [field, codeField] of stringFields) { + const authored = declared[field]; + if (authored === undefined) continue; + if (!isNonemptyString(authored)) { + diagnostics.push(errorDiagnostic( + `codex.interface.${codeField}.invalid`, + `Codex interface ${field} must be a nonempty string.`, + )); + } else { + value[field] = authored; + } + } + + const capabilities = declared['capabilities']; + if (capabilities !== undefined) { + const items = dataArrayValues(capabilities); + if (items === undefined) { + diagnostics.push(errorDiagnostic( + 'codex.interface.capabilities.invalid', + 'Codex interface capabilities must be an array of nonempty strings.', + )); + } else { + let valid = true; + for (const [index, item] of items.entries()) { + if (isNonemptyString(item)) continue; + valid = false; + diagnostics.push(errorDiagnostic( + 'codex.interface.capabilities.item.invalid', + `Codex interface capabilities[${index}] must be a nonempty string.`, + )); + } + if (valid) value['capabilities'] = items; + } + } + + const urlFields = [ + ['websiteURL', 'website-url'], + ['privacyPolicyURL', 'privacy-policy-url'], + ['termsOfServiceURL', 'terms-of-service-url'], + ] as const; + for (const [field, codeField] of urlFields) { + const authored = declared[field]; + if (authored === undefined) continue; + if (!isAbsoluteUrl(authored)) { + diagnostics.push(errorDiagnostic( + `codex.interface.${codeField}.invalid`, + `Codex interface ${field} must be an absolute HTTP(S) URL.`, + )); + } else { + value[field] = authored; + } + } + + const defaultPrompt = declared['defaultPrompt']; + if (defaultPrompt !== undefined) { + const items = dataArrayValues(defaultPrompt); + if (items === undefined || items.length < 1 || items.length > 3) { + diagnostics.push(errorDiagnostic( + 'codex.interface.default-prompt.invalid', + 'Codex interface defaultPrompt must contain between one and three strings.', + )); + } else { + let valid = true; + for (const [index, item] of items.entries()) { + if (isNonemptyString(item) && item.length <= 128) continue; + valid = false; + diagnostics.push(errorDiagnostic( + 'codex.interface.default-prompt.item.invalid', + `Codex interface defaultPrompt[${index}] must be a nonempty string of at most 128 characters.`, + )); + } + if (valid) value['defaultPrompt'] = items; + } + } + + const brandColor = declared['brandColor']; + if (brandColor !== undefined) { + if (typeof brandColor !== 'string' || !/^#[0-9A-Fa-f]{6}$/u.test(brandColor)) { + diagnostics.push(errorDiagnostic( + 'codex.interface.brand-color.invalid', + 'Codex interface brandColor must be a six-digit hexadecimal color such as "#10A37F".', + )); + } else { + value['brandColor'] = brandColor; + } + } + + const assetFields = [ + ['composerIcon', 'composer-icon'], + ['logo', 'logo'], + ['logoDark', 'logo-dark'], + ] as const; + for (const [field, codeField] of assetFields) { + const authored = declared[field]; + if (authored === undefined) continue; + if (!pluginInternalPath(authored)) { + diagnostics.push(errorDiagnostic( + `codex.interface.${codeField}.invalid`, + `Codex interface ${field} must be a ./-prefixed path that stays inside the plugin root.`, + )); + } else { + value[field] = authored; + } + } + + const screenshots = declared['screenshots']; + if (screenshots !== undefined) { + const items = dataArrayValues(screenshots); + if (items === undefined) { + diagnostics.push(errorDiagnostic( + 'codex.interface.screenshots.invalid', + 'Codex interface screenshots must be an array of plugin-internal PNG paths.', + )); + } else { + let valid = true; + for (const [index, item] of items.entries()) { + if ( + pluginInternalPath(item) && + item.startsWith('./assets/') && + item.endsWith('.png') + ) { + continue; + } + valid = false; + diagnostics.push(errorDiagnostic( + 'codex.interface.screenshots.item.invalid', + `Codex interface screenshots[${index}] must be a ./assets/-relative PNG path inside the plugin root.`, + )); + } + if (valid) value['screenshots'] = items; + } + } + + return { + diagnostics, + sourceInputs: sourceInputs(extension.provenance.sourcePath), + value: diagnostics.length === 0 ? value : generated, + }; +}; + +interface CodexAppsPlan { + readonly diagnostics: readonly Diagnostic[]; + readonly document?: Record; + readonly sourceInputs: readonly string[]; +} + +const planCodexApps = (model: NormalizedPlugin): CodexAppsPlan => { + const extension = model.extensions[codexName]; + const declared = extension !== undefined && isPlainDataRecord(extension.value) + ? extension.value['apps'] + : undefined; + if (declared === undefined || extension === undefined) return { diagnostics: [], sourceInputs: [] }; + const inputs = sourceInputs(extension.provenance.sourcePath); + if (!isPlainDataRecord(declared) || Object.keys(declared).length === 0) { + return { + diagnostics: [errorDiagnostic( + 'codex.apps.invalid', + 'Codex apps must be a nonempty object mapping declaration names to registered MCP IDs.', + )], + sourceInputs: inputs, + }; + } + + const diagnostics: Diagnostic[] = []; + const apps: Record = Object.create(null) as Record; + for (const [name, entry] of Object.entries(declared)) { + if (name.trim().length === 0) { + diagnostics.push(errorDiagnostic( + 'codex.apps.name.invalid', + 'Codex app declaration names must be nonempty strings.', + )); + continue; + } + if (!isPlainDataRecord(entry) || Object.keys(entry).length !== 1 || !Object.hasOwn(entry, 'id')) { + diagnostics.push(errorDiagnostic( + 'codex.apps.entry.invalid', + `Codex app ${JSON.stringify(name)} must contain exactly one id field.`, + )); + continue; + } + const id = entry['id']; + if (!isNonemptyString(id)) { + diagnostics.push(errorDiagnostic( + 'codex.apps.id.invalid', + `Codex app ${JSON.stringify(name)} id must be the nonempty technical ID returned by registration.`, + )); + continue; + } + apps[name] = { id }; + } + return { + diagnostics, + ...(diagnostics.length === 0 ? { document: { apps } } : {}), + sourceInputs: inputs, + }; +}; + const hasLeadingPluginRoot = (value: string): boolean => value === pathTokens.pluginRoot || value.startsWith(`${pathTokens.pluginRoot}/`); @@ -512,9 +815,15 @@ export const planCodexArtifacts = ( const hookDocumentValid = hookDocument !== undefined && validateHooks(hookDocument); const manifestMetadata = planCodexManifestMetadata(model); diagnostics.push(...manifestMetadata.diagnostics); + const appsPlan = planCodexApps(model); + diagnostics.push(...appsPlan.diagnostics); + const appsValid = appsPlan.document !== undefined && validateApps(appsPlan.document); + if (appsPlan.document !== undefined) { + diagnostics.push(...schemaDiagnostics('app', appsValid, validateApps.errors)); + } const description = model.metadata.description ?? model.metadata.name; - const interfaceMetadata = { + const generatedInterface = { capabilities: [ ...(mcp === undefined ? [] : ['mcp']), ...(hookDocument === undefined ? [] : ['hooks']), @@ -522,18 +831,19 @@ export const planCodexArtifacts = ( ], defaultPrompt: [`Help me use ${model.metadata.name}.`], developerName: model.metadata.name, - }; + category: 'Productivity', + displayName: model.metadata.name, + longDescription: description, + shortDescription: description, + } satisfies Readonly>; + const interfacePlan = planCodexInterface(model, generatedInterface); + diagnostics.push(...interfacePlan.diagnostics); const plugin = { author: { name: model.metadata.name }, ...manifestMetadata.document, description, - interface: { - ...interfaceMetadata, - category: 'Productivity', - displayName: model.metadata.name, - longDescription: description, - shortDescription: description, - }, + interface: interfacePlan.value, + ...(appsValid ? { apps: `./${codexArtifactPaths.apps}` } : {}), ...(mcp === undefined ? {} : { mcpServers: `./${mcpRelativePath}` }), ...(hookDocument === undefined ? {} : { hooks: `./${hookContract.manifestPath}` }), name: model.metadata.name, @@ -557,8 +867,19 @@ export const planCodexArtifacts = ( diagnostics.push(...schemaDiagnostics('marketplace', marketplaceValid, validateMarketplace.errors)); return withInstallSurface(standardPluginArtifactPlan({ - additionalPluginSourceInputs: manifestMetadata.sourceInputs, + additionalPluginSourceInputs: sourceInputs( + ...manifestMetadata.sourceInputs, + ...interfacePlan.sourceInputs, + ...appsPlan.sourceInputs, + ), diagnostics, + hostDocuments: appsPlan.document !== undefined && appsValid + ? [{ + document: appsPlan.document, + relativePath: codexArtifactPaths.apps, + sourceInputs: appsPlan.sourceInputs, + }] + : [], hookDocument, hookDocumentValid, hookEntries: generatedHooks.hookEntries, @@ -586,6 +907,51 @@ export const codexAdapter: TargetAdapter = Object.freeze({ commands: unavailableCapability( 'The pinned Codex plugin contract (0.147.0) defines no commands component.', ), + interfaceAssets: capabilityStateFromSupport( + capabilityTable.plugin.interface.assets.state === 'supported', + evidence, + 'The pinned Codex plugin contract does not document interface image assets.', + ), + interfaceBrandColor: capabilityStateFromSupport( + capabilityTable.plugin.interface.brandColor.state === 'supported', + evidence, + 'The pinned Codex plugin contract does not document an interface brand color.', + ), + interfaceCategoryCapabilities: capabilityStateFromSupport( + capabilityTable.plugin.interface.categoryCapabilities.state === 'supported', + evidence, + 'The pinned Codex plugin contract does not document interface category and capability metadata.', + ), + interfaceDescriptions: capabilityStateFromSupport( + capabilityTable.plugin.interface.descriptions.state === 'supported', + evidence, + 'The pinned Codex plugin contract does not document interface descriptions.', + ), + interfaceIdentity: capabilityStateFromSupport( + capabilityTable.plugin.interface.identity.state === 'supported', + evidence, + 'The pinned Codex plugin contract does not document interface identity fields.', + ), + interfaceStarterPrompts: capabilityStateFromSupport( + capabilityTable.plugin.interface.starterPrompts.state === 'supported', + evidence, + 'The pinned Codex plugin contract does not document interface starter prompts.', + ), + interfaceUrls: capabilityStateFromSupport( + capabilityTable.plugin.interface.urls.state === 'supported', + evidence, + 'The pinned Codex plugin contract does not document interface external links.', + ), + claudePluginDataEnvironment: capabilityStateFromSupport( + capabilityTable.plugin.hookEnvironment.claudePluginData.state === 'supported', + evidence, + 'The pinned Codex hook contract does not export CLAUDE_PLUGIN_DATA.', + ), + claudePluginRootEnvironment: capabilityStateFromSupport( + capabilityTable.plugin.hookEnvironment.claudePluginRoot.state === 'supported', + evidence, + 'The pinned Codex hook contract does not export CLAUDE_PLUGIN_ROOT.', + ), install: supportedCapability(evidence), marketplace: supportedCapability(evidence), hooks: supportedCapability(evidence), @@ -600,6 +966,30 @@ export const codexAdapter: TargetAdapter = Object.freeze({ evidence, 'The pinned Codex contract does not support both required modern MCP transports.', ), + pluginDataEnvironment: capabilityStateFromSupport( + capabilityTable.plugin.hookEnvironment.pluginData.state === 'supported', + evidence, + 'The pinned Codex hook contract does not export PLUGIN_DATA.', + ), + pluginMcpPolicyApprovalModes: unavailableCapability( + capabilityTable.plugin.mcpServerPolicy.approvalModes.reason, + ), + pluginMcpPolicyEnabled: unavailableCapability( + capabilityTable.plugin.mcpServerPolicy.enabled.reason, + ), + pluginMcpPolicyTools: unavailableCapability( + capabilityTable.plugin.mcpServerPolicy.tools.reason, + ), + pluginRootEnvironment: capabilityStateFromSupport( + capabilityTable.plugin.hookEnvironment.pluginRoot.state === 'supported', + evidence, + 'The pinned Codex hook contract does not export PLUGIN_ROOT.', + ), + registeredMcpApps: capabilityStateFromSupport( + capabilityTable.plugin.apps.registeredMcpMappings.state === 'supported', + evidence, + 'The pinned Codex plugin contract does not document registered MCP app mappings.', + ), rules: unavailableCapability( 'The pinned Codex plugin contract (0.147.0) defines no rules component; Codex guidance remains outside the plugin component surface.', ), diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index 8270271b6..ae3bd48d1 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -106,6 +106,10 @@ const cursorPaths = Object.freeze({ * assertion keeps a future capability-table divergence from silently shipping * one host's matcher to the other. */ +const interfaceUnifiedReason = + 'The unified bundle emits the Codex-only interface install surface, but the pinned Claude and Cursor plugin contracts declare no shared interface metadata field.'; +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 reconciledMatcherKeys = new Set(['file.read', 'file.write']); const claudeMatchers: Readonly> = claudeCapabilityTable.hooks.matchers; const codexMatchers: Readonly> = codexCapabilityTable.hooks.matchers; @@ -156,6 +160,7 @@ const artifactValidation = deepFreeze({ documents: [ // One shared Claude-format hook document serves both hosts; the pinned // Codex hooks schema is byte-identical apart from its $id. + Object.freeze({ path: codexArtifactPaths.apps, required: false, schema: 'codex-app' }), Object.freeze({ path: bundleHookContract.manifestPath, required: false, schema: 'claude-hooks' }), Object.freeze({ path: claudeArtifactPaths.lsp, required: false, schema: 'claude-lsp' }), Object.freeze({ path: claudeArtifactPaths.marketplace, required: false, schema: 'claude-marketplace' }), @@ -186,7 +191,7 @@ const artifactValidation = deepFreeze({ }); const metadata = Object.freeze({ - adapterRevision: '1.19.0', + adapterRevision: '1.20.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 @@ -610,6 +615,46 @@ export const pluginAdapter: TargetAdapter = Object.freeze({ intersectCapabilityStates(claudeAdapter.capabilities.commands!, codexAdapter.capabilities.commands!), cursorAdapter.capabilities.commands!, ), + interfaceAssets: intersectCapabilityStates( + codexAdapter.capabilities.interfaceAssets!, + unavailableCapability(interfaceUnifiedReason), + ), + interfaceBrandColor: intersectCapabilityStates( + codexAdapter.capabilities.interfaceBrandColor!, + unavailableCapability(interfaceUnifiedReason), + ), + interfaceCategoryCapabilities: intersectCapabilityStates( + codexAdapter.capabilities.interfaceCategoryCapabilities!, + unavailableCapability(interfaceUnifiedReason), + ), + interfaceDescriptions: intersectCapabilityStates( + codexAdapter.capabilities.interfaceDescriptions!, + unavailableCapability(interfaceUnifiedReason), + ), + interfaceIdentity: intersectCapabilityStates( + codexAdapter.capabilities.interfaceIdentity!, + unavailableCapability(interfaceUnifiedReason), + ), + interfaceStarterPrompts: intersectCapabilityStates( + codexAdapter.capabilities.interfaceStarterPrompts!, + unavailableCapability(interfaceUnifiedReason), + ), + interfaceUrls: intersectCapabilityStates( + codexAdapter.capabilities.interfaceUrls!, + unavailableCapability(interfaceUnifiedReason), + ), + claudePluginDataEnvironment: intersectCapabilityStates( + codexAdapter.capabilities.claudePluginDataEnvironment!, + unavailableCapability( + 'The pinned Cursor hook contract does not export the CLAUDE_PLUGIN_DATA compatibility variable, so the unified bundle cannot rely on it across hosts.', + ), + ), + claudePluginRootEnvironment: intersectCapabilityStates( + codexAdapter.capabilities.claudePluginRootEnvironment!, + unavailableCapability( + 'The pinned Cursor hook contract does not export the CLAUDE_PLUGIN_ROOT compatibility variable, so the unified bundle cannot rely on it across hosts.', + ), + ), // The Claude half emits the declaration, but neither pinned non-Claude // manifest has a shared dependency-resolution surface. dependencies: intersectCapabilityStates( @@ -745,6 +790,36 @@ export const pluginAdapter: TargetAdapter = Object.freeze({ intersectCapabilityStates(claudeAdapter.capabilities.mcp!, codexAdapter.capabilities.mcp!), cursorAdapter.capabilities.mcp!, ), + pluginDataEnvironment: intersectCapabilityStates( + codexAdapter.capabilities.pluginDataEnvironment!, + unavailableCapability( + 'The pinned Claude and Cursor hook contracts do not export the Codex-specific PLUGIN_DATA variable, so the unified bundle cannot rely on it across hosts.', + ), + ), + pluginMcpPolicyApprovalModes: intersectCapabilityStates( + codexAdapter.capabilities.pluginMcpPolicyApprovalModes!, + unavailableCapability(mcpPolicyUnifiedReason), + ), + pluginMcpPolicyEnabled: intersectCapabilityStates( + codexAdapter.capabilities.pluginMcpPolicyEnabled!, + unavailableCapability(mcpPolicyUnifiedReason), + ), + pluginMcpPolicyTools: intersectCapabilityStates( + codexAdapter.capabilities.pluginMcpPolicyTools!, + unavailableCapability(mcpPolicyUnifiedReason), + ), + pluginRootEnvironment: intersectCapabilityStates( + codexAdapter.capabilities.pluginRootEnvironment!, + unavailableCapability( + 'The pinned Claude and Cursor hook contracts do not export the Codex-specific PLUGIN_ROOT variable, so the unified bundle cannot rely on it across hosts.', + ), + ), + registeredMcpApps: intersectCapabilityStates( + codexAdapter.capabilities.registeredMcpApps!, + unavailableCapability( + 'The pinned Claude and Cursor plugin contracts publish no registered-MCP app mapping document; the emitted .app.json reaches Codex only.', + ), + ), monitors: unavailableCapability( 'The unified bundle emits Claude-only experimental background monitors, but the pinned Codex and Cursor contracts declare no shared monitor surface.', ), diff --git a/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json index db30571b1..0c1ebd07f 100644 --- a/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json +++ b/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json @@ -8,10 +8,17 @@ "Top-level and author objects are closed; author admits only the documented name, email, and url fields.", "Documented URL and email fields use JSON Schema uri and email formats, and authored discovery strings must be nonempty.", "Component paths must begin with ./ and must not contain a parent-directory segment so they remain inside the plugin root.", - "Inline mcpServers values must be objects; inline hook documents use the same closed command-hook shape as hooks.schema.json." + "Inline mcpServers values must be objects; inline hook documents use the same closed command-hook shape as hooks.schema.json.", + "The closed interface object admits every documented install-surface field; brandColor requires a six-digit hexadecimal value, external links require http(s), asset paths must stay inside the plugin root, and screenshots must be ./assets/-relative PNG paths.", + "The apps pointer is const-locked to ./.app.json, and app.schema.json requires a nonempty apps map whose entries carry exactly one nonempty registered-connection id." ] }, "schemas": { + "app.schema.json": { + "bytes": 608, + "sha256": "01c720a645e437bf0c4f8c26fd4cb5a13988e5649e4a8562ee23a1d4b7355c6a", + "url": "https://developers.openai.com/plugins/build/plugins" + }, "hooks.schema.json": { "bytes": 1107, "sha256": "e42eef736997b9abb8f28b2ee9262f5c7b1f7f11d8289e9c25da8cc94a504eff", @@ -28,8 +35,8 @@ "url": "https://github.com/openai/codex/blob/main/codex-rs/core/config.schema.json" }, "plugin.schema.json": { - "bytes": 3894, - "sha256": "a3f24cd9f639b856937ae74fe967340bcce6966d131b0f45c628c78d9ac373f3", + "bytes": 4802, + "sha256": "6d8238718b8d74c59f5519d996c6a4c707ab5ddb02781dfef401333f3ca0b4a5", "url": "https://github.com/openai/codex/blob/main/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md" } }, diff --git a/packages/agent-bundle/src/adapters/schemas/codex/app.schema.json b/packages/agent-bundle/src/adapters/schemas/codex/app.schema.json new file mode 100644 index 000000000..e3abadce4 --- /dev/null +++ b/packages/agent-bundle/src/adapters/schemas/codex/app.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-bundle.dev/schemas/codex/0.147.0/app.schema.json", + "additionalProperties": false, + "properties": { + "apps": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "id": { "minLength": 1, "pattern": "\\S", "type": "string" } + }, + "required": ["id"], + "type": "object" + }, + "minProperties": 1, + "propertyNames": { "minLength": 1, "pattern": "\\S" }, + "type": "object" + } + }, + "required": ["apps"], + "type": "object" +} diff --git a/packages/agent-bundle/src/adapters/schemas/codex/plugin.schema.json b/packages/agent-bundle/src/adapters/schemas/codex/plugin.schema.json index af82f2bf3..bef8c5248 100644 --- a/packages/agent-bundle/src/adapters/schemas/codex/plugin.schema.json +++ b/packages/agent-bundle/src/adapters/schemas/codex/plugin.schema.json @@ -45,6 +45,7 @@ }, "additionalProperties": false, "properties": { + "apps": { "const": "./.app.json", "type": "string" }, "author": { "additionalProperties": false, "properties": { @@ -76,13 +77,21 @@ "interface": { "additionalProperties": false, "properties": { - "capabilities": { "items": { "minLength": 1, "type": "string" }, "type": "array" }, - "category": { "minLength": 1, "type": "string" }, - "defaultPrompt": { "items": { "minLength": 1, "type": "string" }, "maxItems": 3, "minItems": 1, "type": "array" }, - "developerName": { "minLength": 1, "type": "string" }, - "displayName": { "minLength": 1, "type": "string" }, - "longDescription": { "minLength": 1, "type": "string" }, - "shortDescription": { "minLength": 1, "type": "string" } + "brandColor": { "pattern": "^#[0-9A-Fa-f]{6}$", "type": "string" }, + "capabilities": { "items": { "minLength": 1, "pattern": "\\S", "type": "string" }, "type": "array" }, + "category": { "minLength": 1, "pattern": "\\S", "type": "string" }, + "composerIcon": { "pattern": "^\\./(?!.*(?:^|/)\\.\\.(?:/|$)).+$", "type": "string" }, + "defaultPrompt": { "items": { "maxLength": 128, "minLength": 1, "pattern": "\\S", "type": "string" }, "maxItems": 3, "minItems": 1, "type": "array" }, + "developerName": { "minLength": 1, "pattern": "\\S", "type": "string" }, + "displayName": { "minLength": 1, "pattern": "\\S", "type": "string" }, + "logo": { "pattern": "^\\./(?!.*(?:^|/)\\.\\.(?:/|$)).+$", "type": "string" }, + "logoDark": { "pattern": "^\\./(?!.*(?:^|/)\\.\\.(?:/|$)).+$", "type": "string" }, + "longDescription": { "minLength": 1, "pattern": "\\S", "type": "string" }, + "privacyPolicyURL": { "pattern": "^https?://", "type": "string" }, + "screenshots": { "items": { "pattern": "^\\./assets/(?!.*(?:^|/)\\.\\.(?:/|$)).+\\.png$", "type": "string" }, "type": "array" }, + "shortDescription": { "minLength": 1, "pattern": "\\S", "type": "string" }, + "termsOfServiceURL": { "pattern": "^https?://", "type": "string" }, + "websiteURL": { "pattern": "^https?://", "type": "string" } }, "required": ["displayName", "shortDescription", "longDescription", "developerName", "category", "capabilities", "defaultPrompt"], "type": "object" diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index d7b594304..98a5420c2 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -89,6 +89,98 @@ it('records an honest four-state rules row on every adapter', () => { )); }); +const codexParityCapabilityRows = { + interface: { + assets: 'interfaceAssets', + brandColor: 'interfaceBrandColor', + categoryCapabilities: 'interfaceCategoryCapabilities', + descriptions: 'interfaceDescriptions', + identity: 'interfaceIdentity', + starterPrompts: 'interfaceStarterPrompts', + urls: 'interfaceUrls', + }, + apps: { + registeredMcpMappings: 'registeredMcpApps', + }, + mcpServerPolicy: { + approvalModes: 'pluginMcpPolicyApprovalModes', + enabled: 'pluginMcpPolicyEnabled', + tools: 'pluginMcpPolicyTools', + }, + hookEnvironment: { + claudePluginData: 'claudePluginDataEnvironment', + claudePluginRoot: 'claudePluginRootEnvironment', + pluginData: 'pluginDataEnvironment', + pluginRoot: 'pluginRootEnvironment', + }, +} as const; + +it('records dated Codex interface, apps, policy, and hook-environment capability rows', () => { + const registry = createDefaultRegistry(); + const codex = registry.get('codex'); + const unified = registry.get('plugin'); + const expectedStates = { + apps: { registeredMcpMappings: 'supported' }, + hookEnvironment: { + claudePluginData: 'supported', + claudePluginRoot: 'supported', + pluginData: 'supported', + pluginRoot: 'supported', + }, + interface: { + assets: 'supported', + brandColor: 'supported', + categoryCapabilities: 'supported', + descriptions: 'supported', + identity: 'supported', + starterPrompts: 'supported', + urls: 'supported', + }, + mcpServerPolicy: { + approvalModes: 'unavailable', + enabled: 'unavailable', + tools: 'unavailable', + }, + } as const; + + for (const [blockName, rows] of Object.entries(codexParityCapabilityRows)) { + const tableBlock = codexCapabilityTable.plugin[ + blockName as keyof typeof codexParityCapabilityRows + ] as Readonly>; + expect(Object.keys(tableBlock).sort()).toEqual(Object.keys(rows).sort()); + for (const [rowName, capability] of Object.entries(rows)) { + const row = tableBlock[rowName]!; + const expectedState = expectedStates[ + blockName as keyof typeof expectedStates + ][rowName as never]; + expect(row.state).toBe(expectedState); + expect(row.evidence.length).toBeGreaterThan(0); + expect(row.evidence.every((line) => line.startsWith('retrieved 2026-09-02:'))).toBe(true); + expect(codex.capabilities[capability]).toMatchObject({ + ...(row.state === 'unavailable' ? { reason: row.reason } : { + evidence: { observedVersion: '0.147.0', target: 'codex' }, + }), + state: expectedState, + }); + // The unified bundle intersects every Codex-only surface with an + // honest unavailable row for the hosts that lack it. + expect(unified.capabilities[capability]).toMatchObject({ state: 'unavailable' }); + expect(registry.supports('codex', capability)).toBe(expectedState === 'supported'); + expect(registry.supports('plugin', capability)).toBe(false); + } + } + + expect(codexCapabilityTable.tokens).toEqual({ + pluginData: false, + pluginRoot: 'relative-with-plugin-root-cwd', + workspaceRoot: false, + }); +}); + it('reports Claude LSP support and honest unavailable composite coverage', () => { const registry = createDefaultRegistry(); diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index c69187c7c..9cd9806fb 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -67,9 +67,14 @@ it('records exact immutable metadata for every built-in target', () => { ], }); expect(registryMetadata(registry, 'codex')).toEqual({ - adapterRevision: '1.4.0', + adapterRevision: '1.5.0', observedVersion: '0.147.0', schemas: [ + { + name: 'app', + revision: '0.147.0', + sha256: '01c720a645e437bf0c4f8c26fd4cb5a13988e5649e4a8562ee23a1d4b7355c6a', + }, { name: 'hooks', revision: '0.147.0', @@ -88,7 +93,7 @@ it('records exact immutable metadata for every built-in target', () => { { name: 'plugin', revision: '0.147.0', - sha256: 'a3f24cd9f639b856937ae74fe967340bcce6966d131b0f45c628c78d9ac373f3', + sha256: '6d8238718b8d74c59f5519d996c6a4c707ab5ddb02781dfef401333f3ca0b4a5', }, ], }); @@ -164,7 +169,7 @@ it('records exact immutable metadata for every built-in target', () => { }, ], }); - expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.19.0'); + expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.20.0'); }); it('records observed capability versions and rehashes schema snapshots against pinned provenance', async () => { diff --git a/packages/agent-bundle/tests/host-adapters.native.test.ts b/packages/agent-bundle/tests/host-adapters.native.test.ts index 42288005a..0e8afcbd8 100644 --- a/packages/agent-bundle/tests/host-adapters.native.test.ts +++ b/packages/agent-bundle/tests/host-adapters.native.test.ts @@ -294,6 +294,80 @@ nativeIt('registers an emitted Codex plugin carrying authored package metadata', } }); +nativeIt('installs and lists an emitted Codex plugin carrying the complete interface block', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-codex-interface-')); + const pluginRoot = join(root, 'plugin'); + const codexHome = join(root, 'codex-home'); + const interfaceModel: NormalizedPlugin = { + ...model, + extensions: { + codex: { + id: 'extension:codex', + key: 'codex', + provenance: { kind: 'config', sourcePath: '/workspace/codex.config.ts' }, + target: 'codex', + value: { + interface: { + brandColor: '#10A37F', + capabilities: ['Interactive'], + category: 'Developer Tools', + composerIcon: './assets/icon.png', + defaultPrompt: ['Review this repository.'], + developerName: 'Agent Bundle', + displayName: 'Review Tools', + logo: './assets/logo.png', + longDescription: 'Review code and explain findings with repository context.', + privacyPolicyURL: 'https://example.test/privacy', + screenshots: ['./assets/overview.png'], + shortDescription: 'Repository-aware code review', + termsOfServiceURL: 'https://example.test/terms', + websiteURL: 'https://example.test/review-tools', + }, + }, + }, + }, + }; + + try { + await Promise.all([ + mkdir(codexHome, { recursive: true }), + mkdir(join(pluginRoot, 'assets'), { recursive: true }), + ]); + const plan = codexAdapter.plan(interfaceModel); + expect(plan.diagnostics).toEqual([]); + await emitPlanEntries({ entries: plan.entries, root: pluginRoot }); + await Promise.all([ + writeFile(join(pluginRoot, 'assets', 'icon.png'), 'native icon proof\n'), + writeFile(join(pluginRoot, 'assets', 'logo.png'), 'native logo proof\n'), + writeFile(join(pluginRoot, 'assets', 'overview.png'), 'native screenshot proof\n'), + ]); + + // The pinned CLI publishes no plugin validate subcommand; install and + // list are the honest native acceptance probes for the interface block. + const pluginHelp = await runCodex(root, ['plugin', '--help'], codexHome); + expect(pluginHelp.code, pluginHelp.stderr).toBe(0); + expect(`${pluginHelp.stdout}${pluginHelp.stderr}`).not.toMatch(/\bvalidate\b/u); + + const marketplace = await runCodex( + root, + ['plugin', 'marketplace', 'add', pluginRoot], + codexHome, + ); + expect(marketplace.code, marketplace.stderr).toBe(0); + const added = await runCodex( + root, + ['plugin', 'add', 'review-tools@review-tools-marketplace'], + codexHome, + ); + expect(added.code, added.stderr).toBe(0); + const listed = await runCodex(root, ['plugin', 'list'], codexHome); + expect(listed.code, listed.stderr).toBe(0); + expect(`${listed.stdout}${listed.stderr}`).toContain('review-tools'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + nativeIt('pins Claude plugin and marketplace lifecycle command help', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-lifecycle-help-')); diff --git a/packages/agent-bundle/tests/host-adapters.test.ts b/packages/agent-bundle/tests/host-adapters.test.ts index d0ae6ed3b..11573a315 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -274,6 +274,22 @@ const withClaudeMarketplace = ( }, }); +const withCodexConfig = ( + model: NormalizedPlugin, + value: Readonly>, +): NormalizedPlugin => ({ + ...model, + extensions: { + codex: { + id: 'extension:codex', + key: 'codex', + provenance: { kind: 'config', sourcePath: '/workspace/codex.config.ts' }, + target: 'codex', + value, + }, + }, +}); + const validateDocuments = async ( target: 'codex' | 'claude', documents: Readonly>, @@ -2598,6 +2614,129 @@ it('keeps Codex plugin and marketplace interface validator contracts separate', expect(marketplace.interface).toEqual({ displayName: 'review-tools' }); }); +it('emits every authored Codex interface field without changing marketplace metadata', () => { + const plan = createDefaultRegistry().get('codex').plan(withCodexConfig(plugin, { + interface: { + brandColor: '#10A37F', + capabilities: ['Interactive', 'Write'], + category: 'Developer Tools', + composerIcon: './assets/composer.png', + defaultPrompt: ['Review this change.', 'Explain this repository.'], + developerName: 'Agent Bundle', + displayName: 'Review Tools', + logo: './assets/logo.png', + logoDark: './assets/logo-dark.png', + longDescription: 'Review code and explain findings with repository context.', + privacyPolicyURL: 'https://example.test/privacy', + screenshots: ['./assets/overview.png', './assets/details.png'], + shortDescription: 'Repository-aware code review', + termsOfServiceURL: 'http://example.test/terms', + websiteURL: 'https://example.test/review-tools', + }, + })); + const byPath = Object.fromEntries(plan.entries.map((entry) => [entry.relativePath, entry])); + const manifestEntry = byPath['.codex-plugin/plugin.json']; + const marketplaceEntry = byPath['.agents/plugins/marketplace.json']; + + expect(plan.diagnostics).toEqual([]); + expect(manifestEntry).toMatchObject({ + kind: 'write', + sourceInputs: expect.arrayContaining([ + '/workspace/agent-bundle.config.ts', + '/workspace/codex.config.ts', + ]), + }); + if (manifestEntry?.kind !== 'write') throw new Error('Expected an emitted Codex plugin manifest.'); + expect(JSON.parse(manifestEntry.content).interface).toEqual({ + brandColor: '#10A37F', + capabilities: ['Interactive', 'Write'], + category: 'Developer Tools', + composerIcon: './assets/composer.png', + defaultPrompt: ['Review this change.', 'Explain this repository.'], + developerName: 'Agent Bundle', + displayName: 'Review Tools', + logo: './assets/logo.png', + logoDark: './assets/logo-dark.png', + longDescription: 'Review code and explain findings with repository context.', + privacyPolicyURL: 'https://example.test/privacy', + screenshots: ['./assets/overview.png', './assets/details.png'], + shortDescription: 'Repository-aware code review', + termsOfServiceURL: 'http://example.test/terms', + websiteURL: 'https://example.test/review-tools', + }); + expect(marketplaceEntry).toMatchObject({ kind: 'write' }); + if (marketplaceEntry?.kind !== 'write') throw new Error('Expected an emitted Codex marketplace.'); + expect(JSON.parse(marketplaceEntry.content).interface).toEqual({ displayName: 'review-tools' }); +}); + +it.each([ + { code: 'codex.interface.invalid', value: [] }, + { code: 'codex.interface.field.unknown', value: { typo: true } }, + { code: 'codex.interface.display-name.invalid', value: { displayName: '' } }, + { code: 'codex.interface.display-name.invalid', value: { displayName: ' ' } }, + { code: 'codex.interface.short-description.invalid', value: { shortDescription: 7 } }, + { code: 'codex.interface.long-description.invalid', value: { longDescription: '' } }, + { code: 'codex.interface.developer-name.invalid', value: { developerName: null } }, + { code: 'codex.interface.category.invalid', value: { category: '' } }, + { code: 'codex.interface.capabilities.invalid', value: { capabilities: 'Write' } }, + { code: 'codex.interface.capabilities.item.invalid', value: { capabilities: ['Write', ''] } }, + { code: 'codex.interface.website-url.invalid', value: { websiteURL: 'file:///tmp/site' } }, + { code: 'codex.interface.privacy-policy-url.invalid', value: { privacyPolicyURL: '/privacy' } }, + { code: 'codex.interface.terms-of-service-url.invalid', value: { termsOfServiceURL: 'mailto:legal@example.test' } }, + { code: 'codex.interface.default-prompt.invalid', value: { defaultPrompt: [] } }, + { code: 'codex.interface.default-prompt.invalid', value: { defaultPrompt: ['One', 'Two', 'Three', 'Four'] } }, + { code: 'codex.interface.default-prompt.item.invalid', value: { defaultPrompt: [''] } }, + { code: 'codex.interface.default-prompt.item.invalid', value: { defaultPrompt: ['x'.repeat(129)] } }, + { code: 'codex.interface.brand-color.invalid', value: { brandColor: 'green' } }, + { code: 'codex.interface.composer-icon.invalid', value: { composerIcon: '/tmp/icon.png' } }, + { code: 'codex.interface.logo.invalid', value: { logo: './../outside.png' } }, + { code: 'codex.interface.logo-dark.invalid', value: { logoDark: 'https://example.test/logo.png' } }, + { code: 'codex.interface.screenshots.invalid', value: { screenshots: './assets/screenshot.png' } }, + { code: 'codex.interface.screenshots.item.invalid', value: { screenshots: ['./assets/screenshot.jpg'] } }, +] as const)('rejects invalid authored Codex interface input with $code', ({ code, value }) => { + const plan = createDefaultRegistry().get('codex').plan(withCodexConfig(plugin, { interface: value })); + + expect(plan.diagnostics.map((diagnostic) => diagnostic.code)).toContain(code); +}); + +it('emits registered MCP app mappings as the documented compatibility document', () => { + const plan = createDefaultRegistry().get('codex').plan(withCodexConfig(plugin, { + apps: { + notion: { id: 'plugin_asdk_app_0123456789abcdef' }, + search: { id: 'connector_search' }, + }, + })); + const byPath = Object.fromEntries(plan.entries.map((entry) => [entry.relativePath, entry])); + const manifestEntry = byPath['.codex-plugin/plugin.json']; + + expect(plan.diagnostics).toEqual([]); + expect(byPath['.app.json']).toEqual({ + content: '{"apps":{"notion":{"id":"plugin_asdk_app_0123456789abcdef"},"search":{"id":"connector_search"}}}\n', + kind: 'write', + relativePath: '.app.json', + sourceInputs: ['/workspace/codex.config.ts'], + }); + if (manifestEntry?.kind !== 'write') throw new Error('Expected an emitted Codex plugin manifest.'); + expect(JSON.parse(manifestEntry.content).apps).toBe('./.app.json'); + expect(manifestEntry.sourceInputs).toContain('/workspace/codex.config.ts'); +}); + +it.each([ + { code: 'codex.apps.invalid', value: [] }, + { code: 'codex.apps.invalid', value: {} }, + { code: 'codex.apps.name.invalid', value: { '': { id: 'connector_search' } } }, + { code: 'codex.apps.name.invalid', value: { ' ': { id: 'connector_search' } } }, + { code: 'codex.apps.entry.invalid', value: { search: 'connector_search' } }, + { code: 'codex.apps.entry.invalid', value: { search: { id: 'connector_search', typo: true } } }, + { code: 'codex.apps.id.invalid', value: { search: { id: '' } } }, + { code: 'codex.apps.id.invalid', value: { search: { id: ' ' } } }, +] as const)('rejects invalid authored Codex app mappings with $code', ({ code, value }) => { + const plan = createDefaultRegistry().get('codex').plan(withCodexConfig(plugin, { apps: value })); + + expect(plan.diagnostics.map((diagnostic) => diagnostic.code)).toContain(code); + expect(plan.entries.some((entry) => entry.relativePath === '.app.json')).toBe(false); +}); + it('records every selected component provenance for generated host documents', () => { const model = { ...plugin,