From 1ad3dcb84f9ec36ed95ea375f94523158c63079f Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 18 Aug 2026 21:39:16 +0000 Subject: [PATCH] fix(land): refuse to land a PR whose webhook subscription is still armed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md bans PR-webhook babysitting twice over — this loop runs on "no timeout, no cap, never the PR webhook", and no heartbeat may babysit a PR — and the harness arms a subscription on every PR this repo opens anyway: #397, #402 and #489, three occurrences in five days, two of them with no `subscribe_pr_activity` call behind them. So a `permissions.deny` row on the tool closes only the path nobody used, and the remedy until now was an agent remembering, which is prose and therefore feedforward only — the half-change non-negotiable rule 2 refuses. THE ACTOR DESIGN DOES NOT WORK, and this is not it. `land` making the call itself looks reachable: the tool is on the session's toolbox MCP server, an http endpoint under /v2/ccr-sessions/ carrying no bearer token, so a JSON-RPC tools/call looks like a plain POST. Measured 2026-08-18, that POST is answered 401 at both the toolbox and the github endpoint, with the injected config's own header values, and identically when forced through $HTTPS_PROXY: no_proxy carries anthropic.com, so requests to that host bypass the agent proxy and nothing injects a credential; the two headers are routing, not authorization. A first cut of this change shipped that POST behind a fail-open anyway. It removed zero subscriptions while its suite stayed green against a stubbed curl — a mechanism that reads as coverage and is not, which is CLOUD-418's defect rebuilt by hand. Filed as CLOUD-673. So this is `claim-check`'s inversion, the same one `issue-search-check` uses: the agent can do what the task cannot. The session's own tool call succeeds — that is how all three occurrences were remedied — so the agent unsubscribes, `pr-unsubscribed record ` records that it happened for THIS pull request in THIS session from the tool's own answer, and `land` refuses to spend a runner until the record exists. The rule becomes an exit code without pretending to an effect nothing here can produce. Placement and posture: * The check is the FIRST thing `land` does, before the singleton and the lease, so a refusal costs no CI at all and the fix is one tool call away. * Off harness there is no injected config, therefore no session, therefore no subscription — `pr-unsubscribed` passes silently. That fail-open is what makes it safe on the critical path; a gate that cannot look must never become a gate that blocks everything. * Keyed by (session, PR), because a subscription belongs to that pair. A receipt from a previous container attests to nothing about this one, and #489's answer cannot satisfy #490 — the honest error this is built for, since the harness pins a session to one branch name for a whole engagement. * Pointer-only on stdout and in the receipt: the PR, the session and a digest of the answer. Never the answer, which is a message about a webhook stream. The honest limit, stated in the gate's own header: this proves the call was MADE for this PR, not that GitHub's subscription state is empty. Only the API answers that and reaching it is CLOUD-673. The claim receipt has the identical property, accepted there deliberately — the threat model is honest error, not fabrication. Ten rows in tests/pr-unsubscribed.bats cover both verbs: the refusal, the recorded drop, an answer naming the wrong PR, a receipt from another PR and from another session, empty stdin as could-not-look rather than a refusal, off-harness silence, pointer-only output, and bad arguments. Three rows in tests/land.bats cover the landing: the stop spends nothing (no ready, no push, no comment, no verify), the check names the PR being landed, and a passing gate leaves a lap unchanged. Four `#MUTANT` declarations, and the stopping-condition census moves 27 -> 28 — it caught the new stop the moment it was added, which is what it is for. Refs: CLOUD-518 --- mise-tasks/land | 26 +++++ mise-tasks/pr-unsubscribed | 219 +++++++++++++++++++++++++++++++++++++ mise.toml | 2 +- tests/land.bats | 70 +++++++++++- tests/pr-unsubscribed.bats | 152 +++++++++++++++++++++++++ 5 files changed, 466 insertions(+), 3 deletions(-) create mode 100755 mise-tasks/pr-unsubscribed create mode 100644 tests/pr-unsubscribed.bats diff --git a/mise-tasks/land b/mise-tasks/land index eb68a81f7..f27993648 100755 --- a/mise-tasks/land +++ b/mise-tasks/land @@ -971,6 +971,32 @@ set -m [ "$branch" != "main" ] || die "refusing to land from main — work happens on a short-lived branch." +# --- the webhook subscription this repo's contract forbids (CLOUD-518) -------- +# +# AGENTS.md bans PR-webhook babysitting twice over — this loop runs on "no +# timeout, no cap, **never the PR webhook**", and no heartbeat may babysit a PR — +# and the harness arms a subscription on every PR this repo opens anyway. Measured +# on #397, #402 and #489, and on two of those with no `subscribe_pr_activity` call +# behind it, which is why a deny rule on the tool closes only the path nobody +# used. The remedy was an agent remembering, which is prose and therefore +# feedforward only. +# +# The drop itself cannot happen here: the tool lives on the session's MCP endpoint +# and a POST from a task is answered 401 (CLOUD-673). The AGENT can make that call +# and does. So this is the `claim-check` inversion — the gate refuses to spend a +# runner until the call has been made and recorded for THIS pull request — and it +# sits here, before the singleton and the lease, so a refusal costs nothing at all +# and the fix is one tool call away. Off harness there is no session and no +# subscription, and `pr-unsubscribed` passes silently; that fail-open is why this +# is safe to put on the critical path. +# +# The gate's own words reach the operator (CLOUD-407): it names the command that +# mints the receipt, so this `die` adds only the landing's context. +#MUTANT subscription-unenforced|s/^if ! mise run pr-unsubscribed check "\$pr"; then$/if false; then/|a session that has not dropped the subscription cannot land +if ! mise run pr-unsubscribed check "$pr"; then + die "#$pr's webhook subscription has not been dropped, and the refusal above says how. Nothing has been spent — do that, then run land again." +fi + lap=0 lease_waits=0 answer_unknowns=0 diff --git a/mise-tasks/pr-unsubscribed b/mise-tasks/pr-unsubscribed new file mode 100755 index 000000000..905ff841e --- /dev/null +++ b/mise-tasks/pr-unsubscribed @@ -0,0 +1,219 @@ +#!/usr/bin/env bash +#MISE description="Gate: this session has dropped the PR's webhook subscription (records unsubscribe_pr_activity's answer, then decides) — CLOUD-518" +# +# CLOUD-518. AGENTS.md bans PR-webhook babysitting twice over — `land` drives the +# landing loop "no timeout, no cap, **never the PR webhook**", and no heartbeat +# may babysit a PR — and the harness arms a subscription on every PR this repo +# opens anyway. Measured on #397, #402 and #489, three occurrences in five days, +# and on two of them a `subscription.created` arrived seconds after the PR existed +# with **no `subscribe_pr_activity` call behind it**. So a `permissions.deny` row +# on the tool is a placebo: it closes only the path nobody used. What the repo had +# instead was prose telling an agent to remember, which is feedforward only and +# exactly the half-change non-negotiable rule 2 refuses. It was missed once and +# caught by hand twice in a single session, and the human noticed by reading pages +# of raw `` envelopes. +# +# WHAT THE STREAM COSTS, measured on #489: ~two dozen wakes in 13 minutes carrying +# ONE CI verdict. A review bot re-delivers its entire walkthrough comment on every +# edit, so the price per event is the size of the largest comment on the PR. +# +# THE ACTOR DESIGN IS BLOCKED, AND THIS IS NOT IT (CLOUD-673). The obvious +# mechanism is for `land` to make the call itself: `unsubscribe_pr_activity` is a +# tool on the session's toolbox MCP server, an `http` endpoint under +# `/v2/ccr-sessions/` carrying no bearer token, so a JSON-RPC `tools/call` looks +# like a plain POST a task could make. It cannot. Measured 2026-08-18, that POST +# is answered `401 authentication required` — at the toolbox endpoint and at the +# `github` one, with the injected config's own header values, and identically when +# forced through `$HTTPS_PROXY`. `no_proxy` carries `anthropic.com`, so requests to +# that host bypass the agent proxy entirely and nothing injects a credential; the +# two headers are routing identifiers, not authorization. A first cut of this +# change shipped that POST anyway, behind a fail-open, and it removed exactly zero +# subscriptions while its suite stayed green against a stubbed `curl` — a mechanism +# that reads as coverage and is not, which is CLOUD-418's defect rebuilt by hand. +# +# SO THIS IS `claim-check`'s INVERSION, borrowed wholesale from +# `issue-search-check` which borrowed it first: **the agent can do what the task +# cannot.** The session's own tool call succeeds — that is how #397, #402 and #489 +# were each remedied — so the agent unsubscribes, this records that it happened +# for THIS pull request in THIS session, and `land` refuses to spend a runner +# until the record exists. The rule stops being prose and becomes an exit code, +# without pretending to an effect nothing here can produce. +# +# THE HONEST LIMIT, stated because it will occur to every reader: this proves the +# CALL WAS MADE for this PR, not that GitHub's subscription state is now empty. +# Only the API can answer the latter and reaching it is CLOUD-673. The claim +# receipt has the identical property — it attests that a read happened, not that +# it was understood — and that was accepted there deliberately. The threat model +# is honest error, not fabrication (AGENTS.md): an agent that pastes invented text +# defeats this, and is outside it. What the gate forces is the ACTION, and the +# action is the whole failure mode. +# +# WHY IT FAILS OPEN OFF-HARNESS, and why that is the design rather than a caveat. +# No injected client config means no session, therefore no subscription, therefore +# nothing to drop — a local CLI clone, a CI runner, a maintainer's laptop. A gate +# that cannot look must never become a gate that blocks everything +# (`mem:workflow/landing-loop`), and here "cannot look" and "nothing to look at" +# are the same reading. The config is consulted for PRESENCE only; that needs no +# credential, which is the one thing this can still do with it. +# +# Keyed by SESSION and PR, not by branch. A subscription belongs to a +# (session, pull request) pair — the harness arms one per PR and delivers it to +# one session — so a receipt from a previous container attests to nothing about +# this one, and a receipt for #489 attests to nothing about #490. Both halves are +# in the filename, and the session half is why a fresh clone correctly refuses. +# +# Usage: +# mise run pr-unsubscribed record # unsubscribe_pr_activity's answer on stdin +# mise run pr-unsubscribed check +# +# Exit 0 dropped (or nothing to drop) / 1 not dropped / 2 could not look — +# matching `claim-check` so these compose in one pipeline. +# +# Output is pointer-only per non-negotiable rule 4: the PR number, and a digest of +# the answer. Never the answer itself, and never an event body — reprinting one +# here would emit the very payload the stream is being cut to stop. The receipt +# obeys the same rule, so a human debugging a refusal reads a hash, not a message. +# +# The mutation makes `check` pass with no receipt, which is the whole gate: with it +# gone a session that never unsubscribed lands exactly as before and only prose +# objects. +#MUTANT check-always-passes|s/^\tif ! receipt_present "\$pr"; then$/\tif false; then/|a PR this session never unsubscribed is refused +# And `record` must bind the answer to THIS pull request. Without it yesterday's +# answer, or another PR's, mints today's receipt — the honest-error case this is +# built for rather than a hypothetical. +#MUTANT record-ignores-the-pr|s/^if ! answer_names_pr; then$/if false; then/|an answer that does not name this PR is refused +# And the off-harness path must stay OPEN. A mutation that refuses where there is +# no session turns a nuisance into a wedge on every clone that never had a +# subscription — the fail-closed inversion the landing loop's gates all refuse. +#MUTANT off-harness-blocks|s/^\tif \[ -z "\$session" \]; then$/\tif false; then/|a clone with no session has nothing to drop +set -uo pipefail + +fail_input() { + echo "::error:: pr-unsubscribed: $*" >&2 + exit 2 +} + +verb="${1:-}" +pr="${2:-}" + +case "$verb" in +record | check) ;; +*) fail_input "usage: pr-unsubscribed record|check " ;; +esac + +case "$pr" in +'' | *[!0-9]*) fail_input "\"$pr\" is not a pull request number" ;; +esac + +git_dir=$(git rev-parse --git-dir 2>/dev/null) || + fail_input "not a git repository — cannot read or write a receipt" + +# THE SESSION, BY PRESENCE OF THE INJECTED CONFIG. The directory is overridable so +# the suite can supply one without ever reading a real session's, and the FILENAME +# shape is the anchor — CLOUD-191's endpoint-anchoring pattern, reduced to the one +# question that needs no credential: is there a session here at all. +# +# Returns non-zero when there is none, which both verbs read as "nothing to drop". +session_id() { + local candidate base session= + for candidate in "${BATTEN_MCP_CONFIG_DIR:-/tmp}"/mcp-config-cse_*.json; do + [ -f "$candidate" ] || continue + base=${candidate##*/} + base=${base#mcp-config-} + session=${base%.json} + done + if [ -z "$session" ]; then + return 1 + fi + printf '%s' "$session" +} + +receipt_path() { + printf '%s' "$git_dir/batten-receipts/pr-unsubscribed.$1.$2" +} + +# Present for THIS session and THIS pr. A session that cannot be identified has no +# subscription to have dropped, so the absence of one is not a missing receipt. +receipt_present() { + local session + session=$(session_id) || return 0 + [ -f "$(receipt_path "$session" "$1")" ] +} + +if [ "$verb" = check ]; then + # The two passing readings are DIFFERENT FACTS and must not share a sentence. + # "Nothing to drop here" is not "this was dropped", and a gate that reports the + # second when it means the first is a false completion signal in miniature. + if ! checked_session=$(session_id); then + echo "pr-unsubscribed: no injected client config, so this clone has no session and no webhook subscription on #$pr" + exit 0 + fi + if ! receipt_present "$pr"; then + echo "::error:: pr-unsubscribed: #$pr carries no unsubscribe receipt for this session. The harness arms a webhook subscription on every PR this repo opens and AGENTS.md forbids babysitting one, so this must be dropped before a runner is spent. Do: call \`unsubscribe_pr_activity\` for this PR, then pipe its answer to \`mise run pr-unsubscribed record $pr\`." >&2 + exit 1 + fi + echo "pr-unsubscribed: #$pr — session $checked_session has dropped its webhook subscription" + exit 0 +fi + +# --- record ------------------------------------------------------------------ + +session=$(session_id) || session= +# A `record` off-harness is a caller doing the right thing in a place where there +# was nothing to do. Silent success, for the same reason `check` passes there: a +# gate that scolds the honest case teaches its own bypass. +[ -n "$session" ] || { + echo "pr-unsubscribed: no injected client config, so this clone has no session and no subscription — nothing to record for #$pr" + exit 0 +} + +# THE ANSWER MUST NAME THIS PULL REQUEST, as a named predicate so the mutation +# that removes it is a one-line substitution carrying no pipe. The declaration +# grammar is three pipe-separated fields, so a script containing one is parsed as a +# fourth and truncated — measured on this file: three rows came back +# `unappliable-mutation` and `names-no-case` because their scripts matched lines +# written with `||`. +# +# `case` rather than a regex: the only question is whether `#` appears, and a +# pattern would invite exactly the prose-judging this refuses below. +answer_names_pr() { + case "$answer" in + *"#$pr"*) return 0 ;; + *) return 1 ;; + esac +} + +answer=$(cat 2>/dev/null) || fail_input "could not read the answer on stdin" +[ -n "${answer//[[:space:]]/}" ] || + fail_input "stdin is empty; pipe \`unsubscribe_pr_activity\`'s answer for #$pr" + +# THE ANSWER MUST NAME THIS PULL REQUEST. Both observed shapes carry it — the +# no-active-subscription reading names `/#` — and requiring it is +# what stops the honest error this is built for: pasting the answer from the +# previous PR, on a branch name the harness reuses for a whole engagement. +# +# It deliberately does NOT judge the answer's prose beyond that. Matching phrasing +# would be a gate estimating an outcome from wording (non-negotiable 3), and the +# outcome it would be guessing at is the one only CLOUD-673 can observe. +if ! answer_names_pr; then + echo "::error:: pr-unsubscribed: that answer does not name #$pr, so it is not evidence about this pull request. Call \`unsubscribe_pr_activity\` for #$pr and pipe what it returns." >&2 + exit 1 +fi + +mkdir -p "$git_dir/batten-receipts" 2>/dev/null || + fail_input "cannot create the receipt store" + +digest=$(printf '%s' "$answer" | sha256sum 2>/dev/null | cut -c1-12) || digest=unhashed + +# Pointer-only, in the receipt as well as on stdout: what was recorded, for which +# PR, in which session, and a digest of the answer — never the answer, which is a +# message about a webhook stream and has no business being stored here. +{ + echo "pr $pr" + echo "session $session" + echo "answer-sha256 $digest" + echo "answer-bytes ${#answer}" +} >"$(receipt_path "$session" "$pr")" 2>/dev/null || + fail_input "cannot write the receipt for #$pr" + +echo "pr-unsubscribed: recorded the drop of #$pr's webhook subscription (answer $digest)" diff --git a/mise.toml b/mise.toml index 354258cd4..a450345cc 100644 --- a/mise.toml +++ b/mise.toml @@ -306,7 +306,7 @@ CI_ANSWERED_CONCLUSIONS = "success,neutral,failure,timed_out,action_required" # # Seeded with the gates this bundle touches, so every "mutation-checked per # CLOUD-418" obligation in it is cashed rather than cited. -MUTANT_GATES = "msrv-pin-agreement,cap-drift,renovate-config-validator,lock-complete,signing-posture,mcp-timeout-budget,land,land-lock,checks-green,ci-lease-precondition,finding-sink-check,issue-search-check,issue-search-guard,run-shape-guard,graph-check,board-move-guard,board-write-record,reclaim-census,done-pr-check,claim-check" +MUTANT_GATES = "msrv-pin-agreement,cap-drift,renovate-config-validator,lock-complete,signing-posture,mcp-timeout-budget,land,land-lock,checks-green,ci-lease-precondition,finding-sink-check,issue-search-check,issue-search-guard,run-shape-guard,graph-check,board-move-guard,board-write-record,reclaim-census,done-pr-check,claim-check,pr-unsubscribed" # --- GitHub reachability behind an egress proxy (Claude Code web sandbox etc.) --- # mise resolves every tool's release through GitHub's *API* host, api.github.com. diff --git a/tests/land.bats b/tests/land.bats index fae207f49..98ef98576 100644 --- a/tests/land.bats +++ b/tests/land.bats @@ -1834,8 +1834,12 @@ head_verdict() { echo "$1" >"$BATS_TEST_TMPDIR/rc.mise.checks-green"; } # each caller dies at top level, because a `die` inside the `$( )` every # caller wraps it in would exit only the subshell (CLOUD-467, measured again # here). Both are exercised below. - [ "$stops" -eq 27 ] || { - echo "land has $stops stopping conditions; this suite covers 27." + # 28 since CLOUD-518: a PR whose webhook subscription this session has not + # dropped. It is the FIRST stop in the run, before the singleton and the lease, + # so a refusal costs no CI at all — and this counter caught it the moment it + # was added, which is what it is for. Exercised below. + [ "$stops" -eq 28 ] || { + echo "land has $stops stopping conditions; this suite covers 28." echo "Add a case for the new one — an unexercised exit is how the refusal path stayed dead." return 1 } @@ -2651,3 +2655,65 @@ EOF run grep -c 'await_first' "$REAL_LAND" [ "$output" -ge 2 ] } + +# --- CLOUD-518: the webhook subscription the harness arms on every PR ---------- +# +# `land` cannot drop it — the tool lives on the session's MCP endpoint and a POST +# from a task is answered 401 (CLOUD-673) — so it refuses to spend a runner until +# the agent has dropped it and `pr-unsubscribed` has recorded that for this PR. +# The gate's own suite (tests/pr-unsubscribed.bats) covers the recording; these +# rows are about the LANDING: that the refusal stops the lap before anything is +# spent, and that a recorded drop is what lets a landing proceed. +# +# Every other case in this file leaves `pr-unsubscribed` passing, which is both +# the off-harness reading and the ordinary one — so nothing else in the suite is +# perturbed by putting a gate on the critical path. + +@test "CLOUD-518: a session that has not dropped the subscription cannot land" { + # The refusal the whole change exists to produce. It must arrive BEFORE any + # spend: no ready, no push, no comment, and no CI. + # + # THE WORLD IS TERMINAL AND THE RUN IS BOUNDED, both so that the MUTATION + # fails this row instead of hanging it. With the check disabled the lap runs + # on, and `main-watch` never answers by default — so a row written as a bare + # `run "$LAND"` blocks forever rather than going red, which is a mutation + # reported as caught by a case that never finished. Measured: wedged for 100 + # minutes inside `mise run mutant`. `pr_state MERGED` gives the un-gated path + # a fast, wrong ending; `run_timeout` is the backstop if it finds another way + # to stall. + task_fails pr-unsubscribed + pr_state MERGED + local out="$BATS_TEST_TMPDIR/land.out" rc=0 + run_timeout -k 1 20 "$LAND" >"$out" 2>&1 || rc=$? + output=$(cat "$out") + status=$rc + [ "$status" -eq 1 ] + [[ "$output" == *"webhook subscription has not been dropped"* ]] + # Nothing was spent: the PR was never readied, nothing was pushed, and the + # fast-forward was never asked for. + [ -z "$(ready_calls)" ] + [[ "$(call_order)" != *push* ]] + [ "$(comments)" -eq 0 ] + # Not even the verify receipt was consulted — the stop is the first thing. + [ "$(verify_calls)" -eq 0 ] +} + +@test "CLOUD-518: the check runs against THIS PR, not some other" { + # A receipt for the wrong pull request is the honest error the gate is built + # for, so `land` has to hand it the PR it is actually landing. + pr_state MERGED + run "$LAND" + [ "$status" -eq 0 ] + run grep -c '^run pr-unsubscribed check 150$' "$BATS_TEST_TMPDIR/misecalls" + [ "$output" -ge 1 ] +} + +@test "CLOUD-518: a dropped subscription lets the landing proceed untouched" { + # The gate passing must change nothing else about a lap — the same merge, the + # same single fast-forward comment. + pr_state MERGED + run "$LAND" + [ "$status" -eq 0 ] + [[ "$output" == *"is MERGED"* ]] + [ "$(comments)" -eq 1 ] +} diff --git a/tests/pr-unsubscribed.bats b/tests/pr-unsubscribed.bats new file mode 100644 index 000000000..39f8a07b6 --- /dev/null +++ b/tests/pr-unsubscribed.bats @@ -0,0 +1,152 @@ +#!/usr/bin/env bats +# CLOUD-518's mechanism: the webhook subscription the harness arms on every PR +# this repo opens, which AGENTS.md forbids babysitting and nothing enforced. +# +# The gate is `claim-check`'s inversion, so these rows are about the two halves +# that inversion needs to hold: `record` must bind the evidence to ONE pull +# request in ONE session, and `check` must refuse when nothing was recorded — +# while staying open where there is no session to have a subscription at all. +# +# What no row here asserts, deliberately: that GitHub's subscription state is +# empty. The gate cannot observe that (CLOUD-673 — a task is answered 401 by the +# session's own MCP endpoint), and the first cut of this change shipped a POST +# that pretended otherwise, staying green against a stubbed `curl` while removing +# exactly zero subscriptions. A suite that proves shape must not be read as +# proving effect, so this one is scoped to what it really decides. + +setup() { + GATE="$BATS_TEST_DIRNAME/../mise-tasks/pr-unsubscribed" + # A throwaway repository, so a receipt can never land in the real `.git`. + REPO="$BATS_TEST_TMPDIR/repo" + mkdir -p "$REPO" + git -C "$REPO" init -q + # Where the injected client config is looked for. Empty by default, which is + # the off-harness reading: no session, no subscription, nothing to drop. + CFG="$BATS_TEST_TMPDIR/cfg" + mkdir -p "$CFG" + export BATTEN_MCP_CONFIG_DIR="$CFG" +} + +# A session exists here. Only its NAME matters to the gate — presence answers the +# one question that needs no credential — so the fixture carries nothing else. +in_session() { printf '{}' >"$CFG/mcp-config-${1:-cse_fixture}.json"; } + +# What `unsubscribe_pr_activity` actually answered, observed on #490. +answer_for() { printf 'No active subscription found for owner/repo#%s on this session.' "$1"; } + +gate() { (cd "$REPO" && "$GATE" "$@"); } +record() { (cd "$REPO" && "$GATE" record "$1"); } +receipts() { ls "$REPO/.git/batten-receipts" 2>/dev/null || true; } + +@test "CLOUD-518: a clone with no session has nothing to drop, and check passes" { + # The off-harness reading, and the reason this gate is safe on the landing + # critical path: a local clone or a CI runner never had a subscription, so a + # missing receipt is not a missing action. A gate that cannot look must never + # become a gate that blocks everything. + run gate check 490 + [ "$status" -eq 0 ] + [[ "$output" == *"no session"* ]] + # And it does NOT claim a drop happened, which is a different fact. + [[ "$output" != *"has dropped"* ]] +} + +@test "CLOUD-518: a PR this session never unsubscribed is refused" { + in_session + run gate check 490 + [ "$status" -eq 1 ] + [[ "$output" == *"no unsubscribe receipt"* ]] + # The refusal names the command that fixes it, so the operator needs nothing + # else — the gate's own words are what reach a stopped landing (CLOUD-407). + [[ "$output" == *"unsubscribe_pr_activity"* ]] + [[ "$output" == *"pr-unsubscribed record 490"* ]] +} + +@test "CLOUD-518: the recorded drop is what makes check pass" { + in_session + run bash -c "$(declare -f answer_for); answer_for 490 | (cd '$REPO' && '$GATE' record 490)" + [ "$status" -eq 0 ] + [[ "$output" == *"recorded the drop of #490"* ]] + run gate check 490 + [ "$status" -eq 0 ] + [[ "$output" == *"has dropped its webhook subscription"* ]] +} + +@test "CLOUD-518: an answer that does not name this PR is refused" { + # The honest error this exists to catch: the harness pins a session to one + # branch name for a whole engagement, so the answer from the PREVIOUS pull + # request on that name is exactly what a hurried agent pastes. + in_session + run bash -c "$(declare -f answer_for); answer_for 489 | (cd '$REPO' && '$GATE' record 490)" + [ "$status" -eq 1 ] + [[ "$output" == *"does not name #490"* ]] + # Nothing was minted, so the landing stays refused. + [ -z "$(receipts)" ] + run gate check 490 + [ "$status" -eq 1 ] +} + +@test "CLOUD-518: a receipt for another PR does not satisfy this one" { + in_session + run bash -c "$(declare -f answer_for); answer_for 489 | (cd '$REPO' && '$GATE' record 489)" + [ "$status" -eq 0 ] + run gate check 490 + [ "$status" -eq 1 ] + run gate check 489 + [ "$status" -eq 0 ] +} + +@test "CLOUD-518: a receipt from another SESSION does not satisfy this one" { + # A subscription belongs to a (session, PR) pair — the harness arms one per PR + # and delivers it to one session — so a receipt left in a clone by an earlier + # container attests to nothing about this one. + in_session cse_first + run bash -c "$(declare -f answer_for); answer_for 490 | (cd '$REPO' && '$GATE' record 490)" + [ "$status" -eq 0 ] + run gate check 490 + [ "$status" -eq 0 ] + rm -f "$CFG"/mcp-config-cse_first.json + in_session cse_second + run gate check 490 + [ "$status" -eq 1 ] +} + +@test "CLOUD-518: an empty answer is could-not-look, never a refusal" { + # Exit 2 and exit 1 answer different questions, and a caller piping nothing + # must not look like a session that skipped the step. + in_session + run bash -c ": | (cd '$REPO' && '$GATE' record 490)" + [ "$status" -eq 2 ] + [[ "$output" == *"stdin is empty"* ]] + [ -z "$(receipts)" ] +} + +@test "CLOUD-518: recording off-harness mints nothing and says so" { + run bash -c "$(declare -f answer_for); answer_for 490 | (cd '$REPO' && '$GATE' record 490)" + [ "$status" -eq 0 ] + [[ "$output" == *"no session"* ]] + [ -z "$(receipts)" ] +} + +@test "CLOUD-518: POINTER, NEVER PAYLOAD — no answer text is printed or stored" { + # Non-negotiable rule 4, and here it is not a formality: the thing being + # recorded is a message about a webhook stream, and reprinting it would emit + # the very payload the stream is being cut to stop. + in_session + run bash -c "printf 'No active subscription found for owner/repo#490 SECRETMARKER' | (cd '$REPO' && '$GATE' record 490)" + [ "$status" -eq 0 ] + [[ "$output" != *SECRETMARKER* ]] + run cat "$REPO/.git/batten-receipts/pr-unsubscribed.cse_fixture.490" + [[ "$output" != *SECRETMARKER* ]] + # What it does carry is a pointer: the PR, the session and a digest. + [[ "$output" == *"pr 490"* ]] + [[ "$output" == *"answer-sha256"* ]] +} + +@test "CLOUD-518: a bad verb or a non-numeric PR is could-not-look" { + run gate frobnicate 490 + [ "$status" -eq 2 ] + [[ "$output" == *"usage"* ]] + run gate check "not-a-number" + [ "$status" -eq 2 ] + [[ "$output" == *"not a pull request number"* ]] +}