diff --git a/PROJECT_SPEC.md b/PROJECT_SPEC.md index b8f1405..b088667 100644 --- a/PROJECT_SPEC.md +++ b/PROJECT_SPEC.md @@ -631,6 +631,21 @@ While that command runs, the composer remains editable as a local draft, but Send stays disabled until the structured shell returns to ready. Drafting does not queue or automatically execute a command. +Transcript submission has two explicit execution paths: + +- **Send** evaluates the command in the persistent Bash shell. Working-directory + changes, exported variables, aliases, functions, and shell options can carry + into later turns. +- **Run isolated** executes the command in a child Bash process. It inherits the + current directory and exported environment, but its directory, variable, + function, alias, and shell-option changes do not persist. + +Use isolated execution for script-like blocks that enable `errexit`, `nounset`, +or `pipefail`, or that may call `exit` or `exec`. A failing isolated command must +still emit its exit status and completion marker, and the persistent shell must +accept the next command. Detection of common strict-mode prologues is advisory; +Threadline must not silently change the selected execution path. + ### 10.2 Interactive input When raw mode is open, keyboard and control-key input writes directly to the PTY. @@ -912,6 +927,8 @@ vim Also test: - Multiline shell blocks +- An isolated `set -euo pipefail` block that fails, followed by a successful + persistent command in the same session - Pipes and redirects - Command substitutions - Here-documents diff --git a/README.md b/README.md index 246aae3..391f0c8 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,9 @@ connection setup, and saved transcript history. restoration, local drafting while a turn runs, copy, edit, rerun, output collapsing, selectable output, confirmed HTTP(S) links, and raw-terminal switching +- Explicit persistent and isolated execution paths. Isolated commands run in a + child Bash process so strict-mode failures cannot terminate the persistent + shell, and their mode survives card reruns and saved transcript history - One-active-session Home navigation with explicit Return and Disconnect actions; a second connection remains disabled - Advisory detection of alternate-screen, cursor-addressing, mouse-tracking, diff --git a/app/schemas/dev.threadline.data.db.ThreadlineDatabase/5.json b/app/schemas/dev.threadline.data.db.ThreadlineDatabase/5.json new file mode 100644 index 0000000..fe5f9f9 --- /dev/null +++ b/app/schemas/dev.threadline.data.db.ThreadlineDatabase/5.json @@ -0,0 +1,440 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "c916102a8e62c319dbbbe722fffed8c5", + "entities": [ + { + "tableName": "known_hosts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`endpoint_key` TEXT NOT NULL, `hostname` TEXT NOT NULL, `port` INTEGER NOT NULL, `algorithm` TEXT NOT NULL, `encoded_key` BLOB NOT NULL, `first_seen_at_millis` INTEGER NOT NULL, `last_seen_at_millis` INTEGER NOT NULL, PRIMARY KEY(`endpoint_key`))", + "fields": [ + { + "fieldPath": "endpointKey", + "columnName": "endpoint_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hostname", + "columnName": "hostname", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "port", + "columnName": "port", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "algorithm", + "columnName": "algorithm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encodedKey", + "columnName": "encoded_key", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "firstSeenAtMillis", + "columnName": "first_seen_at_millis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenAtMillis", + "columnName": "last_seen_at_millis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "endpoint_key" + ] + } + }, + { + "tableName": "imported_private_keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `display_name` TEXT NOT NULL, `format` TEXT NOT NULL, `key_type` TEXT NOT NULL, `public_key_fingerprint` TEXT NOT NULL, `ciphertext` BLOB NOT NULL, `initialization_vector` BLOB NOT NULL, `created_at_millis` INTEGER NOT NULL, `crypto_version` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "format", + "columnName": "format", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyType", + "columnName": "key_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeyFingerprint", + "columnName": "public_key_fingerprint", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ciphertext", + "columnName": "ciphertext", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "initializationVector", + "columnName": "initialization_vector", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAtMillis", + "columnName": "created_at_millis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cryptoVersion", + "columnName": "crypto_version", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "host_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `display_name` TEXT NOT NULL, `hostname` TEXT NOT NULL, `port` INTEGER NOT NULL, `username` TEXT NOT NULL, `created_at_millis` INTEGER NOT NULL, `updated_at_millis` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hostname", + "columnName": "hostname", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "port", + "columnName": "port", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtMillis", + "columnName": "created_at_millis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAtMillis", + "columnName": "updated_at_millis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "transcript_sessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`session_id` TEXT NOT NULL, `display_name` TEXT NOT NULL, `hostname` TEXT NOT NULL, `port` INTEGER NOT NULL, `username` TEXT NOT NULL, `started_at_millis` INTEGER NOT NULL, `ended_at_millis` INTEGER NOT NULL, `turns_truncated` INTEGER NOT NULL, PRIMARY KEY(`session_id`))", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hostname", + "columnName": "hostname", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "port", + "columnName": "port", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startedAtMillis", + "columnName": "started_at_millis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endedAtMillis", + "columnName": "ended_at_millis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "turnsTruncated", + "columnName": "turns_truncated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "session_id" + ] + } + }, + { + "tableName": "transcript_turns", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`session_id` TEXT NOT NULL, `command_id` TEXT NOT NULL, `turn_index` INTEGER NOT NULL, `command` TEXT NOT NULL, `execution_mode` TEXT NOT NULL, `command_truncated` INTEGER NOT NULL, `directory_at_start` TEXT, `submitted_at_millis` INTEGER NOT NULL, `started_at_millis` INTEGER, `completed_at_millis` INTEGER, `status` TEXT NOT NULL, `exit_status` INTEGER, `current_directory` TEXT, `output_truncated` INTEGER NOT NULL, `output_approximate` INTEGER NOT NULL, `output_byte_count` INTEGER NOT NULL, PRIMARY KEY(`session_id`, `command_id`), FOREIGN KEY(`session_id`) REFERENCES `transcript_sessions`(`session_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "commandId", + "columnName": "command_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "turnIndex", + "columnName": "turn_index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "command", + "columnName": "command", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "executionMode", + "columnName": "execution_mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "commandTruncated", + "columnName": "command_truncated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "directoryAtStart", + "columnName": "directory_at_start", + "affinity": "TEXT" + }, + { + "fieldPath": "submittedAtMillis", + "columnName": "submitted_at_millis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startedAtMillis", + "columnName": "started_at_millis", + "affinity": "INTEGER" + }, + { + "fieldPath": "completedAtMillis", + "columnName": "completed_at_millis", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "exitStatus", + "columnName": "exit_status", + "affinity": "INTEGER" + }, + { + "fieldPath": "currentDirectory", + "columnName": "current_directory", + "affinity": "TEXT" + }, + { + "fieldPath": "outputTruncated", + "columnName": "output_truncated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outputApproximate", + "columnName": "output_approximate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outputByteCount", + "columnName": "output_byte_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "session_id", + "command_id" + ] + }, + "indices": [ + { + "name": "index_transcript_turns_session_id", + "unique": false, + "columnNames": [ + "session_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transcript_turns_session_id` ON `${TABLE_NAME}` (`session_id`)" + } + ], + "foreignKeys": [ + { + "table": "transcript_sessions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "session_id" + ], + "referencedColumns": [ + "session_id" + ] + } + ] + }, + { + "tableName": "transcript_output_chunks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`session_id` TEXT NOT NULL, `command_id` TEXT NOT NULL, `chunk_index` INTEGER NOT NULL, `text` TEXT NOT NULL, PRIMARY KEY(`session_id`, `command_id`, `chunk_index`), FOREIGN KEY(`session_id`, `command_id`) REFERENCES `transcript_turns`(`session_id`, `command_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "commandId", + "columnName": "command_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chunkIndex", + "columnName": "chunk_index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "session_id", + "command_id", + "chunk_index" + ] + }, + "indices": [ + { + "name": "index_transcript_output_chunks_session_id_command_id", + "unique": false, + "columnNames": [ + "session_id", + "command_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transcript_output_chunks_session_id_command_id` ON `${TABLE_NAME}` (`session_id`, `command_id`)" + } + ], + "foreignKeys": [ + { + "table": "transcript_turns", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "session_id", + "command_id" + ], + "referencedColumns": [ + "session_id", + "command_id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c916102a8e62c319dbbbe722fffed8c5')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/dev/threadline/TranscriptScreenTest.kt b/app/src/androidTest/java/dev/threadline/TranscriptScreenTest.kt index 6ec6d17..932e52c 100644 --- a/app/src/androidTest/java/dev/threadline/TranscriptScreenTest.kt +++ b/app/src/androidTest/java/dev/threadline/TranscriptScreenTest.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.Density import dev.threadline.core.shell.ActiveCommand +import dev.threadline.core.shell.CommandExecutionMode import dev.threadline.core.shell.CommandId import dev.threadline.core.shell.CommandSubmissionRejection import dev.threadline.core.shell.CommandSubmissionResult @@ -91,6 +92,46 @@ class TranscriptScreenTest { ) } + @Test + fun strictCommandCanRunIsolatedWithoutUsingPersistentSubmission() { + var persistentSubmission: String? = null + var isolatedSubmission: String? = null + composeRule.setContent { + MaterialTheme { + TranscriptSurface( + structuredShell = StructuredShellState.Ready("/tmp"), + transcript = CommandTranscriptState(), + onSubmit = { command -> + persistentSubmission = command + CommandSubmissionResult.Accepted(CommandId("persistent")) + }, + onSubmitIsolated = { command -> + isolatedSubmission = command + CommandSubmissionResult.Accepted(CommandId("isolated")) + }, + onStop = {}, + onDisconnect = {}, + ) + } + } + val command = "set -euo pipefail\nfalse" + + composeRule.onNodeWithTag(TranscriptTags.COMPOSER).performTextInput(command) + composeRule.onNodeWithText( + "Persistent Send can close the shell after a failure. " + + "Run isolated unless those changes need to persist.", + ).assertIsDisplayed() + composeRule.onNodeWithTag(TranscriptTags.RUN_ISOLATED) + .assertIsEnabled() + .performClick() + + composeRule.runOnIdle { + assertEquals(null, persistentSubmission) + assertEquals(command, isolatedSubmission) + } + assertComposerText("") + } + @Test fun historyNavigationPreservesMultilineCommandsAndRestoresDraft() { val transcript = CommandTranscriptState( @@ -275,6 +316,44 @@ class TranscriptScreenTest { composeRule.onNodeWithTag(TranscriptTags.HISTORY_NEWER).assertIsNotEnabled() } + @Test + fun isolatedCardIsLabeledAndRerunsInIsolatedMode() { + var persistentSubmission: String? = null + var isolatedSubmission: String? = null + val turn = turn( + command = "printf isolated", + executionMode = CommandExecutionMode.ISOLATED, + status = CommandStatus.SUCCEEDED, + ) + composeRule.setContent { + MaterialTheme { + TranscriptSurface( + structuredShell = StructuredShellState.Ready("/tmp"), + transcript = CommandTranscriptState(turns = listOf(turn)), + onSubmit = { command -> + persistentSubmission = command + CommandSubmissionResult.Accepted(CommandId("persistent-rerun")) + }, + onSubmitIsolated = { command -> + isolatedSubmission = command + CommandSubmissionResult.Accepted(CommandId("isolated-rerun")) + }, + onStop = {}, + onDisconnect = {}, + ) + } + } + + composeRule.onNodeWithText("Isolated · Succeeded · /tmp · 35 ms · exit 0") + .assertExists() + composeRule.onNodeWithText("Rerun").performScrollTo().performClick() + + composeRule.runOnIdle { + assertEquals(null, persistentSubmission) + assertEquals(turn.command, isolatedSubmission) + } + } + @Test fun completedTurnShowsCommandOutputAndSemanticStatus() { val turn = CommandTurn( @@ -925,6 +1004,7 @@ class TranscriptScreenTest { private fun turn( id: String = "command-42", command: String = "test command", + executionMode: CommandExecutionMode = CommandExecutionMode.PERSISTENT, status: CommandStatus, submittedAtMillis: Long = 100L, startedAtMillis: Long? = 110L, @@ -934,6 +1014,7 @@ class TranscriptScreenTest { ) = CommandTurn( id = CommandId(id), command = command, + executionMode = executionMode, directoryAtStart = "/tmp", submittedAtMillis = submittedAtMillis, startedAtMillis = startedAtMillis, 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 8aa33ca..6958c5b 100644 --- a/app/src/androidTest/java/dev/threadline/core/session/AndroidStructuredShellIntegrationTest.kt +++ b/app/src/androidTest/java/dev/threadline/core/session/AndroidStructuredShellIntegrationTest.kt @@ -248,6 +248,14 @@ class AndroidStructuredShellIntegrationTest { assertEquals(1, failed.exitStatus) assertEquals("/tmp", failed.currentDirectory) + val strictFailure = executeIsolated( + manager, + "set -euo pipefail; false; printf unreachable", + ) + assertEquals(1, strictFailure.exitStatus) + assertEquals("/tmp", strictFailure.currentDirectory) + assertSuccessful(manager, "printf 'strict-mode-recovered\\n'") + assertSuccessful( manager, "value='two lines'\ntest \"\$value\" = 'two lines'", @@ -452,6 +460,14 @@ class AndroidStructuredShellIntegrationTest { return awaitCompletion(manager, submission.commandId) } + private suspend fun executeIsolated( + manager: SessionManager, + command: String, + ): CompletedCommand { + val submission = accepted(manager.submitIsolatedCommand(command)) + return awaitCompletion(manager, submission.commandId) + } + private suspend fun awaitCompletion( manager: SessionManager, commandId: CommandId, diff --git a/app/src/androidTest/java/dev/threadline/data/db/ThreadlineDatabaseMigrationTest.kt b/app/src/androidTest/java/dev/threadline/data/db/ThreadlineDatabaseMigrationTest.kt index c5369d2..2b5bc21 100644 --- a/app/src/androidTest/java/dev/threadline/data/db/ThreadlineDatabaseMigrationTest.kt +++ b/app/src/androidTest/java/dev/threadline/data/db/ThreadlineDatabaseMigrationTest.kt @@ -144,6 +144,50 @@ class ThreadlineDatabaseMigrationTest { migrated.close() } + @Test + @Throws(IOException::class) + fun migrationFromFourMarksExistingTurnsPersistent() { + helper.createDatabase(DATABASE_NAME, 4).apply { + execSQL( + """ + INSERT INTO transcript_sessions ( + session_id, display_name, hostname, port, username, + started_at_millis, ended_at_millis, turns_truncated + ) VALUES ('session-id', 'Fixture', 'fixture.test', 22, 'threadline', 10, 20, 0) + """.trimIndent(), + ) + execSQL( + """ + INSERT INTO transcript_turns ( + session_id, command_id, turn_index, command, command_truncated, + directory_at_start, submitted_at_millis, started_at_millis, + completed_at_millis, status, exit_status, current_directory, + output_truncated, output_approximate, output_byte_count + ) VALUES ( + 'session-id', 'command-id', 0, 'pwd', 0, '/tmp', 11, 12, 13, + 'SUCCEEDED', 0, '/tmp', 0, 0, 5 + ) + """.trimIndent(), + ) + close() + } + + val migrated = helper.runMigrationsAndValidate( + DATABASE_NAME, + 5, + true, + ThreadlineDatabase.MIGRATION_4_5, + ) + + migrated.query( + "SELECT execution_mode FROM transcript_turns WHERE command_id = 'command-id'", + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("PERSISTENT", cursor.getString(0)) + } + migrated.close() + } + private companion object { const val DATABASE_NAME = "threadline-migration-test" } diff --git a/app/src/androidTest/java/dev/threadline/data/transcript/RoomTranscriptHistoryStoreTest.kt b/app/src/androidTest/java/dev/threadline/data/transcript/RoomTranscriptHistoryStoreTest.kt index d44486d..7264fe4 100644 --- a/app/src/androidTest/java/dev/threadline/data/transcript/RoomTranscriptHistoryStoreTest.kt +++ b/app/src/androidTest/java/dev/threadline/data/transcript/RoomTranscriptHistoryStoreTest.kt @@ -6,6 +6,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import dev.threadline.core.model.HostEndpoint import dev.threadline.core.model.HostProfile +import dev.threadline.core.shell.CommandExecutionMode import dev.threadline.core.shell.CommandId import dev.threadline.core.transcript.CommandOutput import dev.threadline.core.transcript.CommandStatus @@ -57,6 +58,13 @@ class RoomTranscriptHistoryStoreTest { val turns = (0..RoomTranscriptHistoryStore.MAXIMUM_TURNS_PER_SESSION).map { index -> turn( index = index, + executionMode = if ( + index == RoomTranscriptHistoryStore.MAXIMUM_TURNS_PER_SESSION + ) { + CommandExecutionMode.ISOLATED + } else { + CommandExecutionMode.PERSISTENT + }, command = if (index == 0) { "discarded command" } else if (index == RoomTranscriptHistoryStore.MAXIMUM_TURNS_PER_SESSION) { @@ -98,6 +106,7 @@ class RoomTranscriptHistoryStoreTest { ) assertEquals(retainedOutput, last.turn.output.plainText) assertEquals(output.encodeToByteArray().size.toLong(), last.turn.output.byteCount) + assertEquals(CommandExecutionMode.ISOLATED, last.turn.executionMode) val chunkRows = database.transcriptArchives().findChunks(summary.id) .filter { it.commandId == last.turn.id.value } @@ -150,6 +159,7 @@ class RoomTranscriptHistoryStoreTest { ThreadlineDatabase.MIGRATION_1_2, ThreadlineDatabase.MIGRATION_2_3, ThreadlineDatabase.MIGRATION_3_4, + ThreadlineDatabase.MIGRATION_4_5, ).build() private fun archive( @@ -170,11 +180,13 @@ class RoomTranscriptHistoryStoreTest { private fun turn( index: Int, + executionMode: CommandExecutionMode = CommandExecutionMode.PERSISTENT, command: String = "printf $index", output: String = "output $index", ) = CommandTurn( id = CommandId("command-$index"), command = command, + executionMode = executionMode, directoryAtStart = "/tmp", submittedAtMillis = 10, startedAtMillis = 11, diff --git a/app/src/main/java/dev/threadline/MainActivity.kt b/app/src/main/java/dev/threadline/MainActivity.kt index 69251e8..34eac13 100644 --- a/app/src/main/java/dev/threadline/MainActivity.kt +++ b/app/src/main/java/dev/threadline/MainActivity.kt @@ -266,6 +266,7 @@ private fun ThreadlineApp() { structuredShell = snapshot.structuredShell, transcript = snapshot.transcript, onSubmit = manager::submitCommand, + onSubmitIsolated = manager::submitIsolatedCommand, onControlC = manager::sendControlC, onDisconnect = manager::disconnect, onOpenHome = { showConnectedSession = false }, diff --git a/app/src/main/java/dev/threadline/TranscriptScreen.kt b/app/src/main/java/dev/threadline/TranscriptScreen.kt index fa22826..e75f42f 100644 --- a/app/src/main/java/dev/threadline/TranscriptScreen.kt +++ b/app/src/main/java/dev/threadline/TranscriptScreen.kt @@ -65,9 +65,11 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp +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.terminal.TerminalKey import dev.threadline.core.terminal.TerminalModifiers import dev.threadline.core.transcript.AnsiColor @@ -84,6 +86,7 @@ internal object TranscriptTags { const val TRANSCRIPT = "session-transcript" const val COMPOSER = "command-composer" const val SEND = "command-send" + const val RUN_ISOLATED = "command-run-isolated" const val HISTORY_OLDER = "command-history-older" const val HISTORY_NEWER = "command-history-newer" const val MODE_SWITCH = "session-mode-switch" @@ -127,6 +130,7 @@ internal fun ConnectedSessionScreen( structuredShell: StructuredShellState, transcript: CommandTranscriptState, onSubmit: (String) -> CommandSubmissionResult, + onSubmitIsolated: ((String) -> CommandSubmissionResult)? = null, onControlC: () -> Unit, onDisconnect: () -> Unit, onOpenHome: () -> Unit = {}, @@ -208,6 +212,7 @@ internal fun ConnectedSessionScreen( structuredShell = structuredShell, transcript = transcript, onSubmit = onSubmit, + onSubmitIsolated = onSubmitIsolated, onStop = onControlC, onDisconnect = onDisconnect, onOpenTerminal = { rawModeRequested = true }, @@ -305,6 +310,7 @@ internal fun TranscriptSurface( structuredShell: StructuredShellState, transcript: CommandTranscriptState, onSubmit: (String) -> CommandSubmissionResult, + onSubmitIsolated: ((String) -> CommandSubmissionResult)? = null, onStop: () -> Unit, onDisconnect: () -> Unit, onOpenTerminal: () -> Unit = {}, @@ -345,9 +351,17 @@ internal fun TranscriptSurface( } } - fun submit(command: String, clearComposer: Boolean) { + fun submit( + command: String, + clearComposer: Boolean, + executionMode: CommandExecutionMode = CommandExecutionMode.PERSISTENT, + ) { submissionError = null - when (val result = onSubmit(command)) { + val result = when (executionMode) { + CommandExecutionMode.PERSISTENT -> onSubmit(command) + CommandExecutionMode.ISOLATED -> requireNotNull(onSubmitIsolated)(command) + } + when (result) { is CommandSubmissionResult.Accepted -> { if (clearComposer) { composer = "" @@ -428,7 +442,13 @@ internal fun TranscriptSurface( historyDraft = turn.command submissionError = null }, - onRerun = { submit(turn.command, clearComposer = false) }, + onRerun = { + submit( + command = turn.command, + clearComposer = false, + executionMode = turn.executionMode, + ) + }, onOpenUrl = openUrl, onOpenTerminal = onOpenTerminal, clockMillis = clockMillis, @@ -485,6 +505,37 @@ internal fun TranscriptSurface( Text("Send") } } + if ( + onSubmitIsolated != null && + commandMayChangePersistentStrictMode(composer) + ) { + Text( + "Persistent Send can close the shell after a failure. " + + "Run isolated unless those changes need to persist.", + style = MaterialTheme.typography.bodySmall, + ) + } + if (onSubmitIsolated != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton( + onClick = { + submit( + command = composer, + clearComposer = true, + executionMode = CommandExecutionMode.ISOLATED, + ) + }, + enabled = structuredShell is StructuredShellState.Ready && + composer.isNotEmpty(), + modifier = Modifier.testTag(TranscriptTags.RUN_ISOLATED), + ) { + Text("Run isolated") + } + } + } if (historyCommands.isNotEmpty()) { Row( modifier = Modifier.fillMaxWidth(), @@ -753,6 +804,7 @@ private fun rememberTurnTime( } private fun CommandTurn.metadataLabel(nowMillis: Long): String = buildList { + if (executionMode == CommandExecutionMode.ISOLATED) add("Isolated") add(status.label) directoryAtStart?.let(::add) durationMillis(nowMillis)?.let { add(formatDuration(it)) } 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 eb38366..1637323 100644 --- a/app/src/main/java/dev/threadline/core/session/SessionManager.kt +++ b/app/src/main/java/dev/threadline/core/session/SessionManager.kt @@ -10,6 +10,7 @@ import dev.threadline.core.model.TerminalSize import dev.threadline.core.security.KnownHostStore import dev.threadline.core.security.StrictHostKeyGate import dev.threadline.core.shell.BashShellIntegration +import dev.threadline.core.shell.CommandExecutionMode import dev.threadline.core.shell.CommandId import dev.threadline.core.shell.CommandSubmissionRejection import dev.threadline.core.shell.CommandSubmissionResult @@ -217,7 +218,20 @@ class SessionManager( send(byteArrayOf(0x03)) } - fun submitCommand(command: String): CommandSubmissionResult = synchronized(structuredLock) { + fun submitCommand(command: String): CommandSubmissionResult = submitCommand( + command = command, + executionMode = CommandExecutionMode.PERSISTENT, + ) + + fun submitIsolatedCommand(command: String): CommandSubmissionResult = submitCommand( + command = command, + executionMode = CommandExecutionMode.ISOLATED, + ) + + private fun submitCommand( + command: String, + executionMode: CommandExecutionMode, + ): CommandSubmissionResult = synchronized(structuredLock) { val currentState = structuredState.value if (currentState is StructuredShellState.Running) { return@synchronized CommandSubmissionResult.Rejected( @@ -244,13 +258,14 @@ class SessionManager( } val commandId = commandIdFactory() - val invocation = context.integration.invocation(commandId, command) + val invocation = context.integration.invocation(commandId, command, executionMode) structuredStateMachine.apply( StructuredShellEvent.CommandSubmitted(commandId, command), ) commandTranscript.commandSubmitted( id = commandId, command = command, + executionMode = executionMode, directoryAtStart = currentState.currentDirectory, ) if (inputRequests.trySend(SessionInput(session, invocation)).isFailure) { diff --git a/app/src/main/java/dev/threadline/core/shell/BashShellIntegration.kt b/app/src/main/java/dev/threadline/core/shell/BashShellIntegration.kt index 976f72c..4e1af1f 100644 --- a/app/src/main/java/dev/threadline/core/shell/BashShellIntegration.kt +++ b/app/src/main/java/dev/threadline/core/shell/BashShellIntegration.kt @@ -17,6 +17,20 @@ object ShellWordQuoter { } } +enum class CommandExecutionMode( + internal val shellToken: String, +) { + PERSISTENT("persistent"), + ISOLATED("isolated"), +} + +internal fun commandMayChangePersistentStrictMode(command: String): Boolean = + STRICT_SHELL_OPTION_LINE.containsMatchIn(command) + +private val STRICT_SHELL_OPTION_LINE = Regex( + pattern = """(?m)^[\t ]*set[\t ]+(?:-[A-Za-z]*[eu][A-Za-z]*|-o[\t ]+(?:errexit|nounset|pipefail))(?:[\t ;]|$)""", +) + /** * Builds the temporary Bash integration installed into one persistent PTY * shell. The nonce is restricted to safe identifier characters by @@ -32,6 +46,7 @@ class BashShellIntegration( append("() {\n") append(" local __tl_id=\"\$1\"\n") append(" local __tl_command=\"\$2\"\n") + append(" local __tl_mode=\"\$3\"\n") append(" local __tl_exit\n") append(" local __tl_previous_int_trap\n") append(" __tl_previous_int_trap=\"\$(trap -p INT)\"\n") @@ -48,6 +63,9 @@ class BashShellIntegration( append(" printf \"\\033]777;threadline;") append(sessionNonce.value) append(";end;%s;%s;%s\\007\" \"\$__tl_id\" \"\$__tl_exit\" \"\$PWD\"\n") + append(" if [[ \"\$__tl_mode\" == isolated ]]; then\n") + append(" return 0\n") + append(" fi\n") append(" return \"\$__tl_exit\"\n") append(" ' INT\n") append(" printf '\\033]777;threadline;") @@ -56,8 +74,16 @@ class BashShellIntegration( append(" printf '\\033]777;threadline;") append(sessionNonce.value) append(";output;%s\\007' \"\$__tl_id\"\n") - append(" builtin eval -- \"\$__tl_command\"\n") - append(" __tl_exit=\$?\n") + append(" if [[ \"\$__tl_mode\" == isolated ]]; then\n") + append(" if command bash --noprofile --norc -c \"\$__tl_command\"; then\n") + append(" __tl_exit=0\n") + append(" else\n") + append(" __tl_exit=\$?\n") + append(" fi\n") + append(" else\n") + append(" builtin eval -- \"\$__tl_command\"\n") + append(" __tl_exit=\$?\n") + append(" fi\n") append(" if [[ -n \"\$__tl_previous_int_trap\" ]]; then\n") append(" builtin eval -- \"\$__tl_previous_int_trap\"\n") append(" else\n") @@ -66,6 +92,9 @@ class BashShellIntegration( append(" printf '\\033]777;threadline;") append(sessionNonce.value) append(";end;%s;%s;%s\\007' \"\$__tl_id\" \"\$__tl_exit\" \"\$PWD\"\n") + append(" if [[ \"\$__tl_mode\" == isolated ]]; then\n") + append(" return 0\n") + append(" fi\n") append(" return \"\$__tl_exit\"\n") append("}\n") append(invocationText(probeCommandId, NO_OP_COMMAND)) @@ -74,17 +103,21 @@ class BashShellIntegration( fun invocation( commandId: CommandId, command: String, - ): ByteArray = invocationText(commandId, command).encodeToByteArray() + executionMode: CommandExecutionMode = CommandExecutionMode.PERSISTENT, + ): ByteArray = invocationText(commandId, command, executionMode).encodeToByteArray() private fun invocationText( commandId: CommandId, command: String, + executionMode: CommandExecutionMode = CommandExecutionMode.PERSISTENT, ): String = buildString { append(functionName) append(' ') append(ShellWordQuoter.quote(commandId.value)) append(' ') append(ShellWordQuoter.quote(command)) + append(' ') + append(ShellWordQuoter.quote(executionMode.shellToken)) append('\n') } diff --git a/app/src/main/java/dev/threadline/core/transcript/CommandTranscript.kt b/app/src/main/java/dev/threadline/core/transcript/CommandTranscript.kt index a0e840c..e6fb2e2 100644 --- a/app/src/main/java/dev/threadline/core/transcript/CommandTranscript.kt +++ b/app/src/main/java/dev/threadline/core/transcript/CommandTranscript.kt @@ -1,5 +1,6 @@ package dev.threadline.core.transcript +import dev.threadline.core.shell.CommandExecutionMode import dev.threadline.core.shell.CommandId import dev.threadline.core.shell.ShellLifecycleEvent import kotlinx.coroutines.flow.MutableStateFlow @@ -25,6 +26,7 @@ internal class CommandTranscript( id: CommandId, command: String, directoryAtStart: String?, + executionMode: CommandExecutionMode = CommandExecutionMode.PERSISTENT, ) { check(active == null) { "Only one transcript command may be active" } while (turns.size >= maximumTurns) { @@ -33,6 +35,7 @@ internal class CommandTranscript( MutableTurn( id = id, command = command, + executionMode = executionMode, directoryAtStart = directoryAtStart, submittedAtMillis = clockMillis(), collector = collectorFactory(), @@ -153,6 +156,7 @@ internal class CommandTranscript( private class MutableTurn( val id: CommandId, val command: String, + val executionMode: CommandExecutionMode, val directoryAtStart: String?, val submittedAtMillis: Long, val collector: TranscriptCollector, @@ -169,6 +173,7 @@ internal class CommandTranscript( fun snapshot() = CommandTurn( id = id, command = command, + executionMode = executionMode, directoryAtStart = directoryAtStart, submittedAtMillis = submittedAtMillis, startedAtMillis = startedAtMillis, diff --git a/app/src/main/java/dev/threadline/core/transcript/TranscriptModels.kt b/app/src/main/java/dev/threadline/core/transcript/TranscriptModels.kt index becdc7d..e8e8dc0 100644 --- a/app/src/main/java/dev/threadline/core/transcript/TranscriptModels.kt +++ b/app/src/main/java/dev/threadline/core/transcript/TranscriptModels.kt @@ -1,5 +1,6 @@ package dev.threadline.core.transcript +import dev.threadline.core.shell.CommandExecutionMode import dev.threadline.core.shell.CommandId enum class CommandStatus { @@ -74,6 +75,7 @@ enum class InteractiveTerminalHint { data class CommandTurn( val id: CommandId, val command: String, + val executionMode: CommandExecutionMode = CommandExecutionMode.PERSISTENT, val directoryAtStart: String?, val submittedAtMillis: Long, val startedAtMillis: Long?, diff --git a/app/src/main/java/dev/threadline/data/db/ThreadlineDatabase.kt b/app/src/main/java/dev/threadline/data/db/ThreadlineDatabase.kt index 391fbec..95f7059 100644 --- a/app/src/main/java/dev/threadline/data/db/ThreadlineDatabase.kt +++ b/app/src/main/java/dev/threadline/data/db/ThreadlineDatabase.kt @@ -233,6 +233,8 @@ internal data class TranscriptTurnEntity( @ColumnInfo(name = "turn_index") val turnIndex: Int, val command: String, + @ColumnInfo(name = "execution_mode") + val executionMode: String, @ColumnInfo(name = "command_truncated") val commandTruncated: Boolean, @ColumnInfo(name = "directory_at_start") @@ -398,7 +400,7 @@ internal interface TranscriptArchiveDao { TranscriptTurnEntity::class, TranscriptOutputChunkEntity::class, ], - version = 4, + version = 5, exportSchema = true, ) internal abstract class ThreadlineDatabase : RoomDatabase() { @@ -415,7 +417,12 @@ internal abstract class ThreadlineDatabase : RoomDatabase() { context.applicationContext, ThreadlineDatabase::class.java, DATABASE_NAME, - ).addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4).build() + ).addMigrations( + MIGRATION_1_2, + MIGRATION_2_3, + MIGRATION_3_4, + MIGRATION_4_5, + ).build() internal val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { @@ -523,5 +530,14 @@ internal abstract class ThreadlineDatabase : RoomDatabase() { ) } } + + internal val MIGRATION_4_5 = object : Migration(4, 5) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE `transcript_turns` ADD COLUMN " + + "`execution_mode` TEXT NOT NULL DEFAULT 'PERSISTENT'", + ) + } + } } } diff --git a/app/src/main/java/dev/threadline/data/transcript/RoomTranscriptHistoryStore.kt b/app/src/main/java/dev/threadline/data/transcript/RoomTranscriptHistoryStore.kt index 7ecf809..e41ae5e 100644 --- a/app/src/main/java/dev/threadline/data/transcript/RoomTranscriptHistoryStore.kt +++ b/app/src/main/java/dev/threadline/data/transcript/RoomTranscriptHistoryStore.kt @@ -1,5 +1,6 @@ package dev.threadline.data.transcript +import dev.threadline.core.shell.CommandExecutionMode import dev.threadline.core.shell.CommandId import dev.threadline.core.transcript.CommandOutput import dev.threadline.core.transcript.CommandStatus @@ -74,6 +75,7 @@ internal class RoomTranscriptHistoryStore( commandId = turn.id.value, turnIndex = index, command = retainedCommand, + executionMode = turn.executionMode.name, commandTruncated = retainedCommand.length < turn.command.length, directoryAtStart = turn.directoryAtStart, submittedAtMillis = turn.submittedAtMillis, @@ -179,6 +181,7 @@ private fun TranscriptSessionRows.toSavedSession(): SavedTranscriptSession { turn = CommandTurn( id = CommandId(entity.commandId), command = entity.command, + executionMode = CommandExecutionMode.valueOf(entity.executionMode), directoryAtStart = entity.directoryAtStart, submittedAtMillis = entity.submittedAtMillis, startedAtMillis = entity.startedAtMillis, 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 abc88cc..ad3f8a5 100644 --- a/app/src/test/java/dev/threadline/core/session/SessionManagerIoTest.kt +++ b/app/src/test/java/dev/threadline/core/session/SessionManagerIoTest.kt @@ -9,6 +9,7 @@ import dev.threadline.core.model.SessionState import dev.threadline.core.model.TerminalSize import dev.threadline.core.security.KnownHostRecord import dev.threadline.core.security.KnownHostStore +import dev.threadline.core.shell.CommandExecutionMode import dev.threadline.core.shell.CommandId import dev.threadline.core.shell.CommandSubmissionRejection import dev.threadline.core.shell.CommandSubmissionResult @@ -264,6 +265,7 @@ class SessionManagerIoTest { assertEquals("/tmp", turn.currentDirectory) assertEquals(1, turn.exitStatus) assertEquals(CommandStatus.FAILED, turn.status) + assertEquals(CommandExecutionMode.PERSISTENT, turn.executionMode) assertEquals( CommandOutput( plainText = "visible output\n", @@ -277,7 +279,15 @@ class SessionManagerIoTest { ) assertEquals( CommandSubmissionResult.Accepted(CommandId("second-command")), - manager.submitCommand("printf next"), + manager.submitIsolatedCommand("printf next"), + ) + withTimeout(2_000) { + while (session.sent.size < 3) delay(10) + } + assertTrue(session.sent.last().decodeToString().endsWith("'isolated'\n")) + assertEquals( + CommandExecutionMode.ISOLATED, + manager.transcriptState.value.turns.last().executionMode, ) manager.disconnect() diff --git a/app/src/test/java/dev/threadline/core/shell/BashShellIntegrationTest.kt b/app/src/test/java/dev/threadline/core/shell/BashShellIntegrationTest.kt index a1e3898..a03f995 100644 --- a/app/src/test/java/dev/threadline/core/shell/BashShellIntegrationTest.kt +++ b/app/src/test/java/dev/threadline/core/shell/BashShellIntegrationTest.kt @@ -1,5 +1,6 @@ package dev.threadline.core.shell +import java.util.concurrent.TimeUnit import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertThrows @@ -113,11 +114,56 @@ class BashShellIntegrationTest { assertEquals( "__threadline_run_${nonce.value} 'command-42' " + - "'printf '\\''%s\n'\\'' \"\$(uname)\"'\n", + "'printf '\\''%s\n'\\'' \"\$(uname)\"' 'persistent'\n", invocation, ) } + @Test + fun `isolated strict command cannot terminate the integration shell`() { + val persistentStrictId = CommandId("persistent-strict-command") + val strictId = CommandId("strict-command") + val nextId = CommandId("next-command") + val process = ProcessBuilder("bash", "--noprofile", "--norc") + .redirectErrorStream(true) + .start() + + try { + process.outputStream.use { input -> + input.write(integration.bootstrap(CommandId("bootstrap-probe"))) + input.write( + integration.invocation( + commandId = persistentStrictId, + command = "set -euo pipefail", + ), + ) + input.write( + integration.invocation( + commandId = strictId, + command = "set -euo pipefail; false; printf unreachable", + executionMode = CommandExecutionMode.ISOLATED, + ), + ) + input.write( + integration.invocation( + commandId = nextId, + command = "printf 'next-command-ran\\n'", + ), + ) + } + + assertTrue("Bash did not exit", process.waitFor(5, TimeUnit.SECONDS)) + val output = process.inputStream.readBytes().decodeToString() + assertEquals(output, 0, process.exitValue()) + assertTrue(output.contains(";end;${persistentStrictId.value};0;")) + assertTrue(output.contains(";end;${strictId.value};1;")) + assertTrue(output.contains("next-command-ran")) + assertTrue(output.contains(";end;${nextId.value};0;")) + } finally { + process.destroyForcibly() + } + } + @Test fun `bootstrap installs nonce-scoped function and runs no-op probe`() { val probeId = CommandId("bootstrap-probe") @@ -129,6 +175,12 @@ class BashShellIntegrationTest { "printf '\\033]777;threadline;${nonce.value};start;%s\\007'", ), ) + assertTrue(bootstrap.contains("[[ \"\$__tl_mode\" == isolated ]]")) + assertTrue( + bootstrap.contains( + "command bash --noprofile --norc -c \"\$__tl_command\"", + ), + ) assertTrue(bootstrap.contains("builtin eval -- \"\$__tl_command\"")) assertTrue(bootstrap.contains("trap '\n __tl_exit=130\n")) assertTrue( @@ -148,7 +200,7 @@ class BashShellIntegrationTest { assertFalse(bootstrap.contains("__tl_interrupted")) assertTrue( bootstrap.endsWith( - "__threadline_run_${nonce.value} 'bootstrap-probe' ':'\n", + "__threadline_run_${nonce.value} 'bootstrap-probe' ':' 'persistent'\n", ), ) assertFalse(bootstrap.contains("\u001b]")) @@ -161,4 +213,14 @@ class BashShellIntegrationTest { assertEquals(36, id.value.length) assertTrue(id.value.all { it.isLetterOrDigit() || it == '-' }) } + + @Test + fun `strict shell option warning recognizes common prologues`() { + assertTrue(commandMayChangePersistentStrictMode("set -euo pipefail\nprintf ready")) + assertTrue(commandMayChangePersistentStrictMode(" set -o errexit")) + assertTrue(commandMayChangePersistentStrictMode("set -o nounset")) + assertTrue(commandMayChangePersistentStrictMode("set -o pipefail")) + assertFalse(commandMayChangePersistentStrictMode("printf 'set -e'")) + assertFalse(commandMayChangePersistentStrictMode("set +e")) + } } diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index be40d51..fac9288 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -113,6 +113,50 @@ disconnect handling, and whether queued content is persisted. Never send a queued command merely because the shell returned to readiness unless the UI made that execution contract unambiguous. +## Persistent-shell exit recovery + +**Status:** Isolated execution implemented in post-alpha.7 source; automatic +shell restart remains deferred. + +Transcript Send intentionally evaluates inside the persistent Bash shell so +directory changes, exports, aliases, and functions survive between cards. That +also means a command can enable `errexit` or `nounset`, call `exit` or `exec`, +and terminate the shell before Threadline emits its completion marker. + +Run isolated now executes script-like input in a child Bash process. A strict +block can fail with its real exit status while the persistent shell remains +available for the next command. Common strict-option prologues show an advisory +warning, isolated cards are labeled, and reruns preserve their execution mode. +The mode is also retained in saved transcript history. Persistent Send remains +available because changing live shell state is sometimes intentional. + +A later recovery slice may retain the transcript and offer a fresh shell when +the persistent shell itself exits. It must say that shell state was lost. Do +not imply that a restarted shell preserved a directory, variable, function, or +process that died with the old shell. + +## Responses to running commands + +**Status:** Deferred interaction and security design; same-session raw terminal +input is the current path. + +Plain prompts such as `read -p`, package-manager confirmations, and `sudo` +password requests do not necessarily emit terminal control sequences. The +current interactive suggestion can therefore miss them even though the raw +terminal can already send exact input to the waiting process. + +A transcript-mode response control should remain separate from both the next +command draft and a future command queue. Ordinary replies may be sent as +ephemeral PTY input without entering command history. Sensitive replies need a +masked, short-lived entry path that is never persisted or copied into +diagnostics. Threadline cannot promise that a reply stays out of the transcript +if the remote program leaves terminal echo enabled or prints it back. + +Prompt recognition is advisory only. Remote output is untrusted and any +program can print text resembling a password request, so Threadline must not +present a guessed prompt as authenticated `sudo` or inject a guessed answer. +Keep a manual terminal handoff reachable for every running command. + ## Opt-in saved password authentication **Status:** Deferred product and security decision; not an assumed future diff --git a/docs/STATUS.md b/docs/STATUS.md index 461eeb7..2a10122 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,6 +1,6 @@ # Threadline current status -Updated: 2026-08-11 +Updated: 2026-08-16 This is the canonical execution-status page. `PROJECT_SPEC.md` remains the normative product and technical specification. Dated investigations are historical evidence for the boundary they @@ -123,6 +123,16 @@ Galaxy S25 Ultra, and passed the retained-session Home and Return path. While a disabled. Send enabled when the shell returned to ready, and the draft then ran normally. This completes alpha.7 owner-device acceptance. +Post-alpha.7 source work adds an explicit isolated execution path for +script-like commands. It runs the exact composer text in a child Bash process, +reports the child exit status in the normal command card, and leaves the +persistent shell available after strict-mode failures. Persistent Send remains +the state-carrying path. Common `set -e`, `set -u`, and long-form strict-option +prologues produce an advisory warning; isolated cards and saved history retain +their execution mode. Host-side Bash regression, JVM, Compose, Room migration, +and fixture-backed production SSH tests cover the new boundary. Owner-device +acceptance remains pending for the next signed candidate. + ## Remaining Phase 5 boundaries - Technical-alpha use sufficient to evaluate the Phase 5 exit criterion.