feat(feishu): answer an unreadable attachment instead of dropping it silently - #8228
feat(feishu): answer an unreadable attachment instead of dropping it silently#8228JiaDe-Wu wants to merge 1 commit into
Conversation
|
The one red check is One shard of eight, Twelve minutes of zero output at 99%, then cancellation, with orphaned workers. I do not think this is my diff, and here is the reasoning rather than the assertion:
Zero progress, empty log, orphaned python processes. And this diff has nothing that can block outside Python: no subprocess, no C call, no network, no sleep. The reply path awaits a What I checked rather than assumed:
Gates I can run all pass on Linux (parity with CI): If a maintainer can re-run that one shard, that would settle it faster than I can from outside. Flagging rather than force-pushing a no-op commit to trigger a fresh run, since a rebase would also reset the three AI-review lanes that have already reported. |
|
Reproduced CI's shard 4 on a Linux host, both with and without this diff. It does not hang, and the failure count is identical to the unpatched base. Same command both times — Not one of them is in a file this PR touches, and shard 4 finished in 5 minutes 30 seconds where CI sat silent for twelve and was then cancelled at forty. So the cancelled shard was not this diff. Combined with what I noted above — a Python-level hang would have been killed by I cannot trigger that myself ( For completeness, the rest of the gates on the same Linux host: |
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
|
Correcting one link in my own reasoning above, now that I have read the full job log rather than its tail. What I got right and what I got wrong. I wrote that "the twelve-minutes-of-silence-plus-orphaned-workers signature is the memory-pressure class So the mechanism is the single-replacement one: The part of my reasoning that holds is the timeout argument, and the log confirms its premise: the session header reports Nothing changes about the exoneration. Progress at the wedge was 99.8% of 21077 collected items, and the crashed item is the run's only I have filed the general problem rather than leaving it as a footnote on my own PR: evidence on #4227 (comment) and a fix in #8691 — The re-run request above is unchanged, and if #8691 lands first this shard's failure mode would at least come back named. |
…silently Send a photo to the Feishu bot and nothing happened. Not an error, not a hint -- silence. The sender could not tell a refused attachment from a dead WebSocket, a wrong App ID, a missing scope, or an allow-list rejection. `messaging/attachments.py` exists to prevent exactly this: "Silently dropping an attachment is the original defect this module exists to fix ... Callers are expected to surface IngestResult.rejections to the user." But a transport declaring `files_inbound=False` never reaches `ingest`, so it has no result to carry a reason and the mechanism never runs for it. kirodotdev#7551 added the operator's log line for this drop; the sender's half was still missing. Feishu now replies with `attachments.channel_reads_no_attachments()`, in the same bracketed shape every `ingest` rejection already uses, so a channel that later gains real ingestion changes which function builds the string rather than how it reads. Three gates decide WHERE the reply happens, and each is a security property: - after `authorize`, so an unauthorised sender still learns nothing -- telling a stranger the bot is alive is a disclosure this channel deliberately avoids; - after the group gate, so a bot merely sitting in an unlisted group does not announce itself to the room; - after the redelivery-dedup window, so lark's WS replaying a frame cannot answer the same photo twice. The parse step had to change to make that possible. `_handle_receive_v1` dropped a non-text message before extracting the sender, and the transport is the only layer that knows whether that sender is authorised. The type is now decided after `open_id` and the message is carried on with an empty `text` and `unsupported_type` set, so it can never be mistaken for an instruction and never drives a turn. The existing log line is unchanged. `files_inbound` moves ASPIRATIONAL -> ENFORCED in the ledger with its read site cited, per the migration contract that module documents. Scope: Feishu only. iMessage also declares `files_inbound=False`, but its `receive` checks text ahead of `is_own_echo`, whose position its own comment calls load-bearing in both directions -- reordering it "would restore the loop while looking fixed". That belongs in its own change. The platform-supplied `message_type` is echoed back, so it is stripped to a word and length-capped first: brackets and dashes are what a rejection line is made of, and leaving them in would let a crafted type forge a second segment.
b4030c1 to
0d0eff3
Compare
|
Rebased onto main One conflict:
Gates run locally on the changed files only: black, isort, flake8 clean; Please review the resolution. A maintainer push makes the maintainer the last pusher, so under this repo's last-push rule a second approver is needed. Reply if anything looks wrong. |
Design Review (Fable 5, fork) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound placement and reuse; the one gap is that the new reply's only failure signal is a raise nothing observes. WatchThe rejection reply's failure path is invisible by construction. Suggestions
[DESIGN-REVIEWED] 0d0eff3 |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed |
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of All claims verified against the base. The final review follows. First-Principles-Verdict: CONCERNS A shared helper generalized for a second channel this PR explicitly defers, and the operator log line the description calls "untouched" moved and now mislabels answered messages. Not justified as shipped
What this change shipsIntent: a person who sends a photo to the Feishu bot learns it can't be read instead of getting silence — a FIX (#7848; the base doc already listed the silence under "Known gaps").
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 0d0eff3 |
GPT 5.6 Review (fork) — 🔴 changes requested (blocking)Reviewed 2 of 2 blocking finding(s) are security-class and were withheld from adjudication, so the blocking verdict stands. BLOCKING -- src/kiro_crew/feishu/transport.py:281 -- unreadable-message reply bypasses channel governance BLOCKING -- src/kiro_crew/feishu/transport.py:283 -- failed reply remains marked as delivered [BLOCK-MERGE] 0d0eff3 Adjudication (Opus 4.8) — is blocking on each finding proportionate?Both findings are FENCED (annotate-only); the adjudicable block is empty. I verified the code paths. F1 — The dispatcher's per-message governance ceiling is F2 — 🏷️ Fenced finding(s) machine-flagged as likely edge caseThe security fence keeps these findings blocking regardless of adjudication; the only clearance path is a human override recorded by a repository writer, who must independently verify a rationale before recording it — it is machine-authored, and a wrong override on a security-class finding ships exactly the class the fence exists to stop. (This lane's comment deliberately carries no override command.)
|
Fixes #7848.
Problem / Motivation
Send a photo to the Feishu bot and nothing happens. Not an error, not a hint, not a reaction — silence.
feishu/client.pydropped any non-text message at the parse step. #7551 added a log line there, and its reasoning was right for the audience it chose:That sentence is just as true for the sender, and for them nothing changed. A line in
gateway.logresolves the ambiguity for whoever runs the gateway; the person who dropped a screenshot into the chat still cannot tell whether the bot is broken, whether their message arrived, or whether images are simply not a thing here. The rational next move is to send it again — or to send "did you get that?", which does get answered, making the first look like a bug.Why it matters
messaging/attachments.pyalready exists to prevent precisely this, and says so:But a transport declaring
files_inbound=Falsenever reachesingest, so it has noIngestResultto carry a reason and that mechanism never runs for it. The guarantee exists; two channels sit outside it.What changed (motivation → approach → change)
The reply reuses the existing mechanism's shape rather than inventing a parallel one.
attachments.channel_reads_no_attachments()builds the same single bracketed, em-dash-separated segment everyingestrejection already produces ([Attachment {name} — download failed]). It lives in that module because that module owns rejection wording, so a channel that later gains real ingestion changes which function builds the string, not how it reads.Three gates decide where the reply happens, and each one is a security property:
authorizeauthorizeis deny-by-default and owner-only.The parse step had to change to make that possible.
_handle_receive_v1dropped the message before extractingopen_id, and the transport is the only layer that knows whether a sender is authorised — so it cannot answer a message it was never given. The type is now decided after the sender, and an unreadable message is carried on withtext=""andunsupported_typeset. Empty text plus nounsupported_typestill stops inreceiveexactly as before, so a frame whose body resolved to nothing is unchanged. The existing log line is untouched.files_inboundmoves ASPIRATIONAL → ENFORCED intest_capability_ledger.py, with its read site cited inline, per the migration contract that module documents ("A field moving from ASPIRATIONAL to ENFORCED must move sets here in the same change"). A channel declaringTrueis untouched: its attachments go throughingestas before, and the test pins that by flipping the flag.The echoed
message_typeis treated as untrusted. It is platform-supplied and goes back to the sender, so it is stripped to[a-zA-Z0-9_]and length-capped: brackets and dashes are what a rejection line is made of, and leaving them in would let a crafted type forge a second bracketed segment inside the reply.Scope: Feishu only, deliberately
iMessage also declares
files_inbound=Falseand still drops silently. Itsreceivecheckstextahead ofis_own_echo, whose position carries this comment:Reordering a gate whose own comment warns it can silently restore a message loop belongs in its own change, with its own reasoning and its own tests. The doc note records that iMessage is the remaining reader.
Tests
24 new tests.
TestUnreadableInboundIsAnswered(10) pins the reply and, one test per gate, the three deny paths that must stay silent — unauthorised sender, empty allow-list, unlisted group, unknown chat type — plus answered-once on redelivery, never-drives-a-turn, an allow-listed group is answered, and afiles_inbound=Truetransport answering nothing.TestChannelReadsNoAttachments(6) pins the string's shape rather than its prose, so a reworded reason stays one segment. Two client tests replace the old "ignored" assertion with the new carry-through contract, including that a message with no sender is still dropped.Mutation-checked, so each test fails for the reason it claims:
authorize(leaks the bot to a stranger)files_inboundgateManual verification
N/A — unit coverage is sufficient and the alternative is not available to me: reaching the real drop path needs a Feishu app plus
lark-oapi, and every branch this change adds is reachable with theFakeClientthe suite already uses. The tests drivetransport.receiveandclient._handle_receive_v1directly, which is where all five decisions live.Gates run on Linux (parity with CI):
Related Issues
Fixes #7848.
On the one question that issue held for — where a backend-owned user-facing string lives — the answer turned out to be in the tree rather than a decision still owed, and it is the reason this is implementable:
messaging/commands.pyalready returns hardcoded English reply text to users ("No subagents running.","No cron jobs scheduled.","No task running.", and three more), the backend has no catalog, no.po, and nogettextanywhere, andwebsite/src/i18n/is frontend-only. So "backend-owned strings have no catalog path yet" is literal. This string is English for consistency with those six, and when a backend catalog arrives it migrates with them.That remains the reviewable choice here, and it is a product call as much as an engineering one: on Feishu the likely reader is Chinese-speaking, and so are the six precedents. If you would rather this class of string wait for a catalog, say so and I will drop the reply and keep only the carry-through plumbing.