diff --git a/scripts/decoy_coverage_gate.py b/scripts/decoy_coverage_gate.py index 2c0071ca..65860343 100644 --- a/scripts/decoy_coverage_gate.py +++ b/scripts/decoy_coverage_gate.py @@ -36,9 +36,6 @@ EXEMPT = { ("felhom.eu", "one-register"): "R-424 — a real defect parked under state `idea` is invisible. Declared in the gate's own " "docstring as residual hole 1; the decoy passes today.", - ("felhom.eu", "hostinstall"): - "R-426 — no plausible decoy constructed yet. It asserts installer invariants against a " - "shell script; a legitimate decoy needs a shape a real edit would produce.", ("felhom.eu", "due-checks"): "R-426 — no legitimate decoy constructed; the attempt in the sweep was a no-op and its " "verdict was withdrawn rather than reported.", diff --git a/scripts/hostinstall_gates.py b/scripts/hostinstall_gates.py index cdbc3b48..9d09f9fd 100644 --- a/scripts/hostinstall_gates.py +++ b/scripts/hostinstall_gates.py @@ -109,7 +109,10 @@ else: # ── 2. age package (F-10) ─────────────────────────────────────────────────────── # must match the REAL install invocation, not the log_dry echo (red-proof-hardened twice: # a prefix regex matched "agekit", then a loose one matched the dry-run print line). -if re.search(r'DEBIAN_FRONTEND=noninteractive apt-get install -y -q age\b', src): +# R-426 (2026-10-06): and not a COMMENTED-OUT one — `# DEBIAN_FRONTEND=… age` (a line disabled while +# debugging) matched the bare search, so the label stayed and the install was gone. Decoy: +# test_gate_decoys.py `hostinstall/age-commented-out`. +if re.search(r'^[^#\n]*DEBIAN_FRONTEND=noninteractive apt-get install -y -q age\b', src, re.M): ok("age is in the installed package set") else: fail("`age` install not found (F-10 — the fresh-box escrow ceremony dies without it)") @@ -221,7 +224,12 @@ if not fn: else: body = fn.group(1) resolutions = len(re.findall(r'BACKUP_TARGET_RESOLVED="\$BACKUP_TARGET_ID"', body)) - grants = len(re.findall(r'felhom-backup-target-apply grant', body)) + # R-426 (2026-10-06): count only REAL invocations — the path-qualified binary at the start of a + # command line. The bare name also matched the `log_dry "felhom-backup-target-apply grant …"` + # echo, so deleting the Scenario-F arm's real grant (R-185's exact regression) left 2 resolutions + # against 2 "grants" — the dry-run label — and this check printed ok. Decoy: test_gate_decoys.py + # `hostinstall/R-185-dry-run-echo-only`. + grants = len(re.findall(r'^[ \t]*/usr/local/sbin/felhom-backup-target-apply grant\b', body, re.M)) if resolutions == 0: fail("configure_backup_target no longer resolves BACKUP_TARGET_ID anywhere — re-read it") elif grants >= resolutions: diff --git a/scripts/test_gate_decoys.py b/scripts/test_gate_decoys.py index 55987486..be243bb5 100644 --- a/scripts/test_gate_decoys.py +++ b/scripts/test_gate_decoys.py @@ -72,6 +72,8 @@ COVERS = { "their sentences. Also: a prefix of the real sentence, the OLD Hungarian " "quote, a reworded bundle, a deleted key, and the scope case — an absent " "controller clone must be INCONCLUSIVE, never a pass"), + "hostinstall": ("R-426: R-185's regression with only its dry-run echo left, the age install commented " + "out, and a version const in a new hub sub-package; a comment naming the identifier passes"), "hub-copy": ("R-558: an English retrieval promise in the NEW bundle (the sentences moved " "out of templates.go, so the surface list had to move with them), the same " "in Hungarian, and an INNOCENT control using the identical verbs without the " @@ -393,6 +395,48 @@ decoy("site/unlisted-page", "site_gates.py", plant_file(os.path.join(ROOT, "website", "zz-r423-decoy.html"), u"\ufeff\n")) +def replace_in(path, old, new): + """Mutate one exact span of a real file; restore it byte-for-byte. A span that is no longer there makes + the decoy UNBUILDABLE, which is a failure of this suite — never a quietly skipped case.""" + def _plant(): + backup = io.open(path, encoding="utf-8").read() + assert backup.count(old) == 1, "%s: the decoy's anchor is gone or ambiguous — rebuild the decoy" % path + io.open(path, "w", encoding="utf-8").write(backup.replace(old, new, 1)) + return lambda: io.open(path, "w", encoding="utf-8").write(backup) + return _plant + + +# ── hostinstall (R-426, 2026-10-06) ────────────────────────────────────────────────────────────── +# +# Each decoy is a one-line edit a real session would make to felhom-host-install.sh, leaving the LABEL +# the gate used to read while removing the fact. The first two PASSED before 2026-10-06 (live holes): +# * R-185's own regression — the Scenario-F arm loses its real grant but keeps its dry-run echo. The +# gate counted the bare word, so the echo stood in for the grant (2 resolutions, 2 "grants"). +# * the `age` install commented out while debugging — the bare search matched the comment. +# * a host-install version const in a NEW hub sub-package — the scope is a walk, not a file list. +# The genuine article is the unmodified tree, which the runner's own `hostinstall` row passes on every push. +_HI = os.path.join(ROOT, "scripts", "felhom-host-install.sh") +decoy("hostinstall/R-185-dry-run-echo-only", "hostinstall_gates.py", + replace_in(_HI, + ' /usr/local/sbin/felhom-backup-target-apply grant "$BACKUP_TARGET_ID" \\\n' + ' || die "backup target grant failed on the pre-existing target', + ' true \\\n' + ' || die "backup target grant failed on the pre-existing target')) +_AGE_CMD = (" DEBIAN_FRONTEND=noninteractive apt-get install -y -q age >/dev/null 2>&1 \\\n" + " || { apt-get update -q >/dev/null 2>&1; DEBIAN_FRONTEND=noninteractive apt-get install -y -q age >/dev/null 2>&1; } \\\n" + " || die \"failed to install the 'age' package (the escrow ceremony needs it)\"\n") +decoy("hostinstall/age-commented-out", "hostinstall_gates.py", + replace_in(_HI, _AGE_CMD, "".join("#" + l[1:] + "\n" for l in _AGE_CMD.splitlines()))) +decoy("hostinstall/hub-version-const-subpkg", "hostinstall_gates.py", + plant_file(os.path.join(ROOT, "hub", "internal", "web", "zzr426decoy", "v.go"), + u'package zzr426decoy\n\nconst hostInstallVersion = "1.32.0"\n')) +# the genuine article beside the third: a comment that merely NAMES the identifier must pass (configs.go +# carries exactly such a note), so the scope widening is not bought with false convictions. +decoy("hostinstall/comment-names-identifier", "hostinstall_gates.py", + plant_file(os.path.join(ROOT, "hub", "internal", "web", "zzr426decoy", "v.go"), + u'package zzr426decoy\n\n// hostInstallVersion was deleted on purpose (R-94).\n'), + expect="accept") + # ── script-tests (R-885) ───────────────────────────────────────────────────────────────────────── # # Its decoys are whole fake repos, so they live in their own file and are RUN from here (guide-quote's shape).