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