Skip to content

feat(chat): forward every message variant and add the Android host surface - #453

Merged
TarikGul merged 6 commits into
mainfrom
feat/chat-message-surface
Aug 19, 2026
Merged

feat(chat): forward every message variant and add the Android host surface#453
TarikGul merged 6 commits into
mainfrom
feat/chat-message-surface

Conversation

@decrypto21

@decrypto21 decrypto21 commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Summary

  • replace NativeChatCallbacks::post_text_message and post_custom_message with a single post_message(room_id, content), so a native host receives every ChatMessageContent variant a product can post and rejects what it cannot render, instead of the core rejecting it first
  • screen product-authored message content in the runtime layer, above ChatPlatform, so every host flavour inherits the same limits, JS hosts included since feat(platform): serve Chat from a JS host as an optional capability #400
  • bound and NFC-normalize ids, names and icons, and pass message bodies through byte for byte after screening, so a product that hashes or echo-compares what it sent reads back the same bytes
  • restrict any URL a host may fetch or open to https or an inline raster image, screened for smuggling characters before parsing, refused when it carries credentials, and measured against its budget after resolution
  • reject an action set whose ids normalize to one key, because a trigger naming that key cannot say which button was pressed
  • add the Android chat host surface: TrUAPIHostRuntime, TrUAPIProductExecution and ChatHostBridge, with buffered action publication, room-list republication, a Flow of typed UI for stored custom messages, and the session's X25519 chat identity key
  • add make android-check, which compiles the Kotlin adapter against freshly generated bindings
  • serve chat from truapi-host-cli through an in-memory chat host, behind --execution-kind spa|chat, so a chat product is runnable headlessly at all
  • add a --chat-host battery phase and make e2e-chat-cli, nine cases against a real signing host with a real session

The wire protocol is unchanged. No wire ids, payload fields or enum variants move, and the only edits under truapi/src/v01/ and truapi/src/api/chat.rs are doc comments.

What the core screens, and what stays host-owned

The id a host returns from post_message is the correlation key ActionTrigger.message_id carries back, which closes the loop from posting an action set, through a user triggering one, to the product reading the trigger.

Ids and names are bounded, NFC-normalized and rejected for control and bidirectional characters. Message bodies are bounded at 16 KiB and screened, but neither trimmed nor normalized, so line breaks and tabs survive. Reaction emoji keep the tag characters that encode subdivision flags. A file name is screened as a path component as well as a label, so a host joining it onto a cache directory is never handed a separator or a parent reference, and a rejected one says so: fileName must name a single file, not a path. Action and media lists cap at 32, custom payloads at 256 KiB, URLs at 2 KiB, icons at 64 KiB.

is_body_unsafe and is_emoji_unsafe are deliberate carve-outs from is_display_unsafe, and each test states what the carve-out must let through and why.

Both URL fields resolve through one helper, so a budget is measured against the string a host receives rather than the one that arrived: the parser percent-encodes, and a non-ASCII path triples in the process. Credentials are refused, because user:pass@ survives resolution into whatever the host fetches and logs.

Reachability is not screened, and the trait doc says so rather than leaving it implied: https://127.0.0.1, https://[::1], a private range and https://169.254.169.254 all pass, because which networks a host will fetch from depends on where that host runs, and a core that guessed would break a host serving its own media from localhost. A test pins those four as accepted so the behaviour cannot drift away from the sentence.

ChatFile.size_bytes is a product assertion and is not verified against the resource it names. Contextual output escaping and storage limits remain host-owned.

Live coverage

Chat requests answer Denied unless the connection opened as a Chat execution and a session is active, and the CLI host built every product as Spa and installed no ChatPlatform. Nothing in the repo could exercise a chat product end to end, which is why explorer/diagnosis-reports/spa/signing-host-cli.md carries zero Chat/ rows: the battery omits chat rather than failing it, the same shape that let Chat::register_bot sit unimplemented behind a report reading 5 success, 0 failed.

SigningHostRuntime::with_chat_platform (mirroring the pairing-host constructor), an in-memory chat host in the CLI, and a --chat-host battery phase close that: real product calls, over the wire, against a host holding a real session.

The phase exists for a distinction unit tests cannot make. "The product got an error" and "the host was never handed the content" have the same product-visible symptom, and only the second is safe: a host that receives hostile content and refuses it is a host that has to be trusted to screen it again. The CLI host records every effect it accepted to TRUAPI_CHAT_LOG, tagged room, bot or message, and each case reads that record before and after its call, passing only when the product read an error and the host's record did not grow.

Nine cases: create_room; a Text body with a newline and a tab that must reach the host byte for byte, compared against ChatMessageContent.enc of what the product sent so a difference cannot hide behind a rendering; then a javascript: file URL, a file name addressing a path, a URL carrying credentials, a URL and an icon that each arrive inside their budget and resolve past it, a body one byte past 16 KiB, and two action ids that NFC-normalize onto one key.

The phase writes explorer/diagnosis-reports/chat/signing-host-cli.md, committed next to the existing chat/ios.md, so the chat compatibility matrix gains a CLI column and a rerun shows its diff. spa stays the default, so the existing battery phases and the SPA reports they own are untouched.

Anyone can reproduce it: make e2e-chat-cli, documented in both READMEs beside e2e-signing-cli and e2e-pairing-cli.

