Skip to content

feat(bin): add inert private Telegram bridge with enforced publish gate - #1214

Closed
yelenplays wants to merge 23 commits into
kunchenguid:mainfrom
yelenplays:fm/eren-telegram-bridge
Closed

feat(bin): add inert private Telegram bridge with enforced publish gate#1214
yelenplays wants to merge 23 commits into
kunchenguid:mainfrom
yelenplays:fm/eren-telegram-bridge

Conversation

@yelenplays

@yelenplays yelenplays commented Jul 28, 2026

Copy link
Copy Markdown

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

  • Adds the private Telegram bridge under bin/: fm-tg-pair.sh, fm-tg-poll.sh, fm-tg-reply.sh, fm-tg-task.sh and the shared fm-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 no state/telegram/ is written.
  • Moves the merge-blocking guarantees from agent instructions into code. bin/fm-pr-merge.sh and bin/fm-merge-local.sh now 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, and fm-tg-task.sh release --yes --reason is 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.
  • Extracts message splitting, private artifact writes and .env reading out of bin/fm-x-lib.sh into fm-message-split-lib.sh, fm-private-artifact-lib.sh and fm-env-file-lib.sh shared by both channels, teaches the watcher cadence, arm command policy, PR-check migration and fm-test-run.sh family selection about the bridge, and lands the coverage and docs: tests/fm-telegram-bridge.test.sh with a fake local Bot API server in tests/telegram-helpers.sh (82 checks green), tests/fm-pr-merge.test.sh, docs/verification/telegram-bridge.md, the configuration/architecture/AGENTS references and the telegram-respond skill. 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


==============================================================
SCENE 1 - no BotFather token: the bridge is completely inert
==============================================================

# There is no .env token and no pairing. This is a fresh firstmate home.

$ ls -A /var/folders/9d/8w50jhgd79x63rgbq_5cyyvm0000gn/T//fm-tg-operator-session.ZG3V9a/inert/home
config
data
state
exit=0

$ bin/fm-tg-pair.sh status
token: absent (bridge inert)
paired: no
offer: none
pending messages: 0
last error: none
exit=0

$ bin/fm-tg-poll.sh
exit=0

$ echo "hallo" | bin/fm-tg-reply.sh tg-1
fm-tg-reply: no paired peer; nothing was sent
exit=3

# Nothing was written under state/, and the Bot API was never contacted.

$ find /var/folders/9d/8w50jhgd79x63rgbq_5cyyvm0000gn/T//fm-tg-operator-session.ZG3V9a/inert/home/state -mindepth 1
exit=0

$ wc -l < \$FAKE_TG_DIR/calls.log   # Bot API requests made
0


==============================================================
SCENE 2 - token present, still no pairing: outbound stays shut
==============================================================

# FM_TELEGRAM_BOT_TOKEN is now in .env, but nobody has paired yet.

$ bin/fm-tg-pair.sh status
token: present
paired: no
offer: none
pending messages: 0
last error: none
exit=0

$ echo "hallo" | bin/fm-tg-reply.sh tg-1
fm-tg-reply: no paired peer; nothing was sent
exit=3

# sendMessage calls made so far: 0


==============================================================
SCENE 3 - pairing completes, and only then does a message get through
==============================================================

$ bin/fm-tg-pair.sh begin --label eren --project eren-pov-site
Pairing open for "eren" on project "eren-pov-site".
Ask them to open a private chat with the bot and send exactly:

  /start <one-time-code>

Valid for 900 seconds, 5 attempts per sender, single use.
Anyone with the code can redeem it. Pass --user-id <numeric id> to bind the offer to one account.
A wrong or late code is answered with silence, so ask them to report back if nothing arrives.

# Eren sends '/start <one-time-code>' to the bot from their phone.

$ bin/fm-tg-poll.sh   # the watcher's per-check long poll
telegram-paired eren
exit=0

$ bin/fm-tg-pair.sh status
token: present
paired: yes
label: eren
project: eren-pov-site
user_id: 555001
chat_id: 555001
paired_at: 1785311360
offer: none
pending messages: 0
last error: none
exit=0

# Eren asks for something. The poll accepts it exactly once.

$ bin/fm-tg-poll.sh
telegram-message tg-200
exit=0


==============================================================
SCENE 4 - the outbound reply is bounded by an authenticated request
==============================================================

