feat(bin): add inert private Telegram bridge with enforced publish gate - #1214
Closed
yelenplays wants to merge 23 commits into
Closed
feat(bin): add inert private Telegram bridge with enforced publish gate#1214yelenplays wants to merge 23 commits into
yelenplays wants to merge 23 commits into
Conversation
Adds an opt-in Telegram Bot API bridge that lets exactly one paired outside person send requests about exactly one project, and receive replies and previews, without relaying every message through the captain. The bridge is inert until a home puts FM_TELEGRAM_BOT_TOKEN in its gitignored .env and completes a one-time pairing: with no token nothing is written, no host is contacted, and no behavior changes. Shared contracts three channels now need are extracted so neither copy can drift: private-artifact file guarantees, outbound message splitting, and the .env reader. - pairing pins Telegram's immutable numeric user id and chat id; names are never read, and only a salted hash of the single-use code is stored - getUpdates offsets are confirmed only after the whole processed prefix is durable, so a crash neither duplicates nor drops a claimed message - the bot token is passed through a mode-0600 curl config file, never argv - a request authorizes preparing and previewing a change; landing needs a separate matching confirmation for the exact previewed revision - the watcher check sweep now rotates, so two always-on channels cannot starve each other
…API server The fake server is a stateful implementation of the two Bot API methods the bridge uses, reached through the client's real transport: a curl shim that parses the mode-0600 curl config file the client writes. No socket, no port, no real token, so the suite is hermetic while still proving the token never enters an argument vector. getUpdates implements Telegram's real offset contract, which is what makes the crash, duplicate-delivery, and offset-recovery cases testable rather than assumed. Also fixes two defects the suite found: - the pairing redemption ran in a command substitution, so the pinned peer never reached the confirmation sender and no confirmation was delivered - the publish gate read $? after an if statement, which is the if's status, so an already-consumed confirmation was accepted a second time
- configuration.md gains the operator-facing Telegram bridge section: exact BotFather setup and pairing steps with no real token, identity and refusal rules, token handling and rotation, generated state, delivery guarantees, reply contract, the two-step publish confirmation, every tunable, and an honest statement that this needs the local machine and supervision running and is not a hosted always-on service - architecture.md explains why this is not a second X mode and why each difference follows from the sender not being the captain - README, scripts reference, and the documentation audience inventory are updated; a dated verification record captures the active evidence, including the harness matrix and the inspected reason runtime backends are unaffected - fm-test-run.sh registers the suite as its own family, and the three extracted shared libraries select both channels' families so neither can regress unnoticed
…umulator Also tells telegram-respond that a rate-limit error means the paired person's messages were dropped rather than queued, so they get told to resend instead of being left waiting on a reply that will never come.
A gate agent runs inside a firstmate checkout and auto-loads AGENTS.md, so it now reads that this channel exists and could reach for the reply, pairing, or publish surface. The existing capability-removal guard already covers that hazard for the fleet entrypoints; the bridge needs it more, because here the blast radius is a message to someone outside the fleet, an unpaired peer, or an authorized publish.
…s, and temp leftovers
Three surfaces hard-coded X mode's artifact name, so a bridged home was broken in ways its own test suite could not see. - bin/fm-pr-check-migrate.sh quarantined the valid bridge shim, and the watcher runs that migration on every start, so an armed bridge was disarmed moments after bootstrap armed it. The exemption existed as four copies of a single channel condition across three scan predicates and the quarantine loop, which is exactly how the bridge ended up exempt in none of them; it now has one owner covering all four sites. The two remaining X-only paths there migrate a legacy mode-0755 v1 shim and correctly stay X-only, since the bridge shim has no v1 legacy. - bin/fm-arm-command-policy.mjs blessed only config/x-mode.env, so the arm seatbelt denied the exact command the supervision renderer emits for a bridged home. The allowed set is now a list both channels appear in, and sourcing anything outside it is still denied. - bin/fm-claude-stop-autoarm.sh sourced only config/x-mode.env, so a bridge-only home armed at the 300s default instead of 30s on the default primary harness. Each fix has a regression that was verified to fail with only that fix reverted. The verification record previously claimed the compatibility axes were inspected when only two of them were; it now records what was actually checked, names the three surfaces that were missed, and lists the test pinning each.
…ta reads, rotation
… verification docs
Two independent adversarial reviews of this branch each reached FIX BEFORE MERGE. Every counterexample they reproduced is fixed and committed as a negative regression. Outbound: bin/fm-tg-reply.sh took `--text-file <path>` and read any readable file, so the one capability in the fleet that reaches a person outside it was a generic path-to-Telegram primitive guarded only by prose. The body is now read from stdin and staged as a private artifact, and every send requires an authenticated, still-open request bound to the pinned peer and project. Landing: neither bin/fm-pr-merge.sh nor bin/fm-merge-local.sh looked at a publish record, so "a request never authorizes publishing" rested on the agent remembering a check nothing required. Both now refuse a Telegram-linked task without a live confirmation bound to the revision they really land, resolved from the forge head or the branch tip, and consume it atomically. arm-publish and confirm-publish resolve that revision from the task's own worktree instead of trusting an argument. Cadence: the arm seatbelt blessed config/*.env by path and never opened it, so a home-private file an injected agent could write executed through a legitimate arm. Nothing sources a cadence file any more; bin/fm-watch.sh derives the 30s cadence from the channel shim it already authenticates byte-for-byte, and the policy blesses no source node at all. This repairs the pre-existing X-mode path at the same owner. Also: begin --replace is one crash-safe identity transition instead of leaving the old peer authorized against an unredeemable offer; every cleanup path holds the same directory/device/link boundary publication enforces; only Telegram's HTTPS endpoint or an explicit loopback may carry the token; progress that cannot be persisted after a send is reported as ambiguous delivery rather than as safely resumable; the pairing guess budget is per numeric sender with an optional --user-id binding; and the per-poll prune is bounded work instead of growing until the watcher killed the check and the bridge died silently while reporting itself healthy.
…d body Self-review of the security repair found two defects in it. An empty reply exited 4, the same code the new authentication path uses for "that request was never accepted here", so a caller could not tell a composing mistake from a refused request. Nothing to send is now exit 10. Every path that gave up after staging the body - an unsplittable reply, an empty one, a failed outbox record - left that body sitting in bridge state until the retention window expired. All of them now drop it.
Both landing helpers detected a Telegram link by reading tg_request and treating "absent" and "present but empty" alike, so a half-written or hand-edited `tg_request=` line skipped the publish gate entirely instead of refusing. Linkage is now the presence of tg_request or tg_chat, and a present-but-empty link is linked-and-malformed.
`label` is a jq reserved keyword, so `jq --arg label ...` is a compile error on jq <= 1.6. All three uses sit in pipelines whose stderr is discarded, so on those versions the pairing offer, the pinned peer record and every inbox entry would fail to build with no output and no diagnostic - a bridge that looks armed and silently never works. The repository already had a guard for exactly this class (test_no_jq_reserved_keyword_arg_names), and CI caught it on the reviewed head; it was passing unnoticed on a newer local jq. Bind $peer_label instead.
The landing gate checked the pinned project but not the pinned identity, so it leaned on re-pairing cleanup having removed a previous person's authorization rather than saying so itself. An approval is a statement by one identity: the armed record now carries the approving numeric user and chat, and landing refuses when the bridge is paired with anyone else. Also drops the last cadence-sourcing assertion, in the watcher-lock guard repair line, which the full-suite run caught, and puts the remaining multi-sentence Markdown lines in these surfaces on their own lines.
…e and harden landing gate
…am-origin landings
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Close every merge-blocking security finding from the two independent adversarial reviews of PR 1214's private Telegram bridge, so the bridge's outbound reply, publish-landing, watcher-cadence, pairing-replacement, cleanup, API-origin and retry paths are bounded by code rather than by agent instructions, with each reported counterexample committed as a negative regression. The bridge stays inert until a BotFather token and a completed pairing exist.
What Changed
bin/:fm-tg-pair.sh,fm-tg-poll.sh,fm-tg-reply.sh,fm-tg-task.shand the sharedfm-tg-lib.sh. One pinned private peer is heard (numeric user id and chat id both matched, usernames never read), the watcher drives one bounded long poll per cycle, replies are staged and delivered to that single target with splitting and retry, and tasks can be linked back to the request that asked for them. With no BotFather token and no completed pairing the bridge is a hard no-op: poll exits 0 silently, reply refuses, and nostate/telegram/is written.bin/fm-pr-merge.shandbin/fm-merge-local.shnow refuse to land a task carrying a Telegram origin unless the pinned peer confirmed publishing in a fresh inbound message received after the preview, matched against the exact revision about to land and consumed once so an approval cannot be replayed; the origin key is immutable, andfm-tg-task.sh release --yes --reasonis the audited per-task recovery when the pairing is revoked. The same round bounds API origin resolution and token transport, the poll and publish attempt budgets, error dedup and recovery re-announces, pairing replacement and state cleanup, retry into an already-closed request, project routing per subcommand, and refuses no-mistakes gate agents on the paired channel..envreading out ofbin/fm-x-lib.shintofm-message-split-lib.sh,fm-private-artifact-lib.shandfm-env-file-lib.shshared by both channels, teaches the watcher cadence, arm command policy, PR-check migration andfm-test-run.shfamily selection about the bridge, and lands the coverage and docs:tests/fm-telegram-bridge.test.shwith a fake local Bot API server intests/telegram-helpers.sh(82 checks green),tests/fm-pr-merge.test.sh,docs/verification/telegram-bridge.md, the configuration/architecture/AGENTS references and thetelegram-respondskill. Every counterexample the two adversarial reviews reported is committed as a negative regression.Risk Assessment
✅ Low: The round-4 change is a three-line reorder that resolves the last open finding exactly as recommended, it demonstrably weakens nothing for unreleased tasks, and it lands with a focused pull-request regression that asserts both the success and the absence of the specific refusal, leaving no outstanding findings across four review rounds.
Testing
Ran the targeted telegram-bridge suite (82 checks) plus the changed landing-gate and cross-channel suites (fm-pr-merge 20, fm-x-mode 102, and the arm-pretool-check, supervision-instructions, turnend-guard and watcher-lock scripts) - all pass. Because passing tests alone do not show the intent, I also drove the real bridge entrypoints through a 13-scene operator CLI session against the hermetic fake Bot API and captured the full transcript: the bridge is silent and writes nothing until a token and a completed pairing both exist, and every path the intent names now refuses in code with a specific message and exit status - unauthenticated or cross-project replies, --retry into a closed exchange, hostile API origins, landing a Telegram-origin task with no live confirmation, self-written or stale or replayed publish confirmations, an unaudited release, a gate agent reaching the channel, a retired peer's leftover authorizations, cleanup through a symlinked bridge directory, and a tampered shim earning the fast watcher cadence. To confirm the committed counterexamples are real regressions rather than descriptions, I reverted two of the newest fixes one at a time and watched their tests fail with the expected messages, then restored the worktree to a clean state. This is a shell/CLI product with no rendered UI surface, so the reviewer-visible evidence is the command transcript rather than a screenshot. No findings.
Evidence: Operator CLI session - full 13-scene end-to-end transcript of the bridge
Evidence: Key refusals excerpt (inert default, reply/retry bounding, API origin, confirmation authenticity, audited release)
$ bin/fm-tg-poll.sh # no token, no pairing exit=0 $ echo "hallo" | bin/fm-tg-reply.sh tg-1 fm-tg-reply: no paired peer; nothing was sent exit=3 $ echo "gestohlener text" | bin/fm-tg-reply.sh tg-999999 fm-tg-reply: tg-999999 is not a message this home accepted from the paired peer; refusing exit=4 # messages delivered by that attempt: 0 $ bin/fm-tg-reply.sh --retry tg-3100 # exchange closed by a final reply fm-tg-reply: request tg-3100 was already closed by a final reply; refusing exit=7 # messages delivered by the refused retry: 0 FM_TELEGRAM_API_URL -> endpoint that carries the bot token http://attacker.example -> https://api.telegram.org https://evil.test:8443/x -> https://api.telegram.org https://api.telegram.org.evil.test -> https://api.telegram.org http://[email protected] -> https://api.telegram.org http://127.0.0.1:8081 -> http://127.0.0.1:8081 https://api.telegram.org -> https://api.telegram.org $ bin/fm-merge-local.sh site # task carries only tg_origin=tg-200 REFUSED: task site came from the Telegram bridge but no peer is paired, so nobody can confirm publishing it; if the pairing is gone for good, the captain can release this one task with bin/fm-tg-task.sh release site --yes --reason "<why>" exit=6 # fm/site merged into main? no $ bin/fm-tg-task.sh confirm-publish site --message-file self-written.txt fm-tg-task: --message-file was removed: a confirmation must be carried by a real message from the paired person, so confirm-publish reads it from that message's own stored record with --request <request-id> exit=2 # consumed_at on the publish record: null $ bin/fm-tg-task.sh confirm-publish site --request tg-403 # arrived before the preview fm-tg-task: message tg-403 arrived before the preview for site was armed, so it cannot confirm it exit=9 $ bin/fm-tg-task.sh release site fm-tg-task: release needs --yes: it lets one task of Telegram origin land without the paired person's confirmation exit=2 $ bin/fm-tg-task.sh release site --yes --reason "pairing revoked; eren unreachable" released site exit=0 $ bin/fm-merge-local.sh site NOTE: task site came from the Telegram bridge and is landing under an explicit local release, not a publish confirmation: pairing revoked; eren unreachable merged fm/site into local main (691a4af -> 9d1dac8) exit=0/var/folders/9d/8w50jhgd79x63rgbq_5cyyvm0000gn/T/no-mistakes-evidence/01KYNH4K0ET35HFE1KP9EM70B6/bridge-operator-session.sh)Evidence: telegram-bridge family - 82 named behavior checks
/var/folders/9d/8w50jhgd79x63rgbq_5cyyvm0000gn/T/no-mistakes-evidence/01KYNH4K0ET35HFE1KP9EM70B6/changed-tests.log) - Evidence: fm-x-mode cross-channel suite - 102 checks after the library extraction (local file:/var/folders/9d/8w50jhgd79x63rgbq_5cyyvm0000gn/T/no-mistakes-evidence/01KYNH4K0ET35HFE1KP9EM70B6/x-mode.log)Evidence: Non-vacuity proof - release-before-revision fix reverted, its regression fails
$ git show 2cd02e3^:bin/fm-tg-lib.sh > bin/fm-tg-lib.sh # only this fix reverted $ bin/fm-test-run.sh tests/fm-pr-merge.test.sh not ok - a released task could not land through a pull request: expected exit 0, got 1 FM_TEST_SUMMARY total=1 failed=1 skipped_gate=0 duration_ms=10827 $ git checkout -- bin/fm-tg-lib.sh # restored, worktree cleanEvidence: Non-vacuity proof - retry authenticity check removed, its regression fails
Source: Non-vacuity proof - retry authenticity check removed, its regression fails (local file:
/var/folders/9d/8w50jhgd79x63rgbq_5cyyvm0000gn/T/no-mistakes-evidence/01KYNH4K0ET35HFE1KP9EM70B6/revert-proof-retry-authenticity.log)Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
🔧 **Review** - 9 issues found → auto-fixed (3) ✅
bin/fm-tg-task.sh:220- The publish confirmation is not bound to any inbound Telegram message.arm-publishprints the one-time code to stdout (bin/fm-tg-task.sh:209) andconfirm-publishreads the "reply" from an arbitrary caller-supplied path (--message-file <path>, or-for stdin), then scans it for that code. Nothing checks that the code ever arrived in a message from the pinned peer, and nothing requires any elapsed time between arming and confirming, so the two-step gate rests on the skill's prose rule "never arm and confirm in the same turn" rather than on code. This is the same class the change already closed on the outbound side by removing--text-file; the bridge already holds the real inbound message instate/telegram/inbox/<rid>.jsonandcontext/<rid>.json, so the earliest supported shared boundary is to haveconfirm-publishread the confirming text from a request record for the pinned peer received afterarmed_at, instead of from a path.bin/fm-tg-lib.sh:1062- The landing gate's only linkage evidence is the mutable task meta:fmtg_meta_is_linkeddecides by the presence oftg_request=/tg_chat=, and bothbin/fm-tg-reply.sh --final(viafinalize, bin/fm-tg-reply.sh:194) andbin/fm-tg-task.sh unlink(bin/fm-tg-task.sh:183) strip exactly those keys with no authorization check. Once cleared,fm-pr-merge.shandfm-merge-local.shskip the whole publish gate, so the skill's claim "you cannot bypass it by forgetting" (.agents/skills/telegram-respond/SKILL.md:113) does not hold for an out-of-order terminal reply. Suggested minimal repair at the shared boundary: havefmtg_landing_guardalso treat an armed publish record for the task (state/telegram/publish/<task>.json) as linkage, so a task that entered the two-step flow still refuses after its meta link is cleared.bin/fm-tg-task.sh:204-prepared_revisionrefuses withexit 6when the task has no recorded worktree, the worktree is gone, orgit rev-parseyields nothing, but it is invoked asHEAD_REV=$(prepared_revision)and the script runs underset -uwithout-e.exitinside a command substitution terminates only the subshell, so botharm-publish(line 204) andconfirm-publish(line 215) print the refusal to stderr and then keep going with an emptyHEAD_REV. Fix by returning non-zero and checking the caller's status (or capturing$?after the assignment).bin/fm-tg-lib.sh:508-FM_TELEGRAM_PAIR_SENDERSis documented as an operator knob twice (docs/configuration.md:429 and :601) but no code ever reads it.fmtg_load_configdoes not resolve it, and the cap is read from the internalFMTG_PAIR_SENDERSdefault instead, so setting the documented variable silently does nothing. Either resolveFM_TELEGRAM_PAIR_SENDERSinfmtg_load_configalongside the otherFM_TELEGRAM_*knobs, or drop it from the configuration reference.bin/fm-tg-lib.sh:201- The upper clamp forFM_TELEGRAM_PUBLISH_ATTEMPTSresets an out-of-range value to the default 5 rather than clamping to 20 ([ "$raw" -le 20 ] 2>/dev/null || raw=5), unlike every sibling clamp in the file and unlike the documented contract "clamped to 1-20" (docs/configuration.md:602).FM_TELEGRAM_PUBLISH_ATTEMPTS=50yields 5, not 20. Behaviour stays safe (it fails toward the tighter budget), but it contradicts the documented range.bin/fm-tg-reply.sh:199- The--retrypath validates only that the preserved outbox record'schat_idstill equals the pinned peer; it never callsfmtg_request_authentic, so the "already closed by a final reply" refusal (exit 7) that the header and docs promise for every send does not apply to a retry. A reply that failed mid-send leaves its outbox record in place (it is only removed on success), so after a later--finalreply closes that request,--retry <request_id>still delivers the remaining chunks of the abandoned earlier reply. Adding the same authenticity/open check to the retry branch does not break the legitimate case, because a final reply only closes the request after all its chunks land.bin/fm-tg-task.sh:182- The file header states "Every subcommand here compares the task's own recorded project against that pinned name and refuses on any mismatch (exit 6)", but onlylink,arm-publishandconfirm-publishcallrequire_project_match.unlink(line 182),show(line 187) andclear-publish(line 268) do not, soshowprints another project's task link, linked chat and publish-record state, andclear-publishdeletes another project's armed confirmation. Either addrequire_project_matchto those three or narrow the header claim.bin/fm-tg-lib.sh:1135- Thelanding-target-mismatchbranch (exit 10) infmtg_landing_guardis unreachable:landed_atandlanding_targetare written together in the same jq consume at line 1143, and thealready-landedcheck at line 1119 returns 7 before the target comparison is ever evaluated. The documented exit code 10 therefore cannot be observed in practice. Harmless as defense in depth, but the exit-code list above the function advertises a refusal reason that never fires.docs/configuration.md:521- The Telegram section adds two separate### Repliesheadings (lines 496 and 521) with overlapping content: the first covers staging, request authentication and durable progress, the second covers single-target delivery, escaping, splitting, retry and dry run. Duplicate headings collide on the#repliesanchor and split what the section elsewhere claims is a single-owner contract. Merging them into one heading would keep the reply contract in one place.🔧 Fix: bind publish confirmation to a fresh peer message and harden landing gate
2 issues (1 warning, 1 info) still open:
bin/fm-tg-lib.sh:1163- Makingtg_originimmutable (the review-2 fix) means revoking the pairing permanently blocks every task that ever came from the bridge, with no supported recovery. Concrete sequence: (1) the paired person asks for a change, the agent runsfm-tg-task.sh link <task> <rid>, writingtg_origin=into the task meta; (2) the captain later runsbin/fm-tg-pair.sh revoke --yes(or the full opt-out, includingrm -rf state/telegram/); (3) the finished task is landed withbin/fm-merge-local.sh <task>orbin/fm-pr-merge.sh.fmtg_landing_gate_appliesstill returns true on the survivingtg_origin=, andfmtg_landing_guardthen fails atpeer=$(fmtg_peer_get ...) || { printf 'no-paired-peer'; return 6; }, so the merge refuses with "task <id> is linked to a Telegram request but no peer is paired" (exit 6). Nothing clears the origin:fm-tg-pair.shnever touchesstate/*.meta,unlinkdeliberately preservestg_originand now also requires a pinned peer viarequire_project_match, andclear-publishrequires one too. Re-pairing does not help either, becausefmtg_peer_records_clearalready wiped the publish records. This also makes docs/configuration.md:422 ("the home returns to exactly its pre-Telegram behavior") false for any previously linked task. The narrow repair that keeps the immutability you asked for is to let the gate stand down only when it provably has nothing to enforce - no pinned peer, no configured token, and no publish record for that task - rather than to add another path that erases the origin.AGENTS.md:92- The always-loaded task-meta key list still reads "fm-tg-task.sh link appends tg_request=, tg_chat=, and tg_request_ts= for a Telegram-bridged task (section 15)". It omitstg_origin=, which this round introduced as the one key the landing gate actually reads and the one key no clearing path removes. An agent working from AGENTS.md alone would not know it exists, that--final/unlinkdeliberately keep it, or that removing it from a meta by hand switches the publish gate off. docs/configuration.md and the telegram-respond skill were both updated for it; this surface was not.🔧 Fix: add audited per-task release for stranded Telegram-origin landings
1 warning still open:
bin/fm-tg-lib.sh:1222- The explicit release does not reach the pull-request landing path, so the stranding this round fixed is only half fixed.fmtg_landing_guardevaluates[ -n "$rev" ] || { printf 'unresolved-revision'; return 1; }at line 1222, before the release short-circuit at lines 1229-1233. Concrete sequence: (1) a task carriestg_origin=, the pairing is revoked, and the captain runsbin/fm-tg-task.sh release <task> --yes --reason "..."; (2) the task is landed withbin/fm-pr-merge.sh <task> <pr-url>; (3)TG_HEAD=$(fmtg_meta_get "$META" pr_head)is empty becausebin/fm-pr-check.shrecordspr_headonly when the forge CLI can supply it (see its own comment at line 65 and theNEEDS_GH_AUTHbootstrap diagnostic for the unauthenticated-gh state); (4) the guard returnsunresolved-revision(exit 1) and the merge refuses with "the exact revision that task <id> would land could not be resolved, so the confirmation cannot be checked" - a message about verifying a confirmation that, for a released task, does not exist, and with no next step left since the task is already released and a second release is refused. The local path is unaffected becausebin/fm-merge-local.shalready hard-fails at line 45 ifrefs/heads/fm/<id>is missing, which is whytest_a_revoked_pairing_does_not_strand_a_linked_taskpasses - it only exercisesrun_local_merge. The release branch never reads$rev(it returns before every revision comparison), so moving the short-circuit above the[ -n "$rev" ]check restores the stated design without weakening anything: a task with no release still refuses on an unresolvable revision exactly as today.🔧 Fix: evaluate task release before landing revision requirements
✅ Re-checked - no issues remain.
✅ **Test** - passed
✅ No issues found.
bin/fm-test-run.sh --family telegram-bridge- 82 checks, exit 0bin/fm-test-run.sh tests/fm-pr-merge.test.sh tests/fm-arm-pretool-check.test.sh tests/fm-supervision-instructions.test.sh tests/fm-turnend-guard.test.sh tests/fm-watcher-lock.test.sh- exit 0bin/fm-test-run.sh tests/fm-x-mode.test.sh- 102 checks, exit 0 (cross-channel coexistence after the library extraction)Manual 13-scene operator session drivingbin/fm-tg-pair.sh,bin/fm-tg-poll.sh,bin/fm-tg-reply.sh,bin/fm-tg-task.sh,bin/fm-merge-local.shandbin/fm-watch.shagainst the fake Bot API fromtests/telegram-helpers.sh, capturing every command, its output and its exit statusInert-by-default check:bin/fm-tg-poll.shandbin/fm-tg-reply.shin a home with no token - exit 0 / exit 3, emptystate/, zero Bot API callsOutbound bounding:bin/fm-tg-reply.sh tg-999999(exit 4),--taskin an unpaired project (exit 6),--retryinto a closed request (exit 7), each with a delivered-message delta of 0API origin resolution table viafmtg_load_configacross plaintext-remote, arbitrary-HTTPS, lookalike-host, userinfo and loopback originsLanding gate:bin/fm-merge-local.sh siteon a task carrying onlytg_origin- refused (exit 6),fm/sitenot merged into mainConfirmation authenticity:confirm-publish --message-file(exit 2),--request tg-nonexistent(exit 9), no-code message (exit 5), textless message (exit 9), pre-preview message (exit 9), real fresh message (exit 0), replay (exit 7)Release path: missing--yes(exit 2), missing--reason(exit 2), accepted once (exit 0), second attempt refused (exit 6), thenbin/fm-merge-local.sh sitelands under the recorded releaseGate-agent refusal:NO_MISTAKES_GATE=1against reply, task release and pair revoke - exit 3 each, withFM_GATE_REFUSE_BYPASSunsetPairing replacement with--replace --user-id, cleanup through a symlinkedstate/telegram, and watcher cadence probe across valid/tampered/world-writable shim statesRevert proof:git show 2cd02e3^:bin/fm-tg-lib.sh > bin/fm-tg-lib.shthenbin/fm-test-run.sh tests/fm-pr-merge.test.sh- fails witha released task could not land through a pull requestRevert proof: removedrequire_open_request "$RETRY"frombin/fm-tg-reply.shthenbin/fm-test-run.sh tests/fm-telegram-bridge.test.sh- fails witha retry delivered into an exchange a final reply had already closedgit status --porcelain --untracked-files=allafter both reverts - cleanbin/fm-test-run.sh:684- The bridge's own regression selection is incomplete, and only the code can close it. tests/fm-telegram-bridge.test.sh pins test_migration_does_not_quarantine_the_bridge_shim and test_arm_seatbelt_blesses_no_source_node, but bin/fm-test-run.sh's changed-file map routes bin/fm-pr-check-migrate.sh to pr-forge alone (it matches the earlierbin/fm-pr-*arm at line 684) and bin/fm-arm-command-policy.mjs to pure-contract-unit alone (it falls through to families_for_test_reference, and no test references that filename). Editing either file therefore never re-runs the suite that proves it does not disarm the bridge. I could only correct the documentation claim (docs/verification/telegram-bridge.md now says these two need a manual re-run); addingprintf '%s\n' telegram-bridgeto those two mappings is an executable change outside this phase.🔧 Fix: select the bridge suite for both bridge-disarming sources
1 info still open:
bin/fm-test-run.sh:684- Resolving the user-approved fix for tg-family-selection-gap required changes outside this phase's normal documentation-only boundary: bin/fm-test-run.sh (two new changed-path arms) and tests/fm-test-run.test.sh (test_changed_selection_pins_bridge_disarming_sources plus one fixture script). I made them only because the finding came back under user_chose_to_fix with instructions naming exactly those edits. Reported so the outer executor knows the test and lint phases should re-validate non-documentation files from this round; no action is needed on the change itself, which is verified green (19 checks in tests/fm-test-run.test.sh, 82 in --family telegram-bridge, --check-coverage ok, lint exit 0, and the new test proven to fail with only the map fix reverted).✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.