Standard production layout: the OKR app (was nested under AINative_OKR_CASAN5/) is now
the repository root. No more wrapper directory.
- Promote AINative_OKR_CASAN5/* -> repo root (backend/ frontend/ packages/ apps/
.specify/ docs/ infra/ nginx/ scripts/ + configs). Merge tool dirs: .gitea (kept the
active deploy ci.yml, added harness-ci.yml + runbooks), .claude (agents/commands +
launch.json), .github moved up.
- Remove redundant: 00_SUBMISSION_PACKAGE, scattered root notes (FPT_CASAN_Full.md,
tu-tuong-casan.md, casan-tu-sinh..., casan_harness_assessment.md, source-review...,
README_CASAN5_REFINED.md), casan-next-plans/ and optimize-docs/ (competition/planning
artifacts — roadmap + design history preserved in git log / commit messages).
- Update all references to the old layout:
- .gitea/workflows/{ci,harness-ci}.yml, .github/workflows/{ci,deploy}.yml:
working-directory .; drop AINative_OKR_CASAN5/ prefix; .specify/{tests,scripts}
-> packages/casan-harness/... (.specify/logs state kept)
- .claude/launch.json, .gitea/*-runbook.md: path prefixes
- CLAUDE.md, README.md: docs/input -> apps/okr/domain/input
- policy-bundle.yaml: 8 policy paths -> packages/casan-harness/...; manifest re-signed
- secrets-scan.sh: fixture excludes -> new package/domain paths.
Full gate from the new root: PASS=64 FAIL=0 SKIP=3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
260 lines
11 KiB
Bash
260 lines
11 KiB
Bash
#!/usr/bin/env bash
|
|
set -uo pipefail
|
|
|
|
# CASAN H6 — AgentOps hardening tests (D1 live alerting · D2 provider-telemetry
|
|
# API + reconciliation · D3 hosted dashboard · V15 sliding-window breaker).
|
|
#
|
|
# All checks run LIVE against real HTTP endpoints started locally (webhook sink,
|
|
# mock provider usage API, dashboard server) — the same "live" standard as the
|
|
# Vault-dev KMS tests. Deterministic: no model needed.
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
source "$SCRIPT_DIR/../scripts/bash/casan-paths.sh"
|
|
PROJECT_ROOT="$CASAN_APP_ROOT"
|
|
S="$CASAN_HARNESS_ROOT/scripts/bash"
|
|
WORK="$(mktemp -d)"
|
|
SINK_PID=""; API_PID=""
|
|
cleanup() {
|
|
[[ -n "$SINK_PID" ]] && { kill "$SINK_PID" 2>/dev/null; wait "$SINK_PID" 2>/dev/null; }
|
|
[[ -n "$API_PID" ]] && { kill "$API_PID" 2>/dev/null; wait "$API_PID" 2>/dev/null; }
|
|
CASAN_AGENTOPS_DIR="$WORK/agentops" bash "$S/dashboard-serve.sh" stop >/dev/null 2>&1
|
|
rm -rf "$WORK"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
PASS=0; FAIL=0
|
|
pass() { echo "PASS: $1"; PASS=$((PASS + 1)); }
|
|
fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); }
|
|
expect_rc() {
|
|
local want="$1" desc="$2"; shift 2
|
|
local got=0; { "$@" >/dev/null 2>&1; } || got=$?
|
|
[[ "$got" -eq "$want" ]] && pass "$desc (rc=$got)" || fail "$desc (got rc=$got, want $want)"
|
|
}
|
|
|
|
free_port() {
|
|
python - <<'PY'
|
|
import socket
|
|
s = socket.socket()
|
|
s.bind(("127.0.0.1", 0))
|
|
print(s.getsockname()[1])
|
|
s.close()
|
|
PY
|
|
}
|
|
|
|
# ── live webhook sink (records every POST body) ────────────────────────────
|
|
cat > "$WORK/sink.py" <<'PY'
|
|
import sys
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
port, out = int(sys.argv[1]), sys.argv[2]
|
|
class H(BaseHTTPRequestHandler):
|
|
def do_POST(self):
|
|
n = int(self.headers.get("Content-Length", 0))
|
|
body = self.rfile.read(n)
|
|
with open(out, "ab") as f:
|
|
f.write(body + b"\n")
|
|
self.send_response(200)
|
|
self.end_headers()
|
|
self.wfile.write(b'{"ok":true}')
|
|
def log_message(self, *a):
|
|
pass
|
|
HTTPServer(("127.0.0.1", port), H).serve_forever()
|
|
PY
|
|
|
|
# ── live mock provider usage API (serves a JSON file on GET) ───────────────
|
|
cat > "$WORK/mockapi.py" <<'PY'
|
|
import sys
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
port, src = int(sys.argv[1]), sys.argv[2]
|
|
class H(BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
data = open(src, "rb").read()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(data)))
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
def log_message(self, *a):
|
|
pass
|
|
HTTPServer(("127.0.0.1", port), H).serve_forever()
|
|
PY
|
|
|
|
wait_http() { # <url> — poll until reachable (any status)
|
|
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do
|
|
curl -sS -m 2 -o /dev/null "$1" 2>/dev/null && return 0
|
|
sleep 0.2
|
|
done
|
|
return 1
|
|
}
|
|
|
|
export CASAN_AGENTOPS_DIR="$WORK/agentops"
|
|
mkdir -p "$CASAN_AGENTOPS_DIR"
|
|
|
|
echo "===== ① H6 live alert dispatch (webhook, dedup, dead-letter) ====="
|
|
SINK_PORT="$(free_port)"
|
|
SINK_OUT="$WORK/sink-received.jsonl"
|
|
: > "$SINK_OUT"
|
|
python "$WORK/sink.py" "$SINK_PORT" "$SINK_OUT" &
|
|
SINK_PID=$!
|
|
wait_http "http://127.0.0.1:$SINK_PORT/" || true
|
|
|
|
cat > "$WORK/alert1.json" <<'EOF'
|
|
{"timestamp":"2026-07-05T00:00:00Z","trace_id":"t-1","severity":"WARN","resource":{"service.name":"demo.agent","service.version":"1.0.0"},"body":{"message":"Alert triggered: execution-failed","alert.type":"execution-failed","step.name":"write_code"},"attributes":{"latency_ms":100,"status":"failed"}}
|
|
EOF
|
|
|
|
export CASAN_ALERT_WEBHOOK="http://127.0.0.1:$SINK_PORT/hook"
|
|
expect_rc 0 "alert is DELIVERED live to the webhook (ALERT_DISPATCHED)" \
|
|
bash "$S/alert-dispatch.sh" "$WORK/alert1.json"
|
|
if grep -q '"alert_type": *"execution-failed"' "$SINK_OUT"; then
|
|
pass "webhook sink received the alert payload (severity routed as CRITICAL)"
|
|
else
|
|
fail "webhook sink did not receive the alert payload"
|
|
fi
|
|
|
|
BEFORE_COUNT="$(grep -c . "$SINK_OUT" || true)"
|
|
OUT2="$(bash "$S/alert-dispatch.sh" "$WORK/alert1.json" 2>&1)"
|
|
AFTER_COUNT="$(grep -c . "$SINK_OUT" || true)"
|
|
if echo "$OUT2" | grep -q "ALERT_DEDUP_SUPPRESSED" && [[ "$BEFORE_COUNT" == "$AFTER_COUNT" ]]; then
|
|
pass "duplicate alert inside dedup window is SUPPRESSED (no double page)"
|
|
else
|
|
fail "duplicate alert was not suppressed (out=$OUT2 before=$BEFORE_COUNT after=$AFTER_COUNT)"
|
|
fi
|
|
|
|
DEAD_PORT="$(free_port)" # nothing listens here
|
|
cat > "$WORK/alert2.json" <<'EOF'
|
|
{"timestamp":"2026-07-05T00:01:00Z","trace_id":"t-2","severity":"WARN","resource":{"service.name":"demo.agent","service.version":"1.0.0"},"body":{"message":"Alert triggered: cost-spike","alert.type":"cost-spike","step.name":"plan"},"attributes":{"latency_ms":100,"status":"success"}}
|
|
EOF
|
|
expect_rc 1 "webhook DOWN + strict => fail-loud (ALERT_DELIVERY_FAILED, no silent loss)" \
|
|
env CASAN_ALERT_WEBHOOK="http://127.0.0.1:$DEAD_PORT/hook" CASAN_ALERT_STRICT=1 \
|
|
bash "$S/alert-dispatch.sh" "$WORK/alert2.json"
|
|
if [[ -s "$CASAN_AGENTOPS_DIR/alert-deadletter.jsonl" ]]; then
|
|
pass "undelivered alert queued to dead-letter (not lost)"
|
|
else
|
|
fail "dead-letter queue empty after failed delivery"
|
|
fi
|
|
|
|
FLUSH_OUT="$(bash "$S/alert-dispatch.sh" --flush-deadletter 2>&1)"; FLUSH_RC=$?
|
|
if [[ "$FLUSH_RC" -eq 0 ]] && echo "$FLUSH_OUT" | grep -q "redelivered=1 remaining=0"; then
|
|
pass "dead-letter flush REDELIVERS the alert once the channel is back"
|
|
else
|
|
fail "dead-letter flush failed (rc=$FLUSH_RC out=$FLUSH_OUT)"
|
|
fi
|
|
|
|
# end-to-end: a failing pipeline step pushes a live alert through agent-metrics
|
|
printf 'input\n' > "$WORK/in.txt"
|
|
: > "$SINK_OUT"
|
|
CASAN_AGENT_NAME="h6.e2e" CASAN_STEP_NAME="e2e_fail_step" \
|
|
bash "$S/agent-metrics.sh" "$WORK/in.txt" "$WORK/out.txt" -- bash -c 'exit 3' >/dev/null 2>&1
|
|
if grep -q '"step": *"e2e_fail_step"' "$SINK_OUT"; then
|
|
pass "END-TO-END: failing step -> agent-metrics -> live webhook alert received"
|
|
else
|
|
fail "end-to-end live alert not received by webhook sink"
|
|
fi
|
|
unset CASAN_ALERT_WEBHOOK
|
|
|
|
echo "===== ② H6 provider-telemetry API + reconciliation ====="
|
|
cat > "$WORK/usage-valid.json" <<'EOF'
|
|
[
|
|
{"provider":"ollama","model":"ornith:9b","run_id":"r1","step":"plan","input_tokens":200,"output_tokens":300,"total_tokens":500,"cost_usd":0.0,"latency_ms":900,"status":"success"},
|
|
{"provider":"ollama","model":"ornith:9b","run_id":"r1","step":"code","input_tokens":400,"output_tokens":600,"total_tokens":1000,"cost_usd":0.0,"latency_ms":1200,"status":"success"}
|
|
]
|
|
EOF
|
|
API_PORT="$(free_port)"
|
|
python "$WORK/mockapi.py" "$API_PORT" "$WORK/usage-valid.json" &
|
|
API_PID=$!
|
|
wait_http "http://127.0.0.1:$API_PORT/usage" || true
|
|
|
|
FETCH_LOG="$WORK/provider-usage.jsonl"
|
|
expect_rc 0 "usage records FETCHED live from provider API (PROVIDER_TELEMETRY_FETCHED)" \
|
|
bash "$S/provider-usage-fetch.sh" "http://127.0.0.1:$API_PORT/usage" "$FETCH_LOG"
|
|
[[ "$(grep -c . "$FETCH_LOG" 2>/dev/null)" == "2" ]] \
|
|
&& pass "both API records imported with api_endpoint provenance" \
|
|
|| fail "expected 2 imported records in $FETCH_LOG"
|
|
|
|
cat > "$WORK/usage-invalid.json" <<'EOF'
|
|
[{"provider":"ollama","model":"ornith:9b","step":"plan","total_tokens":500}]
|
|
EOF
|
|
kill "$API_PID" 2>/dev/null; wait "$API_PID" 2>/dev/null
|
|
python "$WORK/mockapi.py" "$API_PORT" "$WORK/usage-invalid.json" &
|
|
API_PID=$!
|
|
wait_http "http://127.0.0.1:$API_PORT/usage" || true
|
|
BAD_LOG="$WORK/provider-usage-bad.jsonl"
|
|
expect_rc 1 "invalid API schema is REJECTED all-or-nothing (PROVIDER_USAGE_INVALID)" \
|
|
bash "$S/provider-usage-fetch.sh" "http://127.0.0.1:$API_PORT/usage" "$BAD_LOG"
|
|
[[ ! -s "$BAD_LOG" ]] \
|
|
&& pass "nothing imported from an invalid API response (no dirty telemetry)" \
|
|
|| fail "invalid response leaked records into $BAD_LOG"
|
|
|
|
UNREACH_PORT="$(free_port)"
|
|
expect_rc 1 "unreachable provider API => fail-loud (PROVIDER_API_UNREACHABLE)" \
|
|
bash "$S/provider-usage-fetch.sh" "http://127.0.0.1:$UNREACH_PORT/usage" "$WORK/never.jsonl"
|
|
|
|
# reconciliation: local metrics vs provider ground truth
|
|
cat > "$WORK/local-ok.jsonl" <<'EOF'
|
|
{"step":"plan","total_tokens":495}
|
|
{"step":"code","total_tokens":1000}
|
|
EOF
|
|
expect_rc 0 "local metrics reconcile with provider API (TELEMETRY_RECONCILED)" \
|
|
bash "$S/telemetry-reconcile.sh" "$WORK/local-ok.jsonl" "$FETCH_LOG" 10
|
|
|
|
cat > "$WORK/local-under.jsonl" <<'EOF'
|
|
{"step":"plan","total_tokens":50}
|
|
{"step":"code","total_tokens":1000}
|
|
EOF
|
|
expect_rc 1 "local UNDER-REPORTING (cost-hiding) is caught (TELEMETRY_DISCREPANCY)" \
|
|
bash "$S/telemetry-reconcile.sh" "$WORK/local-under.jsonl" "$FETCH_LOG" 10
|
|
|
|
echo "===== ③ H6 hosted dashboard (/healthz stale-aware) ====="
|
|
DASH_PORT="$(free_port)"
|
|
FRESH_METRICS="$WORK/metrics-fresh.jsonl"
|
|
printf '{"trace_id":"t","step":"s","status":"success","latency_ms":10,"total_tokens":5,"cost_estimate":0}\n' > "$FRESH_METRICS"
|
|
CASAN_DASHBOARD_METRICS="$FRESH_METRICS" bash "$S/dashboard-serve.sh" start "$DASH_PORT" >/dev/null 2>&1
|
|
HEALTH="$(curl -sS -m 3 -w '\n%{http_code}' "http://127.0.0.1:$DASH_PORT/healthz" 2>/dev/null)"
|
|
if [[ "${HEALTH##*$'\n'}" == "200" ]] && echo "$HEALTH" | grep -q '"status": *"ok"'; then
|
|
pass "dashboard HOSTED: /healthz live returns 200 ok with fresh metrics"
|
|
else
|
|
fail "healthz not ok (got: $HEALTH)"
|
|
fi
|
|
if curl -sS -m 3 "http://127.0.0.1:$DASH_PORT/" 2>/dev/null | grep -q "AgentOps Dashboard"; then
|
|
pass "dashboard HTML is served live over HTTP (no static-file-only)"
|
|
else
|
|
fail "dashboard HTML not served over HTTP"
|
|
fi
|
|
bash "$S/dashboard-serve.sh" stop >/dev/null 2>&1
|
|
|
|
STALE_METRICS="$WORK/metrics-stale.jsonl"
|
|
printf '{"step":"s","total_tokens":5}\n' > "$STALE_METRICS"
|
|
touch -t 202601010000 "$STALE_METRICS"
|
|
CASAN_DASHBOARD_METRICS="$STALE_METRICS" bash "$S/dashboard-serve.sh" start "$DASH_PORT" >/dev/null 2>&1
|
|
HEALTH2="$(curl -sS -m 3 -w '\n%{http_code}' "http://127.0.0.1:$DASH_PORT/healthz" 2>/dev/null)"
|
|
if [[ "${HEALTH2##*$'\n'}" == "503" ]] && echo "$HEALTH2" | grep -q '"status": *"stale"'; then
|
|
pass "STALE telemetry => /healthz 503 stale (silent telemetry death is page-able)"
|
|
else
|
|
fail "stale metrics not detected by healthz (got: $HEALTH2)"
|
|
fi
|
|
bash "$S/dashboard-serve.sh" stop >/dev/null 2>&1
|
|
|
|
echo "===== ④ H6 sliding-window circuit breaker (V15 interleave evasion) ====="
|
|
ALT_LOG="$WORK/alt-usage.jsonl"
|
|
: > "$ALT_LOG"
|
|
for i in 1 2 3 4 5; do
|
|
printf '{"step":"s%s","status":"error","total_tokens":10}\n' "$i" >> "$ALT_LOG"
|
|
printf '{"step":"s%s","status":"success","total_tokens":10}\n' "$i" >> "$ALT_LOG"
|
|
done
|
|
expect_rc 1 "alternating fail/success EVADES consecutive counter but TRIPS window breaker (CIRCUIT_OPEN_WINDOW)" \
|
|
env CASAN_PROVIDER_LOG="$ALT_LOG" bash "$S/circuit-breaker-check.sh" --breaker-only
|
|
grep -q "CIRCUIT_OPEN_WINDOW" <(env CASAN_PROVIDER_LOG="$ALT_LOG" bash "$S/circuit-breaker-check.sh" --breaker-only 2>&1) \
|
|
&& pass "window breaker reason is CIRCUIT_OPEN_WINDOW (rate-based, not consecutive)" \
|
|
|| fail "window breaker reason missing"
|
|
|
|
OK_LOG="$WORK/ok-usage.jsonl"
|
|
: > "$OK_LOG"
|
|
for i in 1 2 3 4 5 6 7 8 9 10; do
|
|
printf '{"step":"s%s","status":"success","total_tokens":10}\n' "$i" >> "$OK_LOG"
|
|
done
|
|
expect_rc 0 "healthy provider log keeps BOTH breakers closed (no false trip)" \
|
|
env CASAN_PROVIDER_LOG="$OK_LOG" bash "$S/circuit-breaker-check.sh" --breaker-only
|
|
|
|
echo ""
|
|
echo "===== H6 AGENTOPS SUMMARY: PASS=$PASS FAIL=$FAIL ====="
|
|
[[ "$FAIL" -eq 0 ]] || exit 1
|