# There is no path argument any more; the body comes from stdin only.

$ bin/fm-tg-reply.sh --help | grep -c -- --text-file
0

# An invented request id names no message this home ever accepted.

$ 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

# The real request id works, and the body is staged under bridge state.

$ echo "Klar - ich baue sie um und melde mich." | bin/fm-tg-reply.sh tg-200
tg-200
exit=0

$ # what the paired phone actually received (every delivered message):
  to chat 555001: Connected. You can send your requests here.
  to chat 555001: Klar - ich baue sie um und melde mich.

$ ls state/telegram/reply/    # staged bodies; cleared once delivery ends
  (empty - the staged body is removed when the send completes)

# A reply for a task in another project is refused rather than redirected.

$ echo "hi" | bin/fm-tg-reply.sh --task fremd
fm-tg-reply: task fremd is in project "andere-app", but the bridge is paired for "eren-pov-site"; refusing
exit=6
# messages delivered by that attempt: 0


==============================================================
SCENE 5 - --retry is a send too, and needs the same open request
==============================================================

# Telegram fails halfway through a split reply, so progress is preserved.

$ bin/fm-tg-reply.sh tg-3100 < reply.txt      # Bot API returns 500 partway
fm-tg-reply: send failed after 1 of 3 messages; retry with --retry tg-3100
exit=5

$ ls state/telegram/outbox/
tg-3100.json

# The exchange is then closed, exactly as a --final reply closes it.

$ jq -r ".closed_at" state/telegram/context/tg-3100.json
1750000000

# The abandoned reply can no longer be finished into that closed exchange,

# even though its preserved progress is still on disk.

$ bin/fm-tg-reply.sh --retry tg-3100
fm-tg-reply: request tg-3100 was already closed by a final reply; refusing
exit=7
# messages delivered by the refused retry: 0


==============================================================
SCENE 6 - the bot token may only travel to Telegram or explicit loopback
==============================================================

# FM_TELEGRAM_API_URL is the documented override for a local Bot API server.

# Whatever it is set to, this is the endpoint the token is actually sent to:

$ for u in <origins>; do FM_TELEGRAM_API_URL=$u; show resolved endpoint; done

  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

# Only Telegram HTTPS and an explicit loopback address survive; every
# hostile origin (plaintext remote, arbitrary HTTPS, lookalike host,
# userinfo-carrying) falls back to Telegram instead of being honored.


==============================================================
SCENE 7 - a Telegram-requested change cannot land without an approval
==============================================================

# The task carries only the immutable Telegram origin marker: the exchange

# was already ended with a final reply and unlinked, which is exactly what

# used to switch this gate off.

$ grep tg_ /var/folders/9d/8w50jhgd79x63rgbq_5cyyvm0000gn/T//fm-tg-operator-session.ZG3V9a/landing/state/site.meta
tg_origin=tg-200

$ bin/fm-merge-local.sh site
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
●  WATCHER DOWN - SUPERVISION IS OFF
●  1 task(s) in flight, but no watcher has a fresh beacon (last beat: never, grace 300s).
●  Trust the emitted supervision protocol for this harness; do not use shell & for watcher repair.
●  This is a supervision warning only; the guarded operation WILL still run.
●  repair missing watcher supervision with bin/fm-watch-arm.sh as its own Claude Code background task, never shell &.
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
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>"
This task answers Telegram request tg-200. Preview the change to the paired person and have them confirm publishing it before merging.
exit=6
# fm/site merged into main? no


==============================================================
SCENE 8 - the approval must be carried by a fresh message from that person
==============================================================

# The agent arms a preview. arm-publish prints the one-time code to include

# in the message Eren is shown.

$ bin/fm-tg-task.sh arm-p

... [996 bytes truncated] ...

 confirm-publish site --request tg-nonexistent
fm-tg-task: tg-nonexistent is not a message this home accepted from the paired person; refusing
exit=9

# tg-400 carries no code at all, so it approves nothing.

$ bin/fm-tg-task.sh confirm-publish site --request tg-400
fm-tg-task: message tg-400 carries no matching confirmation code
exit=5

# A photo with no text cannot carry an approval either.

$ bin/fm-tg-task.sh confirm-publish site --request tg-401
fm-tg-task: message tg-401 carries no text (kind=unsupported), so it cannot carry a confirmation
exit=9

