revert: withdraw multi-account Telegram until a bot is governable (#2203) - #2476
Conversation
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsAll references to removed symbols are clean, orchestrator sets all attributes the rewritten gateway reads, No findings. [OPUS-REVIEWED] f7e5e65 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Design Review (Fable 5) — ✅ PASSAdvisory design-level review of Design-Verdict: PASS A deliberate, reversible withdrawal: runtime removed, shipped config preserved round-trip, shadowing semantics kept so no stopped bot restarts on upgrade. Suggestions
[DESIGN-REVIEWED] f7e5e65 |
e7dbea9 to
af4ae88
Compare
|
Dispositions for review of GPT 5.6 — BLOCKING — The finding is correct and the mechanism is exactly as described. The fix follows the suggested shape — preserve parsing and serialization, disable the runtime:
Guarded by Opus 4.8 — no findings: noted, nothing to disposition. |
|
Disposition for Design Review (Fable 5) — 🟡 CONCERNS on Watch — "the reverted surface is not pre-release; RC users lose their channel silently": FIXED. Confirmed independently:
Suggestion 2 (reconcile One-way-door check: withdrawal is deliberately not irreversible. Retaining the config keys is what keeps it reversible — a future re-land can reactivate an operator's existing accounts rather than asking them to re-enter tokens. No UX Review concern was raised (that check reported PASS); nothing to disposition there. |
af4ae88 to
facd234
Compare
|
Disposition for review of GPT 5.6 — BLOCKING — Verified against the pre-withdrawal code before changing anything, and the finding holds. So the reachable consequence is worse than "an old token is still present": on upgrade, a bot the operator had deliberately stopped when they migrated starts polling again, under whatever allow-list the top-level fields still carry — which may be wider than the per-account list that replaced it. That is precisely the class of silently-widened inbound surface this PR exists to reduce, so it would have been self-defeating to ship. Fixed as suggested — Telegram stays disabled while self._telegram_enabled = bool(
cfg.telegram.enabled and self._telegram_bot_token and not cfg.telegram.accounts
)This is strictly narrowing and takes no working bot away: a config with a non-empty account map was already not serving its top-level token, so the set of served bots for every existing config is unchanged (named accounts stop, which is the point of the PR; the top-level token stays shadowed, which is the status quo). Re-enabling is an explicit edit, not an upgrade side effect. The warning was rewritten to match, since the old wording implied the top-level token would take over. It now states that the channel stays OFF while Tests: Now 11 tests, 4 mutations all killed: dropping the Opus 4.8 — no findings and Design Review — PASS at |
facd234 to
e37cb57
Compare
|
Follow-up to the It said Leaving it would have been worse than ordinary dead code. No behaviour change from |
0cfbe35 to
06647d2
Compare
|
Carrying the It is main's breakage, not this PR's: #2424 (04:49:37Z) added the #2478 stays open because it unblocks every other PR in the repo and can land independently. Whichever merges first makes the other's hunk a no-op — it is pure formatter output, so the two cannot diverge. |
06647d2 to
f7e5e65
Compare
|
The victim's assertion was over-broad: it scopes Scoped, not weakened: mutation-verified by injecting a real The leak itself is untouched here and is the real bug — a leaked drain task can inject that ERROR into any later test in the same xdist worker, so which test gets blamed depends on how the duration split happens to shard. #2485 has the diagnosis and the fix direction ( Gates after the change: 1085 passed / 5 skipped on the affected areas, isort + flake8 + mypy clean. |
What is the problem?
Multi-account Telegram support (#2203) shipped an inbound-trust surface without a governance layer. A named account under
telegram.accountscarries its own bot token and sender allow-list, and nothing else:telegram.accounts.<name>means that bot is live. The only kill switch is the globaltelegram.enabled, which takes down every account at once.telegram, so every account shares one ceiling. An operator cannot run one bot read-only and another permissive.allowed_user_idssits inconfig.json, which the agent can rewrite — so the thing that decides who may talk to a bot is not agent-immutable.telegram.<account>:…(telegram/gateway.py,channel_name), whilesel._infer_sourcematches only thetelegram:andtelegram_prefixes. Every named-account turn therefore falls through to the trailingreturn "slack"and is recorded against the wrong surface./link, and forum topics are force-disabled for them, so the feature is partial even on its own terms.Why this issue matters to the user
Adding a second bot opens a second globally-reachable inbound door — a Telegram bot is addressable by
@usernamefrom anywhere — and the operator gets no way to close that one door, no way to give it a narrower posture than the first, and audit records that name the wrong surface when they go looking. For the enterprise case this is the inverse of the feature's purpose: it widens the reachable attack surface while making the widening harder to observe. Reviewer feedback on #2203 asked for exactly this governance control before the capability ships.How our fix solves it
Withdraw the multi-account runtime, and keep the config surface it shipped as a deprecated, inert passthrough.
Chaining from symptom to root cause: the symptom is that a named bot cannot be disabled, cannot carry its own posture, and reports as
slack. The cause is that the account map was added as transport-level plumbing —telegram.accounts.<name>→TelegramAccountConfig, consumed directly by the startup loop — so an account never becomes a first-class entity anywhere above the transport: it has no identity in the governanceScopedMap, no enrollment step distinct from "a token exists", and no entry in_infer_source's namespace list. The root cause is that multi-account landed as a transport loop rather than as a new governed unit, so every layer above the transport still assumes exactly one Telegram connection exists.That assumption cannot be patched away incrementally without first deciding what the governed unit is. Withdrawing the runtime restores the single-account invariant the rest of the stack already agrees on (governance scope, SEL attribution, dashboard mirroring,
/link) and leaves the capability free to return on top of a connection-level governance model rather than underneath one.The config keys, however, are not unshipped — they went out in
v0.2.0-rc.6–rc.8, so an operator'sconfig.jsonmay already hold tokens and allow-lists under them. A plain revert would delete the dataclass, and sinceto_dict()rewrites the wholetelegramsection fromasdict(self.telegram), the nextcfg.save()would erase those tokens and theagents.<a>.telegram_accountbindings with no way to recover them. So the withdrawal is split:Runtime — removed:
_resolve_agent_for_account(telegram/gateway.py)channel_namethreading throughTelegramDispatcher, restoring the single"telegram"session-key channel (telegram/transport_dispatch.py)TelegramConfig.resolved_accounts(), the shim that made an account map stand in for the top-level fieldsConfig — retained, marked
deprecated=True, read by nothing:telegram.accountsandTelegramAccountConfig, still parsed by_parse_telegram_accountsand still serialized byto_dict(), so an existing config round-trips intactagents.<a>.telegram_account, same reasoningA shadowed token must stay shadowed.
resolved_accounts()returned the account map directly when it was non-empty, so a config with named accounts served only those accounts — the top-levelbot_tokenandallowed_user_idswere shadowed, andmaybe_start_telegramextended that to the credential path by gating theTELEGRAM_BOT_TOKENenv override onnot has_explicit_accounts. Removing the runtime without accounting for that would let the top-level token take over on upgrade: a bot the operator stopped when they migrated starts polling again, under an allow-list that may be wider than the per-account list which replaced it. So the enabled predicate keeps Telegram off while an account map is present:This is strictly narrowing and takes no working bot away — for every existing config the set of served bots either shrinks (named accounts stop, which is the point) or is unchanged (a shadowed top-level token stays shadowed, which is the status quo). Re-enabling is an explicit edit rather than an upgrade side effect.
Withdrawal is announced, not silent. The orchestrator logs a WARNING naming every configured account, stating that the channel stays OFF while
telegram.accountsis set and why (the entries already shadowed the top-level token, so falling back to it would start a bot you had stopped), and naming the remediation: remove the accounts block and put the one token you want served intelegram.bot_token.CHANGELOG.mdis reconciled in the same commit: the 0.2.0 line no longer advertises multiple bot accounts, and says what happens to anaccountsentry written by a release candidate.What tests we did
New
test/test_telegram_accounts_deprecated.py(11 tests) locks in the three properties the withdrawal has to preserve:accountsround-trips throughto_dict()with every field intact (token, allow-list, forum ids, threshold),save()writes them back to disk, andagents.<a>.telegram_accountround-trips. This is the regression guard for the erase-on-save defect.cfg.bot_token); and the channel does serve once the accounts block is removed.accountsis empty.Verification:
not cfg.telegram.accountsgate → 2 tests red; warning suppressed → 2 red;accountsfiltered out of serialization → 2 red;telegram_accountfiltered out → 1 red. Suite restored green after each.-k "telegram or config_loader or slack_gateway or config_valid"): 839 passed, 13 skipped.isort --check-only,flake8,mypy src/kiro_crew/(860 files) all clean. Frontend untouched, sotsc/vitestare outside this diff's blast radius.accounts— the only references left are the config dataclass, its parser, the enabled-predicate gate, and the deprecation warning.Manual verification: N/A — the behavior is a config round-trip, a boolean predicate, and a log line, all covered by unit tests at the exact chokepoints (
to_dict/saveand the orchestrator constructor).One file outside the withdrawal:
dashboard/token_auth.pyBackend Lint & Type Checkwas failing on this branch for a reason that has nothing to do with the withdrawal, and the same 7-line reorder is carried here so this PR can go green on its own.mainis red: intoken_auth.pythekiro_crew.dashboard.tailnetimport block sits abovekiro_crew.dashboard.revocation_gen, which is out of alphabetical order. Neither contributing PR could have caught it — #2424 merged at 04:49:37Z adding thetailnetblock, and #2388 merged six minutes later insertingrevocation_genat a position that was correct against its base, wheretailnetdid not yet exist. Each branch was isort-clean alone; only the merged result is unsorted, and because the two insertions do not overlap, git reported no conflict. Every open PR in the repository inherits the failure through its merge ref.Reproduced on a clean
maincheckout at584bbb05fbefore concluding it was inherited. The hunk here is the exact output of the pinnedisort==6.0.0— 7 lines moved, no import added, removed, or renamed,# noqa: F401 # re-exportsand its explanatory comment untouched.The same fix is also open standalone as #2478, since it unblocks every other PR and can land independently. Whichever merges first makes the other's hunk a no-op — this is a pure formatter reorder, so the two cannot diverge.
Any other suggestions on the work
The capability is worth having — it needs a governed unit first. The smaller re-land shape I would propose keeps the (now deprecated) account map as the connection identity and adds the four things that were missing:
enabledfield, so each bot has its own kill switch;ScopedMapmember keyed by connection id, so posture is per-bot rather than per-transport;telegram.<name>:form in_infer_source's namespace list, so audit attribution is honest;None of that requires changing session-key syntax or introducing a new abstraction layer, and the retained config keys mean a re-land can reactivate an operator's existing accounts rather than asking them to re-enter tokens. I will file it as a follow-up issue so the re-land has a spec to land against.