Breaking

  • NativeChatCallbacks.post_text_message and post_custom_message are replaced by post_message(roomId:content:). FFI symbols, vtable layout and checksums change, so the next @parity/ios-host release is a breaking one.
  • TrUAPIHostRuntime.openProductExecution takes (bridge:configuration:chat:) on both platforms. This is a source break for iOS callers, including hosts that do not serve Chat.
  • TrUAPIProductExecutionProtocol requires sessionChatIdentityKey().
  • chat.postMessage rejects content it previously forwarded: text or file text over 16 KiB, URLs over 2 KiB or outside the https/inline-image allowlist, more than 32 actions or media items, custom payloads over 256 KiB, duplicate action ids, and control or bidirectional characters outside \n, \r and \t. A product sending any of these receives MessageTooLarge for an over-budget body or payload, and Unknown naming the field otherwise. There is no negotiation and no feature gate.
  • a URL carrying credentials is rejected on every chat field, and an icon is measured after resolution, so one whose resolved form exceeds 64 KiB is rejected where a 24 KB non-ASCII path previously passed at three times the budget.
  • chat.createRoom and chat.registerBot icons are screened for control and bidirectional characters and delivered as the URL parser resolved them, so a host comparing a stored icon against what a product sent sees https://x.invalid arrive as https://x.invalid/.

Validation

  • cargo test --workspace --all-features: 978 passed, 15 ignored
  • cargo clippy --workspace --all-targets --all-features -- -D warnings
  • cargo +nightly fmt --check
  • cargo check --target wasm32-unknown-unknown -p truapi-server
  • ./scripts/codegen.sh with no tracked-file drift; make uniffi reproduces the committed iOS bindings, and make provider-swift-check the provider's
  • @parity/truapi 225 tests, @parity/truapi-host 55 tests
  • make uniffi-kotlin reproduces the committed Kotlin bindings with no drift
  • make e2e-chat-cli: 9 cases against a live signing host, all passing
  • every commit compiles on its own with --all-features --all-targets

Twenty three tests carry the claims. Sixteen sit with the predicates in truapi-platform: each screened field is fed its own hostile value, a file name is fed both a bidi override that reverses its extension and a path that escapes its directory, a body is fed the text a person actually types, a reaction the emoji a person actually picks, a URL is fed a path that resolves past its budget on both fields and credentials on both, the four host-owned URLs are pinned as accepted, and the_published_limits_are_the_enforced_limits pins the exported constants against what the code enforces so the two cannot drift. Four sit at the boundary: the native adapter forwards every variant, surfaces a host rejection as a typed error, round-trips a posted action set back to the product that posted it, and the runtime screens content before it reaches a host at all. An added ChatMessageContent variant stops validate_chat_message_content compiling rather than passing through unscreened.

Three more cover the CLI chat host directly: a message into a room it never created is refused and leaves the transcript empty, a stored message is recorded with the payload bytes it arrived as, and a room created after a subscription starts reaches the list that subscription already holds.

make android-check compiles the Kotlin adapter, and was last run green on this surface. Gradle is not installed on the machine that produced the current revision, so it has not been re-run there; make uniffi-kotlin reproduces the committed bindings byte for byte and no Kotlin source has changed since, so the compile inputs are unchanged.

Notes

Downstream iOS will not compile until it conforms. Making the protocol members required is deliberate: a silent protocol-extension default is exactly what let register_bot sit unimplemented for five weeks behind a diagnosis report that read "5 success, 0 failed". polkadot-app-ios-v2 and the iOS host PR need sessionChatIdentityKey() and the new post_message shape.

make e2e-chat-cli is a local gate, not a CI one. It needs a host process and a network-backed session, which is why e2e-signing-cli is not a CI job either, and why the repo's one E2E workflow job is if: false. Wiring live e2e into CI is real work with its own tradeoffs and is not attempted here.

Base automatically changed from feat/host-callback-throw-safety to main August 19, 2026 13:38
@decrypto21
decrypto21 requested a review from a team August 19, 2026 13:38
@decrypto21
decrypto21 force-pushed the feat/chat-message-surface branch from 9ff78c9 to ec34ca4 Compare August 19, 2026 13:45
Comment thread rust/crates/truapi-platform/src/lib.rs Outdated
Comment thread rust/crates/truapi-platform/src/lib.rs Outdated
@decrypto21
decrypto21 force-pushed the feat/chat-message-surface branch from ec34ca4 to cf1fe40 Compare August 19, 2026 20:28
@decrypto21
decrypto21 requested a review from TarikGul August 19, 2026 20:58
@TarikGul
TarikGul added this pull request to the merge queue Aug 19, 2026
Merged via the queue into main with commit 2352dbc Aug 19, 2026
18 checks passed
@TarikGul
TarikGul deleted the feat/chat-message-surface branch August 19, 2026 23:18
johnthecat added a commit that referenced this pull request Aug 20, 2026
The main merge brought #453's chat work in against the renamed
ProductExecutionKind. Git had no conflict to report: the enum definition
came from this branch and the call sites came from #453, so App/Widget/Worker
landed next to five uses of the Spa and Chat variants that no longer exist.

Worker is the kind that serves Chat and App is the ordinary full-page
product, so the mapping is a rename at each site. The host CLI keeps its own
--execution spa|chat flag values, which name CLI roles rather than protocol
kinds, so the batteries that pass them are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y65XQWkbMjfxG43gKvVV1b
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