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:
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 ──────────────────────────────────────────────────
|
||||
|
||||
Reference in new issue
Block a user