# A message that DOES carry the code, but arrived before this preview was

# armed, cannot be an answer to it - the same-turn arm-and-confirm case.

$ bin/fm-tg-task.sh arm-publish site      # a newer preview is armed
<publish-code>
exit=0

$ bin/fm-tg-task.sh confirm-publish site --request tg-403
fm-tg-task: message tg-403 arrived before the preview for site was armed, so it cannot confirm it
exit=9
# consumed_at on the publish record: null

# Back to the ordinary path: arm, show Eren the preview, and let them reply.

$ # the message Eren actually sent, as this home recorded it:
  from chat 555001: sieht gut aus, <publish-code>

$ bin/fm-tg-task.sh confirm-publish site --request tg-404
confirmed
exit=0

# And that single approval cannot be spent twice.

$ bin/fm-tg-task.sh confirm-publish site --request tg-404
fm-tg-task: that confirmation was already used
exit=7


==============================================================
SCENE 9 - a revoked pairing must not strand work forever
==============================================================

# The pairing is gone (revoke --yes plus the documented opt-out), so nobody

# is left to approve anything. The landing refuses and names the way out.

$ bin/fm-merge-local.sh site
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
●  WATCHER DOWN - SUPERVISION IS OFF
●  1 task(s) in flight, but no watcher has a fresh beacon (last beat: never, grace 300s).
●  Trust the emitted supervision protocol for this harness; do not use shell & for watcher repair.
●  This is a supervision warning only; the guarded operation WILL still run.
●  repair missing watcher supervision with bin/fm-watch-arm.sh as its own Claude Code background task, never shell &.
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
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>"
This task answers Telegram request tg-200. Preview the change to the paired person and have them confirm publishing it before merging.
exit=6

# A release is not a routine step: it needs --yes and a written reason.

$ 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
fm-tg-task: release requires --reason <text> saying why the confirmation can no longer be obtained
exit=2

$ bin/fm-tg-task.sh release site --yes --reason "pairing revoked; eren unreachable"
released site
exit=0

# Nothing is erased - the origin and the reason both stay in the record.

$ grep tg_ /var/folders/9d/8w50jhgd79x63rgbq_5cyyvm0000gn/T//fm-tg-operator-session.ZG3V9a/release/state/site.meta
tg_origin=tg-200
tg_released_at=1785311367
tg_release_reason=pairing revoked; eren unreachable

$ bin/fm-tg-task.sh release site --yes --reason "nochmal"    # second try
fm-tg-task: task site was already released (pairing revoked; eren unreachable); a release is recorded once and never rewritten
exit=6

# The released task now lands.

$ bin/fm-merge-local.sh site
WARNING: watcher still down (same stale episode; last beat: never, grace 300s) - full banner already printed this episode.
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 (9592346 -> 10a4e61) in /var/folders/9d/8w50jhgd79x63rgbq_5cyyvm0000gn/T//fm-tg-operator-session.ZG3V9a/release/eren-pov-site
exit=0
# fm/site merged into main? yes


==============================================================
SCENE 10 - a no-mistakes gate agent cannot reach the channel at all
==============================================================

$ NO_MISTAKES_GATE=1 bin/fm-tg-reply.sh tg-200 < /dev/null
error: no-mistakes gate agent must not drive the fleet (NO_MISTAKES_GATE set)
exit=3

$ NO_MISTAKES_GATE=1 bin/fm-tg-task.sh release site --yes --reason "x"
error: no-mistakes gate agent must not drive the fleet (NO_MISTAKES_GATE set)
exit=3

$ NO_MISTAKES_GATE=1 bin/fm-tg-pair.sh revoke --yes
error: no-mistakes gate agent must not drive the fleet (NO_MISTAKES_GATE set)
exit=3


==============================================================
SCENE 11 - re-pairing retires the old person in one transition
==============================================================

# Eren is paired and has an armed publish authorization outstanding.

$ ls state/telegram/publish/
site.json

# Re-pairing to somebody else is refused unless it is deliberate.

$ bin/fm-tg-pair.sh begin --label sibling --project eren-pov-site
fm-tg-pair: a peer is already paired; pass --replace to deliberately replace it
exit=2

