From e457e9af98f554f080e525a3642693c84479fb3c Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sat, 17 Oct 2026 09:50:37 +0800 Subject: [PATCH] fix(multiplexer): preserve session ownership and safe shell startup Detach managed sessions when native mobile windows are discarded, retain live reopen references, and fence ordinary startup commands by input and session generation. Handle noexec helpers, isolate tmux discovery locale, and count only verified live contexts against the journal limit. Move actual shell execution coverage into the hostless framework suite. Validation: 76 focused macOS tests passed; iOS multiplexer regressions passed with the inherited hosted-test artwork failure still present. --- .../RemoteMultiplexerShellTests.swift | 457 ++++++++++++++++++ FrameworkTests/RemoteMultiplexerTests.swift | 23 + .../Sessions/SSHShellIntegrationEvent.swift | 13 + .../SSH/RemoteMultiplexerRuntimeAdapter.swift | 40 +- .../SSH/RemoteShellStartup.swift | 37 +- .../SSH/RemoteTerminalDraft.swift | 1 + .../SSH/RemoteTerminalProcessProbe.swift | 28 ++ .../SSH/RemoteTmuxCommands.swift | 19 +- .../SSH/RemoteTmuxRuntimeDialect.swift | 17 +- .../SSH/SSHPTYChannel.swift | 82 +++- TFI/App/MobileAppModel.swift | 19 +- TFI/App/MobileSceneModel.swift | 24 +- TFI/App/MobileWorkspaceSessionModel.swift | 10 +- .../MobileWorkspaceConnectionServices.swift | 3 + TFI/Features/Workspaces/MobileWorkspace.swift | 30 +- ...MobileCommandGeneratorAndUploadTests.swift | 62 +++ TFITests/MobileTerminalRestorationTests.swift | 51 ++ TFITests/MobileWorkspaceSessionTests.swift | 26 +- TFM/Features/Terminal/SSHSessionModel.swift | 34 +- .../HostShellIntegrationSessionTests.swift | 42 ++ TFMTests/RemoteMultiplexerShellTests.swift | 179 ------- 21 files changed, 931 insertions(+), 266 deletions(-) create mode 100644 FrameworkTests/RemoteMultiplexerShellTests.swift delete mode 100644 TFMTests/RemoteMultiplexerShellTests.swift 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) - } -}