diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ae85be..4d2a621 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ All notable changes to this project are documented here. +## [0.3.10] - 2026-07-19 + +### Fixed + +- The `requirements` checker now matches the exact Codex 0.144.6 + `ConfigRequirementsToml` surface, correcting fail-closed defects that rejected + valid managed files. The managed key set uses `experimental_network` (not + `network`) and accepts the `feature_requirements` alias for `features`; + `allowed_permission_profiles` entries that are not built-ins are no longer + rejected (they may be defined in a lower config layer); and `mcp_servers`, + `features`, and other managed values are no longer validated with config.toml + shapes. Permission-profile validation mirrors Codex exactly: `default_permissions` + requires `allowed_permission_profiles`, the effective default must map to `true`, + and the implicit `:workspace` default requires both `:workspace` and `:read-only` + to be allowed. + +### Added + +- The `marketplace` checker discovers `.claude-plugin/marketplace.json` (the third + manifest filename Codex recognizes) alongside `marketplace.json` and + `api_marketplace.json`. +- `create_codex_artifact.py marketplace` gains `--source-type` + (`local`/`url`/`git-subdir`/`npm`) with matching source fields, so the creator + scaffolds every plugin source the checker accepts. + +### Changed + +- The builder plugin advances to 0.3.5 so an in-place `install-builder` + re-materializes the cache after the checker and generator change. + ## [0.3.9] - 2026-07-19 ### Added diff --git a/VERSION b/VERSION index 940ac09..5503126 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.9 +0.3.10 diff --git a/build/manifest.json b/build/manifest.json index ba01d16..da77f80 100644 --- a/build/manifest.json +++ b/build/manifest.json @@ -5,7 +5,7 @@ "location": "sibling ..nddev-codex-backups", "slots": 10 }, - "build_version": "0.3.9", + "build_version": "0.3.10", "command_policy": { "json_supported": [ "list", diff --git a/build/release-evidence.json b/build/release-evidence.json index 1505016..4782c28 100644 --- a/build/release-evidence.json +++ b/build/release-evidence.json @@ -2,15 +2,15 @@ "schema_version": 2, "module": { "repository": "NDDev-it-com/nddev-codex-app", - "setup_digest": "sha256:83fe1de1c52078510e2b4dd42d08863fb804fedb7fef60a656cd4941714d67a3" + "setup_digest": "sha256:7af08077a27c6133d2ec29bbd948ab192341a65a4f6158b64375e15bb395dd93" }, "harness": { "repository": "NDDev-it-com/nddev-harnesses", - "commit": "7ea592555c7bc48e94208602d0acda24f714f95f" + "commit": "656095c3922e6354e6b87fabb9fe137b74d7a29d" }, "adapter": { "id": "codex", - "version": "0.3.9" + "version": "0.3.10" }, "vendor": { "cli_version": "0.144.6", @@ -29,8 +29,8 @@ } ], "lanes": [], - "generated_at_utc": "2026-07-19T06:27:12Z", - "expires_at_utc": "2027-01-15T06:27:12Z", + "generated_at_utc": "2026-07-19T07:13:38Z", + "expires_at_utc": "2027-01-15T07:13:38Z", "promotion": { "decision": "pending", "waivers": [] diff --git a/build/version.json b/build/version.json index 94e0227..821e376 100644 --- a/build/version.json +++ b/build/version.json @@ -1,8 +1,8 @@ { - "build_version": "0.3.9", + "build_version": "0.3.10", "codex_permission_profiles_since": "0.138.0", "codex_cli_tested": "0.144.6", - "nddev_builder_plugin_version": "0.3.4", + "nddev_builder_plugin_version": "0.3.5", "python_requires": ">=3.10", "runtime_baseline_ref": "references/codex-baseline.json", "schema_version": 2 diff --git a/plugins/nddev-builder/.codex-plugin/plugin.json b/plugins/nddev-builder/.codex-plugin/plugin.json index 02aa506..790b67c 100644 --- a/plugins/nddev-builder/.codex-plugin/plugin.json +++ b/plugins/nddev-builder/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "nddev-builder", - "version": "0.3.4", + "version": "0.3.5", "description": "Create and validate native Codex skills, plugins, marketplaces, agents, hooks, MCP servers, app mappings, configuration, instructions, execpolicy rules, and managed requirements.", "author": { "name": "Danil Silantyev / NDDev", diff --git a/plugins/nddev-builder/README.md b/plugins/nddev-builder/README.md index 1cc6158..8445f91 100644 --- a/plugins/nddev-builder/README.md +++ b/plugins/nddev-builder/README.md @@ -69,9 +69,10 @@ python3 scripts/check_codex_artifact.py skill \ Supported generator/checker kinds are `skill`, `plugin`, `marketplace`, `agent`, `hook`, `mcp`, `app`, `config`, `instructions`, `rule`, and -`requirements`. The `marketplace` checker accepts `local`, `url`, `git-subdir`, -and `npm` plugin sources and also discovers a sibling `api_marketplace.json` -catalog. Run each +`requirements`. The `marketplace` generator and checker both cover `local`, +`url`, `git-subdir`, and `npm` plugin sources (use `--source-type`), and the +checker discovers `marketplace.json`, `api_marketplace.json`, and +`.claude-plugin/marketplace.json` catalogs. Run each script with `--help` before using kind-specific options such as `--app-id`, `--transport`, `--event`, or `--prefix`. Use `--json` when another tool needs a stable machine-readable result. diff --git a/plugins/nddev-builder/scripts/check_codex_artifact.py b/plugins/nddev-builder/scripts/check_codex_artifact.py index 8d3457d..1d44d58 100644 --- a/plugins/nddev-builder/scripts/check_codex_artifact.py +++ b/plugins/nddev-builder/scripts/check_codex_artifact.py @@ -2927,7 +2927,9 @@ def _validate_config_file(path: Path, *, budget: ScanBudget | None = None) -> Ar "allow_remote_control", "computer_use", "windows", + # `features` is the primary key; `feature_requirements` is its serde alias. "features", + "feature_requirements", "hooks", "mcp_servers", "plugins", @@ -2935,7 +2937,8 @@ def _validate_config_file(path: Path, *, budget: ScanBudget | None = None) -> Ar "apps", "rules", "enforce_residency", - "network", + # The struct field is `network` but its serde rename is `experimental_network`. + "experimental_network", "permissions", "models", "guardian_policy_config", @@ -2949,15 +2952,15 @@ def _validate_requirements_file( report = ArtifactReport("requirements", path, budget=budget or ScanBudget()) if not _preflight_path(report, path, directory=False): return report - payload, text = _load_toml(report, path) - if text is None: - return report - # Reuse the shared safety gates: deprecated keys, legacy profile selectors, the - # mixed sandbox/permission surface, default_permissions built-in check, sandbox_mode, - # features booleans, reasoning efforts, and mcp_servers shape. - _config_key_checks(report, path, payload, text) + payload, _text = _load_toml(report, path) if payload is None: + # _load_toml already emitted a fail-closed error (unreadable, Python 3.10 + # without tomllib, invalid TOML, or a non-table root). return report + # Do NOT reuse _config_key_checks here: requirements.toml has its own key surface + # (REQUIREMENTS_TOP_LEVEL_KEYS), and its `mcp_servers`/`features` values use managed + # requirement shapes (McpServerRequirement, a feature->bool map) distinct from + # config.toml, so config-shape validation would reject valid managed files. _unknown_keys( report, path, @@ -2965,12 +2968,17 @@ def _validate_requirements_file( REQUIREMENTS_TOP_LEVEL_KEYS, "Codex 0.144.6 requirements top-level", ) - defined_profiles: set[str] = set() - permissions = payload.get("permissions") - if isinstance(permissions, dict): - defined_profiles = {key for key in permissions if isinstance(key, str)} allowed = payload.get("allowed_permission_profiles") + default_permissions = payload.get("default_permissions") if allowed is None: + # Codex requires allowed_permission_profiles whenever default_permissions is set. + if default_permissions is not None: + report.error( + "permission-profile", + path, + "default_permissions requires allowed_permission_profiles", + ) + _requirements_flag_checks(report, path, payload) return report if not isinstance(allowed, dict) or not allowed: report.error( @@ -2978,7 +2986,12 @@ def _validate_requirements_file( path, "allowed_permission_profiles must be a non-empty table of profile name to boolean", ) + _requirements_flag_checks(report, path, payload) return report + # A profile counts as allowed only when present AND mapped to true (Codex + # `is_permission_allowed`). Non-built-in profiles may be defined in a lower + # config layer, so a single-file static check must not reject them. + allowed_true: set[str] = set() for profile, enabled in allowed.items(): if not isinstance(profile, str) or not profile.strip(): report.error( @@ -2993,36 +3006,49 @@ def _validate_requirements_file( path, f"allowed_permission_profiles entry `{profile}` must map to a boolean", ) - if profile.startswith(":"): - if profile not in _BUILTIN_PERMISSION_PROFILES: - report.error( - "permission-profile", - path, - f"allowed_permission_profiles entry `{profile}` is an unknown built-in profile", - ) - elif profile not in defined_profiles: + continue + if profile.startswith(":") and profile not in _BUILTIN_PERMISSION_PROFILES: report.error( "permission-profile", path, - f"allowed_permission_profiles entry `{profile}` is not defined under [permissions.{profile}]", + f"allowed_permission_profiles entry `{profile}` is an unknown built-in profile", ) - default_permissions = payload.get("default_permissions") + if enabled: + allowed_true.add(profile) + # Resolve the effective default as Codex does: the explicit default, else the + # implicit `:workspace` only when both `:workspace` and `:read-only` are allowed. if isinstance(default_permissions, str) and default_permissions.strip(): - if default_permissions not in allowed: - report.error( - "permission-profile", - path, - "default_permissions must be one of the allowed_permission_profiles entries", - ) - elif default_permissions is None and not {":read-only", ":workspace"} <= set(allowed): + effective_default: str | None = default_permissions + elif default_permissions is None and {":workspace", ":read-only"} <= allowed_true: + effective_default = ":workspace" + else: + effective_default = None + if effective_default is None: report.error( "permission-profile", path, - "allowed_permission_profiles must permit both :read-only and :workspace when default_permissions is unset", + "default_permissions must be set unless allowed_permission_profiles allows both :read-only and :workspace", ) + elif effective_default not in allowed_true: + report.error( + "permission-profile", + path, + f"default_permissions `{effective_default}` must be allowed (set to true) by allowed_permission_profiles", + ) + _requirements_flag_checks(report, path, payload) return report +def _requirements_flag_checks(report: ArtifactReport, path: Path, payload: dict[str, Any]) -> None: + for bool_key in ("allow_managed_hooks_only", "allow_appshots", "allow_remote_control"): + value = payload.get(bool_key) + if value is not None and not isinstance(value, bool): + report.error("type", path, f"{bool_key} must be a boolean") + guardian = payload.get("guardian_policy_config") + if guardian is not None and (not isinstance(guardian, str) or not guardian.strip()): + report.error("type", path, "guardian_policy_config must be a non-empty string") + + def _validate_instructions_file(path: Path, *, budget: ScanBudget | None = None) -> ArtifactReport: report = ArtifactReport("instructions", path, budget=budget or ScanBudget()) if not _preflight_path(report, path, directory=False): @@ -3265,6 +3291,8 @@ def _artifact_candidate(path: Path) -> tuple[str, Path] | None: and path.parent.parent.name == ".agents" ): return "marketplace", path + if path.name == "marketplace.json" and path.parent.name == ".claude-plugin": + return "marketplace", path if path.name == "hooks.json": return "hook", path if path.name == ".mcp.json": diff --git a/plugins/nddev-builder/scripts/create_codex_artifact.py b/plugins/nddev-builder/scripts/create_codex_artifact.py index a29950e..4f090ab 100644 --- a/plugins/nddev-builder/scripts/create_codex_artifact.py +++ b/plugins/nddev-builder/scripts/create_codex_artifact.py @@ -916,13 +916,47 @@ def validate_plugin_path(raw_path: str) -> str: return raw_path.rstrip("/") +def _marketplace_source(args: argparse.Namespace, plugin_name: str) -> dict[str, str]: + source_type = args.source_type + if source_type == "local": + path = validate_plugin_path(args.plugin_path or f"./plugins/{plugin_name}") + return {"source": "local", "path": path} + if source_type == "url": + if not args.source_url: + fail("--source-type url requires --source-url") + source: dict[str, str] = {"source": "url", "url": args.source_url} + if args.source_subdir: + source["path"] = args.source_subdir + if args.source_ref: + source["ref"] = args.source_ref + return source + if source_type == "git-subdir": + if not args.source_url or not args.source_subdir: + fail("--source-type git-subdir requires --source-url and --source-subdir") + source = {"source": "git-subdir", "url": args.source_url, "path": args.source_subdir} + if args.source_ref: + source["ref"] = args.source_ref + return source + if source_type == "npm": + if not args.npm_package: + fail("--source-type npm requires --npm-package") + source = {"source": "npm", "package": args.npm_package} + if args.npm_version: + source["version"] = args.npm_version + if args.npm_registry: + if not args.npm_registry.startswith("https://"): + fail("--npm-registry must be an https:// URL") + source["registry"] = args.npm_registry + return source + fail(f"unknown --source-type `{source_type}`") + + def create_marketplace( args: argparse.Namespace, output: Path, name: str, description: str ) -> CreationPlan: if not args.plugin_name: fail("marketplace creation requires --plugin-name") plugin_name = validate_name(args.plugin_name, "--plugin-name") - plugin_path = validate_plugin_path(args.plugin_path or f"./plugins/{plugin_name}") category = validate_required_line(args.category, "--category") manifest_path = output / ".agents" / "plugins" / "marketplace.json" marketplace = { @@ -931,7 +965,7 @@ def create_marketplace( "plugins": [ { "name": plugin_name, - "source": {"source": "local", "path": plugin_path}, + "source": _marketplace_source(args, plugin_name), "policy": { "installation": args.install_policy, "authentication": args.auth_policy, @@ -1221,6 +1255,15 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--category", default="Developer Tools") parser.add_argument("--plugin-name") parser.add_argument("--plugin-path") + parser.add_argument( + "--source-type", choices=("local", "url", "git-subdir", "npm"), default="local" + ) + parser.add_argument("--source-url") + parser.add_argument("--source-subdir") + parser.add_argument("--source-ref") + parser.add_argument("--npm-package") + parser.add_argument("--npm-version") + parser.add_argument("--npm-registry") parser.add_argument( "--install-policy", choices=("AVAILABLE", "INSTALLED_BY_DEFAULT", "NOT_AVAILABLE"), diff --git a/plugins/nddev-builder/skills/codex-marketplace-creator/SKILL.md b/plugins/nddev-builder/skills/codex-marketplace-creator/SKILL.md index 4f78222..8172153 100644 --- a/plugins/nddev-builder/skills/codex-marketplace-creator/SKILL.md +++ b/plugins/nddev-builder/skills/codex-marketplace-creator/SKILL.md @@ -19,7 +19,7 @@ Create a small, auditable catalog that points to canonical plugin sources withou python3 ../../scripts/create_codex_artifact.py marketplace --output . --name example-marketplace --description "Curated Codex plugins for this repository" --plugin-name example-plugin 4. Add stable plugin identifiers and resolvable sources. Keep each category and policy aligned with actual behavior. -5. Prefer immutable remote references for releases and local relative sources for repository development. +5. Select the source with `--source-type`: `local` for repository development, or `url`, `git-subdir`, or `npm` for remote releases. Prefer immutable remote references (`ref`/`sha` or a pinned `npm` version) for releases. 6. Do not embed credentials, environment values, or duplicate plugin manifests. 7. Validate the catalog and all reachable local plugins: