From d056c77c93663764742aeca41119c7919512b1ec Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Fri, 10 Jul 2026 05:00:23 +0300 Subject: [PATCH] feat(chat): handle sudo.request / secret.request and respond via RPC (fixes #524) (#539) Previously mobile dropped the gateway's sudo.request and secret.request events, so any agent turn needing a sudo password or a secret value hung forever with no UI. Now: - EventParser maps sudo.request / secret.request -> WsEvent.SudoRequest / SecretRequest - ChatViewModel surfaces a secure dialog (SudoPromptUi / SecretPromptUi) and replies via sudo.respond / secret.respond with session_id + request_id + payload - ChatWsEventReducer explicitly branches the new events (no-op, VM-owned) - ChatScreen renders password-masked SudoPromptDialog / SecretPromptDialog - 6 unit tests cover prompt-state, RPC params, and prompt-clearing Mirrors desktop/web contract (apps/shared GatewayEventName + tui_gateway). Derives from audit of /opt/hermes-agent desktop app. --- .../m57/hermescontrol/data/ws/EventParser.kt | 10 ++ .../com/m57/hermescontrol/data/ws/WsEvent.kt | 22 ++++ .../m57/hermescontrol/data/ws/WsMethods.kt | 2 + .../m57/hermescontrol/ui/chat/ChatScreen.kt | 121 +++++++++++++++++ .../hermescontrol/ui/chat/ChatViewModel.kt | 111 ++++++++++++++++ .../ui/chat/ChatWsEventReducer.kt | 4 + app/src/main/res/values/strings.xml | 6 + .../ui/chat/ChatViewModelTest.kt | 124 ++++++++++++++++++ 8 files changed, 400 insertions(+) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt index 3bc025fc..b3377fb8 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt @@ -115,6 +115,16 @@ object EventParser { WsEvent.ApprovalRequest(command, description, patternKeys, sessionId) } + "sudo.request" -> { + val requestId = payload?.get("request_id") as? String + WsEvent.SudoRequest(requestId, sessionId) + } + + "secret.request" -> { + val requestId = payload?.get("request_id") as? String + WsEvent.SecretRequest(requestId, sessionId) + } + else -> { Log.w(TAG, "Unknown event type: $eventType") WsEvent.Unknown(rawJson) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt index e6888de6..e41a309f 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt @@ -94,6 +94,28 @@ sealed class WsEvent { val sessionId: String?, ) : WsEvent() + // ── Sudo / secret requests ───────────────────────────────────────── + + /** + * Backend needs the user's sudo password to continue a turn + * (desktop: `sudo.request` → `sudo.respond {request_id, password}`). + * Mobile previously dropped this and the agent hung forever. + */ + data class SudoRequest( + val requestId: String?, + val sessionId: String?, + ) : WsEvent() + + /** + * Backend needs a secret value (password / token) to continue a turn + * (desktop: `secret.request` → `secret.respond {request_id, value}`). + * Mobile previously dropped this and the agent hung forever. + */ + data class SecretRequest( + val requestId: String?, + val sessionId: String?, + ) : WsEvent() + // ── Fallback ───────────────────────────────────────────────────────── data class Unknown( diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index dc0918ce..5d690984 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -17,6 +17,8 @@ object WsMethods { const val PROMPT_SUBMIT = "prompt.submit" const val CLARIFY_RESPOND = "clarify.respond" const val APPROVAL_RESPOND = "approval.respond" + const val SUDO_RESPOND = "sudo.respond" + const val SECRET_RESPOND = "secret.respond" // ── Commands catalog ────────────────────────────────────────────────── const val COMMANDS_CATALOG = "commands.catalog" diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 68452440..c4f11c0e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -101,6 +101,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -275,6 +277,8 @@ fun ChatScreen( currentSearchMatchIndex = state.currentSearchMatchIndex, searchMatchIndices = state.searchMatchIndices, clarifyRequest = state.clarifyRequest, + sudoPrompt = state.sudoPrompt, + secretPrompt = state.secretPrompt, listState = listState, snackbarHostState = snackbarHostState, viewModel = viewModel, @@ -1004,6 +1008,106 @@ private fun ClarifyDialog( ) } +/** + * Secure password dialog for a pending `sudo.request` (issue #524). + * The backend blocked the turn waiting for the sudo password — previously + * mobile dropped the event and the agent hung forever. + */ +@Composable +private fun SudoPromptDialog( + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var password by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.chat_sudo_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(text = stringResource(R.string.chat_sudo_body)) + Spacer(modifier = Modifier.height(4.dp)) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text(stringResource(R.string.chat_sudo_password)) }, + modifier = Modifier.fillMaxWidth().testTag("sudo_password_input"), + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(autoCorrect = false, keyboardType = KeyboardType.Password), + ) + } + }, + confirmButton = { + TextButton( + onClick = { + if (password.isNotBlank()) { + onConfirm(password) + } + }, + enabled = password.isNotBlank(), + ) { + Text(stringResource(R.string.chat_send)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.chat_dismiss)) + } + }, + ) +} + +/** + * Secure value dialog for a pending `secret.request` (issue #524). + * The backend blocked the turn waiting for a secret (token/password) — + * previously mobile dropped the event and the agent hung forever. + */ +@Composable +private fun SecretPromptDialog( + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var secret by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.chat_secret_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(text = stringResource(R.string.chat_secret_body)) + Spacer(modifier = Modifier.height(4.dp)) + OutlinedTextField( + value = secret, + onValueChange = { secret = it }, + label = { Text(stringResource(R.string.chat_secret_value)) }, + modifier = Modifier.fillMaxWidth().testTag("secret_value_input"), + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(autoCorrect = false, keyboardType = KeyboardType.Password), + ) + } + }, + confirmButton = { + TextButton( + onClick = { + if (secret.isNotBlank()) { + onConfirm(secret) + } + }, + enabled = secret.isNotBlank(), + ) { + Text(stringResource(R.string.chat_send)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.chat_dismiss)) + } + }, + ) +} + @Composable private fun CompactSearchInput( value: String, @@ -1230,6 +1334,8 @@ private fun ChatLifecycleEffects( currentSearchMatchIndex: Int, searchMatchIndices: List, clarifyRequest: ClarifyUi?, + sudoPrompt: SudoPromptUi?, + secretPrompt: SecretPromptUi?, listState: LazyListState, snackbarHostState: SnackbarHostState, viewModel: ChatViewModel, @@ -1330,6 +1436,21 @@ private fun ChatLifecycleEffects( ) } + // Sudo / secret prompt dialogs (issue #524) + sudoPrompt?.let { prompt -> + SudoPromptDialog( + onConfirm = viewModel::respondToSudo, + onDismiss = viewModel::dismissSudo, + ) + } + + secretPrompt?.let { prompt -> + SecretPromptDialog( + onConfirm = viewModel::respondToSecret, + onDismiss = viewModel::dismissSecret, + ) + } + // Scroll to current search match LaunchedEffect(isSearchActive, currentSearchMatchIndex, searchMatchIndices) { if (isSearchActive && diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 38d2b58f..8c9327fa 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -70,6 +70,9 @@ data class ChatUiState( val streamingMessage: ChatMessage? = null, val errorMessage: String? = null, val clarifyRequest: ClarifyUi? = null, + // Sudo / secret prompts — surfaced as dialogs (issue #524) + val sudoPrompt: SudoPromptUi? = null, + val secretPrompt: SecretPromptUi? = null, val showSessionPicker: Boolean = false, // Search state val isSearchActive: Boolean = false, @@ -100,6 +103,18 @@ data class ClarifyUi( val clarifyId: String? = null, ) +/** Transient — not persisted. Holds a pending sudo.password request. */ +data class SudoPromptUi( + val requestId: String?, + val sessionId: String?, +) + +/** Transient — not persisted. Holds a pending secret (token/password) request. */ +data class SecretPromptUi( + val requestId: String?, + val sessionId: String?, +) + class ChatViewModel( application: Application, private val startCleanup: Boolean, @@ -358,6 +373,14 @@ class ChatViewModel( handleApprovalRequest(event) } + is WsEvent.SudoRequest -> { + handleSudoRequest(event) + } + + is WsEvent.SecretRequest -> { + handleSecretRequest(event) + } + else -> { /* reducer handles these */ } } } @@ -1096,6 +1119,94 @@ class ChatViewModel( } } + // ── Sudo / secret prompt flow (issue #524) ────────────────────────── + + /** + * The agent needs the user's sudo password. Previously dropped → agent + * hung forever. Now we surface a secure dialog and reply via sudo.respond. + */ + private fun handleSudoRequest(event: WsEvent.SudoRequest) { + _uiState.update { + it.copy( + sudoPrompt = SudoPromptUi(event.requestId, event.sessionId), + isAgentTyping = false, + ) + } + } + + /** + * The agent needs a secret value (token/password). Previously dropped → + * agent hung forever. Now we surface a secure dialog and reply via + * secret.respond. + */ + private fun handleSecretRequest(event: WsEvent.SecretRequest) { + _uiState.update { + it.copy( + secretPrompt = SecretPromptUi(event.requestId, event.sessionId), + isAgentTyping = false, + ) + } + } + + fun dismissSudo() { + _uiState.update { it.copy(sudoPrompt = null) } + } + + fun dismissSecret() { + _uiState.update { it.copy(secretPrompt = null) } + } + + /** + * Send the user's sudo password back to the gateway. Mirrors + * respondToApproval: clear the prompt immediately, then fire the RPC. + */ + fun respondToSudo(password: String) { + val prompt = _uiState.value.sudoPrompt ?: return + val sessionId = prompt.sessionId ?: _uiState.value.currentSessionId ?: return + if (password.isBlank()) return + + _uiState.update { it.copy(sudoPrompt = null) } + + viewModelScope.launch(Dispatchers.IO) { + val params = + mutableMapOf( + "session_id" to sessionId, + "password" to password, + ) + prompt.requestId?.let { id -> params["request_id"] = id } + wsClient.send( + method = WsMethods.SUDO_RESPOND, + params = params, + onSent = { id -> trackRequest(id, WsMethods.SUDO_RESPOND) }, + ) + } + } + + /** + * Send the user's secret value back to the gateway. Mirrors respondToSudo. + */ + fun respondToSecret(value: String) { + val prompt = _uiState.value.secretPrompt ?: return + val sessionId = prompt.sessionId ?: _uiState.value.currentSessionId ?: return + if (value.isBlank()) return + + _uiState.update { it.copy(secretPrompt = null) } + + viewModelScope.launch(Dispatchers.IO) { + val params = + mutableMapOf( + "session_id" to sessionId, + "value" to value, + ) + prompt.requestId?.let { id -> params["request_id"] = id } + wsClient.send( + method = WsMethods.SECRET_RESPOND, + params = params, + onSent = { id -> trackRequest(id, WsMethods.SECRET_RESPOND) }, + ) + } + } + fun reconnect() { _uiState.update { it.copy( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index 30cfefdb..2fd52bc1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -80,6 +80,10 @@ object ChatWsEventReducer { // ApprovalRequest is handled by the ViewModel (needs active session + WS client) is WsEvent.ApprovalRequest -> ReducerResult(state = state, streamingState = streamingState) + + // SudoRequest / SecretRequest are handled by the ViewModel (issue #524) + is WsEvent.SudoRequest -> ReducerResult(state = state, streamingState = streamingState) + is WsEvent.SecretRequest -> ReducerResult(state = state, streamingState = streamingState) } // ── GatewayReady ────────────────────────────────────────────────── diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5ffbd904..6cc5e092 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -12,6 +12,12 @@ Response Send Dismiss + Sudo Password Required + The agent needs your sudo password to continue. It will not be shown. + Password + Secret Required + The agent needs a secret value (token or password) to continue. It will not be shown. + Secret Search messages\u2026 Clear Previous match diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index c52d81df..83b61b70 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -1035,6 +1035,130 @@ class ChatViewModelTest { assertNull(msgAfter!!.approvalInfo) } + // ── Sudo / secret prompt flow (issue #524) ─────────────────────────── + + @Test + fun testSudoRequest_setsPromptState() = + runTest { + val (viewModel, _) = createViewModelWithSession() + advanceUntilIdle() + + mockEventsFlow.emit( + WsEvent.SudoRequest(requestId = "sudo-1", sessionId = null), + ) + advanceUntilIdle() + + val prompt = viewModel.uiState.value.sudoPrompt + assertNotNull(prompt) + assertEquals("sudo-1", prompt?.requestId) + } + + @Test + fun testRespondToSudo_sendsRpc() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + advanceUntilIdle() + + mockEventsFlow.emit( + WsEvent.SudoRequest(requestId = "sudo-1", sessionId = null), + ) + advanceUntilIdle() + + viewModel.respondToSudo("hunter2") + advanceUntilIdle() + + verify { + HermesWsClient.send( + WsMethods.SUDO_RESPOND, + withArg { params -> + assertEquals(sessionId, params["session_id"]) + assertEquals("hunter2", params["password"]) + assertEquals("sudo-1", params["request_id"]) + }, + any(), + ) + } + } + + @Test + fun testRespondToSudo_clearsPrompt() = + runTest { + val (viewModel, _) = createViewModelWithSession() + advanceUntilIdle() + + mockEventsFlow.emit( + WsEvent.SudoRequest(requestId = "sudo-1", sessionId = null), + ) + advanceUntilIdle() + assertNotNull(viewModel.uiState.value.sudoPrompt) + + viewModel.respondToSudo("hunter2") + advanceUntilIdle() + + assertNull(viewModel.uiState.value.sudoPrompt) + } + + @Test + fun testSecretRequest_setsPromptState() = + runTest { + val (viewModel, _) = createViewModelWithSession() + advanceUntilIdle() + + mockEventsFlow.emit( + WsEvent.SecretRequest(requestId = "secret-1", sessionId = null), + ) + advanceUntilIdle() + + val prompt = viewModel.uiState.value.secretPrompt + assertNotNull(prompt) + assertEquals("secret-1", prompt?.requestId) + } + + @Test + fun testRespondToSecret_sendsRpc() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + advanceUntilIdle() + + mockEventsFlow.emit( + WsEvent.SecretRequest(requestId = "secret-1", sessionId = null), + ) + advanceUntilIdle() + + viewModel.respondToSecret("super-secret-token") + advanceUntilIdle() + + verify { + HermesWsClient.send( + WsMethods.SECRET_RESPOND, + withArg { params -> + assertEquals(sessionId, params["session_id"]) + assertEquals("super-secret-token", params["value"]) + assertEquals("secret-1", params["request_id"]) + }, + any(), + ) + } + } + + @Test + fun testRespondToSecret_clearsPrompt() = + runTest { + val (viewModel, _) = createViewModelWithSession() + advanceUntilIdle() + + mockEventsFlow.emit( + WsEvent.SecretRequest(requestId = "secret-1", sessionId = null), + ) + advanceUntilIdle() + assertNotNull(viewModel.uiState.value.secretPrompt) + + viewModel.respondToSecret("super-secret-token") + advanceUntilIdle() + + assertNull(viewModel.uiState.value.secretPrompt) + } + // ── Settings ───────────────────────────────────────────────────────────── @Test