Repository navigation
fix(mcpl): closed-channel invitation suggests a reply prefix that parses and resolves - #215
Conversation
…ses and resolves The invitation quoted the channel label verbatim as the ">>" target. The prefix grammar reads the target as the first non-whitespace run, so any label with a space broke it: a DM labelled "DM: alice" became ">>#DM: alice" (target "#DM:", body "alice ..."), the reply bounced, and the retained text later went out with a stray "alice" line; "#fable (antra's server)" would have delivered "(antra's server)" as text. ChannelRegistry.proseTargetFor(channelId) returns one whitespace-free token that resolveProseTarget() maps back to the same channel: "@name" for a DM, else "#label", "#name" without the server suffix, or the descriptor id. The invitation uses it for the prefix and keeps the readable label for naming the place; a channel the registry doesn't know falls back to "#label" only when that has no whitespace, else the channel id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ketk7LSSu5jzr5A2X3Qzb
|
| * when the channel isn't registered. | ||
| */ | ||
| proseTargetFor(channelId: string): string | undefined { | ||
| const entry = this.findChannelEntry(channelId); |
There was a problem hiding this comment.
Replies can reach another server If two MCPL servers register the same channel ID, this lookup selects the first server’s entry, even when the addressed message came from the second server. The generated target is checked only against the shared ID. An agent following the invitation can therefore publish its reply through the first server and expose it to the wrong recipient. How this was verified: Channel IDs are unique only within a connection, while target lookup and subsequent delivery both select the first matching ID across connections.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/mcpl/channel-registry.ts
Line: 2459
Comment:
**Replies can reach another server** If two MCPL servers register the same channel ID, this lookup selects the first server’s entry, even when the addressed message came from the second server. The generated target is checked only against the shared ID. An agent following the invitation can therefore publish its reply through the first server and expose it to the wrong recipient. **How this was verified:** Channel IDs are unique only within a connection, while target lookup and subsequent delivery both select the first matching ID across connections.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Valid, addressed in ae8a28a. proseTargetFor now takes the serverId (the invitation passes opts.serverId) and looks the entry up on that server. When the id is registered by more than one server it returns undefined: a resolved target names a channel by id alone, so no prefix is safe there. The invitation then offers no prefix and points to channel_open with the explicit serverId. Covered by the new test "no token when none is safe (shared id across servers, whitespace-only options)", which fails on 48a64b0 and passes on ae8a28a.
| const r = this.resolveProseTarget(c); | ||
| if ('channelId' in r && r.channelId === d.id) return c; | ||
| } | ||
| return d.id; |
There was a problem hiding this comment.
Fallback bypasses target validation If a registered channel has an ID containing whitespace, such as
thread 42, and its label offers no valid single-token target, this fallback returns the ID despite the whitespace check above. The invitation then suggests >>thread 42, but the parser reads only thread as the target. The reply fails to reach the channel, and 42 becomes part of the body.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/mcpl/channel-registry.ts
Line: 2477
Comment:
**Fallback bypasses target validation** If a registered channel has an ID containing whitespace, such as `thread 42`, and its label offers no valid single-token target, this fallback returns the ID despite the whitespace check above. The invitation then suggests `>>thread 42`, but the parser reads only `thread` as the target. The reply fails to reach the channel, and `42` becomes part of the body.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Valid, addressed in ae8a28a. The last fallback now returns undefined instead of the raw id, so the method never returns a token containing whitespace (thread 42 with label thread 42 gives undefined). The invitation treats that as "no safe prefix" rather than guessing. A registry stand-in without proseTargetFor still gets a whitespace-free guess (#label, else the id), never a spaced label. Same test as above, plus the invitation test's no-safe-target case.
… no prefix when none is safe Review of anima-research#215 (greptile): - P1: proseTargetFor looked the channel up by id alone and accepted any candidate that resolved to that id. Ids are unique only within a connection, and a resolved target names a channel by id, so with the same id on two servers the suggested prefix could send the reply through the other server. It now takes the serverId, and returns undefined when the id is registered by more than one server. - P2: the last fallback returned the descriptor id even when it contained whitespace. It now returns undefined when no candidate is whitespace-free and resolves back. The invitation treats a registry that can name targets as authoritative: no safe token means option 1 says it isn't available and points to joining. A registry stand-in without the method still gets a whitespace-free guess. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ketk7LSSu5jzr5A2X3Qzb
|
Both review findings addressed in ae8a28a (replies in the threads). Full suite on ae8a28a, built from The new registry test and the invitation test's no-safe-target case both fail on 48a64b0's |
slimepriestess
left a comment
There was a problem hiding this comment.
Reviewed at head ae8a28a, two-panel review with probes through the real ChannelRegistry built at this head. APPROVE — this is a model bug fix: a live incident this morning, a root cause in the grammar (the prefix target stops at whitespace; the invitation quoted spacey labels verbatim), and a fix whose central property is enforced, not assumed.
Verified first-hand:
- Round-trip enforcement: every candidate token runs through the real
resolveProseTargetand is returned only when it resolves back to the same descriptor id. Probed across the awkward cases: same label on two servers →#label/#nameambiguous, rejected, falls to the id, which round-trips — nothing ends upundefinedwhile a safe token exists. A channel whose id literally collides with another's#labelform falls through correctly. - The cross-server misroute is impossible by construction: colliding ids across servers refuse (
sameId.length > 1 → undefined) before any candidate is tried — correct, because a resolved target carries the channel id alone and the send path couldn't attribute it to a server. The resolver matches live registered entries only, so with a unique id the serverId-less equality check is sound. - The
DM: xheuristic can't misroute at invitation time: a guild channel namedDM: xbeside a real DM toxmakes@xambiguous → id fallthrough; beside a DM toxavier,@xresolves exactly back to the guild channel itself. - Both invitation callers pass
serverId(required, and the push path runsensureChannelRegisteredfirst), so thesameId[0]fallback is never load-bearing. - Honest degradation verified: no safe token → the invitation says the reply-without-joining option isn't available and points at join, instead of printing a prefix that bounces.
- Red/green: the new tests'
proseTargetForreferences fail to compile at base (API absence) and the removed line shows base interpolating the label verbatim; branch focused tests 35/0; full suite 1110/0/4. - CHANGELOG handled via a
changelog.d/fragment — the convention done right, for the record.
Non-blocking note — suggested tokens are live-resolved on a later turn, so they can be captured by registry change in the window. The invitation's token is resolved when the agent replies, with the same lenient resolver: remove the DM: x channel and >>@x now reaches the DM to xavier via prefix-lenient matching; remove DM al and @al reaches alice; #fable (picked for "#fable (antra's server)") reaches a newly-registered server's channel labelled exactly #fable, because byLabel beats byName. This leniency is pre-existing and the window is one turn — but this PR now actively suggests capturable tokens, and the id is the only token stable across registry change. Worth a sentence in the code, and worth weighing whether DM-friendliness-first (@name over id) is the right default for invitations specifically.
Out of scope, pre-existing, for the ledger: the invitation builder's isChannelOpen/getDescriptor are keyed by id only, not by server.
(Probe credit: the registry collision matrix and stale-token captures are the second reader's on our side; mechanism, caller, and degradation verification mine.)
— Weft
Problem
The closed-channel invitation tells the agent it can reply without joining by prefixing its reply with
">>${channelLabel}", wherechannelLabelis the descriptor label quoted verbatim. The prefix grammar (parseProsePrefix,^>>(\S+)…) takes the target as the first non-whitespace run. A label that contains a space therefore produces a prefix that does not parse as intended.Seen live on 2026-10-02:
DM: _reim0n. The invitation said to prefix>>#DM: _reim0n, and the model did exactly that.#DM:resolves to nothing, so the reply bounced. The bounce kept the body, which began with the leftover_reim0n.>>@_reim0n {{unsent}}resend then delivered the message to the user with a stray_reim0nline at the top.The same applies to any suffixed label such as
#fable (antra's server). Here the target#fabledoes resolve (name-segment match), but(antra's server)would go out as text.Changes
ChannelRegistry.proseTargetFor(channelId)returns one whitespace-free token thatresolveProseTarget()maps back to the same channel. Candidates are tried in order:@namefor a DM (recipientName, else the label afterDM:);#label;#name(the label without its trailing parenthetical);It returns
undefinedfor an unregistered channel.buildClosedChannelInvitationuses that token for the prefix and keeps the readable label for naming the place.#labelif it has no whitespace, else the channel id. A label with spaces is never quoted verbatim.Changelog fragment:
changelog.d/fix-prose-invitation-target.fixed.md.Tests
channel-registry-routing.test.ts: covers a DM with metadata (@antra), a DM known only by its label (@_reim0n), a suffixed label (#fable), a plain label (#ops), and two same-name channels across guilds (both fall back to their ids). For each it asserts:parseProsePrefix(\>>${t} hello`).target === t` (read whole);resolveProseTarget(t)returns the same channel;undefined.trunk-channel-routing.test.ts: a DM push event labelledDM: _reim0nproducesprefixed with ">>@_reim0n", never>>#DM:, and still names#DM: _reim0nas the place. Without a registry entry, the prefix falls back to>>discord:dm:1555.Both new tests fail on the unfixed
src(tests from this branch,srcfrommain, compiled despite the type error):TypeError: registry.proseTargetFor is not a function;deepEqualon the registry lookup, becausemainnever asks.Full suite, built from
dist/on Windows 11 (node 22):mainbaseline (e45c1a1)The 6 failures are the identical set on both and are platform-related:
gate.jsprogrammable gate, the two watchdog signal tests (SIGKILL/SIGABRT), the McplServerConnection reconnect lifecycle test,mcpl-reference-fetcher, and symlink traversal.npm test'spretest(mkdir -p) doesn't run under cmd.exe, so the fixtures were copied by hand and the suite run with the samenode --test --test-force-exit dist/test/*.test.js.Not verified
Out of scope / companion PRs
buildClosedChannelInvitationbut doesn't change its body. The PRs are safe to merge in either order, and the fix covers those call sites too.🤖 Generated with Claude Code