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.
This commit is contained in:
M57 authored and GitHub committed 2026-07-10 05:00:23 +03:00
1 parent f6e6f86cc4
commit d056c77c93
8 files changed
+400

No files matched your search

@@ -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)
@@ -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(
@@ -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"
@@ -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<Int>,
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 &&
@@ -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<String, Any>(
"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<String, Any>(
"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(
@@ -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 ──────────────────────────────────────────────────