From 491da37a3774c57e619270017e916a09737261af Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 01:08:55 +0000 Subject: [PATCH] feat(codex): complete plugin manifest metadata contract --- .changeset/calm-codex-manifests.md | 5 + .../adapters/capabilities/codex-0.147.0.json | 69 ++++++ packages/agent-bundle/src/adapters/codex.ts | 229 ++++++++++++++++-- packages/agent-bundle/src/adapters/plugin.ts | 16 +- .../adapters/schemas/codex/PROVENANCE.json | 16 +- .../adapters/schemas/codex/plugin.schema.json | 86 ++++++- .../tests/adapter-capability-states.test.ts | 99 +++++++- .../tests/adapter-metadata.test.ts | 6 +- .../tests/host-adapters.native.test.ts | 110 +++++++++ .../agent-bundle/tests/host-adapters.test.ts | 184 ++++++++++++++ 10 files changed, 777 insertions(+), 43 deletions(-) create mode 100644 .changeset/calm-codex-manifests.md diff --git a/.changeset/calm-codex-manifests.md b/.changeset/calm-codex-manifests.md new file mode 100644 index 000000000..7b15c3772 --- /dev/null +++ b/.changeset/calm-codex-manifests.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Add authored Codex plugin package metadata, validate documented component forms, and record submission-facing capability limits. 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 07b17d34d..8a18c89b2 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,75 @@ "plugin": { "manifest": ".codex-plugin/plugin.json", "marketplace": ".agents/plugins/marketplace.json", + "manifestPackage": { + "manifestMetadata": { + "evidence": [ + "retrieved 2026-09-02: https://developers.openai.com/plugins/build/plugins documents top-level author (name, email, url), homepage, repository, license, and keywords as publisher and discovery metadata in .codex-plugin/plugin.json.", + "retrieved 2026-09-02: the adapter exposes those seven documented values through codex host config, validates them before emission, and retains the extension source as manifest provenance." + ], + "fields": [ + "author.name", + "author.email", + "author.url", + "homepage", + "repository", + "license", + "keywords" + ], + "state": "supported" + }, + "manifestPaths": { + "admitted": { + "hooks": ["path", "path-array", "inline-object", "inline-object-array"], + "mcpServers": ["path", "inline-object"], + "skills": ["path"] + }, + "emitted": { + "hooks": "./hooks/hooks.json", + "mcpServers": "./.mcp.json", + "skills": "./skills/" + }, + "evidence": [ + "retrieved 2026-09-02: https://developers.openai.com/plugins/build/plugins requires manifest component paths to begin with ./, resolve relative to the plugin root, and stay inside that root.", + "retrieved 2026-09-02: the same page documents skills as a path, mcpServers as a path or inline server object, and hooks as a path, path array, inline hooks object, or inline-hooks-object array.", + "retrieved 2026-09-02: the adapter emits canonical component paths while the pinned artifact schema admits every documented path and inline form for validating real-world manifests." + ], + "reason": "The adapter emits canonical component locations and validates all documented forms, but it has no authored config surface for selecting custom component paths or inline component objects.", + "state": "degraded" + }, + "optionalAssets": { + "assets": [ + "interface.composerIcon", + "interface.logo", + "interface.logoDark", + "interface.screenshots" + ], + "evidence": [ + "retrieved 2026-09-02: https://developers.openai.com/plugins/build/plugins documents composerIcon, logo, logoDark, and PNG screenshots under ./assets/ as optional install-surface presentation fields.", + "retrieved 2026-09-02: the compiler can copy project assets into assets/ but the Codex adapter exposes no authored mapping to these interface fields and emits none of them." + ], + "reason": "The Codex adapter copies selected assets but cannot author composerIcon, logo, logoDark, or screenshots references in the manifest.", + "state": "unavailable" + }, + "submissionPolicy": { + "constraints": [ + "Apps Management write access", + "verified developer or business identity", + "listing and policy URLs", + "production MCP review materials", + "five positive and three negative test cases", + "country or region availability", + "release notes and policy attestations" + ], + "evidence": [ + "retrieved 2026-09-02: https://developers.openai.com/plugins/deploy/submission requires Apps Management write access, verified publisher identity, matching public listing and policy URLs, and release notes plus policy attestations.", + "retrieved 2026-09-02: the submission portal requires production MCP review materials when applicable, at least five positive and three negative test cases, and selected country or region availability.", + "retrieved 2026-09-02: these are portal-owned distribution checks; the compiler neither submits plugins nor claims to validate reviewer credentials, identity, public services, policy compliance, or regional readiness." + ], + "reason": "Public submission requirements are portal-owned distribution policy with no compiler-authored or compiler-validation surface.", + "state": "unavailable" + } + }, "skills": true }, "validation": { diff --git a/packages/agent-bundle/src/adapters/codex.ts b/packages/agent-bundle/src/adapters/codex.ts index 94d47e094..99cbc5580 100644 --- a/packages/agent-bundle/src/adapters/codex.ts +++ b/packages/agent-bundle/src/adapters/codex.ts @@ -56,12 +56,28 @@ import { deepFreeze } from '../core/freeze.ts'; export interface CodexConfigExtension { - codex?: AgentBundleHostConfig; + codex?: CodexHostConfig; +} + +/** Documented publisher identity in `.codex-plugin/plugin.json`. */ +export interface CodexAuthorConfig { + readonly email?: string; + readonly name: string; + readonly url?: string; +} + +/** Codex-only authored package metadata layered onto the generated manifest. */ +export interface CodexHostConfig extends AgentBundleHostConfig { + readonly author?: CodexAuthorConfig; + readonly homepage?: string; + readonly keywords?: readonly string[]; + readonly license?: string; + readonly repository?: string; } declare module '../core/types.ts' { interface AgentBundleConfigExtensions { - codex?: AgentBundleHostConfig; + codex?: CodexHostConfig; } } @@ -80,29 +96,10 @@ const validateMcp = validator.compile(mcpSchema); const validateMarketplace = validator.compile(marketplaceSchema); /** - * The pinned schema const-locks the manifest's `mcpServers` pointer to the - * conventional root path. The field is a path pointer, so a plan that - * relocates the MCP document widens the validator to accept exactly the - * conventional path or the requested relocation - nothing wider - keeping the - * pointer and its validator impossible to configure apart. + * The pinned schema admits every documented plugin-root-relative component + * path. Standalone and unified plans still emit their canonical paths. */ -const relocatedPluginValidators = new Map(); -const pluginValidatorFor = (mcpRelativePath: string): ValidateFunction => { - if (mcpRelativePath === codexArtifactPaths.mcp) return validatePlugin; - let compiled = relocatedPluginValidators.get(mcpRelativePath); - if (compiled === undefined) { - const cloned = structuredClone(pluginSchema) as Record; - // The clone must not reuse the pinned schema's registered $id. - delete cloned['$id']; - const properties = cloned['properties'] as Record>; - const canonical = properties['mcpServers']?.['const']; - if (typeof canonical !== 'string') throw new Error('Pinned Codex plugin schema mcpServers pointer is not a const string.'); - properties['mcpServers'] = { enum: [canonical, `./${mcpRelativePath}`], type: 'string' }; - compiled = validator.compile(cloned); - relocatedPluginValidators.set(mcpRelativePath, compiled); - } - return compiled; -}; +const pluginValidatorFor = (_mcpRelativePath: string): ValidateFunction => validatePlugin; /** Wrapped manifest validator for a plan whose MCP document path was relocated. */ export const codexPluginDocumentValidator = (mcpRelativePath: string): TargetArtifactDocumentValidator => @@ -128,7 +125,7 @@ const hookContract = Object.freeze({ wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Codex'), } satisfies TargetHookContract); const metadata = Object.freeze({ - adapterRevision: '1.2.0', + adapterRevision: '1.3.0', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); @@ -162,6 +159,172 @@ const mcpRuntime = createTargetMcpRuntime({ const { errorDiagnostic, schemaDiagnostics } = createTargetDiagnostics(codexName, 'Codex'); +const isPlainDataRecord = (value: unknown): value is Readonly> => + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + [null, Object.prototype].includes(Object.getPrototypeOf(value)); + +const isNonemptyString = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0; + +const isAbsoluteUrl = (value: unknown): value is string => { + if (!isNonemptyString(value)) return false; + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +}; + +const isEmail = (value: unknown): value is string => + isNonemptyString(value) && /^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(value); + +interface CodexManifestMetadataPlan { + readonly diagnostics: readonly Diagnostic[]; + readonly document?: Readonly>; + readonly sourceInputs: readonly string[]; +} + +const noManifestMetadataPlan: CodexManifestMetadataPlan = deepFreeze({ + diagnostics: [], + sourceInputs: [], +}); + +const manifestMetadataDiagnostic = ( + code: string, + message: string, + recovery: string, +): Diagnostic => ({ + ...errorDiagnostic(code, message), + recovery, +}); + +const planCodexManifestMetadata = (model: NormalizedPlugin): CodexManifestMetadataPlan => { + const extension = model.extensions[codexName]; + if (extension === undefined || !isPlainDataRecord(extension.value)) return noManifestMetadataPlan; + const author = extension.value['author']; + const homepage = extension.value['homepage']; + const keywords = extension.value['keywords']; + const license = extension.value['license']; + const repository = extension.value['repository']; + if ( + author === undefined && + homepage === undefined && + keywords === undefined && + license === undefined && + repository === undefined + ) { + return noManifestMetadataPlan; + } + + const diagnostics: Diagnostic[] = []; + let plannedAuthor: Readonly> | undefined; + if (author !== undefined) { + if (!isPlainDataRecord(author)) { + diagnostics.push(manifestMetadataDiagnostic( + 'codex.manifest.author.invalid', + 'Codex author must be a plain object.', + 'Set codex.author to an object containing name and optional email and url strings, or remove it.', + )); + } else { + const unknownFields = Object.keys(author).filter((field) => !['email', 'name', 'url'].includes(field)); + if (unknownFields.length > 0) { + diagnostics.push(manifestMetadataDiagnostic( + 'codex.manifest.author.invalid', + `Codex author contains unsupported field${unknownFields.length === 1 ? '' : 's'} ${unknownFields.map((field) => JSON.stringify(field)).join(', ')}.`, + 'Keep only codex.author.name, codex.author.email, and codex.author.url.', + )); + } + const name = author['name']; + const email = author['email']; + const url = author['url']; + if (!isNonemptyString(name)) { + diagnostics.push(manifestMetadataDiagnostic( + 'codex.manifest.author.name.invalid', + 'Codex author.name must be a nonempty string after trimming whitespace.', + 'Set codex.author.name to the author or team name.', + )); + } + if (email !== undefined && !isEmail(email)) { + diagnostics.push(manifestMetadataDiagnostic( + 'codex.manifest.author.email.invalid', + 'Codex author.email must be a valid nonempty email address.', + 'Set codex.author.email to a contact email address, or remove it.', + )); + } + if (url !== undefined && !isAbsoluteUrl(url)) { + diagnostics.push(manifestMetadataDiagnostic( + 'codex.manifest.author.url.invalid', + 'Codex author.url must be an absolute HTTP or HTTPS URL.', + 'Set codex.author.url to the author or team homepage, or remove it.', + )); + } + if ( + unknownFields.length === 0 && + isNonemptyString(name) && + (email === undefined || isEmail(email)) && + (url === undefined || isAbsoluteUrl(url)) + ) { + plannedAuthor = Object.freeze({ + ...(email === undefined ? {} : { email }), + name, + ...(url === undefined ? {} : { url }), + }); + } + } + } + for (const [field, value] of [['homepage', homepage], ['repository', repository]] as const) { + if (value !== undefined && !isAbsoluteUrl(value)) { + diagnostics.push(manifestMetadataDiagnostic( + `codex.manifest.${field}.invalid`, + `Codex ${field} must be an absolute HTTP or HTTPS URL.`, + `Set codex.${field} to an absolute URL, or remove it.`, + )); + } + } + if (license !== undefined && !isNonemptyString(license)) { + diagnostics.push(manifestMetadataDiagnostic( + 'codex.manifest.license.invalid', + 'Codex license must be a nonempty string after trimming whitespace.', + 'Set codex.license to a license identifier such as MIT or Apache-2.0, or remove it.', + )); + } + if ( + keywords !== undefined && + ( + !Array.isArray(keywords) || + keywords.some((keyword) => !isNonemptyString(keyword)) + ) + ) { + const invalidIndex = Array.isArray(keywords) + ? keywords.findIndex((keyword) => !isNonemptyString(keyword)) + : undefined; + diagnostics.push(manifestMetadataDiagnostic( + 'codex.manifest.keywords.invalid', + invalidIndex === undefined + ? 'Codex keywords must be an array of nonempty strings.' + : `Codex keywords[${invalidIndex}] must be a nonempty string after trimming whitespace.`, + 'Set codex.keywords to discovery tags such as ["research", "crm"], or remove it.', + )); + } + + const inputs = [extension.provenance.sourcePath]; + if (diagnostics.length > 0) return { diagnostics, sourceInputs: inputs }; + return { + diagnostics, + document: Object.freeze({ + ...(plannedAuthor === undefined ? {} : { author: plannedAuthor }), + ...(homepage === undefined ? {} : { homepage }), + ...(keywords === undefined ? {} : { keywords }), + ...(license === undefined ? {} : { license }), + ...(repository === undefined ? {} : { repository }), + }), + sourceInputs: inputs, + }; +}; + const hasLeadingPluginRoot = (value: string): boolean => value === pathTokens.pluginRoot || value.startsWith(`${pathTokens.pluginRoot}/`); @@ -347,6 +510,8 @@ export const planCodexArtifacts = ( diagnostics.push(...nativeHooks.diagnostics); const hookDocument = mergeHookDocuments(generatedHooks.document, nativeHooks.document); const hookDocumentValid = hookDocument !== undefined && validateHooks(hookDocument); + const manifestMetadata = planCodexManifestMetadata(model); + diagnostics.push(...manifestMetadata.diagnostics); const description = model.metadata.description ?? model.metadata.name; const interfaceMetadata = { @@ -360,6 +525,7 @@ export const planCodexArtifacts = ( }; const plugin = { author: { name: model.metadata.name }, + ...manifestMetadata.document, description, interface: { ...interfaceMetadata, @@ -391,6 +557,7 @@ export const planCodexArtifacts = ( diagnostics.push(...schemaDiagnostics('marketplace', marketplaceValid, validateMarketplace.errors)); return withInstallSurface(standardPluginArtifactPlan({ + additionalPluginSourceInputs: manifestMetadata.sourceInputs, diagnostics, hookDocument, hookDocumentValid, @@ -441,6 +608,18 @@ export const codexAdapter: TargetAdapter = Object.freeze({ evidence, 'The pinned Codex plugin contract does not support skills.', ), + manifestMetadata: supportedCapability(evidence), + manifestPaths: Object.freeze({ + evidence, + reason: capabilityTable.plugin.manifestPackage.manifestPaths.reason, + state: 'degraded', + }), + optionalAssets: unavailableCapability( + capabilityTable.plugin.manifestPackage.optionalAssets.reason, + ), + submissionPolicy: unavailableCapability( + capabilityTable.plugin.manifestPackage.submissionPolicy.reason, + ), }), configExtension: Object.freeze({ key: codexName }), hookContract, diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index 11740548d..8c55fd96e 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -186,7 +186,7 @@ const artifactValidation = deepFreeze({ }); const metadata = Object.freeze({ - adapterRevision: '1.17.0', + adapterRevision: '1.18.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 @@ -724,15 +724,21 @@ export const pluginAdapter: TargetAdapter = Object.freeze({ // honestly unavailable even though the Claude half still emits `.lsp.json`. lsp: intersectCapabilityStates(claudeAdapter.capabilities.lsp!, codexAdapter.capabilities.lsp!), manifestMetadata: intersectCapabilityStates( - claudeAdapter.capabilities.manifestMetadata!, + intersectCapabilityStates( + claudeAdapter.capabilities.manifestMetadata!, + codexAdapter.capabilities.manifestMetadata!, + ), unavailableCapability( - 'The pinned Codex and Cursor plugin contracts do not share Claude manifest metadata fields; displayName, metadata, and defaultEnabled reach Claude Code only.', + 'The pinned Cursor plugin contract does not share the authored Codex and Claude manifest metadata fields.', ), ), manifestPaths: intersectCapabilityStates( - claudeAdapter.capabilities.manifestPaths!, + intersectCapabilityStates( + claudeAdapter.capabilities.manifestPaths!, + codexAdapter.capabilities.manifestPaths!, + ), unavailableCapability( - 'The unified bundle emits canonical default component directories and the pinned Codex and Cursor contracts do not share Claude custom manifest path rules.', + 'The pinned Cursor plugin contract does not share the Codex and Claude custom manifest path rules.', ), ), mcp: intersectCapabilityStates( diff --git a/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json index 6f2e2aa45..3ce53a617 100644 --- a/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json +++ b/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json @@ -1,8 +1,16 @@ { "observedCliVersion": "0.147.0", - "retrievedAt": "2026-09-01", + "retrievedAt": "2026-09-02", "schemaSource": "https://github.com/openai/codex/blob/main/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md", - "notes": "The generated/subagent-{start,stop}.command.{input,output}.schema.json evidence snapshots are byte-pinned from the rust-v0.147.0 tag at https://github.com/openai/codex/tree/rust-v0.147.0/codex-rs/hooks/schema/generated. Repository text files add one POSIX trailing newline; adapter-metadata.test.ts removes only that byte before comparing the authoritative upstream SHA-256 digests recorded in codex-0.147.0.json.", + "notes": "plugin.schema.json transcribes the 2026-09-02 documented package manifest rather than a host-exported schema because Codex 0.147.0 publishes no plugin validate command. Skills stays optional so MCP-only plugins admitted by the documented packaging and submission flows validate. The generated/subagent-{start,stop}.command.{input,output}.schema.json evidence snapshots are byte-pinned from the rust-v0.147.0 tag at https://github.com/openai/codex/tree/rust-v0.147.0/codex-rs/hooks/schema/generated. Repository text files add one POSIX trailing newline; adapter-metadata.test.ts removes only that byte before comparing the authoritative upstream SHA-256 digests recorded in codex-0.147.0.json.", + "schemaTightenings": { + "plugin.schema.json": [ + "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." + ] + }, "schemas": { "hooks.schema.json": { "bytes": 1107, @@ -20,8 +28,8 @@ "url": "https://github.com/openai/codex/blob/main/codex-rs/core/config.schema.json" }, "plugin.schema.json": { - "bytes": 1728, - "sha256": "f6e8e7d2ecb48c50ffa850d1a8190ad85ceffec705b8f0f39bb44a1d10aca0d9", + "bytes": 3894, + "sha256": "a3f24cd9f639b856937ae74fe967340bcce6966d131b0f45c628c78d9ac373f3", "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/plugin.schema.json b/packages/agent-bundle/src/adapters/schemas/codex/plugin.schema.json index b22c155bc..af82f2bf3 100644 --- a/packages/agent-bundle/src/adapters/schemas/codex/plugin.schema.json +++ b/packages/agent-bundle/src/adapters/schemas/codex/plugin.schema.json @@ -1,15 +1,78 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://agent-bundle.dev/schemas/codex/0.147.0/plugin.schema.json", + "$defs": { + "componentPath": { + "pattern": "^\\./(?!(?:.*\\/)?\\.\\.(?:\\/|$)).+", + "type": "string" + }, + "hookDocument": { + "additionalProperties": false, + "properties": { + "description": { "type": "string" }, + "hooks": { + "additionalProperties": { + "items": { + "additionalProperties": false, + "properties": { + "hooks": { + "items": { + "additionalProperties": false, + "properties": { + "command": { "minLength": 1, "type": "string" }, + "timeout": { "minimum": 1, "type": "integer" }, + "type": { "const": "command" } + }, + "required": ["type", "command"], + "type": "object" + }, + "minItems": 1, + "type": "array" + }, + "matcher": { "type": "string" } + }, + "required": ["hooks"], + "type": "object" + }, + "type": "array" + }, + "type": "object" + } + }, + "required": ["hooks"], + "type": "object" + } + }, "additionalProperties": false, "properties": { "author": { "additionalProperties": false, - "properties": { "name": { "minLength": 1, "type": "string" } }, + "properties": { + "email": { "format": "email", "type": "string" }, + "name": { "minLength": 1, "type": "string" }, + "url": { "format": "uri", "pattern": "^https?://", "type": "string" } + }, "required": ["name"], "type": "object" }, "description": { "minLength": 1, "type": "string" }, + "homepage": { "format": "uri", "pattern": "^https?://", "type": "string" }, + "hooks": { + "oneOf": [ + { "$ref": "#/$defs/componentPath" }, + { + "items": { "$ref": "#/$defs/componentPath" }, + "minItems": 1, + "type": "array" + }, + { "$ref": "#/$defs/hookDocument" }, + { + "items": { "$ref": "#/$defs/hookDocument" }, + "minItems": 1, + "type": "array" + } + ] + }, "interface": { "additionalProperties": false, "properties": { @@ -24,12 +87,25 @@ "required": ["displayName", "shortDescription", "longDescription", "developerName", "category", "capabilities", "defaultPrompt"], "type": "object" }, - "mcpServers": { "const": "./.mcp.json", "type": "string" }, - "hooks": { "const": "./hooks/hooks.json", "type": "string" }, + "keywords": { + "items": { "minLength": 1, "type": "string" }, + "type": "array" + }, + "license": { "minLength": 1, "type": "string" }, + "mcpServers": { + "oneOf": [ + { "$ref": "#/$defs/componentPath" }, + { + "additionalProperties": { "type": "object" }, + "type": "object" + } + ] + }, "name": { "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "type": "string" }, - "skills": { "const": "./skills/", "type": "string" }, + "repository": { "format": "uri", "pattern": "^https?://", "type": "string" }, + "skills": { "$ref": "#/$defs/componentPath" }, "version": { "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$", "type": "string" } }, - "required": ["name", "version", "description", "author", "interface", "skills"], + "required": ["name", "version", "description", "author", "interface"], "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 8a6bf33a1..7245cb20f 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -10,6 +10,7 @@ import { unionCapabilityStates, } from '../src/adapters/capability-state.ts'; import claudeCapabilityTable from '../src/adapters/capabilities/claude-2.1.250.json' with { type: 'json' }; +import codexCapabilityTable from '../src/adapters/capabilities/codex-0.147.0.json' with { type: 'json' }; import { TargetRegistry, createDefaultRegistry } from '../src/adapters/registry.ts'; import { CapabilityStateError, isCapabilityState } from '../src/core/capabilities.ts'; import type { CapabilityEvidence, CapabilityState } from '../src/core/capabilities.ts'; @@ -556,7 +557,7 @@ it.each([ reason: expect.stringContaining(reason), state: 'unavailable', }); - for (const target of ['codex', 'cursor', 'portable'] as const) { + for (const target of ['cursor', 'portable'] as const) { expect(registry.get(target).capabilities[capability]).toBeUndefined(); expect(registry.supports(target, capability)).toBe(false); } @@ -564,6 +565,102 @@ it.each([ expect(registry.supports('plugin', capability)).toBe(false); }); +const codexManifestPackageCapabilities = [ + 'manifestMetadata', + 'manifestPaths', + 'optionalAssets', + 'submissionPolicy', +] as const; + +it('records dated Codex manifest and package capability rows', () => { + const registry = createDefaultRegistry(); + const manifestPackage = codexCapabilityTable.plugin.manifestPackage; + + expect(Object.keys(manifestPackage).sort()).toEqual([...codexManifestPackageCapabilities].sort()); + for (const capability of codexManifestPackageCapabilities) { + const row = manifestPackage[capability]; + expect(row.evidence.length).toBeGreaterThan(0); + expect(row.evidence.every((line) => line.startsWith('retrieved 2026-09-02:'))).toBe(true); + const state = registry.get('codex').capabilities[capability]; + expect(state?.state).toBe(row.state); + if ('reason' in row) { + expect(state).toMatchObject({ reason: row.reason }); + } else { + expect(state).toMatchObject({ evidence: { target: 'codex' } }); + } + } + + expect(manifestPackage.manifestMetadata.fields).toEqual([ + 'author.name', + 'author.email', + 'author.url', + 'homepage', + 'repository', + 'license', + 'keywords', + ]); + expect(manifestPackage.manifestPaths).toMatchObject({ + admitted: { + hooks: ['path', 'path-array', 'inline-object', 'inline-object-array'], + mcpServers: ['path', 'inline-object'], + skills: ['path'], + }, + emitted: { + hooks: './hooks/hooks.json', + mcpServers: './.mcp.json', + skills: './skills/', + }, + state: 'degraded', + }); + expect(manifestPackage.optionalAssets.assets).toEqual([ + 'interface.composerIcon', + 'interface.logo', + 'interface.logoDark', + 'interface.screenshots', + ]); + expect(manifestPackage.submissionPolicy.constraints).toEqual([ + 'Apps Management write access', + 'verified developer or business identity', + 'listing and policy URLs', + 'production MCP review materials', + 'five positive and three negative test cases', + 'country or region availability', + 'release notes and policy attestations', + ]); +}); + +it('mirrors Codex manifest metadata and path states through the unified adapter', () => { + const registry = createDefaultRegistry(); + + expect(registry.get('codex').capabilities.manifestMetadata).toMatchObject({ + evidence: { target: 'codex' }, + state: 'supported', + }); + expect(registry.get('codex').capabilities.manifestPaths).toMatchObject({ + evidence: { target: 'codex' }, + reason: expect.stringContaining('canonical'), + state: 'degraded', + }); + expect(registry.get('plugin').capabilities.manifestMetadata).toEqual(intersectCapabilityStates( + intersectCapabilityStates( + registry.get('claude').capabilities.manifestMetadata!, + registry.get('codex').capabilities.manifestMetadata!, + ), + unavailableCapability( + 'The pinned Cursor plugin contract does not share the authored Codex and Claude manifest metadata fields.', + ), + )); + expect(registry.get('plugin').capabilities.manifestPaths).toEqual(intersectCapabilityStates( + intersectCapabilityStates( + registry.get('claude').capabilities.manifestPaths!, + registry.get('codex').capabilities.manifestPaths!, + ), + unavailableCapability( + 'The pinned Cursor plugin contract does not share the Codex and Claude custom manifest path rules.', + ), + )); +}); + it('intersects supported composite capabilities and merges both evidence records', () => { const intersection = intersectCapabilityStates( supportedCapability(evidence('claude')), diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index 109a32adf..becfbb118 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -67,7 +67,7 @@ it('records exact immutable metadata for every built-in target', () => { ], }); expect(registryMetadata(registry, 'codex')).toEqual({ - adapterRevision: '1.2.0', + adapterRevision: '1.3.0', observedVersion: '0.147.0', schemas: [ { @@ -88,7 +88,7 @@ it('records exact immutable metadata for every built-in target', () => { { name: 'plugin', revision: '0.147.0', - sha256: 'f6e8e7d2ecb48c50ffa850d1a8190ad85ceffec705b8f0f39bb44a1d10aca0d9', + sha256: 'a3f24cd9f639b856937ae74fe967340bcce6966d131b0f45c628c78d9ac373f3', }, ], }); @@ -164,7 +164,7 @@ it('records exact immutable metadata for every built-in target', () => { }, ], }); - expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.17.0'); + expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.18.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 78b4764cf..42288005a 100644 --- a/packages/agent-bundle/tests/host-adapters.native.test.ts +++ b/packages/agent-bundle/tests/host-adapters.native.test.ts @@ -6,6 +6,7 @@ import { join } from 'node:path'; import { expect, it } from '@rstest/core'; import { claudeAdapter } from '../src/adapters/claude.ts'; +import { codexAdapter } from '../src/adapters/codex.ts'; import { emitPlanEntries } from '../src/build/emit.ts'; import { pathTokens, type NormalizedPlugin } from '../src/core/types.ts'; @@ -16,6 +17,12 @@ interface ClaudeValidation { readonly output: string; } +interface CodexValidation { + readonly code: number | null; + readonly stderr: string; + readonly stdout: string; +} + const runClaude = async ( cwd: string, args: readonly string[], @@ -45,6 +52,31 @@ const runClaude = async ( const runClaudeValidation = async (cwd: string, target: string): Promise => runClaude(cwd, ['plugin', 'validate', '--strict', target]); +const runCodex = async ( + cwd: string, + args: readonly string[], + codexHome: string, +): Promise => + new Promise((resolvePromise, reject) => { + const child = spawn('codex', args, { + cwd, + env: { ...process.env, CODEX_HOME: codexHome }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stderr = ''; + let stdout = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.once('error', reject); + child.once('close', (code) => resolvePromise({ code, stderr, stdout })); + }); + const model: NormalizedPlugin = { extensions: {}, hooks: [], @@ -184,6 +216,84 @@ const writeClaudeArtifact = async ( return written; }; +nativeIt('registers an emitted Codex plugin carrying authored package metadata', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-codex-manifest-metadata-')); + const pluginRoot = join(root, 'plugin'); + const codexHome = join(root, 'codex-home'); + const metadataModel: NormalizedPlugin = { + ...model, + extensions: { + codex: { + id: 'extension:codex', + key: 'codex', + provenance: { kind: 'config', sourcePath: '/workspace/codex.config.ts' }, + target: 'codex', + value: { + author: { + email: 'plugins@example.test', + name: 'Review Tools Team', + url: 'https://example.test/review-tools', + }, + homepage: 'https://example.test/review-tools/docs', + keywords: ['review', 'security'], + license: 'MIT', + repository: 'https://github.com/example/review-tools', + }, + }, + }, + }; + + try { + await Promise.all([ + mkdir(pluginRoot, { recursive: true }), + mkdir(codexHome, { recursive: true }), + ]); + const plan = codexAdapter.plan(metadataModel); + expect(plan.diagnostics).toEqual([]); + await emitPlanEntries({ entries: plan.entries, root: pluginRoot }); + + const version = await runCodex(root, ['--version'], codexHome); + expect(version.code, version.stderr).toBe(0); + expect(version.stdout).toContain('0.147.0'); + const marketplace = await runCodex( + root, + ['plugin', 'marketplace', 'add', pluginRoot], + codexHome, + ); + expect(marketplace.code, marketplace.stderr).toBe(0); + const installed = await runCodex( + root, + ['plugin', 'add', 'review-tools@review-tools-marketplace', '--json'], + codexHome, + ); + expect(installed.code, installed.stderr).toBe(0); + expect(JSON.parse(installed.stdout)).toMatchObject({ name: 'review-tools' }); + const listed = await runCodex(root, ['plugin', 'list', '--json'], codexHome); + expect(listed.code, listed.stderr).toBe(0); + const listedDocument = JSON.parse(listed.stdout) as { + readonly installed: readonly Record[]; + }; + expect(listedDocument).toMatchObject({ + available: [], + installed: [{ + enabled: true, + installed: true, + marketplaceName: 'review-tools-marketplace', + name: 'review-tools', + pluginId: 'review-tools@review-tools-marketplace', + version: '1.2.3', + }], + }); + expect(listedDocument.installed[0]).not.toHaveProperty('author'); + expect(listedDocument.installed[0]).not.toHaveProperty('homepage'); + expect(listedDocument.installed[0]).not.toHaveProperty('keywords'); + expect(listedDocument.installed[0]).not.toHaveProperty('license'); + expect(listedDocument.installed[0]).not.toHaveProperty('repository'); + } 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 9ddaef59f..d0ae6ed3b 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -241,6 +241,23 @@ const withClaudeManifestMetadata = ( }, }); +const withCodexManifestMetadata = ( + model: NormalizedPlugin, + manifestMetadata: Readonly>, + target = 'codex', +): NormalizedPlugin => ({ + ...model, + extensions: { + codex: { + id: 'extension:codex', + key: 'codex', + provenance: { kind: 'config', sourcePath: '/workspace/codex-manifest.config.ts' }, + target, + value: manifestMetadata, + }, + }, +}); + const withClaudeMarketplace = ( model: NormalizedPlugin, marketplace: unknown, @@ -937,6 +954,173 @@ it('pins the full closed Claude marketplace schema with the documented source ma } }); +it('emits validated Codex manifest package metadata with extension provenance', async () => { + const model = withCodexManifestMetadata(plugin, { + author: { + email: 'plugins@example.test', + name: 'Review Tools Team', + url: 'https://example.test/review-tools', + }, + homepage: 'https://example.test/review-tools/docs', + keywords: ['review', 'security'], + license: 'MIT', + repository: 'https://github.com/example/review-tools', + }); + const plan = createDefaultRegistry().get('codex').plan(model); + const manifest = plan.entries.find((entry) => entry.relativePath === '.codex-plugin/plugin.json'); + + expect(plan.diagnostics).toEqual([]); + expect(manifest).toMatchObject({ + kind: 'write', + sourceInputs: [ + '/workspace/agent-bundle.config.ts', + '/workspace/src/skills/review/SKILL.md', + '/workspace/codex-manifest.config.ts', + ], + }); + if (manifest?.kind !== 'write') throw new Error('Expected an emitted Codex plugin manifest.'); + expect(JSON.parse(manifest.content)).toMatchObject({ + author: { + email: 'plugins@example.test', + name: 'Review Tools Team', + url: 'https://example.test/review-tools', + }, + homepage: 'https://example.test/review-tools/docs', + keywords: ['review', 'security'], + license: 'MIT', + repository: 'https://github.com/example/review-tools', + }); + await validateDocuments('codex', writeContents(model, 'codex')); +}); + +it.each([ + { + code: 'codex.manifest.author.invalid', + declaration: { author: null }, + label: 'a null author', + }, + { + code: 'codex.manifest.author.invalid', + declaration: { author: { name: 'Review Tools', unknown: true } }, + label: 'an author with an unknown field', + }, + { + code: 'codex.manifest.author.name.invalid', + declaration: { author: { email: 'plugins@example.test' } }, + label: 'an author without a name', + }, + { + code: 'codex.manifest.author.name.invalid', + declaration: { author: { name: ' ' } }, + label: 'a whitespace-only author name', + }, + { + code: 'codex.manifest.author.email.invalid', + declaration: { author: { email: 'not-an-email', name: 'Review Tools' } }, + label: 'an invalid author email', + }, + { + code: 'codex.manifest.author.url.invalid', + declaration: { author: { name: 'Review Tools', url: './profile' } }, + label: 'a relative author URL', + }, + { + code: 'codex.manifest.homepage.invalid', + declaration: { homepage: './docs' }, + label: 'a relative homepage', + }, + { + code: 'codex.manifest.repository.invalid', + declaration: { repository: 7 }, + label: 'a non-string repository', + }, + { + code: 'codex.manifest.license.invalid', + declaration: { license: ' ' }, + label: 'a whitespace-only license', + }, + { + code: 'codex.manifest.keywords.invalid', + declaration: { keywords: 'review' }, + label: 'non-array keywords', + }, + { + code: 'codex.manifest.keywords.invalid', + declaration: { keywords: ['review', ' '] }, + label: 'keywords with an empty entry', + }, +])('rejects $label without emitting authored Codex manifest metadata', ({ code, declaration }) => { + const plan = createDefaultRegistry().get('codex').plan(withCodexManifestMetadata(plugin, declaration)); + const manifest = plan.entries.find((entry) => entry.relativePath === '.codex-plugin/plugin.json'); + + expect(plan.diagnostics).toContainEqual(expect.objectContaining({ + code, + recovery: expect.any(String), + severity: 'error', + target: 'codex', + })); + if (manifest?.kind !== 'write') throw new Error('Expected the base Codex plugin manifest.'); + const document = JSON.parse(manifest.content) as Record; + expect(document.author).toEqual({ name: 'review-tools' }); + expect(document).not.toHaveProperty('homepage'); + expect(document).not.toHaveProperty('repository'); + expect(document).not.toHaveProperty('license'); + expect(document).not.toHaveProperty('keywords'); +}); + +it('admits documented Codex component path and inline manifest forms', async () => { + const schema = (await import('../src/adapters/schemas/codex/plugin.schema.json', { + with: { type: 'json' }, + })).default; + const validator = new Ajv2020({ allErrors: true, strict: false }); + installFormats(validator); + const validate = validator.compile(schema); + const manifest = { + author: { name: 'Review Tools' }, + description: 'Codex path-form schema fixture.', + interface: { + capabilities: ['skills'], + category: 'Productivity', + defaultPrompt: ['Review this repository.'], + developerName: 'Review Tools', + displayName: 'Review Tools', + longDescription: 'Review this repository with reusable workflows.', + shortDescription: 'Repository review workflows.', + }, + name: 'codex-path-fixture', + version: '1.0.0', + }; + const hookDocument = { + hooks: { + SessionStart: [{ + hooks: [{ command: 'node "${PLUGIN_ROOT}/hooks/start.mjs"', type: 'command' }], + }], + }, + }; + + for (const componentFields of [ + { mcpServers: './config/mcp.json' }, + { hooks: './custom/hooks.json', mcpServers: './config/mcp.json', skills: './workflows/' }, + { hooks: ['./hooks/start.json', './hooks/tools.json'], skills: './skills/' }, + { hooks: hookDocument, mcpServers: { docs: { type: 'http', url: 'https://example.test/mcp' } }, skills: './skills/' }, + { hooks: [hookDocument], skills: './skills/' }, + ]) { + expect(validate({ ...manifest, ...componentFields }), JSON.stringify(validate.errors)).toBe(true); + } + for (const invalid of [ + { hooks: '../hooks.json', skills: './skills/' }, + { hooks: ['./hooks.json', '../outside.json'], skills: './skills/' }, + { hooks: [], skills: './skills/' }, + { hooks: [{ description: 'missing hooks map' }], skills: './skills/' }, + { mcpServers: '../.mcp.json', skills: './skills/' }, + { mcpServers: { docs: 'not-an-object' }, skills: './skills/' }, + { skills: '../skills/' }, + { skills: ['./skills/'] }, + ]) { + expect(validate({ ...manifest, ...invalid })).toBe(false); + } +}); + it('plans byte-stable native Codex and Claude plugin trees from the same frozen model', async () => { const registry = createDefaultRegistry(); expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor', 'plugin']);