Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.3.9
0.3.10
2 changes: 1 addition & 1 deletion build/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"location": "sibling .<target-name>.nddev-codex-backups",
"slots": 10
},
"build_version": "0.3.9",
"build_version": "0.3.10",
"command_policy": {
"json_supported": [
"list",
Expand Down
10 changes: 5 additions & 5 deletions build/release-evidence.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": []
Expand Down
4 changes: 2 additions & 2 deletions build/version.json
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion plugins/nddev-builder/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
7 changes: 4 additions & 3 deletions plugins/nddev-builder/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
88 changes: 58 additions & 30 deletions plugins/nddev-builder/scripts/check_codex_artifact.py
Original file line number Diff line number Diff line change
Expand Up @@ -2927,15 +2927,18 @@ 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",
"marketplaces",
"apps",
"rules",
"enforce_residency",
"network",
# The struct field is `network` but its serde rename is `experimental_network`.
"experimental_network",
"permissions",
"models",
"guardian_policy_config",
Expand All @@ -2949,36 +2952,46 @@ 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,
payload,
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(
"permission-profile",
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(
Expand All @@ -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):
Expand Down Expand Up @@ -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":
Expand Down
47 changes: 45 additions & 2 deletions plugins/nddev-builder/scripts/create_codex_artifact.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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,
Expand Down Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down