$ bin/fm-tg-pair.sh begin --label sibling --project eren-pov-site --replace --user-id 999002
Pairing open for "sibling" ... /start <new-one-time-code>
exit=0

# The retired person's armed publish authorization is gone with them.

$ ls state/telegram/publish/
  (empty)

# The offer is bound to one numeric account, so the retired person sending

# the very same code is ignored without spending anything.

$ bin/fm-tg-poll.sh   # the OLD account tries the new code
exit=0

$ bin/fm-tg-pair.sh status
token: present
paired: no
offer: open for "sibling" on "eren-pov-site", 900s left, 0 attempts used of 5
pending messages: 0
last error: none
exit=0

# Only the bound account can redeem it.

$ bin/fm-tg-poll.sh   # the new person redeems it
telegram-paired sibling
exit=0

$ bin/fm-tg-pair.sh status
token: present
paired: yes
label: sibling
project: eren-pov-site
user_id: 999002
chat_id: 999002
paired_at: 1785311368
offer: none
pending messages: 0
last error: none
exit=0


==============================================================
SCENE 12 - cleanup never deletes through a symlinked bridge directory
==============================================================

$ ls -l state/telegram
  state/telegram -> /var/folders/9d/8w50jhgd79x63rgbq_5cyyvm0000gn/T//fm-tg-operator-session.ZG3V9a/symlink/victim

$ bin/fm-tg-pair.sh revoke --yes
fm-tg-pair: /var/folders/9d/8w50jhgd79x63rgbq_5cyyvm0000gn/T//fm-tg-operator-session.ZG3V9a/symlink/home/state/telegram is not a private bridge directory (symlink, wrong mode, or wrong device); refusing to delete through it
exit=2

$ ls /var/folders/9d/8w50jhgd79x63rgbq_5cyyvm0000gn/T//fm-tg-operator-session.ZG3V9a/symlink/victim   # nothing outside bridge state was touched
  pairing.json
  peer.json


==============================================================
SCENE 13 - the watcher cadence comes from the authenticated shim, not a sourced file
==============================================================

$ # watcher check interval, in seconds, for each state of the poll shim

  no bridge armed                                300
  valid shim, mode 700                           30
  one byte appended to the shim                  300
  valid bytes, but world-writable                300

$ grep -cE "^[[:space:]]*(\.|source)[[:space:]].*telegram" bin/fm-watch.sh
0

# A tampered or world-writable shim falls back to the ordinary 300s
# cadence, and no cadence file is sourced anywhere.


--- end of session ---
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&#10;https://evil.test:8443/x -> https://api.telegram.org&#10;https://api.telegram.org.evil.test -> https://api.telegram.org&#10;http://[email protected] -> https://api.telegram.org&#10;http://127.0.0.1:8081 -> http://127.0.0.1:8081&#10;https://api.telegram.org -> https://api.telegram.org&#10;&#10;$ 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



==============================================================
SCENE 1 - no BotFather token: the bridge is completely inert
==============================================================

# There is no .env token and no pairing. This is a fresh firstmate home.

$ ls -A /var/folders/9d/8w50jhgd79x63rgbq_5cyyvm0000gn/T//fm-tg-operator-session.ZG3V9a/inert/home
config
$ bin/fm-tg-poll.sh
exit=0

$ echo "hallo" | bin/fm-tg-reply.sh tg-1
fm-tg-reply: no paired peer; nothing was sent
exit=3

# An invented request id names no message this home ever accepted.

$ 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

# The abandoned reply can no longer be finished into that closed exchange,

# even though its preserved progress is still on disk.

$ bin/fm-tg-reply.sh --retry tg-3100
fm-tg-reply: request tg-3100 was already closed by a final reply; refusing
exit=7

$ for u in <origins>; do FM_TELEGRAM_API_URL=$u; show resolved endpoint; done

  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

# Only Telegram HTTPS and an explicit loopback address survive; every
# hostile origin (plaintext remote, arbitrary HTTPS, lookalike host,
# userinfo-carrying) falls back to Telegram instead of being honored.

# THE REPRODUCER: the agent writes the code it was just handed into a file

# of its own and points confirm-publish at it. There is no path argument left.

$ 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

# An invented request id is inert too.

$ bin/fm-tg-task.sh confirm-publish site --request tg-nonexistent
fm-tg-task: tg-nonexistent is not a message this home accepted from the paired person; refusing
exit=9

