Files
boshu2__agentops/scripts/validate-codex-api-conformance.sh
Bo c516dd55a9 fix(skills): preserve portable Codex activation metadata (#1069)
## What changed

- preserve canonical trigger text while compacting generated Codex
descriptions
- harden portable skill validation for duplicate YAML keys, symlink
containment, and current optional-field rules
- regenerate all owned Codex projections and hashes
- add focused negative and generator regression coverage

## Verification

- portable conformance: 52/52
- portable Bats: 8/8
- generator acceptance: 18/18
- `scripts/regen-all.sh --check`
- Bash syntax, blocking ShellCheck, and `git diff --check`

The interrupted all-skills remediation snapshot is deliberately
excluded: it remains preserved on `codex/all-skills-pass-20260816` and
is not merge-safe.
2026-08-16 21:24:29 -04:00

304 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
# validate-codex-api-conformance.sh — Check generated codex skills against Codex API contract.
# Exit 0 = pass, exit 1 = failures found.
# Contract: docs/contracts/codex-skill-api.md
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
SKILLS_ROOT="${CODEX_SKILLS_ROOT:-$REPO_ROOT/skills-codex}"
# Cross-runtime skills legitimately reference non-Codex runtimes/paths (cc-hooks
# documents ~/.claude/settings.json). Shared exemption list with codex-sync and
# the other Codex gates.
CROSS_RUNTIME_FILE="$REPO_ROOT/scripts/lint/codex-cross-runtime-skills.txt"
is_cross_runtime() {
[[ -f "$CROSS_RUNTIME_FILE" ]] || return 1
grep -vE '^[[:space:]]*#|^[[:space:]]*$' "$CROSS_RUNTIME_FILE" | grep -qxF "$1"
}
# parity_only twins are GENERATED by codex-sync and verified by its byte-exact
# drift gate; conformance is re-checked only on BESPOKE (hand-authored) twins.
BESPOKE_SKILLS="$(python3 -c "import json; d=json.load(open('$REPO_ROOT/skills-codex-overrides/catalog.json')); print(chr(10).join(e['name'] for e in d.get('skills',[]) if e.get('treatment')=='bespoke'))" 2>/dev/null || true)"
is_bespoke() { grep -qxF "$1" <<<"$BESPOKE_SKILLS"; }
failures=0
warnings=0
if [[ ! -d "$SKILLS_ROOT" ]]; then
echo "Error: skills-codex directory not found: $SKILLS_ROOT" >&2
exit 1
fi
# --- Check 1: Portable Agent Skills package contract ---
# The checked-in Codex tree is the portable release projection. Validate every
# generated and bespoke package here; generator parity alone cannot establish
# portable conformance. This intentionally enforces the normative specification
# rather than the narrower behavior of any one reference implementation.
echo "=== Check 1: Portable Agent Skills contract ==="
portable_output=""
if ! portable_output="$(python3 - "$SKILLS_ROOT" "$REPO_ROOT" 2>&1 <<'PY'
import re
import sys
from pathlib import Path
import yaml
from yaml.constructor import ConstructorError
from yaml.resolver import BaseResolver
skills_root = Path(sys.argv[1]).resolve()
repo_root = Path(sys.argv[2]).resolve()
try:
skills_root.relative_to(repo_root)
link_root = repo_root
except ValueError:
# A detached catalog bundle is its own containment boundary. Cross-skill
# links may resolve to sibling packages inside that catalog.
link_root = skills_root
allowed = {"name", "description", "license", "compatibility", "metadata", "allowed-tools"}
name_re = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
link_re = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)")
errors: list[tuple[str, str]] = []
checked = 0
class UniqueKeyLoader(yaml.SafeLoader):
pass
def construct_unique_mapping(loader: UniqueKeyLoader, node: yaml.Node, deep: bool = False) -> dict:
mapping: dict = {}
for key_node, value_node in node.value:
key = loader.construct_object(key_node, deep=deep)
try:
duplicate = key in mapping
except TypeError as exc:
raise ConstructorError(
"while constructing a mapping",
node.start_mark,
"found an unhashable mapping key",
key_node.start_mark,
) from exc
if duplicate:
raise ConstructorError(
"while constructing a mapping",
node.start_mark,
f"found duplicate key {key!r}",
key_node.start_mark,
)
mapping[key] = loader.construct_object(value_node, deep=deep)
return mapping
UniqueKeyLoader.add_constructor(
BaseResolver.DEFAULT_MAPPING_TAG,
construct_unique_mapping,
)
def fail(skill: str, message: str) -> None:
errors.append((skill, message))
for skill_dir in sorted(path for path in skills_root.iterdir() if path.is_dir()):
skill = skill_dir.name
if skill_dir.is_symlink():
fail(skill, "skill package directory must not be a symlink")
continue
skill_md = skill_dir / "SKILL.md"
if not skill_md.is_file() or skill_md.is_symlink():
fail(skill, "missing regular SKILL.md")
continue
checked += 1
try:
text = skill_md.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
fail(skill, f"SKILL.md is not loadable UTF-8: {exc}")
continue
if not text.startswith("---\n"):
fail(skill, "SKILL.md must start with YAML frontmatter")
continue
marker = text.find("\n---\n", 4)
if marker < 0:
fail(skill, "SKILL.md frontmatter is not closed")
continue
raw_frontmatter = text[4:marker]
body = text[marker + 5 :]
try:
metadata = yaml.load(raw_frontmatter, Loader=UniqueKeyLoader)
except yaml.YAMLError as exc:
fail(skill, f"invalid YAML frontmatter: {exc}")
continue
if not isinstance(metadata, dict):
fail(skill, "frontmatter must be a mapping")
continue
unexpected = sorted(set(metadata) - allowed)
if unexpected:
fail(skill, f"host-only frontmatter fields: {', '.join(unexpected)}")
name = metadata.get("name")
if not isinstance(name, str) or not name_re.fullmatch(name) or len(name) > 64:
fail(skill, "name must be 1-64 lowercase ASCII letters/digits/hyphens without edge or repeated hyphens")
elif name != skill:
fail(skill, f"name {name!r} does not match directory {skill!r}")
description = metadata.get("description")
if not isinstance(description, str) or not description.strip() or len(description) > 1024:
fail(skill, "description must be a nonempty string of at most 1024 characters")
if "license" in metadata and (
not isinstance(metadata["license"], str) or not metadata["license"].strip()
):
fail(skill, "license must be a nonempty string")
if "compatibility" in metadata:
compatibility = metadata["compatibility"]
if not isinstance(compatibility, str) or not compatibility.strip() or len(compatibility) > 500:
fail(skill, "compatibility must be a nonempty string of at most 500 characters")
if "metadata" in metadata:
extension = metadata["metadata"]
if not isinstance(extension, dict) or any(
not isinstance(key, str) or not isinstance(value, str)
for key, value in (extension.items() if isinstance(extension, dict) else ())
):
fail(skill, "metadata must map strings to strings")
if "allowed-tools" in metadata:
tools = metadata["allowed-tools"]
if not isinstance(tools, str) or not tools.strip() or "," in tools or "\t" in tools or "\n" in tools:
fail(skill, "allowed-tools must be a nonempty space-separated string without comma delimiters")
if not body.strip():
fail(skill, "SKILL.md body must be nonempty")
for resource in sorted(skill_dir.rglob("*")):
if resource.is_symlink():
fail(skill, f"resource must not be a symlink: {resource.relative_to(skill_dir)}")
# Validate actual Markdown resource links after removing code, where text
# such as errors.AsType[T](err) is not a link. External URLs and anchors are
# valid but do not identify bundled resources.
for markdown in sorted(skill_dir.rglob("*.md")):
if markdown.is_symlink():
continue
try:
markdown_text = markdown.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
fail(skill, f"resource is not loadable UTF-8: {markdown.relative_to(skill_dir)}: {exc}")
continue
markdown_text = re.sub(r"```.*?```", "", markdown_text, flags=re.S)
markdown_text = re.sub(r"`[^`\n]*`", "", markdown_text)
for match in link_re.finditer(markdown_text):
raw_target = match.group(1).strip()
target = raw_target.split(maxsplit=1)[0].strip("<>")
if not target or target.startswith("#") or re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", target):
continue
if target.startswith(("/", "~")) or re.match(r"^[A-Za-z]:[\\/]", target):
fail(skill, f"resource link must be relative: {target}")
continue
path_part = target.split("#", 1)[0].split("?", 1)[0]
resolved = (markdown.parent / path_part).resolve()
try:
resolved.relative_to(link_root)
except ValueError:
fail(skill, f"resource link escapes catalog: {target}")
continue
if not resolved.exists():
fail(skill, f"resource link does not resolve: {markdown.relative_to(skill_dir)} -> {target}")
if checked == 0:
fail("<root>", "no skill packages found")
for skill, message in errors:
print(f" FAIL [{skill}] {message}")
if errors:
raise SystemExit(1)
print(f" PASS [portable] {checked} package(s)")
PY
)"; then
printf '%s\n' "$portable_output"
portable_failures="$(printf '%s\n' "$portable_output" | grep -c '^ FAIL \[' || true)"
if [[ "$portable_failures" -eq 0 ]]; then
echo " FAIL [portable-validator] validator did not complete"
portable_failures=1
fi
failures=$((failures + portable_failures))
else
printf '%s\n' "$portable_output"
fi
# --- Check 2: No Claude-only primitive names ---
echo "=== Check 2: Claude primitive references ==="
# All Claude-only primitives — none have working Codex equivalents
# (todo_write/update_plan empirically verified as unavailable via a headless codex run)
CLAUDE_PRIMITIVES='TaskCreate|TaskList|TaskUpdate|TaskGet|TaskStop|TeamCreate|TeamDelete|SendMessage|EnterPlanMode|ExitPlanMode|EnterWorktree|task-create|task-list|task-update|task-get|task-stop|team-create|team-delete|send-message|enter-plan-mode|exit-plan-mode|enter-worktree|todo_write|update_plan'
while IFS= read -r skill_md; do
skill_name="$(basename "$(dirname "$skill_md")")"
is_bespoke "$skill_name" || continue # parity twins are generator/drift-verified
# Search body (after frontmatter) for Claude primitives
body=$(awk 'BEGIN{skip=0} NR==1 && /^---$/{skip=1; next} skip && /^---$/{skip=0; next} !skip{print}' "$skill_md")
matches=$(echo "$body" | grep -onE "\b($CLAUDE_PRIMITIVES)\b" 2>/dev/null || true)
if [[ -n "$matches" ]]; then
count=$(echo "$matches" | wc -l | tr -d ' ')
echo " FAIL [$skill_name] $count Claude primitive reference(s)"
failures=$((failures + 1))
fi
done < <(find "$SKILLS_ROOT" -mindepth 2 -maxdepth 2 -name 'SKILL.md' -type f | sort)
# --- Check 3: No Claude-specific paths ---
echo "=== Check 3: Claude-specific paths ==="
while IFS= read -r skill_md; do
skill_name="$(basename "$(dirname "$skill_md")")"
is_bespoke "$skill_name" || continue # parity twins are generator/drift-verified
# Cross-runtime skills may reference ~/.claude accurately (cc-hooks documents
# the Claude Code hook config path); skip this check for them.
if is_cross_runtime "$skill_name"; then
continue
fi
# shellcheck disable=SC2088 # intentional literal match of the documented path
matches=$(grep -n '~/\.claude/' "$skill_md" 2>/dev/null || true)
if [[ -n "$matches" ]]; then
count=$(echo "$matches" | wc -l | tr -d ' ')
echo " FAIL [$skill_name] $count ~/.claude/ path reference(s)"
failures=$((failures + 1))
fi
done < <(find "$SKILLS_ROOT" -mindepth 2 -maxdepth 2 -name 'SKILL.md' -type f | sort)
# --- Check 4: agents/openai.yaml validity (if present) ---
echo "=== Check 4: agents/openai.yaml validity ==="
while IFS= read -r yaml_file; do
skill_name="$(basename "$(dirname "$(dirname "$yaml_file")")")"
# Basic YAML syntax check
if ! python3 -c "import yaml; yaml.safe_load(open('$yaml_file'))" 2>/dev/null; then
echo " FAIL [$skill_name] Invalid YAML: $yaml_file"
failures=$((failures + 1))
fi
done < <(find "$SKILLS_ROOT" -path '*/agents/openai.yaml' -type f 2>/dev/null | sort)
# --- Check 5: No Skill() tool invocations ---
echo "=== Check 5: Skill() tool invocations ==="
while IFS= read -r skill_md; do
skill_name="$(basename "$(dirname "$skill_md")")"
is_bespoke "$skill_name" || continue # parity twins are generator/drift-verified
matches=$(grep -n 'Skill(skill=' "$skill_md" 2>/dev/null || true)
if [[ -n "$matches" ]]; then
count=$(echo "$matches" | wc -l | tr -d ' ')
echo " FAIL [$skill_name] $count Skill() tool invocation(s) (use \$skill syntax)"
failures=$((failures + 1))
fi
done < <(find "$SKILLS_ROOT" -mindepth 2 -maxdepth 2 -name 'SKILL.md' -type f | sort)
# --- Summary ---
echo ""
echo "=== Summary ==="
echo "Failures: $failures"
echo "Warnings: $warnings"
if [[ $failures -gt 0 ]]; then
echo ""
echo "Codex API conformance check FAILED with $failures failure(s)."
exit 1
else
echo ""
echo "Codex API conformance check passed."
exit 0
fi