Skip to content

fix(mcpl): closed-channel invitation suggests a reply prefix that parses and resolves - #215

Merged
antra-tess merged 2 commits into
anima-research:mainfrom
ajaniramon:fix/prose-invitation-target
Oct 3, 2026
Merged

antra-tess merged 2 commits into
anima-research:mainfrom
ajaniramon:fix/prose-invitation-target

Conversation

@ajaniramon

@ajaniramon ajaniramon commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Problem

The closed-channel invitation tells the agent it can reply without joining by prefixing its reply with ">>${channelLabel}", where channelLabel is 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:

  • A companion's first DM from its owner arrived on a closed DM channel labelled DM: _reim0n. The invitation said to prefix >>#DM: _reim0n, and the model did exactly that.
  • Target #DM: resolves to nothing, so the reply bounced. The bounce kept the body, which began with the leftover _reim0n.
  • The model's >>@_reim0n {{unsent}} resend then delivered the message to the user with a stray _reim0n line at the top.

The same applies to any suffixed label such as #fable (antra's server). Here the target #fable does resolve (name-segment match), but (antra's server) would go out as text.

Changes

  • ChannelRegistry.proseTargetFor(channelId) returns one whitespace-free token that resolveProseTarget() maps back to the same channel. Candidates are tried in order:

    1. @name for a DM (recipientName, else the label after DM: );
    2. #label;
    3. #name (the label without its trailing parenthetical);
    4. the descriptor id.

    It returns undefined for an unregistered channel.

  • buildClosedChannelInvitation uses that token for the prefix and keeps the readable label for naming the place.

    • The call is optional-chained, so a registry stand-in without the method still works.
    • When the registry doesn't know the channel, the fallback is #label if 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:

    • the exact token;
    • that parseProsePrefix(\>>${t} hello`).target === t` (read whole);
    • that resolveProseTarget(t) returns the same channel;
    • that an unregistered channel gives undefined.
  • trunk-channel-routing.test.ts: a DM push event labelled DM: _reim0n produces prefixed with ">>@_reim0n", never >>#DM:, and still names #DM: _reim0n as 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, src from main, compiled despite the type error):

    • TypeError: registry.proseTargetFor is not a function;
    • the invitation test's deepEqual on the registry lookup, because main never asks.
  • Full suite, built from dist/ on Windows 11 (node 22):

    tests pass fail skipped
    this branch 1114 1099 6 9
    main baseline (e45c1a1) 1112 1097 6 9

    The 6 failures are the identical set on both and are platform-related: gate.js programmable gate, the two watchdog signal tests (SIGKILL/SIGABRT), the McplServerConnection reconnect lifecycle test, mcpl-reference-fetcher, and symlink traversal.

  • npm test's pretest (mkdir -p) doesn't run under cmd.exe, so the fixtures were copied by hand and the suite run with the same node --test --test-force-exit dist/test/*.test.js.

Not verified

  • The patched invitation has not been exercised end to end against a live host and Discord. The live failure above came from connectome-host's pinned 0.17 build.
  • Thread labels (which can contain spaces) are covered only through the generic label path, not with a thread-shaped descriptor.

Out of scope / companion PRs

🤖 Generated with Claude Code

…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
@greptile-apps

greptile-apps Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 3/5 Tier: apex

[Medium risk] Fixes how closed-channel invitations suggest reply prefixes.

This PR is not safe to merge until invitations cannot direct replies across MCPL connections sharing a channel ID.

Findings

  1. P1 Security Replies can reach another server ▶
  2. P2 Fallback bypasses target validation ▶
Fix with agent prompt
### Issue 1
src/mcpl/channel-registry.ts:2459
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.

### Issue 2
src/mcpl/channel-registry.ts:2477
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.

Summary

The PR makes closed-channel invitations suggest a single-token prose reply target instead of copying labels that may contain spaces.

  • Adds registry-based target selection and invitation fallback behavior.
  • Adds routing and invitation tests plus a changelog fragment.

Reviews (1) · Last reviewed commit: "fix(mcpl): closed-channel invitation sug..."

Comment thread src/mcpl/channel-registry.ts Outdated
* when the channel isn't registered.
*/
proseTargetFor(channelId: string): string | undefined {
const entry = this.findChannelEntry(channelId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/mcpl/channel-registry.ts Outdated
const r = this.resolveProseTarget(c);
if ('channelId' in r && r.channelId === d.id) return c;
}
return d.id;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@ajaniramon

Copy link
Copy Markdown
Contributor Author

Both review findings addressed in ae8a28a (replies in the threads).

Full suite on ae8a28a, built from dist/ on Windows 11: 1115 tests, 1100 pass, 6 fail, 9 skipped. The 6 failures are the same platform-related set as on the main baseline (e45c1a1: 1112 / 1097 / 6 / 9). The three new tests account for the extra passes.

The new registry test and the invitation test's no-safe-target case both fail on 48a64b0's src and pass on ae8a28a. The changelog fragment is updated to describe the no-prefix case.

@slimepriestess slimepriestess left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 resolveProseTarget and is returned only when it resolves back to the same descriptor id. Probed across the awkward cases: same label on two servers → #label/#name ambiguous, rejected, falls to the id, which round-trips — nothing ends up undefined while a safe token exists. A channel whose id literally collides with another's #label form 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: x heuristic can't misroute at invitation time: a guild channel named DM: x beside a real DM to x makes @x ambiguous → id fallthrough; beside a DM to xavier, @x resolves exactly back to the guild channel itself.
  • Both invitation callers pass serverId (required, and the push path runs ensureChannelRegistered first), so the sameId[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' proseTargetFor references 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

@antra-tess
antra-tess merged commit 38e7179 into anima-research:main Oct 3, 2026
4 of 5 checks passed
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.

3 participants