diff --git a/FrameworkTests/RemoteMultiplexerShellTests.swift b/FrameworkTests/RemoteMultiplexerShellTests.swift new file mode 100644 index 00000000..c39cc2e0 --- /dev/null +++ b/FrameworkTests/RemoteMultiplexerShellTests.swift @@ -0,0 +1,457 @@ +#if os(macOS) + import Foundation + import TermindApplication + import TermindDomain + import Testing + + @testable import TermindRemoteInfrastructure + + @Suite + nonisolated struct RemoteMultiplexerShellTests { + private static let shells = ["/bin/bash", "/bin/zsh", "/opt/homebrew/bin/fish"] + .filter { FileManager.default.isExecutableFile(atPath: $0) } + + @Test(arguments: [ + ("100 100 100 pts/4 /bin/sh\n", true), + ("100 100 100 pts/4 -ksh\n", true), + ("100 100 100 pts/4 tmux\n", false), + ("100 100 200 pts/4 bash\n200 200 200 pts/4 tmux\n", false), + ("100 100 100 pts/4 bash\n200 100 100 pts/4 vim\n", false), + ("100 100 100 pts/4 bash\n200 100 100 pts/4 zsh\n", false), + ("100 100 100 pts/5 sh\n", false), + ("100 100 100 pts/4 unknown\n", false), + ("malformed\n", false), + ("", false), + ]) + func fallbackRequiresOneVerifiedForegroundShell(value: (String, Bool)) async throws { + let fixture = try TmuxFixture() + defer { fixture.cleanUp() } + let probe = RemoteTerminalProcessProbe { command in + try Self.run( + "ps() { printf '%s' \(try RemoteMultiplexerCommands.quote(value.0)); }\n" + command, + home: fixture.home.path) + } + #expect(try await probe.isOrdinaryShell(terminal: "/dev/pts/4") == value.1) + } + + @Test(arguments: [false, true]) + func tmuxCreationPreservesTheInteractiveLocale(explicitLocale: Bool) throws { + let fixture = try TmuxFixture() + defer { fixture.cleanUp() } + let result = try fixture.run( + """ + export STUB_EMPTY=1 LANG=en_US.UTF-8 + \(explicitLocale ? "export LC_ALL=C" : "unset LC_ALL") + \(try RemoteTmuxCommands.create(token: fixture.owner, startupCommand: nil, shellIntegration: true)) + code=$? + rm -rf "$helper" + exit "$code" + """) + #expect(result.exitCode == 0, "\(result.stdout)") + let locale = try String(contentsOf: fixture.home.appendingPathComponent("locale"), encoding: .utf8) + #expect(locale == (explicitLocale ? "x|C|en_US.UTF-8" : "||en_US.UTF-8")) + #expect(FileManager.default.fileExists(atPath: fixture.home.appendingPathComponent("created").path)) + } + + @Test func ordinaryStartupRunsReadableNonExecutableHelpersWithAnUnsupportedShell() throws { + let fixture = try TmuxFixture() + defer { fixture.cleanUp() } + let install = try RemoteShellStartup.install( + token: UUID(), startupCommand: nil, shellIntegration: true, ordinary: true + ) + .replacingOccurrences(of: "/tmp/termind-shell.XXXXXXXX", with: fixture.home.path + "/helper.XXXXXXXX") + let installation = try Self.run(install + "\nprintf '%s' \"$helper\"", home: fixture.home.path) + #expect(installation.exitCode == 0) + let helper = installation.stdout + for name in ["shell", "first"] { + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: helper + "/" + name) + } + let input = "printf usable > \"$HOME/usable\"; exit\n" + for launch in [ + try RemoteShellStartup.ordinaryCommand(helper: helper), + "export TERMIND_TMUX_HELPERS=\(try RemoteMultiplexerCommands.quote(helper)); exec /bin/sh \"$TERMIND_TMUX_HELPERS/first\"", + ] { + let result = try Self.run( + """ + export TERMIND_TMUX_SHELL=/bin/sh + printf '%s' \(try RemoteMultiplexerCommands.quote(input)) | /bin/sh -c \(try RemoteMultiplexerCommands.quote(launch)) + """, home: fixture.home.path) + #expect(result.exitCode == 0, "\(result.stdout)") + #expect( + try String(contentsOf: fixture.home.appendingPathComponent("usable"), encoding: .utf8) == "usable") + try FileManager.default.removeItem(at: fixture.home.appendingPathComponent("usable")) + } + } + + @Test func managedHelperExecutionFailurePrecedesNativeCreation() throws { + let fixture = try TmuxFixture() + defer { fixture.cleanUp() } + let create = try RemoteTmuxCommands.create( + token: fixture.owner, startupCommand: nil, shellIntegration: true + ) + .replacingOccurrences( + of: "chmod 700 \"$helper/shell\" \"$helper/first\"", + with: "chmod 600 \"$helper/shell\" \"$helper/first\"") + let result = try fixture.run("export STUB_EMPTY=1\n" + create) + #expect(result.exitCode == 78) + #expect(!FileManager.default.fileExists(atPath: fixture.home.appendingPathComponent("created").path)) + } + + @Test func everyTmuxOperationResolvesTheExecutableOutsidePATH() throws { + let fixture = try TmuxFixture() + defer { fixture.cleanUp() } + try fixture.journals(stale: 0, live: 1, backend: "tmux") + let probe = try fixture.run(RemoteMultiplexerCommands.discovery(.tmux) + "\n\"$exe\" -V") + #expect(probe.stdout == "tmux 3.4\n") + let listing = try fixture.run(RemoteTmuxCommands.list) + let session = try #require(try RemoteTmuxCommands.parseSessions(listing.stdout).first) + let reference = RemoteSessionReference( + backend: .tmux, session: session, hostID: UUID(), endpoint: "test:22", username: "user", + ownershipToken: fixture.owner) + let create = try fixture.run( + RemoteTmuxCommands.create(token: fixture.owner, startupCommand: nil, shellIntegration: true)) + #expect(try RemoteTmuxCommands.parseSessions(create.stdout).first?.id == session.id) + #expect(try fixture.run(RemoteTmuxCommands.attach(reference)).stdout == "TERMIND_ATTACHED\n") + #expect(try fixture.run(RemoteTmuxCommands.terminate(reference)).stdout == "TERMIND_ENDED\n") + + let runtime = try fixture.run(RemoteTmuxRuntimeDialect.command(terminal: "/dev/pts/4")) + let update = try RemoteTmuxRuntimeDialect.parse(runtime.stdout, terminal: "/dev/pts/4") + let context = try #require(update.contexts.first) + #expect(context.capabilities.contains(.replaceDraft)) + let client = try #require(RemoteTmuxClientIdentity(update: update, terminal: "/dev/pts/4")) + let resize = try fixture.run( + "kill() { [ \"$1:$2\" = '-WINCH:300' ]; }\n" + RemoteTmuxRuntimeDialect.resizeCommand(client: client)) + #expect(resize.stdout == "TERMIND_RESIZED\n") + let request = UUID() + let draft = try fixture.run( + RemoteTerminalDraft.command( + context: context, terminal: "/dev/pts/4", helper: nil, clientIdentity: update.clientIdentity, + request: request, replacement: "echo draft")) + #expect(draft.exitCode == 0) + let requested = try String( + contentsOf: fixture.helper.appendingPathComponent("ctx.live0/request"), encoding: .utf8) + #expect(requested.hasPrefix(request.uuidString + "|1|replace|")) + let journal = fixture.helper.appendingPathComponent("ctx.live0/journal") + let updatedJournal = try String(contentsOf: journal, encoding: .utf8) + .replacingOccurrences(of: "R1|1|1|", with: "R1|2|1|") + try (updatedJournal + "E1|2|draft|%64%72%61%66%74|\(request)\n") + .write(to: journal, atomically: true, encoding: .utf8) + let result = try fixture.run( + RemoteTerminalDraft.resultCommand(context: context, helper: nil, request: request)) + let record = try RemoteShellContextRecord(result.stdout) + #expect(record.entries.last?.1 == .draft(request: request.uuidString, value: "draft")) + } + + @Test(arguments: [1, 129]) + func tmuxJournalLimitCountsOnlyLivePaneProcesses(live: Int) throws { + let fixture = try TmuxFixture() + defer { fixture.cleanUp() } + try fixture.journals(stale: 129, live: live, backend: "tmux") + let result = try fixture.run( + "export STUB_PANES=\(live)\n" + RemoteTmuxRuntimeDialect.command(terminal: "/dev/pts/4")) + if live > 128 { + #expect(result.exitCode == 78) + } else { + #expect(result.exitCode == 0, "\(result.stdout)") + let update = try RemoteTmuxRuntimeDialect.parse(result.stdout, terminal: "/dev/pts/4") + #expect(update.contexts.count == 1) + #expect(update.contexts.first?.capabilities.contains(.replaceDraft) == true) + #expect(update.active?.shellInstance == "ctx.live0") + } + } + + @Test(arguments: [1, 129]) + func socketJournalLimitCountsOnlyValidatedProcesses(live: Int) throws { + let fixture = try TmuxFixture() + defer { fixture.cleanUp() } + try fixture.journals(stale: 129, live: live, backend: "zmx") + let result = try fixture.run( + """ + helper="$STUB_HELPER" + shell_check() { [ "$1" -ge 500 ]; } + foreground_shell() { shell_check "$@"; } + \(RemoteSocketMultiplexerRuntime.journalCommand(backend: .zmx)) + """) + if live > 128 { + #expect(result.exitCode == 78) + } else { + #expect(result.exitCode == 0) + let lines = result.stdout.components(separatedBy: "\n") + #expect(lines.first == "BEGIN|1") + #expect(lines.filter { $0 == "BEGIN|1" }.count == 1) + let record = try RemoteShellContextRecord(lines[1...3].joined(separator: "\n")) + #expect(record.instance == "ctx.live0") + #expect(record.ready) + } + } + + @Test(arguments: ["shell", "tmux", "zellij", "zmx", "herdr"], shells) + func shellHooksKeepCwdInTheJournalDuringCommands(backend: String, shell: String) throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("termind-cwd-test-\(UUID())") + try FileManager.default.createDirectory( + at: root.appendingPathComponent("next"), withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let install = try RemoteShellStartup.install( + token: UUID(), startupCommand: "cd \"$HOME\"", shellIntegration: true, + backend: ["shell", "tmux"].contains(backend) ? nil : backend + ).replacingOccurrences( + of: "/tmp/termind-shell.XXXXXXXX", + with: root.appendingPathComponent("termind-shell.XXXXXXXX").path + ) + let input = """ + cat "$__termind_context_dir/journal" > "$HOME/during" + cd "$HOME/next" + cat "$__termind_context_dir/journal" > "$HOME/after" + exit + """ + let result = try Self.run( + """ + \(install) + export TERMIND_TMUX_HELPERS="$helper" TERMIND_TMUX_SHELL=\(try RemoteMultiplexerCommands.quote(shell)) + unset TMUX_PANE ZELLIJ_PANE_ID HERDR_PANE_ID + \(backend == "tmux" ? "export TMUX_PANE=%1" : "") + export ZELLIJ_PANE_ID=1 HERDR_PANE_ID=1 + printf '%s\\n' \(try RemoteMultiplexerCommands.quote(input)) | /bin/sh "$helper/first" + """, home: root.path) + #expect(result.exitCode == 0, "\(result.stdout)") + let during = try RemoteShellContextRecord( + String(contentsOf: root.appendingPathComponent("during"), encoding: .utf8)) + let after = try RemoteShellContextRecord( + String(contentsOf: root.appendingPathComponent("after"), encoding: .utf8)) + #expect(during.backend == backend) + #expect(!during.ready) + #expect(!after.ready) + #expect(during.directory == root.resolvingSymlinksInPath().path) + #expect(after.directory == root.appendingPathComponent("next").resolvingSymlinksInPath().path) + #expect(after.sequence > during.sequence) + } + + @Test(arguments: SessionPersistenceBackendID.supported, shells) + func startupSnippetRunsOnlyInFirstShellAndPreservesUserConfiguration( + backend: SessionPersistenceBackendID, shell: String + ) throws { + for integration in [false, true] { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("termind-shell-test-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let name = URL(fileURLWithPath: shell).lastPathComponent + let userConfig = name == "zsh" ? ".zshrc" : ".bash_profile" + try "export USER_CONFIGURATION=preserved\n".write( + to: root.appendingPathComponent(userConfig), atomically: true, encoding: .utf8) + if name == "fish" { + let config = root.appendingPathComponent(".config/fish") + try FileManager.default.createDirectory(at: config, withIntermediateDirectories: true) + try "set -gx USER_CONFIGURATION preserved\n".write( + to: config.appendingPathComponent("config.fish"), atomically: true, encoding: .utf8) + } + let startup = + name == "fish" + ? "cd \"$HOME\"; set -gx FROM_SNIPPET yes; printf once >> \"$HOME/once\"" + : "cd \"$HOME\"; export FROM_SNIPPET=yes; printf once >> \"$HOME/once\"" + let install = try RemoteShellStartup.install( + token: UUID(), startupCommand: startup, shellIntegration: integration, + backend: backend == .tmux ? nil : backend.rawValue, + environment: [.init(variable: "SESSION_ONLY", value: "host-value")] + ) + .replacingOccurrences( + of: "/tmp/termind-shell.XXXXXXXX", + with: root.appendingPathComponent("termind-shell.XXXXXXXX").path + ) + + let firstInput = """ + printf '%s|%s|%s|%s\\n' "$PWD" "$FROM_SNIPPET" "$USER_CONFIGURATION" "$SESSION_ONLY" > "$HOME/first-result" + exit + """ + let secondInput = """ + printf '%s\\n' "$USER_CONFIGURATION" > "$HOME/second-result" + exit + """ + let script = """ + \(install) + trap 'rm -rf "$helper"' EXIT + [ -z "${SESSION_ONLY:-}" ] || exit 83 + export TERMIND_TMUX_HELPERS="$helper" TERMIND_TMUX_SHELL=\(try RemoteMultiplexerCommands.quote(shell)) + export TMUX_PANE=%1 ZELLIJ_PANE_ID=1 HERDR_PANE_ID=1 + printf '%s\\n' \(try RemoteMultiplexerCommands.quote(firstInput)) | /bin/sh "$helper/first" + printf '%s\\n' \(try RemoteMultiplexerCommands.quote(secondInput)) | /bin/sh "$helper/shell" + """ + let result = try Self.run(script, home: root.path) + #expect(result.exitCode == 0, "\(result.stderr)\n\(result.stdout)") + #expect(try String(contentsOf: root.appendingPathComponent("once"), encoding: .utf8) == "once") + let first = try String(contentsOf: root.appendingPathComponent("first-result"), encoding: .utf8) + #expect(first.hasSuffix("|yes|preserved|host-value\n"), "\(first)") + #expect( + try String(contentsOf: root.appendingPathComponent("second-result"), encoding: .utf8) + == "preserved\n") + #expect( + try String(contentsOf: root.appendingPathComponent(userConfig), encoding: .utf8) + == "export USER_CONFIGURATION=preserved\n") + } + } + + @Test(arguments: shells) + func cancelledStartupCannotRunLaterWhenUserConfigurationFinishes(shell: String) throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("termind-cancel-test-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let install = try RemoteShellStartup.install( + token: UUID(), startupCommand: "printf late > \"$HOME/late\"", shellIntegration: true, backend: "zmx" + ).replacingOccurrences( + of: "/tmp/termind-shell.XXXXXXXX", with: root.appendingPathComponent("termind-shell.XXXXXXXX").path) + let result = try Self.run( + """ + \(install) + export TERMIND_TMUX_HELPERS="$helper" TERMIND_TMUX_SHELL=\(try RemoteMultiplexerCommands.quote(shell)) + mkdir "$helper/startup-decision" + printf 'printf ready > "$HOME/ready"\\nexit\\n' | /bin/sh "$helper/shell" || exit $? + [ ! -e "$helper/started" ] + """, home: root.path) + #expect(result.exitCode == 0, "\(result.stdout)") + #expect(try String(contentsOf: root.appendingPathComponent("ready"), encoding: .utf8) == "ready") + #expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent("late").path)) + } + + @Test func expiredAndMismatchedDraftRequestsAreConsumedWithoutReturningPayload() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("termind-draft-test-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let state = root.appendingPathComponent("state") + let request = root.appendingPathComponent("request") + let reader = root.appendingPathComponent("reader") + try "R1|10|1|%2Ftmp\n".write(to: state, atomically: true, encoding: .utf8) + try RemoteShellContextJournal.requestReader.write(to: reader, atomically: true, encoding: .utf8) + for (sequence, deadline) in [("10", "1"), ("9", String(Int(Date().timeIntervalSince1970) + 60))] { + try "\(UUID())|\(sequence)|replace|%65%63%68%6F|\(deadline)|-\n".write( + to: request, atomically: true, encoding: .utf8) + let result = try Self.run( + "sh \(RemoteMultiplexerCommands.quote(reader.path)) \(RemoteMultiplexerCommands.quote(root.path)) replace", + home: root.path) + #expect(result.exitCode != 0) + #expect(result.stdout.isEmpty) + #expect(!FileManager.default.fileExists(atPath: request.path)) + } + } + + private struct TmuxFixture { + let home = FileManager.default.temporaryDirectory.appendingPathComponent("termind-tmux-test-\(UUID())") + let helper = URL( + fileURLWithPath: "/tmp/termind-shell." + UUID().uuidString.replacingOccurrences(of: "-", with: "")) + let owner = UUID() + let token = UUID() + + init() throws { + let bin = home.appendingPathComponent(".local/bin") + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: helper, withIntermediateDirectories: true) + try token.uuidString.write( + to: helper.appendingPathComponent("event-token"), atomically: true, encoding: .utf8) + let executable = bin.appendingPathComponent("tmux") + try Self.stub.write(to: executable, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: executable.path) + } + + func cleanUp() { + try? FileManager.default.removeItem(at: home) + try? FileManager.default.removeItem(at: helper) + } + + func run(_ command: String) throws -> RemoteSessionCommandResult { + // Do not allow fixture execution to discover a real multiplexer installed on the test host. + let command = command.replacingOccurrences( + of: "/opt/homebrew/bin /usr/local/bin /usr/bin /snap/bin \"$HOME/.local/bin\" \"$HOME/.cargo/bin\"", + with: "\"$HOME/.local/bin\"") + return try RemoteMultiplexerShellTests.run( + """ + export PATH=/usr/bin:/bin + export STUB_HELPER=\(try RemoteMultiplexerCommands.quote(helper.path)) STUB_OWNER='\(owner)' + \(command) + """, home: home.path) + } + + func journals(stale: Int, live: Int, backend: String) throws { + for (prefix, count, firstPID) in [("stale", stale, 10), ("live", live, 500)] { + for index in 0..> "$HOME/calls" + for argument do format=$argument; done + case "$1" in + -V) printf 'tmux 3.4\n' ;; + list-sessions) [ "${STUB_EMPTY:-}" = 1 ] || printf '$1\n' ;; + new-session) + : > "$HOME/created" + printf '%s|%s|%s' "${LC_ALL+x}" "${LC_ALL-}" "${LANG-}" > "$HOME/locale" + printf '$1\n' ;; + set-option) ;; + show-environment) + case "$4" in + TERMIND_TMUX_OWNER) printf 'TERMIND_TMUX_OWNER=%s\n' "$STUB_OWNER" ;; + TERMIND_TMUX_HELPERS) printf 'TERMIND_TMUX_HELPERS=%s\n' "$STUB_HELPER" ;; + TERMIND_TMUX_SHELL) printf 'TERMIND_TMUX_SHELL=/bin/bash\n' ;; + *) exit 90 ;; + esac ;; + display-message) + case "$format" in + '#{session_name}') printf 'build\n' ;; + '#{pid}:#{session_created}') printf '200:100\n' ;; + '#{pane_current_path}') printf '/work\n' ;; + '#{pane_current_command}') printf 'bash\n' ;; + *session_id*) printf '$1 100 200 1 1\n' ;; + *) printf '1\n' ;; + esac ;; + list-clients) + case "$format" in + C*) printf 'C|/dev/pts/4|300|400|200|$1|100|%%1||root\n' ;; + *client_key_table*) printf '/dev/pts/4|300|400|%%1||root|$1|100\n' ;; + *) printf '/dev/pts/4|300|400|200|$1|100|\n' ;; + esac ;; + list-panes) + i=0 + while [ "$i" -lt "${STUB_PANES:-1}" ]; do + printf 'P|%%%s|%s|0|0|bash\n' "$((i + 1))" "$((i + 500))" + i=$((i + 1)) + done ;; + if-shell) + case "$*" in + *kill-session*) printf 'TERMIND_ENDED\n' ;; + *attach-session*) printf 'TERMIND_ATTACHED\n' ;; + esac ;; + *) exit 91 ;; + esac + """# + } + + static func run(_ command: String, home: String) throws -> RemoteSessionCommandResult { + let process = Process() + let output = Pipe() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", command] + process.environment = ProcessInfo.processInfo.environment.merging([ + "HOME": home, "XDG_CONFIG_HOME": home + "/.config", "XDG_DATA_HOME": home + "/.local/share", + "ZDOTDIR": home, "LC_ALL": "C", + ]) { _, new in new } + process.standardOutput = output + process.standardError = output + try process.run() + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return .init(stdout: String(decoding: data, as: UTF8.self), stderr: "", exitCode: process.terminationStatus) + } + } +#endif diff --git a/FrameworkTests/RemoteMultiplexerTests.swift b/FrameworkTests/RemoteMultiplexerTests.swift index 4515a906..e63d9c56 100644 --- a/FrameworkTests/RemoteMultiplexerTests.swift +++ b/FrameworkTests/RemoteMultiplexerTests.swift @@ -7,6 +7,29 @@ import Testing @Suite nonisolated struct RemoteMultiplexerTests { + @Test func ordinaryStartupFallbackIsBoundedOneShotAndGenerationScoped() { + let generation = UUID() + var unsupportedShell = RemoteShellStartupReadiness(generation: generation, now: 100) + unsupportedShell.receive(.init(status: .unknown)) + let beforeDeadline = unsupportedShell.poll(generation: generation, now: 3_000_000_099) + let atDeadline = unsupportedShell.poll(generation: generation, now: 3_000_000_100) + let afterCompletion = unsupportedShell.poll(generation: generation, now: 4_000_000_000) + #expect(!beforeDeadline) + #expect(atDeadline) + #expect(!afterCompletion) + var replaced = RemoteShellStartupReadiness(generation: generation, now: 100) + let replacement = replaced.poll(generation: UUID(), now: 4_000_000_000) + let afterReplacement = replaced.poll(generation: generation, now: 4_000_000_000) + #expect(!replacement) + #expect(!afterReplacement) + for status in [TerminalRuntimeUpdate.Status.ordinary, .multiplexer] { + var integrated = RemoteShellStartupReadiness(generation: generation, now: 100) + integrated.receive(.init(status: status)) + let afterIntegration = integrated.poll(generation: generation, now: 4_000_000_000) + #expect(!afterIntegration) + } + } + private static let token = UUID(uuidString: "11111111-2222-3333-4444-555555555555")! private static let kinds = SessionPersistenceBackendID.supported diff --git a/Frameworks/TermindDomain/Sessions/SSHShellIntegrationEvent.swift b/Frameworks/TermindDomain/Sessions/SSHShellIntegrationEvent.swift index e708f0c8..b598773d 100644 --- a/Frameworks/TermindDomain/Sessions/SSHShellIntegrationEvent.swift +++ b/Frameworks/TermindDomain/Sessions/SSHShellIntegrationEvent.swift @@ -2,6 +2,8 @@ import Foundation public nonisolated enum SSHShellIntegrationEvent: Sendable { case runtime(TerminalRuntimeUpdate) + /// Ordinary PTY startup completed without hooks; this grants no runtime context capabilities. + case ordinaryStartupUnavailable(ticket: SSHOrdinaryStartupTicket?) case available(shell: String, systemInfo: RemoteSystemInfo?, homeDirectory: String?) case systemInfo(RemoteSystemInfo) case homeResolutionUnavailable @@ -10,3 +12,14 @@ public nonisolated enum SSHShellIntegrationEvent: Sendable { case commandFinished(Int32, duration: TimeInterval?) case draft(String) } + +/// A single connection/input epoch, revalidated by the transport before fallback submission. +public nonisolated struct SSHOrdinaryStartupTicket: Sendable, Equatable { + public let generation: UUID + public let inputRevision: UInt64 + + public init(generation: UUID, inputRevision: UInt64) { + self.generation = generation + self.inputRevision = inputRevision + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteMultiplexerRuntimeAdapter.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteMultiplexerRuntimeAdapter.swift index 36c81cc7..12a08505 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteMultiplexerRuntimeAdapter.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteMultiplexerRuntimeAdapter.swift @@ -210,23 +210,7 @@ nonisolated struct RemoteSocketMultiplexerRuntime: RemoteMultiplexerRuntime { native=$(\(nativeCommand)) || exit 79 focus=$(\(focusCommand)) || exit 79 printf 'C|%s\\nN|%s\\nA|%s\\n' "$client" "$(hex "$native")" "$(hex "$focus")" - count=0 - for file in "$helper"/ctx.*/journal; do - [ -f "$file" ] && [ ! -L "$file" ] || continue - count=$((count + 1)); [ "$count" -le 128 ] || exit 78 - IFS='|' read -r tag token pane instance shell home pid history backend < "$file" || exit 78 - [ "$tag" = M2 ] && [ "$backend" = '\(try identity.kind.rawValue)' ] || continue - [ "$token" = "$(cat "$helper/event-token")" ] || continue - dir=${file%/journal} - [ "${dir##*/}" = "$instance" ] || continue - shell_check "$pid" "$shell" "$dir" || continue - foreground=0 - if foreground_shell "$pid" "$shell" "$dir"; then foreground=1; fi - printf 'BEGIN|%s\\n' "$foreground" - cat "$file" || exit 78 - printf 'END\\n' - done - printf 'DONE\\n' + \(Self.journalCommand(backend: try identity.kind)) """ let result = try await execute(command) guard result.exitCode == 0, !result.truncated else { throw TerminalRuntimeError.unsupported } @@ -234,6 +218,28 @@ nonisolated struct RemoteSocketMultiplexerRuntime: RemoteMultiplexerRuntime { result.stdout, reference: reference, charset: charset, clientBound: boundClient != nil) } + static func journalCommand(backend: SessionPersistenceBackendID) -> String { + """ + count=0 + for file in "$helper"/ctx.*/journal; do + [ -f "$file" ] && [ ! -L "$file" ] || continue + IFS='|' read -r tag token pane instance shell home pid history backend < "$file" || exit 78 + [ "$tag" = M2 ] && [ "$backend" = '\(backend.rawValue)' ] || continue + [ "$token" = "$(cat "$helper/event-token")" ] || continue + dir=${file%/journal} + [ "${dir##*/}" = "$instance" ] || continue + shell_check "$pid" "$shell" "$dir" || continue + count=$((count + 1)); [ "$count" -le 128 ] || exit 78 + foreground=0 + if foreground_shell "$pid" "$shell" "$dir"; then foreground=1; fi + printf 'BEGIN|%s\\n' "$foreground" + cat "$file" || exit 78 + printf 'END\\n' + done + printf 'DONE\\n' + """ + } + static func parse( _ text: String, reference: RemoteSessionReference, charset: RemoteCharset = .utf8, clientBound: Bool = false ) throws -> TerminalRuntimeUpdate { diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteShellStartup.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteShellStartup.swift index a078c7f9..442a0eef 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteShellStartup.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteShellStartup.swift @@ -3,6 +3,10 @@ import TermindDomain /// Private, per-session startup files. Nothing is ever typed into an attached terminal. nonisolated enum RemoteShellStartup { + static func ordinaryCommand(helper: String) throws -> String { + "export TERMIND_TMUX_HELPERS=\(try RemoteMultiplexerCommands.quote(helper)); exec /bin/sh \"$TERMIND_TMUX_HELPERS/shell\"" + } + static func install( token: UUID, startupCommand: String?, shellIntegration: Bool, ordinary: Bool = false, backend: String? = nil, environment: [HostEnvironmentVariable] = [] @@ -54,6 +58,7 @@ nonisolated enum RemoteShellStartup { + "\n" + fishStartup let shell = """ #!/bin/sh + if [ "${1:-}" = --termind-check-executable ]; then exit 0; fi \(backend == nil ? "" : """ if [ "${TERMIND_SHELL_LOCALE_SET:-}" = x ]; then export LC_ALL="$TERMIND_SHELL_LOCALE" @@ -97,7 +102,7 @@ nonisolated enum RemoteShellStartup { """ // Run the first command inside the initialized shell, preserving cd/export state. // Subsequent ordinary panes use `shell` directly and cannot replay this command. - let first = "#!/bin/sh\nexport TERMIND_TMUX_FIRST=1\nexec \"$TERMIND_TMUX_HELPERS/shell\"\n" + let first = "#!/bin/sh\nexport TERMIND_TMUX_FIRST=1\nexec /bin/sh \"$TERMIND_TMUX_HELPERS/shell\"\n" var files = [ "shell": shell, "first": first, "bashrc": bashrc, "fish": fish, "event": RemoteShellContextJournal.writer, "request-reader": RemoteShellContextJournal.requestReader, "event-token": eventToken.uuidString, @@ -119,6 +124,36 @@ nonisolated enum RemoteShellStartup { helper=$(mktemp -d "/tmp/termind-shell.XXXXXXXX") || exit 78 \(writes) chmod 700 "$helper/shell" "$helper/first" || exit 78 + \(ordinary ? "" : """ + "$helper/shell" --termind-check-executable >/dev/null 2>&1 || { rm -rf "$helper"; exit 78; } + """) """ } } + +/// Only ordinary startup uses the plain-shell fallback, never multiplexer inspection. +nonisolated struct RemoteShellStartupReadiness { + private let generation: UUID + private var deadline: UInt64? + + init(generation: UUID, now: UInt64 = DispatchTime.now().uptimeNanoseconds) { + self.generation = generation + deadline = now + 3_000_000_000 + } + + mutating func receive(_ update: TerminalRuntimeUpdate) { + if update.status == .ordinary || update.status == .multiplexer || update.backend != nil { + deadline = nil + } + } + + mutating func poll(generation: UUID, now: UInt64 = DispatchTime.now().uptimeNanoseconds) -> Bool { + guard self.generation == generation else { + deadline = nil + return false + } + guard let deadline, now >= deadline else { return false } + self.deadline = nil + return true + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTerminalDraft.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTerminalDraft.swift index df8141e2..df21d158 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTerminalDraft.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTerminalDraft.swift @@ -65,6 +65,7 @@ nonisolated enum RemoteTerminalDraft { throw TerminalRuntimeError.staleContext } root = """ + \(RemoteTmuxCommands.executionPreamble) helper=$(tmux show-environment -t \(try RemoteMultiplexerCommands.quote(String(session))) TERMIND_TMUX_HELPERS) || exit 79 helper=${helper#TERMIND_TMUX_HELPERS=} """ diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTerminalProcessProbe.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTerminalProcessProbe.swift index b2f2b80a..aed227c5 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTerminalProcessProbe.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTerminalProcessProbe.swift @@ -15,6 +15,34 @@ nonisolated struct RemoteTerminalProcessProbe { let execute: RemoteSessionCommandExecutor + func isOrdinaryShell(terminal: String) async throws -> Bool { + let result = try await execute(Self.ordinaryShellCommand(terminal: terminal)) + return result.exitCode == 0 && !result.truncated && result.stdout == "TERMIND_ORDINARY_SHELL\n" + } + + static func ordinaryShellCommand(terminal: String) throws -> String { + guard RemoteTmuxClientIdentity.validTerminal(terminal) else { throw TerminalRuntimeError.invalidResponse } + return """ + export LC_ALL=C + tty=\(try RemoteMultiplexerCommands.quote(String(terminal.dropFirst("/dev/".count)))) + rows=$(ps -t "$tty" -o pid= -o pgid= -o tpgid= -o tty= -o comm= | head -c 65537) || exit 79 + [ "${#rows}" -le 65536 ] || exit 79 + printf '%s\\n' "$rows" | awk -v tty="$tty" ' + NF != 5 || $1 !~ /^[0-9]+$/ || $2 !~ /^[0-9]+$/ || $3 !~ /^[0-9]+$/ || $4 != tty { + invalid=1; next + } + $2 == $3 && $3 > 0 { + count++ + name=$5; sub(/^.*\\//,"",name); sub(/^-/,"",name) + if (name ~ /^(sh|dash|ksh|ksh93|bash|zsh|fish|csh|tcsh)$/) shells++ + } + END { + if (!invalid && count == 1 && shells == 1) print "TERMIND_ORDINARY_SHELL" + else exit 79 + }' + """ + } + func inspect(terminal: String) async throws -> Client? { let result = try await execute(Self.command(terminal: terminal)) guard result.exitCode == 0, !result.truncated else { throw TerminalRuntimeError.unsupported } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxCommands.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxCommands.swift index 5d3fc849..710de6d2 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxCommands.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxCommands.swift @@ -16,6 +16,18 @@ nonisolated struct TmuxSessionIdentity: Codable { } public nonisolated enum RemoteTmuxCommands { + static var executionPreamble: String { + """ + termind_resolve_tmux() { + \(RemoteMultiplexerCommands.discovery(.tmux)) + printf '%s' "$exe" + } + exe=$(termind_resolve_tmux) || exit $? + unset -f termind_resolve_tmux + export PATH="${exe%/*}:$PATH" + """ + } + public static func quote(_ value: String) throws -> String { try RemoteMultiplexerCommands.quote(value) } @@ -42,7 +54,7 @@ public nonisolated enum RemoteTmuxCommands { public static var list: String { """ - export LC_ALL=C + \(executionPreamble) \(describe) ids=$(tmux list-sessions -F '#{session_id}' 2>&1) code=$? @@ -132,6 +144,7 @@ public nonisolated enum RemoteTmuxCommands { case \(shell) in */bash|*/zsh|*/fish) ;; *) exit 80 ;; esac """ return """ + \(executionPreamble) \(describe) session_shell=\(shell) id=$(tmux list-sessions -f '#{==:#{session_name},\(name)}' -F '#{session_id}' 2>/dev/null) @@ -173,13 +186,15 @@ public nonisolated enum RemoteTmuxCommands { // The guard and attach share tmux's command queue; never use -d, -A, or a name prefix. let command = "attach-session -t \(target)" return - "exec tmux if-shell -t \(target) -F \(try quote(identityCondition(attachment))) \(try quote(command)) 'display-message -p TERMIND_IDENTITY_MISMATCH'" + executionPreamble + + "\nexec tmux if-shell -t \(target) -F \(try quote(identityCondition(attachment))) \(try quote(command)) 'display-message -p TERMIND_IDENTITY_MISMATCH'" } public static func terminate(_ attachment: RemoteSessionReference) throws -> String { guard attachment.ownershipToken != nil else { throw RemoteSessionFailure.ownershipMismatch } let target = try quote(attachment.session.id) return """ + \(executionPreamble) [ "$(tmux display-message -p -t \(target) \(try quote(identityCondition(attachment))))" = 1 ] || exit 79 tmux if-shell -t \(target) -F \(try quote(identityCondition(attachment))) \ \(try quote("kill-session -t \(target) ; display-message -p TERMIND_ENDED")) diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxRuntimeDialect.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxRuntimeDialect.swift index 617bcfb7..208d9b78 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxRuntimeDialect.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxRuntimeDialect.swift @@ -41,12 +41,12 @@ nonisolated struct RemoteTmuxRuntimeDialect: RemoteMultiplexerRuntime { // Revalidate on the server immediately before signalling. A detached client's TTY // may already contain a shell, so this operation must never write terminal input. return """ - export LC_ALL=C + \(RemoteTmuxCommands.executionPreamble) client=$(tmux list-clients -F '#{client_tty}|#{client_pid}|#{client_created}|#{pid}|#{session_id}|#{session_created}|#{client_flags}' 2>/dev/null | \ while IFS='|' read -r tty cp cc sp sid sc flags; do if [ "$tty" = \(try RemoteTmuxCommands.quote(client.terminal)) ]; then case ",$flags," in - *,read-only,*|*,ignore-size,*|*,active-pane,*|*,control-mode,*) continue ;; + (*,read-only,*|*,ignore-size,*|*,active-pane,*|*,control-mode,*) continue ;; esac printf '%s|%s|%s|%s|%s|%s\\n' "$tty" "$cp" "$cc" "$sp" "$sid" "$sc" fi @@ -60,8 +60,7 @@ nonisolated struct RemoteTmuxRuntimeDialect: RemoteMultiplexerRuntime { static func command(terminal: String) throws -> String { guard RemoteTmuxClientIdentity.validTerminal(terminal) else { throw TerminalRuntimeError.invalidResponse } return """ - export LC_ALL=C - command -v tmux >/dev/null 2>&1 || exit 77 + \(RemoteTmuxCommands.executionPreamble) tmux -V || exit 77 client=$(tmux list-clients -F 'C|#{client_tty}|#{client_pid}|#{client_created}|#{pid}|#{session_id}|#{session_created}|#{pane_id}|#{client_flags}|#{client_key_table}' 2>/dev/null | \ while IFS='|' read -r tag tty cp cc sp sid sc pane flags table; do @@ -72,7 +71,8 @@ nonisolated struct RemoteTmuxRuntimeDialect: RemoteMultiplexerRuntime { if [ -z "$client" ]; then printf 'NONE\\n'; exit 0; fi printf '%s\\n' "$client" sid=$(printf '%s' "$client" | cut -d '|' -f6) - tmux list-panes -s -t "$sid" -F 'P|#{pane_id}|#{pane_pid}|#{pane_in_mode}|#{synchronize-panes}|#{?#{==:#{pane_current_command},bash},bash,#{?#{==:#{pane_current_command},zsh},zsh,#{?#{==:#{pane_current_command},fish},fish,unknown}}}' || exit 78 + panes=$(tmux list-panes -s -t "$sid" -F 'P|#{pane_id}|#{pane_pid}|#{pane_in_mode}|#{synchronize-panes}|#{?#{==:#{pane_current_command},bash},bash,#{?#{==:#{pane_current_command},zsh},zsh,#{?#{==:#{pane_current_command},fish},fish,unknown}}}') || exit 78 + printf '%s\\n' "$panes" pane=$(printf '%s' "$client" | cut -d '|' -f8) cwd=$(tmux display-message -p -t "$pane" '#{pane_current_path}') || exit 78 foreground=$(tmux display-message -p -t "$pane" '#{pane_current_command}') || exit 78 @@ -87,6 +87,12 @@ nonisolated struct RemoteTmuxRuntimeDialect: RemoteMultiplexerRuntime { count=0 for file in "$helper"/ctx.*/journal; do [ -f "$file" ] && [ ! -L "$file" ] || continue + IFS='|' read -r tag token pane instance shell home pid history backend < "$file" || continue + case "$tag:$backend" in M1:|M2:tmux) ;; *) continue ;; esac + [ "$token" = "$(cat "$helper/event-token")" ] || continue + dir=${file%/journal} + [ ! -L "$dir" ] && [ "${dir##*/}" = "$instance" ] || continue + [ "$(printf '%s\\n' "$panes" | awk -F '|' -v pane="$pane" '$2 == pane {print $3}')" = "$pid" ] || continue count=$((count + 1)); [ "$count" -le 128 ] || exit 78 printf 'BEGIN\\n' cat "$file" || exit 78 @@ -216,6 +222,7 @@ extension RemoteTmuxRuntimeDialect { else { throw TerminalRuntimeError.staleContext } let target = try RemoteTmuxCommands.quote(session[0]) let root = """ + \(RemoteTmuxCommands.executionPreamble) [ "$(tmux display-message -p -t \(target) '#{pid}:#{session_created}')" = '\(identity.server):\(session[1])' ] || exit 79 [ "$(tmux list-clients -F '#{client_tty}|#{client_pid}|#{client_created}|#{pane_id}|#{client_flags}|#{client_key_table}|#{session_id}|#{session_created}' | \ awk -F '|' -v tty=\(try RemoteTmuxCommands.quote(terminal)) '$1 == tty {print $2 ":" $3 ":" $4 ":" $6 ":" $7 ":" $8; if ($5 ~ /active-pane|read-only/) print "UNSUPPORTED"}')" \ diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift index a7b645a2..8aec58ff 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift @@ -892,6 +892,11 @@ public actor SSHPTYChannel { worker.write(data) } + public func submitInitialCommand(_ data: Data, ticket: SSHOrdinaryStartupTicket) async { + guard resumable == nil else { return } + await worker.submitInitialCommand(data, ticket: ticket) + } + /// Opens an SFTP subsystem Channel on the PTY Channel's authenticated connection. /// This is a fallback only: callers must first attempt their ordinary /// independent SFTP connection. @@ -969,6 +974,9 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { private var shellIntegrationLifecycleLogger: RemoteShellIntegrationLifecycleLogger private var commandEventFilter: RemoteCommandEventFilter? private var contextFilter: RemoteTerminalContextFilter? + private var ordinaryStartup: RemoteShellStartupReadiness? + private var ordinaryStartupInputRevision: UInt64? + private var ordinaryStartupTicket: SSHOrdinaryStartupTicket? private var runtimeTask: Task? private var runtimeTerminal: String? private var runtimeRevision: UInt64 = 0 @@ -1344,13 +1352,6 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { hostID: request.configuration.hostID, enabled: shellIntegrationEnabled ) - if shellIntegrationEnabled { - let bootstrap = RemoteShellIntegrationBootstrap(charset: request.configuration.charset) - pendingWrites.append(bootstrap.startCommand) - commandEventFilter = RemoteCommandEventFilter( - token: bootstrap.token, charset: request.configuration.charset) - shellIntegration = bootstrap - } startPump() progress?(.connected(request.configuration.hostID)) } @@ -1384,6 +1385,38 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { } } + func submitInitialCommand(_ data: Data, ticket: SSHOrdinaryStartupTicket) async { + do { + let (terminal, revision) = try await queue.perform { [self] in + guard ordinaryStartupTicket == ticket, + ptyResize.generation == ticket.generation, runtimeInputRevision == ticket.inputRevision, + runtimeAttachment == nil, ptyChannel != nil, let runtimeTerminal + else { throw TerminalRuntimeError.staleContext } + ordinaryStartupTicket = nil + return (runtimeTerminal, runtimeRevision) + } + let probe = RemoteTerminalProcessProbe( + execute: try await sessionCommandExecutor(runtime: true, expectedInput: ticket.inputRevision)) + guard try await probe.isOrdinaryShell(terminal: terminal) else { throw TerminalRuntimeError.unsupported } + try await queue.perform { [self] in + guard ptyResize.generation == ticket.generation, runtimeInputRevision == ticket.inputRevision, + runtimeRevision == revision, runtimeTerminal == terminal, runtimeAttachment == nil, + ptyChannel != nil, latestRuntimeUpdate?.status != .multiplexer, + latestRuntimeUpdate?.backend == nil + else { throw TerminalRuntimeError.staleContext } + runtimeInputRevision &+= 1 + runtimeRevision &+= 1 + let converted = (try? inputConverter?.convert(data).data) ?? data + if !converted.isEmpty { pendingWrites.append(converted) } + pump() + } + } catch { + logger.notice( + "ssh.initial_command.skipped", + metadata: ["reason": "ordinary_shell_not_verified"]) + } + } + public func resize(columns: UInt16, rows: UInt16, widthPixels: UInt32, heightPixels: UInt32) { guard columns > 0, rows > 0 else { return } queue.async { [self] in @@ -1534,16 +1567,19 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { (48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0) }) else { throw TerminalRuntimeError.invalidResponse } - startupCommand = - "export TERMIND_TMUX_HELPERS=\(try RemoteMultiplexerCommands.quote(helper)); exec \"$TERMIND_TMUX_HELPERS/shell\"" - await queue.perform { self.runtimeHelper = helper } + startupCommand = try RemoteShellStartup.ordinaryCommand(helper: helper) + try await queue.perform { + guard self.ptyResize.generation == generation else { throw CancellationError() } + self.runtimeHelper = helper + } } catch { try Task.checkCancellation() // Startup-only integration is optional; never fall back to typing a bootstrap. usesRuntime = false + startupCommand = "exec \"${SHELL:-/bin/sh}\" -l" } } - if let original = startupCommand, usesRuntime { + if let original = startupCommand, shellIntegrationEnabled { startupCommand = "printf '\\033]777;TermindTerminal=\(runtimeToken.uuidString);%s\\007' \"$(tty)\";\n" + original } @@ -1556,8 +1592,10 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { runtimeEnabled = runtimeIsEnabled allowsDefaultHistorySource = launch == nil && !runtimeIsEnabled runtimeWantsObservation = runtimeIsEnabled && launch != nil - if runtimeIsEnabled { + if shellIntegrationEnabled { contextFilter = .init(token: runtimeToken.uuidString, charset: charset) + } + if runtimeIsEnabled { publishRuntime(.init(status: .unknown)) } else if shellIntegrationEnabled { publishRuntime(.init(status: .unavailable)) @@ -1620,6 +1658,10 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { outputConverter = try RemoteCharsetStreamConverter(charset: charset, direction: .decoding) workingDirectoryFilter = RemoteOSC7CharsetFilter(charset: charset) didReportExit = false + if launch == nil, shellIntegrationEnabled { + ordinaryStartup = .init(generation: generation) + ordinaryStartupInputRevision = runtimeInputRevision + } progress?(.connected(request.configuration.hostID)) pump() } @@ -2030,6 +2072,18 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { shellIntegration = update.isComplete ? nil : integration applyShellIntegrationUpdate(update) } + if let generation = ptyResize.generation, ordinaryStartup?.poll(generation: generation) == true { + ordinaryStartupTicket = + runtimeTerminal == nil || runtimeInputRevision != ordinaryStartupInputRevision + ? nil : .init(generation: generation, inputRevision: runtimeInputRevision) + shellIntegrationHandler( + .ordinaryStartupUnavailable(ticket: ordinaryStartupTicket)) + if ordinaryStartupTicket == nil { + logger.notice( + "ssh.initial_command.skipped", + metadata: ["reason": "terminal_missing_or_input_changed"]) + } + } while !pendingWrites.isEmpty && DispatchTime.now().uptimeNanoseconds < deadline { let written = pendingWrites.withFirstBytes { bytes -> Int in @@ -2267,6 +2321,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { } private func publishRuntime(_ proposed: TerminalRuntimeUpdate) { + ordinaryStartup?.receive(proposed) var update = proposed if runtimeControlUnavailable || channelBudget?.permitsConcurrentControl == false { if update.status == .multiplexer { @@ -2509,6 +2564,9 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { } private func stopRuntimeObservation() { + ordinaryStartup = nil + ordinaryStartupInputRevision = nil + ordinaryStartupTicket = nil activationTask?.task.cancel() activationTask = nil terminalActivation.reset() diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index 53eb0a47..a0e08089 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -765,21 +765,10 @@ final class MobileAppModel { return id } - func discardWorkspaceSessions(_ ids: Set, authorized: Bool = false) { + func discardWorkspaceSessions(_ ids: Set) { guard !ids.isEmpty else { return } - let recovered = ids.reduce(into: Set()) { $0.formUnion(recoveredWorkspaceIDs[$1] ?? []) } - let targets = workspaceSessions.flatMap { session in - session.workspaces.filter { ids.contains(session.id) || recovered.contains($0.id) } - } - if !authorized, - deferPersistentClose( - targets, - action: { [weak self] in - self?.discardWorkspaceSessions(ids, authorized: true) - }) - { - return - } + // Native dismissal detaches clients; only explicit workspace closure ends owned remote work. + persistentClose.cancel() discardedSessionIDs.formUnion(ids) orphanedSessionIDs.subtract(ids) for id in Array(sceneSessionIDs.keys) where ids.contains(sceneSessionIDs[id] ?? "") { removeScene(id) } @@ -798,7 +787,7 @@ final class MobileAppModel { for workspaceID in recoveredIDs { session.close(workspaceID, rememberForReopen: false, authorized: true) } } for id in ids { recoveredWorkspaceIDs[id] = nil } - for session in workspaceSessions where ids.contains(session.id) { session.discard(authorized: true) } + for session in workspaceSessions where ids.contains(session.id) { session.discard() } workspaceSessions.removeAll { ids.contains($0.id) } } diff --git a/TFI/App/MobileSceneModel.swift b/TFI/App/MobileSceneModel.swift index 7497af4e..bcd9745d 100644 --- a/TFI/App/MobileSceneModel.swift +++ b/TFI/App/MobileSceneModel.swift @@ -386,34 +386,18 @@ final class MobileSceneModel { requestWindowClose(sessionID: workspaceSession.id) } - func closeAllWindows(authorized: Bool = false) { + func closeAllWindows() { guard !workspaceSession.isDiscarded else { return } let sessionIDs = app.openWorkspaceSessionIDs() - if !authorized, - app.deferPersistentClose( - sessionIDs.flatMap { app.workspaceSession(for: $0).workspaces }, - action: { [weak self] in self?.closeAllWindows(authorized: true) }) - { - return - } for id in sessionIDs.sorted() where id != workspaceSession.id { - requestWindowClose(sessionID: id, authorized: true) + requestWindowClose(sessionID: id) } if sessionIDs.contains(workspaceSession.id) { - requestWindowClose(sessionID: workspaceSession.id, authorized: true) + requestWindowClose(sessionID: workspaceSession.id) } } - private func requestWindowClose(sessionID: String, authorized: Bool = false) { - if !authorized, - app.deferPersistentClose( - app.workspaceSession(for: sessionID).workspaces, - action: { [weak self] in - self?.requestWindowClose(sessionID: sessionID, authorized: true) - }) - { - return - } + private func requestWindowClose(sessionID: String) { // UIKit's discard callback owns cleanup; a rejected request must preserve this session. let logger = app.logging.makeLogger(label: "termind.window") app.closeWorkspaceSession(sessionID) { error in diff --git a/TFI/App/MobileWorkspaceSessionModel.swift b/TFI/App/MobileWorkspaceSessionModel.swift index c291e307..ea731947 100644 --- a/TFI/App/MobileWorkspaceSessionModel.swift +++ b/TFI/App/MobileWorkspaceSessionModel.swift @@ -98,12 +98,9 @@ final class MobileWorkspaceSessionModel { } } - func discard(authorized: Bool = false) { + func discard() { guard !isDiscarded else { return } - if !authorized, app.deferPersistentClose(workspaces, action: { [weak self] in self?.discard(authorized: true) }) - { - return - } + app.persistentClose.cancel() let states = orderedWorkspaceItems.map { $0.workspace?.reopenState ?? .init(descriptor: $0.descriptor) } app.rememberClosedWorkspaces(states, isWindow: true) closeAll(rememberForReopen: false, authorized: true) @@ -416,7 +413,8 @@ final class MobileWorkspaceSessionModel { usernameHint: state.usernameHint, connectionOrigin: state.connectionOrigin, externalSFTPResourcePath: state.externalSFTPResourcePath, externalSFTPMustBeDirectory: state.externalSFTPMustBeDirectory) - workspace.restoreRemoteSession(state.remoteSession) + workspace.restoreRemoteSession(state.descriptor.remoteSession) + workspace.sessionPersistence.requiresSelection = state.descriptor.kind == .ssh addWorkspaces([workspace], persists: state.connectionOrigin.allowsPersistence) } } diff --git a/TFI/Application/Connections/MobileWorkspaceConnectionServices.swift b/TFI/Application/Connections/MobileWorkspaceConnectionServices.swift index d0d5c4c1..63647f1f 100644 --- a/TFI/Application/Connections/MobileWorkspaceConnectionServices.swift +++ b/TFI/Application/Connections/MobileWorkspaceConnectionServices.swift @@ -23,5 +23,8 @@ struct MobileWorkspaceConnectionServices { (@Sendable (String) -> Void)? ) async throws -> any SFTPClientProtocol var writeSSH: (SSHPTYChannel, Data) async -> Void + var submitInitialCommand: @MainActor (SSHPTYChannel, Data, SSHOrdinaryStartupTicket) async -> Void = { + await $0.submitInitialCommand($1, ticket: $2) + } } diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index b1d4aea9..35b25dc0 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -146,6 +146,7 @@ final class MobileWorkspace: Identifiable { private(set) var isShellIntegrationEnabled = true private var shellIntegrationAvailable = false private var isInitialCommandSubmissionReady = false + private var initialCommandStartupTicket: SSHOrdinaryStartupTicket? private var pendingCommand: MobilePendingInitialCommand? /// Re-resolved from the Host and Snippet library for each physical terminal shell. private var pendingConnectionStartupCommand: MobilePendingInitialCommand? @@ -694,6 +695,7 @@ final class MobileWorkspace: Identifiable { isShellPromptReady = false shellIntegrationAvailable = false isInitialCommandSubmissionReady = false + initialCommandStartupTicket = nil pendingConnectionStartupCommand = nil workingDirectory = nil commandExcludedFromHistory = nil @@ -711,6 +713,19 @@ final class MobileWorkspace: Identifiable { resetCommandGeneratorShellState() return } + if case .ordinaryStartupUnavailable(let ticket) = event { + guard sessionPersistence.attachment == nil else { return } + guard let ticket else { + pendingCommand = nil + pendingConnectionStartupCommand = nil + initialCommandStartupTicket = nil + return + } + initialCommandStartupTicket = ticket + isInitialCommandSubmissionReady = true + submitPendingInitialCommandIfReady() + return + } if case .runtime(let update) = event { receiveRuntimeContext(update) return @@ -718,7 +733,7 @@ final class MobileWorkspace: Identifiable { guard !hasRuntimeContext else { return } commandMonitor.receiveShellEvent(event) switch event { - case .runtime: break + case .runtime, .ordinaryStartupUnavailable: break case .available(let shell, let systemInfo, _): integratedShell = shell remoteSystemInfo = systemInfo @@ -1476,9 +1491,15 @@ final class MobileWorkspace: Identifiable { else { return } let token = generation let data = Data((command + "\r").utf8) + let startupTicket = initialCommandStartupTicket + initialCommandStartupTicket = nil Task { [weak self] in guard let self, generation == token, connected, ssh === session else { return } - await connectionServices.writeSSH(session, data) + if let startupTicket { + await connectionServices.submitInitialCommand(session, data, startupTicket) + } else { + await connectionServices.writeSSH(session, data) + } } } @@ -1860,15 +1881,14 @@ final class MobileWorkspace: Identifiable { var connectionOrigin: RemoteConnectionOrigin = .library var externalSFTPResourcePath: String? = nil var externalSFTPMustBeDirectory = false - var remoteSession: RemoteSessionReference? = nil } var reopenState: ReopenState { ReopenState( - descriptor: descriptor, transientHost: transientHost, connectionRoute: connectionRoute, + descriptor: restorationDescriptor, transientHost: transientHost, connectionRoute: connectionRoute, usernameHint: usernameHint, connectionOrigin: connectionOrigin, externalSFTPResourcePath: externalSFTPResourcePath, - externalSFTPMustBeDirectory: externalSFTPMustBeDirectory, remoteSession: sessionPersistence.attachment) + externalSFTPMustBeDirectory: externalSFTPMustBeDirectory) } func disconnect() { diff --git a/TFITests/MobileCommandGeneratorAndUploadTests.swift b/TFITests/MobileCommandGeneratorAndUploadTests.swift index 75051e1b..da2ec260 100644 --- a/TFITests/MobileCommandGeneratorAndUploadTests.swift +++ b/TFITests/MobileCommandGeneratorAndUploadTests.swift @@ -68,6 +68,9 @@ private final class MobileSSHCommandConnectionBox { if data == Data("\u{1B}[777;1~".utf8) { shellIntegrations.last?(.draft(draft)) } + }, + submitInitialCommand: { [weak self] _, data, _ in + self?.writes.append(data) } ) } @@ -94,6 +97,65 @@ private final class MobileSSHCommandConnectionBox { @Suite @MainActor struct MobileCommandGeneratorTests { + @Test func ordinaryStartupFallbackSubmitsOnceAndOldConnectionCannotReleaseANewSnippet() async throws { + var (host, library) = try commandFixture() + host.shellIntegrationMode = .enabled + let snippet = Snippet(title: "Startup", body: "echo startup") + host.startupSnippetID = snippet.id + library.upsert(snippet) + library.upsert(host) + let connections = MobileSSHCommandConnectionBox() + let workspace = MobileWorkspace( + descriptor: .init(hostID: host.id, title: host.name, kind: .ssh), hosts: library, + connectionServices: connections.services, initialCommand: "echo explicit") + defer { workspace.disconnect() } + workspace.connect() + #expect(await eventually { workspace.connected }) + await connections.sendShellEvents([.runtime(.init(status: .unavailable))]) + #expect(await eventually { workspace.terminalContext.status == .unavailable }) + #expect(connections.writes.isEmpty) + let fallback = SSHShellIntegrationEvent.ordinaryStartupUnavailable( + ticket: .init(generation: UUID(), inputRevision: 0)) + await connections.sendShellEvents([fallback, fallback]) + #expect(await eventually { connections.writes == [Data("echo explicit\r".utf8)] }) + #expect(workspace.terminalContext.active == nil) + #expect(!workspace.canGenerateFromCurrentDraft) + #expect(workspace.workingDirectory == nil) + + await connections.finishSession(.failure(.socket("test disconnect")), session: 0) + #expect(await eventually(attempts: 500) { connections.sessionCount == 2 && workspace.connected }) + await connections.sendShellEvents([fallback], session: 0) + await connections.sendShellEvents([.runtime(.init(status: .unavailable))], session: 1) + #expect(await eventually { workspace.terminalContext.status == .unavailable }) + #expect(connections.writes == [Data("echo explicit\r".utf8)]) + await connections.sendShellEvents([fallback, fallback], session: 1) + #expect( + await eventually { + connections.writes == [Data("echo explicit\r".utf8), Data("echo startup\r".utf8)] + }) + } + + @Test func rejectedOrdinaryStartupDoesNotReplayAtALaterPrompt() async throws { + var (host, library) = try commandFixture() + host.shellIntegrationMode = .enabled + library.upsert(host) + let connections = MobileSSHCommandConnectionBox() + let workspace = MobileWorkspace( + descriptor: .init(hostID: host.id, title: host.name, kind: .ssh), hosts: library, + connectionServices: connections.services, initialCommand: "echo initial") + defer { workspace.disconnect() } + workspace.connect() + #expect(await eventually { workspace.connected }) + await connections.sendShellEvents([ + .ordinaryStartupUnavailable(ticket: nil), + .available(shell: "bash", systemInfo: nil, homeDirectory: "/home"), + .promptReady, + ]) + #expect(await eventually { workspace.canGenerateFromCurrentDraft }) + #expect(workspace.pendingInitialCommand == nil) + #expect(connections.writes.isEmpty) + } + @Test(arguments: ["shell", "tmux", "zellij", "zmx", "herdr"]) func runningCommandsKeepTheDisplayedDirectoryUntilTheShellReportsAnother(backend: String) async throws { var (host, library) = try commandFixture() diff --git a/TFITests/MobileTerminalRestorationTests.swift b/TFITests/MobileTerminalRestorationTests.swift index ee7106e5..64f1f852 100644 --- a/TFITests/MobileTerminalRestorationTests.swift +++ b/TFITests/MobileTerminalRestorationTests.swift @@ -8,6 +8,57 @@ import Testing @Suite @MainActor struct MobileTerminalRestorationTests { + @Test(arguments: [false, true]) + func unopenedManagedReferenceSurvivesTabAndWindowReopen(window: Bool) throws { + let app = try MobileAppModel(inMemory: true) + defer { for workspace in app.workspaces { workspace.disconnect() } } + let host = Host(name: "Server", host: "example.invalid") + app.library.upsert(host) + let reference = RemoteSessionReference( + backend: .tmux, session: .init(id: "$4", name: "build", identity: Data("identity".utf8)), + hostID: host.id, endpoint: "example.invalid:22", username: "user", ownershipToken: UUID()) + let descriptor = MobileWorkspaceDescriptor( + hostID: host.id, title: "Saved", kind: .ssh, remoteSession: reference) + let source = MobileWorkspaceSessionModel(id: "source", app: app, descriptors: [descriptor]) + #expect(source.workspaces.isEmpty) + if window { source.discard() } else { source.close(descriptor.id) } + let record = try #require(app.closedWorkspaceRecords.first) + #expect(record.isWindow == window) + #expect(record.items.first?.remoteSession == reference) + // Exercise the record-only reconstruction, not just retained in-process state. + app.closedWorkspaceStates[record.id] = nil + let destination = app.workspaceSession(for: "destination") + #expect(app.restoreClosedRecord(record.id, into: destination) == descriptor.id) + let reopened = try #require(destination.workspaces.first) + #expect(reopened.sessionPersistence.attachment == reference) + #expect(reopened.sessionPersistence.requiresSelection) + #expect(reopened.pendingInitialCommand == nil) + } + + @Test func endedLiveSessionDoesNotResurrectItsDescriptorReference() throws { + let app = try MobileAppModel(inMemory: true) + defer { for workspace in app.workspaces { workspace.disconnect() } } + let host = Host(name: "Server", host: "example.invalid") + app.library.upsert(host) + let reference = RemoteSessionReference( + backend: .tmux, session: .init(id: "$4", name: "build", identity: Data("identity".utf8)), + hostID: host.id, endpoint: "example.invalid:22", username: "user", ownershipToken: UUID()) + let workspace = MobileWorkspace( + descriptor: .init(hostID: host.id, title: "Ended", kind: .ssh, remoteSession: reference), + hosts: app.library) + defer { workspace.disconnect() } + workspace.restoreRemoteSession(reference) + workspace.sessionPersistence.markEnded() + app.rememberClosedWorkspaces([workspace.reopenState]) + let record = try #require(app.closedWorkspaceRecords.first) + #expect(record.items.first?.remoteSession == nil) + let destination = app.workspaceSession(for: "destination") + #expect(app.restoreClosedRecord(record.id, into: destination) == workspace.id) + let reopened = try #require(destination.workspaces.first) + #expect(reopened.sessionPersistence.attachment == nil) + #expect(reopened.sessionPersistence.requiresSelection) + } + @Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal]) func initialFailureRemainsRetryableAfterTransportCleanup(_ transport: HostTerminalProtocol) { let host = Host(name: "Server", host: "example.invalid") diff --git a/TFITests/MobileWorkspaceSessionTests.swift b/TFITests/MobileWorkspaceSessionTests.swift index 3de0e961..e2ce629d 100644 --- a/TFITests/MobileWorkspaceSessionTests.swift +++ b/TFITests/MobileWorkspaceSessionTests.swift @@ -790,8 +790,10 @@ struct MobileWorkspaceSessionTests { let first = MobileSceneModel(app: app, sessionID: "a") let second = MobileSceneModel(app: app, sessionID: "b") first.open(hostID: fixture.host.id, kind: .ssh) - second.open(hostID: fixture.host.id, kind: .sftp) + second.open(hostID: fixture.host.id, kind: .ssh) let original = try #require(second.workspaceSession.workspaces.first) + let reference = managedReference(hostID: fixture.host.id) + original.restoreRemoteSession(reference) let history = app.connectionHistory.entries() app.sceneChanged(first.id, phase: .active) app.sceneChanged(second.id, phase: .background) @@ -810,6 +812,7 @@ struct MobileWorkspaceSessionTests { #expect(app.workspaceOwner(original.id) == nil) #expect(app.closedWorkspaceRecords.first?.isWindow == true) #expect(app.closedWorkspaceRecords.first?.items.map(\.id) == [original.id]) + #expect(app.closedWorkspaceRecords.first?.items.first?.remoteSession == reference) #expect(second.workspaceSession.open(hostID: fixture.host.id, kind: .ssh) == nil) } @@ -922,6 +925,8 @@ struct MobileWorkspaceSessionTests { scene.open(hostID: fixture.host.id, kind: .ssh) scene.activatePendingWorkspace() let workspace = try #require(scene.activeWorkspace) + let reference = managedReference(hostID: fixture.host.id) + workspace.restoreRemoteSession(reference) var requestedSessions: [String] = [] app.closeWorkspaceSession = { id, _ in requestedSessions.append(id) } @@ -929,10 +934,12 @@ struct MobileWorkspaceSessionTests { #expect(requestedSessions == ["owner"]) #expect(scene.activeWorkspace === workspace) + #expect(workspace.sessionPersistence.attachment == reference) #expect(!scene.workspaceSession.isDiscarded) app.discardWorkspaceSessions(["owner"]) #expect(scene.workspaceSession.workspaces.isEmpty) #expect(scene.workspaceSession.isDiscarded) + #expect(app.closedWorkspaceRecords.first?.items.first?.remoteSession == reference) } @Test func closeAllWindowsSnapshotsNativeSessionsAndRetainsRejectedWindows() throws { @@ -942,8 +949,12 @@ struct MobileWorkspaceSessionTests { let first = MobileSceneModel(app: app, sessionID: "first") let second = MobileSceneModel(app: app, sessionID: "second") first.open(hostID: fixture.host.id, kind: .ssh) - second.open(hostID: fixture.host.id, kind: .sftp) + second.open(hostID: fixture.host.id, kind: .ssh) + let firstReference = managedReference(hostID: fixture.host.id) + first.workspaceSession.workspaces.first?.restoreRemoteSession(firstReference) let retainedWorkspace = try #require(second.workspaceSession.workspaces.first) + let retainedReference = managedReference(hostID: fixture.host.id) + retainedWorkspace.restoreRemoteSession(retainedReference) var nativeSessions: Set = ["first", "second", "background"] var requestedSessions: [String] = [] app.openWorkspaceSessionIDs = { nativeSessions } @@ -962,6 +973,8 @@ struct MobileWorkspaceSessionTests { #expect(requestedSessions == ["background", "second", "first"]) #expect(first.workspaceSession.isDiscarded) #expect(second.workspaceSession.workspaces.first === retainedWorkspace) + #expect(retainedWorkspace.sessionPersistence.attachment == retainedReference) + #expect(app.closedWorkspaceRecords.first?.items.first?.remoteSession == firstReference) #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) #expect(!second.workspaceSession.isDiscarded) #expect(app.error == nil) @@ -974,6 +987,8 @@ struct MobileWorkspaceSessionTests { let scene = MobileSceneModel(app: app, sessionID: "a") scene.open(hostID: fixture.host.id, kind: .ssh) let workspace = try #require(scene.workspaceSession.workspaces.first) + let reference = managedReference(hostID: fixture.host.id) + workspace.restoreRemoteSession(reference) var reportFailure: (@MainActor (any Error) -> Void)? app.closeWorkspaceSession = { _, failed in reportFailure = failed } scene.openSettings() @@ -983,6 +998,7 @@ struct MobileWorkspaceSessionTests { failed(NSError(domain: "WindowCloseTest", code: 1)) #expect(scene.workspaceSession.workspaces.first === workspace) + #expect(workspace.sessionPersistence.attachment == reference) #expect(!scene.workspaceSession.isDiscarded) #expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil) #expect(scene.selectedDestination == .settings) @@ -1046,6 +1062,12 @@ struct MobileWorkspaceSessionTests { try #require(condition()) } + private func managedReference(hostID: UUID) -> RemoteSessionReference { + .init( + backend: .tmux, session: .init(id: "$1", name: "build", identity: Data("identity".utf8)), + hostID: hostID, endpoint: "test.invalid:22", username: "user", ownershipToken: UUID()) + } + private final class Fixture { let name = "WorkspaceSessions.\(UUID())" let defaults: UserDefaults diff --git a/TFM/Features/Terminal/SSHSessionModel.swift b/TFM/Features/Terminal/SSHSessionModel.swift index 32ab01b9..a58f18ac 100644 --- a/TFM/Features/Terminal/SSHSessionModel.swift +++ b/TFM/Features/Terminal/SSHSessionModel.swift @@ -192,6 +192,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { private var pendingConnectionStartupCommand: PendingInitialCommand? private var commandExcludedFromHistory: String? private var isInitialCommandSubmissionReady = false + private var initialCommandStartupTicket: SSHOrdinaryStartupTicket? var reconnectState: ReconnectState? { connection.reconnectState } @@ -462,6 +463,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { isShellPromptReady = false shellIntegrationAvailable = false isInitialCommandSubmissionReady = false + initialCommandStartupTicket = nil pendingConnectionStartupCommand = nil activeShellCommand = nil commandDraft.cancel() @@ -1423,6 +1425,19 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { func receiveShellIntegrationEvent(_ event: SSHShellIntegrationEvent) { guard isShellIntegrationEnabled else { return } + if case .ordinaryStartupUnavailable(let ticket) = event { + guard sessionPersistence.attachment == nil else { return } + guard let ticket else { + pendingInitialCommand = nil + pendingConnectionStartupCommand = nil + initialCommandStartupTicket = nil + return + } + initialCommandStartupTicket = ticket + isInitialCommandSubmissionReady = true + submitPendingInitialCommandIfReady() + return + } if case .runtime(let update) = event { receiveRuntimeContext(update) return @@ -1430,7 +1445,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { guard !hasRuntimeContext else { return } commandMonitor.receiveShellEvent(event) switch event { - case .runtime: break + case .runtime, .ordinaryStartupUnavailable: break case .available(let shell, let systemInfo, let homeDirectory): if let homeDirectory, homeDirectory.hasPrefix("/") { homeResolution = .resolved(RemotePath.normalized(homeDirectory)) @@ -1538,7 +1553,22 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { in: initialCommand.command ) } - insert(initialCommand.command, submit: true) + if let ticket = initialCommandStartupTicket { + initialCommandStartupTicket = nil + if let statusStore, let host = statusStore.host(hostID), + (try? statusStore.requireHostAccess(host)) == nil + { + return + } + let data = Data((initialCommand.command + "\r").utf8) + if let inputSink { + inputSink(data) + } else if let ssh { + Task { await ssh.submitInitialCommand(data, ticket: ticket) } + } + } else { + insert(initialCommand.command, submit: true) + } } private func submitPendingInitialCommandIfReady() { diff --git a/TFMTests/HostShellIntegrationSessionTests.swift b/TFMTests/HostShellIntegrationSessionTests.swift index b4adc9f6..662c2fa8 100644 --- a/TFMTests/HostShellIntegrationSessionTests.swift +++ b/TFMTests/HostShellIntegrationSessionTests.swift @@ -9,6 +9,48 @@ import struct TermindDomain.Host @Suite(.serialized) @MainActor struct HostShellIntegrationSessionTests { + @Test(arguments: [false, true]) + func ordinaryStartupFallbackSubmitsOnceButNeverIntoManagedAttachments(managed: Bool) { + let host = Host(name: "Server", host: "server.invalid", shellIntegrationMode: .enabled) + let session = SSHSessionModel(hostID: host.id, title: host.name, initialCommand: "echo initial") + var submitted = Data() + session.inputSink = { submitted.append($0) } + defer { session.disconnect() } + session.connect(using: HostLibraryModel(hosts: [host])) + session.status = .connected + if managed { + session.sessionPersistence.attachment = .init( + backend: .tmux, session: .init(id: "$1", name: "build", identity: Data()), + hostID: host.id, endpoint: "server.invalid:22", username: "user", ownershipToken: UUID()) + } + session.receiveShellIntegrationEvent(.runtime(.init(status: .unknown))) + session.receiveShellIntegrationEvent(.runtime(.init(status: .unavailable))) + #expect(submitted.isEmpty) + let fallback = SSHShellIntegrationEvent.ordinaryStartupUnavailable( + ticket: .init(generation: UUID(), inputRevision: 0)) + session.receiveShellIntegrationEvent(fallback) + session.receiveShellIntegrationEvent(fallback) + session.receiveShellIntegrationEvent(.promptReady) + #expect(String(decoding: submitted, as: UTF8.self) == (managed ? "" : "echo initial\r")) + #expect(!session.shellIntegrationAvailable) + #expect(!session.canGenerateFromCurrentDraft) + #expect(session.workingDirectory == nil) + } + + @Test func rejectedOrdinaryStartupDoesNotReplayAtALaterPrompt() { + let host = Host(name: "Server", host: "server.invalid", shellIntegrationMode: .enabled) + let session = SSHSessionModel(hostID: host.id, title: host.name, initialCommand: "echo initial") + var submitted = Data() + session.inputSink = { submitted.append($0) } + defer { session.disconnect() } + session.connect(using: HostLibraryModel(hosts: [host])) + session.status = .connected + session.receiveShellIntegrationEvent(.ordinaryStartupUnavailable(ticket: nil)) + session.receiveShellIntegrationEvent(.available(shell: "bash", systemInfo: nil, homeDirectory: "/home")) + session.receiveShellIntegrationEvent(.promptReady) + #expect(submitted.isEmpty) + } + @Test(arguments: ["shell", "tmux", "zellij", "zmx", "herdr"]) func runningCommandsKeepTheDisplayedDirectoryUntilTheShellReportsAnother(backend: String) throws { let session = SSHSessionModel(hostID: UUID(), title: "Cwd") diff --git a/TFMTests/RemoteMultiplexerShellTests.swift b/TFMTests/RemoteMultiplexerShellTests.swift deleted file mode 100644 index 80587005..00000000 --- a/TFMTests/RemoteMultiplexerShellTests.swift +++ /dev/null @@ -1,179 +0,0 @@ -import Foundation -import TermindApplication -import TermindDomain -import Testing - -@testable import TermindRemoteInfrastructure - -@Suite -nonisolated struct RemoteMultiplexerShellTests { - private static let shells = ["/bin/bash", "/bin/zsh", "/opt/homebrew/bin/fish"] - .filter { FileManager.default.isExecutableFile(atPath: $0) } - - @Test(arguments: ["shell", "tmux", "zellij", "zmx", "herdr"], shells) - func shellHooksKeepCwdInTheJournalDuringCommands(backend: String, shell: String) throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent("termind-cwd-test-\(UUID())") - try FileManager.default.createDirectory( - at: root.appendingPathComponent("next"), withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - let install = try RemoteShellStartup.install( - token: UUID(), startupCommand: "cd \"$HOME\"", shellIntegration: true, - backend: ["shell", "tmux"].contains(backend) ? nil : backend - ).replacingOccurrences( - of: "/tmp/termind-shell.XXXXXXXX", - with: root.appendingPathComponent("termind-shell.XXXXXXXX").path - ).replacingOccurrences( - of: "exec \"$TERMIND_TMUX_HELPERS/shell\"", - with: "exec /bin/sh \"$TERMIND_TMUX_HELPERS/shell\"") - let input = """ - cat "$__termind_context_dir/journal" > "$HOME/during" - cd "$HOME/next" - cat "$__termind_context_dir/journal" > "$HOME/after" - exit - """ - let result = try Self.run( - """ - \(install) - export TERMIND_TMUX_HELPERS="$helper" TERMIND_TMUX_SHELL=\(try RemoteMultiplexerCommands.quote(shell)) - unset TMUX_PANE ZELLIJ_PANE_ID HERDR_PANE_ID - \(backend == "tmux" ? "export TMUX_PANE=%1" : "") - export ZELLIJ_PANE_ID=1 HERDR_PANE_ID=1 - printf '%s\\n' \(try RemoteMultiplexerCommands.quote(input)) | /bin/sh "$helper/first" - """, home: root.path) - #expect(result.exitCode == 0, "\(result.stdout)") - let during = try RemoteShellContextRecord( - String(contentsOf: root.appendingPathComponent("during"), encoding: .utf8)) - let after = try RemoteShellContextRecord( - String(contentsOf: root.appendingPathComponent("after"), encoding: .utf8)) - #expect(during.backend == backend) - #expect(!during.ready) - #expect(!after.ready) - #expect(during.directory == root.resolvingSymlinksInPath().path) - #expect(after.directory == root.appendingPathComponent("next").resolvingSymlinksInPath().path) - #expect(after.sequence > during.sequence) - } - - @Test(arguments: SessionPersistenceBackendID.supported, shells) - func startupSnippetRunsOnlyInFirstShellAndPreservesUserConfiguration( - backend: SessionPersistenceBackendID, shell: String - ) throws { - for integration in [false, true] { - let root = FileManager.default.temporaryDirectory.appendingPathComponent("termind-shell-test-\(UUID())") - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - let name = URL(fileURLWithPath: shell).lastPathComponent - let userConfig = name == "zsh" ? ".zshrc" : ".bash_profile" - try "export USER_CONFIGURATION=preserved\n".write( - to: root.appendingPathComponent(userConfig), atomically: true, encoding: .utf8) - if name == "fish" { - let config = root.appendingPathComponent(".config/fish") - try FileManager.default.createDirectory(at: config, withIntermediateDirectories: true) - try "set -gx USER_CONFIGURATION preserved\n".write( - to: config.appendingPathComponent("config.fish"), atomically: true, encoding: .utf8) - } - let startup = - name == "fish" - ? "cd \"$HOME\"; set -gx FROM_SNIPPET yes; printf once >> \"$HOME/once\"" - : "cd \"$HOME\"; export FROM_SNIPPET=yes; printf once >> \"$HOME/once\"" - let install = try RemoteShellStartup.install( - token: UUID(), startupCommand: startup, shellIntegration: integration, - backend: backend == .tmux ? nil : backend.rawValue, - environment: [.init(variable: "SESSION_ONLY", value: "host-value")] - ) - .replacingOccurrences( - of: "/tmp/termind-shell.XXXXXXXX", - with: root.appendingPathComponent("termind-shell.XXXXXXXX").path - ) - .replacingOccurrences( - of: "exec \"$TERMIND_TMUX_HELPERS/shell\"", - with: "exec /bin/sh \"$TERMIND_TMUX_HELPERS/shell\"") - let firstInput = """ - printf '%s|%s|%s|%s\\n' "$PWD" "$FROM_SNIPPET" "$USER_CONFIGURATION" "$SESSION_ONLY" > "$HOME/first-result" - exit - """ - let secondInput = """ - printf '%s\\n' "$USER_CONFIGURATION" > "$HOME/second-result" - exit - """ - let script = """ - \(install) - trap 'rm -rf "$helper"' EXIT - [ -z "${SESSION_ONLY:-}" ] || exit 83 - export TERMIND_TMUX_HELPERS="$helper" TERMIND_TMUX_SHELL=\(try RemoteMultiplexerCommands.quote(shell)) - export TMUX_PANE=%1 ZELLIJ_PANE_ID=1 HERDR_PANE_ID=1 - printf '%s\\n' \(try RemoteMultiplexerCommands.quote(firstInput)) | /bin/sh "$helper/first" - printf '%s\\n' \(try RemoteMultiplexerCommands.quote(secondInput)) | /bin/sh "$helper/shell" - """ - let result = try Self.run(script, home: root.path) - #expect(result.exitCode == 0, "\(result.stderr)\n\(result.stdout)") - #expect(try String(contentsOf: root.appendingPathComponent("once"), encoding: .utf8) == "once") - let first = try String(contentsOf: root.appendingPathComponent("first-result"), encoding: .utf8) - #expect(first.hasSuffix("|yes|preserved|host-value\n"), "\(first)") - #expect( - try String(contentsOf: root.appendingPathComponent("second-result"), encoding: .utf8) == "preserved\n") - #expect( - try String(contentsOf: root.appendingPathComponent(userConfig), encoding: .utf8) - == "export USER_CONFIGURATION=preserved\n") - } - } - - @Test(arguments: shells) - func cancelledStartupCannotRunLaterWhenUserConfigurationFinishes(shell: String) throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent("termind-cancel-test-\(UUID())") - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - let install = try RemoteShellStartup.install( - token: UUID(), startupCommand: "printf late > \"$HOME/late\"", shellIntegration: true, backend: "zmx" - ).replacingOccurrences( - of: "/tmp/termind-shell.XXXXXXXX", with: root.appendingPathComponent("termind-shell.XXXXXXXX").path) - let result = try Self.run( - """ - \(install) - export TERMIND_TMUX_HELPERS="$helper" TERMIND_TMUX_SHELL=\(try RemoteMultiplexerCommands.quote(shell)) - mkdir "$helper/startup-decision" - printf 'printf ready > "$HOME/ready"\\nexit\\n' | /bin/sh "$helper/shell" || exit $? - [ ! -e "$helper/started" ] - """, home: root.path) - #expect(result.exitCode == 0, "\(result.stdout)") - #expect(try String(contentsOf: root.appendingPathComponent("ready"), encoding: .utf8) == "ready") - #expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent("late").path)) - } - - @Test func expiredAndMismatchedDraftRequestsAreConsumedWithoutReturningPayload() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent("termind-draft-test-\(UUID())") - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - let state = root.appendingPathComponent("state") - let request = root.appendingPathComponent("request") - let reader = root.appendingPathComponent("reader") - try "R1|10|1|%2Ftmp\n".write(to: state, atomically: true, encoding: .utf8) - try RemoteShellContextJournal.requestReader.write(to: reader, atomically: true, encoding: .utf8) - for (sequence, deadline) in [("10", "1"), ("9", String(Int(Date().timeIntervalSince1970) + 60))] { - try "\(UUID())|\(sequence)|replace|%65%63%68%6F|\(deadline)|-\n".write( - to: request, atomically: true, encoding: .utf8) - let result = try Self.run( - "sh \(RemoteMultiplexerCommands.quote(reader.path)) \(RemoteMultiplexerCommands.quote(root.path)) replace", - home: root.path) - #expect(result.exitCode != 0) - #expect(result.stdout.isEmpty) - #expect(!FileManager.default.fileExists(atPath: request.path)) - } - } - - static func run(_ command: String, home: String) throws -> RemoteSessionCommandResult { - let process = Process() - let output = Pipe() - process.executableURL = URL(fileURLWithPath: "/bin/sh") - process.arguments = ["-c", command] - process.environment = ProcessInfo.processInfo.environment.merging([ - "HOME": home, "XDG_CONFIG_HOME": home + "/.config", "XDG_DATA_HOME": home + "/.local/share", - "ZDOTDIR": home, "LC_ALL": "C", - ]) { _, new in new } - process.standardOutput = output - process.standardError = output - try process.run() - let data = output.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - return .init(stdout: String(decoding: data, as: UTF8.self), stderr: "", exitCode: process.terminationStatus) - } -}