From 353d87b6ae845a340359c109b2385b1b0eaeb6c4 Mon Sep 17 00:00:00 2001 From: thanhnv Date: Fri, 24 Jul 2026 16:49:44 +0700 Subject: [PATCH] fix: support Codex CLI 0.142 hook schema --- VERSION | 2 +- packages/casan-devkit/casan-init.py | 23 ++++++++++++++++--- .../casan-devkit/templates/codex/README.md | 4 ++++ .../tests/hybrid-install-tests.sh | 22 ++++++++++++++++++ .../adapters/codex/hooks.template.json | 1 - .../phase-upgrade-compatibility-tests.sh | 6 ++--- 6 files changed, 50 insertions(+), 8 deletions(-) diff --git a/VERSION b/VERSION index ee90284..90a27f9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.4 +1.0.5 diff --git a/packages/casan-devkit/casan-init.py b/packages/casan-devkit/casan-init.py index 9f584c6..eae10c2 100755 --- a/packages/casan-devkit/casan-init.py +++ b/packages/casan-devkit/casan-init.py @@ -89,6 +89,16 @@ CLIENT_SURFACES = { "contract": "casan_owned_explicit_route", }, } +CASAN_CODEX_DESCRIPTIONS = { + ( + "CASAN Plan-20 lifecycle hooks. Review with /hooks; the project " + "bootstrap resolves and verifies the pinned global harness." + ), + ( + "CASAN lifecycle hooks. Review with /hooks; the project bootstrap " + "resolves and verifies the pinned Core runtime." + ), +} RUNTIME_MODES = ("managed", "vendored") @@ -642,10 +652,12 @@ def _remove_marker_hooks(doc, marker): return changed -def merge_json_hooks(target_file, template_file, marker, backups): +def merge_json_hooks( + target_file, template_file, marker, backups, owned_scalars=None): """MERGE the template's hook groups into an existing hooks JSON without clobbering the user's own hooks/agents/skills config. Existing CASAN-owned - handlers are replaced so upgrades cannot leave a stale command behind. + handlers and explicitly listed CASAN-owned scalar metadata are replaced so + upgrades cannot leave a stale or client-incompatible value behind. Returns created|merged|unchanged|None.""" tmpl = _load_json_or(template_file, None) if not isinstance(tmpl, dict): @@ -655,6 +667,9 @@ def merge_json_hooks(target_file, template_file, marker, backups): if not isinstance(doc, dict): doc = {} before = json.dumps(doc, ensure_ascii=False, sort_keys=True) + for key, owned_values in (owned_scalars or {}).items(): + if doc.get(key) in owned_values: + doc.pop(key, None) hooks = doc.get("hooks") if not isinstance(hooks, dict): hooks = {} @@ -1271,7 +1286,9 @@ def cmd_init(args): mark_owned_if_absent(dsth) remove_json_hooks(dsth, "codex_hook.py", backups) r = merge_json_hooks(dsth, os.path.join(ad, "codex", "hooks.template.json"), - "casan-hook.py", backups) + "casan-hook.py", backups, { + "description": CASAN_CODEX_DESCRIPTIONS, + }) if r and r != "unchanged": created_add(dsth) merges[".codex/hooks.json"] = r diff --git a/packages/casan-devkit/templates/codex/README.md b/packages/casan-devkit/templates/codex/README.md index 34020a0..4770533 100644 --- a/packages/casan-devkit/templates/codex/README.md +++ b/packages/casan-devkit/templates/codex/README.md @@ -5,6 +5,10 @@ handler calls `.casan/casan-hook.py`, which resolves and verifies the pinned global harness before dispatching the Codex adapter. No custom `[casan]` TOML keys are required. +The generated `hooks.json` intentionally keeps only the top-level `hooks` key. +This remains compatible with Codex CLI 0.142.5, which rejects newer optional +top-level metadata such as `description`. + This one integration covers the local Codex runtime across the desktop app, CLI, and IDE extension. It does not claim coverage for Codex Cloud/Web, where a repo-local process and its `.casan/casan-hook.py` bootstrap are not the local diff --git a/packages/casan-devkit/tests/hybrid-install-tests.sh b/packages/casan-devkit/tests/hybrid-install-tests.sh index c47d29f..533aa86 100755 --- a/packages/casan-devkit/tests/hybrid-install-tests.sh +++ b/packages/casan-devkit/tests/hybrid-install-tests.sh @@ -184,6 +184,7 @@ echo "$CODEX_BEGIN" | grep -q '"continue": true' && pass "Codex generated hook o python3 - "$PROJ/.codex/hooks.json" <<'PY' && pass "Codex hooks use the current schema and project-relative bootstrap" || fail "Codex hook contract is stale" import json,sys d=json.load(open(sys.argv[1])) +assert set(d) == {"hooks"} handlers=[h for groups in d["hooks"].values() for group in groups for h in group["hooks"]] assert handlers and all(h.get("type") == "command" for h in handlers) assert all(isinstance(h.get("command"), str) for h in handlers) @@ -241,6 +242,16 @@ EXP="$WORK/existing"; mkdir -p "$EXP/.claude/agents" "$EXP/.claude/skills" "$EXP cat > "$EXP/.claude/settings.json" <<'EOF' { "model": "claude-opus-4-8", "hooks": { "PreToolUse": [ {"matcher":"*","hooks":[{"type":"command","command":"my-existing-hook.sh"}]} ] } } EOF +cat > "$EXP/.codex/hooks.json" <<'EOF' +{ + "description": "CASAN Plan-20 lifecycle hooks. Review with /hooks; the project bootstrap resolves and verifies the pinned global harness.", + "hooks": { + "PreToolUse": [ + {"matcher":"*","hooks":[{"type":"command","command":"my-existing-codex-hook.sh"}]} + ] + } +} +EOF echo "agent" > "$EXP/.claude/agents/reviewer.md"; echo "skill" > "$EXP/.claude/skills/deploy.md" printf '[hooks]\nenabled = true\n\n[mytool]\nfoo = 1\n' > "$EXP/.codex/config.toml" ( cd "$EXP" && "$CASAN" init --project existing-app >/dev/null 2>&1 ) @@ -255,6 +266,17 @@ PY py_check "$EXP/.claude/settings.json" "my-existing-hook" && pass "existing Claude hook preserved (not clobbered)" || fail "existing hook clobbered" py_check "$EXP/.claude/settings.json" "casan-hook.py" && pass "CASAN Claude hook merged in" || fail "CASAN hook not merged" [ "$(python3 -c 'import json;print(json.load(open("'"$EXP"'/.claude/settings.json")).get("model"))')" = "claude-opus-4-8" ] && pass "unrelated settings key (model) preserved" || fail "model key lost" +py_check "$EXP/.codex/hooks.json" "my-existing-codex-hook" && pass "existing Codex hook preserved (not clobbered)" || fail "existing Codex hook clobbered" +python3 - "$EXP/.codex/hooks.json" <<'PY' \ + && pass "obsolete CASAN description migrated for Codex 0.142.5 compatibility" \ + || fail "obsolete Codex description was retained" +import json +import sys + +d = json.load(open(sys.argv[1], encoding="utf-8")) +assert "description" not in d +assert set(d) == {"hooks"} +PY grep -q '\[mytool\]' "$EXP/.codex/config.toml" && pass "existing codex [mytool] preserved" || fail "mytool lost" [ "$(grep -c '^\[hooks\]' "$EXP/.codex/config.toml")" = "1" ] && pass "codex config.toml has no duplicate [hooks] table" || fail "duplicate [hooks]" # idempotent diff --git a/packages/casan-harness/adapters/codex/hooks.template.json b/packages/casan-harness/adapters/codex/hooks.template.json index 88d2067..64d70b0 100644 --- a/packages/casan-harness/adapters/codex/hooks.template.json +++ b/packages/casan-harness/adapters/codex/hooks.template.json @@ -1,5 +1,4 @@ { - "description": "CASAN lifecycle hooks. Review with /hooks; the project bootstrap resolves and verifies the pinned Core runtime.", "hooks": { "UserPromptSubmit": [ { diff --git a/packages/casan-harness/tests/phase-upgrade-compatibility-tests.sh b/packages/casan-harness/tests/phase-upgrade-compatibility-tests.sh index 2a08278..1aac911 100755 --- a/packages/casan-harness/tests/phase-upgrade-compatibility-tests.sh +++ b/packages/casan-harness/tests/phase-upgrade-compatibility-tests.sh @@ -6,8 +6,8 @@ S="$CASAN_HARNESS_ROOT/scripts/bash/upgrade-compatibility.sh" M="$CASAN_APP_ROOT/infra/production/compatibility-matrix.json" PASS=0; FAIL=0 pass(){ echo "PASS: $1"; PASS=$((PASS+1)); }; fail(){ echo "FAIL: $1"; FAIL=$((FAIL+1)); } -if bash "$S" check --from 1.0.3 --to 1.0.4 --matrix "$M" | grep -q '"decision": "allow"'; then pass "patch upgrade is explicitly allowed"; else fail "patch upgrade denied"; fi -if bash "$S" check --from 1.0.4 --to 1.0.3 --rollback --matrix "$M" | grep -q '"rollback": true'; then pass "approved rollback is explicitly allowed"; else fail "approved rollback denied"; fi -if bash "$S" check --from 1.0.4 --to 2.0.0 --matrix "$M" >/dev/null 2>&1; then fail "unreviewed major upgrade accepted"; else pass "unreviewed major upgrade denied"; fi +if bash "$S" check --from 1.0.4 --to 1.0.5 --matrix "$M" | grep -q '"decision": "allow"'; then pass "patch upgrade is explicitly allowed"; else fail "patch upgrade denied"; fi +if bash "$S" check --from 1.0.5 --to 1.0.4 --rollback --matrix "$M" | grep -q '"rollback": true'; then pass "approved rollback is explicitly allowed"; else fail "approved rollback denied"; fi +if bash "$S" check --from 1.0.5 --to 2.0.0 --matrix "$M" >/dev/null 2>&1; then fail "unreviewed major upgrade accepted"; else pass "unreviewed major upgrade denied"; fi if bash "$S" check --from invalid --to 1.0.0 --matrix "$M" >/dev/null 2>&1; then fail "invalid version accepted"; else pass "invalid version denied"; fi echo "===== UPGRADE COMPATIBILITY SUMMARY: PASS=$PASS FAIL=$FAIL ====="; [[ "$FAIL" -eq 0 ]]