diff --git a/CLAUDE.md b/CLAUDE.md index 3b4fc0c41..ba581c51f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,7 +71,8 @@ scripts/battery.sh run the generated battery against both headless CLI h bindings, and the `ios-swift` job compiles the package and its test target on pull requests touching `ios/`, `Package.swift`, the `Makefile` or `native*`, which is what catches a hand-written conformer that missed a new protocol - requirement. `TrUAPIHost.kt` and the embedding apps are compiled by neither. + requirement. `TrUAPIHost.kt` and the embedding apps are compiled by neither; + run `make android-check` after touching the Kotlin surface. Hosts implement `HostBridge`, whose protocol extension defaults the optional callbacks; `TrUAPIHostRuntime` and `TrUAPIHostCore` both accept one. To publish the binary, include `@parity/ios-host ` diff --git a/Makefile b/Makefile index cd8a8caed..80ed177f0 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ # Run `make help` for the list of targets. .DEFAULT_GOAL := help -.PHONY: help setup build codegen test check clean playground wasm wasm-crypto-test uniffi uniffi-kotlin ios-build ios-run ios-chat-run ios-chat-host-playground-run ios-chat-all android-jni android-publish-local dotli-link dev dev-bootstrap dev-link-check e2e-dotli e2e-signing-cli e2e-pairing-cli headless install matrix explorer xcframework +.PHONY: help setup build codegen test check clean playground wasm wasm-crypto-test uniffi uniffi-kotlin android-check ios-build ios-run ios-chat-run ios-chat-host-playground-run ios-chat-all android-jni android-publish-local dotli-link dev dev-bootstrap dev-link-check e2e-dotli e2e-signing-cli e2e-pairing-cli e2e-chat-cli headless install matrix explorer xcframework CARGO ?= cargo TRUAPI_PKG := js/packages/truapi @@ -206,6 +206,9 @@ android-jni: ## Cross-compile libtruapi_server.so for Android ABIs into jniLibs -o $(ANDROID_JNILIBS) \ build --release -p truapi-server --features ws-bridge +android-check: uniffi-kotlin ## Compile the Kotlin host adapter against freshly generated bindings (needs Gradle + Android SDK). + gradle :truapi-host:compileReleaseKotlin + android-publish-local: uniffi-kotlin ## Generate Kotlin bindings, then publish the AAR to ~/.m2 (needs Gradle + JDK 17). The AAR does not bundle the cdylib; consumers build it per ABI (see android-jni). gradle :truapi-host:publishReleasePublicationToMavenLocal @@ -344,6 +347,9 @@ e2e-signing-cli: ## Run the generated battery against the direct signing-host CL e2e-pairing-cli: ## Run the generated battery against the paired pairing-host CLI. scripts/battery.sh --pairing-host +e2e-chat-cli: ## Run the Chat content-screening battery against a chat signing-host CLI. + scripts/battery.sh --chat-host + matrix: ## Regenerate the host compatibility matrix from explorer/diagnosis-reports. cd $(EXPLORER) && npm run generate-matrix diff --git a/README.md b/README.md index 6209a7b91..06dd6ac51 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,8 @@ js/packages/ (embedded smoldot light client + remote WebSocket RPC) js/container/ TS lockdown container for the iOS host web view; bundles into ios/truapi-host/Sources/TrUAPIHost/Resources/truapi-container.js -android/truapi-host/ Kotlin host adapter package over the truapi-server UniFFI core +android/truapi-host/ Kotlin host adapter package over the truapi-server UniFFI core; + compiled by no CI job, so run `make android-check` after changing it android/truapi-provider/ truapi-provider-android: chain transport AAR (bindings + cdylib) ios/truapi-host/ Swift host adapter package over the truapi-server UniFFI core ios/truapi-provider/ TrUAPIProvider Swift package: chain transport over UniFFI @@ -168,6 +169,7 @@ scripts/battery.sh --signing-host # direct phase only scripts/battery.sh --pairing-host # paired phase only make e2e-signing-cli # same direct signing-host phase make e2e-pairing-cli # same paired pairing-host phase +make e2e-chat-cli # chat content screening against a chat signing-host ``` To run the playground locally: diff --git a/android/truapi-host/README.md b/android/truapi-host/README.md index 896d17277..8b50acc29 100644 --- a/android/truapi-host/README.md +++ b/android/truapi-host/README.md @@ -50,6 +50,80 @@ The public surface lives in [`src/main/kotlin/io/parity/truapi/TrUAPIHost.kt`](s - `HostCoreStorage` - core-owned read/write/clear interface for auth session, pairing identity, and persisted permission decisions (`key` is a SCALE-encoded `CoreStorageKey`). - `TrUAPIHostCore` - owning wrapper around the UniFFI-generated `NativeTrUApiCore`. Holds the bridge alive for the lifetime of the core and exposes the localhost WebSocket bridge, core-owned disconnect, local-session activation, permission-authorization status, and native change notifications for session storage, theme, and preimage updates. - `LocalhostBridgeBootstrap` - JS snippet that publishes the WS bridge endpoint (`window.__truapi_localhost`) to the product page so it can dial back in. +- `TrUAPIHostRuntime` - process-owned runtime whose product executions share one authentication session. Open a connection per executable with `openProductExecution`, which returns a `TrUAPIProductExecution` carrying that connection's own WS bridge, permission authorization, theme/preimage/chain notifications, and the Chat controls below. +- `ChatHostBridge` - native Chat storage and UI, implemented by hosts that serve the Chat modality and passed to `openProductExecution`. Hosts without it pass nothing and Chat calls answer unsupported. + +## Chat + +A host serving the Chat modality implements `ChatHostBridge` (`createRoom`, `registerBot`, `postMessage`, `listRooms`) and opens the execution with `ProductExecutionKind.CHAT`: + +```kotlin +import io.parity.truapi.* +import uniffi.truapi.ChatBotRegistrationStatus +import uniffi.truapi.ChatMessageContent +import uniffi.truapi.ChatRoom +import uniffi.truapi.ChatRoomParticipation +import uniffi.truapi.ChatRoomRegistrationStatus +import uniffi.truapi_server.HostRejection + +// Called from a shared dispatch pool, so the backing store must be +// thread-safe, and a slow call here stalls other product executions. +class MyChatBridge(private val store: ChatStore) : ChatHostBridge { + override fun createRoom(roomId: String, name: String, icon: String) = + if (store.putRoom(roomId, name, icon)) ChatRoomRegistrationStatus.NEW + else ChatRoomRegistrationStatus.EXISTS + + override fun registerBot(botId: String, name: String, icon: String) = + if (store.putBot(botId, name, icon)) ChatBotRegistrationStatus.NEW + else ChatBotRegistrationStatus.EXISTS + + override fun postMessage(roomId: String, content: ChatMessageContent): String { + if (content is ChatMessageContent.File) { + // Declining a variant is how a host opts out of rendering one. + throw HostRejection.Rejected("this host cannot render file cards") + } + return store.append(roomId, content) + } + + override fun listRooms(): List = store.rooms() +} + +val runtime = TrUAPIHostRuntime( + bridge = bridge, + runtimeConfig = HostRuntimeConfig( + hostName = "My Chat Host", + peopleChainGenesisHash = peopleChainGenesisHash, // exactly 32 bytes + bulletinChainGenesisHash = bulletinChainGenesisHash, + ), +) +// Chat needs an active session; without one every Chat call answers `Denied`. +runtime.activateLocalSession(secret) + +val execution = runtime.openProductExecution( + bridge = bridge, + configuration = ProductExecutionConfig("chat.dot", ProductExecutionKind.CHAT), + chat = MyChatBridge(store), +) +val endpoint = execution.startWsBridge() +webView.evaluateJavascript( + LocalhostBridgeBootstrap.script(endpoint.port, endpoint.token), + null, +) +``` + +Chat requires an active session: `openProductExecution` succeeds without one, +but every Chat call then answers `Denied` until `activateLocalSession` or SSO +pairing completes. + +The core bounds and screens the product-supplied fields it forwards — ids, +names, icons, message bodies, URLs, and the action and media counts. Ids and +names are also normalized; a message body is bounded and screened but passed +through byte-for-byte, and `ChatFile.size_bytes` is product-asserted and +unverified. Contextual output escaping is the host's job. + +`postMessage` receives any `ChatMessageContent` variant; throw from it for one this host cannot render. The id it returns is the correlation key `ActionTrigger.messageId` carries back, so it must name that message for as long as the host stores it. + +On the execution: `publishChatAction` delivers a user's action back to the product (buffered until it subscribes), `notifyChatRoomsChanged` republishes the room list, `renderCustomMessage` returns a `Flow` of typed UI for a stored custom message, and `sessionChatIdentityKey` reads the session's X25519 chat identity key. ## Architecture @@ -358,3 +432,7 @@ the generator. The `codegen` profile is required because uniffi-bindgen scans the cdylib's exported metadata symbols, which the `release` profile strips — a plain `--release` build produces a stripped library and no bindings. (`make uniffi` regenerates the Swift bindings; use `make uniffi-kotlin` for Android.) + +No CI job compiles this package. After changing `TrUAPIHost.kt` or the UniFFI +surface it wraps, run `make android-check` locally — it regenerates the Kotlin +bindings and compiles the module against them. diff --git a/android/truapi-host/build.gradle.kts b/android/truapi-host/build.gradle.kts index 8145270ce..a4d0fcf81 100644 --- a/android/truapi-host/build.gradle.kts +++ b/android/truapi-host/build.gradle.kts @@ -56,8 +56,10 @@ android { dependencies { // UniFFI Kotlin bindings use JNA for FFI. api("net.java.dev.jna:jna:5.14.0@aar") - // UniFFI async functions and callbacks use cancellable continuations and jobs. - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") + // UniFFI async functions and callbacks use cancellable continuations and + // jobs, and `TrUAPIProductExecution.renderCustomMessage` returns a `Flow`, + // so consumers compile against this. + api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") } // Coordinates for the local Maven publication (`publishToMavenLocal`). diff --git a/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt index 9b8b11d33..410b61cdf 100644 --- a/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt +++ b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt @@ -26,22 +26,38 @@ package io.parity.truapi +import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.conflate +import uniffi.truapi.ChatBotRegistrationStatus +import uniffi.truapi.ChatMessageContent +import uniffi.truapi.ChatRoom +import uniffi.truapi.ChatRoomRegistrationStatus +import uniffi.truapi.CustomRendererNode +import uniffi.truapi.HostChatActionSubscribeItem import uniffi.truapi.HostDevicePermissionRequest -import uniffi.truapi.HostLocalStorageReadError -import uniffi.truapi.HostNavigateToError import uniffi.truapi.HostFeatureSupportedRequest import uniffi.truapi.HostPushNotificationRequest import uniffi.truapi.RemotePermission import uniffi.truapi.HostThemeSubscribeItem import uniffi.truapi.ThemeName import uniffi.truapi.ThemeVariant +import uniffi.truapi.HostLocalStorageReadError +import uniffi.truapi.HostNavigateToError import uniffi.truapi_platform.AuthState import uniffi.truapi_platform.HostChainSet import uniffi.truapi_platform.PermissionAuthorizationRequest import uniffi.truapi_platform.PermissionAuthorizationStatus import uniffi.truapi_platform.UserConfirmationReview import uniffi.truapi_server.HostCallbacks +import uniffi.truapi_server.NativeChatCallbacks +import uniffi.truapi_server.NativeCustomRendererObserver +import uniffi.truapi_server.NativeProductExecution +import uniffi.truapi_server.NativeTrUApiHostRuntime +import uniffi.truapi_server.ProductRuntimeException import uniffi.truapi_server.HostNavigateRejection import uniffi.truapi_server.HostRejection import uniffi.truapi_server.HostStorageException @@ -55,6 +71,8 @@ import uniffi.truapi_server.WsBridgeEndpoint import uniffi.truapi_server.WsBridgeStartException import uniffi.truapi_server.NativePairingDeeplinkScheme as UniFfiNativePairingDeeplinkScheme import uniffi.truapi_server.NativeRuntimeConfig as UniFfiNativeRuntimeConfig +import uniffi.truapi_server.NativeHostRuntimeConfig as UniFfiNativeHostRuntimeConfig +import uniffi.truapi_server.NativeProductExecutionConfig as UniFfiNativeProductExecutionConfig /** Package metadata. */ object TrUAPIHost { @@ -162,6 +180,75 @@ data class RuntimeConfig( } } +/** + * Immutable process-wide configuration shared by every product execution + * opened from one [TrUAPIHostRuntime]. [peopleChainGenesisHash] and + * [bulletinChainGenesisHash] must each be exactly 32 bytes. + */ +data class HostRuntimeConfig( + val hostName: String, + val hostIcon: String? = null, + val hostVersion: String? = null, + val platformType: String? = null, + val platformVersion: String? = null, + val peopleChainGenesisHash: ByteArray, + val bulletinChainGenesisHash: ByteArray, + val localSessionSecret: ByteArray? = null, + val localSessionLiteUsername: String? = null, +) { + internal fun toNative(): UniFfiNativeHostRuntimeConfig = + UniFfiNativeHostRuntimeConfig( + hostName = hostName, + hostIcon = hostIcon, + hostVersion = hostVersion, + platformType = platformType, + platformVersion = platformVersion, + peopleChainGenesisHash = peopleChainGenesisHash, + bulletinChainGenesisHash = bulletinChainGenesisHash, + localSessionSecret = localSessionSecret, + localSessionLiteUsername = localSessionLiteUsername, + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is HostRuntimeConfig) return false + return hostName == other.hostName && + hostIcon == other.hostIcon && + hostVersion == other.hostVersion && + platformType == other.platformType && + platformVersion == other.platformVersion && + peopleChainGenesisHash.contentEquals(other.peopleChainGenesisHash) && + bulletinChainGenesisHash.contentEquals(other.bulletinChainGenesisHash) && + localSessionSecret.contentEquals(other.localSessionSecret) && + localSessionLiteUsername == other.localSessionLiteUsername + } + + override fun hashCode(): Int { + var result = hostName.hashCode() + result = 31 * result + (hostIcon?.hashCode() ?: 0) + result = 31 * result + (hostVersion?.hashCode() ?: 0) + result = 31 * result + (platformType?.hashCode() ?: 0) + result = 31 * result + (platformVersion?.hashCode() ?: 0) + result = 31 * result + peopleChainGenesisHash.contentHashCode() + result = 31 * result + bulletinChainGenesisHash.contentHashCode() + result = 31 * result + (localSessionSecret?.contentHashCode() ?: 0) + result = 31 * result + (localSessionLiteUsername?.hashCode() ?: 0) + return result + } +} + +/** Host-selected identity and trusted kind for one executable connection. */ +data class ProductExecutionConfig( + val productId: String, + val executionKind: ProductExecutionKind, +) { + internal fun toNative(): UniFfiNativeProductExecutionConfig = + UniFfiNativeProductExecutionConfig( + productId = productId, + executionKind = executionKind.toNative(), + ) +} + /** * Product-scoped key-value storage the host provides to the Rust core. Throws * [HostStorageException] to signal quota exhaustion or unknown failure; the @@ -333,54 +420,53 @@ interface HostBridge { val coreStorage: HostCoreStorage } -// A host that throws an exception type its callback does not declare crosses -// the FFI as an unexpected callback error. The Rust core converts those rather -// than aborting, but the reason it receives is then a raw JVM description, so -// each adapter funnels host throws into the declared type here. - -// Bounded: this reaches the product as the rejection reason, and a host -// message can carry a whole failed statement. -private fun hostRejectionReason(error: Throwable): String = - (error.message ?: error.toString()).take(HOST_REJECTION_REASON_MAX_CHARS) - -private const val HOST_REJECTION_REASON_MAX_CHARS = 256 +/** + * Native Chat storage and UI surface. Implement and pass to + * [TrUAPIHostRuntime.openProductExecution] when the host supports the Chat + * modality; hosts without it pass nothing. + * + * Threading: these run inline on the process-wide dispatch pool shared by + * every product execution, so implementations must be safe to enter + * concurrently and one that blocks stalls the others. Return promptly and + * marshal UI work to the main thread. + */ +interface ChatHostBridge { + /** + * Create or resolve a native product Chat room. The core has bounded and + * normalized these arguments and screened the icon scheme; escaping them + * for the surface that renders them is still the host's job. + */ + @Throws(HostRejection::class) + fun createRoom(roomId: String, name: String, icon: String): ChatRoomRegistrationStatus -private inline fun withHostRejection(operation: () -> T): T = - try { - operation() - } catch (rejection: HostRejection) { - throw rejection - } catch (cancellation: CancellationException) { - throw cancellation - } catch (error: Throwable) { - throw HostRejection.Rejected(hostRejectionReason(error)).apply { initCause(error) } - } + /** + * Register or resolve a native product Chat bot. The core has bounded and + * normalized these arguments and screened the icon scheme; escaping them + * for the surface that renders them is still the host's job. + */ + @Throws(HostRejection::class) + fun registerBot(botId: String, name: String, icon: String): ChatBotRegistrationStatus -private inline fun withNavigateRejection(operation: () -> T): T = - try { - operation() - } catch (rejection: HostNavigateRejection) { - throw rejection - } catch (cancellation: CancellationException) { - throw cancellation - } catch (error: Throwable) { - throw HostNavigateRejection.Navigate( - HostNavigateToError.Unknown(hostRejectionReason(error)), - ).apply { initCause(error) } - } + /** + * Persist a product-authored message in native Chat storage. Throw for a + * content variant this host cannot render. + * + * The core has bounded and screened every field, but a body passes through + * byte-for-byte and `ChatFile.sizeBytes` is an unverified product + * assertion, so escaping and sizing remain the host's job. + * + * The returned id is what `ActionTrigger.messageId` carries back, so it + * must name this message for as long as the host stores it. An id arriving + * in a `Reaction` or `ReactionRemoved` is product-chosen and untrusted: it + * may name a message in another room, or none at all. + */ + @Throws(HostRejection::class) + fun postMessage(roomId: String, content: ChatMessageContent): String -private inline fun withStorageException(operation: () -> T): T = - try { - operation() - } catch (storage: HostStorageException) { - throw storage - } catch (cancellation: CancellationException) { - throw cancellation - } catch (error: Throwable) { - throw HostStorageException.Storage( - HostLocalStorageReadError.Unknown(hostRejectionReason(error)), - ).apply { initCause(error) } - } + /** Return the current product-scoped native Chat rooms. */ + @Throws(HostRejection::class) + fun listRooms(): List +} /** * Adapter from the public [HostBridge] surface to the generated UniFFI @@ -463,6 +549,78 @@ private class HostCallbackAdapter(private val bridge: HostBridge) : HostCallback withStorageException { bridge.storage.clear(key) } } +// A host that throws an exception type its callback does not declare crosses +// the FFI as an unexpected callback error. The Rust core converts those rather +// than aborting, but the reason it receives is then a raw JVM description, so +// each adapter funnels host throws into the declared type here. + +// Bounded: this reaches the product as the rejection reason, and a host +// message can carry a whole failed statement. +private fun hostRejectionReason(error: Throwable): String = + (error.message ?: error.toString()).take(HOST_REJECTION_REASON_MAX_CHARS) + +private const val HOST_REJECTION_REASON_MAX_CHARS = 256 + +private inline fun withHostRejection(operation: () -> T): T = + try { + operation() + } catch (rejection: HostRejection) { + throw rejection + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Throwable) { + throw HostRejection.Rejected(hostRejectionReason(error)).apply { initCause(error) } + } + +private inline fun withNavigateRejection(operation: () -> T): T = + try { + operation() + } catch (rejection: HostNavigateRejection) { + throw rejection + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Throwable) { + throw HostNavigateRejection.Navigate( + HostNavigateToError.Unknown(hostRejectionReason(error)), + ).apply { initCause(error) } + } + +private inline fun withStorageException(operation: () -> T): T = + try { + operation() + } catch (storage: HostStorageException) { + throw storage + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Throwable) { + throw HostStorageException.Storage( + HostLocalStorageReadError.Unknown(hostRejectionReason(error)), + ).apply { initCause(error) } + } + +/** + * Adapter from the public [ChatHostBridge] surface to the generated UniFFI + * [NativeChatCallbacks] interface. + */ +private class ChatCallbackAdapter(private val bridge: ChatHostBridge) : NativeChatCallbacks { + override fun createRoom( + roomId: String, + name: String, + icon: String, + ): ChatRoomRegistrationStatus = withHostRejection { bridge.createRoom(roomId, name, icon) } + + override fun registerBot( + botId: String, + name: String, + icon: String, + ): ChatBotRegistrationStatus = withHostRejection { bridge.registerBot(botId, name, icon) } + + override fun postMessage(roomId: String, content: ChatMessageContent): String = + withHostRejection { bridge.postMessage(roomId, content) } + + override fun listRooms(): List = withHostRejection { bridge.listRooms() } +} + /** * Bootstrap helper for the native localhost WebSocket bridge that the Rust core * stands up via [TrUAPIHostCore.startWsBridge] when the cdylib is built with the @@ -750,3 +908,231 @@ class TrUAPIHostCore private constructor( inner.close() } } + +/** + * Process-owned Rust host runtime. Product executables open independent + * connections from this object and share its authentication and core services. + */ +class TrUAPIHostRuntime private constructor( + bridge: HostBridge, + runtimeConfig: UniFfiNativeHostRuntimeConfig, +) : AutoCloseable { + @Throws(NativeRuntimeConfigException::class) + constructor(bridge: HostBridge, runtimeConfig: HostRuntimeConfig) : this( + bridge, + runtimeConfig.toNative(), + ) + + // Co-owns the adapter alongside the generated FfiConverter handle map, + // which is what actually keeps the callback object alive for the runtime. + private val callbackRetainer: HostCallbacks = HostCallbackAdapter(bridge) + private val inner: NativeTrUApiHostRuntime = + NativeTrUApiHostRuntime.withRuntimeConfig(callbackRetainer, runtimeConfig) + + /** + * Open one executable connection with a host-assigned immutable context. + * Pass [chat] to install the host's Chat adapter; hosts without the Chat + * modality omit it. + */ + @Throws(NativeRuntimeConfigException::class) + fun openProductExecution( + bridge: HostBridge, + configuration: ProductExecutionConfig, + chat: ChatHostBridge? = null, + ): TrUAPIProductExecution { + val adapter = HostCallbackAdapter(bridge) + val chatAdapter = chat?.let { ChatCallbackAdapter(it) } + val execution = inner.openProductExecution(adapter, chatAdapter, configuration.toNative()) + return TrUAPIProductExecution(execution, adapter, chatAdapter) + } + + /** Core-owned logout for the process-wide authentication session. */ + fun disconnect() { + inner.disconnect() + } + + /** Activate or replace the process-wide local signing session. */ + @Throws(HostRejection::class) + fun activateLocalSession(secret: ByteArray, liteUsername: String? = null) { + inner.activateLocalSession(secret, liteUsername) + } + + /** Push a JSON-RPC response from a native chain connection into the runtime. */ + fun notifyChainResponse(connectionId: UInt, json: String) { + inner.notifyChainResponse(connectionId, json) + } + + /** Notify the runtime that a native chain connection closed externally. */ + fun notifyChainClosed(connectionId: UInt) { + inner.notifyChainClosed(connectionId) + } + + /** + * Record the accounts renewal should keep allowed on the Statement Store. + * Needs an active session, so call it after [activateLocalSession] or after + * pairing, not at construction. + * + * Recipe-shaped targets survive a change of root entropy; a raw + * [NativeStatementRenewalTarget.Account] does not, so re-track those + * whenever the active identity changes. + */ + @Throws(NativeRenewalTargetException::class) + fun trackStatementRenewalTargets(targets: List) { + inner.trackStatementRenewalTargets(targets) + } + + /** + * Run one renewal pass now, reporting what each tracked target got. Submits + * extrinsics and blocks until they are included, so call it from a + * WorkManager worker rather than the main thread. + */ + @Throws(HostRejection::class) + fun renewStatementAllowances(): StatementRenewalReport = inner.renewStatementAllowances() + + /** + * Start the in-process renewal loop, for a host that stays resident. A + * suspended app stops ticking, so prefer scheduling + * [renewStatementAllowances]. + */ + fun startStatementAllowanceRenewal() { + inner.startStatementAllowanceRenewal() + } + + /** + * The in-process loop's own cadence, capped at an hour. A host scheduling + * one wake-up per period should read a value under an hour as the boundary + * approaching rather than waking hourly. + */ + fun nextStatementRenewalDelay(): java.time.Duration = inner.nextStatementRenewalDelay() + + override fun close() { + inner.close() + } +} + +/** + * One SPA or Chat executable connected to a shared [TrUAPIHostRuntime]. Closing + * it shuts the connection down permanently; the runtime stays usable. + */ +class TrUAPIProductExecution internal constructor( + private val inner: NativeProductExecution, + private val callbackRetainer: HostCallbacks, + private val chatRetainer: NativeChatCallbacks?, +) : AutoCloseable { + private val shutDown = AtomicBoolean(false) + + /** Start this execution's independently authenticated localhost bridge. */ + @Throws(WsBridgeStartException::class) + fun startWsBridge(bindPort: UShort = 0u): WsBridgeEndpoint = inner.startWsBridge(bindPort) + + /** Stop the active bridge while leaving the execution reusable. */ + fun stopWsBridge() { + inner.stopWsBridge() + } + + /** + * Publish one native Chat action, buffering it until the product + * connection subscribes. + */ + @Throws(ProductRuntimeException::class) + fun publishChatAction(action: HostChatActionSubscribeItem) { + inner.publishChatAction(action) + } + + /** + * Republish the product-scoped native Chat room list. Call it whenever the + * host's own rooms change, including when a host joins a registered bot to + * a room. + */ + fun notifyChatRoomsChanged(rooms: List) { + inner.notifyChatRoomsChanged(rooms) + } + + /** + * Request typed native UI for one stored custom Chat message. The flow + * subscribes on collection, so a closed or non-Chat execution fails the + * collector with [ProductRuntimeException] rather than this call. It + * cancels the renderer when collection ends; + * each emission is a complete replacement tree, so only the latest is kept + * when the collector falls behind. + */ + fun renderCustomMessage( + messageId: String, + messageType: String, + payload: ByteArray, + ): Flow = + callbackFlow { + val observer = + object : NativeCustomRendererObserver { + // The core declares both infallible, so uniffi has no error + // type to convert a throw into and panics -- which aborts + // under `panic = "abort"`. + override fun onUpdate(node: CustomRendererNode) { + runCatching { trySend(node) } + } + + override fun onComplete() { + runCatching { close() } + } + } + val subscription = inner.renderCustomMessage(messageId, messageType, payload, observer) + awaitClose { + subscription.cancel() + subscription.close() + } + }.conflate() + + /** Read the active session's X25519 chat identity private key, if any. */ + @Throws(HostRejection::class) + fun sessionChatIdentityKey(): ByteArray? = inner.sessionChatIdentityKey() + + /** Read a stored permission authorization status without prompting. */ + @Throws(HostRejection::class) + fun permissionAuthorizationStatus( + request: PermissionAuthorizationRequest, + ): PermissionAuthorizationStatus = inner.permissionAuthorizationStatus(request) + + /** + * Update a stored permission authorization status. Passing `NotDetermined` + * clears the stored value so the next product request prompts again. + */ + @Throws(HostRejection::class) + fun setPermissionAuthorizationStatus( + request: PermissionAuthorizationRequest, + status: PermissionAuthorizationStatus, + ) { + inner.setPermissionAuthorizationStatus(request, status) + } + + /** Push a host theme update to active TrUAPI theme subscriptions. */ + fun notifyThemeChanged(theme: HostThemeSubscribeItem) { + inner.notifyThemeChanged(theme) + } + + /** Push a preimage lookup update to active subscriptions for [key]. */ + fun notifyPreimageChanged(key: ByteArray, value: ByteArray?) { + inner.notifyPreimageChanged(key, value) + } + + /** Push a JSON-RPC response from a native chain connection into the core. */ + fun notifyChainResponse(connectionId: UInt, json: String) { + inner.notifyChainResponse(connectionId, json) + } + + /** Notify the core that a native chain connection closed externally. */ + fun notifyChainClosed(connectionId: UInt) { + inner.notifyChainClosed(connectionId) + } + + @Synchronized + override fun close() { + // `shutdown` goes through the generated call guard, which throws once + // the handle is freed, so a repeat close must not reach it. Serialized + // as well as guarded: a concurrent close could otherwise free the + // handle between the guard and the call. + if (shutDown.compareAndSet(false, true)) { + inner.shutdown() + } + inner.close() + } +} diff --git a/explorer/diagnosis-reports/chat/signing-host-cli.md b/explorer/diagnosis-reports/chat/signing-host-cli.md new file mode 100644 index 000000000..a86b66a23 --- /dev/null +++ b/explorer/diagnosis-reports/chat/signing-host-cli.md @@ -0,0 +1,13 @@ +## Truapi Signing Host CLI Chat Diagnosis + +| Method | Status | Details | +| --- | --- | --- | +| `Chat/create_room` | ✅ | | +| `Chat/post_message` | ✅ | | +| `Chat/create_room_refuses_an_icon_that_resolves_past_its_budget` | ✅ | | +| `Chat/post_message_refuses_a_url_the_host_would_fetch_or_open` | ✅ | | +| `Chat/post_message_refuses_a_file_name_that_addresses_a_path` | ✅ | | +| `Chat/post_message_refuses_a_url_that_carries_credentials` | ✅ | | +| `Chat/post_message_refuses_a_url_that_resolves_past_its_budget` | ✅ | | +| `Chat/post_message_refuses_a_body_past_the_published_budget` | ✅ | | +| `Chat/post_message_refuses_two_action_ids_that_normalize_alike` | ✅ | | diff --git a/ios/truapi-host/README.md b/ios/truapi-host/README.md index 8bc478736..db399e8b8 100644 --- a/ios/truapi-host/README.md +++ b/ios/truapi-host/README.md @@ -73,6 +73,81 @@ Run the package tests against an iOS simulator (the xcframework has no macOS sli xcodebuild test -scheme TrUAPIHost -destination 'platform=iOS Simulator,name=iPhone 16' ``` +## Chat + +A host serving the Chat modality implements `ChatHostBridge` and opens the +execution with `ProductExecutionKind.chat`. Hosts without it pass nothing and +Chat calls answer unsupported. + +```swift +// Called from a shared dispatch pool, so the backing store must be +// thread-safe, and a slow call here stalls other product executions. +final class MyChatBridge: ChatHostBridge, @unchecked Sendable { + private let store: ChatStore + + init(store: ChatStore) { self.store = store } + + func createRoom(roomId: String, name: String, icon: String) throws + -> ChatRoomRegistrationStatus + { + store.putRoom(roomId, name: name, icon: icon) ? .new : .exists + } + + func registerBot(botId: String, name: String, icon: String) throws + -> ChatBotRegistrationStatus + { + store.putBot(botId, name: name, icon: icon) ? .new : .exists + } + + func postMessage(roomId: String, content: ChatMessageContent) throws -> String { + if case .file = content { + // Declining a variant is how a host opts out of rendering one. + // Throw `HostRejection.Rejected` (or a `LocalizedError`) so the + // product receives your reason rather than a bare type name. + throw HostRejection.Rejected(reason: "this host cannot render file cards") + } + return store.append(roomId, content: content) + } + + func listRooms() throws -> [ChatRoom] { store.rooms() } +} + +let runtime = try TrUAPIHostRuntime( + bridge: bridge, + runtimeConfig: HostRuntimeConfig( + hostName: "My Chat Host", + peopleChainGenesisHash: peopleChainGenesisHash, // exactly 32 bytes + bulletinChainGenesisHash: bulletinChainGenesisHash + ) +) +// Chat needs an active session; without one every Chat call answers denied. +try runtime.activateLocalSession(secret: secret) + +let execution = try runtime.openProductExecution( + bridge: bridge, + configuration: ProductExecutionConfig(productId: "chat.dot", executionKind: .chat), + chat: MyChatBridge(store: store) +) +let endpoint = try execution.startWsBridge() +``` + +The core bounds and screens the product-supplied fields it forwards — ids, +names, icons, message bodies, URLs, and the action and media counts. Ids and +names are also normalized; a message body is bounded and screened but passed +through byte-for-byte, and `ChatFile.sizeBytes` is product-asserted and +unverified. Contextual output escaping is the host's job. + +The id `postMessage` returns is the correlation key `ActionTrigger.messageId` +carries back, so it must name that message for as long as the host stores it. +Ids arriving *in* a `Reaction` or `ReactionRemoved` are product-chosen and +untrusted: they may name a message in another room, or one that never existed. + +On the execution: `publishChatAction` delivers a user's action back to the +product, buffering up to 64 before it subscribes; `notifyChatRoomsChanged` +republishes the room list; `renderCustomMessage` returns a stream of typed UI +for a stored custom message; and `sessionChatIdentityKey` reads the session's +X25519 chat identity private key, which must not be logged or persisted. + ## Architecture ```text diff --git a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift index 873d90f1c..3abfa4c66 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift @@ -427,10 +427,19 @@ public protocol HostBridge: AnyObject, Sendable { } /// Native Chat storage and UI surface. Implement and pass to -/// ``TrUAPIHostRuntime/openProductExecution(bridge:chat:configuration:)`` +/// ``TrUAPIHostRuntime/openProductExecution(bridge:configuration:chat:)`` /// when the host supports the Chat modality; hosts without it pass nothing. +/// Native Chat storage and UI surface, called from the process-wide dispatch +/// pool shared by every product execution: implementations must be safe to +/// enter concurrently, and one that blocks stalls the others. +/// +/// Throw ``HostRejection`` (or an error conforming to `LocalizedError`) to +/// decline a call. A plain `Error` reaches the product as its type name alone, +/// because a value's stored properties would otherwise cross to it. public protocol ChatHostBridge: AnyObject, Sendable { - /// Create or resolve a native product Chat room. + /// Create or resolve a native product Chat room. The core has bounded and + /// normalized these arguments and screened the icon scheme; escaping them + /// for the surface that renders them is still the host's job. func createRoom(roomId: String, name: String, icon: String) throws -> ChatRoomRegistrationStatus @@ -440,15 +449,18 @@ public protocol ChatHostBridge: AnyObject, Sendable { func registerBot(botId: String, name: String, icon: String) throws -> ChatBotRegistrationStatus - /// Persist a text message in native Chat storage. - func postTextMessage(roomId: String, text: String) throws -> String - - /// Persist a custom message in native Chat storage. - func postCustomMessage( - roomId: String, - messageType: String, - payload: Data - ) throws -> String + /// Persist a product-authored message in native Chat storage. Throw for a + /// content variant this host cannot render. + /// + /// The core has bounded and screened every field, but a body passes + /// through byte-for-byte and `ChatFile.sizeBytes` is an unverified product + /// assertion, so escaping and sizing remain the host's job. + /// + /// The returned id is what `ActionTrigger.messageId` carries back, so it + /// must name this message for as long as the host stores it. An id + /// arriving in a `reaction` or `reactionRemoved` is product-chosen and + /// untrusted: it may name a message in another room, or none at all. + func postMessage(roomId: String, content: ChatMessageContent) throws -> String /// Return the current product-scoped native Chat rooms. func listRooms() throws -> [ChatRoom] @@ -500,23 +512,9 @@ private final class ChatCallbackAdapter: NativeChatCallbacks, @unchecked Sendabl } } - func postTextMessage(roomId: String, text: String) throws -> String { - try withHostRejection { - try bridge.postTextMessage(roomId: roomId, text: text) - } - } - - func postCustomMessage( - roomId: String, - messageType: String, - payload: Data - ) throws -> String { + func postMessage(roomId: String, content: ChatMessageContent) throws -> String { try withHostRejection { - try bridge.postCustomMessage( - roomId: roomId, - messageType: messageType, - payload: payload - ) + try bridge.postMessage(roomId: roomId, content: content) } } @@ -738,8 +736,8 @@ public final class TrUAPIHostRuntime: @unchecked Sendable { /// modality omit it. public func openProductExecution( bridge: HostBridge, - chat: ChatHostBridge? = nil, - configuration: ProductExecutionConfig + configuration: ProductExecutionConfig, + chat: ChatHostBridge? = nil ) throws -> TrUAPIProductExecution { let adapter = HostCallbackAdapter(bridge: bridge) let chatAdapter = chat.map { ChatCallbackAdapter(bridge: $0) } @@ -876,6 +874,7 @@ public protocol TrUAPIProductExecutionProtocol: AnyObject, Sendable { func notifyChainResponse(connectionId: UInt32, json: String) func notifyChainClosed(connectionId: UInt32) func notifyChatRoomsChanged(rooms: [ChatRoom]) + func sessionChatIdentityKey() throws -> Data? } /// One SPA or Chat executable connected to a shared host runtime. @@ -958,6 +957,10 @@ public final class TrUAPIProductExecution: TrUAPIProductExecutionProtocol, @unch inner.notifyChainClosed(connectionId: connectionId) } + public func sessionChatIdentityKey() throws -> Data? { + try inner.sessionChatIdentityKey() + } + public func notifyChatRoomsChanged(rooms: [ChatRoom]) { inner.notifyChatRoomsChanged(rooms: rooms) } @@ -1105,8 +1108,8 @@ public final class TrUAPIHostCore: TrUAPIHostCoreProtocol { /// A value that is not a `LocalizedError` has no author-written description, /// and `localizedDescription` renders it as "The operation couldn't be /// completed. (Module.Type error 1.)" — which names the host's module and says -/// nothing about the failure. A plain value's `String(describing:)` prints its -/// stored properties, so only the type name crosses to the product. +/// nothing about the failure. That string reaches the product, so prefer what +/// the host actually wrote. private func hostRejectionReason(_ error: Error) -> String { let reason: String if let described = (error as? LocalizedError)?.errorDescription { diff --git a/ios/truapi-host/Sources/TrUAPIHost/truapi.swift b/ios/truapi-host/Sources/TrUAPIHost/truapi.swift index 811a85590..760db5beb 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/truapi.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/truapi.swift @@ -604,7 +604,8 @@ fileprivate struct FfiConverterData: FfiConverterRustBuffer { */ public struct ActionTrigger: Equatable, Hashable { /** - * Message containing the action. + * Message containing the action, as returned by `Chat::post_message` in + * [`HostChatPostMessageResponse::message_id`]. */ public var messageId: String /** @@ -620,7 +621,8 @@ public struct ActionTrigger: Equatable, Hashable { // declare one manually. public init( /** - * Message containing the action. + * Message containing the action, as returned by `Chat::post_message` in + * [`HostChatPostMessageResponse::message_id`]. */messageId: String, /** * Which action was triggered. diff --git a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift index 545219d63..750111c8b 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift @@ -1958,14 +1958,15 @@ public protocol NativeChatCallbacks: AnyObject, Sendable { func registerBot(botId: String, name: String, icon: String) throws -> ChatBotRegistrationStatus /** - * Persist a text message in native Chat storage. - */ - func postTextMessage(roomId: String, text: String) throws -> String - - /** - * Persist a custom message in native Chat storage. + * Persist a product-authored message in native Chat storage. A host that + * cannot render a given content variant returns a rejection for it. + * + * The returned id is what [`ActionTrigger::message_id`] carries back, so + * it must name this message for as long as the host stores it. + * + * [`ActionTrigger::message_id`]: truapi::latest::ActionTrigger */ - func postCustomMessage(roomId: String, messageType: String, payload: Data) throws -> String + func postMessage(roomId: String, content: ChatMessageContent) throws -> String /** * Return the current product-scoped native Chat room list. @@ -2064,30 +2065,21 @@ open func registerBot(botId: String, name: String, icon: String)throws -> ChatB } /** - * Persist a text message in native Chat storage. - */ -open func postTextMessage(roomId: String, text: String)throws -> String { - return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeHostRejection_lift) { - uniffiCallStatus in - uniffi_truapi_server_fn_method_nativechatcallbacks_post_text_message( - self.uniffiCloneHandle(), - FfiConverterString.lower(roomId), - FfiConverterString.lower(text),uniffiCallStatus - ) -}) -} - - /** - * Persist a custom message in native Chat storage. + * Persist a product-authored message in native Chat storage. A host that + * cannot render a given content variant returns a rejection for it. + * + * The returned id is what [`ActionTrigger::message_id`] carries back, so + * it must name this message for as long as the host stores it. + * + * [`ActionTrigger::message_id`]: truapi::latest::ActionTrigger */ -open func postCustomMessage(roomId: String, messageType: String, payload: Data)throws -> String { +open func postMessage(roomId: String, content: ChatMessageContent)throws -> String { return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeHostRejection_lift) { uniffiCallStatus in - uniffi_truapi_server_fn_method_nativechatcallbacks_post_custom_message( + uniffi_truapi_server_fn_method_nativechatcallbacks_post_message( self.uniffiCloneHandle(), FfiConverterString.lower(roomId), - FfiConverterString.lower(messageType), - FfiConverterData.lower(payload),uniffiCallStatus + FfiConverterTypeChatMessageContent_lower(content),uniffiCallStatus ) }) } @@ -2190,10 +2182,10 @@ fileprivate struct UniffiCallbackInterfaceNativeChatCallbacks { lowerError: FfiConverterTypeHostRejection_lower ) }, - postTextMessage: { ( + postMessage: { ( uniffiHandle: UInt64, roomId: RustBuffer, - text: RustBuffer, + content: RustBuffer, uniffiOutReturn: UnsafeMutablePointer, uniffiCallStatus: UnsafeMutablePointer ) in @@ -2202,38 +2194,9 @@ fileprivate struct UniffiCallbackInterfaceNativeChatCallbacks { guard let uniffiObj = try? FfiConverterTypeNativeChatCallbacks.handleMap.get(handle: uniffiHandle) else { throw UniffiInternalError.unexpectedStaleHandle } - return try uniffiObj.postTextMessage( + return try uniffiObj.postMessage( roomId: try FfiConverterString.lift(roomId), - text: try FfiConverterString.lift(text) - ) - } - - - let writeReturn = { uniffiOutReturn.pointee = FfiConverterString.lower($0) } - uniffiTraitInterfaceCallWithError( - callStatus: uniffiCallStatus, - makeCall: makeCall, - writeReturn: writeReturn, - lowerError: FfiConverterTypeHostRejection_lower - ) - }, - postCustomMessage: { ( - uniffiHandle: UInt64, - roomId: RustBuffer, - messageType: RustBuffer, - payload: RustBuffer, - uniffiOutReturn: UnsafeMutablePointer, - uniffiCallStatus: UnsafeMutablePointer - ) in - let makeCall = { - () throws -> String in - guard let uniffiObj = try? FfiConverterTypeNativeChatCallbacks.handleMap.get(handle: uniffiHandle) else { - throw UniffiInternalError.unexpectedStaleHandle - } - return try uniffiObj.postCustomMessage( - roomId: try FfiConverterString.lift(roomId), - messageType: try FfiConverterString.lift(messageType), - payload: try FfiConverterData.lift(payload) + content: try FfiConverterTypeChatMessageContent_lift(content) ) } @@ -6486,13 +6449,10 @@ private let initializationResult: InitializationResult = { if (uniffi_truapi_server_checksum_method_nativechatcallbacks_register_bot() != 59357) { return InitializationResult.apiChecksumMismatch } - if (uniffi_truapi_server_checksum_method_nativechatcallbacks_post_text_message() != 49314) { - return InitializationResult.apiChecksumMismatch - } - if (uniffi_truapi_server_checksum_method_nativechatcallbacks_post_custom_message() != 28844) { + if (uniffi_truapi_server_checksum_method_nativechatcallbacks_post_message() != 56893) { return InitializationResult.apiChecksumMismatch } - if (uniffi_truapi_server_checksum_method_nativechatcallbacks_list_rooms() != 37616) { + if (uniffi_truapi_server_checksum_method_nativechatcallbacks_list_rooms() != 21374) { return InitializationResult.apiChecksumMismatch } if (uniffi_truapi_server_checksum_method_nativeproductexecution_device_encryption_key() != 18707) { diff --git a/ios/truapi-host/Sources/truapi_serverFFI/include/truapi_serverFFI.h b/ios/truapi-host/Sources/truapi_serverFFI/include/truapi_serverFFI.h index 441535d28..d2c1bb1a6 100644 --- a/ios/truapi-host/Sources/truapi_serverFFI/include/truapi_serverFFI.h +++ b/ios/truapi-host/Sources/truapi_serverFFI/include/truapi_serverFFI.h @@ -420,14 +420,7 @@ typedef void (*UniffiCallbackInterfaceNativeChatCallbacksMethod2)(uint64_t, Rust #endif #ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_NATIVE_CHAT_CALLBACKS_METHOD3 #define UNIFFI_FFIDEF_CALLBACK_INTERFACE_NATIVE_CHAT_CALLBACKS_METHOD3 -typedef void (*UniffiCallbackInterfaceNativeChatCallbacksMethod3)(uint64_t, RustBuffer, RustBuffer, RustBuffer, RustBuffer* _Nonnull, - RustCallStatus *_Nonnull uniffiCallStatus - ); - -#endif -#ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_NATIVE_CHAT_CALLBACKS_METHOD4 -#define UNIFFI_FFIDEF_CALLBACK_INTERFACE_NATIVE_CHAT_CALLBACKS_METHOD4 -typedef void (*UniffiCallbackInterfaceNativeChatCallbacksMethod4)(uint64_t, RustBuffer* _Nonnull, +typedef void (*UniffiCallbackInterfaceNativeChatCallbacksMethod3)(uint64_t, RustBuffer* _Nonnull, RustCallStatus *_Nonnull uniffiCallStatus ); @@ -478,9 +471,8 @@ typedef struct UniffiVTableCallbackInterfaceNativeChatCallbacks { UniffiCallbackInterfaceClone _Nonnull uniffiClone; UniffiCallbackInterfaceNativeChatCallbacksMethod0 _Nonnull createRoom; UniffiCallbackInterfaceNativeChatCallbacksMethod1 _Nonnull registerBot; - UniffiCallbackInterfaceNativeChatCallbacksMethod2 _Nonnull postTextMessage; - UniffiCallbackInterfaceNativeChatCallbacksMethod3 _Nonnull postCustomMessage; - UniffiCallbackInterfaceNativeChatCallbacksMethod4 _Nonnull listRooms; + UniffiCallbackInterfaceNativeChatCallbacksMethod2 _Nonnull postMessage; + UniffiCallbackInterfaceNativeChatCallbacksMethod3 _Nonnull listRooms; } UniffiVTableCallbackInterfaceNativeChatCallbacks; #endif @@ -629,14 +621,9 @@ RustBuffer uniffi_truapi_server_fn_method_nativechatcallbacks_create_room(uint64 RustBuffer uniffi_truapi_server_fn_method_nativechatcallbacks_register_bot(uint64_t ptr, RustBuffer bot_id, RustBuffer name, RustBuffer icon, RustCallStatus *_Nonnull out_status ); #endif -#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVECHATCALLBACKS_POST_TEXT_MESSAGE -#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVECHATCALLBACKS_POST_TEXT_MESSAGE -RustBuffer uniffi_truapi_server_fn_method_nativechatcallbacks_post_text_message(uint64_t ptr, RustBuffer room_id, RustBuffer text, RustCallStatus *_Nonnull out_status -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVECHATCALLBACKS_POST_CUSTOM_MESSAGE -#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVECHATCALLBACKS_POST_CUSTOM_MESSAGE -RustBuffer uniffi_truapi_server_fn_method_nativechatcallbacks_post_custom_message(uint64_t ptr, RustBuffer room_id, RustBuffer message_type, RustBuffer payload, RustCallStatus *_Nonnull out_status +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVECHATCALLBACKS_POST_MESSAGE +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVECHATCALLBACKS_POST_MESSAGE +RustBuffer uniffi_truapi_server_fn_method_nativechatcallbacks_post_message(uint64_t ptr, RustBuffer room_id, RustBuffer content, RustCallStatus *_Nonnull out_status ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVECHATCALLBACKS_LIST_ROOMS @@ -1344,15 +1331,9 @@ uint16_t uniffi_truapi_server_checksum_method_nativechatcallbacks_register_bot(v ); #endif -#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVECHATCALLBACKS_POST_TEXT_MESSAGE -#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVECHATCALLBACKS_POST_TEXT_MESSAGE -uint16_t uniffi_truapi_server_checksum_method_nativechatcallbacks_post_text_message(void - -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVECHATCALLBACKS_POST_CUSTOM_MESSAGE -#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVECHATCALLBACKS_POST_CUSTOM_MESSAGE -uint16_t uniffi_truapi_server_checksum_method_nativechatcallbacks_post_custom_message(void +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVECHATCALLBACKS_POST_MESSAGE +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVECHATCALLBACKS_POST_MESSAGE +uint16_t uniffi_truapi_server_checksum_method_nativechatcallbacks_post_message(void ); #endif diff --git a/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift b/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift index a0e0f14e5..d8a95821e 100644 --- a/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift +++ b/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift @@ -107,13 +107,9 @@ final class StubChatHostBridge: ChatHostBridge { icon _: String ) throws -> ChatBotRegistrationStatus { .new } - func postTextMessage(roomId _: String, text _: String) throws -> String { "message-id" } - - func postCustomMessage( - roomId _: String, - messageType _: String, - payload _: Data - ) throws -> String { "message-id" } + func postMessage(roomId _: String, content _: ChatMessageContent) throws -> String { + "message-id" + } func listRooms() throws -> [ChatRoom] { [] } } diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 9b89acd46..75a025d0f 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -831,11 +831,26 @@ export interface ChainProvider { * storage and UI. Optional: a host that omits it leaves Chat requests * answered `Unsupported`. See `OptionalPlatform`. * - * On `create_chat_room` and `register_chat_bot` the core bounds ids, names and - * icons, NFC-normalizes them, screens control and bidi characters, and - * restricts an icon to `https` or an inline raster image. Contextual output - * escaping, storage limits, and every `post_chat_message` field remain - * host-owned. + * The core bounds and screens the product-supplied fields it forwards. Ids, + * names and icons on `create_chat_room`, `register_chat_bot` and + * `post_chat_message` are NFC-normalized and rejected for control and bidi + * characters. Message bodies are bounded and screened but pass through + * byte-for-byte, keeping line breaks and tabs, so a product reads back the + * bytes it sent. Counts and byte budgets are enforced, and any URL a host may + * fetch or open is restricted to `https` or an inline raster image and + * delivered as the parser resolved it. + * + * The core screens a URL's shape, not its reachability. `https://127.0.0.1`, + * `https://[::1]`, a private range and `https://169.254.169.254` (the cloud + * metadata endpoint) all pass: which networks a host is willing to fetch from + * depends on where that host runs, and a core that guessed would break a host + * serving its own media from localhost. A host that fetches these URLs owns + * that decision. Credentials are the exception and are refused, because + * `user:pass@` survives resolution into whatever the host fetches and logs. + * + * `ChatFile::size_bytes` is a product assertion and is not verified against + * the resource it names. Contextual output escaping, storage limits, and + * anything a host derives from product-supplied values remain host-owned. */ export interface ChatPlatform { /** diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index a51b3371c..79717b852 100644 --- a/rust/crates/truapi-host-cli/README.md +++ b/rust/crates/truapi-host-cli/README.md @@ -303,6 +303,7 @@ Six scripts ship under `js/scripts/`: scripts/battery.sh --pairing-host # paired phase only make e2e-signing-cli # direct phase only make e2e-pairing-cli # paired phase only + make e2e-chat-cli # chat phase only scripts/battery.sh --release # release binary scripts/battery.sh -- --network foo # arguments after `--` go to every host process ``` diff --git a/rust/crates/truapi-host-cli/js/chat-e2e.ts b/rust/crates/truapi-host-cli/js/chat-e2e.ts new file mode 100644 index 000000000..c67373006 --- /dev/null +++ b/rust/crates/truapi-host-cli/js/chat-e2e.ts @@ -0,0 +1,306 @@ +// End-to-end Chat content screening, run against a host that actually serves +// chat (`truapi-host --execution-kind chat`). +// +// The core screens product-authored message content in the runtime layer, +// above the platform, so a host is never handed content it would have to be +// trusted to screen again. A product-visible error cannot tell that apart from +// a host that was handed the content and refused it, so these cases read the +// host's own transcript (`TRUAPI_CHAT_LOG`, one JSON line per stored message) +// and require it not to have grown. +import { existsSync, readFileSync } from "node:fs"; +import type { TrUApiClient } from "../../../../js/packages/truapi/src/index.ts"; +import { ChatMessageContent } from "../../../../js/packages/truapi/src/generated/types.ts"; +import type { + ChatMessageContent as ChatMessageContentValue, + HostChatPostMessageRequest, +} from "../../../../js/packages/truapi/src/generated/types.ts"; +import type { DiagnosisRow } from "./diagnosis.ts"; + +/** Room every case posts into, created by the first case. */ +const ROOM_ID = "support"; + +/** Published limits, mirrored from `truapi-platform` so a case can cross one. */ +const BODY_MAX_BYTES = 16 * 1024; +const URL_MAX_BYTES = 2048; +const ICON_MAX_BYTES = 64 * 1024; + +/** + * A path that arrives inside `limit` and resolves past it. + * + * "é" is two bytes and percent-encodes to six, so a quarter of the budget in + * leaves at roughly one and a half times it. The budget has to be measured + * against what the host receives for this to be refused. + */ +function pathThatGrowsPast(limit: number): string { + return "\u00e9".repeat(Math.floor(limit / 4)); +} + +/** Messages the host has stored so far, one JSON object per line. */ +function transcript(path: string): string[] { + if (!existsSync(path)) { + return []; + } + return readFileSync(path, "utf8") + .split("\n") + .filter((line) => line.length > 0); +} + +/** Rooms and bots the host registered, which a refused icon must not reach. */ +function registrations(path: string): number { + return transcript(path).filter((line) => { + const kind = JSON.parse(line).kind as string; + return kind === "room" || kind === "bot"; + }).length; +} + +/** The payload of the last message the host stored, SCALE-encoded as hex. */ +function lastStoredPayload(path: string): string | undefined { + const messages = transcript(path).filter( + (line) => JSON.parse(line).kind === "message", + ); + const last = messages[messages.length - 1]; + return last ? (JSON.parse(last).payload as string) : undefined; +} + +function hex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +/** One case: a payload, and whether the host may ever see it. */ +interface Case { + name: string; + payload: ChatMessageContentValue; +} + +/** Payloads the core must refuse before any host is handed them. */ +const REFUSED: Case[] = [ + { + name: "a url the host would fetch or open", + payload: { + tag: "File", + value: { + url: "javascript:alert(document.cookie)", + fileName: "receipt.pdf", + mimeType: "application/pdf", + sizeBytes: 1n, + }, + }, + }, + { + name: "a file name that addresses a path", + payload: { + tag: "File", + value: { + url: "https://files.invalid/receipt.pdf", + fileName: "../../etc/passwd", + mimeType: "application/pdf", + sizeBytes: 1n, + }, + }, + }, + { + name: "a url that carries credentials", + payload: { + tag: "File", + value: { + // `user:pass@` survives URL resolution, so a host handed this would + // fetch with the credentials and log them. + url: "https://user:pass@files.invalid/receipt.pdf", + fileName: "receipt.pdf", + mimeType: "application/pdf", + sizeBytes: 1n, + }, + }, + }, + { + name: "a url that resolves past its budget", + payload: { + tag: "File", + value: { + url: `https://files.invalid/${pathThatGrowsPast(URL_MAX_BYTES)}`, + fileName: "receipt.pdf", + mimeType: "application/pdf", + sizeBytes: 1n, + }, + }, + }, + { + name: "a body past the published budget", + payload: { tag: "Text", value: { text: "a".repeat(BODY_MAX_BYTES + 1) } }, + }, + { + name: "two action ids that normalize alike", + payload: { + tag: "Actions", + value: { + text: "Pick one", + actions: [ + { actionId: "caf\u00e9", title: "Precomposed" }, + // The same identifier written with a combining accent. NFC folds it + // onto the one above, and a trigger naming that key could not say + // which button was pressed. + { actionId: "cafe\u0301", title: "Decomposed" }, + ], + layout: "Column", + }, + }, + }, +]; + +/** + * Create a room, post content a host can render, then require every refused + * payload to be refused without reaching the host's transcript. + */ +export async function runChatScreeningE2e( + client: TrUApiClient, + chatLogPath: string | undefined, +): Promise { + const rows: DiagnosisRow[] = []; + const row = ( + methodName: string, + status: DiagnosisRow["status"], + output: string, + startedAt: number, + ): DiagnosisRow => ({ + id: `Chat/${methodName}`, + serviceName: "Chat", + methodName, + status, + output, + durationMs: Math.round(performance.now() - startedAt), + }); + + if (!chatLogPath) { + return [ + row( + "content_screening_e2e", + "skipped", + "TRUAPI_CHAT_LOG not set; cannot tell a core rejection from a host one", + performance.now(), + ), + ]; + } + + let startedAt = performance.now(); + const created = await client.chat.createRoom({ + roomId: ROOM_ID, + name: "Support", + icon: "https://rooms.invalid/support.png", + }); + rows.push( + created.isOk() + ? row("create_room", "pass", String(created.value.status), startedAt) + : row("create_room", "fail", JSON.stringify(created.error), startedAt), + ); + if (created.isErr()) { + return rows; + } + + // Content a host can render reaches it byte for byte: line breaks and tabs + // survive, because a body is screened but never trimmed or normalized. + startedAt = performance.now(); + const text = "line one\nline two\twith a tab"; + const accepted: HostChatPostMessageRequest = { + roomId: ROOM_ID, + payload: { tag: "Text", value: { text } }, + }; + const posted = await client.chat.postMessage(accepted); + if (posted.isErr()) { + rows.push( + row("post_message", "fail", JSON.stringify(posted.error), startedAt), + ); + return rows; + } + const storedPayload = lastStoredPayload(chatLogPath); + const sentPayload = hex(ChatMessageContent.enc(accepted.payload)); + rows.push( + storedPayload === sentPayload + ? row( + "post_message", + "pass", + `message ${posted.value.messageId} stored byte for byte`, + startedAt, + ) + : row( + "post_message", + "fail", + `host stored ${storedPayload ?? "nothing"}, product sent ${sentPayload}`, + startedAt, + ), + ); + + // The icon path is where the budget was measured on the arriving string + // rather than the resolved one, so it gets a case of its own: the room must + // not reach the host at all. + startedAt = performance.now(); + const roomsBefore = registrations(chatLogPath); + const oversizedIcon = await client.chat.createRoom({ + roomId: "oversized-icon", + name: "Oversized", + icon: `https://icons.invalid/${pathThatGrowsPast(ICON_MAX_BYTES)}`, + }); + const roomsAfter = registrations(chatLogPath); + rows.push( + oversizedIcon.isOk() + ? row( + "create_room_refuses_an_icon_that_resolves_past_its_budget", + "fail", + `accepted: ${JSON.stringify(oversizedIcon.value)}`, + startedAt, + ) + : roomsAfter === roomsBefore + ? row( + "create_room_refuses_an_icon_that_resolves_past_its_budget", + "pass", + `${JSON.stringify(oversizedIcon.error)}; host registered no room`, + startedAt, + ) + : row( + "create_room_refuses_an_icon_that_resolves_past_its_budget", + "fail", + "rejected the product, but the host registered the room", + startedAt, + ), + ); + + for (const refused of REFUSED) { + startedAt = performance.now(); + const before = transcript(chatLogPath).length; + const result = await client.chat.postMessage({ + roomId: ROOM_ID, + payload: refused.payload, + }); + const after = transcript(chatLogPath).length; + const methodName = `post_message_refuses_${refused.name.replace(/\s+/g, "_")}`; + if (result.isOk()) { + rows.push( + row(methodName, "fail", `accepted: ${result.value.messageId}`, startedAt), + ); + continue; + } + rows.push( + after === before + ? row( + methodName, + "pass", + `${JSON.stringify(result.error)}; host transcript unchanged`, + startedAt, + ) + : row( + methodName, + "fail", + `rejected the product, but the host stored ${after - before} message(s)`, + startedAt, + ), + ); + } + + return rows; +} + +/** Non-`skipped` rows that did not pass. */ +export function chatScreeningFailures(rows: DiagnosisRow[]): DiagnosisRow[] { + return rows.filter((entry) => entry.status === "fail"); +} diff --git a/rust/crates/truapi-host-cli/js/diagnosis-report.ts b/rust/crates/truapi-host-cli/js/diagnosis-report.ts index 455effa50..487c3508e 100644 --- a/rust/crates/truapi-host-cli/js/diagnosis-report.ts +++ b/rust/crates/truapi-host-cli/js/diagnosis-report.ts @@ -31,6 +31,23 @@ export function cliDiagnosisReportMetadata( } } +/** + * Report metadata for the same CLI host serving a Chat execution. + * + * The aggregator picks the matrix from the parent directory, so a chat report + * belongs under `diagnosis-reports/chat/`; the title carries the modality too, + * matching `chat/ios.md`. + */ +export function cliChatDiagnosisReportMetadata( + role: string | undefined, +): CliDiagnosisReportMetadata { + const spa = cliDiagnosisReportMetadata(role); + return { + filename: spa.filename, + title: spa.title.replace(/ Diagnosis$/, " Chat Diagnosis"), + }; +} + /** Render the same Markdown matrix used by the playground diagnosis reports. */ export function renderDiagnosisReport( title: string, diff --git a/rust/crates/truapi-host-cli/js/scripts/chat-battery.ts b/rust/crates/truapi-host-cli/js/scripts/chat-battery.ts new file mode 100644 index 000000000..134d367b1 --- /dev/null +++ b/rust/crates/truapi-host-cli/js/scripts/chat-battery.ts @@ -0,0 +1,73 @@ +/// +// Chat content screening against a real host, over the real wire. +// +// Run via: +// scripts/battery.sh --chat-host +// +// which starts a signing host with `--execution-kind chat`, so the product +// connection opens as a Chat execution and the CLI's in-memory chat host is +// installed. Chat is denied to a Spa connection and to a host with no session, +// so neither the generated Spa battery nor an in-process harness can reach +// this path: a live host is the only way to exercise it. +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runChatScreeningE2e } from "../chat-e2e.ts"; +import { + cliChatDiagnosisReportMetadata, + renderDiagnosisReport, +} from "../diagnosis-report.ts"; + +const report = cliChatDiagnosisReportMetadata(process.env.TRUAPI_CLI_HOST_ROLE); +const DEFAULT_REPORT_PATH = fileURLToPath( + new URL( + `../../../../../explorer/diagnosis-reports/chat/${report.filename}`, + import.meta.url, + ), +); +const REPORT_PATH = + process.env.TRUAPI_BATTERY_REPORT_PATH || DEFAULT_REPORT_PATH; + +const login = await truapi.account.requestLogin({ reason: undefined }); +if ( + !login.isOk() || + !["Success", "AlreadyConnected"].includes(String(login.value)) +) { + throw new Error( + `chat battery login failed: ${login.isOk() ? login.value : JSON.stringify(login.error)}`, + ); +} + +const rows = await runChatScreeningE2e(truapi, process.env.TRUAPI_CHAT_LOG); +for (const row of rows) { + const mark = { pass: "✅", fail: "❌", skipped: "⏭️" }[row.status]; + console.log(`${mark} ${row.id} (${row.durationMs}ms) ${row.output}`); +} + +// Committed, so a rerun overwrites it and the diff shows what changed. The +// chat matrix reads it from the directory it lands in. +mkdirSync(dirname(REPORT_PATH), { recursive: true }); +writeFileSync(REPORT_PATH, renderDiagnosisReport(report.title, rows)); +console.log(`chat battery: report saved to ${REPORT_PATH}`); + +const skipped = rows.filter((row) => row.status === "skipped"); +if (skipped.length > 0) { + // A skip here means the run could not tell a core rejection from a host one, + // which is the single thing these cases exist to distinguish. + throw new Error( + `chat battery skipped ${skipped.length} case(s): ${skipped + .map((row) => row.output) + .join("; ")}`, + ); +} + +const failures = rows.filter((row) => row.status === "fail"); +if (failures.length > 0) { + throw new Error( + `chat battery failed: ${failures.length} of ${rows.length} cases\n${failures + .map((row) => `${row.id}: ${row.output}`) + .join("\n")}`, + ); +} + +console.log(`chat battery: ${rows.length} cases passed`); diff --git a/rust/crates/truapi-host-cli/src/chat.rs b/rust/crates/truapi-host-cli/src/chat.rs new file mode 100644 index 000000000..762c68b52 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/chat.rs @@ -0,0 +1,335 @@ +//! In-memory Chat host for the CLI. +//! +//! Rooms, bots and messages live for the length of the process: this exists to +//! make a chat product runnable headlessly, not to be a chat backend. +//! +//! Every message the core hands over is appended to the transcript named by +//! `TRUAPI_CHAT_LOG`, one JSON object per line. A product-visible error alone +//! cannot tell "the core rejected this before any host saw it" apart from "the +//! host was handed it and refused", and that distinction is the whole point of +//! screening content in the runtime; the transcript is what lets a battery +//! assert the first reading. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use futures::StreamExt; +use futures::channel::mpsc; +use futures::stream::{self, BoxStream}; +use parity_scale_codec::Encode; +use truapi::latest::{ + ChatBotRegistrationStatus, ChatMessageContent, ChatRoomRegistrationStatus, GenericError, + HostChatCreateRoomError, HostChatCreateRoomRequest, HostChatCreateRoomResponse, + HostChatListSubscribeItem, HostChatPostMessageError, HostChatPostMessageRequest, + HostChatPostMessageResponse, HostChatRegisterBotError, HostChatRegisterBotRequest, + HostChatRegisterBotResponse, +}; +use truapi::v01::{ChatRoom, ChatRoomParticipation}; +use truapi_platform::{ChatPlatform, ProductContext, async_trait}; + +/// Rooms, bots and posted messages for one process. +#[derive(Default)] +struct State { + /// Room id to how this host participates in it. + rooms: BTreeMap, + /// Registered bot ids. A bot is not a room, so registering one does not + /// republish the room list. + bots: BTreeSet, + /// Messages accepted so far. The count is what the next message id counts + /// from, and a product correlates an action trigger against that id. + accepted: usize, + /// Live room-list subscribers, one per product connection. + subscribers: Vec>, +} + +/// A chat host that keeps everything in memory. +pub struct CliChatHost { + state: Mutex, + transcript: Option, +} + +impl CliChatHost { + /// Build a chat host, writing a transcript when `TRUAPI_CHAT_LOG` names a + /// path. + pub fn from_env() -> Arc { + Self::new(std::env::var_os("TRUAPI_CHAT_LOG").map(PathBuf::from)) + } + + /// Build a chat host recording to `transcript`. The file is truncated at + /// startup so a run never reads an earlier run's messages as its own. + fn new(transcript: Option) -> Arc { + if let Some(path) = transcript.as_ref() + && let Err(error) = std::fs::write(path, b"") + { + tracing::warn!(?path, %error, "chat transcript could not be truncated"); + } + Arc::new(Self { + state: Mutex::new(State::default()), + transcript, + }) + } + + fn lock(&self) -> std::sync::MutexGuard<'_, State> { + self.state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + /// Current room list, in room-id order so a replacement that changes + /// nothing is byte-identical to the one before it. + fn room_list(state: &State) -> HostChatListSubscribeItem { + HostChatListSubscribeItem { + rooms: state + .rooms + .iter() + .map(|(room_id, participating_as)| ChatRoom { + room_id: room_id.clone(), + participating_as: *participating_as, + }) + .collect(), + } + } + + /// Send the current list to every live subscriber, dropping closed ones. + fn republish(state: &mut State) { + let item = Self::room_list(state); + state + .subscribers + .retain(|subscriber| subscriber.unbounded_send(item.clone()).is_ok()); + } + + /// Append one accepted message to the transcript, if one is configured. + fn record_message(&self, message_id: &str, request: &HostChatPostMessageRequest) { + self.record(serde_json::json!({ + "kind": "message", + "messageId": message_id, + "roomId": request.room_id, + "variant": variant_name(&request.payload), + // The payload as the host received it. A summary would let a + // difference between what a product sent and what a host stored + // hide behind the summary. + "payload": hex::encode(request.payload.encode()), + })); + } + + /// Append one accepted room or bot registration. + fn record_registration(&self, kind: &str, id: &str, name: &str, icon: &str) { + self.record(serde_json::json!({ + "kind": kind, + "id": id, + "name": name, + // Icons resolve before a host sees them, so record what arrived + // rather than what the product typed. + "icon": icon, + })); + } + + /// Append one line to the transcript, if one is configured. + fn record(&self, line: serde_json::Value) { + let Some(path) = self.transcript.as_ref() else { + return; + }; + let appended = OpenOptions::new() + .create(true) + .append(true) + .open(path) + .and_then(|mut file| writeln!(file, "{line}")); + if let Err(error) = appended { + tracing::warn!(?path, %error, "chat transcript could not be appended to"); + } + } +} + +/// The variant name a transcript reader matches on. +fn variant_name(content: &ChatMessageContent) -> &'static str { + match content { + ChatMessageContent::Text { .. } => "Text", + ChatMessageContent::RichText(_) => "RichText", + ChatMessageContent::Actions(_) => "Actions", + ChatMessageContent::File(_) => "File", + ChatMessageContent::Reaction(_) => "Reaction", + ChatMessageContent::ReactionRemoved(_) => "ReactionRemoved", + ChatMessageContent::Custom(_) => "Custom", + } +} + +#[async_trait] +impl ChatPlatform for CliChatHost { + async fn create_chat_room( + &self, + _product: &ProductContext, + request: HostChatCreateRoomRequest, + ) -> Result { + let mut state = self.lock(); + let status = if state.rooms.contains_key(&request.room_id) { + ChatRoomRegistrationStatus::Exists + } else { + state + .rooms + .insert(request.room_id.clone(), ChatRoomParticipation::RoomHost); + Self::republish(&mut state); + ChatRoomRegistrationStatus::New + }; + drop(state); + self.record_registration("room", &request.room_id, &request.name, &request.icon); + Ok(HostChatCreateRoomResponse { status }) + } + + async fn register_chat_bot( + &self, + _product: &ProductContext, + request: HostChatRegisterBotRequest, + ) -> Result { + let mut state = self.lock(); + let status = if state.bots.insert(request.bot_id.clone()) { + ChatBotRegistrationStatus::New + } else { + ChatBotRegistrationStatus::Exists + }; + drop(state); + self.record_registration("bot", &request.bot_id, &request.name, &request.icon); + Ok(HostChatRegisterBotResponse { status }) + } + + async fn post_chat_message( + &self, + _product: &ProductContext, + request: HostChatPostMessageRequest, + ) -> Result { + let mut state = self.lock(); + if !state.rooms.contains_key(&request.room_id) { + // A room this host never created is not one it can store against. + return Err(HostChatPostMessageError::Unknown { + reason: format!("unknown room {:?}", request.room_id), + }); + } + state.accepted += 1; + let message_id = format!("m{}", state.accepted); + drop(state); + self.record_message(&message_id, &request); + Ok(HostChatPostMessageResponse { message_id }) + } + + fn subscribe_chat_rooms( + &self, + _product: &ProductContext, + ) -> BoxStream<'static, Result> { + let mut state = self.lock(); + let snapshot = Self::room_list(&state); + let (sender, receiver) = mpsc::unbounded(); + state.subscribers.push(sender); + // The snapshot first, then every replacement, so a product that + // subscribes before creating a room still sees the room it creates. + stream::once(async move { Ok(snapshot) }) + .chain(receiver.map(Ok)) + .boxed() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::read_to_string; + + fn product() -> ProductContext { + ProductContext::new("chat.dot".to_string()).expect("valid product id") + } + + fn text(text: &str) -> ChatMessageContent { + ChatMessageContent::Text { + text: text.to_string(), + } + } + + fn room(room_id: &str) -> HostChatCreateRoomRequest { + HostChatCreateRoomRequest { + room_id: room_id.to_string(), + name: "Support".to_string(), + icon: String::new(), + } + } + + #[test] + fn a_message_needs_a_room_this_host_created() { + let transcript = tempfile::NamedTempFile::new().expect("a temp transcript"); + let host = CliChatHost::new(Some(transcript.path().to_path_buf())); + + let posted = futures::executor::block_on(host.post_chat_message( + &product(), + HostChatPostMessageRequest { + room_id: "support".to_string(), + payload: text("hello"), + }, + )); + + assert!(matches!( + posted, + Err(HostChatPostMessageError::Unknown { .. }) + )); + // A refused message is not one this host was willing to store, so it + // must not appear in the record a battery reads. + assert_eq!( + read_to_string(transcript.path()).expect("the transcript is readable"), + "" + ); + } + + #[test] + fn a_stored_message_is_recorded_as_the_host_received_it() { + let transcript = tempfile::NamedTempFile::new().expect("a temp transcript"); + let host = CliChatHost::new(Some(transcript.path().to_path_buf())); + futures::executor::block_on(host.create_chat_room(&product(), room("support"))) + .expect("a new room is created"); + + let payload = text("line one\nline two"); + let posted = futures::executor::block_on(host.post_chat_message( + &product(), + HostChatPostMessageRequest { + room_id: "support".to_string(), + payload: payload.clone(), + }, + )) + .expect("a message posts into a room this host created"); + + assert_eq!(posted.message_id, "m1"); + let transcript_text = + read_to_string(transcript.path()).expect("the transcript is readable"); + let recorded: serde_json::Value = serde_json::from_str( + transcript_text + .lines() + .next_back() + .expect("the message is the last line, after the room"), + ) + .expect("each line is one JSON object"); + assert_eq!(recorded["kind"], "message"); + assert_eq!(recorded["messageId"], "m1"); + assert_eq!(recorded["roomId"], "support"); + assert_eq!(recorded["variant"], "Text"); + // The payload as bytes, so a difference between what a product sent + // and what the host received cannot hide behind a rendering. + assert_eq!(recorded["payload"], hex::encode(payload.encode())); + } + + #[test] + fn a_room_appears_in_the_list_a_subscriber_already_holds() { + let host = CliChatHost::new(None); + let mut rooms = host.subscribe_chat_rooms(&product()); + + let snapshot = futures::executor::block_on(rooms.next()) + .expect("a subscription emits its snapshot") + .expect("the snapshot is not an error"); + assert!(snapshot.rooms.is_empty()); + + futures::executor::block_on(host.create_chat_room(&product(), room("support"))) + .expect("a new room is created"); + + let replacement = futures::executor::block_on(rooms.next()) + .expect("creating a room republishes the list") + .expect("the replacement is not an error"); + assert_eq!(replacement.rooms.len(), 1); + assert_eq!(replacement.rooms[0].room_id, "support"); + } +} diff --git a/rust/crates/truapi-host-cli/src/frame_server.rs b/rust/crates/truapi-host-cli/src/frame_server.rs index fb8186284..9dd06e0e9 100644 --- a/rust/crates/truapi-host-cli/src/frame_server.rs +++ b/rust/crates/truapi-host-cli/src/frame_server.rs @@ -19,6 +19,7 @@ use tokio::sync::{mpsc, watch}; use tokio_tungstenite::accept_async; use tokio_tungstenite::tungstenite::Message; use tracing::{debug, warn}; +use truapi_platform::ProductExecutionKind; use truapi_server::{ FrameSink, PairingHostRuntime, ProductContext, ProductRuntime, SigningHostRuntime, }; @@ -35,15 +36,22 @@ const ACCEPT_RETRY_DELAY: Duration = Duration::from_millis(50); /// Process-local product selection shared by the command loop and frame server. pub struct ProductSelection { current: watch::Sender, + /// Execution kind every selection keeps. A host serves one kind for its + /// lifetime: the core reads it per connection, and chat is denied to a + /// connection that opened as `Spa`. + execution_kind: ProductExecutionKind, } impl ProductSelection { /// Validate and normalize the initial product id. - pub fn new(product_id: String) -> Result> { - let product = ProductContext::new(product_id) + pub fn new(product_id: String, execution_kind: ProductExecutionKind) -> Result> { + let product = ProductContext::new_with_execution(product_id, execution_kind) .map_err(|error| anyhow::anyhow!("invalid product id: {error}"))?; let (current, _) = watch::channel(product); - Ok(Arc::new(Self { current })) + Ok(Arc::new(Self { + current, + execution_kind, + })) } /// Return the normalized current product id. @@ -53,7 +61,7 @@ impl ProductSelection { /// Select a validated product, returning whether the selection changed. pub fn select(&self, product_id: String) -> Result { - let product = ProductContext::new(product_id) + let product = ProductContext::new_with_execution(product_id, self.execution_kind) .map_err(|error| anyhow::anyhow!("invalid product id: {error}"))?; Ok(self.current.send_if_modified(|current| { if current == &product { @@ -361,7 +369,7 @@ mod tests { #[test] fn product_selection_validates_and_normalizes_ids() -> Result<()> { - let product = ProductSelection::new(" Dotli.DOT ".to_string())?; + let product = ProductSelection::new(" Dotli.DOT ".to_string(), ProductExecutionKind::Spa)?; assert_eq!(product.current(), "dotli.dot"); assert!(product.select("localhost:3000".to_string())?); @@ -373,7 +381,7 @@ mod tests { #[tokio::test] async fn changing_product_notifies_connections() -> Result<()> { - let product = ProductSelection::new("first.dot".to_string())?; + let product = ProductSelection::new("first.dot".to_string(), ProductExecutionKind::Spa)?; let mut connection = product.subscribe(); assert!(product.select("second.dot".to_string())?); diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index 4c05116f3..09d5ff779 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -14,6 +14,7 @@ mod accounts; mod attestation; mod chain; +mod chat; mod frame_server; mod network; mod platform; @@ -36,7 +37,7 @@ use futures::future::BoxFuture; use tracing_subscriber::Layer; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; -use truapi_platform::{HostInfo, PlatformInfo}; +use truapi_platform::{ChatPlatform, HostInfo, PlatformInfo, ProductExecutionKind}; use truapi_server::statement_allowance as alloc; use truapi_server::subscription::Spawner; use truapi_server::{ @@ -205,8 +206,36 @@ enum Command { }, } +/// Execution kind the CLI serves a product as. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +enum ExecutionKind { + /// Ordinary product. Chat requests answer `Denied`, as they do on any + /// host that does not serve chat. + Spa, + /// Chat product, served by the CLI's in-memory chat host. + Chat, +} + +impl ExecutionKind { + fn context(self) -> ProductExecutionKind { + match self { + Self::Spa => ProductExecutionKind::Spa, + Self::Chat => ProductExecutionKind::Chat, + } + } + + /// The chat host to install, if this kind serves chat at all. + fn chat_host(self) -> Option> { + matches!(self, Self::Chat).then(chat::CliChatHost::from_env) + } +} + #[derive(Args)] struct PairingHostArgs { + /// Execution kind the served product runs as. `chat` installs the CLI's + /// in-memory chat host; `spa` leaves Chat unserved. + #[arg(long = "execution-kind", value_enum, default_value = "spa")] + execution_kind: ExecutionKind, /// Product script to run (JS/TS). If omitted, start the terminal UI. #[arg(long)] script: Option, @@ -230,6 +259,10 @@ struct PairingHostArgs { #[derive(Args)] struct SigningHostArgs { + /// Execution kind the served product runs as. `chat` installs the CLI's + /// in-memory chat host; `spa` leaves Chat unserved. + #[arg(long = "execution-kind", value_enum, default_value = "spa")] + execution_kind: ExecutionKind, /// Product script to run (JS/TS). If omitted, start an interactive shell. #[arg(long)] script: Option, @@ -753,7 +786,8 @@ async fn run_pairing_host( } let network = args.network.config(); let base_path = args.base_path.unwrap_or_else(default_base_path); - let product = frame_server::ProductSelection::new(args.product_id)?; + let product = + frame_server::ProductSelection::new(args.product_id, args.execution_kind.context())?; let product_id = product.current(); let storage_paths = CliStoragePaths::pairing(base_path.join(network.id)); let (terminal_ui, ui_handle) = if interactive { @@ -780,7 +814,13 @@ async fn run_pairing_host( ) .context("invalid pairing host config")?; let storage_platform = platform.clone(); - let pairing_runtime = Arc::new(PairingHostRuntime::new(platform, config, tokio_spawner())); + let chat_host = args.execution_kind.chat_host(); + let pairing_runtime = Arc::new(PairingHostRuntime::with_chat_platform( + platform, + config, + tokio_spawner(), + chat_host.map(|chat| chat as Arc), + )); let frame_server = frame_server::bind(args.frame_listen).await?; let frame_url = frame_server.endpoint().to_string(); @@ -848,7 +888,10 @@ async fn run_signing_host( let exec_command = exec_input .as_deref() .map(|input| parse_command(input).unwrap_or_else(|error| invalid_invocation(error))); - let product = frame_server::ProductSelection::new(args.product_id.clone())?; + let product = frame_server::ProductSelection::new( + args.product_id.clone(), + args.execution_kind.context(), + )?; let product_id = product.current(); let network = args.network.config(); let base_path = args.base_path.clone().unwrap_or_else(default_base_path); @@ -997,6 +1040,9 @@ struct SigningHostSession { lite_username_prefix: Option, approval: ApprovalPolicy, ui: Option, + /// Set when this host serves a chat product. Held across runtime rebuilds + /// so switching session keeps the rooms and messages already posted. + chat: Option>, } fn initial_session_name(args: &SigningHostArgs, catalog: &SessionCatalog) -> String { @@ -1078,12 +1124,14 @@ async fn start_signing_host( signer = Some(explicit_signer); } let approval = approval_policy(args.auto_accept); + let chat = args.execution_kind.chat_host(); let runtime = build_signing_runtime( network, storage_profile.path, storage_profile.product_storage_dir, approval, ui.clone(), + chat.clone(), )?; let runtime_factory = frame_server::SwitchableSigningRuntime::new(runtime.clone()); let last_script = profile @@ -1140,6 +1188,7 @@ async fn start_signing_host( lite_username_prefix: normalized(args.lite_username_prefix.clone()), approval, ui, + chat, }) } @@ -1149,6 +1198,7 @@ fn build_signing_runtime( product_storage_dir: PathBuf, approval: ApprovalPolicy, ui: Option, + chat: Option>, ) -> Result> { let platform = CliPlatform::new( network, @@ -1163,7 +1213,12 @@ fn build_signing_runtime( network.bulletin_genesis, ) .context("invalid signing host config")?; - let runtime = Arc::new(SigningHostRuntime::new(platform, config, tokio_spawner())); + let runtime = Arc::new(SigningHostRuntime::with_chat_platform( + platform, + config, + tokio_spawner(), + chat.map(|chat| chat as Arc), + )); runtime.start_statement_allowance_renewal(); Ok(runtime) } @@ -1325,6 +1380,7 @@ fn promote_current_profile(session: &mut SigningHostSession) -> Result<()> { promoted.product_storage_dir.clone(), session.approval, session.ui.clone(), + session.chat.clone(), )?; session.runtime_factory.replace(runtime.clone()); session.runtime = runtime; @@ -1684,6 +1740,7 @@ async fn switch_session(session: &mut SigningHostSession, name: String) -> Resul profile.product_storage_dir.clone(), session.approval, session.ui.clone(), + session.chat.clone(), )?; let available_sessions = session.catalog.list()?; diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 89cc125bc..7defcb6e2 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -14,6 +14,8 @@ //! Async capability traits use `async_trait` so the combined [`Platform`] //! surface can be used as a trait object by the runtime. +use std::collections::BTreeSet; + use futures::stream::BoxStream; use parity_scale_codec::{Decode, Encode}; use unicode_normalization::UnicodeNormalization; @@ -28,14 +30,15 @@ uniffi::use_remote_type!(truapi::Bytes32); use truapi::Bytes32; use truapi::latest::{ - AllocatableResource, ChainIdentifier, GenericError, HostChatCreateRoomError, - HostChatCreateRoomRequest, HostChatCreateRoomResponse, HostChatListSubscribeItem, - HostChatPostMessageError, HostChatPostMessageRequest, HostChatPostMessageResponse, - HostChatRegisterBotError, HostChatRegisterBotRequest, HostChatRegisterBotResponse, - HostDevicePermissionRequest, HostDevicePermissionResponse, HostFeatureSupportedRequest, - HostFeatureSupportedResponse, HostLocalStorageReadError, HostNavigateToError, - HostPushNotificationRequest, HostPushNotificationResponse, HostSignPayloadRequest, - HostSignPayloadWithLegacyAccountRequest, HostSignRawRequest, + AllocatableResource, ChainIdentifier, ChatAction, ChatActions, ChatCustomMessage, ChatFile, + ChatMedia, ChatMessageContent, ChatReaction, ChatRichText, GenericError, + HostChatCreateRoomError, HostChatCreateRoomRequest, HostChatCreateRoomResponse, + HostChatListSubscribeItem, HostChatPostMessageError, HostChatPostMessageRequest, + HostChatPostMessageResponse, HostChatRegisterBotError, HostChatRegisterBotRequest, + HostChatRegisterBotResponse, HostDevicePermissionRequest, HostDevicePermissionResponse, + HostFeatureSupportedRequest, HostFeatureSupportedResponse, HostLocalStorageReadError, + HostNavigateToError, HostPushNotificationRequest, HostPushNotificationResponse, + HostSignPayloadRequest, HostSignPayloadWithLegacyAccountRequest, HostSignRawRequest, HostSignRawWithLegacyAccountRequest, HostThemeSubscribeItem, LegacyAccountTxPayload, NotificationId, ProductAccountId, ProductAccountTxPayload, ProductProofContext, RemotePermission, RemotePermissionRequest, RemotePermissionResponse, RingLocation, @@ -336,6 +339,267 @@ fn normalize_chat_text(field: &'static str, value: &str) -> Result Result { + use ChatMessageContent as Content; + Ok(match content { + Content::Text { text } => Content::Text { + text: validate_chat_body("text", &text)?, + }, + Content::RichText(rich) => Content::RichText(ChatRichText { + text: rich + .text + .map(|t| validate_chat_body("text", &t)) + .transpose()?, + media: validate_chat_media("media", rich.media)?, + }), + Content::Actions(actions) => { + if actions.actions.len() > CHAT_ACTIONS_MAX { + return Err(ChatFieldError::TooMany { + field: "actions", + limit: CHAT_ACTIONS_MAX, + }); + } + Content::Actions(ChatActions { + text: actions + .text + .map(|t| validate_chat_body("text", &t)) + .transpose()?, + actions: validate_chat_actions(actions.actions)?, + layout: actions.layout, + }) + } + Content::File(file) => Content::File(ChatFile { + url: validate_chat_url("url", &file.url)?, + file_name: validate_chat_file_name("fileName", &file.file_name)?, + mime_type: validate_chat_name("mimeType", &file.mime_type)?, + size_bytes: file.size_bytes, + text: file + .text + .map(|t| validate_chat_body("text", &t)) + .transpose()?, + }), + Content::Reaction(reaction) => Content::Reaction(validate_chat_reaction(reaction)?), + Content::ReactionRemoved(reaction) => { + Content::ReactionRemoved(validate_chat_reaction(reaction)?) + } + Content::Custom(custom) => { + if custom.payload.len() > CHAT_CUSTOM_PAYLOAD_MAX_BYTES { + return Err(ChatFieldError::TooLong { + field: "payload", + limit: CHAT_CUSTOM_PAYLOAD_MAX_BYTES, + }); + } + Content::Custom(ChatCustomMessage { + message_type: normalize_chat_identifier("messageType", &custom.message_type)?, + payload: custom.payload, + }) + } + }) +} + +/// Validate a product-supplied file name. +/// +/// Screened as a display name, because a bidi override reverses the extension a +/// host shows on a download affordance, and additionally as a path component: +/// a host that joins this onto a cache directory must not be handed separators +/// or a parent reference. +fn validate_chat_file_name(field: &'static str, name: &str) -> Result { + let validated = validate_chat_name(field, name)?; + if validated.is_empty() { + return Err(ChatFieldError::Empty { field }); + } + if validated.contains(['/', '\\', ':']) + || validated == ".." + || validated == "." + || validated.starts_with("..") + { + return Err(ChatFieldError::PathComponent { field }); + } + Ok(validated) +} + +/// Validate one message's action buttons. +/// +/// Ids are normalized, which can map two spellings onto one key, so the +/// normalized set is checked for collisions: a product shipping both `approve` +/// and ` approve ` would otherwise get one button, and a trigger naming that +/// key could not say which was pressed. +fn validate_chat_actions(actions: Vec) -> Result, ChatFieldError> { + let mut seen = BTreeSet::new(); + actions + .into_iter() + .map(|action| { + let action_id = normalize_chat_identifier("actionId", &action.action_id)?; + if !seen.insert(action_id.clone()) { + return Err(ChatFieldError::Duplicate { field: "actionId" }); + } + Ok(ChatAction { + action_id, + title: validate_chat_name("title", &action.title)?, + }) + }) + .collect() +} + +/// Validate a reaction: the message it names is an identifier, matched rather +/// than read, so it is screened like one. +fn validate_chat_reaction(reaction: ChatReaction) -> Result { + Ok(ChatReaction { + message_id: normalize_chat_identifier("messageId", &reaction.message_id)?, + emoji: validate_chat_emoji("emoji", &reaction.emoji)?, + }) +} + +fn validate_chat_media( + field: &'static str, + media: Vec, +) -> Result, ChatFieldError> { + if media.len() > CHAT_MEDIA_MAX { + return Err(ChatFieldError::TooMany { + field, + limit: CHAT_MEDIA_MAX, + }); + } + media + .into_iter() + .map(|item| { + Ok(ChatMedia { + url: validate_chat_url("url", &item.url)?, + }) + }) + .collect() +} + +/// Bound and screen a product-authored message body. +/// +/// A body is opaque content rather than a label, so unlike a name it is +/// neither trimmed nor NFC-normalized: a product that hashes, signs or +/// echo-compares what it sent reads back the same bytes, and leading +/// indentation in a code block survives. +fn validate_chat_body(field: &'static str, value: &str) -> Result { + if value.len() > CHAT_BODY_MAX_BYTES { + return Err(ChatFieldError::TooLong { + field, + limit: CHAT_BODY_MAX_BYTES, + }); + } + if value.chars().any(is_body_unsafe) { + return Err(ChatFieldError::UnsafeCharacter { field }); + } + Ok(value.to_string()) +} + +/// Bound and screen a product-supplied reaction emoji. +fn validate_chat_emoji(field: &'static str, value: &str) -> Result { + let normalized = value.trim().nfc().collect::(); + if normalized.len() > CHAT_FIELD_MAX_BYTES { + return Err(ChatFieldError::TooLong { + field, + limit: CHAT_FIELD_MAX_BYTES, + }); + } + if normalized.chars().any(is_emoji_unsafe) { + return Err(ChatFieldError::UnsafeCharacter { field }); + } + Ok(normalized) +} + +/// Resolve a product-supplied `https` URL to what a host will actually be +/// handed, or reject it. +/// +/// Both chat URL fields route through here so the budget is always measured +/// against the resolved string. The parser percent-encodes, and a non-ASCII +/// path triples in the process, so a value that arrives inside its cap can +/// leave well past it. +/// +/// Credentials are refused rather than carried: `Url::to_string` keeps +/// `user:pass@`, so a host handed one would fetch with them and log them. +/// +/// Which hosts are reachable is deliberately not decided here. See +/// [`validate_chat_url`]. +fn resolve_chat_https( + field: &'static str, + trimmed: &str, + limit: usize, +) -> Result { + let parsed = Url::parse(trimmed) + .ok() + .filter(|parsed| parsed.scheme() == "https") + .ok_or(ChatFieldError::RejectedScheme { field })?; + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(ChatFieldError::Credentials { field }); + } + // The resolved target, not the arriving string: a host renders what the + // core validated, and the budget applies to what the host receives. + let resolved = parsed.to_string(); + if resolved.len() > limit { + return Err(ChatFieldError::TooLong { field, limit }); + } + Ok(resolved) +} + +/// Validate a URL a host may fetch or open. +/// +/// The same allowlist [`validate_chat_icon`] applies, for the same reason: a +/// URL parser reaches a scheme through whitespace, tabs and NUL that a prefix +/// comparison does not, so `javascript:` and `file:` must be excluded by what +/// is permitted rather than by what is named. +fn validate_chat_url(field: &'static str, url: &str) -> Result { + let trimmed = url.trim(); + if trimmed.is_empty() { + return Err(ChatFieldError::Empty { field }); + } + // Screened before parsing: the parser drops tabs and newlines, so a string + // it accepts is not the string a host would render. + if trimmed.chars().any(is_display_unsafe) { + return Err(ChatFieldError::UnsafeCharacter { field }); + } + match icon_scheme(trimmed).as_deref() { + Some("https") => resolve_chat_https(field, trimmed, CHAT_URL_MAX_BYTES), + // An inline image is measured against the icon budget; the link cap + // would leave `data:` accepted but too small to carry an image. + Some("data") if is_allowed_icon_data_url(trimmed) => { + if trimmed.len() > CHAT_ICON_MAX_BYTES { + return Err(ChatFieldError::TooLong { + field, + limit: CHAT_ICON_MAX_BYTES, + }); + } + Ok(trimmed.to_string()) + } + _ => Err(ChatFieldError::RejectedScheme { field }), + } +} + /// Validate a product-supplied chat icon: absent, an `https` URL, or an inline /// image in [`ALLOWED_ICON_DATA_TYPES`]. /// @@ -352,13 +616,12 @@ pub fn validate_chat_icon(field: &'static str, icon: &str) -> Result Url::parse(trimmed) - .ok() - .filter(|parsed| parsed.scheme() == "https") - .map(|_| trimmed.to_string()) - .ok_or(ChatFieldError::RejectedScheme { field }), + Some("https") => resolve_chat_https(field, trimmed, CHAT_ICON_MAX_BYTES), Some("data") if is_allowed_icon_data_url(trimmed) => Ok(trimmed.to_string()), _ => Err(ChatFieldError::RejectedScheme { field }), } @@ -415,6 +678,25 @@ fn is_identifier_unsafe(character: char) -> bool { ) || (character.is_whitespace() && character != ' ') } +/// Line breaks and tabs a message body legitimately carries. A body is written +/// text, so these are content; every other character +/// [`is_display_unsafe`] rejects still applies. +fn is_body_unsafe(character: char) -> bool { + if matches!(character, '\n' | '\r' | '\t') { + return false; + } + is_display_unsafe(character) +} + +/// Tag characters encode the subdivision flags, so a picked reaction keeps +/// them where a display label would not. +fn is_emoji_unsafe(character: char) -> bool { + if matches!(character, '\u{e0020}'..='\u{e007f}') { + return false; + } + is_display_unsafe(character) +} + /// Control characters and bidi overrides let two distinct values render alike. fn is_display_unsafe(character: char) -> bool { character.is_control() @@ -422,6 +704,7 @@ fn is_display_unsafe(character: char) -> bool { character, '\u{200b}' | '\u{061c}' + | '\u{2028}' | '\u{2029}' // line and paragraph separators | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' | '\u{feff}' @@ -438,6 +721,35 @@ pub enum ChatFieldError { /// Offending field name. field: &'static str, }, + /// Two entries resolved to the same value, so neither can be addressed. + #[display("{field} must not repeat a value")] + Duplicate { + /// Offending field name. + field: &'static str, + }, + /// The URL carried credentials, which a host would fetch and log with. + #[display("{field} must not carry credentials")] + Credentials { + /// Offending field name. + field: &'static str, + }, + /// The field names a path rather than one file. Reported separately from + /// [`Self::UnsafeCharacter`] because a separator or a parent reference is + /// neither: a product told its file name carries control characters would + /// go looking for one that is not there. + #[display("{field} must name a single file, not a path")] + PathComponent { + /// Offending field name. + field: &'static str, + }, + /// The field carried more entries than are accepted. + #[display("{field} must not carry more than {limit} entries")] + TooMany { + /// Offending field name. + field: &'static str, + /// Largest accepted number of entries. + limit: usize, + }, /// The field exceeded its byte budget. #[display("{field} must be at most {limit} bytes")] TooLong { @@ -1059,6 +1371,539 @@ fn canonical_remote_request(request: &RemotePermissionRequest) -> RemotePermissi mod tests { use super::*; + fn file_with_url(url: &str) -> ChatMessageContent { + ChatMessageContent::File(ChatFile { + url: url.to_string(), + file_name: "report.pdf".to_string(), + mime_type: "application/pdf".to_string(), + size_bytes: 1, + text: None, + }) + } + + #[test] + fn a_message_url_is_screened_like_an_icon() { + // The icon field already rejects these. A file card is fetched or + // opened the same way, so the same allowlist applies. + for hostile in [ + "javascript:alert(document.cookie)", + "file:///etc/passwd", + "content://com.host.provider/secret", + "data:image/svg+xml,", + ] { + assert_eq!( + validate_chat_message_content(file_with_url(hostile)), + Err(ChatFieldError::RejectedScheme { field: "url" }), + "{hostile} must be rejected" + ); + } + + // Characters a URL parser drops are rejected before it runs, so the + // string a host renders is the string the scheme check ran against. + for smuggled in [ + "java\tscript:alert(1)", + "\u{0}javascript:alert(1)", + "https:/\t/evil.invalid/x", + "https://\u{200b}evil.invalid/x", + "https://example.invalid/\u{202e}gpj.exe", + ] { + assert_eq!( + validate_chat_message_content(file_with_url(smuggled)), + Err(ChatFieldError::UnsafeCharacter { field: "url" }), + "{smuggled} must be rejected" + ); + } + + // What reaches the host is what the parser resolved. + assert_eq!( + validate_chat_message_content(file_with_url("https://example.invalid")), + Ok(file_with_url("https://example.invalid/")) + ); + } + + #[test] + fn a_file_name_cannot_reverse_its_own_extension() { + // A bidi override renders `invoicegnp.exe` as `invoiceexe.png` + // on the download affordance the host draws. + let spoofed = ChatMessageContent::File(ChatFile { + url: "https://example.invalid/f".to_string(), + file_name: "invoice\u{202e}gnp.exe".to_string(), + mime_type: "application/pdf".to_string(), + size_bytes: 1, + text: None, + }); + assert_eq!( + validate_chat_message_content(spoofed), + Err(ChatFieldError::UnsafeCharacter { field: "fileName" }) + ); + } + + #[test] + fn message_content_is_bounded_by_count_and_by_bytes() { + let too_many = ChatMessageContent::Actions(ChatActions { + text: None, + actions: (0..CHAT_ACTIONS_MAX + 1) + .map(|index| ChatAction { + action_id: format!("a{index}"), + title: "go".to_string(), + }) + .collect(), + layout: truapi::latest::ChatActionLayout::Column, + }); + assert_eq!( + validate_chat_message_content(too_many), + Err(ChatFieldError::TooMany { + field: "actions", + limit: CHAT_ACTIONS_MAX, + }) + ); + + let too_long = ChatMessageContent::Text { + text: "x".repeat(CHAT_BODY_MAX_BYTES + 1), + }; + assert_eq!( + validate_chat_message_content(too_long), + Err(ChatFieldError::TooLong { + field: "text", + limit: CHAT_BODY_MAX_BYTES, + }) + ); + + let too_much_media = ChatMessageContent::RichText(ChatRichText { + text: None, + media: (0..CHAT_MEDIA_MAX + 1) + .map(|_| ChatMedia { + url: "https://example.invalid/m".to_string(), + }) + .collect(), + }); + assert_eq!( + validate_chat_message_content(too_much_media), + Err(ChatFieldError::TooMany { + field: "media", + limit: CHAT_MEDIA_MAX, + }) + ); + } + + #[test] + fn a_message_body_carries_the_text_a_person_typed() { + // A chat message is written text: line breaks and tabs are content, + // and the bytes must survive so a product can echo-compare them. + let markdown = "# Report\n\n| a | b |\n| - | - |\n\tindented\n"; + assert_eq!( + validate_chat_message_content(ChatMessageContent::Text { + text: markdown.to_string(), + }), + Ok(ChatMessageContent::Text { + text: markdown.to_string(), + }) + ); + + // Neither trimmed nor NFC-normalized. + let unnormalized = " cafe\u{301} "; + assert_eq!( + validate_chat_message_content(ChatMessageContent::Text { + text: unnormalized.to_string(), + }), + Ok(ChatMessageContent::Text { + text: unnormalized.to_string(), + }) + ); + + // The bidi and zero-width screen still applies. + assert_eq!( + validate_chat_message_content(ChatMessageContent::Text { + text: "pay \u{202e}yletamitigel".to_string(), + }), + Err(ChatFieldError::UnsafeCharacter { field: "text" }) + ); + } + + #[test] + fn a_reaction_keeps_the_emoji_a_person_picked() { + // Subdivision flags encode as tag characters, which a display label + // rejects and a picked glyph must not. + for emoji in [ + "\u{1f3f4}\u{e0067}\u{e0062}\u{e0073}\u{e0063}\u{e0074}\u{e007f}", + "\u{1f468}\u{200d}\u{1f469}\u{200d}\u{1f467}", + "\u{2764}\u{fe0f}", + "\u{1f1ee}\u{1f1f3}", + ] { + assert_eq!( + validate_chat_message_content(ChatMessageContent::Reaction(ChatReaction { + message_id: "message-1".to_string(), + emoji: emoji.to_string(), + })), + Ok(ChatMessageContent::Reaction(ChatReaction { + message_id: "message-1".to_string(), + emoji: emoji.to_string(), + })), + "{emoji:?} must survive" + ); + } + } + + #[test] + fn every_screened_field_rejects_its_own_hostile_value() { + // One case per screen, so removing any single one fails a test. + let media = ChatMessageContent::RichText(ChatRichText { + text: None, + media: vec![ChatMedia { + url: "javascript:alert(1)".to_string(), + }], + }); + assert_eq!( + validate_chat_message_content(media), + Err(ChatFieldError::RejectedScheme { field: "url" }) + ); + + let action = ChatMessageContent::Actions(ChatActions { + text: None, + actions: vec![ChatAction { + action_id: "app\u{200d}rove".to_string(), + title: "Approve".to_string(), + }], + layout: truapi::latest::ChatActionLayout::Column, + }); + assert_eq!( + validate_chat_message_content(action), + Err(ChatFieldError::UnsafeCharacter { field: "actionId" }) + ); + + let mime = ChatMessageContent::File(ChatFile { + url: "https://example.invalid/f".to_string(), + file_name: "f".to_string(), + mime_type: "text/\u{202e}nialp".to_string(), + size_bytes: 1, + text: None, + }); + assert_eq!( + validate_chat_message_content(mime), + Err(ChatFieldError::UnsafeCharacter { field: "mimeType" }) + ); + + let custom_type = ChatMessageContent::Custom(ChatCustomMessage { + message_type: " ".to_string(), + payload: Vec::new(), + }); + assert_eq!( + validate_chat_message_content(custom_type), + Err(ChatFieldError::Empty { + field: "messageType" + }) + ); + + let payload = ChatMessageContent::Custom(ChatCustomMessage { + message_type: "vote".to_string(), + payload: vec![0; CHAT_CUSTOM_PAYLOAD_MAX_BYTES + 1], + }); + assert_eq!( + validate_chat_message_content(payload), + Err(ChatFieldError::TooLong { + field: "payload", + limit: CHAT_CUSTOM_PAYLOAD_MAX_BYTES, + }) + ); + + let emoji = ChatMessageContent::Reaction(ChatReaction { + message_id: "message-1".to_string(), + emoji: "\u{202e}".to_string(), + }); + assert_eq!( + validate_chat_message_content(emoji), + Err(ChatFieldError::UnsafeCharacter { field: "emoji" }) + ); + + // The removal variant screens the same fields as the addition. + let removed = ChatMessageContent::ReactionRemoved(ChatReaction { + message_id: " ".to_string(), + emoji: "\u{1f3b2}".to_string(), + }); + assert_eq!( + validate_chat_message_content(removed), + Err(ChatFieldError::Empty { field: "messageId" }) + ); + + // Every optional body, not just the one `Text` carries. + let bidi = "pay \u{202e}yletamitigel".to_string(); + let bodies = [ + ChatMessageContent::RichText(ChatRichText { + text: Some(bidi.clone()), + media: Vec::new(), + }), + ChatMessageContent::Actions(ChatActions { + text: Some(bidi.clone()), + actions: Vec::new(), + layout: truapi::latest::ChatActionLayout::Column, + }), + ChatMessageContent::File(ChatFile { + url: "https://example.invalid/f".to_string(), + file_name: "f".to_string(), + mime_type: "text/plain".to_string(), + size_bytes: 1, + text: Some(bidi), + }), + ]; + for body in bodies { + assert_eq!( + validate_chat_message_content(body.clone()), + Err(ChatFieldError::UnsafeCharacter { field: "text" }), + "{body:?} must screen its body" + ); + } + + let title = ChatMessageContent::Actions(ChatActions { + text: None, + actions: vec![ChatAction { + action_id: "approve".to_string(), + title: "Approve\u{202e}".to_string(), + }], + layout: truapi::latest::ChatActionLayout::Column, + }); + assert_eq!( + validate_chat_message_content(title), + Err(ChatFieldError::UnsafeCharacter { field: "title" }) + ); + } + + #[test] + fn an_icon_is_screened_and_resolved_like_a_message_url() { + // `validate_chat_icon` shares the url path's screen and resolution, and + // is reached from `create_room` and `register_bot` rather than here. + assert_eq!( + validate_chat_icon("icon", "https://example.invalid/\u{202e}gpj.exe"), + Err(ChatFieldError::UnsafeCharacter { field: "icon" }) + ); + assert_eq!( + validate_chat_icon("icon", "https://example.invalid").unwrap(), + "https://example.invalid/" + ); + assert_eq!(validate_chat_icon("icon", " ").unwrap(), ""); + } + + #[test] + fn a_name_cannot_break_its_own_line() { + // U+2028 and U+2029 are Zl/Zp, not Cc, so `char::is_control` misses + // them -- yet they break a line exactly like the `\n` this rejects, + // which is what hides an extension on a one-line download affordance. + for separator in ['\u{2028}', '\u{2029}'] { + let spoofed = ChatMessageContent::File(ChatFile { + url: "https://example.invalid/f".to_string(), + file_name: format!("invoice.pdf{separator} .exe"), + mime_type: "application/pdf".to_string(), + size_bytes: 1, + text: None, + }); + assert_eq!( + validate_chat_message_content(spoofed), + Err(ChatFieldError::UnsafeCharacter { field: "fileName" }), + "{separator:?} must be rejected in a name" + ); + } + } + + #[test] + fn a_url_budget_applies_to_what_the_host_receives() { + // Resolution percent-encodes, so a URL measured on arrival can land + // nearly three times over budget. + // Each of these is 3 bytes raw and 9 percent-encoded, so the arriving + // string fits the budget and the resolved one does not. + let padded = format!("https://example.invalid/{}", "\u{4e00}".repeat(300)); + assert!(padded.len() <= CHAT_URL_MAX_BYTES); + assert_eq!( + validate_chat_message_content(file_with_url(&padded)), + Err(ChatFieldError::TooLong { + field: "url", + limit: CHAT_URL_MAX_BYTES, + }) + ); + } + + #[test] + fn action_ids_that_normalize_alike_are_rejected() { + // Normalization maps these onto one key. Accepting both would give the + // user two buttons whose trigger the product cannot tell apart. + let colliding = ChatMessageContent::Actions(ChatActions { + text: None, + actions: vec![ + ChatAction { + action_id: "approve".to_string(), + title: "Approve".to_string(), + }, + ChatAction { + action_id: " approve ".to_string(), + title: "Reject".to_string(), + }, + ], + layout: truapi::latest::ChatActionLayout::Column, + }); + assert_eq!( + validate_chat_message_content(colliding), + Err(ChatFieldError::Duplicate { field: "actionId" }) + ); + + // A literal repeat is the same defect without the normalization step. + let repeated = ChatMessageContent::Actions(ChatActions { + text: None, + actions: vec![ + ChatAction { + action_id: "approve".to_string(), + title: "Approve".to_string(), + }, + ChatAction { + action_id: "approve".to_string(), + title: "Reject".to_string(), + }, + ], + layout: truapi::latest::ChatActionLayout::Column, + }); + assert_eq!( + validate_chat_message_content(repeated), + Err(ChatFieldError::Duplicate { field: "actionId" }) + ); + } + + #[test] + fn a_file_name_cannot_address_a_path() { + // A host joining this onto a cache directory must not be handed a + // separator or a parent reference. + for traversal in [ + "../../../../data/data/io.parity.wallet/files/session.json", + "..", + "a/b.txt", + "a\\b.txt", + "C:evil.exe", + ] { + assert_eq!( + validate_chat_message_content(ChatMessageContent::File(ChatFile { + url: "https://example.invalid/f".to_string(), + file_name: traversal.to_string(), + mime_type: "application/pdf".to_string(), + size_bytes: 1, + text: None, + })), + Err(ChatFieldError::PathComponent { field: "fileName" }), + "{traversal:?} must be rejected" + ); + } + } + + #[test] + fn a_url_budget_is_measured_after_resolution_on_every_field() { + // Percent-encoding grows a non-ASCII path threefold, so a value that + // arrives inside its cap can leave well past it. Measuring the arriving + // string alone let an icon through at three times its budget. + let long_path = "\u{00e9}".repeat(CHAT_ICON_MAX_BYTES / 4); + let icon = format!("https://icons.invalid/{long_path}"); + assert!(icon.len() <= CHAT_ICON_MAX_BYTES, "arrives inside its cap"); + assert!( + Url::parse(&icon) + .expect("a parsable https url") + .to_string() + .len() + > CHAT_ICON_MAX_BYTES, + "resolves past it" + ); + + assert_eq!( + validate_chat_icon("icon", &icon), + Err(ChatFieldError::TooLong { + field: "icon", + limit: CHAT_ICON_MAX_BYTES, + }) + ); + + let message_url = format!( + "https://files.invalid/{}", + "\u{00e9}".repeat(CHAT_URL_MAX_BYTES / 4) + ); + assert!(message_url.len() <= CHAT_URL_MAX_BYTES); + assert_eq!( + validate_chat_url("url", &message_url), + Err(ChatFieldError::TooLong { + field: "url", + limit: CHAT_URL_MAX_BYTES, + }) + ); + } + + #[test] + fn a_url_must_not_carry_credentials() { + // `Url::to_string` keeps `user:pass@`, so a host handed this would + // fetch with the credentials and log them. + for field_url in [ + "https://user:pass@example.invalid/avatar.png", + "https://user@example.invalid/avatar.png", + ] { + assert_eq!( + validate_chat_icon("icon", field_url), + Err(ChatFieldError::Credentials { field: "icon" }), + "{field_url:?} must be rejected" + ); + assert_eq!( + validate_chat_url("url", field_url), + Err(ChatFieldError::Credentials { field: "url" }), + "{field_url:?} must be rejected" + ); + } + } + + #[test] + fn reachability_is_the_hosts_decision_and_the_docs_say_so() { + // Named rather than incidental: the trait doc tells a host these pass + // and that fetching them is its own call. A core that guessed would + // break a host serving its own media from localhost, so if this ever + // starts rejecting, the doc has to change with it. + for reachable_only_by_the_host in [ + "https://127.0.0.1:9944/rpc", + "https://[::1]/admin", + "https://169.254.169.254/latest/meta-data/", + "https://10.0.0.1/internal", + ] { + assert!( + validate_chat_url("url", reachable_only_by_the_host).is_ok(), + "{reachable_only_by_the_host:?} is the host's call, not the core's" + ); + } + } + + #[test] + fn the_published_limits_are_the_enforced_limits() { + // `Chat::post_message`'s doc states these numbers to products in prose, + // so a test asserting `CONST + 1` would let the constant drift away + // from the contract without failing. + assert_eq!(CHAT_BODY_MAX_BYTES, 16 * 1024); + assert_eq!(CHAT_URL_MAX_BYTES, 2048); + assert_eq!(CHAT_ACTIONS_MAX, 32); + assert_eq!(CHAT_MEDIA_MAX, 32); + assert_eq!(CHAT_CUSTOM_PAYLOAD_MAX_BYTES, 256 * 1024); + assert_eq!(CHAT_FIELD_MAX_BYTES, 256); + } + + #[test] + fn a_reaction_names_its_message_as_an_identifier() { + // `message_id` addresses a message the way `room_id` addresses a room, + // so it gets the identifier screen rather than the display one. + let confusable = ChatMessageContent::Reaction(ChatReaction { + message_id: "message\u{200d}-1".to_string(), + emoji: "\u{1f3b2}".to_string(), + }); + assert_eq!( + validate_chat_message_content(confusable), + Err(ChatFieldError::UnsafeCharacter { field: "messageId" }) + ); + + let blank = ChatMessageContent::Reaction(ChatReaction { + message_id: " ".to_string(), + emoji: "\u{1f3b2}".to_string(), + }); + assert_eq!( + validate_chat_message_content(blank), + Err(ChatFieldError::Empty { field: "messageId" }) + ); + } + #[test] fn auth_session_storage_key_has_stable_encoding() { assert_eq!(CoreStorageKey::AuthSession.encode(), [0]); @@ -1683,11 +2528,26 @@ pub trait PreimageHost: Send + Sync { /// storage and UI. Optional: a host that omits it leaves Chat requests /// answered `Unsupported`. See [`OptionalPlatform`]. /// -/// On `create_chat_room` and `register_chat_bot` the core bounds ids, names and -/// icons, NFC-normalizes them, screens control and bidi characters, and -/// restricts an icon to `https` or an inline raster image. Contextual output -/// escaping, storage limits, and every `post_chat_message` field remain -/// host-owned. +/// The core bounds and screens the product-supplied fields it forwards. Ids, +/// names and icons on `create_chat_room`, `register_chat_bot` and +/// `post_chat_message` are NFC-normalized and rejected for control and bidi +/// characters. Message bodies are bounded and screened but pass through +/// byte-for-byte, keeping line breaks and tabs, so a product reads back the +/// bytes it sent. Counts and byte budgets are enforced, and any URL a host may +/// fetch or open is restricted to `https` or an inline raster image and +/// delivered as the parser resolved it. +/// +/// The core screens a URL's shape, not its reachability. `https://127.0.0.1`, +/// `https://[::1]`, a private range and `https://169.254.169.254` (the cloud +/// metadata endpoint) all pass: which networks a host is willing to fetch from +/// depends on where that host runs, and a core that guessed would break a host +/// serving its own media from localhost. A host that fetches these URLs owns +/// that decision. Credentials are the exception and are refused, because +/// `user:pass@` survives resolution into whatever the host fetches and logs. +/// +/// `ChatFile::size_bytes` is a product assertion and is not verified against +/// the resource it names. Contextual output escaping, storage limits, and +/// anything a host derives from product-supplied values remain host-owned. #[async_trait] pub trait ChatPlatform: Send + Sync { /// Create or resolve a product-scoped native chat room. diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index fcd33d75e..34c729942 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -375,17 +375,37 @@ pub struct SigningHostRuntime { impl SigningHostRuntime { /// Build a long-lived signing-host runtime around a platform implementation. + /// Chat is answered `Unsupported`; [`Self::with_chat_platform`] serves it. #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.new"))] pub fn new

