fix: support Codex CLI 0.142 hook schema
This commit is contained in:
@@ -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 ]]
|
||||
|
||||
Reference in New Issue
Block a user