fix: support Codex CLI 0.142 hook schema

This commit is contained in:
thanhnv
2026-07-24 16:49:44 +07:00
parent 6804fd14f4
commit 353d87b6ae
6 changed files with 50 additions and 8 deletions
+1 -1
View File
@@ -1 +1 @@
1.0.4 1.0.5
+20 -3
View File
@@ -89,6 +89,16 @@ CLIENT_SURFACES = {
"contract": "casan_owned_explicit_route", "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") RUNTIME_MODES = ("managed", "vendored")
@@ -642,10 +652,12 @@ def _remove_marker_hooks(doc, marker):
return changed 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 """MERGE the template's hook groups into an existing hooks JSON without
clobbering the user's own hooks/agents/skills config. Existing CASAN-owned 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.""" Returns created|merged|unchanged|None."""
tmpl = _load_json_or(template_file, None) tmpl = _load_json_or(template_file, None)
if not isinstance(tmpl, dict): if not isinstance(tmpl, dict):
@@ -655,6 +667,9 @@ def merge_json_hooks(target_file, template_file, marker, backups):
if not isinstance(doc, dict): if not isinstance(doc, dict):
doc = {} doc = {}
before = json.dumps(doc, ensure_ascii=False, sort_keys=True) 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") hooks = doc.get("hooks")
if not isinstance(hooks, dict): if not isinstance(hooks, dict):
hooks = {} hooks = {}
@@ -1271,7 +1286,9 @@ def cmd_init(args):
mark_owned_if_absent(dsth) mark_owned_if_absent(dsth)
remove_json_hooks(dsth, "codex_hook.py", backups) remove_json_hooks(dsth, "codex_hook.py", backups)
r = merge_json_hooks(dsth, os.path.join(ad, "codex", "hooks.template.json"), 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": if r and r != "unchanged":
created_add(dsth) created_add(dsth)
merges[".codex/hooks.json"] = r merges[".codex/hooks.json"] = r
@@ -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 global harness before dispatching the Codex adapter. No custom `[casan]` TOML
keys are required. 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, 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 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 repo-local process and its `.casan/casan-hook.py` bootstrap are not the local
@@ -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" 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 import json,sys
d=json.load(open(sys.argv[1])) 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"]] 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 handlers and all(h.get("type") == "command" for h in handlers)
assert all(isinstance(h.get("command"), str) 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' cat > "$EXP/.claude/settings.json" <<'EOF'
{ "model": "claude-opus-4-8", "hooks": { "PreToolUse": [ {"matcher":"*","hooks":[{"type":"command","command":"my-existing-hook.sh"}]} ] } } { "model": "claude-opus-4-8", "hooks": { "PreToolUse": [ {"matcher":"*","hooks":[{"type":"command","command":"my-existing-hook.sh"}]} ] } }
EOF 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" 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" 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 ) ( 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" "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" 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" [ "$(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 -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]" [ "$(grep -c '^\[hooks\]' "$EXP/.codex/config.toml")" = "1" ] && pass "codex config.toml has no duplicate [hooks] table" || fail "duplicate [hooks]"
# idempotent # idempotent
@@ -1,5 +1,4 @@
{ {
"description": "CASAN lifecycle hooks. Review with /hooks; the project bootstrap resolves and verifies the pinned Core runtime.",
"hooks": { "hooks": {
"UserPromptSubmit": [ "UserPromptSubmit": [
{ {
@@ -6,8 +6,8 @@ S="$CASAN_HARNESS_ROOT/scripts/bash/upgrade-compatibility.sh"
M="$CASAN_APP_ROOT/infra/production/compatibility-matrix.json" M="$CASAN_APP_ROOT/infra/production/compatibility-matrix.json"
PASS=0; FAIL=0 PASS=0; FAIL=0
pass(){ echo "PASS: $1"; PASS=$((PASS+1)); }; fail(){ echo "FAIL: $1"; FAIL=$((FAIL+1)); } 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.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.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.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.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.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 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 ]] echo "===== UPGRADE COMPATIBILITY SUMMARY: PASS=$PASS FAIL=$FAIL ====="; [[ "$FAIL" -eq 0 ]]