diff --git a/.changeset/codex-marketplace-policy.md b/.changeset/codex-marketplace-policy.md new file mode 100644 index 000000000..714004378 --- /dev/null +++ b/.changeset/codex-marketplace-policy.md @@ -0,0 +1,5 @@ +--- +'agent-bundle': minor +--- + +Author the Codex marketplace entry (`codex.marketplace` displayName, category, and documented `policy.installation` / `policy.authentication` values, with the category following the plugin interface category; `installation: NOT_AVAILABLE` fails the build with `codex.marketplace.policy.installation.not-installable` because live `codex plugin add` refuses it and the emitted `INSTALL.md` / `installBundle()` run that command), admit every documented marketplace source form (local string or object, Git root `url`, `git-subdir`, `npm`) in the pinned marketplace schema with structurally validated Git and registry URLs, and publish dated four-state Codex distribution rows for marketplace discovery, sources, cache layout, enable state, `codex plugin` / `codex plugin marketplace` JSON contracts, feature flags, managed `requirements.toml`, `allow_managed_hooks_only`, `restrict_to_allowed_sources`, and workspace publishing, backed by live codex-cli 0.147.0 probes. diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 9eb71ce1f..85f601bad 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -58,6 +58,24 @@ a `matcher` on `UserPromptSubmit` or `Stop`, and a `codex:WebSearch`-style hoste Hook trust (review by current hash in `/hooks`, plugin hooks skipped until trusted, managed hooks immutable) is host-owned and is recorded as unavailable rather than claimed. +The Codex artifact is also its own repo marketplace: `.agents/plugins/marketplace.json` carries one +local `./` entry whose `category` follows the plugin's interface category and whose +`policy.installation` / `policy.authentication` default to `AVAILABLE` / `ON_INSTALL`. +`codex.marketplace` authors the picker `displayName`, the `category`, and the documented policy +values, except `installation: NOT_AVAILABLE`, which fails the build +(`codex.marketplace.policy.installation.not-installable`) because live `codex plugin add` refuses +such entries and that is exactly the command the emitted `INSTALL.md` and `installBundle()` run. +The pinned marketplace schema also admits Git root (`url`), `git-subdir`, and `npm` sources for +validating real-world marketplaces (Git and registry URLs must carry a syntactically valid +host (DNS, IPv4, or bracketed IPv6), port, and path rather than a bare scheme prefix, and +npm `version` must be a semver version, range, or dist-tag, and Git `ref` must satisfy +`git check-ref-format`), but the adapter never emits them. Personal and legacy +`.claude-plugin/marketplace.json` discovery, the `~/.codex/plugins/cache` layout, `config.toml` +enable state, `features.plugins` / `features.hooks`, inline `[hooks]` TOML, `requirements.toml` +managed hooks, `allow_managed_hooks_only`, and `restrict_to_allowed_sources` are host- or +admin-owned and are recorded in `codex-0.147.0.json` (`distribution`) with dated evidence instead of +being claimed. + agent-bundle also owns the npm-facing package build: `bin` entries become self-executing `dist/bin/.js` bundles (shebang, executable bit, generated `main(argv)` envelope) and the optional `lib` entry becomes `dist/.js` with declarations (resolving `typescript` from the 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 5527c4730..d1217d565 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 @@ -12,6 +12,155 @@ "source": "https://developers.openai.com/codex/cli/reference", "state": "supported" }, + "marketplace": { + "policy": { + "authentication": ["ON_INSTALL", "ON_USE"], + "installation": ["AVAILABLE", "INSTALLED_BY_DEFAULT", "NOT_AVAILABLE"], + "notInstallable": "NOT_AVAILABLE" + } + }, + "distribution": { + "repoMarketplaceDiscovery": { + "evidence": [ + "retrieved 2026-09-02: https://developers.openai.com/plugins/build/plugins documents repo marketplaces at $REPO_ROOT/.agents/plugins/marketplace.json, resolves each entry's ./-prefixed source.path relative to the marketplace root (not the .agents/plugins folder), and lets codex plugin marketplace add register a local marketplace root directory.", + "retrieved 2026-09-02: the Codex adapter emits .agents/plugins/marketplace.json at the artifact root with one local entry whose source.path is ./, so the artifact is both the marketplace root and the plugin root; live codex-cli 0.147.0 registered such an artifact with codex plugin marketplace add and installed it with codex plugin add @-marketplace in isolated CODEX_HOME probes." + ], + "path": ".agents/plugins/marketplace.json", + "state": "supported" + }, + "personalMarketplaceDiscovery": { + "evidence": [ + "retrieved 2026-09-02: https://developers.openai.com/plugins/build/plugins documents the personal marketplace at ~/.agents/plugins/marketplace.json with plugin folders commonly under ./.codex/plugins/; live codex-cli 0.147.0 lists it as the implicit marketplace named personal rooted at $HOME in codex plugin marketplace list --json." + ], + "path": "~/.agents/plugins/marketplace.json", + "reason": "The personal marketplace is a user-owned file in the home directory; the compiler emits artifacts only and the installer registers the artifact root through codex plugin marketplace add instead of editing ~/.agents/plugins/marketplace.json.", + "state": "unavailable" + }, + "legacyClaudeMarketplaceCompatibility": { + "evidence": [ + "retrieved 2026-09-02: https://developers.openai.com/plugins/build/plugins lists a legacy-compatible marketplace at $REPO_ROOT/.claude-plugin/marketplace.json alongside the repo and personal marketplace locations." + ], + "path": ".claude-plugin/marketplace.json", + "reason": "The Codex adapter emits the Codex-native .agents/plugins/marketplace.json and never a .claude-plugin/marketplace.json; the legacy path is a host-side reading convenience for Claude-authored repositories, and the unified plugin target's Claude marketplace is emitted by the Claude adapter, not as a Codex compatibility artifact.", + "state": "unavailable" + }, + "marketplaceSources": { + "admitted": ["local", "local-string", "url", "git-subdir", "npm"], + "emitted": "local", + "evidence": [ + "retrieved 2026-09-02: https://developers.openai.com/plugins/build/plugins documents local entries as { source: \"local\", path: \"./...\" } or a plain ./-prefixed string, Git root entries as source \"url\" with url plus optional ref or sha, Git subdirectory entries as source \"git-subdir\" with url, ./-prefixed path, and ref or sha, and npm entries with required package (scope allowed), optional version (versions, dist-tags, ranges; no path or URL selectors), and optional registry (HTTPS without credentials, query, or fragment).", + "retrieved 2026-09-02: the same page states source.path must stay inside the marketplace root, Codex downloads npm packages without running lifecycle scripts, the npm CLI must be installed with registry authentication from its own configuration, and an unresolvable entry is skipped instead of failing the whole marketplace.", + "retrieved 2026-09-02: the pinned marketplace.schema.json admits every documented source form with root-contained paths, Git URL shapes, hexadecimal sha selectors, and credential-free HTTPS registries; the adapter emits only the local ./ entry that points at the plugin itself." + ], + "reason": "The emitted marketplace always describes the artifact's own plugin root as a local source; Git, git-subdir, and npm sources are validated for real-world marketplaces but not authored, and lifecycle-script prohibition plus unresolved-entry skipping are Codex install-time behavior.", + "state": "degraded" + }, + "marketplaceInterface": { + "evidence": [ + "retrieved 2026-09-02: https://developers.openai.com/plugins/build/plugins documents top-level name as the marketplace identifier and interface.displayName as the marketplace title shown in the picker; the adapter emits -marketplace with a displayName that defaults to the plugin name and is authorable through codex.marketplace.displayName." + ], + "state": "supported" + }, + "marketplacePolicy": { + "evidence": [ + "retrieved 2026-09-02: https://developers.openai.com/plugins/build/plugins requires policy.installation (AVAILABLE, INSTALLED_BY_DEFAULT, or NOT_AVAILABLE) and policy.authentication (install-time or first-use) on every entry; the adapter defaults to AVAILABLE and ON_INSTALL and accepts codex.marketplace.policy overrides validated against the documented values.", + "retrieved 2026-09-02: live codex-cli 0.147.0 echoed an authored INSTALLED_BY_DEFAULT/ON_USE entry back as installPolicy and authPolicy in codex plugin list --json and authPolicy in codex plugin add --json inside an isolated CODEX_HOME.", + "retrieved 2026-09-03: live codex-cli 0.147.0 accepted codex plugin marketplace add for a NOT_AVAILABLE entry but refused codex plugin add with `plugin is not available for install in marketplace `; because the emitted INSTALL.md and installBundle() run exactly that command, the adapter rejects authored NOT_AVAILABLE with codex.marketplace.policy.installation.not-installable instead of emitting a bundle whose own install path fails." + ], + "rejected": ["NOT_AVAILABLE"], + "state": "supported" + }, + "marketplaceCategory": { + "evidence": [ + "retrieved 2026-09-02: https://developers.openai.com/plugins/build/plugins requires category on every marketplace entry and does not enumerate values; the adapter emits the plugin's interface category (generated Productivity or the authored codex.interface.category) and accepts a codex.marketplace.category override." + ], + "state": "supported" + }, + "installCacheLayout": { + "evidence": [ + "retrieved 2026-09-02: https://developers.openai.com/plugins/build/plugins documents installs under ~/.codex/plugins/cache/$MARKETPLACE_NAME/$PLUGIN_NAME/$VERSION/ with $VERSION equal to local for local plugins and the installed copy loaded from that cache path.", + "2026-09-02: live codex-cli 0.147.0 installed a local-marketplace plugin to $CODEX_HOME/plugins/cache//// and reported that path as installedPath; the observed segment was the manifest version (1.0.0), not the documented literal local." + ], + "path": "~/.codex/plugins/cache////", + "reason": "The install cache is created and loaded by the host; the compiler neither writes it nor relies on the documented local version segment, which the pinned 0.147.0 CLI replaces with the manifest version.", + "state": "unavailable" + }, + "pluginEnableState": { + "evidence": [ + "retrieved 2026-09-02: https://developers.openai.com/plugins/build/plugins states each plugin's on or off state is stored in ~/.codex/config.toml; live codex-cli 0.147.0 wrote [plugins.\"@\"] enabled = true and a [marketplaces.] table with source_type, source, and last_updated on add, and removed both on codex plugin remove and codex plugin marketplace remove." + ], + "reason": "Enable state lives in the user's config.toml and is toggled by the host (/plugins, codex plugin add and remove); the compiler never writes config.toml.", + "state": "unavailable" + }, + "pluginCliLifecycle": { + "commands": ["add", "list", "remove"], + "evidence": [ + "retrieved 2026-09-02: https://learn.chatgpt.com/docs/developer-commands?surface=cli documents codex plugin add [--marketplace NAME] [--json], list [--marketplace NAME] [--available --json] [--json], and remove [--json]; add JSON prints pluginId, name, marketplaceName, version, installedPath, and authPolicy; list JSON prints installed and available arrays whose entries include pluginId, name, marketplaceName, version, installed, enabled, source, installPolicy, authPolicy, and optional marketplaceSource; remove JSON prints pluginId, name, and marketplaceName.", + "2026-09-02: live codex-cli 0.147.0 in an isolated CODEX_HOME matched every documented JSON field for add, list, list --available, and remove; agent-bundle install codex runs codex plugin marketplace add followed by codex plugin add @." + ], + "reason": "The installer drives codex plugin add after registering the artifact marketplace, but list and remove remain user-driven and the read-only Doctor pins no Codex inventory verb yet.", + "state": "degraded" + }, + "marketplaceCliLifecycle": { + "commands": ["add", "list", "upgrade", "remove"], + "evidence": [ + "retrieved 2026-09-02: https://learn.chatgpt.com/docs/developer-commands?surface=cli documents codex plugin marketplace add [--ref REF] [--sparse PATH] [--json] for GitHub shorthand, Git HTTP(S) or SSH URLs, and local roots, list [--json], remove [--json], and upgrade [name] [--json] for Git marketplaces; add JSON prints marketplaceName, installedRoot, and alreadyAdded; list JSON prints marketplaces with name, root, and optional marketplaceSource; upgrade JSON prints selectedMarketplaces, upgradedRoots, and errors; remove JSON prints marketplaceName and installedRoot.", + "2026-09-02: live codex-cli 0.147.0 in an isolated CODEX_HOME matched every documented JSON field; a second add of the same root returned alreadyAdded true, upgrade of a local marketplace exited 1 with `is not configured as a Git marketplace`, and remove of a local marketplace returned installedRoot null." + ], + "reason": "The installer drives codex plugin marketplace add for the artifact root, but list, upgrade, and remove remain user-driven and Git-backed marketplace sources are never emitted.", + "state": "degraded" + }, + "featurePlugins": { + "evidence": [ + "retrieved 2026-09-02: https://learn.chatgpt.com/docs/enterprise/managed-configuration documents features.plugins = false in requirements.toml to turn plugins off in supported local clients, including API-key sign-in, and https://developers.openai.com/plugins/build/plugins documents features.plugin_sharing = false to disable workspace plugin publishing." + ], + "reason": "features.plugins is user or admin configuration in config.toml or requirements.toml; the compiler emits plugin artifacts and cannot enable plugins where policy disables them.", + "state": "unavailable" + }, + "featureHooks": { + "evidence": [ + "retrieved 2026-09-02: https://learn.chatgpt.com/docs/hooks documents hooks as enabled by default, turned off with [features] hooks = false, codex_hooks as a deprecated alias, and admins forcing hooks off or pinning them on with [features].hooks in requirements.toml." + ], + "reason": "features.hooks and its deprecated codex_hooks alias are host feature flags in config.toml or requirements.toml; emitted plugin hooks load only where the flag is on.", + "state": "unavailable" + }, + "inlineHooksToml": { + "evidence": [ + "retrieved 2026-09-02: https://learn.chatgpt.com/docs/hooks documents inline [hooks] tables in config.toml as an alternative to hooks.json per config layer, merged with a startup warning when one layer defines both, and states plugins bundle hooks through the manifest or hooks/hooks.json instead." + ], + "reason": "Inline [hooks] TOML belongs to user, project, and managed config layers; plugins deliver hooks only through hooks/hooks.json or manifest hook entries, which the adapter emits.", + "state": "unavailable" + }, + "managedRequirements": { + "evidence": [ + "retrieved 2026-09-02: https://learn.chatgpt.com/docs/enterprise/managed-configuration composes requirements.toml from /etc/codex/requirements.toml (or %ProgramData%\\OpenAI\\Codex), cloud-managed bundles, legacy managed_config.toml, and macOS MDM com.openai.codex:requirements_toml_base64, and lets admins define managed hooks under [hooks] with managed_dir and windows_managed_dir plus command_windows overrides while scripts are distributed by MDM.", + "retrieved 2026-09-02: managed hooks are trusted by policy, cannot be disabled from the user hook browser, and must reference absolute script paths under the managed directory." + ], + "reason": "requirements.toml is admin-owned enterprise policy delivered outside the plugin package; the compiler cannot author managed hooks, managed_dir, or MDM payloads.", + "state": "unavailable" + }, + "allowManagedHooksOnly": { + "evidence": [ + "retrieved 2026-09-02: https://learn.chatgpt.com/docs/hooks and https://learn.chatgpt.com/docs/enterprise/managed-configuration document allow_managed_hooks_only = true as skipping user, project, session, and plugin hooks while still loading requirements.toml and other managed-layer hooks." + ], + "reason": "allow_managed_hooks_only is an admin requirement that disables plugin-bundled hooks outright; emitted hooks cannot opt out of or satisfy it.", + "state": "unavailable" + }, + "restrictToAllowedSources": { + "evidence": [ + "retrieved 2026-09-02: https://learn.chatgpt.com/docs/enterprise/managed-configuration documents [marketplaces] restrict_to_allowed_sources = true with allowed_sources rules of source git (normalized url plus optional exact ref), host_pattern (regex over the lowercase Git host), or local (absolute normalized path); unmatched marketplace add, plugin install, and Git refresh operations are rejected while already configured marketplaces are not filtered at runtime and Codex-managed OpenAI marketplaces stay available." + ], + "reason": "Marketplace source restriction is admin-owned requirements policy applied at add, install, and refresh time; a local artifact marketplace is admitted only if an allowed_sources local rule names its absolute path.", + "state": "unavailable" + }, + "workspacePublishing": { + "evidence": [ + "retrieved 2026-09-02: https://developers.openai.com/plugins/build/plugins documents workspace admins publishing a personal plugin to selected workspace roles from ChatGPT Plugins, scoped to the workspace boundary and separate from the universal public directory, and features.plugin_sharing = false in cloud-managed requirements to disable it." + ], + "reason": "Workspace publishing is a ChatGPT admin surface with no local artifact or CLI contract; public directory submission is recorded separately as submissionPolicy.", + "state": "unavailable" + } + }, "deferredNativeEvents": { "Interrupt": { "reason": "retrieved 2026-09-02: https://learn.chatgpt.com/docs/hooks now documents Interrupt (main-thread turn interruption; matcher ignored; one-second default and three-second maximum timeout; systemMessage-only output), but the pinned rust-v0.147.0 generated hook schema directory has no interrupt.command.{input,output}.schema.json (those files exist only on main), and Agent Bundle has no canonical interrupt event route (#97 owns route expansion). Deferred until the Codex pin moves to a release that ships the generated Interrupt schemas; authored native Interrupt hooks fail closed with codex.native-hooks.event.deferred.", diff --git a/packages/agent-bundle/src/adapters/codex.ts b/packages/agent-bundle/src/adapters/codex.ts index 396efbb0a..cb558df8b 100644 --- a/packages/agent-bundle/src/adapters/codex.ts +++ b/packages/agent-bundle/src/adapters/codex.ts @@ -90,6 +90,21 @@ export interface CodexAuthorConfig { readonly url?: string; } +/** Documented install and authentication policy for the emitted local marketplace entry. */ +export interface CodexMarketplacePolicyConfig { + readonly authentication?: 'ON_INSTALL' | 'ON_USE'; + readonly installation?: 'AVAILABLE' | 'INSTALLED_BY_DEFAULT' | 'NOT_AVAILABLE'; +} + +/** Authored fields of the emitted `.agents/plugins/marketplace.json`; the source stays the local plugin root. */ +export interface CodexMarketplaceConfig { + /** Marketplace-entry category; defaults to the plugin's interface category. */ + readonly category?: string; + /** Marketplace picker title; defaults to the plugin name. */ + readonly displayName?: string; + readonly policy?: CodexMarketplacePolicyConfig; +} + /** Codex-only authored package metadata and install-surface config layered onto the generated manifest. */ export interface CodexHostConfig extends AgentBundleHostConfig { /** Registered MCP connection mappings emitted to the root `.app.json` compatibility document. */ @@ -100,6 +115,7 @@ export interface CodexHostConfig extends AgentBundleHostConfig { readonly interface?: CodexInterfaceConfig; readonly keywords?: readonly string[]; readonly license?: string; + readonly marketplace?: CodexMarketplaceConfig; readonly repository?: string; } @@ -159,7 +175,7 @@ const hookContract = Object.freeze({ wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Codex'), } satisfies TargetHookContract); const metadata = Object.freeze({ - adapterRevision: '1.7.0', + adapterRevision: '1.8.0', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); @@ -180,6 +196,7 @@ const tableCapability = (row: { readonly reason?: string; readonly state: string }; const hookContractTable = capabilityTable.hooks.contract; +const distributionTable = capabilityTable.distribution; const codexReleaseHookEvents: readonly string[] = capabilityTable.hooks.releaseEvents; const codexHookRules = Object.freeze({ additionalContextEvents: hookContractTable.additionalContextLimit.additionalContextEvents as readonly string[], @@ -808,6 +825,125 @@ const planCodexApps = (model: NormalizedPlugin): CodexAppsPlan => { }; }; +const marketplaceTable = capabilityTable.marketplace; +const marketplaceAuthenticationPolicies: readonly string[] = marketplaceTable.policy.authentication; +const marketplaceInstallationPolicies: readonly string[] = marketplaceTable.policy.installation; +const marketplaceNotInstallablePolicy: string = marketplaceTable.policy.notInstallable; +const marketplaceConfigFields = Object.freeze(['category', 'displayName', 'policy']); +const marketplacePolicyFields = Object.freeze(['authentication', 'installation']); + +interface CodexMarketplacePlan { + readonly category: string; + readonly diagnostics: readonly Diagnostic[]; + readonly displayName: string; + readonly policy: { readonly authentication: string; readonly installation: string }; + readonly sourceInputs: readonly string[]; +} + +const planCodexMarketplace = (model: NormalizedPlugin, interfaceCategory: string): CodexMarketplacePlan => { + const defaults = { + category: interfaceCategory, + diagnostics: [] as readonly Diagnostic[], + displayName: model.metadata.name, + policy: { authentication: 'ON_INSTALL', installation: 'AVAILABLE' }, + sourceInputs: [] as readonly string[], + }; + const extension = model.extensions[codexName]; + const declared = extension !== undefined && isPlainDataRecord(extension.value) + ? extension.value['marketplace'] + : undefined; + if (declared === undefined || extension === undefined) return defaults; + const inputs = sourceInputs(extension.provenance.sourcePath); + if (!isPlainDataRecord(declared)) { + return { + ...defaults, + diagnostics: [errorDiagnostic( + 'codex.marketplace.invalid', + 'Codex marketplace must be a plain object containing only category, displayName, and policy.', + )], + sourceInputs: inputs, + }; + } + const diagnostics: Diagnostic[] = []; + for (const key of Object.keys(declared)) { + if (!marketplaceConfigFields.includes(key)) { + diagnostics.push(errorDiagnostic( + 'codex.marketplace.field.unknown', + `Codex marketplace field ${JSON.stringify(key)} is not documented; the emitted local marketplace supports category, displayName, and policy.`, + )); + } + } + let category = defaults.category; + let displayName = defaults.displayName; + for (const [field, code] of [['category', 'category'], ['displayName', 'display-name']] as const) { + const authored = declared[field]; + if (authored === undefined) continue; + if (!isNonemptyString(authored)) { + diagnostics.push(errorDiagnostic( + `codex.marketplace.${code}.invalid`, + `Codex marketplace ${field} must be a nonempty string.`, + )); + } else if (field === 'category') { + category = authored; + } else { + displayName = authored; + } + } + const policy = { ...defaults.policy }; + const declaredPolicy = declared['policy']; + if (declaredPolicy !== undefined) { + if (!isPlainDataRecord(declaredPolicy)) { + diagnostics.push(errorDiagnostic( + 'codex.marketplace.policy.invalid', + 'Codex marketplace policy must be a plain object containing installation and authentication.', + )); + } else { + for (const key of Object.keys(declaredPolicy)) { + if (!marketplacePolicyFields.includes(key)) { + diagnostics.push(errorDiagnostic( + 'codex.marketplace.policy.field.unknown', + `Codex marketplace policy field ${JSON.stringify(key)} is not documented.`, + )); + } + } + const authentication = declaredPolicy['authentication']; + if (authentication !== undefined) { + if (typeof authentication !== 'string' || !marketplaceAuthenticationPolicies.includes(authentication)) { + diagnostics.push(errorDiagnostic( + 'codex.marketplace.policy.authentication.invalid', + `Codex marketplace policy.authentication must be one of ${marketplaceAuthenticationPolicies.join(', ')}.`, + )); + } else { + policy.authentication = authentication; + } + } + const installation = declaredPolicy['installation']; + if (installation !== undefined) { + if (typeof installation !== 'string' || !marketplaceInstallationPolicies.includes(installation)) { + diagnostics.push(errorDiagnostic( + 'codex.marketplace.policy.installation.invalid', + `Codex marketplace policy.installation must be one of ${marketplaceInstallationPolicies.join(', ')}.`, + )); + } else if (installation === marketplaceNotInstallablePolicy) { + // The emitted marketplace exists to install this artifact: INSTALL.md and + // installBundle() both run `codex plugin add`, which the host refuses for + // NOT_AVAILABLE entries, so a self-installing bundle cannot honestly carry it. + diagnostics.push(errorDiagnostic( + 'codex.marketplace.policy.installation.not-installable', + `Codex marketplace policy.installation ${marketplaceNotInstallablePolicy} makes the emitted bundle refuse its own codex plugin add install path; use ${ + marketplaceInstallationPolicies.filter((value) => value !== marketplaceNotInstallablePolicy).join(' or ') + }.`, + )); + } else { + policy.installation = installation; + } + } + } + } + if (diagnostics.length > 0) return { ...defaults, diagnostics, sourceInputs: inputs }; + return { category, diagnostics, displayName, policy, sourceInputs: inputs }; +}; + const hasLeadingPluginRoot = (value: string): boolean => value === pathTokens.pluginRoot || value.startsWith(`${pathTokens.pluginRoot}/`); @@ -1051,18 +1187,26 @@ export const planCodexArtifacts = ( const pluginValidator = pluginValidatorFor(mcpRelativePath); diagnostics.push(...schemaDiagnostics('plugin', pluginValidator(plugin), pluginValidator.errors)); + const interfaceCategory = interfacePlan.value['category']; + const marketplacePlan = planCodexMarketplace( + model, + typeof interfaceCategory === 'string' ? interfaceCategory : generatedInterface.category, + ); + diagnostics.push(...marketplacePlan.diagnostics); const marketplace = { - interface: { displayName: model.metadata.name }, + interface: { displayName: marketplacePlan.displayName }, name: `${model.metadata.name}-marketplace`, plugins: [{ - category: 'Productivity', + category: marketplacePlan.category, name: model.metadata.name, - policy: { authentication: 'ON_INSTALL', installation: 'AVAILABLE' }, + policy: marketplacePlan.policy, source: { path: './', source: 'local' }, }], }; - const marketplaceValid = validateMarketplace(marketplace); - diagnostics.push(...schemaDiagnostics('marketplace', marketplaceValid, validateMarketplace.errors)); + const marketplaceValid = marketplacePlan.diagnostics.length === 0 && validateMarketplace(marketplace); + if (marketplacePlan.diagnostics.length === 0) { + diagnostics.push(...schemaDiagnostics('marketplace', marketplaceValid, validateMarketplace.errors)); + } const basePlan = standardPluginArtifactPlan({ additionalPluginSourceInputs: sourceInputs( @@ -1085,6 +1229,7 @@ export const planCodexArtifacts = ( isSelected, marketplace, marketplaceRelativePath: codexArtifactPaths.marketplace, + marketplaceSourceInputs: sourceInputs(...marketplacePlan.sourceInputs, ...interfacePlan.sourceInputs), marketplaceValid, mcp, mcpRelativePath, @@ -1158,6 +1303,24 @@ export const codexAdapter: TargetAdapter = Object.freeze({ ), install: supportedCapability(evidence), marketplace: supportedCapability(evidence), + allowManagedHooksOnly: tableCapability(distributionTable.allowManagedHooksOnly), + featureHooks: tableCapability(distributionTable.featureHooks), + featurePlugins: tableCapability(distributionTable.featurePlugins), + inlineHooksToml: tableCapability(distributionTable.inlineHooksToml), + installCacheLayout: tableCapability(distributionTable.installCacheLayout), + legacyClaudeMarketplaceCompatibility: tableCapability(distributionTable.legacyClaudeMarketplaceCompatibility), + managedRequirements: tableCapability(distributionTable.managedRequirements), + marketplaceCategory: tableCapability(distributionTable.marketplaceCategory), + marketplaceCliLifecycle: tableCapability(distributionTable.marketplaceCliLifecycle), + marketplaceInterface: tableCapability(distributionTable.marketplaceInterface), + marketplacePolicy: tableCapability(distributionTable.marketplacePolicy), + marketplaceSources: tableCapability(distributionTable.marketplaceSources), + personalMarketplaceDiscovery: tableCapability(distributionTable.personalMarketplaceDiscovery), + pluginCliLifecycle: tableCapability(distributionTable.pluginCliLifecycle), + pluginEnableState: tableCapability(distributionTable.pluginEnableState), + repoMarketplaceDiscovery: tableCapability(distributionTable.repoMarketplaceDiscovery), + restrictToAllowedSources: tableCapability(distributionTable.restrictToAllowedSources), + workspacePublishing: tableCapability(distributionTable.workspacePublishing), hooks: supportedCapability(evidence), hookAdditionalContextLimit: tableCapability(hookContractTable.additionalContextLimit), hookAsyncCommands: tableCapability(hookContractTable.asyncCommandHooks), diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index 064354cde..733e8c25c 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -112,6 +112,26 @@ const mcpPolicyUnifiedReason = 'The MCP approval policy is enforced by the Codex host at install time; the pinned Claude and Cursor contracts publish no shared per-plugin MCP policy surface.'; const hookContractUnifiedReason = 'The unified bundle emits the Codex-only hook handler contract, but the pinned Claude and Cursor hook contracts declare no shared handler-type, timeout, matcher, or trust surface.'; +const distributionUnifiedReason = + 'The unified bundle emits the Codex-only marketplace and install-policy surface, but the pinned Claude and Cursor contracts declare no shared marketplace source, cache, enable-state, feature-flag, or managed-requirements surface.'; +const codexDistributionCapabilities = [ + 'allowManagedHooksOnly', + 'featureHooks', + 'featurePlugins', + 'inlineHooksToml', + 'installCacheLayout', + 'legacyClaudeMarketplaceCompatibility', + 'managedRequirements', + 'marketplaceCategory', + 'marketplaceInterface', + 'marketplacePolicy', + 'marketplaceSources', + 'personalMarketplaceDiscovery', + 'pluginEnableState', + 'repoMarketplaceDiscovery', + 'restrictToAllowedSources', + 'workspacePublishing', +] as const; const codexHookContractCapabilities = [ 'hookAdditionalContextLimit', 'hookAsyncCommands', @@ -208,7 +228,7 @@ const artifactValidation = deepFreeze({ }); const metadata = Object.freeze({ - adapterRevision: '1.22.0', + adapterRevision: '1.23.0', observedVersion: `${claudeAdapter.metadata.observedVersion}+${codexAdapter.metadata.observedVersion}+${cursorAdapter.metadata.observedVersion}`, // Metadata schemas must exactly match the validation contract: each host's // documents, with one shared Claude-format hook schema (the pinned Codex @@ -601,15 +621,22 @@ const componentCapabilities = Object.freeze(Object.fromEntries( ]), )); -const codexHookContractUnifiedCapabilities = Object.freeze(Object.fromEntries( - codexHookContractCapabilities.map((capability) => [ +const codexHookContractUnifiedCapabilities = Object.freeze(Object.fromEntries([ + ...codexHookContractCapabilities.map((capability) => [ capability, intersectCapabilityStates( codexAdapter.capabilities[capability]!, unavailableCapability(hookContractUnifiedReason), ), ]), -)); + ...codexDistributionCapabilities.map((capability) => [ + capability, + intersectCapabilityStates( + codexAdapter.capabilities[capability]!, + unavailableCapability(distributionUnifiedReason), + ), + ]), +])); const agentCapabilities = Object.freeze(Object.fromEntries( Object.keys(claudeCapabilityTable.plugin.agents).map((rowName) => { @@ -764,7 +791,10 @@ export const pluginAdapter: TargetAdapter = Object.freeze({ ), ), marketplaceCliLifecycle: intersectCapabilityStates( - claudeAdapter.capabilities.marketplaceCliLifecycle!, + intersectCapabilityStates( + claudeAdapter.capabilities.marketplaceCliLifecycle!, + codexAdapter.capabilities.marketplaceCliLifecycle!, + ), unavailableCapability( 'The unified bundle emits host marketplace documents but cannot add, list, remove, or update marketplaces as one cross-host lifecycle transaction.', ), @@ -855,7 +885,10 @@ export const pluginAdapter: TargetAdapter = Object.freeze({ 'The unified bundle emits Claude-only output styles, but the pinned Codex and Cursor contracts declare no shared output styles surface.', ), pluginCliLifecycle: intersectCapabilityStates( - claudeAdapter.capabilities.pluginCliLifecycle!, + intersectCapabilityStates( + claudeAdapter.capabilities.pluginCliLifecycle!, + codexAdapter.capabilities.pluginCliLifecycle!, + ), unavailableCapability( 'The unified bundle emits host artifacts but cannot run Claude-only plugin creation, installation, state, inspection, update, or release commands.', ), diff --git a/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json index 9bd27d70c..6aff5440e 100644 --- a/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json +++ b/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json @@ -11,7 +11,13 @@ "Inline mcpServers values must be objects; inline hook documents use the same closed eleven-event, command-or-mcp_tool handler 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.", - "Component and interface-asset path patterns treat backslashes as separators and reject them outright so Windows-form parent traversal cannot escape the plugin root; HTTP(S) URL scheme patterns are case-insensitive to match WHATWG URL protocol normalization." + "Component and interface-asset path patterns treat backslashes as separators and reject them outright so Windows-form parent traversal cannot escape the plugin root, and they reject control characters and Unicode line terminators (U+0000-U+001F, U+007F, U+2028, U+2029) so a line break cannot hide a parent segment from the containment lookahead; HTTP(S) URL scheme patterns are case-insensitive to match WHATWG URL protocol normalization." + ], + "marketplace.schema.json": [ + "Transcribed 2026-09-02 from the Marketplace metadata section of https://developers.openai.com/plugins/build/plugins: top-level name, interface.displayName, and plugins[] entries with name, source, policy, and category.", + "interface is required even though one documented example omits it: the adapter always emits a displayName, and the marketplace picker shows that title.", + "source admits every documented form: a plain ./-prefixed string, { source: local, path }, { source: url, url, ref?, sha? }, { source: git-subdir, url, path, ref?, sha? }, and { source: npm, package, version?, registry? }; paths are root-contained with backslashes, control characters, and Unicode line terminators rejected, Git URLs must be HTTP(S) or ssh:// with a syntactically valid host (DNS labels whose last label contains a letter, an in-range dotted-quad IPv4, or a bracketed RFC 3986 IPv6 literal; optional RFC 3986 userinfo including user:password and percent-escapes, and a 0-65535 port) and a non-empty percent-encoded repository path, or scp-style user@host:path, so scheme-prefixed values with an unusable authority such as https://%zz/repo.git or https://999.999.999.999/repo.git are rejected; ref selectors follow git check-ref-format (no control characters, whitespace, ~^:?*[\\, \"..\", or \"@{\"; components cannot be empty, start with \".\", or end with \".\" or \".lock\"; no leading \"-\" or lone \"@\"), sha selectors are 7-64 hexadecimal digits, npm versions must be a semver version, a semver range using semver's whitespace grammar (any whitespace run between comparators and around hyphens, trimmed at the ends), or a dist-tag made only of characters encodeURIComponent leaves unescaped and not starting with \".\" (npm-package-arg rejects anything else with EINVALIDTAGNAME or treats it as a directory spec), and registries must be credential-, query-, and fragment-free HTTPS URLs with the same host grammar.", + "policy.installation and policy.authentication are closed to the documented AVAILABLE|INSTALLED_BY_DEFAULT|NOT_AVAILABLE and ON_INSTALL|ON_USE values, and both are required alongside category as the page instructs." ], "hooks.schema.json": [ "The hooks object is closed to the eleven release-documented events; Interrupt is excluded because rust-v0.147.0 ships no generated Interrupt schema.", @@ -31,9 +37,9 @@ "url": "https://learn.chatgpt.com/docs/hooks" }, "marketplace.schema.json": { - "bytes": 1671, - "sha256": "1d43c5ed19de401fb7455c5912e4c21113f6e387aef4c28d2eca121f7554c4e8", - "url": "https://github.com/openai/codex/blob/main/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md" + "bytes": 11063, + "sha256": "fbccce3ade39e1b077fcb440e60260de6e811e8c2d222b2ae0fe8fe47706b470", + "url": "https://developers.openai.com/plugins/build/plugins" }, "mcp.schema.json": { "bytes": 1267, @@ -41,8 +47,8 @@ "url": "https://github.com/openai/codex/blob/main/codex-rs/core/config.schema.json" }, "plugin.schema.json": { - "bytes": 6454, - "sha256": "986bcafa6ef46f9dc4558f05781f53400b3d75533a075068184ba8d43670d4ec", + "bytes": 6598, + "sha256": "4ad476545c96c83d899c4524dcccd4eb4fe7d3299c307878a0cd46a237f48d58", "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/marketplace.schema.json b/packages/agent-bundle/src/adapters/schemas/codex/marketplace.schema.json index 61f2d8f67..628e7519b 100644 --- a/packages/agent-bundle/src/adapters/schemas/codex/marketplace.schema.json +++ b/packages/agent-bundle/src/adapters/schemas/codex/marketplace.schema.json @@ -1,6 +1,65 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://agent-bundle.dev/schemas/codex/0.147.0/marketplace.schema.json", + "$defs": { + "containedPath": { + "pattern": "^\\./(?!(?:.*[\\/\\\\])?\\.\\.(?:[\\/\\\\]|$))[^\\\\\\u0000-\\u001F\\u007F\\u2028\\u2029]*$", + "type": "string" + }, + "gitUrl": { + "minLength": 1, + "pattern": "^(?:(?:[Hh][Tt][Tt][Pp][Ss]?|[Ss][Ss][Hh])://(?:(?:[A-Za-z0-9._~!$&'()*+,;=:-]|%[0-9A-Fa-f]{2})+@)?(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|\\[(?:(?:[0-9A-Fa-f]{1,4}:){6}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3})|::(?:[0-9A-Fa-f]{1,4}:){5}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3})|(?:[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){4}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3})|(?:(?:[0-9A-Fa-f]{1,4}:){0,1}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){3}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3})|(?:(?:[0-9A-Fa-f]{1,4}:){0,2}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){2}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3})|(?:(?:[0-9A-Fa-f]{1,4}:){0,3}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}:(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3})|(?:(?:[0-9A-Fa-f]{1,4}:){0,4}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3})|(?:(?:[0-9A-Fa-f]{1,4}:){0,5}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}|(?:(?:[0-9A-Fa-f]{1,4}:){0,6}[0-9A-Fa-f]{1,4})?::)\\]|(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\\.)*(?=[A-Za-z0-9-]{1,63}(?:[:/]|$))(?=[0-9-]*[A-Za-z])[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(?::0*(?:0|[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f]{2})+)+/?|[A-Za-z0-9._-]+@(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|\\[(?:(?:[0-9A-Fa-f]{1,4}:){6}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3})|::(?:[0-9A-Fa-f]{1,4}:){5}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3})|(?:[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){4}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3})|(?:(?:[0-9A-Fa-f]{1,4}:){0,1}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){3}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3})|(?:(?:[0-9A-Fa-f]{1,4}:){0,2}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){2}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3})|(?:(?:[0-9A-Fa-f]{1,4}:){0,3}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}:(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3})|(?:(?:[0-9A-Fa-f]{1,4}:){0,4}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3})|(?:(?:[0-9A-Fa-f]{1,4}:){0,5}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}|(?:(?:[0-9A-Fa-f]{1,4}:){0,6}[0-9A-Fa-f]{1,4})?::)\\]|(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\\.)*(?=[A-Za-z0-9-]{1,63}(?::|$))(?=[0-9-]*[A-Za-z])[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?):(?:~?[A-Za-z0-9._!$&'()*+,;=@-]+)(?:/[A-Za-z0-9._~!$&'()*+,;=@-]+)*/?)$", + "type": "string" + }, + "gitRef": { "minLength": 1, "pattern": "^(?!-)(?!@$)(?!\\.)(?![^/]*\\.lock(?:/|$))(?:[^\\u0000-\\u0020\\u007F~^:?*\\[\\\\/.@]|\\.(?![./]|$)|@(?!\\{))+(?:/(?!\\.)(?![^/]*\\.lock(?:/|$))(?:[^\\u0000-\\u0020\\u007F~^:?*\\[\\\\/.@]|\\.(?![./]|$)|@(?!\\{))+)*$", "type": "string" }, + "gitSha": { "minLength": 1, "pattern": "^[0-9A-Fa-f]{7,64}$", "type": "string" }, + "localSource": { + "additionalProperties": false, + "properties": { + "path": { "$ref": "#/$defs/containedPath" }, + "source": { "const": "local", "type": "string" } + }, + "required": ["source", "path"], + "type": "object" + }, + "gitRootSource": { + "additionalProperties": false, + "properties": { + "ref": { "$ref": "#/$defs/gitRef" }, + "sha": { "$ref": "#/$defs/gitSha" }, + "source": { "const": "url", "type": "string" }, + "url": { "$ref": "#/$defs/gitUrl" } + }, + "required": ["source", "url"], + "type": "object" + }, + "gitSubdirSource": { + "additionalProperties": false, + "properties": { + "path": { "$ref": "#/$defs/containedPath" }, + "ref": { "$ref": "#/$defs/gitRef" }, + "sha": { "$ref": "#/$defs/gitSha" }, + "source": { "const": "git-subdir", "type": "string" }, + "url": { "$ref": "#/$defs/gitUrl" } + }, + "required": ["source", "url", "path"], + "type": "object" + }, + "npmSource": { + "additionalProperties": false, + "properties": { + "package": { "minLength": 1, "pattern": "^(?:@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*$", "type": "string" }, + "registry": { + "pattern": "^[Hh][Tt][Tt][Pp][Ss]://(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|\\[(?:(?:[0-9A-Fa-f]{1,4}:){6}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3})|::(?:[0-9A-Fa-f]{1,4}:){5}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3})|(?:[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){4}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3})|(?:(?:[0-9A-Fa-f]{1,4}:){0,1}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){3}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3})|(?:(?:[0-9A-Fa-f]{1,4}:){0,2}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){2}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3})|(?:(?:[0-9A-Fa-f]{1,4}:){0,3}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}:(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3})|(?:(?:[0-9A-Fa-f]{1,4}:){0,4}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3})|(?:(?:[0-9A-Fa-f]{1,4}:){0,5}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}|(?:(?:[0-9A-Fa-f]{1,4}:){0,6}[0-9A-Fa-f]{1,4})?::)\\]|(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\\.)*(?=[A-Za-z0-9-]{1,63}(?:[:/]|$))(?=[0-9-]*[A-Za-z])[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(?::0*(?:0|[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:/(?:[A-Za-z0-9._~!$&'()*+,;=:-]|%[0-9A-Fa-f]{2})+)*/?$", + "type": "string" + }, + "source": { "const": "npm", "type": "string" }, + "version": { "minLength": 1, "pattern": "^(?:\\s*(?:v?(?:[xX*]|0|[1-9][0-9]*)(?:\\.(?:[xX*]|0|[1-9][0-9]*)(?:\\.(?:[xX*]|0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?)?)?\\s+-\\s+v?(?:[xX*]|0|[1-9][0-9]*)(?:\\.(?:[xX*]|0|[1-9][0-9]*)(?:\\.(?:[xX*]|0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?)?)?|(?:(?:[<>]=?|=|~>?|\\^)\\s*)?v?(?:[xX*]|0|[1-9][0-9]*)(?:\\.(?:[xX*]|0|[1-9][0-9]*)(?:\\.(?:[xX*]|0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?)?)?(?:\\s+(?:(?:[<>]=?|=|~>?|\\^)\\s*)?v?(?:[xX*]|0|[1-9][0-9]*)(?:\\.(?:[xX*]|0|[1-9][0-9]*)(?:\\.(?:[xX*]|0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?)?)?)*)(?:\\s*\\|\\|\\s*(?:v?(?:[xX*]|0|[1-9][0-9]*)(?:\\.(?:[xX*]|0|[1-9][0-9]*)(?:\\.(?:[xX*]|0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?)?)?\\s+-\\s+v?(?:[xX*]|0|[1-9][0-9]*)(?:\\.(?:[xX*]|0|[1-9][0-9]*)(?:\\.(?:[xX*]|0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?)?)?|(?:(?:[<>]=?|=|~>?|\\^)\\s*)?v?(?:[xX*]|0|[1-9][0-9]*)(?:\\.(?:[xX*]|0|[1-9][0-9]*)(?:\\.(?:[xX*]|0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?)?)?(?:\\s+(?:(?:[<>]=?|=|~>?|\\^)\\s*)?v?(?:[xX*]|0|[1-9][0-9]*)(?:\\.(?:[xX*]|0|[1-9][0-9]*)(?:\\.(?:[xX*]|0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?)?)?)*))*\\s*|(?!\\.)[A-Za-z0-9!'()*._~-]+)$", "type": "string" } + }, + "required": ["source", "package"], + "type": "object" + } + }, "additionalProperties": false, "properties": { "interface": { @@ -26,13 +85,13 @@ "type": "object" }, "source": { - "additionalProperties": false, - "properties": { - "path": { "pattern": "^\\./", "type": "string" }, - "source": { "const": "local", "type": "string" } - }, - "required": ["source", "path"], - "type": "object" + "oneOf": [ + { "$ref": "#/$defs/containedPath" }, + { "$ref": "#/$defs/localSource" }, + { "$ref": "#/$defs/gitRootSource" }, + { "$ref": "#/$defs/gitSubdirSource" }, + { "$ref": "#/$defs/npmSource" } + ] } }, "required": ["name", "source", "policy", "category"], 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 78e47aae4..c9403b984 100644 --- a/packages/agent-bundle/src/adapters/schemas/codex/plugin.schema.json +++ b/packages/agent-bundle/src/adapters/schemas/codex/plugin.schema.json @@ -3,7 +3,7 @@ "$id": "https://agent-bundle.dev/schemas/codex/0.147.0/plugin.schema.json", "$defs": { "componentPath": { - "pattern": "^\\./(?!(?:.*[\\/\\\\])?\\.\\.(?:[\\/\\\\]|$))[^\\\\]+$", + "pattern": "^\\./(?!(?:.*[\\/\\\\])?\\.\\.(?:[\\/\\\\]|$))[^\\\\\\u0000-\\u001F\\u007F\\u2028\\u2029]+$", "type": "string" }, "hookCommandHandler": { @@ -117,12 +117,12 @@ "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" }, + "composerIcon": { "pattern": "^\\./(?!(?:.*[/\\\\])?\\.\\.(?:[/\\\\]|$))[^\\\\\\u0000-\\u001F\\u007F\\u2028\\u2029]+$", "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" }, + "logo": { "pattern": "^\\./(?!(?:.*[/\\\\])?\\.\\.(?:[/\\\\]|$))[^\\\\\\u0000-\\u001F\\u007F\\u2028\\u2029]+$", "type": "string" }, + "logoDark": { "pattern": "^\\./(?!(?:.*[/\\\\])?\\.\\.(?:[/\\\\]|$))[^\\\\\\u0000-\\u001F\\u007F\\u2028\\u2029]+$", "type": "string" }, "longDescription": { "minLength": 1, "pattern": "\\S", "type": "string" }, "privacyPolicyURL": { "pattern": "^[Hh][Tt][Tt][Pp][Ss]?://", "type": "string" }, "screenshots": { "items": { "pattern": "^\\./assets/(?!.*(?:^|/)\\.\\.(?:/|$)).+\\.png$", "type": "string" }, "type": "array" }, diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index d205236af..344805341 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -68,7 +68,7 @@ it('records exact immutable metadata for every built-in target', () => { ], }); expect(registryMetadata(registry, 'codex')).toEqual({ - adapterRevision: '1.7.0', + adapterRevision: '1.8.0', observedVersion: '0.147.0', schemas: [ { @@ -84,7 +84,7 @@ it('records exact immutable metadata for every built-in target', () => { { name: 'marketplace', revision: '0.147.0', - sha256: '1d43c5ed19de401fb7455c5912e4c21113f6e387aef4c28d2eca121f7554c4e8', + sha256: 'fbccce3ade39e1b077fcb440e60260de6e811e8c2d222b2ae0fe8fe47706b470', }, { name: 'mcp', @@ -94,7 +94,7 @@ it('records exact immutable metadata for every built-in target', () => { { name: 'plugin', revision: '0.147.0', - sha256: '986bcafa6ef46f9dc4558f05781f53400b3d75533a075068184ba8d43670d4ec', + sha256: '4ad476545c96c83d899c4524dcccd4eb4fe7d3299c307878a0cd46a237f48d58', }, ], }); @@ -170,7 +170,7 @@ it('records exact immutable metadata for every built-in target', () => { }, ], }); - expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.22.0'); + expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.23.0'); }); it('records observed capability versions and rehashes schema snapshots against pinned provenance', async () => { diff --git a/packages/agent-bundle/tests/codex-distribution.test.ts b/packages/agent-bundle/tests/codex-distribution.test.ts new file mode 100644 index 000000000..996ba3e3e --- /dev/null +++ b/packages/agent-bundle/tests/codex-distribution.test.ts @@ -0,0 +1,363 @@ +import { expect, it } from '@rstest/core'; + +import codexCapabilityTable from '../src/adapters/capabilities/codex-0.147.0.json' with { type: 'json' }; +import { codexAdapter } from '../src/adapters/codex.ts'; +import { createDefaultRegistry } from '../src/adapters/registry.ts'; +import marketplaceSchema from '../src/adapters/schemas/codex/marketplace.schema.json' with { type: 'json' }; +import { createAdapterValidator } from '../src/adapters/types.ts'; +import type { NormalizedPlugin } from '../src/core/types.ts'; + +const distributionRows = { + allowManagedHooksOnly: 'unavailable', + featureHooks: 'unavailable', + featurePlugins: 'unavailable', + inlineHooksToml: 'unavailable', + installCacheLayout: 'unavailable', + legacyClaudeMarketplaceCompatibility: 'unavailable', + managedRequirements: 'unavailable', + marketplaceCategory: 'supported', + marketplaceCliLifecycle: 'degraded', + marketplaceInterface: 'supported', + marketplacePolicy: 'supported', + marketplaceSources: 'degraded', + personalMarketplaceDiscovery: 'unavailable', + pluginCliLifecycle: 'degraded', + pluginEnableState: 'unavailable', + repoMarketplaceDiscovery: 'supported', + restrictToAllowedSources: 'unavailable', + workspacePublishing: 'unavailable', +} as const; + +const plugin: NormalizedPlugin = Object.freeze({ + extensions: Object.freeze({}), + hooks: Object.freeze([]), + marketplace: true as const, + mcpServers: Object.freeze([]), + metadata: Object.freeze({ + description: 'Review code and explain findings.', + id: 'plugin:review-tools', + name: 'review-tools', + provenance: Object.freeze({ kind: 'config' as const, sourcePath: '/workspace/agent-bundle.config.ts' }), + version: '1.2.3', + }), + runtime: Object.freeze({ node: '22.12.0' }), + scripts: Object.freeze([]), + skills: Object.freeze([]), + targets: Object.freeze([ + Object.freeze({ id: 'target:codex', name: 'codex', provenance: Object.freeze({ kind: 'config' as const, sourcePath: '/workspace/agent-bundle.config.ts' }) }), + ]), +}); + +const withCodexConfig = (value: Readonly>): NormalizedPlugin => ({ + ...plugin, + extensions: { + codex: { + id: 'extension:codex', + key: 'codex', + provenance: { kind: 'config', sourcePath: '/workspace/codex.config.ts' }, + target: 'codex', + value, + }, + }, +}); + +const emittedMarketplace = (model: NormalizedPlugin) => { + const plan = codexAdapter.plan(model); + const entry = plan.entries.find((candidate) => candidate.relativePath === '.agents/plugins/marketplace.json'); + return { + codes: plan.diagnostics.map((diagnostic) => diagnostic.code), + document: entry?.kind === 'write' ? JSON.parse(entry.content) as Record : undefined, + entry, + }; +}; + +const entry = (source: unknown) => ({ + category: 'Productivity', + name: 'remote-helper', + policy: { authentication: 'ON_INSTALL', installation: 'AVAILABLE' }, + source, +}); + +const marketplace = (...sources: readonly unknown[]) => ({ + interface: { displayName: 'Local Example Plugins' }, + name: 'local-example-plugins', + plugins: sources.map(entry), +}); + +it('records dated four-state Codex distribution rows mirrored by the adapter and intersected by the unified bundle', () => { + const registry = createDefaultRegistry(); + const unified = registry.get('plugin'); + const table = codexCapabilityTable.distribution as Readonly>; + + expect(Object.keys(table).sort()).toEqual(Object.keys(distributionRows).sort()); + for (const [capability, expectedState] of Object.entries(distributionRows)) { + const row = table[capability]!; + expect(row.state, capability).toBe(expectedState); + expect(row.evidence.length, capability).toBeGreaterThan(0); + expect(row.evidence.every((line) => /^(?:retrieved )?2026-09-0[23]:/u.test(line)), capability).toBe(true); + if (expectedState === 'supported') { + expect(row.reason).toBeUndefined(); + expect(codexAdapter.capabilities[capability]).toEqual({ + evidence: { observedVersion: '0.147.0', target: 'codex' }, + state: 'supported', + }); + } else { + expect(row.reason, capability).toMatch(/\S/u); + expect(codexAdapter.capabilities[capability]).toMatchObject({ reason: row.reason, state: expectedState }); + } + expect(registry.supports('codex', capability)).toBe(expectedState === 'supported'); + expect(unified.capabilities[capability]).toMatchObject({ state: 'unavailable' }); + expect(registry.supports('plugin', capability)).toBe(false); + } + expect(table.pluginCliLifecycle).toMatchObject({ commands: ['add', 'list', 'remove'] }); + expect(table.marketplaceCliLifecycle).toMatchObject({ commands: ['add', 'list', 'upgrade', 'remove'] }); + expect(table.marketplaceSources).toMatchObject({ + admitted: ['local', 'local-string', 'url', 'git-subdir', 'npm'], + emitted: 'local', + }); + expect(table.installCacheLayout).toMatchObject({ + evidence: expect.arrayContaining([expect.stringContaining('manifest version')]), + }); + expect(codexCapabilityTable.marketplace.policy).toEqual({ + authentication: ['ON_INSTALL', 'ON_USE'], + installation: ['AVAILABLE', 'INSTALLED_BY_DEFAULT', 'NOT_AVAILABLE'], + notInstallable: 'NOT_AVAILABLE', + }); +}); + +it('admits every documented marketplace source form and rejects escapes, credentials, and selectors', () => { + const validate = createAdapterValidator().compile(marketplaceSchema); + const admitted = marketplace( + './plugins/my-plugin', + { path: './plugins/my-plugin', source: 'local' }, + { ref: 'main', source: 'url', url: 'https://github.com/example/codex-plugins.git' }, + { sha: '0123456789abcdef0123456789abcdef01234567', source: 'url', url: 'git@github.com:example/codex-plugins.git' }, + { path: './plugins/remote-helper', ref: 'main', source: 'git-subdir', url: 'https://github.com/example/codex-plugins.git' }, + { path: './plugins/remote-helper', source: 'git-subdir', url: 'ssh://git@github.com/example/codex-plugins.git' }, + { package: '@example/codex-plugin', registry: 'https://registry.npmjs.org', source: 'npm', version: '^1.2.0' }, + { package: 'codex-plugin', source: 'npm' }, + { package: 'codex-plugin', source: 'npm', version: 'latest' }, + { source: 'url', url: 'https://token@git.example.test:8443/team/codex-plugins.git' }, + // RFC 3986 userinfo: user:password and percent-encoded credentials, on every host form. + { source: 'url', url: 'https://user:token@git.example.test/team/codex-plugins.git' }, + { source: 'url', url: 'https://user%40corp:p%3Ass@git.example.test/team/codex-plugins.git' }, + { source: 'url', url: 'https://user:token@10.0.0.1:8443/team/codex-plugins.git' }, + { source: 'url', url: 'https://user:token@[2001:db8::1]:8443/team/codex-plugins.git' }, + { source: 'url', url: 'ssh://git@git.example.test:2222/team/codex-plugins.git' }, + { source: 'url', url: 'git@git.example.test:~team/codex-plugins.git' }, + { source: 'url', url: 'https://github.com/example/codex%20plugins.git' }, + { package: 'codex-plugin', registry: 'https://npm.example.test:4873/prefix/', source: 'npm' }, + { source: 'url', url: 'https://github.com:65535/example/codex-plugins.git' }, + { source: 'url', url: 'https://github.com:0443/example/codex-plugins.git' }, + { package: 'codex-plugin', registry: 'https://npm.example.test:65535', source: 'npm' }, + { source: 'url', url: 'https://10.0.0.1/team/codex-plugins.git' }, + { source: 'url', url: 'https://192.168.1.250:8443/team/codex-plugins.git' }, + { source: 'url', url: 'git@10.0.0.1:team/codex-plugins.git' }, + { source: 'url', url: 'https://localhost/team/codex-plugins.git' }, + { source: 'url', url: 'https://xn--bcher-kva.example/team/codex-plugins.git' }, + { package: 'codex-plugin', registry: 'https://10.0.0.1:4873/', source: 'npm' }, + // Bracketed IPv6 authorities (RFC 3986 IP-literal) in every URL form Git and npm accept. + { source: 'url', url: 'ssh://git@[2001:db8::1]/team/codex-plugins.git' }, + { source: 'url', url: 'https://[2001:db8::1]:4873/team/codex-plugins.git' }, + { source: 'url', url: 'https://[::1]/team/codex-plugins.git' }, + { source: 'url', url: 'https://[::ffff:10.0.0.1]/team/codex-plugins.git' }, + { source: 'url', url: 'https://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]/team/codex-plugins.git' }, + { source: 'url', url: 'git@[2001:db8::1]:team/codex-plugins.git' }, + { package: 'codex-plugin', registry: 'https://[2001:db8::1]:4873/', source: 'npm' }, + { package: 'codex-plugin', registry: 'https://[::1]/', source: 'npm' }, + // Git refs follow git check-ref-format: branch, tag, and fully qualified forms. + ...['release/1.2', 'v1.2.3', 'refs/tags/v1.2.3', 'feature/foo.bar', 'HEAD', 'user/-dash', 'x.lockfile', 'a@b'] + .map((ref) => ({ ref, source: 'url', url: 'https://github.com/example/codex-plugins.git' })), + // npm version selectors: semver versions, ranges, and dist-tags (npm-package-arg rules), using + // semver's whitespace grammar (any run of whitespace between comparators, trimmed at the ends). + ...['1.2.3', '~1.2', '>=1.0.0 <2.0.0', '>=1.0.0 <2.0.0', '>=1.0.0\t<2.0.0', '>= 1.0.0', '~ 1.2', ' 1.2.3 ', '1.2.3 - 2.3.4', '1.0.0 - 2.0.0', '1.x', '*', '>=1.0.0-beta.1', '1.0.0+build.5', '^1 || ^2', '^1||^2', 'v1.2.3', '=1.2.3', 'next', 'beta-2', 'rc.1'] + .map((version) => ({ package: 'codex-plugin', source: 'npm', version })), + ); + expect(validate(admitted), JSON.stringify(validate.errors)).toBe(true); + + const rejected: readonly unknown[] = [ + '../outside', + 'plugins/my-plugin', + { path: './plugins/../../outside', source: 'local' }, + { path: '.\\plugins\\my-plugin', source: 'local' }, + // Line terminators must not let a parent segment slip past the containment lookahead. + './x\n/../../outside', + { path: './x\n/../../outside', source: 'local' }, + { path: './x\r\n/../../outside', source: 'local' }, + { path: './x\u2028/../../outside', source: 'local' }, + { path: './plugins/my\u0000plugin', source: 'local' }, + { path: './x\n/../../outside', ref: 'main', source: 'git-subdir', url: 'https://github.com/example/codex-plugins.git' }, + { source: 'url', url: 'file:///tmp/plugins' }, + // Scheme-prefix matches with an unusable authority or path are not URLs. + { source: 'url', url: 'https://%zz/repository.git' }, + { source: 'url', url: 'https://' }, + { source: 'url', url: 'https://github.com' }, + { source: 'url', url: 'https://github.com/example/codex%2plugins.git' }, + { source: 'url', url: 'https://git hub.com/example/codex-plugins.git' }, + { source: 'url', url: 'https://-github.com/example/codex-plugins.git' }, + { source: 'url', url: 'https://github.com:port/example/codex-plugins.git' }, + { source: 'url', url: 'https://github.com:65536/example/codex-plugins.git' }, + { source: 'url', url: 'https://github.com:99999/example/codex-plugins.git' }, + { source: 'url', url: 'ssh://git@github.com:65536/example/codex-plugins.git' }, + { source: 'url', url: 'https://github.com/example/codex-plugins.git?ref=main' }, + { source: 'url', url: 'ssh://%zz/repository.git' }, + { source: 'url', url: 'ssh://github.com' }, + { source: 'url', url: 'git@%zz:example/codex-plugins.git' }, + { source: 'url', url: 'git@github.com:' }, + { source: 'url', url: 'git@github.com:example/codex plugins.git' }, + // Credentials still cannot mask a bad authority, contain raw spaces or bad escapes, or repeat "@". + { source: 'url', url: 'https://user:token@/team/codex-plugins.git' }, + { source: 'url', url: 'https://user:to ken@git.example.test/team/codex-plugins.git' }, + { source: 'url', url: 'https://us%zzer@git.example.test/team/codex-plugins.git' }, + { source: 'url', url: 'https://user:token@%zz/team/codex-plugins.git' }, + { source: 'url', url: 'https://user:token@github.com:65536/team/codex-plugins.git' }, + { source: 'url', url: 'https://user:token@999.999.999.999/team/codex-plugins.git' }, + { source: 'url', url: 'https://a@b@github.com/team/codex-plugins.git' }, + { source: 'url', url: 'https://github.com/example/codex-plugins.git', sha: 'not-hex' }, + { source: 'git-subdir', url: 'https://github.com/example/codex-plugins.git' }, + { path: './plugins/remote-helper', source: 'git-subdir', url: 'https://%zz/repository.git' }, + { package: '@example/codex-plugin', registry: 'https://%zz/', source: 'npm' }, + { package: '@example/codex-plugin', registry: 'https://', source: 'npm' }, + { package: '@example/codex-plugin', registry: 'https://registry.example.test:port', source: 'npm' }, + { package: '@example/codex-plugin', registry: 'https://registry.example.test:65536', source: 'npm' }, + // Numeric authorities must be in-range dotted-quad IPv4; a last label of digits is not a DNS host. + { source: 'url', url: 'https://999.999.999.999/team/plugin.git' }, + { source: 'url', url: 'https://256.1.1.1/team/plugin.git' }, + { source: 'url', url: 'https://1.2.3/team/plugin.git' }, + { source: 'url', url: 'https://1.2.3.4.5/team/plugin.git' }, + { source: 'url', url: 'https://example.123/team/plugin.git' }, + { source: 'url', url: 'git@999.999.999.999:team/plugin.git' }, + { source: 'url', url: 'ssh://999.999.999.999/team/plugin.git' }, + { package: '@example/codex-plugin', registry: 'https://999.999.999.999/', source: 'npm' }, + { package: '@example/codex-plugin', registry: 'https://example.123/', source: 'npm' }, + // IPv6 literals must be bracketed, well-formed, and at most eight groups. + { source: 'url', url: 'https://[2001:db8::1/team/plugin.git' }, + { source: 'url', url: 'https://[zz::1]/team/plugin.git' }, + { source: 'url', url: 'https://[1:2:3:4:5:6:7:8:9]/team/plugin.git' }, + { source: 'url', url: 'https://[1:2:3:4:5:6:7]/team/plugin.git' }, + { source: 'url', url: 'https://[]/team/plugin.git' }, + { source: 'url', url: 'https://2001:db8::1/team/plugin.git' }, + { package: '@example/codex-plugin', registry: 'https://[2001:db8::1', source: 'npm' }, + { package: '@example/codex-plugin', registry: 'https://[1:2:3:4:5:6:7:8:9]/', source: 'npm' }, + // Selectors npm rejects with EINVALIDTAGNAME or as unparseable ranges. + ...['foo bar', '%', '', '1.2.3 || foo bar', '>=1.0.0 <', 'latest@1', 'a/b', 'a:b', 'a#b', 'a?b', '1.0.0 -2.0.0'] + .map((version) => ({ package: '@example/codex-plugin', source: 'npm', version })), + // Dot-prefixed selectors are directory specs to npm-package-arg, not dist-tags. + ...['.', '..', '.foo', '.latest'].map((version) => ({ package: '@example/codex-plugin', source: 'npm', version })), + // Refs git check-ref-format refuses (control characters, "..", "@{", ".lock", leading "-", empty components). + ...['', 'foo bar', 'foo..bar', 'foo@{1}', '@', '-main', 'main.lock', 'a.lock/b', 'refs/heads/', '/main', 'a//b', 'main.', '.hidden', 'a/.b', 'a~b', 'a^b', 'a:b', 'a?b', 'a*b', 'a[b', 'a\\b', 'a\tb', 'a\u007fb', 'a\nb'] + .flatMap((ref) => [ + { ref, source: 'url', url: 'https://github.com/example/codex-plugins.git' }, + { path: './plugins/remote-helper', ref, source: 'git-subdir', url: 'https://github.com/example/codex-plugins.git' }, + ]), + { package: '@example/codex-plugin', registry: 'https://registry.example.test/a b', source: 'npm' }, + { package: '@example/codex-plugin', source: 'npm', version: 'file:../local' }, + { package: '@example/codex-plugin', source: 'npm', version: 'https://example.test/pkg.tgz' }, + { package: '@example/codex-plugin', source: 'npm', version: 'npm:other@1.0.0' }, + { package: '@example/codex-plugin', registry: 'http://registry.example.test', source: 'npm' }, + { package: '@example/codex-plugin', registry: 'https://user:token@registry.example.test', source: 'npm' }, + { package: '@example/codex-plugin', registry: 'https://registry.example.test/?auth=1', source: 'npm' }, + { source: 'npm' }, + { source: 'git', url: 'https://github.com/example/codex-plugins.git' }, + ]; + for (const source of rejected) { + expect(validate(marketplace(source)), JSON.stringify(source)).toBe(false); + } + for (const policy of [ + { authentication: 'ON_INSTALL' }, + { authentication: 'ALWAYS', installation: 'AVAILABLE' }, + { authentication: 'ON_INSTALL', installation: 'HIDDEN' }, + ]) { + expect(validate({ ...marketplace('./'), plugins: [{ ...entry('./'), policy }] }), JSON.stringify(policy)).toBe(false); + } + expect(validate({ ...marketplace('./'), plugins: [{ ...entry('./'), category: undefined }] })).toBe(false); +}); + +it('emits the marketplace entry with documented defaults and follows the authored interface category', () => { + const generated = emittedMarketplace(plugin); + expect(generated.codes).toEqual([]); + expect(generated.document).toEqual({ + interface: { displayName: 'review-tools' }, + name: 'review-tools-marketplace', + plugins: [{ + category: 'Productivity', + name: 'review-tools', + policy: { authentication: 'ON_INSTALL', installation: 'AVAILABLE' }, + source: { path: './', source: 'local' }, + }], + }); + + const categorized = emittedMarketplace(withCodexConfig({ interface: { category: 'Developer Tools' } })); + expect(categorized.codes).toEqual([]); + expect(categorized.document).toMatchObject({ plugins: [{ category: 'Developer Tools' }] }); + expect(categorized.entry).toMatchObject({ sourceInputs: expect.arrayContaining(['/workspace/codex.config.ts']) }); +}); + +it('emits every authored Codex marketplace field', () => { + const authored = emittedMarketplace(withCodexConfig({ + interface: { category: 'Developer Tools' }, + marketplace: { + category: 'Security', + displayName: 'Review Tools Marketplace', + policy: { authentication: 'ON_USE', installation: 'INSTALLED_BY_DEFAULT' }, + }, + })); + expect(authored.codes).toEqual([]); + expect(authored.document).toEqual({ + interface: { displayName: 'Review Tools Marketplace' }, + name: 'review-tools-marketplace', + plugins: [{ + category: 'Security', + name: 'review-tools', + policy: { authentication: 'ON_USE', installation: 'INSTALLED_BY_DEFAULT' }, + source: { path: './', source: 'local' }, + }], + }); + expect(authored.entry).toMatchObject({ sourceInputs: expect.arrayContaining(['/workspace/codex.config.ts']) }); +}); + +it.each([ + { code: 'codex.marketplace.invalid', value: [] }, + { code: 'codex.marketplace.invalid', value: 'AVAILABLE' }, + { code: 'codex.marketplace.field.unknown', value: { source: { source: 'npm', package: 'x' } } }, + { code: 'codex.marketplace.category.invalid', value: { category: '' } }, + { code: 'codex.marketplace.display-name.invalid', value: { displayName: ' ' } }, + { code: 'codex.marketplace.policy.invalid', value: { policy: 'ON_USE' } }, + { code: 'codex.marketplace.policy.field.unknown', value: { policy: { approval: 'prompt' } } }, + { code: 'codex.marketplace.policy.authentication.invalid', value: { policy: { authentication: 'ALWAYS' } } }, + { code: 'codex.marketplace.policy.installation.invalid', value: { policy: { installation: 'HIDDEN' } } }, + { code: 'codex.marketplace.policy.installation.not-installable', value: { policy: { installation: 'NOT_AVAILABLE' } } }, +] as const)('rejects invalid authored Codex marketplace input with $code', ({ code, value }) => { + const rejected = emittedMarketplace(withCodexConfig({ marketplace: value })); + + expect(rejected.codes).toContain(code); + expect(rejected.document).toBeUndefined(); +}); + +it('keeps NOT_AVAILABLE documented in the pinned schema but refuses it for the self-installing artifact', () => { + // Live codex-cli 0.147.0 registers a NOT_AVAILABLE marketplace entry and then refuses + // `codex plugin add @`, the exact command INSTALL.md and installBundle() run. + expect(codexCapabilityTable.marketplace.policy.notInstallable).toBe('NOT_AVAILABLE'); + expect(codexCapabilityTable.distribution.marketplacePolicy).toMatchObject({ + evidence: expect.arrayContaining([expect.stringContaining('is not available for install in marketplace')]), + rejected: ['NOT_AVAILABLE'], + }); + const validate = createAdapterValidator().compile(marketplaceSchema); + expect(validate({ + ...marketplace('./'), + plugins: [{ ...entry('./'), policy: { authentication: 'ON_INSTALL', installation: 'NOT_AVAILABLE' } }], + })).toBe(true); + + const rejected = emittedMarketplace(withCodexConfig({ marketplace: { policy: { installation: 'NOT_AVAILABLE' } } })); + expect(rejected.codes).toEqual(['codex.marketplace.policy.installation.not-installable']); + expect(rejected.document).toBeUndefined(); + for (const installation of ['AVAILABLE', 'INSTALLED_BY_DEFAULT']) { + const admitted = emittedMarketplace(withCodexConfig({ marketplace: { policy: { installation } } })); + expect(admitted.codes).toEqual([]); + expect(admitted.document).toMatchObject({ plugins: [{ policy: { authentication: 'ON_INSTALL', installation } }] }); + } +}); diff --git a/packages/agent-bundle/tests/host-adapters.native.test.ts b/packages/agent-bundle/tests/host-adapters.native.test.ts index 0e8afcbd8..309a3a5bc 100644 --- a/packages/agent-bundle/tests/host-adapters.native.test.ts +++ b/packages/agent-bundle/tests/host-adapters.native.test.ts @@ -368,6 +368,125 @@ nativeIt('installs and lists an emitted Codex plugin carrying the complete inter } }); +nativeIt('pins the Codex plugin and marketplace CLI JSON contracts, cache layout, and enable state for an authored marketplace policy', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-codex-marketplace-policy-')); + const pluginRoot = join(root, 'plugin'); + const codexHome = join(root, 'codex-home'); + const policyModel: NormalizedPlugin = { + ...model, + extensions: { + codex: { + id: 'extension:codex', + key: 'codex', + provenance: { kind: 'config', sourcePath: '/workspace/codex.config.ts' }, + target: 'codex', + value: { + interface: { category: 'Developer Tools' }, + marketplace: { + displayName: 'Review Tools Marketplace', + policy: { authentication: 'ON_USE', installation: 'INSTALLED_BY_DEFAULT' }, + }, + }, + }, + }, + }; + const pluginId = 'review-tools@review-tools-marketplace'; + + try { + await Promise.all([ + mkdir(pluginRoot, { recursive: true }), + mkdir(codexHome, { recursive: true }), + ]); + const plan = codexAdapter.plan(policyModel); + expect(plan.diagnostics).toEqual([]); + await emitPlanEntries({ entries: plan.entries, root: pluginRoot }); + + const added = await runCodex(root, ['plugin', 'marketplace', 'add', pluginRoot, '--json'], codexHome); + expect(added.code, added.stderr).toBe(0); + expect(JSON.parse(added.stdout)).toEqual({ + alreadyAdded: false, + installedRoot: pluginRoot, + marketplaceName: 'review-tools-marketplace', + }); + const readded = await runCodex(root, ['plugin', 'marketplace', 'add', pluginRoot, '--json'], codexHome); + expect(readded.code, readded.stderr).toBe(0); + expect(JSON.parse(readded.stdout)).toMatchObject({ alreadyAdded: true }); + + const marketplaces = await runCodex(root, ['plugin', 'marketplace', 'list', '--json'], codexHome); + expect(marketplaces.code, marketplaces.stderr).toBe(0); + expect(JSON.parse(marketplaces.stdout)).toMatchObject({ + marketplaces: expect.arrayContaining([{ + marketplaceSource: { source: pluginRoot, sourceType: 'local' }, + name: 'review-tools-marketplace', + root: pluginRoot, + }]), + }); + + const installed = await runCodex(root, ['plugin', 'add', pluginId, '--json'], codexHome); + expect(installed.code, installed.stderr).toBe(0); + const installedDocument = JSON.parse(installed.stdout) as { readonly installedPath: string }; + expect(installedDocument).toEqual({ + authPolicy: 'ON_USE', + installedPath: join(codexHome, 'plugins', 'cache', 'review-tools-marketplace', 'review-tools', '1.2.3'), + marketplaceName: 'review-tools-marketplace', + name: 'review-tools', + pluginId, + version: '1.2.3', + }); + // The documented cache layout names a literal `local` version segment for + // local plugins; the pinned 0.147.0 CLI uses the manifest version instead. + await access(join(installedDocument.installedPath, '.codex-plugin', 'plugin.json')); + const enableState = await readFile(join(codexHome, 'config.toml'), 'utf8'); + expect(enableState).toContain(`[plugins."${pluginId}"]\nenabled = true`); + expect(enableState).toContain('[marketplaces.review-tools-marketplace]'); + expect(enableState).toContain('source_type = "local"'); + + const listed = await runCodex(root, ['plugin', 'list', '--json'], codexHome); + expect(listed.code, listed.stderr).toBe(0); + expect(JSON.parse(listed.stdout)).toEqual({ + available: [], + installed: [{ + authPolicy: 'ON_USE', + enabled: true, + installPolicy: 'INSTALLED_BY_DEFAULT', + installed: true, + marketplaceName: 'review-tools-marketplace', + marketplaceSource: { source: pluginRoot, sourceType: 'local' }, + name: 'review-tools', + pluginId, + source: { path: pluginRoot, source: 'local' }, + version: '1.2.3', + }], + }); + + const upgraded = await runCodex(root, ['plugin', 'marketplace', 'upgrade', '--json'], codexHome); + expect(upgraded.code, upgraded.stderr).toBe(0); + expect(JSON.parse(upgraded.stdout)).toEqual({ errors: [], selectedMarketplaces: [], upgradedRoots: [] }); + const upgradeLocal = await runCodex(root, ['plugin', 'marketplace', 'upgrade', 'review-tools-marketplace', '--json'], codexHome); + expect(upgradeLocal.code).toBe(1); + expect(upgradeLocal.stderr).toContain('is not configured as a Git marketplace'); + + const removed = await runCodex(root, ['plugin', 'remove', pluginId, '--json'], codexHome); + expect(removed.code, removed.stderr).toBe(0); + expect(JSON.parse(removed.stdout)).toEqual({ + marketplaceName: 'review-tools-marketplace', + name: 'review-tools', + pluginId, + }); + const marketplaceRemoved = await runCodex(root, ['plugin', 'marketplace', 'remove', 'review-tools-marketplace', '--json'], codexHome); + expect(marketplaceRemoved.code, marketplaceRemoved.stderr).toBe(0); + expect(JSON.parse(marketplaceRemoved.stdout)).toEqual({ + installedRoot: null, + marketplaceName: 'review-tools-marketplace', + }); + const cleared = await readFile(join(codexHome, 'config.toml'), 'utf8'); + expect(cleared).not.toContain(pluginId); + expect(cleared).not.toContain('[marketplaces.review-tools-marketplace]'); + } 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 234f3ebad..b5c7025d0 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -1163,8 +1163,15 @@ it('admits documented Codex component path and inline manifest forms', async () { mcpServers: { docs: 'not-an-object' }, skills: './skills/' }, { skills: '../skills/' }, { skills: ['./skills/'] }, + // Line terminators must not let a parent segment slip past the containment lookahead. + { skills: './x\n/../../outside/' }, + { hooks: './x\r\n/../../outside.json', skills: './skills/' }, + { mcpServers: './x\u2028/../../outside.json', skills: './skills/' }, + { skills: './skills\u0000/' }, + { interface: { ...manifest.interface, logo: './x\n/../../outside.png' }, skills: './skills/' }, + { interface: { ...manifest.interface, composerIcon: './x\u2029/../../outside.png' }, skills: './skills/' }, ]) { - expect(validate({ ...manifest, ...invalid })).toBe(false); + expect(validate({ ...manifest, ...invalid }), JSON.stringify(invalid)).toBe(false); } });