diff --git a/app/src/androidTest/java/dev/threadline/TranscriptScreenTest.kt b/app/src/androidTest/java/dev/threadline/TranscriptScreenTest.kt index d50cd12..4392e1e 100644 --- a/app/src/androidTest/java/dev/threadline/TranscriptScreenTest.kt +++ b/app/src/androidTest/java/dev/threadline/TranscriptScreenTest.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentDataType import androidx.compose.ui.input.key.Key import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag @@ -64,6 +65,7 @@ import dev.threadline.core.shell.CommandSubmissionResult import dev.threadline.core.shell.CompletedCommand import dev.threadline.core.shell.LifecyclePhase import dev.threadline.core.shell.StructuredShellState +import dev.threadline.core.session.ReplySubmissionResult import dev.threadline.core.terminal.TerminalKey import dev.threadline.core.terminal.TerminalModifiers import dev.threadline.core.transcript.CommandOutput @@ -80,6 +82,116 @@ class TranscriptScreenTest { @get:Rule val composeRule = createAndroidComposeRule() + @Test + fun runningReplySendsToItsCommandWithoutChangingTheComposerDraft() { + val replies = mutableListOf>() + composeRule.setContent { + MaterialTheme { + TranscriptSurface( + structuredShell = runningShell(), + transcript = CommandTranscriptState( + turns = listOf(turn(status = CommandStatus.RUNNING)), + ), + onSubmit = { CommandSubmissionResult.Rejected(CommandSubmissionRejection.NOT_READY) }, + onReply = { id, value -> + replies += id to value.concatToString() + ReplySubmissionResult.SENT + }, + onStop = {}, + onDisconnect = {}, + ) + } + } + + composeRule.onNodeWithTag(TranscriptTags.COMPOSER).performTextInput("draft command") + composeRule.onNodeWithTag(TranscriptTags.REPLY).assertIsDisplayed().performClick() + composeRule.onNodeWithText("The remote program can echo or print this reply. " + + "It may appear in the transcript and saved history.").assertIsDisplayed() + composeRule.onNodeWithTag(TranscriptTags.REPLY_FIELD).performTextInput("yes") + composeRule.onNodeWithTag(TranscriptTags.REPLY_SEND).performClick() + + composeRule.runOnIdle { + assertEquals(listOf(CommandId("command-42") to "yes"), replies) + } + assertComposerText("draft command") + composeRule.onNodeWithText("Reply to running command").assertDoesNotExist() + } + + @Test + fun maskedReplyIsNotRestoredAndCancelSendsNothing() { + val replies = mutableListOf() + val restorationTester = StateRestorationTester(composeRule) + restorationTester.setContent { + MaterialTheme { + TranscriptSurface( + structuredShell = runningShell(), + transcript = CommandTranscriptState( + turns = listOf(turn(status = CommandStatus.RUNNING)), + ), + onSubmit = { CommandSubmissionResult.Rejected(CommandSubmissionRejection.NOT_READY) }, + onReply = { _, value -> + replies += value.concatToString() + ReplySubmissionResult.SENT + }, + onStop = {}, + onDisconnect = {}, + ) + } + } + + composeRule.onNodeWithTag(TranscriptTags.REPLY).performClick() + composeRule.onNodeWithTag(TranscriptTags.REPLY_MASKED).performClick() + composeRule.onNodeWithTag(TranscriptTags.REPLY_FIELD) + .assert(SemanticsMatcher.expectValue( + SemanticsProperties.ContentDataType, + ContentDataType.None, + )) + .performTextInput("fixture-secret") + composeRule.onNodeWithTag(TranscriptTags.REPLY_FIELD) + .assert(SemanticsMatcher.keyIsDefined(SemanticsProperties.Password)) + composeRule.onNodeWithText("Cancel").performClick() + composeRule.runOnIdle { assertTrue(replies.isEmpty()) } + + composeRule.onNodeWithTag(TranscriptTags.REPLY).performClick() + composeRule.onNodeWithTag(TranscriptTags.REPLY_MASKED).performClick() + composeRule.onNodeWithTag(TranscriptTags.REPLY_SEND).performClick() + composeRule.runOnIdle { assertEquals(listOf(""), replies) } + + composeRule.onNodeWithTag(TranscriptTags.REPLY).performClick() + composeRule.onNodeWithTag(TranscriptTags.REPLY_MASKED).performClick() + composeRule.onNodeWithTag(TranscriptTags.REPLY_FIELD).performTextInput("not-restored") + restorationTester.emulateSavedInstanceStateRestore() + composeRule.onNodeWithText("Reply to running command").assertDoesNotExist() + composeRule.onNodeWithText("not-restored").assertDoesNotExist() + } + + @Test + fun replyControlsRemainReachableAtTwoHundredPercentFontScale() { + composeRule.setContent { + val density = LocalDensity.current + CompositionLocalProvider(LocalDensity provides Density(density.density, 2f)) { + MaterialTheme { + TranscriptSurface( + structuredShell = runningShell(), + transcript = CommandTranscriptState( + turns = listOf(turn(status = CommandStatus.RUNNING)), + ), + onSubmit = { + CommandSubmissionResult.Rejected(CommandSubmissionRejection.NOT_READY) + }, + onStop = {}, + onDisconnect = {}, + ) + } + } + } + + composeRule.onNodeWithTag(TranscriptTags.REPLY).assertIsDisplayed().performClick() + composeRule.onNodeWithTag(TranscriptTags.REPLY_MASKED).assertIsDisplayed().performClick() + composeRule.onNodeWithTag(TranscriptTags.REPLY_FIELD).assertIsDisplayed() + composeRule.onNodeWithTag(TranscriptTags.REPLY_SEND).assertIsDisplayed() + } + @Test fun composerSubmitsExactMultilineCommandAndClearsAfterAcceptance() { var submitted: String? = null diff --git a/app/src/androidTest/java/dev/threadline/core/session/AndroidStructuredShellIntegrationTest.kt b/app/src/androidTest/java/dev/threadline/core/session/AndroidStructuredShellIntegrationTest.kt index 33ee896..5310816 100644 --- a/app/src/androidTest/java/dev/threadline/core/session/AndroidStructuredShellIntegrationTest.kt +++ b/app/src/androidTest/java/dev/threadline/core/session/AndroidStructuredShellIntegrationTest.kt @@ -50,6 +50,109 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class AndroidStructuredShellIntegrationTest { + @Test + fun repliesReachRunningCommandAndOnlyRemoteEchoEntersSavedOutput() = runBlocking { + val arguments = InstrumentationRegistry.getArguments() + val password = arguments.getString(PASSWORD_ARGUMENT) + assumeTrue("No fixture password supplied", !password.isNullOrEmpty()) + val targetContext = InstrumentationRegistry.getInstrumentation().targetContext + val legacyPreferences = targetContext.getSharedPreferences( + "android_reply_known_hosts", + Context.MODE_PRIVATE, + ) + legacyPreferences.edit().clear().commit() + val database = Room.inMemoryDatabaseBuilder( + targetContext, + ThreadlineDatabase::class.java, + ).build() + val transcriptHistoryStore = RoomTranscriptHistoryStore(database.transcriptArchives()) + val manager = SessionManager( + adapter = ConnectBotSshClientAdapter( + HostKeyAlgorithmPolicy.overrideWhenEd25519Unavailable( + AndroidSshCryptoProvider.install(), + ), + ), + knownHostStore = RoomKnownHostStore(database.knownHosts(), legacyPreferences), + terminal = NoOpTerminal, + transcriptArchiveSink = transcriptHistoryStore, + ) + val profile = HostProfile( + displayName = "Reply fixture", + endpoint = HostEndpoint( + arguments.getString(HOST_ARGUMENT) ?: DEFAULT_HOST, + arguments.getString(PORT_ARGUMENT)?.toIntOrNull() ?: DEFAULT_PORT, + ), + username = arguments.getString(USER_ARGUMENT) ?: DEFAULT_USER, + ) + + try { + assertTrue(manager.prepareConnection(ConnectionRequest( + profile, + SessionCredential.Password.from(requireNotNull(password).toCharArray()), + ))) + assertTrue(manager.connectPrepared()) + withTimeout(CONNECTION_TIMEOUT_MILLIS) { + manager.state.filterIsInstance().first() + } + assertTrue(manager.resolveHostKey(HostKeyDecision.ACCEPT_AND_SAVE)) + withTimeout(CONNECTION_TIMEOUT_MILLIS) { + manager.structuredState.filterIsInstance().first() + } + + val ordinary = accepted(manager.submitCommand( + "stty -echo; read -r -p 'Continue? ' reply; stty echo; test -n \"\$reply\"", + )) + awaitTranscriptOutput(manager, ordinary.commandId, "Continue? ") + assertEquals(ReplySubmissionResult.SENT, + manager.sendReply(ordinary.commandId, "yes".toCharArray())) + assertEquals(0, awaitCompletion(manager, ordinary.commandId).exitStatus) + + val masked = accepted(manager.submitCommand( + "stty -echo; read -r -p 'Secret? ' reply; stty echo; test -n \"\$reply\"", + )) + awaitTranscriptOutput(manager, masked.commandId, "Secret? ") + assertEquals(ReplySubmissionResult.SENT, + manager.sendReply(masked.commandId, "fixture-secret".toCharArray())) + assertEquals(0, awaitCompletion(manager, masked.commandId).exitStatus) + assertTrue(!manager.transcriptState.value.turns.first { it.id == masked.commandId } + .output.plainText.contains("fixture-secret")) + + val echoed = accepted(manager.submitCommand( + "read -r -p 'Password: ' reply; test -n \"\$reply\"", + )) + awaitTranscriptOutput(manager, echoed.commandId, "Password: ") + assertEquals(ReplySubmissionResult.SENT, + manager.sendReply(echoed.commandId, "fixture-echo".toCharArray())) + assertEquals(0, awaitCompletion(manager, echoed.commandId).exitStatus) + assertTrue(manager.transcriptState.value.turns.first { it.id == echoed.commandId } + .output.plainText.contains("fixture-echo")) + + manager.disconnect() + withTimeout(CONNECTION_TIMEOUT_MILLIS) { + manager.state.first { it is SessionState.Disconnected } + } + val savedId = withTimeout(CONNECTION_TIMEOUT_MILLIS) { + transcriptHistoryStore.sessions.first { it.isNotEmpty() }.single().id + } + val saved = transcriptHistoryStore.load(savedId).turns.map { it.turn } + assertTrue(saved.first { it.id == masked.commandId }.let { + !it.command.contains("fixture-secret") && + !it.output.plainText.contains("fixture-secret") + }) + assertTrue(saved.first { it.id == echoed.commandId } + .output.plainText.contains("fixture-echo")) + } finally { + if (manager.state.value !is SessionState.Disconnected) { + manager.disconnect() + withTimeout(CONNECTION_TIMEOUT_MILLIS) { + manager.state.first { it is SessionState.Disconnected } + } + } + database.close() + legacyPreferences.edit().clear().commit() + } + } + @Test fun encryptedImportedKeyAuthenticatesAfterDatabaseReopen() = runBlocking { val arguments = InstrumentationRegistry.getArguments() @@ -471,6 +574,19 @@ class AndroidStructuredShellIntegrationTest { assertEquals(0, completed.exitStatus) } + private suspend fun awaitTranscriptOutput( + manager: SessionManager, + commandId: CommandId, + text: String, + ) { + withTimeout(COMMAND_TIMEOUT_MILLIS) { + manager.transcriptState.first { transcript -> + transcript.turns.firstOrNull { it.id == commandId } + ?.output?.plainText?.contains(text) == true + } + } + } + private suspend fun assertSuccessful( manager: SessionManager, command: String, diff --git a/app/src/main/java/dev/threadline/MainActivity.kt b/app/src/main/java/dev/threadline/MainActivity.kt index 9de8a7e..3a1ca22 100644 --- a/app/src/main/java/dev/threadline/MainActivity.kt +++ b/app/src/main/java/dev/threadline/MainActivity.kt @@ -406,6 +406,7 @@ private fun ThreadlineApp() { onSubmit = manager::submitCommand, onSubmitIsolated = manager::submitIsolatedCommand, onControlC = manager::sendControlC, + onReply = manager::sendReply, onDisconnect = manager::disconnect, onOpenHome = { showConnectedSession = false }, onOpenDiagnostics = openDiagnostics, diff --git a/app/src/main/java/dev/threadline/TranscriptScreen.kt b/app/src/main/java/dev/threadline/TranscriptScreen.kt index 64cf33f..2f982aa 100644 --- a/app/src/main/java/dev/threadline/TranscriptScreen.kt +++ b/app/src/main/java/dev/threadline/TranscriptScreen.kt @@ -26,7 +26,10 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.TextObfuscationMode import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card @@ -35,6 +38,7 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedSecureTextField import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -53,6 +57,7 @@ import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentDataType import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType @@ -66,6 +71,7 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.contentDataType import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics @@ -77,15 +83,18 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.PopupProperties +import dev.threadline.core.shell.CommandId import dev.threadline.core.shell.CommandExecutionMode import dev.threadline.core.shell.CommandSubmissionRejection import dev.threadline.core.shell.CommandSubmissionResult import dev.threadline.core.shell.StructuredShellState import dev.threadline.core.shell.commandMayChangePersistentStrictMode +import dev.threadline.core.session.ReplySubmissionResult import dev.threadline.core.terminal.TerminalKey import dev.threadline.core.terminal.TerminalModifiers import dev.threadline.core.transcript.AnsiColor @@ -114,6 +123,11 @@ internal object TranscriptTags { const val SUBMISSION_ERROR = "command-submission-error" const val SESSION_ACTIONS = "session-actions" const val SESSION_OVERFLOW = "session-overflow" + const val REPLY = "command-reply" + const val REPLY_ORDINARY = "command-reply-ordinary" + const val REPLY_MASKED = "command-reply-masked" + const val REPLY_FIELD = "command-reply-field" + const val REPLY_SEND = "command-reply-send" const val COMPACT_HEADER = "session-compact-header" const val RAW_TERMINAL_VIEWPORT = "raw-terminal-viewport" const val HOME = "session-home" @@ -158,6 +172,9 @@ internal fun ConnectedSessionScreen( onSubmit: (String) -> CommandSubmissionResult, onSubmitIsolated: ((String) -> CommandSubmissionResult)? = null, onControlC: () -> Unit, + onReply: (CommandId, CharArray) -> ReplySubmissionResult = { _, _ -> + ReplySubmissionResult.NOT_RUNNING + }, onDisconnect: () -> Unit, onOpenHome: () -> Unit = {}, onOpenDiagnostics: () -> Unit = {}, @@ -219,6 +236,7 @@ internal fun ConnectedSessionScreen( transcript = transcript, onSubmit = onSubmit, onSubmitIsolated = onSubmitIsolated, + onReply = onReply, onStop = onControlC, onDisconnect = onDisconnect, onOpenTerminal = { rawModeRequested = true }, @@ -570,6 +588,9 @@ internal fun TranscriptSurface( transcript: CommandTranscriptState, onSubmit: (String) -> CommandSubmissionResult, onSubmitIsolated: ((String) -> CommandSubmissionResult)? = null, + onReply: (CommandId, CharArray) -> ReplySubmissionResult = { _, _ -> + ReplySubmissionResult.NOT_RUNNING + }, onStop: () -> Unit, onDisconnect: () -> Unit, onOpenTerminal: () -> Unit = {}, @@ -702,6 +723,9 @@ internal fun TranscriptSurface( CommandCard( turn = turn, canSubmit = structuredShell is StructuredShellState.Ready, + canReply = structuredShell is StructuredShellState.Running && + structuredShell.activeCommand.id == turn.id, + onReply = onReply, onStop = onStop, onDisconnect = onDisconnect, onEdit = { @@ -858,10 +882,131 @@ internal fun TranscriptSurface( } } +@Composable +private fun RunningCommandReplyDialog( + commandId: CommandId, + onReply: (CommandId, CharArray) -> ReplySubmissionResult, + onDismiss: () -> Unit, +) { + var masked by remember { mutableStateOf(false) } + var ordinaryText by remember { mutableStateOf("") } + val maskedText = remember { TextFieldState() } + var error by remember { mutableStateOf(null) } + + fun clearInput() { + ordinaryText = "" + maskedText.edit { replace(0, length, "") } + } + + fun dismiss() { + clearInput() + onDismiss() + } + + AlertDialog( + onDismissRequest = ::dismiss, + title = { Text("Reply to running command") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + "The remote program can echo or print this reply. It may appear in the " + + "transcript and saved history.", + style = MaterialTheme.typography.bodySmall, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = !masked, + onClick = { + clearInput() + masked = false + error = null + }, + label = { Text("Ordinary") }, + modifier = Modifier.testTag(TranscriptTags.REPLY_ORDINARY), + ) + FilterChip( + selected = masked, + onClick = { + clearInput() + masked = true + error = null + }, + label = { Text("Masked") }, + modifier = Modifier.testTag(TranscriptTags.REPLY_MASKED), + ) + } + if (masked) { + OutlinedSecureTextField( + state = maskedText, + label = { Text("Masked reply") }, + textObfuscationMode = TextObfuscationMode.Hidden, + keyboardOptions = KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + ), + modifier = Modifier + .fillMaxWidth() + .semantics { contentDataType = ContentDataType.None } + .testTag(TranscriptTags.REPLY_FIELD), + ) + } else { + OutlinedTextField( + value = ordinaryText, + onValueChange = { ordinaryText = it }, + label = { Text("Reply") }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .testTag(TranscriptTags.REPLY_FIELD), + ) + } + error?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } + }, + confirmButton = { + Button( + onClick = { + val value = if (masked) { + CharArray(maskedText.text.length) { maskedText.text[it] } + } else { + ordinaryText.toCharArray() + } + val result = try { + onReply(commandId, value) + } finally { + value.fill('\u0000') + } + when (result) { + ReplySubmissionResult.SENT, + ReplySubmissionResult.NOT_RUNNING, + -> dismiss() + + ReplySubmissionResult.INVALID_TEXT -> + error = "Replies must be one line without NUL characters." + + ReplySubmissionResult.INPUT_BACKPRESSURE -> + error = "The session input queue is full. Try again." + } + }, + modifier = Modifier.testTag(TranscriptTags.REPLY_SEND), + ) { Text("Send reply") } + }, + dismissButton = { TextButton(onClick = ::dismiss) { Text("Cancel") } }, + ) +} + @Composable private fun CommandCard( turn: CommandTurn, canSubmit: Boolean, + canReply: Boolean, + onReply: (CommandId, CharArray) -> ReplySubmissionResult, onStop: () -> Unit, onDisconnect: () -> Unit, onEdit: () -> Unit, @@ -874,6 +1019,7 @@ private fun CommandCard( val context = LocalContext.current var expanded by rememberSaveable(turn.id.value) { mutableStateOf(false) } var cardActionsExpanded by remember { mutableStateOf(false) } + var replyOpen by remember { mutableStateOf(false) } var pendingUrl by rememberSaveable(turn.id.value) { mutableStateOf(null) } var linkOpenFailed by rememberSaveable(turn.id.value) { mutableStateOf(false) } val nowMillis = rememberTurnTime(turn, clockMillis) @@ -886,6 +1032,10 @@ private fun CommandCard( 0 } + LaunchedEffect(canReply) { + if (!canReply) replyOpen = false + } + Card(modifier = modifier.fillMaxWidth()) { Column( modifier = Modifier.padding(14.dp), @@ -961,6 +1111,12 @@ private fun CommandCard( Text(if (expanded) "Collapse" else "Show all") } } + if (turn.status == CommandStatus.RUNNING && canReply) { + TextButton( + onClick = { replyOpen = true }, + modifier = Modifier.testTag(TranscriptTags.REPLY), + ) { Text("Reply") } + } when (turn.status) { CommandStatus.SUBMITTED, CommandStatus.RUNNING, @@ -1031,6 +1187,14 @@ private fun CommandCard( } } + if (replyOpen && canReply) { + RunningCommandReplyDialog( + commandId = turn.id, + onReply = onReply, + onDismiss = { replyOpen = false }, + ) + } + pendingUrl?.let { url -> AlertDialog( onDismissRequest = { pendingUrl = null }, diff --git a/app/src/main/java/dev/threadline/core/session/SessionManager.kt b/app/src/main/java/dev/threadline/core/session/SessionManager.kt index 37de00e..5708498 100644 --- a/app/src/main/java/dev/threadline/core/session/SessionManager.kt +++ b/app/src/main/java/dev/threadline/core/session/SessionManager.kt @@ -34,6 +34,8 @@ import dev.threadline.core.transcript.NoOpTranscriptArchiveSink import dev.threadline.core.transcript.TranscriptArchiveSink import dev.threadline.core.transcript.TranscriptSessionArchive import java.io.ByteArrayOutputStream +import java.nio.CharBuffer +import java.nio.charset.CharacterCodingException import java.util.UUID import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CancellationException @@ -57,6 +59,13 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +enum class ReplySubmissionResult { + SENT, + NOT_RUNNING, + INVALID_TEXT, + INPUT_BACKPRESSURE, +} + @OptIn(FlowPreview::class) class SessionManager( private val adapter: SshClientAdapter, @@ -130,21 +139,33 @@ class SessionManager( init { scope.launch { inputRequests.consumeEach { input -> - runCatching { - if (input.hideExactPtyEcho) { - internalInputEchoFilter.expect(input.bytes) - } - input.session.send(input.bytes) + if ( + input.commandId != null && + (structuredState.value as? StructuredShellState.Running) + ?.activeCommand?.id != input.commandId + ) { + input.bytes.fill(0) + return@consumeEach } - .onFailure { - internalInputEchoFilter.cancel() - if ( - liveSession === input.session && - state.value is SessionState.Connected - ) { - failSession(SessionError.ConnectionLost) + try { + runCatching { + if (input.hideExactPtyEcho) { + internalInputEchoFilter.expect(input.bytes) } + input.session.send(input.bytes) } + .onFailure { + internalInputEchoFilter.cancel() + if ( + liveSession === input.session && + state.value is SessionState.Connected + ) { + failSession(SessionError.ConnectionLost) + } + } + } finally { + if (input.clearAfterSend) input.bytes.fill(0) + } } } scope.launch { @@ -229,6 +250,48 @@ class SessionManager( send(byteArrayOf(0x03)) } + fun sendReply(commandId: CommandId, reply: CharArray): ReplySubmissionResult = + synchronized(structuredLock) { + val running = structuredState.value as? StructuredShellState.Running + if ( + state.value !is SessionState.Connected || + running?.activeCommand?.id != commandId + ) { + return@synchronized ReplySubmissionResult.NOT_RUNNING + } + if (reply.any { it == '\r' || it == '\n' || it == '\u0000' }) { + return@synchronized ReplySubmissionResult.INVALID_TEXT + } + val session = liveSession ?: return@synchronized ReplySubmissionResult.NOT_RUNNING + val encoded = try { + Charsets.UTF_8.newEncoder().encode(CharBuffer.wrap(reply)) + } catch (_: CharacterCodingException) { + return@synchronized ReplySubmissionResult.INVALID_TEXT + } + val bytes = try { + ByteArray(encoded.remaining() + 1).also { + encoded.get(it, 0, it.lastIndex) + it[it.lastIndex] = '\r'.code.toByte() + } + } finally { + if (encoded.hasArray()) encoded.array().fill(0) + } + if ( + inputRequests.trySend( + SessionInput( + session = session, + bytes = bytes, + clearAfterSend = true, + commandId = commandId, + ), + ).isFailure + ) { + bytes.fill(0) + return@synchronized ReplySubmissionResult.INPUT_BACKPRESSURE + } + ReplySubmissionResult.SENT + } + fun submitCommand(command: String): CommandSubmissionResult = submitCommand( command = command, executionMode = CommandExecutionMode.PERSISTENT, @@ -707,6 +770,8 @@ class SessionManager( val session: LiveSshSession, val bytes: ByteArray, val hideExactPtyEcho: Boolean = false, + val clearAfterSend: Boolean = false, + val commandId: CommandId? = null, ) private data class StructuredShellContext( diff --git a/app/src/test/java/dev/threadline/core/session/SessionManagerIoTest.kt b/app/src/test/java/dev/threadline/core/session/SessionManagerIoTest.kt index b18114a..d0a1006 100644 --- a/app/src/test/java/dev/threadline/core/session/SessionManagerIoTest.kt +++ b/app/src/test/java/dev/threadline/core/session/SessionManagerIoTest.kt @@ -158,6 +158,76 @@ class SessionManagerIoTest { assertArrayEquals(observed, filter.consume(observed)) } + @Test + fun `reply uses the active PTY without becoming a command or archived input`() = runBlocking { + val session = RecordingSession() + val archiveSink = RecordingTranscriptArchiveSink() + val nonce = SessionNonce("0123456789abcdef0123456789abcdef") + val manager = SessionManager( + adapter = ImmediateAdapter(session), + knownHostStore = EmptyKnownHostStore, + terminal = FakeTerminal, + sessionNonceFactory = { nonce }, + commandIdFactory = { + if (session.sent.isEmpty()) CommandId("bootstrap-probe") + else CommandId("running-command") + }, + transcriptArchiveSink = archiveSink, + ) + + manager.prepareConnection(fixtureRequest()) + manager.connectPrepared() + withTimeout(2_000) { while (session.sent.isEmpty()) delay(10) } + session.output.send( + lifecycleBytes(nonce, CommandId("bootstrap-probe"), 0, "/tmp"), + ) + withTimeout(2_000) { + manager.structuredState.filterIsInstance().first() + } + + val commandId = CommandId("running-command") + assertEquals( + CommandSubmissionResult.Accepted(commandId), + manager.submitCommand("read -r reply"), + ) + assertEquals( + ReplySubmissionResult.NOT_RUNNING, + manager.sendReply(CommandId("other-command"), "wrong".toCharArray()), + ) + assertEquals( + ReplySubmissionResult.INVALID_TEXT, + manager.sendReply(commandId, "two\nlines".toCharArray()), + ) + assertEquals( + ReplySubmissionResult.SENT, + manager.sendReply(commandId, "π-confirm".toCharArray()), + ) + withTimeout(2_000) { while (session.sent.size < 3) delay(10) } + assertTrue(session.sent[1].decodeToString().contains("read -r reply")) + assertArrayEquals("π-confirm\r".encodeToByteArray(), session.sent[2]) + assertEquals(listOf("read -r reply"), manager.transcriptState.value.turns.map { it.command }) + + session.output.send( + lifecycleBytes(nonce, commandId, 0, "/tmp", output = "prompt: "), + ) + withTimeout(2_000) { + manager.structuredState.filterIsInstance() + .first { it.lastCommand?.id == commandId } + } + assertEquals( + ReplySubmissionResult.NOT_RUNNING, + manager.sendReply(commandId, "late".toCharArray()), + ) + assertEquals(3, session.sent.size) + assertEquals("prompt: ", manager.transcriptState.value.turns.single().output.plainText) + + manager.disconnect() + withTimeout(2_000) { while (archiveSink.archives.isEmpty()) delay(10) } + val archived = archiveSink.archives.single().transcript.turns.single() + assertEquals("read -r reply", archived.command) + assertEquals("prompt: ", archived.output.plainText) + } + @Test fun `cancelling input echo expectation releases a partial match`() { val filter = InternalInputEchoFilter() @@ -793,7 +863,7 @@ private class RecordingSession : LiveSshSession { override suspend fun send(bytes: ByteArray) { delay(1) - sent += bytes + sent += bytes.copyOf() } override suspend fun resize(size: TerminalSize): Boolean = true