# tg-400 carries no code at all, so it approves nothing.

$ bin/fm-tg-task.sh confirm-publish site --request tg-400
fm-tg-task: message tg-400 carries no matching confirmation code
exit=5
exit=6

# A release is not a routine step: it needs --yes and a written reason.

$ 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
fm-tg-task: release requires --reason <text> saying why the confirmation can no longer be obtained
exit=2

$ bin/fm-tg-task.sh release site --yes --reason "pairing revoked; eren unreachable"
released site
- Evidence: Driver used to produce the operator transcript (reproducible) (local file: /var/folders/9d/8w50jhgd79x63rgbq_5cyyvm0000gn/T/no-mistakes-evidence/01KYNH4K0ET35HFE1KP9EM70B6/bridge-operator-session.sh)
Evidence: telegram-bridge family - 82 named behavior checks
FM_TEST_BEGIN 2026-07-29T07:33:35Z tests/fm-telegram-bridge.test.sh family=telegram-bridge expected_gate_skip=none
ok - absent config is a zero-behavior no-op: nothing written, nothing printed, nothing contacted
ok - a malformed bot token is refused before any request, and never echoed
ok - the bot token stays out of argv, local state, and every status line
ok - bridge state is an owner-only directory of mode-0600 files
ok - a redeemed code pins the immutable numeric user and chat ids and consumes the offer
ok - only a salted digest of the pairing code is stored, never the code
ok - a wrong pairing code is answered with silence and consumes one bounded attempt
ok - the pairing attempt budget is per sender, so a stranger cannot exhaust it remotely
ok - a sender that spends its own attempt budget cannot pair even with the right code
ok - an offer bound to one numeric account refuses every other sender without spending anything
ok - an expired pairing code cannot pair and stays silent
ok - a redeemed pairing code cannot be replayed after revocation
ok - re-pairing over a live peer requires an explicit deliberate --replace
ok - revoke ends access, keeps the offset so nothing replays, and needs explicit confirmation
ok - a paired private text message is accepted exactly once
ok - a redelivered update is dropped: no second wake and no second inbox entry
ok - a crash before the seen claim redelivers once, without duplicating the entry
ok - a message the agent already drained is never recreated by a redelivery
ok - the update offset is confirmed only after the whole processed prefix is durable
ok - a pending message whose wake was lost is re-announced by the bounded recovery sweep
ok - a stuck inbox entry is re-announced a bounded number of times, reported once, and never dropped
ok - the long poll and its request deadline stay inside the watcher's per-check kill budget
ok - one watcher check spends one budget across every call it makes
ok - an orphaned publication temporary is swept, an in-flight one is left alone, and revoke takes both
ok - groups, supergroups, channels, bots, and unpaired users get no access and no reply
ok - attachments and oversized text are classified, never downloaded, and never carry a body
ok - message bodies stay inert data: no shell, no path, no wake payload, no control bytes
ok - a flood costs one wake per window, not one per poll cycle, and a new window reports again
ok - a killed poll strands temporaries but never one holding the bot token
ok - two homes sharing one bot token is reported as the misconfiguration it is
ok - task links and publish arming are refused outside the paired project
ok - show distinguishes an absent link from a linked value instead of printing a blank
ok - publishing needs one matching confirmation code, and it is single use
ok - a bare agreement without the code never authorizes publishing
ok - a confirmation is refused once the prepared change is no longer what was previewed
ok - expired confirmations and exhausted attempt budgets are both refused
ok - a publish confirmation must be carried by a fresh, authentic message from the paired person
ok - a message from an exchange a final reply already closed cannot confirm a publish
ok - a message with no text can never carry a publish confirmation
ok - an unresolvable prepared revision refuses instead of arming an empty one
ok - replies are delivered literally with no markup parser, always to the pinned chat
ok - a reply is refused rather than redirected when the pinned peer changed
ok - sendMessage never runs before pairing
ok - a long reply splits into numbered messages within the per-message budget
ok - a failed send is preserved with its progress and resumes without repeating messages
ok - a final reply ends the thread but keeps the evidence a linked task still needs
DRY RUN: 1 message(s) to the paired chat, recorded at /var/folders/9d/8w50jhgd79x63rgbq_5cyyvm0000gn/T//fm-telegram-bridge-tests.7mpTw7/dry/home/state/telegram/outbox/tg-2200.json
ok - dry run records the would-be reply and sends nothing
ok - bootstrap arms, re-arms idempotently, disarms on opt-out, and is silent once off
ok - a bridged home needs the supervision cycle even with no project work
ok - no harness protocol sources a cadence file, on any supported harness
ok - the watcher derives its cadence only from a shim whose bytes and mode still validate
ok - the arm seatbelt blesses no source node, so a writable cadence file cannot execute
ok - X mode and the Telegram bridge coexist in one home without displacing each other
ok - the check sweep rotates, so one always-on channel cannot starve another
ok - the PR-check migration exempts a valid bridge shim and still quarantines a tampered one
ok - the arm seatbelt accepts the rendered bridge arm, one or both channels, and nothing else
ok - the default harness's routine arm reaches the bridge cadence without sourcing anything
ok - a no-mistakes gate agent cannot message, unpair, authorize a publish, or release one
ok - one firstmate home can never read or consume another home's bridge state
ok - a reply needs a request this home really accepted, and takes no caller-supplied path
ok - a reply body is staged under bridge state and a final reply closes the request
ok - a reply for a task outside the paired project is refused
ok - delivery whose progress cannot be persisted is never reported as safely resumable
ok - cleanup refuses a symlinked bridge directory instead of deleting through it
ok - only Telegram's HTTPS endpoint or an explicit loopback origin may carry the bot token
ok - --replace retires the old peer and its records, and the new numeric identity can redeem
ok - re-pairing clears publish authorizations the retired person had given
ok - the per-poll prune is bounded and retires markers the confirmed offset already covers
ok - the local merge refuses a Telegram-linked task with no publish confirmation
ok - the local merge refuses unconfirmed and moved-revision publish authorizations
ok - the local merge lands exactly one confirmed change and refuses the replay
ok - a local-only task with no Telegram link merges exactly as it did before the bridge existed
ok - ending the Telegram conversation cannot turn the landing gate off
ok - an armed publish record gates the landing even with no link left in the task record
ok - a publish confirmation bound to one landing target cannot be spent on another
ok - a retry needs the same authenticated, still-open request every other send needs
ok - a task stranded by a revoked pairing can be released once, explicitly, and then lands
ok - a release needs --yes, one line of real reason, a Telegram-origin task, and happens once
ok - a release is refused whenever the ordinary confirmation path is still open
ok - an armed preview supersedes an earlier release and restores the ordinary rules
ok - FM_TELEGRAM_PAIR_SENDERS really caps how many senders one offer tracks
ok - an empty reply is its own outcome and leaves no staged body behind
FM_TEST_END 2026-07-29T07:34:51Z tests/fm-telegram-bridge.test.sh exit=0 duration_ms=75708 gate_skip=false
FM_TEST_SUMMARY total=1 failed=0 skipped_gate=0 duration_ms=75755
FM_TEST_SUMMARY_FAMILY family=telegram-bridge count=1 duration_ms=75708 failed=0
FM_TEST_SLOWEST rank=1 script=tests/fm-telegram-bridge.test.sh duration_ms=75708
- Evidence: Changed suites: fm-pr-merge, arm-pretool-check, supervision-instructions, turnend-guard, watcher-lock (local file: /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 clean

FM_TEST_BEGIN 2026-07-29T07:46:05Z tests/fm-pr-merge.test.sh family=pr-forge expected_gate_skip=none
ok - fm-pr-merge records pr= and pr_head= before invoking gh-axi pr merge
ok - fm-pr-merge propagates a real merge failure without silently succeeding
ok - fm-pr-merge forwards extra flags to gh-axi pr merge after the -- separator
ok - fm-pr-merge refuses before merging when task meta is missing
ok - fm-pr-merge refuses malformed PR URLs before calling gh-axi
ok - fm-pr-merge refuses unsafe PR URL segments before recording state
ok - fm-pr-merge refuses repo override args before recording state
ok - fm-pr-merge does not add default --squash when the caller passes an explicit merge method
ok - fm-pr-merge respects --method=<value> as an explicit merge method
ok - fm-pr-merge parses a GitHub PR URL into gh-axi number and --repo arguments
ok - a Telegram-linked task never merges when no publish confirmation was armed
ok - a Telegram-linked task never merges on an armed but unconfirmed publish record
ok - a Telegram-linked task never merges a revision the person did not approve
ok - a Telegram-linked task never merges on a confirmation given for another project
ok - a Telegram-linked task refuses to merge a revision it cannot verify
not ok - tg-released: a released task could not land through a pull request: expected exit 0, got 1
FM_TEST_END 2026-07-29T07:46:16Z tests/fm-pr-merge.test.sh exit=1 duration_ms=10781 gate_skip=false
FM_TEST_SUMMARY total=1 failed=1 skipped_gate=0 duration_ms=10827
FM_TEST_SUMMARY_FAMILY family=pr-forge count=1 duration_ms=10781 failed=1
FM_TEST_SLOWEST rank=1 script=tests/fm-pr-merge.test.sh duration_ms=10781
Evidence: 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)

