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",
},
}
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
@@ -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
@@ -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
@@ -1,5 +1,4 @@
{
"description": "CASAN lifecycle hooks. Review with /hooks; the project bootstrap resolves and verifies the pinned Core runtime.",
"hooks": {
"UserPromptSubmit": [
{
@@ -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 ]]