Browse Source

fix(skills): loop-ops — tolerate CRLF + UTF-8 BOM in configs (Windows footgun)

Adversarial edge-case pass found loop-check / loop-doctor mis-parsing a config
saved by a Windows editor: a leading UTF-8 BOM made `^name:` fail to match (exit 10
on a valid config), and CRLF only "worked" because Windows gawk silently strips \r
(it would break on Linux awk). Both scripts now normalize the config — strip a
line-1 BOM + CR line-endings (portable octal BOM + gsub \r) — before the flat-YAML
parse, so a CRLF/BOM file parses identically to a clean LF one. Added CRLF + BOM
regression tests. Suite 116 -> 120.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0xDarkMatter 3 days ago
parent
commit
746db45da5

+ 5 - 0
skills/loop-ops/scripts/loop-check.sh

@@ -78,6 +78,11 @@ done
 [[ "$MIN" =~ ^[0-9]+$ ]] || die_usage "--min must be an integer (got '$MIN')"
 [[ -f "$CFG" ]] || { printf 'error: config not found: %s\n' "$CFG" >&2; exit "$EX_NOTFOUND"; }
 
+# Normalize Windows-authored configs: strip a leading UTF-8 BOM (line 1) and CR
+# line-endings so a CRLF/BOM file parses identically to a clean LF one (octal BOM +
+# gsub \r are portable across gawk/mawk/BSD awk). Falls back to the original on failure.
+__NORM="$(mktemp 2>/dev/null)" && awk 'NR==1{sub(/^\357\273\277/,"")} {gsub(/\r/,""); print}' "$CFG" > "$__NORM" 2>/dev/null && CFG="$__NORM" && trap 'rm -f "$__NORM"' EXIT
+
 # Unparseable: no top-level `key:` lines at all.
 if ! grep -Eq '^[a-z_]+:' "$CFG"; then
   printf 'error: no parseable top-level keys in %s\n' "$CFG" >&2

+ 3 - 0
skills/loop-ops/scripts/loop-doctor.sh

@@ -82,6 +82,9 @@ command -v grep >/dev/null 2>&1 || { echo "loop-doctor: grep required" >&2; exit
 
 [[ -n "$CFG" ]] || die_usage "a loop.config.yaml path is required"
 [[ -f "$CFG" ]] || { printf 'error: config not found: %s\n' "$CFG" >&2; exit "$EX_NOTFOUND"; }
+# Normalize Windows-authored configs: strip a leading UTF-8 BOM + CR line-endings so a
+# CRLF/BOM file parses like a clean LF one (portable octal BOM + gsub \r).
+__NORM="$(mktemp 2>/dev/null)" && awk 'NR==1{sub(/^\357\273\277/,"")} {gsub(/\r/,""); print}' "$CFG" > "$__NORM" 2>/dev/null && CFG="$__NORM" && trap 'rm -f "$__NORM"' EXIT
 grep -Eq '^[a-z_]+:' "$CFG" || { printf 'error: no parseable keys in %s\n' "$CFG" >&2; exit "$EX_UNPARSEABLE"; }
 
 # Pick a working python for the budget-vs-cost check (skipped gracefully if none).

+ 10 - 0
skills/loop-ops/tests/run.sh

@@ -269,6 +269,16 @@ out="$("$PYTHON" "$SYNC" --json 2>/dev/null)"
 expect_has "pricing-sync json schema" "claude-mods.loop-ops.pricing-sync/v1" "$out"
 expect_has "pricing-sync json in_sync" '"in_sync": true' "$out"
 
+# ── Windows-authored configs: CRLF + UTF-8 BOM must parse like clean LF ─────
+echo "-- windows-authored configs (CRLF / BOM) --"
+good_l1 "$SB/win.yaml"
+sed 's/$/\r/' "$SB/win.yaml" > "$SB/win-crlf.yaml"                       # LF -> CRLF
+bash "$AUDIT"  "$SB/win-crlf.yaml" >/dev/null 2>&1; expect_exit "CRLF config audits clean -> 0" 0 $?
+bash "$DOCTOR" --offline "$SB/win-crlf.yaml" >/dev/null 2>&1; expect_exit "CRLF config doctors clean -> 0" 0 $?
+printf '\xEF\xBB\xBF' > "$SB/win-bom.yaml"; cat "$SB/win.yaml" >> "$SB/win-bom.yaml"  # prepend BOM
+bash "$AUDIT"  "$SB/win-bom.yaml" >/dev/null 2>&1; expect_exit "BOM config audits clean -> 0" 0 $?
+bash "$DOCTOR" --offline "$SB/win-bom.yaml" >/dev/null 2>&1; expect_exit "BOM config doctors clean -> 0" 0 $?
+
 # ── worked example: the shipped example stays gate-clean ───────────────────
 echo "-- worked example --"
 EX="$SKILL/assets/examples/pr-watch/loop.config.yaml"