(platform: Arc

, config: SigningHostConfig, spawner: Spawner) -> Self + where + P: Platform + 'static, + { + Self::with_chat_platform(platform, config, spawner, None) + } + + /// Build a signing-host runtime that serves Chat through `chat_platform`. + /// + /// The pairing host has had this since chat reached the core; a signing + /// host needs it for the same reason a native host does, and without it no + /// runnable host in this repo can serve a chat product at all. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.with_chat_platform"))] + pub fn with_chat_platform

( + platform: Arc

, + config: SigningHostConfig, + spawner: Spawner, + chat_platform: Option>, + ) -> Self where P: Platform + 'static, { let platform: Arc = platform; - let services = RuntimeServices::new( + let services = RuntimeServices::with_chat_platform( platform, config.people_chain_genesis_hash, config.bulletin_chain_genesis_hash, spawner, + chat_platform, ); let signing_host = SigningHostRole::new(services.clone()); Self { diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index 702c9b4a7..0d22741c4 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -566,15 +566,17 @@ pub trait NativeChatCallbacks: Send + Sync { icon: String, ) -> Result; - /// Persist a text message in native Chat storage. - fn post_text_message(&self, room_id: String, text: String) -> Result; - - /// Persist a custom message in native Chat storage. - fn post_custom_message( + /// Persist a product-authored message in native Chat storage. A host that + /// cannot render a given content variant returns a rejection for it. + /// + /// The returned id is what [`ActionTrigger::message_id`] carries back, so + /// it must name this message for as long as the host stores it. + /// + /// [`ActionTrigger::message_id`]: truapi::latest::ActionTrigger + fn post_message( &self, room_id: String, - message_type: String, - payload: Vec, + content: v01::ChatMessageContent, ) -> Result; /// Return the current product-scoped native Chat room list. @@ -1891,23 +1893,12 @@ impl truapi_platform::ChatPlatform for ChatCallbackPlatform { _product: &ProductContext, request: v01::HostChatPostMessageRequest, ) -> Result { - let message_id = match request.payload { - v01::ChatMessageContent::Text { text } => { - self.chat.post_text_message(request.room_id, text) - } - v01::ChatMessageContent::Custom(custom) => { - self.chat - .post_custom_message(request.room_id, custom.message_type, custom.payload) - } - _ => { - return Err(v01::HostChatPostMessageError::Unknown { - reason: "native Chat adapter supports text and custom messages".to_string(), - }); - } - } - .map_err(|error| v01::HostChatPostMessageError::Unknown { - reason: error.to_string(), - })?; + let message_id = self + .chat + .post_message(request.room_id, request.payload) + .map_err(|error| v01::HostChatPostMessageError::Unknown { + reason: error.to_string(), + })?; Ok(v01::HostChatPostMessageResponse { message_id }) } @@ -2071,7 +2062,8 @@ mod tests { chat_bot_status: Mutex, chat_registered_bots: Mutex>, chat_bot_rejection: Mutex>, - chat_posted_text: Mutex>, + chat_post_rejection: Mutex>, + chat_posted: Mutex>, theme: Mutex, preimages: Mutex, auth_states: Mutex>, @@ -2089,7 +2081,8 @@ mod tests { chat_bot_status: Mutex::new(v01::ChatBotRegistrationStatus::New), chat_registered_bots: Mutex::new(Vec::new()), chat_bot_rejection: Mutex::new(None), - chat_posted_text: Mutex::new(Vec::new()), + chat_post_rejection: Mutex::new(None), + chat_posted: Mutex::new(Vec::new()), theme: Mutex::new(v01::HostThemeSubscribeItem { name: v01::ThemeName::Default, variant: v01::ThemeVariant::Light, @@ -2253,25 +2246,27 @@ mod tests { .expect("bot status mutex poisoned")) } - fn post_text_message( + fn post_message( &self, room_id: String, - text: String, + content: v01::ChatMessageContent, ) -> Result { - self.chat_posted_text + if let Some(reason) = self + .chat_post_rejection .lock() - .expect("posted text mutex poisoned") - .push((room_id, text)); - Ok("message-id".to_string()) - } - - fn post_custom_message( - &self, - _room_id: String, - _message_type: String, - _payload: Vec, - ) -> Result { - Ok("message-id".to_string()) + .expect("post rejection mutex poisoned") + .clone() + { + return Err(HostRejection::Rejected { reason }); + } + let mut posted = self + .chat_posted + .lock() + .expect("posted messages mutex poisoned"); + posted.push((room_id, content)); + // Distinct per message: a correlation assertion must not pass on a + // constant the host happens to return every time. + Ok(format!("message-{}", posted.len())) } fn list_rooms(&self) -> Result, HostRejection> { @@ -2558,9 +2553,7 @@ mod tests { .unwrap(); let mut stream = truapi_platform::ChatPlatform::subscribe_chat_rooms(&platform, &product); - let first = futures::executor::block_on(stream.next()) - .unwrap() - .expect("initial room list"); + let first = ready_rooms(stream.as_mut(), "initial room list"); events.notify_chat_rooms_changed(vec![v01::ChatRoom { room_id: "support".to_string(), participating_as: v01::ChatRoomParticipation::Bot, @@ -2578,8 +2571,29 @@ mod tests { ); } + /// What a room-list subscription yields: a replacement, or the host's + /// failure to produce one. + type RoomListItem = Result; + + /// Reads a room list the subscription has already emitted. + /// + /// Polled without blocking on purpose: both the eager snapshot and a + /// notified replacement are sent before this runs, so a subscription that + /// stopped emitting one fails here rather than parking on a live sender + /// and timing the job out. + fn ready_rooms( + mut rooms: core::pin::Pin<&mut (dyn futures::Stream + Send)>, + what: &str, + ) -> v01::HostChatListSubscribeItem { + let mut cx = core::task::Context::from_waker(futures::task::noop_waker_ref()); + match rooms.as_mut().poll_next(&mut cx) { + core::task::Poll::Ready(Some(Ok(item))) => item, + other => panic!("{what} must be ready, got {other:?}"), + } + } + #[test] - fn native_chat_adapter_rejects_the_message_variants_it_cannot_persist() { + fn native_chat_adapter_forwards_every_message_variant() { let callbacks = Arc::new(EventCallbacks::new()); let platform = ChatCallbackPlatform { chat: callbacks.clone(), @@ -2590,11 +2604,16 @@ mod tests { .unwrap(); let reaction = v01::ChatReaction { message_id: "message-1".to_string(), - emoji: "🎲".to_string(), + emoji: "\u{1f3b2}".to_string(), }; - // `NativeChatCallbacks` persists text and custom messages only; the - // rest must surface a typed error rather than be dropped. - let unsupported = [ + // Every variant the protocol advertises reaches the host, so a product + // can post the `Actions` that `action_subscribe` later reports back. + // An added variant stops `validate_chat_message_content` compiling, + // which is what forces this list to be revisited. + let variants = [ + v01::ChatMessageContent::Text { + text: "hello".to_string(), + }, v01::ChatMessageContent::RichText(v01::ChatRichText { text: None, media: Vec::new(), @@ -2613,30 +2632,165 @@ mod tests { }), v01::ChatMessageContent::Reaction(reaction.clone()), v01::ChatMessageContent::ReactionRemoved(reaction), + v01::ChatMessageContent::Custom(v01::ChatCustomMessage { + message_type: "vote".to_string(), + payload: vec![1, 2], + }), ]; - for payload in unsupported { - let error = - futures::executor::block_on(truapi_platform::ChatPlatform::post_chat_message( - &platform, - &product, - v01::HostChatPostMessageRequest { - room_id: "support".to_string(), - payload: payload.clone(), - }, - )) - .expect_err("the native adapter cannot persist this variant"); - assert!( - matches!(error, v01::HostChatPostMessageError::Unknown { .. }), - "{payload:?} must report a typed error" - ); + for payload in &variants { + futures::executor::block_on(truapi_platform::ChatPlatform::post_chat_message( + &platform, + &product, + v01::HostChatPostMessageRequest { + room_id: "support".to_string(), + payload: payload.clone(), + }, + )) + .unwrap_or_else(|error| panic!("{payload:?} must reach the host: {error:?}")); } + let posted = callbacks + .chat_posted + .lock() + .expect("posted messages mutex poisoned") + .clone(); + // Asserts the room alongside the content: routing the room correctly + // for `Text` while misrouting the rest must not pass. + assert_eq!( + posted, + variants + .iter() + .map(|content| ("support".to_string(), content.clone())) + .collect::>() + ); + } + + #[test] + fn a_posted_action_set_round_trips_to_the_product_that_posted_it() { + // The loop the widened variant set exists to enable: a product posts + // `Actions`, a user triggers one, and the product reads the trigger + // back. The halves travel different paths -- `post_message` through the + // chat adapter, `ActionTriggered` through the connection -- so this + // covers what the core owns: that an `Actions` set reaches the host, + // and that the trigger naming the returned id arrives unaltered. + // Reusing that id is the host's half of the contract, documented on + // `NativeChatCallbacks::post_message` and not enforceable here. + let callbacks = Arc::new(EventCallbacks::new()); + let platform = ChatCallbackPlatform { + chat: callbacks.clone(), + events: Arc::new(NativeEventBus::default()), + }; + let product = + ProductContext::new_with_execution("chat.dot".to_string(), ProductExecutionKind::Chat) + .unwrap(); + let connection = crate::runtime::ChatConnection::new(); + + let posted = futures::executor::block_on(truapi_platform::ChatPlatform::post_chat_message( + &platform, + &product, + v01::HostChatPostMessageRequest { + room_id: "support".to_string(), + payload: v01::ChatMessageContent::Actions(v01::ChatActions { + text: Some("pick one".to_string()), + actions: vec![v01::ChatAction { + action_id: "approve".to_string(), + title: "Approve".to_string(), + }], + layout: v01::ChatActionLayout::Column, + }), + }, + )) + .expect("an action set must reach the host"); + + let mut actions = connection.subscribe_actions(); + connection + .publish_action(truapi::versioned::chat::HostChatActionSubscribeItem::V1( + v01::HostChatActionSubscribeItem { + room_id: "support".to_string(), + peer: "alice".to_string(), + payload: v01::ChatActionPayload::ActionTriggered(v01::ActionTrigger { + message_id: posted.message_id.clone(), + action_id: "approve".to_string(), + payload: None, + }), + }, + )) + .expect("a trigger on a live subscription must be delivered"); + + let mut cx = core::task::Context::from_waker(futures::task::noop_waker_ref()); + let delivered = match actions.poll_next_unpin(&mut cx) { + core::task::Poll::Ready(Some(item)) => item, + other => panic!("a published trigger must be ready, got {other:?}"), + }; + let truapi::versioned::chat::HostChatActionSubscribeItem::V1(delivered) = delivered; + let v01::ChatActionPayload::ActionTriggered(trigger) = delivered.payload else { + panic!( + "expected an ActionTriggered payload, got {:?}", + delivered.payload + ); + }; + + // The action set really reached the host, in the room it named. + assert_eq!( + callbacks + .chat_posted + .lock() + .expect("posted messages mutex poisoned") + .len(), + 1 + ); + // The id the product must match on to find the message it posted. + assert_eq!(trigger.message_id, posted.message_id); + assert_eq!(trigger.action_id, "approve"); + } + + #[test] + fn native_chat_adapter_surfaces_a_message_rejection() { + let callbacks = Arc::new(EventCallbacks::new()); + let platform = ChatCallbackPlatform { + chat: callbacks.clone(), + events: Arc::new(NativeEventBus::default()), + }; + let product = + ProductContext::new_with_execution("chat.dot".to_string(), ProductExecutionKind::Chat) + .unwrap(); + *callbacks + .chat_post_rejection + .lock() + .expect("post rejection mutex poisoned") = + Some("cannot render a file card".to_string()); + + let error = futures::executor::block_on(truapi_platform::ChatPlatform::post_chat_message( + &platform, + &product, + v01::HostChatPostMessageRequest { + room_id: "support".to_string(), + payload: v01::ChatMessageContent::File(v01::ChatFile { + url: "https://example.invalid/f".to_string(), + file_name: "f".to_string(), + mime_type: "text/plain".to_string(), + size_bytes: 1, + text: None, + }), + }, + )) + .expect_err("a host rejection must not be reported as a stored message"); + + // Declining a variant is how a host that cannot render one opts out, + // so a swallowed rejection would hand the product a message id for + // something that was never persisted. + assert_eq!( + error, + v01::HostChatPostMessageError::Unknown { + reason: "cannot render a file card".to_string(), + } + ); assert!( callbacks - .chat_posted_text + .chat_posted .lock() - .expect("posted text mutex poisoned") + .expect("posted messages mutex poisoned") .is_empty() ); } @@ -2703,9 +2857,7 @@ mod tests { let mut rooms = truapi_platform::ChatPlatform::subscribe_chat_rooms(&platform, &product); assert!( - futures::executor::block_on(rooms.next()) - .expect("initial room list") - .expect("room list is not an error") + ready_rooms(rooms.as_mut(), "initial room list") .rooms .is_empty() ); @@ -2752,9 +2904,7 @@ mod tests { participating_as: v01::ChatRoomParticipation::Bot, }]); assert_eq!( - futures::executor::block_on(rooms.next()) - .expect("genuine room change") - .expect("room list is not an error") + ready_rooms(rooms.as_mut(), "a genuine room change") .rooms .len(), 1 @@ -2778,9 +2928,7 @@ mod tests { }; let mut rooms = truapi_platform::ChatPlatform::subscribe_chat_rooms(&platform, &product); assert!( - futures::executor::block_on(rooms.next()) - .expect("initial room list") - .expect("room list is not an error") + ready_rooms(rooms.as_mut(), "initial room list") .rooms .is_empty() ); @@ -2791,9 +2939,7 @@ mod tests { request.clone(), )) .unwrap(); - let updated_rooms = futures::executor::block_on(rooms.next()) - .expect("created room replacement") - .expect("room list is not an error"); + let updated_rooms = ready_rooms(rooms.as_mut(), "the created room replacement"); *callbacks .chat_room_status .lock() @@ -2818,7 +2964,7 @@ mod tests { assert_eq!(updated_rooms.rooms.len(), 1); assert_eq!(updated_rooms.rooms[0].room_id, "support"); assert_eq!(existing.status, v01::ChatRoomRegistrationStatus::Exists); - assert_eq!(posted.message_id, "message-id"); + assert_eq!(posted.message_id, "message-1"); assert_eq!( callbacks .chat_created_rooms @@ -2832,11 +2978,16 @@ mod tests { ); assert_eq!( callbacks - .chat_posted_text + .chat_posted .lock() - .expect("posted text mutex poisoned") + .expect("posted messages mutex poisoned") .as_slice(), - &[("second-room".to_string(), "Echo: hello".to_string())] + &[( + "second-room".to_string(), + v01::ChatMessageContent::Text { + text: "Echo: hello".to_string(), + }, + )] ); } diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index b9980a4a4..a56627db7 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -189,11 +189,11 @@ use truapi::{CallContext, CallError, CancellationReason, Subscription}; use truapi::{latest, v01}; use truapi_platform::Platform; use truapi_platform::{ - AccountAccessReview, CreateTransactionReview, IdentityDisclosureReview, + AccountAccessReview, ChatFieldError, CreateTransactionReview, IdentityDisclosureReview, PermissionAuthorizationRequest, PermissionAuthorizationStatus, PreimageSubmitReview, ProductContext, ProductStorageKey, ResourceAllocationReview, SessionUiInfo, SignPayloadReview, SignRawReview, UserConfirmationReview, normalize_chat_identifier, normalize_product_identifier, - validate_chat_icon, validate_chat_name, + validate_chat_icon, validate_chat_message_content, validate_chat_name, }; /// Error reason surfaced to products when a remote permission is not granted. @@ -2261,13 +2261,11 @@ impl Chat for ProductRuntimeHost { // The same normalization create_room applied, so a product's own // spelling of a room id still resolves to the stored room. request.room_id = - normalize_chat_identifier("roomId", &request.room_id).map_err(|error| { - CallError::Domain(HostChatPostMessageError::V1( - v01::HostChatPostMessageError::Unknown { - reason: error.to_string(), - }, - )) - })?; + normalize_chat_identifier("roomId", &request.room_id).map_err(chat_post_field_error)?; + // Content is product-authored and host-rendered, so it gets the same + // treatment the room fields get rather than reaching a host raw. + request.payload = + validate_chat_message_content(request.payload).map_err(chat_post_field_error)?; platform .post_chat_message(&self.product, request) .await @@ -2297,6 +2295,27 @@ fn chat_register_bot_field_error( )) } +/// Fields whose rejection a product resolves by sending less content, and the +/// only ones the fieldless `MessageTooLarge` can describe. +const CHAT_SIZED_CONTENT_FIELDS: [&str; 2] = ["text", "payload"]; + +/// Maps a rejected `post_message` field onto the wire error, reporting a size +/// rejection as the size variant the protocol declares for it. +fn chat_post_field_error(error: ChatFieldError) -> CallError { + let payload = match error { + // `MessageTooLarge` names no field, so it is reserved for the content + // a product shrinks by sending less. Every other rejection reports + // through `Unknown`, whose reason names the field and its limit. + ChatFieldError::TooLong { field, .. } if CHAT_SIZED_CONTENT_FIELDS.contains(&field) => { + v01::HostChatPostMessageError::MessageTooLarge + } + error => v01::HostChatPostMessageError::Unknown { + reason: error.to_string(), + }, + }; + CallError::Domain(HostChatPostMessageError::V1(payload)) +} + /// Report a rejected chat room field as a room-creation domain error. fn chat_create_room_field_error( error: truapi_platform::ChatFieldError, @@ -2942,6 +2961,7 @@ mod tests { registered_bots: Mutex>, created_rooms: Mutex>, posted_rooms: Mutex>, + posted_payloads: Mutex>, } #[truapi::async_trait] @@ -2992,6 +3012,10 @@ mod tests { .lock() .expect("posted rooms mutex poisoned") .push(request.room_id); + self.posted_payloads + .lock() + .expect("posted payloads mutex poisoned") + .push(request.payload); Ok(truapi::latest::HostChatPostMessageResponse { message_id: "message-id".to_string(), }) @@ -3008,6 +3032,148 @@ mod tests { } } + #[test] + fn chat_post_message_screens_content_before_it_reaches_a_host() { + let (host_config, _) = runtime_config("chat.dot"); + let product = ProductContext::new_with_execution( + "chat.dot".to_string(), + truapi_platform::ProductExecutionKind::Chat, + ) + .expect("test chat product context is valid"); + let spawner = test_spawner(); + let platform: Arc = stub_platform(); + let services = RuntimeServices::new( + platform.clone(), + host_config.people_chain_genesis_hash, + host_config.bulletin_chain_genesis_hash, + spawner.clone(), + ); + let chat_platform = Arc::new(RecordingChatPlatform::default()); + let pairing_host = PairingHost::new(services.clone(), host_config); + let mut adapters = crate::host_core::ConnectionAdapters::from_services(&services); + adapters.chat_platform = Some(chat_platform.clone()); + let host = ProductRuntimeHost::from_services(services, adapters, pairing_host, product); + install_pairing_session(&host, session_info()); + + let post = |payload: v01::ChatMessageContent| { + futures::executor::block_on(Chat::post_message( + &host, + &CallContext::default(), + HostChatPostMessageRequest::V1(v01::HostChatPostMessageRequest { + room_id: "support".to_string(), + payload, + }), + )) + }; + + // The screen runs at this entrypoint, not only in the helper: a host + // must never be handed a scheme it would fetch or open. + let rejected = post(v01::ChatMessageContent::File(v01::ChatFile { + url: "javascript:alert(document.cookie)".to_string(), + file_name: "f".to_string(), + mime_type: "text/plain".to_string(), + size_bytes: 1, + text: None, + })) + .expect_err("a javascript: file url must not reach the host"); + assert!(matches!( + rejected, + CallError::Domain(HostChatPostMessageError::V1( + v01::HostChatPostMessageError::Unknown { .. } + )) + )); + + // A body over budget reports the size variant the protocol declares. + let too_large = post(v01::ChatMessageContent::Text { + text: "x".repeat(truapi_platform::CHAT_BODY_MAX_BYTES + 1), + }) + .expect_err("an over-budget body must not reach the host"); + assert!(matches!( + too_large, + CallError::Domain(HostChatPostMessageError::V1( + v01::HostChatPostMessageError::MessageTooLarge + )) + )); + + // The payload arm of the size variant, which the body arm does not cover. + let big_payload = post(v01::ChatMessageContent::Custom(v01::ChatCustomMessage { + message_type: "vote".to_string(), + payload: vec![0; truapi_platform::CHAT_CUSTOM_PAYLOAD_MAX_BYTES + 1], + })) + .expect_err("an over-budget custom payload must not reach the host"); + assert!(matches!( + big_payload, + CallError::Domain(HostChatPostMessageError::V1( + v01::HostChatPostMessageError::MessageTooLarge + )) + )); + + // An over-long room id is not an over-large message. + let long_room = futures::executor::block_on(Chat::post_message( + &host, + &CallContext::default(), + HostChatPostMessageRequest::V1(v01::HostChatPostMessageRequest { + room_id: "r".repeat(truapi_platform::CHAT_FIELD_MAX_BYTES + 1), + payload: v01::ChatMessageContent::Text { + text: "hi".to_string(), + }, + }), + )) + .expect_err("an over-long room id must be rejected"); + assert!(matches!( + long_room, + CallError::Domain(HostChatPostMessageError::V1( + v01::HostChatPostMessageError::Unknown { .. } + )) + )); + + assert!( + chat_platform + .posted_rooms + .lock() + .expect("posted rooms mutex poisoned") + .is_empty(), + "nothing rejected may reach the host" + ); + + // The validated value is what the host receives, not the arriving one: + // running the screen and discarding its result would pass every + // rejection assertion above. + post(v01::ChatMessageContent::Reaction(v01::ChatReaction { + message_id: " cafe\u{301} ".to_string(), + emoji: "\u{1f3b2}".to_string(), + })) + .expect("a normalizable reaction is accepted"); + post(v01::ChatMessageContent::File(v01::ChatFile { + url: "https://example.invalid".to_string(), + file_name: "f".to_string(), + mime_type: "text/plain".to_string(), + size_bytes: 1, + text: None, + })) + .expect("a resolvable file url is accepted"); + assert_eq!( + chat_platform + .posted_payloads + .lock() + .expect("posted payloads mutex poisoned") + .as_slice(), + &[ + v01::ChatMessageContent::Reaction(v01::ChatReaction { + message_id: "caf\u{e9}".to_string(), + emoji: "\u{1f3b2}".to_string(), + }), + v01::ChatMessageContent::File(v01::ChatFile { + url: "https://example.invalid/".to_string(), + file_name: "f".to_string(), + mime_type: "text/plain".to_string(), + size_bytes: 1, + text: None, + }), + ] + ); + } + #[test] fn chat_room_ids_agree_across_create_and_post() { let (host_config, _) = runtime_config("chat.dot"); diff --git a/rust/crates/truapi/src/api/chat.rs b/rust/crates/truapi/src/api/chat.rs index 40f7d2cc5..068422271 100644 --- a/rust/crates/truapi/src/api/chat.rs +++ b/rust/crates/truapi/src/api/chat.rs @@ -71,6 +71,18 @@ pub trait Chat: Send + Sync { /// Post a message to a chat room. /// + /// The host bounds and screens what it forwards. Message text is capped at + /// 16 KiB and keeps line breaks and tabs, but is rejected for other + /// control characters and for bidirectional overrides. Identifiers and + /// display names are normalized and screened. A message carries at most 32 + /// actions and 32 media items, a custom payload at most 256 KiB, and a URL + /// at most 2 KiB which must be `https` or an inline raster image. A + /// rejection reports `MessageTooLarge` when the body or custom payload is + /// over budget, and `Unknown` with a reason naming the field otherwise. + /// + /// The returned `messageId` is the correlation key for any action the + /// message carries: a later `actionSubscribe` trigger names it. + /// /// ```ts /// const result = await truapi.chat.postMessage({ /// roomId: "test-room", diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index cfb9e9a1d..6876c08a1 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -51,16 +51,17 @@ pub mod latest { use crate::versioned::{self, Versioned}; pub use crate::v01::{ - AccountId, AllocatableResource, AllocationOutcome, ChainIdentifier, - ChatBotRegistrationStatus, ChatRoomRegistrationStatus, ContextualAlias, DerivationIndex, - GenericError, HostSignPayloadData, NotificationId, OperationStartedResult, - ProductAccountId, ProductProofContext, RawPayload, RegisteredRingVrfKey, RemotePermission, - RemoteStatementStoreCreateProofError, RemoteStatementStoreCreateProofRequest, - RemoteStatementStoreCreateProofResponse, RemoteStatementStoreSubscribeItem, - RemoteStatementStoreSubscribeRequest, RingLocation, RingVrfKeyDisclosure, RingVrfPublicKey, - RuntimeApi, RuntimeSpec, RuntimeType, SignedStatement, Statement, StatementProof, - StorageQueryItem, StorageQueryType, StorageResultItem, ThemeName, ThemeVariant, - TxPayloadExtension, + AccountId, AllocatableResource, AllocationOutcome, ChainIdentifier, ChatAction, + ChatActionLayout, ChatActions, ChatBotRegistrationStatus, ChatCustomMessage, ChatFile, + ChatMedia, ChatMessageContent, ChatReaction, ChatRichText, ChatRoomRegistrationStatus, + ContextualAlias, DerivationIndex, GenericError, HostSignPayloadData, NotificationId, + OperationStartedResult, ProductAccountId, ProductProofContext, RawPayload, + RegisteredRingVrfKey, RemotePermission, RemoteStatementStoreCreateProofError, + RemoteStatementStoreCreateProofRequest, RemoteStatementStoreCreateProofResponse, + RemoteStatementStoreSubscribeItem, RemoteStatementStoreSubscribeRequest, RingLocation, + RingVrfKeyDisclosure, RingVrfPublicKey, RuntimeApi, RuntimeSpec, RuntimeType, + SignedStatement, Statement, StatementProof, StorageQueryItem, StorageQueryType, + StorageResultItem, ThemeName, ThemeVariant, TxPayloadExtension, }; /// Latest payload type of a versioned envelope. diff --git a/rust/crates/truapi/src/v01/chat.rs b/rust/crates/truapi/src/v01/chat.rs index 5456c386f..df983f1fe 100644 --- a/rust/crates/truapi/src/v01/chat.rs +++ b/rust/crates/truapi/src/v01/chat.rs @@ -225,7 +225,8 @@ pub struct HostChatPostMessageRequest { /// Result of posting a message. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct HostChatPostMessageResponse { - /// Assigned message ID. + /// Host-assigned message id, and the correlation key for any action the + /// message carries: a trigger names it in [`ActionTrigger::message_id`]. pub message_id: String, } @@ -245,7 +246,8 @@ pub enum HostChatPostMessageError { #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] pub struct ActionTrigger { - /// Message containing the action. + /// Message containing the action, as returned by `Chat::post_message` in + /// [`HostChatPostMessageResponse::message_id`]. pub message_id: String, /// Which action was triggered. pub action_id: String, diff --git a/scripts/battery.sh b/scripts/battery.sh index f0ba16f40..57ea58934 100755 --- a/scripts/battery.sh +++ b/scripts/battery.sh @@ -45,6 +45,7 @@ cd "$ROOT" unset DYLD_LIBRARY_PATH SCRIPT="rust/crates/truapi-host-cli/js/scripts/battery.ts" +CHAT_SCRIPT="rust/crates/truapi-host-cli/js/scripts/chat-battery.ts" PRODUCT_ID="truapi-playground.dot" REPORTS="explorer/diagnosis-reports/spa" LOG_DIR="target/battery" @@ -56,6 +57,7 @@ CARGO_ARGS=() HOST_ARGS=() RUN_SIGNING=1 RUN_PAIRING=1 +RUN_CHAT=0 usage() { awk 'NR == 1 { next } /^#/ { sub(/^# ?/, ""); print; next } { exit }' "$0" @@ -65,6 +67,9 @@ while [ $# -gt 0 ]; do case "$1" in --signing-host) RUN_PAIRING=0 ;; --pairing-host) RUN_SIGNING=0 ;; + # Chat is its own phase: it needs a Chat-execution connection, which the + # generated Spa battery cannot open, and it writes no diagnosis report. + --chat-host) RUN_SIGNING=0; RUN_PAIRING=0; RUN_CHAT=1 ;; --release) CARGO_ARGS+=(--release); PROFILE_DIR="release" ;; --product-id) [ $# -ge 2 ] || { echo "battery: --product-id needs a value" >&2; exit 2; } @@ -183,6 +188,26 @@ signing_phase() { return "$rc" } +chat_phase() { + local log="$LOG_DIR/chat-host-cli.log" + echo "battery: chat phase (host messages $LOG_DIR/chat-host-messages.jsonl)" + # What the host was actually handed. The cases read it to tell a core + # rejection apart from a host that stored the content and then refused. + export TRUAPI_CHAT_LOG="$ROOT/$LOG_DIR/chat-host-messages.jsonl" + rm -f "$TRUAPI_CHAT_LOG" + "$HOST" signing-host \ + --product-id "$PRODUCT_ID" \ + --execution-kind chat \ + --script "$CHAT_SCRIPT" \ + --auto-accept \ + ${HOST_ARGS[@]+"${HOST_ARGS[@]}"} > >(tee "$log") 2>&1 & + local host_pid=$! rc=0 + start_watchdog "$host_pid" "chat phase" + wait "$host_pid" || rc=$? + stop_watchdog + return "$rc" +} + pairing_phase() { local log="$LOG_DIR/pairing-host-cli.log" local signer_log="$LOG_DIR/pairing-host-cli-signer.log" @@ -255,6 +280,7 @@ pairing_phase() { SIGNING_RC="skipped" PAIRING_RC="skipped" +CHAT_RC="skipped" if [ "$RUN_SIGNING" = 1 ]; then SIGNING_RC=0 @@ -266,8 +292,14 @@ if [ "$RUN_PAIRING" = 1 ]; then pairing_phase || PAIRING_RC=$? fi +if [ "$RUN_CHAT" = 1 ]; then + CHAT_RC=0 + chat_phase || CHAT_RC=$? +fi + echo -echo "battery: signing-host exit=$SIGNING_RC · pairing-host exit=$PAIRING_RC" +echo "battery: signing-host exit=$SIGNING_RC · pairing-host exit=$PAIRING_RC · chat-host exit=$CHAT_RC" echo "battery: reports under $REPORTS/, logs under $LOG_DIR/" [ "$SIGNING_RC" = 0 ] || [ "$SIGNING_RC" = "skipped" ] || exit "$SIGNING_RC" [ "$PAIRING_RC" = 0 ] || [ "$PAIRING_RC" = "skipped" ] || exit "$PAIRING_RC" +[ "$CHAT_RC" = 0 ] || [ "$CHAT_RC" = "skipped" ] || exit "$CHAT_RC"