$ # bin/fm-tg-reply.sh: require_open_request "$RETRY" replaced with a no-op
$ bin/fm-test-run.sh tests/fm-telegram-bridge.test.sh
not ok - a retry delivered into an exchange a final reply had already closed (missing: 'rc=7')
FM_TEST_SUMMARY total=1 failed=1 skipped_gate=0 duration_ms=73505
$ git checkout -- bin/fm-tg-reply.sh # restored, worktree clean

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-publish prints the one-time code to stdout (bin/fm-tg-task.sh:209) and confirm-publish reads the "reply" from an arbitrary caller-supplied path (--message-file &lt;path&gt;, 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 in state/telegram/inbox/&lt;rid&gt;.json and context/&lt;rid&gt;.json, so the earliest supported shared boundary is to have confirm-publish read the confirming text from a request record for the pinned peer received after armed_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_linked decides by the presence of tg_request=/tg_chat=, and both bin/fm-tg-reply.sh --final (via finalize, bin/fm-tg-reply.sh:194) and bin/fm-tg-task.sh unlink (bin/fm-tg-task.sh:183) strip exactly those keys with no authorization check. Once cleared, fm-pr-merge.sh and fm-merge-local.sh skip 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: have fmtg_landing_guard also treat an armed publish record for the task (state/telegram/publish/&lt;task&gt;.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_revision refuses with exit 6 when the task has no recorded worktree, the worktree is gone, or git rev-parse yields nothing, but it is invoked as HEAD_REV=$(prepared_revision) and the script runs under set -u without -e. exit inside a command substitution terminates only the subshell, so both arm-publish (line 204) and confirm-publish (line 215) print the refusal to stderr and then keep going with an empty HEAD_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_SENDERS is documented as an operator knob twice (docs/configuration.md:429 and :601) but no code ever reads it. fmtg_load_config does not resolve it, and the cap is read from the internal FMTG_PAIR_SENDERS default instead, so setting the documented variable silently does nothing. Either resolve FM_TELEGRAM_PAIR_SENDERS in fmtg_load_config alongside the other FM_TELEGRAM_* knobs, or drop it from the configuration reference.
  • ℹ️ bin/fm-tg-lib.sh:201 - The upper clamp for FM_TELEGRAM_PUBLISH_ATTEMPTS resets an out-of-range value to the default 5 rather than clamping to 20 ([ &#34;$raw&#34; -le 20 ] 2&gt;/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=50 yields 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 --retry path validates only that the preserved outbox record's chat_id still equals the pinned peer; it never calls fmtg_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 --final reply closes that request, --retry &lt;request_id&gt; 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 only link, arm-publish and confirm-publish call require_project_match. unlink (line 182), show (line 187) and clear-publish (line 268) do not, so show prints another project's task link, linked chat and publish-record state, and clear-publish deletes another project's armed confirmation. Either add require_project_match to those three or narrow the header claim.
  • ℹ️ bin/fm-tg-lib.sh:1135 - The landing-target-mismatch branch (exit 10) in fmtg_landing_guard is unreachable: landed_at and landing_target are written together in the same jq consume at line 1143, and the already-landed check 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 ### Replies headings (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 #replies anchor 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 - Making tg_origin immutable (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 runs fm-tg-task.sh link &lt;task&gt; &lt;rid&gt;, writing tg_origin= into the task meta; (2) the captain later runs bin/fm-tg-pair.sh revoke --yes (or the full opt-out, including rm -rf state/telegram/); (3) the finished task is landed with bin/fm-merge-local.sh &lt;task&gt; or bin/fm-pr-merge.sh. fmtg_landing_gate_applies still returns true on the surviving tg_origin=, and fmtg_landing_guard then fails at peer=$(fmtg_peer_get ...) || { printf &#39;no-paired-peer&#39;; 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.sh never touches state/*.meta, unlink deliberately preserves tg_origin and now also requires a pinned peer via require_project_match, and clear-publish requires one too. Re-pairing does not help either, because fmtg_peer_records_clear already 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 omits tg_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/unlink deliberately 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_guard evaluates [ -n &#34;$rev&#34; ] || { printf &#39;unresolved-revision&#39;; return 1; } at line 1222, before the release short-circuit at lines 1229-1233. Concrete sequence: (1) a task carries tg_origin=, the pairing is revoked, and the captain runs bin/fm-tg-task.sh release &lt;task&gt; --yes --reason &#34;...&#34;; (2) the task is landed with bin/fm-pr-merge.sh &lt;task&gt; &lt;pr-url&gt;; (3) TG_HEAD=$(fmtg_meta_get &#34;$META&#34; pr_head) is empty because bin/fm-pr-check.sh records pr_head only when the forge CLI can supply it (see its own comment at line 65 and the NEEDS_GH_AUTH bootstrap diagnostic for the unauthenticated-gh state); (4) the guard returns unresolved-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 because bin/fm-merge-local.sh already hard-fails at line 45 if refs/heads/fm/&lt;id&gt; is missing, which is why test_a_revoked_pairing_does_not_strand_a_linked_task passes - it only exercises run_local_merge. The release branch never reads $rev (it returns before every revision comparison), so moving the short-circuit above the [ -n &#34;$rev&#34; ] 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 0
  • bin/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 0
  • bin/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 driving bin/fm-tg-pair.sh, bin/fm-tg-poll.sh, bin/fm-tg-reply.sh, bin/fm-tg-task.sh, bin/fm-merge-local.sh and bin/fm-watch.sh against the fake Bot API from tests/telegram-helpers.sh, capturing every command, its output and its exit status
  • Inert-by-default check: bin/fm-tg-poll.sh and bin/fm-tg-reply.sh in a home with no token - exit 0 / exit 3, empty state/, zero Bot API calls
  • Outbound bounding: bin/fm-tg-reply.sh tg-999999 (exit 4), --task in an unpaired project (exit 6), --retry into a closed request (exit 7), each with a delivered-message delta of 0
  • API origin resolution table via fmtg_load_config across plaintext-remote, arbitrary-HTTPS, lookalike-host, userinfo and loopback origins
  • Landing gate: bin/fm-merge-local.sh site on a task carrying only tg_origin - refused (exit 6), fm/site not merged into main
  • Confirmation 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), then bin/fm-merge-local.sh site lands under the recorded release
  • Gate-agent refusal: NO_MISTAKES_GATE=1 against reply, task release and pair revoke - exit 3 each, with FM_GATE_REFUSE_BYPASS unset
  • Pairing replacement with --replace --user-id, cleanup through a symlinked state/telegram, and watcher cadence probe across valid/tampered/world-writable shim states
  • Revert proof: git show 2cd02e3^:bin/fm-tg-lib.sh &gt; bin/fm-tg-lib.sh then bin/fm-test-run.sh tests/fm-pr-merge.test.sh - fails with a released task could not land through a pull request
  • Revert proof: removed require_open_request &#34;$RETRY&#34; from bin/fm-tg-reply.sh then bin/fm-test-run.sh tests/fm-telegram-bridge.test.sh - fails with a retry delivered into an exchange a final reply had already closed
  • git status --porcelain --untracked-files=all after both reverts - clean
⚠️ **Document** - 1 info
  • ⚠️ bin/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 earlier bin/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); adding printf &#39;%s\n&#39; telegram-bridge to 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.

yelenplays and others added 23 commits July 28, 2026 21:36
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.
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.
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.
@yelenplays yelenplays changed the title feat(bin): add an inert private Telegram bridge for one paired human feat(bin): add inert private Telegram bridge with enforced publish gate Jul 29, 2026
@yelenplays yelenplays closed this Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants