From 5a74809da89a8fc2ccd5d6f20f91238a72f2c2d4 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Fri, 16 Oct 2026 18:50:55 +0800 Subject: [PATCH] feat(sessions): unify multiplexer backends and preserve cwd Add Zellij, zmx, and Herdr alongside tmux using shared lifecycle, shell startup, journal, and runtime infrastructure. Keep verified shell directories visible during commands without restoring unsafe operation capabilities. Cover backend ownership, startup replay, runtime context, and desktop/mobile cwd regressions. Preserve conservative degradation where client focus cannot be verified. --- FrameworkTests/RemoteMultiplexerTests.swift | 378 ++++++++++ FrameworkTests/SessionPersistenceTests.swift | 48 +- .../TerminalContextCoordinatorTests.swift | 61 ++ Frameworks/Resources/Localizable.xcstrings | 47 ++ .../Terminal/RemoteSessionBackend.swift | 8 +- .../Terminal/TerminalContextCoordinator.swift | 5 +- .../Sessions/SessionPersistence.swift | 6 + .../Sessions/TerminalContext.swift | 1 + .../HostIntegrationSection.swift | 7 +- .../RemoteSessionPromptView.swift | 1 + .../Strings/SessionPersistence.swift | 3 + .../SSH/RemoteHerdrCommands.swift | 63 ++ .../SSH/RemoteMultiplexerRuntimeAdapter.swift | 327 +++++++++ .../SSH/RemoteMultiplexerService.swift | 45 ++ .../SSH/RemoteMultiplexerSupport.swift | 660 ++++++++++++++++++ .../SSH/RemoteSessionBackendRegistry.swift | 13 + .../SSH/RemoteShellContextJournal.swift | 51 +- ...Startup.swift => RemoteShellStartup.swift} | 47 +- .../SSH/RemoteTerminalContextFilter.swift | 25 +- .../SSH/RemoteTerminalDraft.swift | 54 +- ...Service.swift => RemoteTmuxCommands.swift} | 98 +-- ...r.swift => RemoteTmuxRuntimeDialect.swift} | 38 +- .../SSH/RemoteZellijCommands.swift | 85 +++ .../SSH/RemoteZmxCommands.swift | 78 +++ .../SSH/SSHPTYChannel.swift | 67 +- ...MobileCommandGeneratorAndUploadTests.swift | 38 + TFMTests/ArchitectureBoundaryTests.swift | 12 +- .../HostShellIntegrationSessionTests.swift | 35 + TFMTests/RemoteMultiplexerShellTests.swift | 179 +++++ TFMTests/RemoteMultiplexerSmokeTests.swift | 96 +++ TFMTests/RemoteSessionWireTests.swift | 14 +- TFMTests/TerminalActivationWireTests.swift | 8 +- TFMTests/TerminalContextRuntimeTests.swift | 27 +- TFMTests/TerminalContextWireTests.swift | 77 +- 34 files changed, 2487 insertions(+), 215 deletions(-) create mode 100644 FrameworkTests/RemoteMultiplexerTests.swift create mode 100644 Frameworks/TermindRemoteInfrastructure/SSH/RemoteHerdrCommands.swift create mode 100644 Frameworks/TermindRemoteInfrastructure/SSH/RemoteMultiplexerRuntimeAdapter.swift create mode 100644 Frameworks/TermindRemoteInfrastructure/SSH/RemoteMultiplexerService.swift create mode 100644 Frameworks/TermindRemoteInfrastructure/SSH/RemoteMultiplexerSupport.swift create mode 100644 Frameworks/TermindRemoteInfrastructure/SSH/RemoteSessionBackendRegistry.swift rename Frameworks/TermindRemoteInfrastructure/SSH/{RemoteTmuxShellStartup.swift => RemoteShellStartup.swift} (70%) rename Frameworks/TermindRemoteInfrastructure/SSH/{RemoteTmuxService.swift => RemoteTmuxCommands.swift} (65%) rename Frameworks/TermindRemoteInfrastructure/SSH/{RemoteTmuxRuntimeAdapter.swift => RemoteTmuxRuntimeDialect.swift} (77%) create mode 100644 Frameworks/TermindRemoteInfrastructure/SSH/RemoteZellijCommands.swift create mode 100644 Frameworks/TermindRemoteInfrastructure/SSH/RemoteZmxCommands.swift create mode 100644 TFMTests/RemoteMultiplexerShellTests.swift create mode 100644 TFMTests/RemoteMultiplexerSmokeTests.swift diff --git a/FrameworkTests/RemoteMultiplexerTests.swift b/FrameworkTests/RemoteMultiplexerTests.swift new file mode 100644 index 00000000..b929e67b --- /dev/null +++ b/FrameworkTests/RemoteMultiplexerTests.swift @@ -0,0 +1,378 @@ +import Foundation +import TermindApplication +import TermindDomain +import Testing + +@testable import TermindRemoteInfrastructure + +@Suite +nonisolated struct RemoteMultiplexerTests { + private static let token = UUID(uuidString: "11111111-2222-3333-4444-555555555555")! + private static let kinds: [RemoteMultiplexerKind] = [.tmux, .zellij, .zmx, .herdr] + + @Test(arguments: kinds) + func registryAndVersionProbeUseTheSameService(kind: RemoteMultiplexerKind) async throws { + let versions: [RemoteMultiplexerKind: String] = [ + .tmux: "tmux 3.3a\n", .zellij: "zellij 0.44.3\n", + .zmx: "zmx\t\t0.7.0\nghostty_vt\t1.2.3\nsocket_dir\t/tmp/zmx\nlog_dir\t/tmp\n", + .herdr: "herdr 0.7.5\n", + ] + let recorder = Recorder([.init(stdout: versions[kind]!, stderr: "", exitCode: 0)]) + let backend = try RemoteSessionBackendRegistry.live.backend( + kind.id, execute: { try await recorder.execute($0) }) + #expect(backend is RemoteMultiplexerService) + #expect(try await backend.probe().shellIntegration) + let command = try #require(await recorder.commands.first) + #expect(command.contains("command -v \(kind.rawValue)")) + #expect(!command.contains("new-session")) + } + + @Test(arguments: [ + ("zellij", "0.44.2", false), ("zellij", "0.44.3", true), ("zellij", "1.0.0", true), + ("zmx", "0.6.9", false), ("zmx", "0.7.0", true), + ("herdr", "0.7.4", false), ("herdr", "0.7.5", true), ("herdr", "0.7.99", true), + ("herdr", "0.8.0", false), ("herdr", "0.8.1", false), ("herdr", "0.8.2", true), + ]) + func versionBoundaries(value: (String, String, Bool)) throws { + let kind = try #require(RemoteMultiplexerKind(rawValue: value.0)) + if value.2 { + try kind.validateVersion("\(value.0) \(value.1)\n") + } else { + #expect(throws: RemoteSessionFailure.unsupported) { + try kind.validateVersion("\(value.0) \(value.1)\n") + } + } + #expect(throws: (any Error).self) { try kind.validateVersion("\(value.0) 0.99\n") } + #expect(throws: (any Error).self) { try kind.validateVersion("\(value.0) 0.99.0-dev\n") } + } + + @Test(arguments: kinds) + func failuresAndTruncationNeverEnableABackend(kind: RemoteMultiplexerKind) async { + for (response, expected) in [ + (RemoteSessionCommandResult(stdout: "", stderr: "", exitCode: 77), RemoteSessionFailure.missing), + (.init(stdout: "", stderr: "", exitCode: 79), .ownershipMismatch), + (.init(stdout: "", stderr: "", exitCode: 81), .startupUnavailable), + (.init(stdout: "partial", stderr: "", exitCode: 0, truncated: true), .invalidResponse), + (.init(stdout: "not a version", stderr: "", exitCode: 0), .invalidResponse), + ] { + let backend = RemoteMultiplexerService(backend: kind.id) { _ in response } + await #expect(throws: expected) { try await backend.probe() } + } + } + + @Test func nativeListingsAreStrictAndDoNotInferOwnershipFromLabelsOrNames() throws { + let zmx = try RemoteZmxCommands.parseList( + "ROOT|\(Self.hex("/tmp/zmx"))\nname=termind-external\tpid=123\tclients=2\tcreated=456\tmanaged=true\nEND\n") + #expect(zmx.first?.clients == 2) + #expect(zmx.first?.process == "123") + let zellij = try RemoteZellijCommands.parseList(Self.list(.zellij)) + #expect(zellij.count == 1) + #expect(zellij.first?.isolated == true) + let herdr = try RemoteHerdrCommands.parseList(Self.list(.herdr)) + #expect(herdr.map(\.name) == [Self.name]) + for value in [ + "name=test\tpid=123\tclients=1", "name=test\tpid=123\tclients=-1\tcreated=456", + "name=test\tpid=123\tpid=456\tclients=1\tcreated=456", "{\"sessions\":[]}", + "name=../test\tpid=123\tclients=1\tcreated=456", "name=test\terr=Timeout", + ] { + #expect(throws: (any Error).self) { + try RemoteZmxCommands.parseList("ROOT|\(Self.hex("/tmp/zmx"))\n\(value)\nEND\n") + } + } + #expect(throws: (any Error).self) { try RemoteZmxCommands.parseList(String(Self.list(.zmx).dropLast(4))) } + #expect(throws: (any Error).self) { try RemoteZellijCommands.parseList(String(Self.list(.zellij).dropLast(4))) } + #expect(throws: (any Error).self) { try RemoteHerdrCommands.parseList(String(Self.list(.herdr).dropLast())) } + } + + @Test(arguments: [RemoteMultiplexerKind.zellij, .zmx, .herdr]) + func recoveryRejectsReplacedInstancesAndNeverAcquiresManualOwnership(kind: RemoteMultiplexerKind) throws { + let reference = try Self.reference(kind) + let backend = RemoteMultiplexerService(backend: kind.id) { _ in throw CancellationError() } + #expect(backend.matches(reference, session: reference.session)) + var identity = try RemoteMultiplexerIdentity.decode(reference.session.identity, kind: kind) + identity = .init( + kind: kind, root: identity.root, socket: identity.socket, fingerprint: "1:2:999", + process: identity.process, created: identity.created, owner: Self.token, + helper: identity.helper, isolated: identity.isolated, directoryFingerprint: identity.directoryFingerprint, + daemonProcess: identity.daemonProcess, daemonStarted: identity.daemonStarted) + let replacement = RemoteSessionDescriptor( + id: reference.session.id, name: reference.session.name, identity: try JSONEncoder().encode(identity)) + #expect(!backend.matches(reference, session: replacement)) + let manual = RemoteSessionReference( + backend: kind.id, session: reference.session, hostID: UUID(), + endpoint: "server:22", username: "user", ownershipToken: nil) + #expect(try !backend.launch(manual, created: false).attachment.isManaged) + } + + @Test(arguments: [RemoteMultiplexerKind.zellij, .zmx, .herdr]) + func identityRoutingAndOwnershipAreRecheckedBeforeClose(kind: RemoteMultiplexerKind) async throws { + let recorder = Recorder([.init(stdout: "TERMIND_ENDED\n", stderr: "", exitCode: 0)]) + let backend = RemoteMultiplexerService(backend: kind.id, execute: { try await recorder.execute($0) }) + let reference = try Self.reference(kind) + try await backend.terminate(reference) + let command = try #require(await recorder.commands.first) + #expect(command.contains("fingerprint")) + #expect(command.contains(Self.token.uuidString)) + #expect(command.contains("private_state")) + if kind == .zmx { + #expect(command.contains("termind_owner")) + #expect(command.contains("123:456")) + #expect(command.contains("kill '\(Self.name)' --force")) + } + if kind == .herdr { + #expect(command.contains("XDG_CONFIG_HOME='/tmp/test-config'")) + #expect(command.contains("directory_fingerprint")) + #expect(command.contains("session stop")) + #expect(command.contains("session delete")) + } + let manual = RemoteSessionReference( + backend: kind.id, session: reference.session, hostID: UUID(), + endpoint: "", username: "", ownershipToken: nil) + await #expect(throws: RemoteSessionFailure.ownershipMismatch) { try await backend.terminate(manual) } + #expect(await recorder.commands.count == 1) + } + + @Test func zmxDaemonReplacementDoesNotMatchEvenIfTheSocketFingerprintIsUnchanged() throws { + let reference = try Self.reference(.zmx) + let original = try RemoteMultiplexerIdentity.decode(reference.session.identity, kind: .zmx) + let service = RemoteMultiplexerService(backend: .zmx) { _ in throw CancellationError() } + for (pid, created, owner) in [ + ("124", "456", Self.token), ("123", "457", Self.token), ("123", "456", UUID()), + ] { + let changed = RemoteMultiplexerIdentity( + kind: .zmx, root: original.root, socket: original.socket, fingerprint: original.fingerprint, + process: pid, created: created, owner: owner, helper: original.helper, isolated: false, + daemonProcess: original.daemonProcess, daemonStarted: original.daemonStarted) + let session = RemoteSessionDescriptor( + id: reference.session.id, name: reference.session.name, identity: try JSONEncoder().encode(changed)) + #expect(!service.matches(reference, session: session)) + } + var replacedDaemon = original + replacedDaemon.daemonStarted = "Fri Oct 16 10:00:01 2026" + let session = RemoteSessionDescriptor( + id: reference.session.id, name: reference.session.name, identity: try JSONEncoder().encode(replacedDaemon)) + #expect(!service.matches(reference, session: session)) + } + + @Test func herdrDefaultAndForeignOwnershipNeverReachStopOrDelete() async throws { + let recorder = Recorder([]) + let service = RemoteMultiplexerService(backend: .herdr, execute: { try await recorder.execute($0) }) + let identity = RemoteMultiplexerIdentity( + kind: .herdr, root: "/tmp/config/herdr", socket: "/tmp/config/herdr/herdr.sock", + fingerprint: "1:2:3", process: nil, created: nil, owner: Self.token, helper: nil, + isolated: false, directoryFingerprint: "1:2") + let session = RemoteSessionDescriptor( + id: "user:default", name: "default", identity: try JSONEncoder().encode(identity)) + let reference = RemoteSessionReference( + backend: .herdr, session: session, hostID: UUID(), endpoint: "", username: "", + ownershipToken: Self.token) + await #expect(throws: RemoteSessionFailure.ownershipMismatch) { try await service.terminate(reference) } + let owned = try Self.reference(.herdr) + let foreign = RemoteSessionReference( + backend: .herdr, session: owned.session, hostID: UUID(), endpoint: "", username: "", + ownershipToken: UUID()) + await #expect(throws: RemoteSessionFailure.ownershipMismatch) { try await service.terminate(foreign) } + #expect(await recorder.commands.isEmpty) + } + + @Test(arguments: [RemoteMultiplexerKind.zellij, .zmx, .herdr]) + func closeFailureAndIncompleteOutputAreNotReportedAsSuccess(kind: RemoteMultiplexerKind) async throws { + for response in [ + RemoteSessionCommandResult(stdout: "", stderr: "", exitCode: 79), + .init(stdout: "TERMIND_ENDED", stderr: "", exitCode: 0), + .init(stdout: "TERMIND_ENDED\n", stderr: "", exitCode: 0, truncated: true), + ] { + let service = RemoteMultiplexerService(backend: kind.id) { _ in response } + await #expect(throws: (any Error).self) { try await service.terminate(Self.reference(kind)) } + } + } + + @Test(arguments: [RemoteMultiplexerKind.zellij, .zmx, .herdr]) + func confirmedCreateRetriesNeverReplayStartupOrCreateAnotherShell(kind: RemoteMultiplexerKind) async throws { + let recorder = Recorder([ + .init(stdout: Self.list(kind), stderr: "", exitCode: 0), + .init(stdout: Self.metadata(kind), stderr: "", exitCode: 0), + .init(stdout: "TERMIND_CREATED\n", stderr: "", exitCode: 0), + ]) + let service = RemoteMultiplexerService(backend: kind.id, execute: { try await recorder.execute($0) }) + let session = try await service.create( + token: Self.token, startupCommand: "echo NEVER_REPLAY", shellIntegration: true) + #expect(session.name == Self.name) + let completion = try #require(await recorder.commands.last) + #expect(completion.contains("/started")) + #expect(!completion.contains("NEVER_REPLAY")) + #expect(!completion.contains("attach --create")) + #expect(!completion.contains(" server ")) + } + + @Test(arguments: [RemoteMultiplexerKind.zellij, .zmx, .herdr]) + func freshCreateInstallsWrapperAndConfirmsTheOwnedIncarnation(kind: RemoteMultiplexerKind) async throws { + let recorder = Recorder([ + .init(stdout: Self.list(kind, empty: true), stderr: "", exitCode: 0), + .init(stdout: "TERMIND_CREATED\n", stderr: "", exitCode: 0), + .init(stdout: Self.list(kind), stderr: "", exitCode: 0), + .init(stdout: Self.metadata(kind), stderr: "", exitCode: 0), + ]) + let service = RemoteMultiplexerService(backend: kind.id, execute: { try await recorder.execute($0) }) + _ = try await service.create( + token: Self.token, startupCommand: "cd /tmp; export FROM_SNIPPET=yes", shellIntegration: true) + let create = await recorder.commands[1] + #expect(create.contains("first-claimed")) + #expect(create.contains("cd /tmp; export FROM_SNIPPET=yes")) + #expect(create.contains("TERMIND_CREATED")) + switch kind { + case .zellij: + #expect(create.contains("--session-serialization false")) + #expect(create.contains("--layout-string 'layout { pane; }'")) + case .zmx: + #expect(create.contains("termind_owner=\(Self.token.uuidString)")) + #expect(create.contains(" String { + "C|987\nN|\(Self.hex(native))\nA|\(Self.hex(focus))\nBEGIN|1\n\(record)\nEND\nDONE\n" + } + let update = try RemoteSocketMultiplexerRuntime.parse(frame(native, focus), reference: reference) + if kind == .herdr { + #expect(update.active == nil) + } else { + #expect(update.active?.pane == pane) + #expect(update.contexts.first?.capabilities.contains(.replaceDraft) == true) + let ambiguousNative = + kind == .zmx ? native.replacingOccurrences(of: "clients=1", with: "clients=2") : native + let ambiguousFocus = kind == .zellij ? focus + "\n2 terminal_2 bash" : focus + let ambiguous = try RemoteSocketMultiplexerRuntime.parse( + frame(ambiguousNative, ambiguousFocus), reference: reference) + #expect(ambiguous.active == nil) + #expect(ambiguous.contexts.allSatisfy { !$0.capabilities.contains(.replaceDraft) }) + } + let running = try RemoteSocketMultiplexerRuntime.parse( + frame(native, focus) + .replacingOccurrences(of: "BEGIN|1", with: "BEGIN|0") + .replacingOccurrences(of: "R1|2|1|", with: "R1|3|0|") + .replacingOccurrences(of: "\nEND\n", with: "\nE1|3|start|sleep%2010|\nEND\n"), + reference: reference) + let coordinator = TerminalContextCoordinator() + coordinator.receive(update, generation: coordinator.generation) + coordinator.receive(running, generation: coordinator.generation) + #expect(running.contexts.first?.directory == "/tmp") + // Herdr still cannot bind a client to its active pane; never substitute global focus. + #expect(coordinator.directory == (kind == .herdr ? nil : "/tmp")) + #expect(coordinator.ticket(requiring: .directory) == nil) + #expect(coordinator.ticket(requiring: .readDraft) == nil) + #expect(coordinator.ticket(requiring: .replaceDraft) == nil) + #expect(throws: (any Error).self) { + try RemoteSocketMultiplexerRuntime.parse(String(frame(native, focus).dropLast(6)), reference: reference) + } + #expect(throws: (any Error).self) { + try RemoteSocketMultiplexerRuntime.parse( + frame(native, focus).replacingOccurrences(of: "|\(kind.rawValue)\n", with: "|foreign\n"), + reference: reference) + } + } + + @Test func quotingCannotEscapeIntoShellSyntax() throws { + #expect(try RemoteMultiplexerCommands.quote("a'b\n$(touch /tmp/no)") == "'a'\\''b\n$(touch /tmp/no)'") + #expect(throws: RemoteSessionFailure.invalidResponse) { try RemoteMultiplexerCommands.quote("bad\0value") } + #expect(!RemoteMultiplexerCommands.validName("test*")) + #expect(!RemoteMultiplexerCommands.validPath("/tmp/../etc")) + } + + private static var name: String { RemoteMultiplexerCommands.managedName(token) } + private static func hex(_ value: String) -> String { value.utf8.map { String(format: "%02x", $0) }.joined() } + private static func root(_ kind: RemoteMultiplexerKind) -> String { + kind == .herdr ? "/tmp/test-config/herdr/sessions/\(name)" : "/tmp/\(kind.rawValue)" + } + private static func socket(_ kind: RemoteMultiplexerKind) -> String { + root(kind) + (kind == .herdr ? "/herdr.sock" : "/\(name)") + } + private static func list(_ kind: RemoteMultiplexerKind, empty: Bool = false) -> String { + switch kind { + case .zellij: + return "ROOT|user|\(hex("/tmp/user-zellij"))\nEND\nROOT|managed|\(hex(root(kind)))\n" + + (empty ? "" : "\(name)\n") + "END\n" + case .zmx: + return "ROOT|\(hex(root(kind)))\n" + + (empty ? "" : "name=\(name)\tpid=123\tclients=1\tcreated=456\n") + "END\n" + case .herdr: + return empty + ? #"{"sessions":[]}"# + : """ + {"sessions":[{"name":"\(name)","running":true,"socket_path":"\(socket(kind))","session_dir":"\(root(kind))"}]} + """ + case .tmux: return "TERMIND_TMUX_1\n" + } + } + private static func metadata(_ kind: RemoteMultiplexerKind) -> String { + "F|0|\(hex(socket(kind)))|1:2:3|\(token.uuidString)|/tmp/termind-shell.testing|\(kind == .herdr ? "1:2" : "-")" + + (kind == .zmx ? "|122|\(hex("Fri Oct 16 10:00:00 2026"))\n" : "|-|-\n") + } + private static func reference(_ kind: RemoteMultiplexerKind) throws -> RemoteSessionReference { + let identity = RemoteMultiplexerIdentity( + kind: kind, root: root(kind), socket: socket(kind), fingerprint: "1:2:3", + process: kind == .zmx ? "123" : nil, created: kind == .zmx ? "456" : nil, + owner: token, helper: "/tmp/termind-shell.testing", isolated: kind == .zellij, + directoryFingerprint: kind == .herdr ? "1:2" : nil, + daemonProcess: kind == .zmx ? "122" : nil, + daemonStarted: kind == .zmx ? "Fri Oct 16 10:00:00 2026" : nil) + return .init( + backend: kind.id, + session: .init( + id: (kind == .zellij ? "managed:" : "user:") + name, name: name, + identity: try JSONEncoder().encode(identity)), + hostID: UUID(), endpoint: "server:22", username: "user", ownershipToken: token) + } + + private actor Recorder { + var responses: [RemoteSessionCommandResult] + var commands: [String] = [] + init(_ responses: [RemoteSessionCommandResult]) { self.responses = responses } + func execute(_ command: String) throws -> RemoteSessionCommandResult { + commands.append(command) + guard !responses.isEmpty else { throw RemoteSessionFailure.invalidResponse } + return responses.removeFirst() + } + } +} diff --git a/FrameworkTests/SessionPersistenceTests.swift b/FrameworkTests/SessionPersistenceTests.swift index b6f69d80..e0301527 100644 --- a/FrameworkTests/SessionPersistenceTests.swift +++ b/FrameworkTests/SessionPersistenceTests.swift @@ -11,11 +11,35 @@ import struct TermindDomain.Host @Suite @MainActor struct SessionPersistenceTests { - @Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal]) - func resumableTransportAttachesTmuxAndRestoresWithoutReplayingStartup(_ transport: HostTerminalProtocol) + @Test(arguments: SessionPersistenceBackendID.supported) + func sshReconnectKeepsAttachmentAndDoesNotReplayTheSelectedSnippet(backendID: SessionPersistenceBackendID) async throws { - let backend = Backend() + let backend = Backend(id: backendID) + let coordinator = RemoteSessionCoordinator() + let hostID = UUID() + let registry = RemoteSessionBackendRegistry([backendID: { _ in backend }]) + let execute: RemoteSessionCommandExecutor = { _ in throw CancellationError() } + let first = try await coordinator.prepare( + preferences: .init(enabled: true, backend: backendID, onConnect: .createManaged), + registry: registry, execute: execute, hostID: hostID, endpoint: "server:22", username: "user", + startupCommand: "cd /tmp; export ONCE=yes", requiresNewSession: false, shellIntegration: true) + let reference = try #require(first?.runtimeAttachment) + let restored = try await coordinator.prepare( + preferences: .init(enabled: true, backend: backendID, onConnect: .createManaged), + registry: registry, execute: execute, hostID: hostID, endpoint: "server:22", username: "user", + startupCommand: "must-not-replay", requiresNewSession: false, shellIntegration: true) + #expect(restored?.runtimeAttachment == reference) + #expect(await backend.commands == ["cd /tmp; export ONCE=yes"]) + } + + @Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal], SessionPersistenceBackendID.supported) + func resumableTransportAttachesAndRestoresWithoutReplayingStartup( + _ transport: HostTerminalProtocol, backendID: SessionPersistenceBackendID + ) + async throws + { + let backend = Backend(id: backendID) let coordinator = RemoteSessionCoordinator() let hostID = UUID() let preparation: SSHPTYChannel.StartupPreparation = { execute in @@ -32,6 +56,7 @@ struct SessionPersistenceTests { let startup = try await ResumableTerminalStartup.prepare( restoring: false, preparation: preparation, execute: execute) #expect(coordinator.attachment != nil) + #expect(startup.plan?.runtimeAttachment?.backend == backendID) #expect(await backend.commands == ["uptime"]) if transport == .mosh { #expect(try startup.moshArguments == " -- /bin/sh -c 'attach-test'") @@ -63,10 +88,10 @@ struct SessionPersistenceTests { } } - @Test(arguments: [false, true]) - func preferencesRoundTripWithoutResettingSelections(enabled: Bool) throws { + @Test(arguments: [false, true], SessionPersistenceBackendID.supported + [.init(rawValue: "future")]) + func preferencesRoundTripWithoutResettingSelections(enabled: Bool, backend: SessionPersistenceBackendID) throws { var host = Host(name: "Saved", host: "example.invalid") - host.sessionPersistence = .init(enabled: enabled, backend: .init(rawValue: "future"), onConnect: .createManaged) + host.sessionPersistence = .init(enabled: enabled, backend: backend, onConnect: .createManaged) #expect(try JSONDecoder().decode(Host.self, from: JSONEncoder().encode(host)) == host) #expect(PersistentHost(host).value.sessionPersistence == host.sessionPersistence) let envelope = VaultEnvelope( @@ -367,7 +392,7 @@ struct SessionPersistenceTests { @Test func creationRequiresMatchingRemoteOwnershipConfirmation() async throws { let name = Array("session\n".utf8).map { String(format: "%02x", $0) }.joined() - let service = RemoteTmuxService { _ in + let service = RemoteMultiplexerService { _ in .init(stdout: "TERMIND_TMUX_1\nS $1 123 456 1 0 \(name) -\n", stderr: "", exitCode: 0) } await #expect(throws: RemoteSessionFailure.ownershipMismatch) { @@ -382,12 +407,12 @@ struct SessionPersistenceTests { (.init(stdout: "unrecognized\n", stderr: "", exitCode: 0), .invalidResponse), (.init(stdout: "tmux 3.3a\n", stderr: "", exitCode: 0, truncated: true), .invalidResponse), ] { - let backend = RemoteTmuxService { _ in result } + let backend = RemoteMultiplexerService { _ in result } await #expect(throws: failure) { try await backend.probe() } } - let timeout = RemoteTmuxService { _ in throw RemoteSessionFailure.timedOut } + let timeout = RemoteMultiplexerService { _ in throw RemoteSessionFailure.timedOut } await #expect(throws: RemoteSessionFailure.timedOut) { try await timeout.probe() } - let empty = RemoteTmuxService { _ in .init(stdout: "TERMIND_TMUX_1\n", stderr: "", exitCode: 0) } + let empty = RemoteMultiplexerService { _ in .init(stdout: "TERMIND_TMUX_1\n", stderr: "", exitCode: 0) } #expect(try await empty.sessions().isEmpty) } @@ -579,7 +604,8 @@ struct SessionPersistenceTests { } private actor Backend: RemoteSessionBackend { - nonisolated let id = SessionPersistenceBackendID(rawValue: "test-backend") + nonisolated let id: SessionPersistenceBackendID + init(id: SessionPersistenceBackendID = .init(rawValue: "test-backend")) { self.id = id } nonisolated let capabilities = RemoteSessionCapabilities() var probeCount = 0 var commands: [String?] = [] diff --git a/FrameworkTests/TerminalContextCoordinatorTests.swift b/FrameworkTests/TerminalContextCoordinatorTests.swift index eb4b6eb6..a1680bc1 100644 --- a/FrameworkTests/TerminalContextCoordinatorTests.swift +++ b/FrameworkTests/TerminalContextCoordinatorTests.swift @@ -12,6 +12,67 @@ struct TerminalContextCoordinatorTests { capabilities: [.directory, .commandLifecycle, .readDraft, .replaceDraft]) } + @Test(arguments: ["shell", "tmux", "zellij", "zmx", "herdr"]) + func commandsKeepTheReportedDirectoryWithoutKeepingOperationTickets(backend: String) throws { + let model = TerminalContextCoordinator() + var shell = TerminalShellContext( + identity: .init(backend: backend, server: "s", session: "s", pane: "a", shellInstance: "one"), + shell: "bash", directory: "/before", home: "/home/test", promptReady: true, sequence: 1, + capabilities: [.directory, .commandLifecycle, .readDraft, .replaceDraft]) + let status: TerminalRuntimeUpdate.Status = backend == "shell" ? .ordinary : .multiplexer + func receive(_ events: [TerminalContextEvent] = []) { + model.receive( + .init(status: status, active: shell.identity, contexts: [shell], events: events), + generation: model.generation) + } + receive() + let draft = try #require(model.ticket(requiring: .replaceDraft)) + let directory = try #require(model.ticket(requiring: .directory)) + shell.promptReady = false + shell.capabilities = [.commandLifecycle] + shell.sequence = 2 + receive([.init(context: shell.identity, sequence: 2, payload: .commandStarted("sleep 10"))]) + #expect(model.directory == "/before") + #expect(model.ticket(requiring: .readDraft) == nil) + #expect(model.ticket(requiring: .replaceDraft) == nil) + #expect(model.ticket(requiring: .directory) == nil) + #expect(!model.isCurrent(draft, requiring: .replaceDraft)) + #expect(!model.isCurrent(directory, requiring: .directory)) + receive() + #expect(model.directory == "/before") + + // A chpwd event may arrive before the next prompt (for example, zsh's cd hook). + shell.directory = "/after" + shell.sequence = 3 + receive([.init(context: shell.identity, sequence: 3, payload: .directory("/after"))]) + #expect(model.directory == "/after") + #expect(model.ticket(requiring: .replaceDraft) == nil) + shell.promptReady = true + shell.sequence = 5 + shell.capabilities = [.directory, .commandLifecycle, .readDraft, .replaceDraft] + receive([ + .init(context: shell.identity, sequence: 4, payload: .commandFinished(0, duration: 10)), + .init(context: shell.identity, sequence: 5, payload: .promptReady), + ]) + #expect(model.directory == "/after") + #expect(model.ticket(requiring: .replaceDraft) != nil) + #expect(!model.isCurrent(draft, requiring: .replaceDraft)) + + var replacement = context("b", instance: "new") + replacement.directory = nil + model.receive( + .init(status: status, active: replacement.identity, contexts: [shell, replacement]), + generation: model.generation) + #expect(model.directory == nil) + receive() + #expect(model.directory == "/after") + model.receive(.init(status: .multiplexer, contexts: [shell]), generation: model.generation) + #expect(model.directory == nil) + receive() + model.reset() + #expect(model.directory == nil) + } + @Test func activeContextIsNotInferredFromEventSource() { let model = TerminalContextCoordinator() let a = context() diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 25358a6f..de3a06d1 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -74883,6 +74883,53 @@ } } }, + "The startup command could not run in the session shell. Check the shell and backend configuration." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Startbefehl konnte in der Sitzungsshell nicht ausgeführt werden. Prüfe die Shell- und Backend-Konfiguration." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The startup command could not run in the session shell. Check the shell and backend configuration." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La commande de démarrage n’a pas pu être exécutée dans le shell de la session. Vérifiez la configuration du shell et du backend." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "セッションのシェルで起動コマンドを実行できませんでした。シェルとバックエンドの設定を確認してください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "세션 셸에서 시작 명령을 실행할 수 없습니다. 셸과 백엔드 설정을 확인하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法在会话Shell中运行启动命令。请检查Shell和后端配置。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法在工作階段Shell中執行啟動命令。請檢查Shell和後端設定。" + } + } + } + }, "The task failed. Open the conversation for details." : { "extractionState" : "manual", "localizations" : { diff --git a/Frameworks/TermindApplication/Terminal/RemoteSessionBackend.swift b/Frameworks/TermindApplication/Terminal/RemoteSessionBackend.swift index 05e70657..6e87d2ff 100644 --- a/Frameworks/TermindApplication/Terminal/RemoteSessionBackend.swift +++ b/Frameworks/TermindApplication/Terminal/RemoteSessionBackend.swift @@ -17,9 +17,13 @@ public nonisolated struct RemoteSessionLaunch: Sendable { public nonisolated struct SSHPTYStartupPlan: Sendable { public let command: String public let shellEventToken: String? - public init(command: String, shellEventToken: String? = nil) { + public let runtimeAttachment: RemoteSessionReference? + public init( + command: String, shellEventToken: String? = nil, runtimeAttachment: RemoteSessionReference? = nil + ) { self.command = command self.shellEventToken = shellEventToken + self.runtimeAttachment = runtimeAttachment } } @@ -222,7 +226,7 @@ public final class RemoteSessionCoordinator { service: service, hostID: hostID, endpoint: endpoint, username: username, startupCommand: startupCommand, requiresNewSession: requiresNewSession, shellIntegration: shellIntegration, onConnect: preferences.onConnect, environment: environment) - return launch.map { SSHPTYStartupPlan(command: $0.command) } + return launch.map { SSHPTYStartupPlan(command: $0.command, runtimeAttachment: $0.attachment) } } } diff --git a/Frameworks/TermindApplication/Terminal/TerminalContextCoordinator.swift b/Frameworks/TermindApplication/Terminal/TerminalContextCoordinator.swift index 79af98f8..3dc3ccd4 100644 --- a/Frameworks/TermindApplication/Terminal/TerminalContextCoordinator.swift +++ b/Frameworks/TermindApplication/Terminal/TerminalContextCoordinator.swift @@ -32,8 +32,9 @@ public final class TerminalContextCoordinator { public init() {} public var directory: String? { - guard let active, active.capabilities.contains(.directory) else { return nil } - return active.directory + // The shell's last reported cwd remains useful while a command owns the + // foreground. Capability tickets, not path display, gate live operations. + active?.directory } public func reset() { diff --git a/Frameworks/TermindDomain/Sessions/SessionPersistence.swift b/Frameworks/TermindDomain/Sessions/SessionPersistence.swift index e8a8a630..3ecaa486 100644 --- a/Frameworks/TermindDomain/Sessions/SessionPersistence.swift +++ b/Frameworks/TermindDomain/Sessions/SessionPersistence.swift @@ -4,6 +4,11 @@ public nonisolated struct SessionPersistenceBackendID: RawRepresentable, Codable public var rawValue: String public init(rawValue: String) { self.rawValue = rawValue } public static let tmux = Self(rawValue: "tmux") + public static let zellij = Self(rawValue: "zellij") + public static let zmx = Self(rawValue: "zmx") + public static let herdr = Self(rawValue: "herdr") + + public static let supported: [Self] = [.tmux, .zellij, .zmx, .herdr] } public nonisolated struct SessionPersistencePreferences: Codable, Hashable, Sendable { @@ -73,5 +78,6 @@ public nonisolated enum TerminalTeardownIntent: Sendable { public nonisolated enum RemoteSessionFailure: Error, Equatable, Sendable { case missing, unsupported, timedOut, invalidResponse, sessionMissing, ownershipMismatch + case startupUnavailable case commandFailed(String) } diff --git a/Frameworks/TermindDomain/Sessions/TerminalContext.swift b/Frameworks/TermindDomain/Sessions/TerminalContext.swift index 1680bb0c..4d8c465c 100644 --- a/Frameworks/TermindDomain/Sessions/TerminalContext.swift +++ b/Frameworks/TermindDomain/Sessions/TerminalContext.swift @@ -29,6 +29,7 @@ public nonisolated struct TerminalContextCapabilities: OptionSet, Sendable, Equa public nonisolated struct TerminalShellContext: Sendable, Equatable { public let identity: TerminalContextIdentity public var shell: String + /// Last cwd reported by this shell instance, independent of foreground/input readiness. public var directory: String? public var home: String? public var historyPath: String? diff --git a/Frameworks/TermindPresentation/HostIntegrationSection.swift b/Frameworks/TermindPresentation/HostIntegrationSection.swift index e6d284f1..0fe76b87 100644 --- a/Frameworks/TermindPresentation/HostIntegrationSection.swift +++ b/Frameworks/TermindPresentation/HostIntegrationSection.swift @@ -10,8 +10,11 @@ public struct HostSessionPersistenceSection: View { Section(AppStrings.SessionPersistence.section.swiftUIKey) { Toggle(isOn: $preferences.enabled) { Text(AppStrings.Common.enabled) } Picker(AppStrings.SessionPersistence.backend.swiftUIKey, selection: $preferences.backend) { - Text(verbatim: "tmux").tag(SessionPersistenceBackendID.tmux) - if preferences.backend != .tmux { + ForEach(SessionPersistenceBackendID.supported, id: \.self) { backend in + Text(verbatim: backend == .herdr ? "Herdr" : backend == .zellij ? "Zellij" : backend.rawValue) + .tag(backend) + } + if !SessionPersistenceBackendID.supported.contains(preferences.backend) { Text(verbatim: preferences.backend.rawValue).tag(preferences.backend) } } diff --git a/Frameworks/TermindPresentation/RemoteSessionPromptView.swift b/Frameworks/TermindPresentation/RemoteSessionPromptView.swift index fd4da5f9..a66183bc 100644 --- a/Frameworks/TermindPresentation/RemoteSessionPromptView.swift +++ b/Frameworks/TermindPresentation/RemoteSessionPromptView.swift @@ -109,6 +109,7 @@ public struct RemoteSessionPromptView: View { switch failure { case .missing: AppStrings.SessionPersistence.missing case .unsupported: AppStrings.SessionPersistence.unsupported + case .startupUnavailable: AppStrings.SessionPersistence.startupUnavailable case .timedOut: AppStrings.SessionPersistence.timedOut case .sessionMissing, .ownershipMismatch: AppStrings.SessionPersistence.changed case .invalidResponse, .commandFailed: AppStrings.SessionPersistence.failed diff --git a/Frameworks/TermindPresentation/Strings/SessionPersistence.swift b/Frameworks/TermindPresentation/Strings/SessionPersistence.swift index b2735568..acde2485 100644 --- a/Frameworks/TermindPresentation/Strings/SessionPersistence.swift +++ b/Frameworks/TermindPresentation/Strings/SessionPersistence.swift @@ -16,6 +16,9 @@ extension AppStrings { } public static var timedOut: LocalizedStringResource { "Preparing the remote session timed out." } public static var failed: LocalizedStringResource { "Unable to prepare the remote session." } + public static var startupUnavailable: LocalizedStringResource { + "The startup command could not run in the session shell. Check the shell and backend configuration." + } public static var closeFailed: LocalizedStringResource { "Unable to end the remote session. Retry, or disconnect without ending it." } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteHerdrCommands.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteHerdrCommands.swift new file mode 100644 index 00000000..74776fb0 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteHerdrCommands.swift @@ -0,0 +1,63 @@ +import Foundation +import TermindApplication +import TermindDomain + +nonisolated enum RemoteHerdrCommands { + private struct Listing: Decodable { + let sessions: [Session] + struct Session: Decodable { + let name: String + let running: Bool + let socketPath: String + let sessionDir: String + let connectionError: String? + + enum CodingKeys: String, CodingKey { + case name, running + case socketPath = "socket_path" + case sessionDir = "session_dir" + case connectionError = "connection_error" + } + } + } + + static var list: String { + RemoteMultiplexerKind.herdr.discovery + "\n\"$exe\" session list --json" + } + + static func parseList(_ text: String) throws -> [RemoteMultiplexerCandidate] { + let value: Listing + do { value = try JSONDecoder().decode(Listing.self, from: Data(text.utf8)) } catch { + throw RemoteSessionFailure.invalidResponse + } + return try value.sessions.compactMap { session in + guard session.connectionError == nil else { throw RemoteSessionFailure.invalidResponse } + guard RemoteMultiplexerCommands.validName(session.name), + RemoteMultiplexerCommands.validPath(session.sessionDir), + session.socketPath == session.sessionDir + "/herdr.sock" + else { throw RemoteSessionFailure.invalidResponse } + guard session.running else { return nil } + return .init(name: session.name, root: session.sessionDir, socket: session.socketPath) + } + } + + static func create(name: String) -> String { + """ + root="${XDG_CONFIG_HOME:-$HOME/.config}/herdr/sessions" + mkdir -p "$root" || exit 78 + [ ! -L "$root" ] || exit 79 + mkdir "$root/\(name)" || exit 79 + chmod 700 "$root/\(name)" || exit 78 + socket="$root/\(name)/herdr.sock" + # Only the new named server inherits the wrapper; the user's config remains untouched. + native_started=1 + SHELL="$helper/shell" HERDR_STARTUP_CWD="$PWD" nohup "$exe" --session '\(name)' server \ + "$meta/create-error" 2>&1 & + attempt=0 + while [ ! -S "$socket" ]; do + attempt=$((attempt + 1)); [ "$attempt" -lt 50 ] || exit 78 + sleep 0.1 + done + """ + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteMultiplexerRuntimeAdapter.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteMultiplexerRuntimeAdapter.swift new file mode 100644 index 00000000..c84a5bb6 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteMultiplexerRuntimeAdapter.swift @@ -0,0 +1,327 @@ +import Foundation +import TermindApplication +import TermindDomain + +nonisolated struct RemoteMultiplexerDraftTarget { + let root: String + let trigger: String +} + +public nonisolated struct RemoteMultiplexerRuntimeAdapter: TerminalRuntimeAdapter { + private let attachment: RemoteSessionReference? + private let execute: RemoteSessionCommandExecutor + private let charset: RemoteCharset + + public init( + attachment: RemoteSessionReference? = nil, execute: @escaping RemoteSessionCommandExecutor, + charset: RemoteCharset = .utf8 + ) { + self.attachment = attachment + self.execute = execute + self.charset = charset + } + + public func inspect(terminal: String) async throws -> TerminalRuntimeUpdate { + if let attachment, attachment.backend != .tmux { + return try await RemoteSocketMultiplexerRuntime( + reference: attachment, execute: execute, charset: charset + ).inspect(terminal: terminal) + } + return try await RemoteTmuxRuntimeDialect(execute: execute, charset: charset).inspect(terminal: terminal) + } + + static func helper(for attachment: RemoteSessionReference?) throws -> String? { + guard let attachment, attachment.backend != .tmux else { return nil } + guard let kind = RemoteMultiplexerKind(rawValue: attachment.backend.rawValue) else { + throw TerminalRuntimeError.unsupported + } + return try RemoteMultiplexerIdentity.decode(attachment.session.identity, kind: kind).helper + } + + func draftTarget( + context: TerminalShellContext, terminal: String, replacing: Bool + ) async throws -> RemoteMultiplexerDraftTarget { + if let attachment, attachment.backend != .tmux { + return try await RemoteSocketMultiplexerRuntime( + reference: attachment, execute: execute, charset: charset + ).draftTarget(context: context, terminal: terminal, replacing: replacing) + } + let update = try await inspect(terminal: terminal) + guard update.active == context.identity, + update.contexts.first(where: { $0.identity == context.identity }) == context + else { throw TerminalRuntimeError.staleContext } + return try RemoteTmuxRuntimeDialect.draftTarget( + context: context, terminal: terminal, clientIdentity: update.clientIdentity, replacing: replacing) + } + + func refreshSize(of client: RemoteTmuxClientIdentity) async throws { + try await RemoteTmuxRuntimeDialect(execute: execute, charset: charset).refreshSize(of: client) + } +} + +nonisolated struct RemoteSocketMultiplexerRuntime: TerminalRuntimeAdapter { + let reference: RemoteSessionReference + let execute: RemoteSessionCommandExecutor + var charset: RemoteCharset = .utf8 + + func inspect(terminal: String) async throws -> TerminalRuntimeUpdate { + try await snapshot(terminal: terminal).update + } + + private struct Snapshot { + let update: TerminalRuntimeUpdate + let native: String + let focus: String + } + + private var identity: RemoteMultiplexerIdentity { + get throws { + guard let kind = RemoteMultiplexerKind(rawValue: reference.backend.rawValue) else { + throw TerminalRuntimeError.unsupported + } + return try RemoteMultiplexerIdentity.decode(reference.session.identity, kind: kind) + } + } + + private func nativeCommands() throws -> (String, String) { + let name = try RemoteMultiplexerCommands.quote(reference.session.name) + switch try identity.kind { + case .tmux: throw TerminalRuntimeError.unsupported + case .zellij: + return ("\"$exe\" list-sessions --short --no-formatting", "\"$exe\" --session \(name) action list-clients") + case .zmx: return ("\"$exe\" list", "printf 'shell'") + case .herdr: return ("\"$exe\" session list --json", "\"$exe\" --session \(name) pane list") + } + } + + private func clientGuard(terminal: String) throws -> String { + guard RemoteTmuxClientIdentity.validTerminal(terminal), let helper = try identity.helper else { + throw TerminalRuntimeError.unsupported + } + let kind = try identity.kind.rawValue + return """ + helper=\(try RemoteMultiplexerCommands.quote(helper)) + \(RemoteMultiplexerCommands.stateRoot(try identity.kind, create: false)) + meta="$state/\(reference.session.name)" + private_state || exit 79 + [ "$(cat "$meta/helper")" = "$helper" ] || exit 79 + [ "$(cat "$meta/identity")" = \(try RemoteMultiplexerCommands.quote(identity.fingerprint)) ] || exit 79 + key=$(printf '%s' \(try RemoteMultiplexerCommands.quote(terminal)) | od -An -v -tx1 | tr -d ' \\n') + [ ! -L "$helper" ] && [ ! -L "$helper/client-$key" ] || exit 79 + IFS='|' read -r client started < "$helper/client-$key" || exit 79 + case "$client" in ''|*[!0-9]*) exit 79 ;; esac + [ -n "$started" ] && [ "$(process_start "$client")" = "$started" ] || exit 79 + client_tty=$(ps -p "$client" -o tty= | tr -d ' ') + [ "/dev/$client_tty" = \(try RemoteMultiplexerCommands.quote(terminal)) ] || exit 79 + # Without a native TTY-to-client API, any other interactive client makes focus ambiguous. + clients=$(ps -u "$(id -u)" -o pid= -o tty= -o comm= | \ + awk '$2 != "?" && $2 != "??" {n=split($3,a,"/"); if(a[n]=="\(kind)") print $1}') + [ "$clients" = "$client" ] || exit 79 + """ + } + + private var shellCheck: String { + get throws { + """ + shell_check() { + case "$1" in ''|*[!0-9]*) return 1 ;; esac + case "$2" in bash|zsh|fish) ;; *) return 1 ;; esac + [ -d "$3" ] && [ ! -L "$3" ] && [ ! -L "$3/process-start" ] || return 1 + started=$(cat "$3/process-start") || return 1 + [ -n "$started" ] && [ "$(process_start "$1")" = "$started" ] || return 1 + parent=$(ps -p "$1" -o ppid= | tr -d ' ') || return 1 + \(try identity.process.map { "[ \"$1\" = '\($0)' ] && [ \"$parent\" = '\(try identity.daemonProcess!)' ] || return 1" } ?? "") + parent_name=$(ps -p "$parent" -o comm=) + [ "${parent_name##*/}" = '\(try identity.kind.rawValue)' ] || return 1 + name=$(ps -p "$1" -o comm=) + [ "${name##*/}" = "$2" ] || [ "${name##*/}" = "-$2" ] || return 1 + } + foreground_shell() { + shell_check "$1" "$2" "$3" || return 1 + groups=$(ps -p "$1" -o pgid= -o tpgid=) || return 1 + set -- $groups + [ "$#" = 2 ] && [ "$1" = "$2" ] + } + """ + } + } + + private func snapshot(terminal: String) async throws -> Snapshot { + let (nativeCommand, focusCommand) = try nativeCommands() + let command = """ + \(try RemoteMultiplexerCommands.guardIdentity(reference)) + \(try clientGuard(terminal: terminal)) + \(try shellCheck) + 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' + """ + let result = try await execute(command) + guard result.exitCode == 0, !result.truncated else { throw TerminalRuntimeError.unsupported } + return try Self.parseSnapshot(result.stdout, reference: reference, charset: charset) + } + + static func parse( + _ text: String, reference: RemoteSessionReference, charset: RemoteCharset = .utf8 + ) throws -> TerminalRuntimeUpdate { + try parseSnapshot(text, reference: reference, charset: charset).update + } + + private static func parseSnapshot( + _ text: String, reference: RemoteSessionReference, charset: RemoteCharset + ) throws -> Snapshot { + guard text.utf8.count <= 1024 * 1024, + let kind = RemoteMultiplexerKind(rawValue: reference.backend.rawValue) + else { throw TerminalRuntimeError.invalidResponse } + let identity = try RemoteMultiplexerIdentity.decode(reference.session.identity, kind: kind) + let lines = text.components(separatedBy: "\n") + guard lines.count >= 5, lines[0].hasPrefix("C|"), UInt64(lines[0].dropFirst(2)) != nil, + lines[1].hasPrefix("N|"), lines[2].hasPrefix("A|"), lines.suffix(2) == ["DONE", ""] + else { throw TerminalRuntimeError.invalidResponse } + let native = try RemoteMultiplexerCommands.unhex(String(lines[1].dropFirst(2)), limit: 512 * 1024) + let focus = try RemoteMultiplexerCommands.unhex(String(lines[2].dropFirst(2)), limit: 512 * 1024) + let activePane = try activePane(kind: kind, native: native, focus: focus, reference: reference) + var contexts: [TerminalShellContext] = [] + var events: [TerminalContextEvent] = [] + var active: TerminalContextIdentity? + var index = 3 + while index < lines.count - 2 { + guard lines[index] == "BEGIN|0" || lines[index] == "BEGIN|1" else { + throw TerminalRuntimeError.invalidResponse + } + let foreground = lines[index] == "BEGIN|1" + index += 1 + let start = index + while index < lines.count - 2, lines[index] != "END" { index += 1 } + guard index < lines.count - 2 else { throw TerminalRuntimeError.invalidResponse } + let record = try RemoteShellContextRecord(lines[start.. String? { + switch kind { + case .tmux: throw TerminalRuntimeError.unsupported + case .zellij: + // A switched client cannot be bound using its original argv. Require a unique live session too. + guard native == reference.session.name else { return nil } + let clients = focus.split(separator: "\n") + guard clients.first == "CLIENT_ID ZELLIJ_PANE_ID RUNNING_COMMAND", clients.count == 2 else { return nil } + let fields = clients[1].split(whereSeparator: \.isWhitespace) + guard fields.count >= 3, UInt64(fields[0]) != nil, fields[1].hasPrefix("terminal_") else { return nil } + let pane = String(fields[1].dropFirst("terminal_".count)) + return RemoteShellContextRecord.validPane(pane, backend: "zellij") ? pane : nil + case .zmx: + let identity = try RemoteMultiplexerIdentity.decode(reference.session.identity, kind: kind) + let root = identity.root.utf8.map { String(format: "%02x", $0) }.joined() + let sessions = try RemoteZmxCommands.parseList("ROOT|\(root)\n" + native + "\nEND\n") + guard sessions.count == 1, let session = sessions.first, + session.name == reference.session.name, session.process == identity.process, + session.created == identity.created, session.clients == 1 + else { return nil } + return "shell" + case .herdr: + let sessions = try RemoteHerdrCommands.parseList(native) + guard sessions.count == 1, sessions.first?.name == reference.session.name else { return nil } + struct Response: Decodable { + struct Result: Decodable { + struct Pane: Decodable { + let paneID: String + enum CodingKeys: String, CodingKey { case paneID = "pane_id" } + } + let panes: [Pane] + } + let result: Result + } + let response = try JSONDecoder().decode(Response.self, from: Data(focus.utf8)) + // Herdr's pane-current is global, not a per-client focus API. + guard response.result.panes.count == 1, let pane = response.result.panes.first?.paneID, + RemoteShellContextRecord.validPane(pane, backend: "herdr") + else { return nil } + // The supported CLI only exposes global pane state, not the attached client's + // session/focus binding (including endpoint switching). Do not infer it from ps. + // Journals remain useful for lifecycle events; active-context operations stay disabled. + _ = pane + return nil + } + } + + func draftTarget( + context: TerminalShellContext, terminal: String, replacing: Bool + ) async throws -> RemoteMultiplexerDraftTarget { + let snapshot = try await snapshot(terminal: terminal) + guard snapshot.update.active == context.identity, + snapshot.update.contexts.first(where: { $0.identity == context.identity }) == context, + context.capabilities.contains(replacing ? .replaceDraft : .readDraft), + let helper = try identity.helper + else { throw TerminalRuntimeError.staleContext } + let (nativeCommand, focusCommand) = try nativeCommands() + let guardCommand = """ + \(try RemoteMultiplexerCommands.guardIdentity(reference)) + \(try clientGuard(terminal: terminal)) + [ "$(\(nativeCommand))" = \(try RemoteMultiplexerCommands.quote(snapshot.native)) ] || exit 79 + [ "$(\(focusCommand))" = \(try RemoteMultiplexerCommands.quote(snapshot.focus)) ] || exit 79 + """ + let key = try RemoteMultiplexerCommands.quote(replacing ? "\u{1B}[777;2~" : "\u{1B}[777;1~") + let name = try RemoteMultiplexerCommands.quote(reference.session.name) + let pane = try RemoteMultiplexerCommands.quote(context.identity.pane) + let write: String + switch try identity.kind { + case .tmux: throw TerminalRuntimeError.unsupported + case .zellij: write = "\"$exe\" --session \(name) action write-chars --pane-id \(pane) \(key)" + case .zmx: write = "\"$exe\" send \(name) \(key)" + case .herdr: write = "\"$exe\" --session \(name) pane send-text \(pane) \(key)" + } + return .init( + root: guardCommand + "\nhelper=\(try RemoteMultiplexerCommands.quote(helper))", + trigger: """ + ( + \(guardCommand) + \(try shellCheck) + IFS='|' read -r tag token pane instance shell home pid history backend < "$dir/meta" || exit 79 + [ "$backend" = '\(context.identity.backend)' ] && [ "$pane" = \(pane) ] \ + && [ "$instance" = '\(context.identity.shellInstance)' ] || exit 79 + foreground_shell "$pid" "$shell" "$dir" || exit 79 + IFS='|' read -r tag seq ready cwd < "$dir/state" || exit 79 + [ "$seq" = '\(context.sequence)' ] && [ "$ready" = 1 ] || exit 79 + \(write) >/dev/null || exit 79 + ) || { rm -f "$dir/request"; exit 79; } + """) + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteMultiplexerService.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteMultiplexerService.swift new file mode 100644 index 00000000..5501f3aa --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteMultiplexerService.swift @@ -0,0 +1,45 @@ +import Foundation +import TermindApplication +import TermindDomain + +/// A single service entry point; command dialects retain their native identity contracts. +public nonisolated struct RemoteMultiplexerService: RemoteSessionBackend { + public typealias Executor = RemoteSessionCommandExecutor + public let id: SessionPersistenceBackendID + private let execute: Executor + + public init(backend: SessionPersistenceBackendID = .tmux, execute: @escaping Executor) { + id = backend + self.execute = execute + } + + private var backend: RemoteMultiplexerBackend { + get throws { + guard let kind = RemoteMultiplexerKind(rawValue: id.rawValue) else { + throw RemoteSessionFailure.unsupported + } + return .init(kind: kind, execute: execute) + } + } + + public var capabilities: RemoteSessionCapabilities { + (try? backend.capabilities) ?? .init() + } + + public func probe() async throws -> RemoteSessionCapabilities { try await backend.probe() } + public func sessions() async throws -> [RemoteSessionDescriptor] { try await backend.sessions() } + public func validate(_ reference: RemoteSessionReference) throws { try backend.validate(reference) } + public func matches(_ reference: RemoteSessionReference, session: RemoteSessionDescriptor) -> Bool { + (try? backend.matches(reference, session: session)) ?? false + } + public func create( + token: UUID, startupCommand: String?, shellIntegration: Bool, environment: [HostEnvironmentVariable] = [] + ) async throws -> RemoteSessionDescriptor { + try await backend.create( + token: token, startupCommand: startupCommand, shellIntegration: shellIntegration, environment: environment) + } + public func launch(_ attachment: RemoteSessionReference, created: Bool) throws -> RemoteSessionLaunch { + try backend.launch(attachment, created: created) + } + public func terminate(_ attachment: RemoteSessionReference) async throws { try await backend.terminate(attachment) } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteMultiplexerSupport.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteMultiplexerSupport.swift new file mode 100644 index 00000000..ca92461c --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteMultiplexerSupport.swift @@ -0,0 +1,660 @@ +import CryptoKit +import Foundation +import TermindApplication +import TermindDomain + +nonisolated enum RemoteMultiplexerKind: String, Codable, Sendable { + case tmux, zellij, zmx, herdr + + var id: SessionPersistenceBackendID { .init(rawValue: rawValue) } + + func validateVersion(_ output: String) throws { + if self == .tmux { return try RemoteTmuxCommands.validateVersion(output) } + let fields = output.split(whereSeparator: \.isWhitespace) + guard fields.count >= 2, fields[0].lowercased() == rawValue else { + throw RemoteSessionFailure.invalidResponse + } + let components = fields[1].split(separator: ".") + guard components.count == 3, let major = Int(components[0]), let minor = Int(components[1]), + let patch = Int(components[2]), major >= 0, minor >= 0, patch >= 0 + else { throw RemoteSessionFailure.unsupported } + let supported: Bool + // Keep vvterm's supported baselines, not guessed theoretical minimums. + // zmx 0.7 adds ownership labels; Zellij 0.44.3 and Herdr 0.8.2 include lifecycle fixes. + switch self { + case .tmux: supported = false + case .zellij: supported = major > 0 || minor > 44 || (minor == 44 && patch >= 3) + case .zmx: supported = major > 0 || minor >= 7 + case .herdr: + supported = major > 0 || minor > 8 || (minor == 8 && patch >= 2) || (minor == 7 && patch >= 5) + } + guard supported else { throw RemoteSessionFailure.unsupported } + } + + var discovery: String { + """ + export LC_ALL=C + exe=$(command -v \(rawValue) 2>/dev/null) + case "$exe" in /*) ;; *) exe= ;; esac + if [ -z "$exe" ]; then + for dir in /opt/homebrew/bin /usr/local/bin /usr/bin /snap/bin "$HOME/.local/bin" "$HOME/.cargo/bin"; do + if [ -f "$dir/\(rawValue)" ] && [ -x "$dir/\(rawValue)" ]; then exe="$dir/\(rawValue)"; break; fi + done + fi + [ -n "$exe" ] && [ -x "$exe" ] || exit 77 + unset ZELLIJ ZELLIJ_PANE_ID ZELLIJ_SESSION_NAME ZMX_SESSION ZMX_SESSION_PREFIX + unset HERDR_SESSION HERDR_SOCKET_PATH HERDR_CLIENT_SOCKET_PATH HERDR_PANE_ID HERDR_PANE_RUNTIME_ID + unset HERDR_ACTIVE_PANE_ID HERDR_ACTIVE_TAB_ID HERDR_ACTIVE_WORKSPACE_ID HERDR_ACTIVE_PANE_CWD + unset HERDR_WORKSPACE_ID HERDR_TAB_ID HERDR_STARTUP_CWD HERDR_BIN_PATH + """ + } +} + +nonisolated struct RemoteMultiplexerIdentity: Codable, Equatable, Sendable { + var version = 1 + let kind: RemoteMultiplexerKind + let root: String + let socket: String + let fingerprint: String + let process: String? + let created: String? + let owner: UUID? + let helper: String? + let isolated: Bool + var directoryFingerprint: String? = nil + var daemonProcess: String? = nil + var daemonStarted: String? = nil + + static func decode(_ data: Data, kind: RemoteMultiplexerKind) throws -> Self { + let value: Self + do { value = try JSONDecoder().decode(Self.self, from: data) } catch { + throw RemoteSessionFailure.invalidResponse + } + guard value.version == 1, value.kind == kind, kind != .tmux else { + throw RemoteSessionFailure.unsupported + } + guard RemoteMultiplexerCommands.validPath(value.root), RemoteMultiplexerCommands.validPath(value.socket), + value.socket.hasPrefix(value.root + "/"), + value.fingerprint.split(separator: ":").count == 3, + value.fingerprint.utf8.allSatisfy({ (48...57).contains($0) || $0 == 58 }), + value.helper.map(RemoteMultiplexerCommands.validHelper) ?? true, + value.process.map({ UInt64($0).map { $0 > 1 } ?? false }) ?? true, + value.created.map({ UInt64($0) != nil }) ?? true, + kind != .zmx || (value.process != nil && value.created != nil), + kind != .zmx + || (value.daemonProcess.flatMap(UInt64.init).map { $0 > 1 } == true + && value.daemonStarted?.isEmpty == false + && value.daemonStarted!.utf8.allSatisfy { (32...126).contains($0) }), + value.directoryFingerprint.map({ + $0.split(separator: ":").count == 2 + && $0.utf8.allSatisfy { (48...57).contains($0) || $0 == 58 } + }) ?? true, + kind == .zellij || !value.isolated + else { throw RemoteSessionFailure.invalidResponse } + return value + } +} + +nonisolated struct RemoteMultiplexerCandidate { + let name: String + let root: String + let socket: String? + var process: String? = nil + var created: String? = nil + var clients: Int? = nil + var isolated = false +} + +nonisolated enum RemoteMultiplexerCommands { + static func quote(_ value: String) throws -> String { + guard !value.contains("\0") else { throw RemoteSessionFailure.invalidResponse } + return "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + static func validPath(_ value: String) -> Bool { + value.hasPrefix("/") && value.utf8.count <= 4096 + && !value.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) + && !value.split(separator: "/").contains("..") + } + + static func validHelper(_ value: String) -> Bool { + value.hasPrefix("/tmp/termind-shell.") && value.count > "/tmp/termind-shell.".count + && value.dropFirst("/tmp/termind-shell.".count).utf8.allSatisfy { + (48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0) + } + } + + static func validName(_ value: String) -> Bool { + !value.isEmpty && value.utf8.count <= 128 && value.first != "-" + && value.utf8.allSatisfy { + (48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0) + || [45, 46, 95].contains($0) + } && value != "." && value != ".." + } + + static func managedName(_ token: UUID) -> String { + "termind-" + + SHA256.hash(data: Data(token.uuidString.utf8)).prefix(8).map { + String(format: "%02x", $0) + }.joined() + } + + static func unhex(_ value: String, limit: Int = 8192) throws -> String { + guard value.count.isMultiple(of: 2), value.count <= limit else { + throw RemoteSessionFailure.invalidResponse + } + var bytes: [UInt8] = [] + var cursor = value.startIndex + while cursor != value.endIndex { + let end = value.index(cursor, offsetBy: 2) + guard let byte = UInt8(value[cursor../dev/null || stat -f '%d:%i:%c' "$1" + } + private_dir() { + [ -d "$1" ] || (umask 077; mkdir "$1") || return 1 + [ -d "$1" ] && [ ! -L "$1" ] || return 1 + owner=$(stat -c '%u' "$1" 2>/dev/null || stat -f '%u' "$1") || return 1 + [ "$owner" = "$(id -u)" ] && chmod 700 "$1" + } + directory_fingerprint() { + [ -d "$1" ] && [ ! -L "$1" ] || return 1 + stat -c '%d:%i' "$1" 2>/dev/null || stat -f '%d:%i' "$1" + } + private_state() { + for path in "/tmp/termind-sessions-$(id -u)" "$state" "$meta"; do + [ -d "$path" ] && [ ! -L "$path" ] || return 1 + [ "$(stat -c '%u' "$path" 2>/dev/null || stat -f '%u' "$path")" = "$(id -u)" ] || return 1 + done + for field in owner helper identity; do + [ -f "$meta/$field" ] && [ ! -L "$meta/$field" ] || return 1 + done + } + """# + + static func stateRoot(_ kind: RemoteMultiplexerKind, create: Bool) -> String { + """ + state="/tmp/termind-sessions-$(id -u)" + \(create ? "private_dir \"$state\" || exit 78" : "") + state="$state/\(kind.rawValue)" + \(create ? "private_dir \"$state\" || exit 78" : "") + """ + } + + static func environment(_ values: [HostEnvironmentVariable]) throws -> String { + try values.map { value in + guard value.isValid, !value.variable.hasPrefix("TERMIND_"), + !["TMUX", "ZELLIJ", "ZMX", "HERDR"].contains(where: { + value.variable == $0 || value.variable.hasPrefix($0 + "_") + }) + else { throw RemoteSessionFailure.invalidResponse } + return "export \(value.variable)=\(try quote(value.value))" + }.joined(separator: "\n") + } + + static func routing(_ identity: RemoteMultiplexerIdentity) throws -> String { + switch identity.kind { + case .tmux: throw RemoteSessionFailure.unsupported + case .zellij: + return """ + export ZELLIJ_SOCKET_DIR=\(try quote(identity.root)) + \(identity.isolated ? "export XDG_CACHE_HOME=\(try quote(identity.root + "/cache"))" : "") + """ + case .zmx: return "export ZMX_DIR=\(try quote(identity.root))" + case .herdr: + let root = identity.root + let config: String + if root.hasSuffix("/herdr") { + config = String(root.dropLast("/herdr".count)) + } else if let range = root.range(of: "/herdr/sessions/", options: .backwards), + validName(String(root[range.upperBound...])) + { + config = String(root[.. String { + guard let kind = RemoteMultiplexerKind(rawValue: reference.backend.rawValue), + reference.version == 1, validName(reference.session.name) + else { throw RemoteSessionFailure.unsupported } + let identity = try RemoteMultiplexerIdentity.decode(reference.session.identity, kind: kind) + try validateRoute(identity, name: reference.session.name) + if let owner = reference.ownershipToken, owner != identity.owner { + throw RemoteSessionFailure.ownershipMismatch + } + let owned = + try reference.ownershipToken.map { token in + let labelGuard = + kind == .zmx + ? """ + [ "$("$exe" get \(try quote(reference.session.name)) termind_owner)" = '\(token.uuidString)' ] || exit 79 + [ "$(cat "$meta/native" 2>/dev/null)" = '\(identity.process!):\(identity.created!)' ] || exit 79 + """ : "" + return """ + \(stateRoot(kind, create: false)) + meta="$state/\(reference.session.name)" + private_state || exit 79 + [ "$(cat "$meta/owner" 2>/dev/null)" = '\(token.uuidString)' ] || exit 79 + [ "$(cat "$meta/identity" 2>/dev/null)" = \(try quote(identity.fingerprint)) ] || exit 79 + \(labelGuard) + """ + } ?? "" + return """ + \(utilities) + \(kind.discovery) + \(try routing(identity)) + [ "$(fingerprint \(try quote(identity.socket)))" = \(try quote(identity.fingerprint)) ] || exit 79 + \(kind == .zmx ? """ + [ "$(\(RemoteZmxCommands.nativeIdentity(name: reference.session.name)))" = '\(identity.process!):\(identity.created!)' ] || exit 79 + [ "$(ps -p '\(identity.process!)' -o ppid= | tr -d ' ')" = '\(identity.daemonProcess!)' ] || exit 79 + [ "$(process_start '\(identity.daemonProcess!)')" = \(try quote(identity.daemonStarted!)) ] || exit 79 + """ : "") + \(owned) + """ + } + + static func validateRoute(_ identity: RemoteMultiplexerIdentity, name: String) throws { + let valid: Bool + switch identity.kind { + case .tmux: valid = false + case .zmx: valid = identity.socket == identity.root + "/" + name + case .zellij: valid = identity.socket.hasSuffix("/" + name) + case .herdr: + valid = + identity.socket == identity.root + "/herdr.sock" + && (name == "default" + ? identity.root.hasSuffix("/herdr") : identity.root.hasSuffix("/herdr/sessions/" + name)) + } + guard valid else { throw RemoteSessionFailure.invalidResponse } + _ = try routing(identity) + } +} + +/// Shared ownership and framing policy; native command dialects remain backend-owned. +nonisolated struct RemoteMultiplexerBackend: Sendable { + let kind: RemoteMultiplexerKind + let execute: RemoteSessionCommandExecutor + + var capabilities: RemoteSessionCapabilities { + .init(windowCount: kind == .tmux, clientCount: kind == .tmux || kind == .zmx, shellIntegration: true) + } + + func probe() async throws -> RemoteSessionCapabilities { + let versionFlag = kind == .tmux ? "-V" : kind == .zmx ? "version" : "--version" + let result = try await execute(kind.discovery + "\n\"$exe\" \(versionFlag)") + try Self.check(result) + try kind.validateVersion(result.stdout) + return capabilities + } + + static func check(_ result: RemoteSessionCommandResult) throws { + guard !result.truncated, result.stdout.utf8.count <= 1024 * 1024 else { + throw RemoteSessionFailure.invalidResponse + } + switch result.exitCode { + case 0: return + case 77: throw RemoteSessionFailure.missing + case 79: throw RemoteSessionFailure.ownershipMismatch + case 80: throw RemoteSessionFailure.unsupported + case 81: throw RemoteSessionFailure.startupUnavailable + default: throw RemoteSessionFailure.commandFailed(result.stderr) + } + } + + func validate(_ reference: RemoteSessionReference) throws { + guard reference.version == 1, reference.backend == kind.id else { throw RemoteSessionFailure.unsupported } + if kind == .tmux { + _ = try RemoteTmuxCommands.attach(reference) + return + } + guard reference.version == 1, reference.backend == kind.id, + RemoteMultiplexerCommands.validName(reference.session.name) + else { throw RemoteSessionFailure.unsupported } + let identity = try RemoteMultiplexerIdentity.decode(reference.session.identity, kind: kind) + try RemoteMultiplexerCommands.validateRoute(identity, name: reference.session.name) + guard reference.session.id == (identity.isolated ? "managed:" : "user:") + reference.session.name, + reference.ownershipToken == nil || reference.ownershipToken == identity.owner, + reference.ownershipToken.map({ RemoteMultiplexerCommands.managedName($0) == reference.session.name }) + ?? true + else { throw RemoteSessionFailure.ownershipMismatch } + } + + func matches(_ reference: RemoteSessionReference, session: RemoteSessionDescriptor) -> Bool { + if kind == .tmux { + guard (try? validate(reference)) != nil, reference.session.id == session.id, + let saved = try? TmuxSessionIdentity.decode(reference.session.identity), + let current = try? TmuxSessionIdentity.decode(session.identity) + else { return false } + return saved.created == current.created && saved.serverPID == current.serverPID + && (reference.ownershipToken.map { current.owner == $0.uuidString } ?? true) + } + guard (try? validate(reference)) != nil, reference.session.id == session.id, + let saved = try? RemoteMultiplexerIdentity.decode(reference.session.identity, kind: kind), + let current = try? RemoteMultiplexerIdentity.decode(session.identity, kind: kind) + else { return false } + return saved.root == current.root && saved.socket == current.socket + && saved.fingerprint == current.fingerprint && saved.process == current.process + && saved.created == current.created + && saved.directoryFingerprint == current.directoryFingerprint + && saved.daemonProcess == current.daemonProcess && saved.daemonStarted == current.daemonStarted + && (reference.ownershipToken == nil || reference.ownershipToken == current.owner) + } + + func sessions() async throws -> [RemoteSessionDescriptor] { + let command: String + switch kind { + case .tmux: command = RemoteTmuxCommands.list + case .zellij: command = RemoteZellijCommands.list + case .zmx: command = RemoteZmxCommands.list + case .herdr: command = RemoteHerdrCommands.list + } + let response = try await execute(command) + try Self.check(response) + let candidates: [RemoteMultiplexerCandidate] + switch kind { + case .tmux: return try RemoteTmuxCommands.parseSessions(response.stdout) + case .zellij: candidates = try RemoteZellijCommands.parseList(response.stdout) + case .zmx: candidates = try RemoteZmxCommands.parseList(response.stdout) + case .herdr: candidates = try RemoteHerdrCommands.parseList(response.stdout) + } + guard candidates.count <= 256, Set(candidates.map { "\($0.isolated):\($0.name)" }).count == candidates.count + else { throw RemoteSessionFailure.invalidResponse } + guard !candidates.isEmpty else { return [] } + let commands = try candidates.enumerated().map { index, candidate -> String in + guard RemoteMultiplexerCommands.validName(candidate.name), + RemoteMultiplexerCommands.validPath(candidate.root) + else { throw RemoteSessionFailure.invalidResponse } + let socket: String + if let path = candidate.socket { + guard RemoteMultiplexerCommands.validPath(path), path.hasPrefix(candidate.root + "/") else { + throw RemoteSessionFailure.invalidResponse + } + socket = "socket=\(try RemoteMultiplexerCommands.quote(path))" + } else { + socket = """ + socket=$(find \(try RemoteMultiplexerCommands.quote(candidate.root)) -type s -name \ + \(try RemoteMultiplexerCommands.quote(candidate.name)) -print 2>/dev/null) || exit 78 + """ + } + return """ + \(socket) + fp=$(fingerprint "$socket") || exit 79 + owner=-; helper=-; directory=-; daemon=-; born=- + meta="$state/\(candidate.name)" + if private_state && [ "$(cat "$meta/identity" 2>/dev/null)" = "$fp" ]; then + owner=$(cat "$meta/owner") || exit 78 + helper=$(cat "$meta/helper") || exit 78 + \(kind == .zmx ? """ + export ZMX_DIR=\(try RemoteMultiplexerCommands.quote(candidate.root)) + if [ "$("$exe" get '\(candidate.name)' termind_owner)" != "$owner" ] \ + || [ "$(cat "$meta/native" 2>/dev/null)" != '\(candidate.process!):\(candidate.created!)' ]; then + owner=-; helper=- + fi + """ : "") + fi + \(kind == .herdr ? "directory=$(directory_fingerprint \(try RemoteMultiplexerCommands.quote(candidate.root))) || exit 79" : "") + \(kind == .zmx ? """ + # zmx's list PID is the PTY child, not the daemon. Bind its parent separately. + daemon=$(ps -p '\(candidate.process!)' -o ppid= | tr -d ' ') || exit 79 + comm=$(ps -p "$daemon" -o comm=) + [ "${comm##*/}" = zmx ] || exit 79 + born=$(process_start "$daemon") + [ -n "$born" ] || exit 79 + born=$(hex "$born") + """ : "") + printf 'F|\(index)|%s|%s|%s|%s|%s|%s|%s\\n' "$(hex "$socket")" "$fp" "$owner" "$helper" "$directory" "$daemon" "$born" + """ + }.joined(separator: "\n") + let metadata = try await execute( + kind.discovery + "\n" + RemoteMultiplexerCommands.utilities + "\n" + + RemoteMultiplexerCommands.stateRoot(kind, create: false) + + "\n" + commands) + try Self.check(metadata) + let lines = metadata.stdout.split(separator: "\n") + guard metadata.stdout.hasSuffix("\n"), lines.count == candidates.count else { + throw RemoteSessionFailure.invalidResponse + } + return try lines.enumerated().map { index, line in + let fields = line.split(separator: "|", omittingEmptySubsequences: false).map(String.init) + guard fields.count == 9, fields[0] == "F", fields[1] == String(index), + fields[4] == "-" || UUID(uuidString: fields[4]) != nil, + fields[5] == "-" || RemoteMultiplexerCommands.validHelper(fields[5]) + else { throw RemoteSessionFailure.invalidResponse } + let candidate = candidates[index] + let identity = RemoteMultiplexerIdentity( + kind: kind, root: candidate.root, socket: try RemoteMultiplexerCommands.unhex(fields[2]), + fingerprint: fields[3], process: candidate.process, created: candidate.created, + owner: UUID(uuidString: fields[4]), helper: fields[5] == "-" ? nil : fields[5], + isolated: candidate.isolated, directoryFingerprint: fields[6] == "-" ? nil : fields[6], + daemonProcess: fields[7] == "-" ? nil : fields[7], + daemonStarted: fields[8] == "-" ? nil : try RemoteMultiplexerCommands.unhex(fields[8])) + let data = try JSONEncoder().encode(identity) + _ = try RemoteMultiplexerIdentity.decode(data, kind: kind) + try RemoteMultiplexerCommands.validateRoute(identity, name: candidate.name) + return RemoteSessionDescriptor( + id: (candidate.isolated ? "managed:" : "user:") + candidate.name, + name: candidate.name, identity: data, clients: candidate.clients) + } + } + + func create( + token: UUID, startupCommand: String?, shellIntegration: Bool, environment: [HostEnvironmentVariable] + ) async throws -> RemoteSessionDescriptor { + if kind == .tmux { + let result = try await execute( + RemoteTmuxCommands.create( + token: token, startupCommand: startupCommand, shellIntegration: shellIntegration, + environment: environment)) + try Self.check(result) + let confirmed = try RemoteTmuxCommands.parseSessions(result.stdout) + guard confirmed.count == 1, let session = confirmed.first, + try TmuxSessionIdentity.decode(session.identity).owner == token.uuidString + else { throw RemoteSessionFailure.ownershipMismatch } + return session + } + let name = RemoteMultiplexerCommands.managedName(token) + if let existing = try await sessions().first(where: { $0.name == name }) { + let identity = try RemoteMultiplexerIdentity.decode(existing.identity, kind: kind) + guard identity.owner == token else { throw RemoteSessionFailure.ownershipMismatch } + // An interrupted response may leave a confirmed incarnation without its ready marker. + // Only finish that same incarnation; never replay a startup command or adopt by name. + let reference = RemoteSessionReference( + backend: kind.id, session: existing, hostID: UUID(), endpoint: "", username: "", ownershipToken: token) + let completion = try await execute( + """ + \(try RemoteMultiplexerCommands.guardIdentity(reference)) + helper=$(cat "$meta/helper") || exit 79 + \(startupCommand?.isEmpty == false ? """ + [ -f "$helper/started" ] && [ ! -L "$helper/started" ] \ + && [ "$(cat "$helper/started")" = '\(token.uuidString)' ] || exit 81 + """ : "") + : > "$meta/ready" + printf 'TERMIND_CREATED\\n' + """) + try Self.check(completion) + guard completion.stdout == "TERMIND_CREATED\n" else { throw RemoteSessionFailure.invalidResponse } + return existing + } + let native: String + switch kind { + case .tmux: throw RemoteSessionFailure.unsupported + case .zellij: native = RemoteZellijCommands.create(name: name) + case .zmx: native = RemoteZmxCommands.create(name: name, token: token) + case .herdr: native = RemoteHerdrCommands.create(name: name) + } + let requireStartup = startupCommand?.isEmpty == false + let shell = + try environment.last(where: { $0.variable == "SHELL" }).map { + try RemoteMultiplexerCommands.quote($0.value) + } ?? "\"${SHELL:-/bin/sh}\"" + let result = try await execute( + """ + export TERMIND_SHELL_LOCALE_SET="${LC_ALL+x}" TERMIND_SHELL_LOCALE="${LC_ALL-}" + \(kind.discovery) + \(RemoteMultiplexerCommands.utilities) + \(RemoteMultiplexerCommands.stateRoot(kind, create: true)) + umask 077 + lock="$state/\(name).lock" + mkdir "$lock" 2>/dev/null || exit 79 + reserved=0; native_started=0 + cleanup() { + code=$? + if [ "$reserved" = 1 ] && [ "$native_started" = 0 ]; then + rm -f "$meta/owner" "$meta/helper" "$meta/create-error" + rmdir "$meta" 2>/dev/null + fi + rmdir "$lock" 2>/dev/null + exit "$code" + } + trap cleanup EXIT + trap 'exit 78' HUP INT TERM + session_shell=\(shell) + \(requireStartup ? "case \"$session_shell\" in */bash|*/zsh|*/fish) [ -x \"$session_shell\" ] || exit 81 ;; *) exit 81 ;; esac" : "") + \(try RemoteShellStartup.install( + token: token, startupCommand: startupCommand, shellIntegration: shellIntegration, backend: kind.rawValue, + environment: environment)) + meta="$state/\(name)" + # A reservation with no confirmed incarnation is not permission to adopt a same-named session. + mkdir "$meta" 2>/dev/null || exit 79 + reserved=1 + chmod 700 "$meta" || exit 78 + printf '%s\\n' '\(token.uuidString)' > "$meta/owner" || exit 78 + printf '%s\\n' "$helper" > "$meta/helper" || exit 78 + export TERMIND_TMUX_HELPERS="$helper" TERMIND_TMUX_SHELL="$session_shell" + \(native) + fp=$(fingerprint "$socket") || exit 79 + printf '%s\\n' "$fp" > "$meta/identity" || exit 78 + \(requireStartup ? """ + attempt=0 + while [ ! -f "$helper/started" ]; do + attempt=$((attempt + 1)) + if [ "$attempt" -ge 50 ]; then + # A slow or bypassed user rc must not run the snippet after reporting failure. + mkdir "$helper/startup-decision" 2>/dev/null && exit 81 + [ -f "$helper/started" ] || exit 81 + fi + sleep 0.1 + done + [ "$(cat "$helper/started")" = '\(token.uuidString)' ] || exit 81 + """ : "") + : > "$meta/ready" + printf 'TERMIND_CREATED\\n' + """) + try Self.check(result) + guard result.stdout == "TERMIND_CREATED\n", + let session = try await sessions().first(where: { $0.name == name }), + try RemoteMultiplexerIdentity.decode(session.identity, kind: kind).owner == token + else { throw RemoteSessionFailure.ownershipMismatch } + return session + } + + func launch(_ reference: RemoteSessionReference, created: Bool) throws -> RemoteSessionLaunch { + try validate(reference) + if kind == .tmux { + return .init( + command: try RemoteTmuxCommands.attach(reference), attachment: reference, created: created) + } + let identity = try RemoteMultiplexerIdentity.decode(reference.session.identity, kind: kind) + let name = try RemoteMultiplexerCommands.quote(reference.session.name) + let attach: String + switch kind { + case .tmux: throw RemoteSessionFailure.unsupported + case .zellij: attach = "\"$exe\" attach \(name)" + case .zmx: + // attach is an upsert. If the daemon disappears after validation, its replacement must exit, + // not become an unrelated interactive shell (or replay the original startup command). + attach = "\"$exe\" attach \(name) /bin/sh -c 'exit 79'" + case .herdr: attach = "\"$exe\" --session \(name) client" + } + let noResurrection = + kind == .zellij + ? """ + # An attach command can otherwise resurrect a cached session after a disconnect race. + cache="/tmp/termind-zellij-attach-cache-$(id -u)" + private_dir "$cache" || exit 79 + export XDG_CACHE_HOME="$cache" + """ : "" + let bind: String + if let helper = identity.helper { + bind = """ + helper=\(try RemoteMultiplexerCommands.quote(helper)) + tty=$(tty) || exit 79 + key=$(printf '%s' "$tty" | od -An -v -tx1 | tr -d ' \\n') + printf '%s|%s\\n' "$$" "$(process_start "$$")" > "$helper/client-$key" + """ + } else { + bind = "" + } + return .init( + command: try RemoteMultiplexerCommands.guardIdentity(reference) + "\n" + noResurrection + + "\n" + bind + "\nexec " + attach, + attachment: reference, created: created) + } + + func terminate(_ reference: RemoteSessionReference) async throws { + try validate(reference) + guard reference.isManaged, kind != .herdr || reference.session.name != "default" else { + throw RemoteSessionFailure.ownershipMismatch + } + if kind == .tmux { + let result = try await execute(RemoteTmuxCommands.terminate(reference)) + try Self.check(result) + guard result.stdout == "TERMIND_ENDED\n" else { throw RemoteSessionFailure.ownershipMismatch } + return + } + let identity = try RemoteMultiplexerIdentity.decode(reference.session.identity, kind: kind) + if kind == .herdr, identity.directoryFingerprint == nil { + throw RemoteSessionFailure.ownershipMismatch + } + let name = try RemoteMultiplexerCommands.quote(reference.session.name) + let stop: String + switch kind { + case .tmux: throw RemoteSessionFailure.unsupported + case .zellij: stop = "\"$exe\" kill-session \(name)" + case .zmx: stop = "\"$exe\" kill \(name) --force" + case .herdr: stop = "\"$exe\" session stop \(name) --json" + } + let result = try await execute( + """ + \(try RemoteMultiplexerCommands.guardIdentity(reference)) + \(stop) >/dev/null || exit 78 + attempt=0 + while [ -S \(try RemoteMultiplexerCommands.quote(identity.socket)) ]; do + attempt=$((attempt + 1)); [ "$attempt" -lt 50 ] || exit 79 + sleep 0.1 + done + \(kind == .herdr ? """ + private_state || exit 79 + [ "$(cat "$meta/owner")" = '\(reference.ownershipToken!.uuidString)' ] || exit 79 + [ "$(cat "$meta/identity")" = \(try RemoteMultiplexerCommands.quote(identity.fingerprint)) ] || exit 79 + [ "$(directory_fingerprint \(try RemoteMultiplexerCommands.quote(identity.root)))" = '\(identity.directoryFingerprint!)' ] || exit 79 + [ ! -e \(try RemoteMultiplexerCommands.quote(identity.socket)) ] || exit 79 + "$exe" session delete \(name) --json >/dev/null || exit 78 + [ ! -e \(try RemoteMultiplexerCommands.quote(identity.root)) ] || exit 79 + """ : "") + printf 'TERMIND_ENDED\\n' + """) + try Self.check(result) + guard result.stdout == "TERMIND_ENDED\n" else { throw RemoteSessionFailure.invalidResponse } + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteSessionBackendRegistry.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteSessionBackendRegistry.swift new file mode 100644 index 00000000..ddfa4b49 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteSessionBackendRegistry.swift @@ -0,0 +1,13 @@ +import TermindApplication +import TermindDomain + +extension RemoteSessionBackendRegistry { + public nonisolated static var live: Self { + Self( + Dictionary( + uniqueKeysWithValues: SessionPersistenceBackendID.supported.map { + id -> (SessionPersistenceBackendID, Factory) in + (id, { execute in RemoteMultiplexerService(backend: id, execute: execute) }) + })) + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteShellContextJournal.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteShellContextJournal.swift index e0ddedc5..258df937 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteShellContextJournal.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteShellContextJournal.swift @@ -76,22 +76,34 @@ nonisolated enum RemoteShellContextJournal { fi """# - static func initialization(shell: String, token: String) -> String { + static func initialization(shell: String, token: String, backend: String? = nil) -> String { + let paneVariable: String? + switch backend { + case "zellij": paneVariable = "ZELLIJ_PANE_ID" + case "herdr": paneVariable = "HERDR_PANE_ID" + case "zmx": paneVariable = nil + default: paneVariable = "TMUX_PANE" + } + let tag = backend == nil ? "M1" : "M2" + let suffix = backend.map { "|\($0)" } ?? "" if shell == "fish" { return """ if set -q __termind_context_dir; return; end set -gx TERMIND_CONTEXT_TOKEN '\(token)' set -gx __termind_context_dir (command mktemp -d "$TERMIND_TMUX_HELPERS/ctx.XXXXXXXX") test -n "$__termind_context_dir"; or return + \(backend == nil ? "" : """ + command env LC_ALL=C TZ=UTC ps -p "$fish_pid" -o lstart= | string trim > "$__termind_context_dir/process-start" + """) set -l pane shell - if set -q TMUX_PANE; set pane "$TMUX_PANE"; end + \(paneVariable.map { "if set -q \($0); set pane \"$\($0)\"; end" } ?? "") set -l history_name fish if set -q fish_history; set history_name "$fish_history"; end set -l history_base "$HOME/.local/share" if set -q XDG_DATA_HOME; set history_base "$XDG_DATA_HOME"; end set -l history_path '' if test -n "$history_name"; set history_path "$history_base/fish/$history_name"_history; end - printf 'M1|%s|%s|%s|fish|%s|%s|%s\\n' "$TERMIND_CONTEXT_TOKEN" "$pane" \ + printf '\(tag)|%s|%s|%s|fish|%s|%s|%s\(suffix)\\n' "$TERMIND_CONTEXT_TOKEN" "$pane" \ (command basename "$__termind_context_dir") (string escape --style=url -- "$HOME") "$fish_pid" \ (string escape --style=url -- "$history_path") > "$__termind_context_dir/meta" function __termind_event @@ -104,18 +116,22 @@ nonisolated enum RemoteShellContextJournal { export TERMIND_CONTEXT_TOKEN='\(token)' __termind_context_dir=$(command mktemp -d "$TERMIND_TMUX_HELPERS/ctx.XXXXXXXX") || return export __termind_context_dir + \(backend == nil ? "" : """ + command env LC_ALL=C TZ=UTC ps -p "$$" -o lstart= | sed 's/^ *//;s/ *$//' > "$__termind_context_dir/process-start" + """) __termind_event() { command sh "$TERMIND_TMUX_HELPERS/event" "$__termind_context_dir" "$@"; } __termind_home=$(printf '%s' "$HOME" | od -An -v -tx1 | tr -d ' \\n' | sed 's/../%&/g') __termind_history=${HISTFILE:-} case "$__termind_history" in ''|/*) ;; *) __termind_history="$PWD/$__termind_history" ;; esac __termind_history=$(printf '%s' "$__termind_history" | od -An -v -tx1 | tr -d ' \\n' | sed 's/../%&/g') - printf 'M1|%s|%s|%s|\(shell)|%s|%s|%s\\n' "$TERMIND_CONTEXT_TOKEN" "${TMUX_PANE:-shell}" \ + printf '\(tag)|%s|%s|%s|\(shell)|%s|%s|%s\(suffix)\\n' "$TERMIND_CONTEXT_TOKEN" "\(paneVariable.map { "${\($0):-shell}" } ?? "shell")" \ "${__termind_context_dir##*/}" "$__termind_home" "$$" "$__termind_history" > "$__termind_context_dir/meta" """ } } nonisolated struct RemoteShellContextRecord { + let backend: String let token: String let pane: String let instance: String @@ -136,8 +152,13 @@ nonisolated struct RemoteShellContextRecord { guard lines.count >= 2 else { throw TerminalRuntimeError.invalidResponse } let meta = lines[0].split(separator: "|", omittingEmptySubsequences: false).map(String.init) let state = lines[1].split(separator: "|", omittingEmptySubsequences: false).map(String.init) - guard (7...8).contains(meta.count), meta[0] == "M1", UUID(uuidString: meta[1]) != nil, UInt64(meta[6]) != nil, - meta[2] == "shell" || Self.validPane(meta[2]), + let legacy = (7...8).contains(meta.count) && meta.first == "M1" + let modern = meta.count == 9 && meta.first == "M2" + guard legacy || modern else { throw TerminalRuntimeError.invalidResponse } + let backend = modern ? meta[8] : (meta[2] == "shell" ? "shell" : "tmux") + guard ["shell", "tmux", "zellij", "zmx", "herdr"].contains(backend), + UUID(uuidString: meta[1]) != nil, UInt64(meta[6]) != nil, + Self.validPane(meta[2], backend: backend), meta[3].hasPrefix("ctx."), meta[3].utf8.allSatisfy({ $0 == 46 || (48...57).contains($0) @@ -148,6 +169,7 @@ nonisolated struct RemoteShellContextRecord { ["0", "1"].contains(state[2]) else { throw TerminalRuntimeError.invalidResponse } token = meta[1] + self.backend = backend pane = meta[2] instance = meta[3] shell = meta[4] @@ -174,7 +196,7 @@ nonisolated struct RemoteShellContextRecord { } let home = try decode(meta[5]) self.home = home.hasPrefix("/") ? home : nil - let history = try meta.count == 8 ? decode(meta[7]) : "" + let history = try meta.count >= 8 ? decode(meta[7]) : "" historyPath = (history.isEmpty || history.hasPrefix("/")) && !history.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) @@ -214,6 +236,21 @@ nonisolated struct RemoteShellContextRecord { value.hasPrefix("%") && value.count > 1 && value.dropFirst().utf8.allSatisfy { (48...57).contains($0) } } + static func validPane(_ value: String, backend: String) -> Bool { + switch backend { + case "shell", "zmx": return value == "shell" + case "tmux": return validPane(value) + case "zellij": return !value.isEmpty && value.utf8.allSatisfy { (48...57).contains($0) } + case "herdr": + return !value.isEmpty && value.utf8.count <= 128 + && value.utf8.allSatisfy { + (48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0) + || [45, 58, 95].contains($0) + } + default: return false + } + } + func context(server: String, session: String, backend: String) -> TerminalShellContext { TerminalShellContext( identity: .init(backend: backend, server: server, session: session, pane: pane, shellInstance: instance), diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxShellStartup.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteShellStartup.swift similarity index 70% rename from Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxShellStartup.swift rename to Frameworks/TermindRemoteInfrastructure/SSH/RemoteShellStartup.swift index 098cb3cf..cbb8de0b 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxShellStartup.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteShellStartup.swift @@ -1,17 +1,21 @@ import Foundation +import TermindDomain /// Private, per-session startup files. Nothing is ever typed into an attached terminal. -nonisolated enum RemoteTmuxShellStartup { +nonisolated enum RemoteShellStartup { static func install( - token: UUID, startupCommand: String?, shellIntegration: Bool, ordinary: Bool = false + token: UUID, startupCommand: String?, shellIntegration: Bool, ordinary: Bool = false, + backend: String? = nil, environment: [HostEnvironmentVariable] = [] ) throws -> String { let eventToken = ordinary ? token : UUID() let bootstrap = RemoteShellIntegrationBootstrap(token: eventToken.uuidString) - let startup = try RemoteTmuxCommands.quote(startupCommand ?? "") + let startup = try RemoteMultiplexerCommands.quote(startupCommand ?? "") let reportsStartup = shellIntegration && startupCommand?.isEmpty == false let posixStartup = """ if [ "${TERMIND_TMUX_FIRST:-}" = 1 ]; then unset TERMIND_TMUX_FIRST + \(backend == nil ? "" : "mkdir \"$TERMIND_TMUX_HELPERS/startup-decision\" 2>/dev/null || return") + printf '%s\\n' '\(token.uuidString)' > "$TERMIND_TMUX_HELPERS/started" \(reportsStartup ? "__termind_startup_time=$(date +%s); __termind_event start \"$(__termind_encode \(startup))\"" : "") eval \(startup) \(reportsStartup ? "__termind_startup_status=$?; __termind_event finish \"$__termind_startup_status\" \"$(( ($(date +%s) - __termind_startup_time) * 1000 ))\"" : "") @@ -20,6 +24,8 @@ nonisolated enum RemoteTmuxShellStartup { let fishStartup = """ if test "$TERMIND_TMUX_FIRST" = 1 set -e TERMIND_TMUX_FIRST + \(backend == nil ? "" : "command mkdir \"$TERMIND_TMUX_HELPERS/startup-decision\" 2>/dev/null; or return") + printf '%s\\n' '\(token.uuidString)' > "$TERMIND_TMUX_HELPERS/started" \(reportsStartup ? "set -g __termind_startup_time (date +%s); __termind_event start (string escape --style=url -- \(startup))" : "") eval \(startup) \(reportsStartup ? "set -l command_status $status; set -l elapsed (math (date +%s) - $__termind_startup_time); __termind_event finish $command_status (math $elapsed '*' 1000)" : "") @@ -27,29 +33,54 @@ nonisolated enum RemoteTmuxShellStartup { """ let bash = (shellIntegration - ? RemoteShellContextJournal.initialization(shell: "bash", token: eventToken.uuidString) + ? RemoteShellContextJournal.initialization( + shell: "bash", token: eventToken.uuidString, backend: backend) + "\n" + String(decoding: bootstrap.installationCommand(for: "bash", runtime: true)!, as: UTF8.self) : "") + "\n" + (shellIntegration ? "__termind_command_ready=0\n" : "") + posixStartup + (shellIntegration ? "\n__termind_report_prompt\n" : "") let zsh = (shellIntegration - ? RemoteShellContextJournal.initialization(shell: "zsh", token: eventToken.uuidString) + ? RemoteShellContextJournal.initialization(shell: "zsh", token: eventToken.uuidString, backend: backend) + "\n" + String(decoding: bootstrap.installationCommand(for: "zsh", runtime: true)!, as: UTF8.self) : "") + "\n" + posixStartup let fish = (shellIntegration - ? RemoteShellContextJournal.initialization(shell: "fish", token: eventToken.uuidString) + ? RemoteShellContextJournal.initialization( + shell: "fish", token: eventToken.uuidString, backend: backend) + "\n" + String(decoding: bootstrap.installationCommand(for: "fish", runtime: true)!, as: UTF8.self) : "") + "\n" + fishStartup let shell = """ #!/bin/sh + \(backend == nil ? "" : """ + if [ "${TERMIND_SHELL_LOCALE_SET:-}" = x ]; then + export LC_ALL="$TERMIND_SHELL_LOCALE" + else + unset LC_ALL + fi + unset TERMIND_SHELL_LOCALE_SET TERMIND_SHELL_LOCALE + """) + \(backend == "zellij" ? """ + if [ "${TERMIND_SHELL_CACHE_HOME_SET:-}" = x ]; then + export XDG_CACHE_HOME="$TERMIND_SHELL_CACHE_HOME" + else + unset XDG_CACHE_HOME + fi + unset TERMIND_SHELL_CACHE_HOME_SET TERMIND_SHELL_CACHE_HOME + """ : "") + \(try RemoteMultiplexerCommands.environment(environment)) SHELL=${TERMIND_TMUX_SHELL:-${SHELL:-/bin/sh}} export SHELL if [ "$#" -gt 0 ]; then exec "$SHELL" "$@"; fi unset __termind_context_dir + \(backend == nil ? "" : """ + unset TERMIND_TMUX_FIRST + if mkdir "$TERMIND_TMUX_HELPERS/first-claimed" 2>/dev/null; then + export TERMIND_TMUX_FIRST=1 + fi + """) export TERMIND_CONTEXT_OSC=\(ordinary ? "1" : "0") case "$SHELL" in */bash) @@ -76,7 +107,7 @@ nonisolated enum RemoteTmuxShellStartup { let first = "#!/bin/sh\nexport TERMIND_TMUX_FIRST=1\nexec \"$TERMIND_TMUX_HELPERS/shell\"\n" var files = [ "shell": shell, "first": first, "bashrc": bashrc, "fish": fish, "event": RemoteShellContextJournal.writer, - "request-reader": RemoteShellContextJournal.requestReader, + "request-reader": RemoteShellContextJournal.requestReader, "event-token": eventToken.uuidString, ] for name in [".zshenv", ".zprofile", ".zshrc", ".zlogin"] { files[name] = """ @@ -88,7 +119,7 @@ nonisolated enum RemoteTmuxShellStartup { } files[".zlogin", default: ""] += "\nexport ZDOTDIR=\"$TERMIND_USER_ZDOTDIR\"\n\(zsh)\n" let writes = try files.keys.sorted().map { name in - "printf '%s\\n' \(try RemoteTmuxCommands.quote(files[name]!)) > \"$helper/\(name)\" || exit 78" + "printf '%s\\n' \(try RemoteMultiplexerCommands.quote(files[name]!)) > \"$helper/\(name)\" || exit 78" }.joined(separator: "\n") return """ umask 077 diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTerminalContextFilter.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTerminalContextFilter.swift index 4767f522..b8b0462a 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTerminalContextFilter.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTerminalContextFilter.swift @@ -58,8 +58,7 @@ nonisolated struct RemoteTerminalContextFilter { } update.contexts.append( .init( - status: context.promptReady ? .ordinary : .unknown, - active: context.promptReady ? context.identity : nil, + status: .ordinary, active: context.identity, contexts: [context], events: record.events(for: context))) } else { update.contexts.append(.init(status: .unavailable)) @@ -80,4 +79,26 @@ nonisolated struct RemoteTerminalContextFilter { buffer = Data(buffer[end...]) return update } + + static func needsObservation(_ update: TerminalRuntimeUpdate) -> Bool { + update.status != .ordinary + || update.contexts.first(where: { $0.identity == update.active })?.promptReady != true + } + + static func reconcileObservation( + _ observation: TerminalRuntimeUpdate, current: TerminalRuntimeUpdate? + ) -> TerminalRuntimeUpdate { + // A negative multiplexer probe does not invalidate the authenticated outer + // shell's cwd. Keep polling for manual attach, without restoring prompt rights. + if observation.status == .unknown || observation.status == .unavailable, + let current, current.status == .ordinary, + let shell = current.contexts.first(where: { $0.identity == current.active }), + shell.identity.backend == "shell", !shell.promptReady + { + var retained = current + retained.events = [] + return retained + } + return observation + } } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTerminalDraft.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTerminalDraft.swift index 1e2b940d..72dd47b5 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTerminalDraft.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTerminalDraft.swift @@ -6,55 +6,41 @@ nonisolated enum RemoteTerminalDraft { static func command( context: TerminalShellContext, terminal: String, helper: String?, clientIdentity: String?, request: UUID, replacement: String?, original: String? = nil, - charset: RemoteCharset = .utf8 + charset: RemoteCharset = .utf8, target: RemoteMultiplexerDraftTarget? = nil ) throws -> String { let identity = context.identity guard context.promptReady, ["bash", "zsh", "fish"].contains(context.shell), identity.shellInstance.hasPrefix("ctx."), identity.shellInstance.utf8.allSatisfy({ (48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0) || $0 == 46 - }), replacement?.utf8.count ?? 0 <= 4096, replacement?.contains("\0") != true + }), replacement?.utf8.count ?? 0 <= 4096, replacement?.contains("\0") != true, + original?.utf8.count ?? 0 <= 4096, original?.contains("\0") != true else { throw TerminalRuntimeError.staleContext } let root: String let trigger: String - let key = replacement == nil ? "\u{1B}[777;1~" : "\u{1B}[777;2~" - if identity.backend == "tmux" { - let session = identity.session.split(separator: ":").map(String.init) - let client = clientIdentity?.split(separator: ":").map(String.init) ?? [] - guard session.count == 2, session[0].hasPrefix("$"), UInt64(session[0].dropFirst()) != nil, - UInt64(session[1]) != nil, UInt64(identity.server) != nil, - RemoteShellContextRecord.validPane(identity.pane), client.count == 3, client[0] == terminal, - UInt64(client[1]) != nil, UInt64(client[2]) != nil - else { throw TerminalRuntimeError.staleContext } - let target = try RemoteTmuxCommands.quote(session[0]) - root = """ - [ "$(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"}')" \ - = '\(client[1]):\(client[2]):\(identity.pane):root:\(identity.session)' ] || exit 79 - helper=$(tmux show-environment -t \(target) TERMIND_TMUX_HELPERS) || exit 79 - helper=${helper#TERMIND_TMUX_HELPERS=} - """ - let guardFormat = - "#{&&:#{==:#{pane_in_mode},0},#{&&:#{==:#{synchronize-panes},0},#{&&:#{==:#{pane_dead},0},#{==:#{pane_current_command},\(context.shell)}}}}" - trigger = """ - tmux if-shell -t \(try RemoteTmuxCommands.quote(identity.pane)) -F \(try RemoteTmuxCommands.quote(guardFormat)) \ - \(try RemoteTmuxCommands.quote("send-keys -t \(identity.pane) -l \(try RemoteTmuxCommands.quote(key))")) || exit 79 - """ + if let target { + guard RemoteMultiplexerKind(rawValue: identity.backend) != nil else { + throw TerminalRuntimeError.staleContext + } + root = target.root + trigger = target.trigger + } else if identity.backend == "tmux" { + let target = try RemoteTmuxRuntimeDialect.draftTarget( + context: context, terminal: terminal, clientIdentity: clientIdentity, replacing: replacement != nil) + root = target.root + trigger = target.trigger } else { guard identity.backend == "shell", let helper else { throw TerminalRuntimeError.staleContext } - root = "helper=\(try RemoteTmuxCommands.quote(helper))" + root = "helper=\(try RemoteMultiplexerCommands.quote(helper))" // The worker sends the negotiated widget key after this request is installed. trigger = "printf 'READY\\n'" } let bytes = try RemoteCharsetStreamConverter.encode(replacement ?? "", charset: charset) guard bytes.count <= 4096 else { throw TerminalRuntimeError.staleContext } let encoded = bytes.map { String(format: "%%%02X", $0) }.joined() - let baseline = - try original.map { - try RemoteCharsetStreamConverter.encode($0, charset: charset).map { String(format: "%%%02X", $0) } - .joined() - } ?? "-" + let baselineBytes = try original.map { try RemoteCharsetStreamConverter.encode($0, charset: charset) } + guard baselineBytes?.count ?? 0 <= 4096 else { throw TerminalRuntimeError.staleContext } + let baseline = baselineBytes?.map { String(format: "%%%02X", $0) }.joined() ?? "-" return """ umask 077 \(root) @@ -79,12 +65,12 @@ nonisolated enum RemoteTerminalDraft { throw TerminalRuntimeError.staleContext } root = """ - helper=$(tmux show-environment -t \(try RemoteTmuxCommands.quote(String(session))) TERMIND_TMUX_HELPERS) || exit 79 + helper=$(tmux show-environment -t \(try RemoteMultiplexerCommands.quote(String(session))) TERMIND_TMUX_HELPERS) || exit 79 helper=${helper#TERMIND_TMUX_HELPERS=} """ } else { guard let helper else { throw TerminalRuntimeError.staleContext } - root = "helper=\(try RemoteTmuxCommands.quote(helper))" + root = "helper=\(try RemoteMultiplexerCommands.quote(helper))" } return """ \(root) diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxService.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxCommands.swift similarity index 65% rename from Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxService.swift rename to Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxCommands.swift index f39e806c..5d3fc849 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxService.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxCommands.swift @@ -1,94 +1,8 @@ -import CryptoKit import Foundation import TermindApplication import TermindDomain -/// Control operations run serially before the terminal channel occupies a server session slot. -public nonisolated struct RemoteTmuxService: RemoteSessionBackend, Sendable { - public typealias Executor = RemoteSessionCommandExecutor - private let execute: Executor - public var id: SessionPersistenceBackendID { .tmux } - public var capabilities: RemoteSessionCapabilities { - .init(windowCount: true, clientCount: true, shellIntegration: true) - } - - public init(execute: @escaping Executor) { self.execute = execute } - - public func validate(_ reference: RemoteSessionReference) throws { - guard reference.version == 1, reference.backend == id else { throw RemoteSessionFailure.unsupported } - _ = try TmuxSessionIdentity.decode(reference.session.identity) - } - - public func matches(_ reference: RemoteSessionReference, session: RemoteSessionDescriptor) -> Bool { - guard reference.version == 1, reference.backend == id, - reference.session.id == session.id, - let saved = try? TmuxSessionIdentity.decode(reference.session.identity), - let current = try? TmuxSessionIdentity.decode(session.identity), - saved.created == current.created, saved.serverPID == current.serverPID - else { return false } - return reference.ownershipToken.map { current.owner == $0.uuidString } ?? true - } - - public func probe() async throws -> RemoteSessionCapabilities { - let version = try await execute("command -v tmux >/dev/null 2>&1 || exit 77; tmux -V") - if version.exitCode == 77 { throw RemoteSessionFailure.missing } - try Self.check(version) - try RemoteTmuxCommands.validateVersion(version.stdout) - return capabilities - } - - public func sessions() async throws -> [RemoteSessionDescriptor] { - let result = try await execute(RemoteTmuxCommands.list) - try Self.check(result) - return try RemoteTmuxCommands.parseSessions(result.stdout) - } - - public func create( - token: UUID, startupCommand: String?, shellIntegration: Bool, environment: [HostEnvironmentVariable] = [] - ) async throws - -> RemoteSessionDescriptor - { - let result = try await execute( - RemoteTmuxCommands.create( - token: token, startupCommand: startupCommand, shellIntegration: shellIntegration, - environment: environment)) - if result.exitCode == 80 { throw RemoteSessionFailure.unsupported } - try Self.check(result) - let confirmed = try RemoteTmuxCommands.parseSessions(result.stdout) - guard confirmed.count == 1, let session = confirmed.first, - try TmuxSessionIdentity.decode(session.identity).owner == token.uuidString - else { - throw RemoteSessionFailure.ownershipMismatch - } - return session - } - - public func launch(_ attachment: RemoteSessionReference, created: Bool) throws -> RemoteSessionLaunch { - RemoteSessionLaunch( - command: try RemoteTmuxCommands.attach(attachment), attachment: attachment, created: created) - } - - public func terminate(_ attachment: RemoteSessionReference) async throws { - guard attachment.isManaged else { throw RemoteSessionFailure.ownershipMismatch } - let result = try await execute(RemoteTmuxCommands.terminate(attachment)) - if result.exitCode == 79 { throw RemoteSessionFailure.ownershipMismatch } - try Self.check(result) - guard result.stdout == "TERMIND_ENDED\n" else { throw RemoteSessionFailure.ownershipMismatch } - } - - private static func check(_ result: RemoteSessionCommandResult) throws { - guard !result.truncated else { throw RemoteSessionFailure.invalidResponse } - guard result.exitCode == 0 else { - throw RemoteSessionFailure.commandFailed(result.stderr) - } - } -} - -extension RemoteSessionBackendRegistry { - public static var live: Self { Self([.tmux: { RemoteTmuxService(execute: $0) }]) } -} - -private nonisolated struct TmuxSessionIdentity: Codable { +nonisolated struct TmuxSessionIdentity: Codable { var version = 1 let created: String let serverPID: String @@ -103,8 +17,7 @@ private nonisolated struct TmuxSessionIdentity: Codable { public nonisolated enum RemoteTmuxCommands { public static func quote(_ value: String) throws -> String { - guard !value.contains("\0") else { throw RemoteSessionFailure.invalidResponse } - return "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + try RemoteMultiplexerCommands.quote(value) } public static func validateVersion(_ output: String) throws { @@ -194,10 +107,7 @@ public nonisolated enum RemoteTmuxCommands { } static func managedSessionName(token: UUID) -> String { - // Keep 64 bits of a one-way digest: short to display, stable on retry, not an ownership credential. - "termind-" - + SHA256.hash(data: Data(token.uuidString.utf8)).prefix(8) - .map { String(format: "%02x", $0) }.joined() + RemoteMultiplexerCommands.managedName(token) } public static func create( @@ -205,7 +115,7 @@ public nonisolated enum RemoteTmuxCommands { ) throws -> String { let owner = token.uuidString let name = managedSessionName(token: token) - let wrapper = try RemoteTmuxShellStartup.install( + let wrapper = try RemoteShellStartup.install( token: token, startupCommand: startupCommand, shellIntegration: shellIntegration) guard environment.allSatisfy({ $0.isValid && !$0.variable.hasPrefix("TERMIND_") && $0.variable != "TMUX" }) else { diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxRuntimeAdapter.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxRuntimeDialect.swift similarity index 77% rename from Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxRuntimeAdapter.swift rename to Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxRuntimeDialect.swift index 6af877ed..09a0d80b 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxRuntimeAdapter.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteTmuxRuntimeDialect.swift @@ -2,15 +2,15 @@ import Foundation import TermindApplication import TermindDomain -public nonisolated struct RemoteTmuxRuntimeAdapter: TerminalRuntimeAdapter { +nonisolated struct RemoteTmuxRuntimeDialect: TerminalRuntimeAdapter { private let execute: RemoteSessionCommandExecutor private let charset: RemoteCharset - public init(execute: @escaping RemoteSessionCommandExecutor, charset: RemoteCharset = .utf8) { + init(execute: @escaping RemoteSessionCommandExecutor, charset: RemoteCharset = .utf8) { self.execute = execute self.charset = charset } - public func inspect(terminal: String) async throws -> TerminalRuntimeUpdate { + func inspect(terminal: String) async throws -> TerminalRuntimeUpdate { let result = try await execute(Self.command(terminal: terminal)) guard result.exitCode == 0, !result.truncated else { throw TerminalRuntimeError.unsupported } return try Self.parse(result.stdout, terminal: terminal, charset: charset) @@ -166,3 +166,35 @@ public nonisolated struct RemoteTmuxRuntimeAdapter: TerminalRuntimeAdapter { status: .multiplexer, clientIdentity: clientIdentity, active: active, contexts: contexts, events: events) } } + +extension RemoteTmuxRuntimeDialect { + static func draftTarget(context: TerminalShellContext, terminal: String, clientIdentity: String?, replacing: Bool) + throws -> RemoteMultiplexerDraftTarget + { + let identity = context.identity + let key = replacing ? "\u{1B}[777;2~" : "\u{1B}[777;1~" + let session = identity.session.split(separator: ":").map(String.init) + let client = clientIdentity?.split(separator: ":").map(String.init) ?? [] + guard session.count == 2, session[0].hasPrefix("$"), UInt64(session[0].dropFirst()) != nil, + UInt64(session[1]) != nil, UInt64(identity.server) != nil, + RemoteShellContextRecord.validPane(identity.pane), client.count == 3, client[0] == terminal, + UInt64(client[1]) != nil, UInt64(client[2]) != nil + else { throw TerminalRuntimeError.staleContext } + let target = try RemoteTmuxCommands.quote(session[0]) + let root = """ + [ "$(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"}')" \ + = '\(client[1]):\(client[2]):\(identity.pane):root:\(identity.session)' ] || exit 79 + helper=$(tmux show-environment -t \(target) TERMIND_TMUX_HELPERS) || exit 79 + helper=${helper#TERMIND_TMUX_HELPERS=} + """ + let guardFormat = + "#{&&:#{==:#{pane_in_mode},0},#{&&:#{==:#{synchronize-panes},0},#{&&:#{==:#{pane_dead},0},#{==:#{pane_current_command},\(context.shell)}}}}" + let trigger = """ + tmux if-shell -t \(try RemoteTmuxCommands.quote(identity.pane)) -F \(try RemoteTmuxCommands.quote(guardFormat)) \ + \(try RemoteTmuxCommands.quote("send-keys -t \(identity.pane) -l \(try RemoteTmuxCommands.quote(key))")) || exit 79 + """ + return .init(root: root, trigger: trigger) + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteZellijCommands.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteZellijCommands.swift new file mode 100644 index 00000000..9a09b085 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteZellijCommands.swift @@ -0,0 +1,85 @@ +import Foundation +import TermindApplication +import TermindDomain + +nonisolated enum RemoteZellijCommands { + static let userRoot = #""" + if [ -n "${ZELLIJ_SOCKET_DIR:-}" ]; then root=$ZELLIJ_SOCKET_DIR + elif [ -n "${XDG_RUNTIME_DIR:-}" ]; then root="$XDG_RUNTIME_DIR/zellij" + else root="${TMPDIR:-/tmp}/zellij-$(id -u)"; fi + """# + + static var list: String { + """ + \(RemoteMultiplexerKind.zellij.discovery) + \(RemoteMultiplexerCommands.utilities) + \(userRoot) + user_root=$root + for scope in user managed; do + if [ "$scope" = user ]; then root=$user_root; else root="/tmp/termind-zellij-$(id -u)"; fi + printf 'ROOT|%s|%s\\n' "$scope" "$(hex "$root")" + if [ -d "$root" ]; then + export ZELLIJ_SOCKET_DIR=$root + listing=$("$exe" list-sessions --short --no-formatting 2>&1) + code=$? + if [ "$code" = 0 ]; then + printf '%s\\n' "$listing" | while IFS= read -r name; do + case "$name" in ''|*[!a-zA-Z0-9_.-]*|.|..) exit 78 ;; esac + # list-sessions also includes serialized, exited sessions. Never offer those as live. + socket=$(find "$root" -type s -name "$name" -print 2>/dev/null) || exit 78 + [ -S "$socket" ] && [ ! -L "$socket" ] || continue + "$exe" --session "$name" action list-panes --json >/dev/null 2>&1 || continue + printf '%s\\n' "$name" + done || exit 78 + elif [ "$listing" != 'No active zellij sessions found.' ]; then printf '%s\\n' "$listing" >&2; exit 78; fi + fi + printf 'END\\n' + done + """ + } + + static func parseList(_ text: String) throws -> [RemoteMultiplexerCandidate] { + guard text.hasSuffix("\n") else { throw RemoteSessionFailure.invalidResponse } + var result: [RemoteMultiplexerCandidate] = [] + var root: String? + var isolated = false + var scopes: Set = [] + for line in text.split(separator: "\n").map(String.init) { + if line.hasPrefix("ROOT|") { + let fields = line.components(separatedBy: "|") + guard root == nil, fields.count == 3, ["user", "managed"].contains(fields[1]), + scopes.insert(fields[1]).inserted + else { throw RemoteSessionFailure.invalidResponse } + root = try RemoteMultiplexerCommands.unhex(fields[2]) + isolated = fields[1] == "managed" + } else if line == "END" { + guard root != nil else { throw RemoteSessionFailure.invalidResponse } + root = nil + } else { + guard let root, RemoteMultiplexerCommands.validName(line) else { + throw RemoteSessionFailure.invalidResponse + } + result.append(.init(name: line, root: root, socket: nil, isolated: isolated)) + } + } + guard root == nil, scopes == ["user", "managed"] else { throw RemoteSessionFailure.invalidResponse } + return result + } + + static func create(name: String) -> String { + """ + root="/tmp/termind-zellij-$(id -u)" + private_dir "$root" || exit 78 + export ZELLIJ_SOCKET_DIR=$root + export TERMIND_SHELL_CACHE_HOME_SET="${XDG_CACHE_HOME+x}" TERMIND_SHELL_CACHE_HOME="${XDG_CACHE_HOME-}" + private_dir "$root/cache" || exit 78 + export XDG_CACHE_HOME="$root/cache" + existing=$(find "$root" -type s -name '\(name)' -print) || exit 78 + [ -z "$existing" ] || exit 79 + native_started=1 + "$exe" --layout-string 'layout { pane; }' attach --create-background '\(name)' options --default-shell "$helper/shell" \ + --session-serialization false >/dev/null || exit 78 + socket=$(find "$root" -type s -name '\(name)' -print) || exit 78 + """ + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteZmxCommands.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteZmxCommands.swift new file mode 100644 index 00000000..83dcd293 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteZmxCommands.swift @@ -0,0 +1,78 @@ +import Foundation +import TermindApplication +import TermindDomain + +nonisolated enum RemoteZmxCommands { + static let root = #""" + if [ -n "${ZMX_DIR:-}" ]; then root=$ZMX_DIR + elif [ -n "${XDG_RUNTIME_DIR:-}" ]; then root="$XDG_RUNTIME_DIR/zmx" + else root="${TMPDIR:-/tmp}/zmx-$(id -u)"; fi + """# + + static var list: String { + """ + \(RemoteMultiplexerKind.zmx.discovery) + \(RemoteMultiplexerCommands.utilities) + \(root) + printf 'ROOT|%s\\n' "$(hex "$root")" + "$exe" list || exit 78 + printf 'END\\n' + """ + } + + static func parseList(_ text: String) throws -> [RemoteMultiplexerCandidate] { + var lines = text.split(separator: "\n").map(String.init) + guard text.hasSuffix("\n"), lines.count >= 2, lines[0].hasPrefix("ROOT|"), lines.last == "END" else { + throw RemoteSessionFailure.invalidResponse + } + lines.removeLast() + let root = try RemoteMultiplexerCommands.unhex(String(lines.removeFirst().dropFirst(5))) + guard RemoteMultiplexerCommands.validPath(root) else { throw RemoteSessionFailure.invalidResponse } + return try lines.map { line in + var fields: [String: String] = [:] + for part in line.split(separator: "\t") { + guard let separator = part.firstIndex(of: "=") else { throw RemoteSessionFailure.invalidResponse } + let key = String(part[.. 1 }) == true, + let created = fields["created"], UInt64(created) != nil, + let clients = fields["clients"].flatMap(Int.init), clients >= 0, fields["err"] == nil + else { throw RemoteSessionFailure.invalidResponse } + return .init( + name: name, root: root, socket: root + "/" + name, process: pid, created: created, clients: clients) + } + } + + static func create(name: String, token: UUID) -> String { + """ + \(root) + export ZMX_DIR=$root + socket="$root/\(name)" + [ ! -e "$socket" ] && [ ! -L "$socket" ] || exit 79 + native_started=1 + "$exe" attach '\(name)' "$helper/shell" /dev/null 2>"$meta/create-error" || exit 78 + "$exe" set '\(name)' 'termind_owner=\(token.uuidString)' >/dev/null || exit 78 + native=$(\(nativeIdentity(name: name))) || exit 79 + [ -n "$native" ] || exit 79 + printf '%s\\n' "$native" > "$meta/native" + """ + } + + // The listed PID is the PTY child. Labels or commands containing "pid=" are not identity fields. + static func nativeIdentity(name: String) -> String { + """ + "$exe" list | awk -F '\\t' -v target='\(name)' ' + { name=""; pid=""; created=""; count=0 + for(i=1;i<=NF;i++) { + if ($i ~ /^name=/) {name=substr($i,6); count++} + if ($i ~ /^pid=[0-9]+$/) {pid=substr($i,5); count++} + if ($i ~ /^created=[0-9]+$/) {created=substr($i,9); count++} + } + if(name==target && count==3 && pid!="" && created!="") print pid ":" created + }' + """ + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift index ac944811..ee2ef724 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift @@ -969,6 +969,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { private var runtimeTerminal: String? private var runtimeRevision: UInt64 = 0 private var runtimeEnabled = false + private var runtimeAttachment: RemoteSessionReference? private var runtimeWantsObservation = false private var runtimeControlUnavailable = false private var runtimeHelper: String? @@ -1512,6 +1513,11 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { let executor = try await sessionCommandExecutor() let launch = try await preparation(executor) try Task.checkCancellation() + try await queue.perform { [self] in + guard ptyResize.generation == generation else { throw CancellationError() } + runtimeAttachment = launch?.runtimeAttachment + runtimeHelper = try RemoteMultiplexerRuntimeAdapter.helper(for: runtimeAttachment) + } if shellIntegrationEnabled { var probe = RemoteShellIntegrationBootstrap(charset: charset) if let result = try? await executor(String(decoding: probe.startCommand, as: UTF8.self)), @@ -1529,7 +1535,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { var startupCommand = launch?.command if launch == nil, shellIntegrationEnabled { do { - let install = try RemoteTmuxShellStartup.install( + let install = try RemoteShellStartup.install( token: runtimeToken, startupCommand: nil, shellIntegration: true, ordinary: true) let result = try await executor(install + "\nprintf '%s\\n' \"$helper\"\n") guard result.exitCode == 0, !result.truncated else { throw TerminalRuntimeError.unsupported } @@ -1541,7 +1547,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { }) else { throw TerminalRuntimeError.invalidResponse } startupCommand = - "export TERMIND_TMUX_HELPERS=\(try RemoteTmuxCommands.quote(helper)); exec \"$TERMIND_TMUX_HELPERS/shell\"" + "export TERMIND_TMUX_HELPERS=\(try RemoteMultiplexerCommands.quote(helper)); exec \"$TERMIND_TMUX_HELPERS/shell\"" await queue.perform { self.runtimeHelper = helper } } catch { try Task.checkCancellation() @@ -1554,7 +1560,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { "printf '\\033]777;TermindTerminal=\(runtimeToken.uuidString);%s\\007' \"$(tty)\";\n" + original } let command = try startupCommand.map { - SSHPTYStartupBytes(Data(("/bin/sh -c " + (try RemoteTmuxCommands.quote($0))).utf8)) + SSHPTYStartupBytes(Data(("/bin/sh -c " + (try RemoteMultiplexerCommands.quote($0))).utf8)) } let runtimeIsEnabled = usesRuntime try await queue.perform { [self] in @@ -2115,7 +2121,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { try Task.checkCancellation() let execute = try await sessionCommandExecutor( runtime: true, expectedInput: inputRevision, activation: request) - try await RemoteTmuxRuntimeAdapter(execute: execute).refreshSize(of: request.client) + try await RemoteMultiplexerRuntimeAdapter(execute: execute).refreshSize(of: request.client) } catch { // Optional sizing assistance must not disconnect a usable terminal or inject a fallback key. } @@ -2184,7 +2190,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { } for context in update.contexts { runtimeRevision &+= 1 - runtimeWantsObservation = context.status != .ordinary + runtimeWantsObservation = RemoteTerminalContextFilter.needsObservation(context) publishRuntime(context) if runtimeWantsObservation { startRuntimeObservation() } } @@ -2221,23 +2227,25 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { while !Task.isCancelled { try? await Task.sleep(for: .milliseconds(delay)) guard !Task.isCancelled else { break } - let request = try? await queue.perform { [self] () -> (String, UInt64, RemoteCharset)? in + let request = try? await queue.perform { + [self] () -> (String, UInt64, RemoteCharset, RemoteSessionReference?)? in guard runtimeEnabled, runtimeWantsObservation, ptyResize.generation == generation, !runtimeControlUnavailable, let terminal = runtimeTerminal else { return nil } - return (terminal, runtimeRevision, charset) + return (terminal, runtimeRevision, charset, runtimeAttachment) } guard let request else { continue } do { - let adapter = RemoteTmuxRuntimeAdapter( - execute: try await sessionCommandExecutor(runtime: true), charset: request.2) + let adapter = RemoteMultiplexerRuntimeAdapter( + attachment: request.3, execute: try await sessionCommandExecutor(runtime: true), + charset: request.2) let update = try await adapter.inspect(terminal: request.0) try Task.checkCancellation() await queue.perform { [self] in guard ptyResize.generation == generation, runtimeRevision == request.1, runtimeWantsObservation else { return } - publishRuntime(update) + publishRuntimeObservation(update) } delay = update.status == .multiplexer ? 750 : min(delay * 2, 5000) } catch { @@ -2246,7 +2254,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { guard ptyResize.generation == generation, runtimeRevision == request.1, runtimeWantsObservation else { return } - publishRuntime(.init(status: .unavailable)) + publishRuntimeObservation(.init(status: .unavailable)) } delay = min(delay * 2, 5000) } @@ -2254,6 +2262,10 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { } } + private func publishRuntimeObservation(_ update: TerminalRuntimeUpdate) { + publishRuntime(RemoteTerminalContextFilter.reconcileObservation(update, current: latestRuntimeUpdate)) + } + private func publishRuntime(_ proposed: TerminalRuntimeUpdate) { var update = proposed if runtimeControlUnavailable || channelBudget?.permitsConcurrentControl == false { @@ -2281,12 +2293,13 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { guard let terminal = runtimeTerminal, latestRuntimeUpdate?.active == expected.identity else { throw TerminalRuntimeError.staleContext } - return (terminal, runtimeInputRevision, ptyResize.generation, charset) + return (terminal, runtimeInputRevision, ptyResize.generation, charset, runtimeAttachment) } if expected.identity.backend != "shell" { let execute = try await sessionCommandExecutor(runtime: true, expectedInput: state.1) - let update = try await RemoteTmuxRuntimeAdapter(execute: execute, charset: state.3).inspect( - terminal: state.0) + let update = try await RemoteMultiplexerRuntimeAdapter( + attachment: state.4, execute: execute, charset: state.3 + ).inspect(terminal: state.0) guard update.active == expected.identity, update.contexts.first(where: { $0.identity == expected.identity }) == expected else { throw TerminalRuntimeError.staleContext } @@ -2310,14 +2323,15 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { .capabilities.contains(replacement == nil ? .readDraft : .replaceDraft) == true else { throw TerminalRuntimeError.staleContext } pendingRuntimeDraft = request - return (terminal, runtimeHelper, runtimeInputRevision, ptyResize.generation, charset) + return (terminal, runtimeHelper, runtimeInputRevision, ptyResize.generation, charset, runtimeAttachment) } do { let execute = try await sessionCommandExecutor(runtime: true, expectedInput: state.2) let update: TerminalRuntimeUpdate? - if expected.identity.backend == "tmux" { - update = try await RemoteTmuxRuntimeAdapter(execute: execute, charset: state.4).inspect( - terminal: state.0) + if expected.identity.backend != "shell" { + update = try await RemoteMultiplexerRuntimeAdapter( + attachment: state.5, execute: execute, charset: state.4 + ).inspect(terminal: state.0) guard update?.active == expected.identity, update?.contexts.first(where: { $0.identity == expected.identity }) == expected else { throw TerminalRuntimeError.staleContext } @@ -2325,12 +2339,18 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { update = nil } try await validateDraft(request, context: expected.identity, input: state.2, generation: state.3) + var target: RemoteMultiplexerDraftTarget? + if expected.identity.backend != "shell" { + target = try await RemoteMultiplexerRuntimeAdapter( + attachment: state.5, execute: execute, charset: state.4 + ).draftTarget(context: expected, terminal: state.0, replacing: replacement != nil) + } let command = try RemoteTerminalDraft.command( context: expected, terminal: state.0, helper: state.1, clientIdentity: update?.clientIdentity, request: request, replacement: replacement, original: try await queue.perform { [self] in replacement != nil && runtimeDraftBaseline?.0 == expected.identity ? runtimeDraftBaseline?.1 : nil - }, charset: state.4) + }, charset: state.4, target: target) let started = try await execute(command) guard started.exitCode == 0, !started.truncated else { throw TerminalRuntimeError.staleContext } if expected.identity.backend == "shell" { @@ -2496,6 +2516,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { runtimeTask = nil runtimeTerminal = nil runtimeEnabled = false + runtimeAttachment = nil allowsDefaultHistorySource = true runtimeWantsObservation = false runtimeControlUnavailable = false @@ -2698,12 +2719,14 @@ nonisolated extension SSHPTYChannelWorker { guard matchesHistoryContext(expected), let terminal = runtimeTerminal else { throw TerminalRuntimeError.staleContext } - return (terminal, ptyResize.generation, charset) + return (terminal, ptyResize.generation, charset, runtimeAttachment) } if expected.identity.backend != "shell" { let execute = try await sessionCommandExecutor(runtime: true) - let update = try await RemoteTmuxRuntimeAdapter(execute: execute, charset: state.2) - .inspect(terminal: state.0) + let update = try await RemoteMultiplexerRuntimeAdapter( + attachment: state.3, execute: execute, charset: state.2 + ) + .inspect(terminal: state.0) guard update.status == .multiplexer, update.active == expected.identity, update.contexts.first(where: { $0.identity == expected.identity })?.historySource == expected.historySource diff --git a/TFITests/MobileCommandGeneratorAndUploadTests.swift b/TFITests/MobileCommandGeneratorAndUploadTests.swift index fe52ff85..75051e1b 100644 --- a/TFITests/MobileCommandGeneratorAndUploadTests.swift +++ b/TFITests/MobileCommandGeneratorAndUploadTests.swift @@ -94,6 +94,44 @@ private final class MobileSSHCommandConnectionBox { @Suite @MainActor struct MobileCommandGeneratorTests { + @Test(arguments: ["shell", "tmux", "zellij", "zmx", "herdr"]) + func runningCommandsKeepTheDisplayedDirectoryUntilTheShellReportsAnother(backend: String) 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) + defer { workspace.disconnect() } + workspace.connect() + #expect(await eventually { workspace.connected }) + var context = TerminalShellContext( + identity: .init(backend: backend, server: "s", session: "s", pane: "a", shellInstance: "i"), + shell: "bash", directory: "/before", home: "/home/test", promptReady: true, sequence: 1, + capabilities: [.directory, .commandLifecycle, .readDraft, .replaceDraft]) + let status: TerminalRuntimeUpdate.Status = backend == "shell" ? .ordinary : .multiplexer + func receive() { + workspace.receiveShellEvent( + .runtime(.init(status: status, active: context.identity, contexts: [context]))) + } + receive() + #expect(workspace.canGenerateFromCurrentDraft) + context.sequence = 2 + context.promptReady = false + context.capabilities = [.commandLifecycle] + receive() + #expect(workspace.workingDirectory == "/before") + #expect(!workspace.canGenerateFromCurrentDraft) + context.sequence = 3 + context.directory = "/after" + receive() + #expect(workspace.workingDirectory == "/after") + #expect(!workspace.canGenerateFromCurrentDraft) + workspace.receiveShellEvent(.runtime(.init(status: .unavailable))) + #expect(workspace.workingDirectory == nil) + } + @Test(arguments: [false, true]) func paneHistoryInvalidationDoesNotDependOnSessionPersistence(managed: Bool) async throws { let (host, library) = try commandFixture() diff --git a/TFMTests/ArchitectureBoundaryTests.swift b/TFMTests/ArchitectureBoundaryTests.swift index d0dae15d..cdefdaa4 100644 --- a/TFMTests/ArchitectureBoundaryTests.swift +++ b/TFMTests/ArchitectureBoundaryTests.swift @@ -4,14 +4,18 @@ import Testing @Suite struct ArchitectureBoundaryTests { @Test - func sessionPersistencePolicyDoesNotDependOnTheTmuxImplementation() throws { + func sessionPersistencePolicyDoesNotDependOnMultiplexerDialects() throws { for directory in [ "../Frameworks/TermindDomain", "../Frameworks/TermindApplication", "../Frameworks/TermindPresentation", "Features", "../TFI/Features", ] { for file in try swiftFiles(in: directory) { let text = try String(contentsOf: file, encoding: .utf8) - for symbol in ["RemoteTmuxService", "RemoteTmuxCommands", "RemoteTmuxShellStartup"] { + for symbol in [ + "RemoteMultiplexerService", "RemoteMultiplexerKind", "RemoteMultiplexerCommands", + "RemoteTmuxCommands", "RemoteZellijCommands", "RemoteZmxCommands", "RemoteHerdrCommands", + "RemoteShellStartup", "RemoteTmuxRuntimeDialect", "RemoteSocketMultiplexerRuntime", + ] { #expect(!containsToken(symbol, in: text), "\(file.path) depends on \(symbol)") } } @@ -19,7 +23,9 @@ struct ArchitectureBoundaryTests { let channel = try String( contentsOf: sourceRoot.appending(path: "../Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift"), encoding: .utf8) - #expect(!containsToken("RemoteTmuxService", in: channel)) + #expect(!containsToken("RemoteMultiplexerService", in: channel)) + #expect(!containsToken("RemoteTmuxRuntimeDialect", in: channel)) + #expect(!containsToken("RemoteSocketMultiplexerRuntime", in: channel)) #expect(containsToken("StartupPreparation", in: channel)) } diff --git a/TFMTests/HostShellIntegrationSessionTests.swift b/TFMTests/HostShellIntegrationSessionTests.swift index 582a17dd..b4adc9f6 100644 --- a/TFMTests/HostShellIntegrationSessionTests.swift +++ b/TFMTests/HostShellIntegrationSessionTests.swift @@ -9,6 +9,41 @@ import struct TermindDomain.Host @Suite(.serialized) @MainActor struct HostShellIntegrationSessionTests { + @Test(arguments: ["shell", "tmux", "zellij", "zmx", "herdr"]) + func runningCommandsKeepTheDisplayedDirectoryUntilTheShellReportsAnother(backend: String) throws { + let session = SSHSessionModel(hostID: UUID(), title: "Cwd") + defer { session.disconnect() } + session.status = .connected + var context = TerminalShellContext( + identity: .init(backend: backend, server: "s", session: "s", pane: "a", shellInstance: "i"), + shell: "bash", directory: "/home/test/before", home: "/home/test", promptReady: true, sequence: 1, + capabilities: [.directory, .commandLifecycle, .readDraft, .replaceDraft]) + let status: TerminalRuntimeUpdate.Status = backend == "shell" ? .ordinary : .multiplexer + func receive() { + session.receiveShellIntegrationEvent( + .runtime(.init(status: status, active: context.identity, contexts: [context]))) + } + receive() + #expect(session.canGenerateFromCurrentDraft) + let ticket = try #require(session.directoryContextTicket) + context.sequence = 2 + context.promptReady = false + context.capabilities = [.commandLifecycle] + receive() + #expect(session.workingDirectory == "/home/test/before") + #expect(session.displayedWorkingDirectory == "~/before") + #expect(!session.canGenerateFromCurrentDraft) + #expect(!session.isDirectoryContextCurrent(ticket)) + context.sequence = 3 + context.directory = "/home/test/after" + receive() + #expect(session.workingDirectory == "/home/test/after") + #expect(session.displayedWorkingDirectory == "~/after") + #expect(!session.canGenerateFromCurrentDraft) + session.receiveShellIntegrationEvent(.runtime(.init(status: .unavailable))) + #expect(session.workingDirectory == nil) + } + @Test(arguments: [false, true]) func paneHistoryInvalidationDoesNotDependOnSessionPersistence(managed: Bool) async { let session = SSHSessionModel(hostID: UUID(), title: "History") diff --git a/TFMTests/RemoteMultiplexerShellTests.swift b/TFMTests/RemoteMultiplexerShellTests.swift new file mode 100644 index 00000000..80587005 --- /dev/null +++ b/TFMTests/RemoteMultiplexerShellTests.swift @@ -0,0 +1,179 @@ +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) + } +} diff --git a/TFMTests/RemoteMultiplexerSmokeTests.swift b/TFMTests/RemoteMultiplexerSmokeTests.swift new file mode 100644 index 00000000..b0fff38f --- /dev/null +++ b/TFMTests/RemoteMultiplexerSmokeTests.swift @@ -0,0 +1,96 @@ +import Foundation +import TermindApplication +import TermindDomain +import TermindRemoteInfrastructure +import Testing + +/// Explicitly opt in with an isolated SSH fixture, never a normal user account. +@Suite(.enabled(if: ProcessInfo.processInfo.environment["TERMIND_MULTIPLEXER_FIXTURE"] != nil), .serialized) +@MainActor +struct RemoteMultiplexerSmokeTests { + private struct Fixture: Decodable { + let port: Int + let hostFingerprint: String + let privateKeyPath: String + } + + @Test(.timeLimit(.minutes(2)), arguments: SessionPersistenceBackendID.supported) + func nativeCreateRetryAttachDetachRestoreAndOwnedClose(backendID: SessionPersistenceBackendID) async throws { + let path = try #require(ProcessInfo.processInfo.environment["TERMIND_MULTIPLEXER_FIXTURE"]) + let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: URL(fileURLWithPath: path))) + let hostID = UUID() + let control = SSHPTYChannel(output: { _ in }, exit: { _ in }) + await control.configureStartup { _ in nil } + try await connect(control, fixture: fixture, hostID: hostID) + try await control.releaseTerminalForControl() + let execute = try await control.sessionCommandExecutor() + let backend = RemoteMultiplexerService(backend: backendID, execute: execute) + let token = UUID() + let marker = "/tmp/termind-multiplexer-smoke-\(token.uuidString)" + var owned: RemoteSessionReference? + var client: SSHPTYChannel? + do { + _ = try await backend.probe() + let before = try await backend.sessions() + let session = try await backend.create( + token: token, startupCommand: "cd /tmp; export TERMIND_SMOKE=yes; printf once >> '\(marker)'", + shellIntegration: true, environment: [.init(variable: "SHELL", value: "/bin/bash")]) + let reference = RemoteSessionReference( + backend: backendID, session: session, hostID: hostID, endpoint: "localhost:\(fixture.port)", + username: "root", ownershipToken: token) + owned = reference + let retried = try await backend.create( + token: token, startupCommand: "printf REPLAY >> '\(marker)'", shellIntegration: true) + #expect(backend.matches(reference, session: retried)) + let manual = RemoteSessionReference( + backend: backendID, session: session, hostID: hostID, endpoint: reference.endpoint, + username: reference.username, ownershipToken: nil) + await #expect(throws: RemoteSessionFailure.ownershipMismatch) { try await backend.terminate(manual) } + for _ in 0..<2 { + let terminal = SSHPTYChannel(output: { _ in }, exit: { _ in }) + client = terminal + await terminal.configureStartup { _ in + let launch = try backend.launch(reference, created: false) + return SSHPTYStartupPlan(command: launch.command, runtimeAttachment: launch.attachment) + } + try await connect(terminal, fixture: fixture, hostID: hostID) + try await Task.sleep(for: .milliseconds(500)) + #expect(try await backend.sessions().contains { backend.matches(reference, session: $0) }) + #expect(try await execute("cat '\(marker)'").stdout == "once") + await terminal.disconnect() + client = nil + #expect(try await backend.sessions().contains { backend.matches(reference, session: $0) }) + } + try await backend.terminate(reference) + owned = nil + let after = try await backend.sessions() + #expect(!after.contains { backend.matches(reference, session: $0) }) + // In particular, stopping/deleting our named Herdr session must not touch default. + for existing in before { + let external = RemoteSessionReference( + backend: backendID, session: existing, hostID: hostID, endpoint: reference.endpoint, + username: reference.username, ownershipToken: nil) + #expect(after.contains { backend.matches(external, session: $0) }) + } + _ = try await execute("rm -f '\(marker)'") + await control.disconnect() + } catch { + if let client { await client.disconnect() } + if let owned { try? await backend.terminate(owned) } + _ = try? await execute("rm -f '\(marker)'") + await control.disconnect() + throw error + } + } + + private func connect(_ channel: SSHPTYChannel, fixture: Fixture, hostID: UUID) async throws { + let key = try String(contentsOfFile: fixture.privateKeyPath, encoding: .utf8) + try await channel.connect( + using: SSHConnectionRequest( + configuration: .init( + hostID: hostID, host: "127.0.0.1", port: fixture.port, username: "root", + credentials: [.privateKey(privateKey: SSHSecret(key), publicKey: nil, passphrase: nil)], + expectedHostKeys: [.init(algorithm: .ed25519, fingerprint: fixture.hostFingerprint)])), + shellIntegrationEnabled: true, columns: 80, rows: 24) + } +} diff --git a/TFMTests/RemoteSessionWireTests.swift b/TFMTests/RemoteSessionWireTests.swift index a48a34e5..d870aa67 100644 --- a/TFMTests/RemoteSessionWireTests.swift +++ b/TFMTests/RemoteSessionWireTests.swift @@ -62,7 +62,7 @@ struct RemoteSessionWireTests { let second = SSHPTYChannel(output: { _ in }, exit: { _ in }) await second.configureStartup { execute in - let backend = RemoteTmuxService(execute: execute) + let backend = RemoteMultiplexerService(execute: execute) let before = try await backend.sessions() #expect(before.contains { backend.matches(reference, session: $0) && $0.clients == 1 }) let target = try RemoteTmuxCommands.quote(reference.session.id) @@ -76,7 +76,7 @@ struct RemoteSessionWireTests { try await connect(second, fixture: fixture, hostID: hostID) let observer = SSHPTYChannel(output: { _ in }, exit: { _ in }) await observer.configureStartup { execute in - let backend = RemoteTmuxService(execute: execute) + let backend = RemoteMultiplexerService(execute: execute) let sessions = try await backend.sessions() #expect(sessions.contains { backend.matches(reference, session: $0) && $0.clients == 2 }) return nil @@ -86,7 +86,7 @@ struct RemoteSessionWireTests { // Release the first terminal before executing control commands on its MaxSessions=1 transport. try await channel.releaseTerminalForControl() let execute = try await channel.sessionCommandExecutor() - let backend = RemoteTmuxService(execute: execute) + let backend = RemoteMultiplexerService(execute: execute) let listed = try await backend.sessions() #expect(listed.contains { backend.matches(reference, session: $0) && $0.clients == 1 }) #expect(try await execute("cat '\(marker)'").stdout == "once") @@ -136,7 +136,7 @@ struct RemoteSessionWireTests { printf '%s\\n' \(contents) > \(quotedDirectory)/.config/fish/config.fish """) #expect(setup.exitCode == 0) - let backend = RemoteTmuxService(execute: execute) + let backend = RemoteMultiplexerService(execute: execute) let token = UUID() let session = try await backend.create( token: token, @@ -242,7 +242,7 @@ struct RemoteSessionWireTests { await control.disconnect() return } - let backend = RemoteTmuxService(execute: execute) + let backend = RemoteMultiplexerService(execute: execute) #expect(try await backend.sessions().contains { backend.matches(reference, session: $0) }) try await connect(channel, fixture: fixture, hostID: hostID) #expect(coordinator.attachment?.hasSameIdentity(as: reference) == true) @@ -260,7 +260,7 @@ struct RemoteSessionWireTests { let coordinator = RemoteSessionCoordinator() let control = SSHPTYChannel(output: { _ in }, exit: { _ in }) await control.configureStartup { execute in - let backend = RemoteTmuxService(execute: execute) + let backend = RemoteMultiplexerService(execute: execute) let token = UUID() let session = try await backend.create(token: token, startupCommand: nil, shellIntegration: true) let reference = RemoteSessionReference( @@ -294,7 +294,7 @@ struct RemoteSessionWireTests { let vimAfter = try await execute("tmux capture-pane -p -t \(target)").stdout #expect(vimAfter == vimBefore) await client.write(Data([0x02, 0x64])) - let backend = RemoteTmuxService(execute: execute) + let backend = RemoteMultiplexerService(execute: execute) var detached = false for _ in 0..<100 { detached = try await backend.sessions().contains { $0.id == reference.session.id && $0.clients == 0 } diff --git a/TFMTests/TerminalActivationWireTests.swift b/TFMTests/TerminalActivationWireTests.swift index 1247ebae..bddc9a96 100644 --- a/TFMTests/TerminalActivationWireTests.swift +++ b/TFMTests/TerminalActivationWireTests.swift @@ -65,7 +65,7 @@ struct TerminalActivationWireTests { let identity = try first.identity() _ = try await fixture.attach(columns: 40, rows: 22, integrated: false) try await fixture.expectSize(columns: 40, rows: 22) - let adapter = RemoteTmuxRuntimeAdapter(execute: fixture.execute) + let adapter = RemoteTmuxRuntimeDialect(execute: fixture.execute) for field in ["terminal", "process", "created", "server", "session", "sessionCreated"] { let terminal = field == "terminal" ? "/dev/pts/99999" : identity.terminal var update = first.update @@ -128,7 +128,7 @@ struct TerminalActivationWireTests { try await TmuxActivationFixture.wait { first.context?.capabilities.contains(.readDraft) == true } #expect(try await first.draft() == "echo draft-not-executed") - let adapter = RemoteTmuxRuntimeAdapter(execute: fixture.execute) + let adapter = RemoteTmuxRuntimeDialect(execute: fixture.execute) for flag in ["ignore-size", "read-only", "active-pane"] { _ = try await fixture.command("tmux refresh-client -t \(tty) -f \(flag)") await #expect(throws: TerminalRuntimeError.self) { try await adapter.refreshSize(of: identity) } @@ -292,7 +292,7 @@ final class TmuxActivationFixture { try await fixture.control.releaseTerminalForControl() fixture.execute = try await fixture.control.sessionCommandExecutor() let token = UUID() - let session = try await RemoteTmuxService(execute: fixture.execute).create( + let session = try await RemoteMultiplexerService(execute: fixture.execute).create( token: token, startupCommand: nil, shellIntegration: true, environment: [.init(variable: "SHELL", value: "/bin/\(shell)")]) fixture.reference = .init( @@ -382,7 +382,7 @@ final class TmuxActivationFixture { func close() async { for client in clients { await client.channel.disconnect() } - if let reference, let execute { try? await RemoteTmuxService(execute: execute).terminate(reference) } + if let reference, let execute { try? await RemoteMultiplexerService(execute: execute).terminate(reference) } await control.disconnect() } } diff --git a/TFMTests/TerminalContextRuntimeTests.swift b/TFMTests/TerminalContextRuntimeTests.swift index 2d4e1662..32b8a13e 100644 --- a/TFMTests/TerminalContextRuntimeTests.swift +++ b/TFMTests/TerminalContextRuntimeTests.swift @@ -47,7 +47,7 @@ struct TerminalContextRuntimeTests { """ await channel.configureStartup { execute in _ = try await execute(setup) - let backend = RemoteTmuxService(execute: execute) + let backend = RemoteMultiplexerService(execute: execute) let owner = UUID() let session = try await backend.create( token: owner, startupCommand: nil, shellIntegration: true, @@ -101,7 +101,7 @@ struct TerminalContextRuntimeTests { let channel = makeChannel(state) var stage = "connect" await channel.configureStartup { execute in - let backend = RemoteTmuxService(execute: execute) + let backend = RemoteMultiplexerService(execute: execute) let owner = UUID() let session = try await backend.create( token: owner, startupCommand: "cd /tmp", shellIntegration: true, @@ -200,7 +200,8 @@ struct TerminalContextRuntimeTests { stage = "unsafe modes" _ = try await execute("tmux copy-mode -t '\(second)'") try await wait { state.coordinator.active?.capabilities.contains(.readDraft) == false } - #expect(state.coordinator.directory == nil) + #expect(state.coordinator.directory == state.coordinator.active?.directory) + #expect(state.coordinator.ticket(requiring: .directory) == nil) _ = try await execute( "tmux send-keys -t '\(second)' -X cancel; tmux set-window-option -t '\(second)' synchronize-panes on") try await Task.sleep(for: .seconds(1)) @@ -241,7 +242,7 @@ struct TerminalContextRuntimeTests { state.coordinator.reset() state.events.removeAll() await channel.configureStartup { execute in - let backend = RemoteTmuxService(execute: execute) + let backend = RemoteMultiplexerService(execute: execute) try backend.validate(reference) return try SSHPTYStartupPlan(command: backend.launch(reference, created: false).command) } @@ -283,12 +284,22 @@ struct TerminalContextRuntimeTests { await #expect(throws: TerminalRuntimeError.self) { try await RemoteShellHistoryLoader(channel: channel, charset: .utf8).load(source: nil) } + stage = "ordinary running cwd" + let cwd = try #require(state.coordinator.directory) + let invalidations = state.directoryInvalidations + await channel.write(Data("sleep 2\n".utf8)) + try await wait { state.coordinator.active?.promptReady == false } + #expect(state.coordinator.directory == cwd) + #expect(state.coordinator.ticket(requiring: .replaceDraft) == nil) + try await wait { state.coordinator.active?.promptReady == true } + #expect(state.directoryInvalidations == invalidations) + #expect(state.coordinator.directory == cwd) let execute = try await channel.runtimeCommandExecutor() _ = try await execute( "mkdir -p \(try RemoteTmuxCommands.quote(root + "/fish")); printf %s " + "\(try RemoteTmuxCommands.quote(historyContents("echo manual-pane", shell: shell)))" + " > \(try RemoteTmuxCommands.quote(path))") - let backend = RemoteTmuxService(execute: execute) + let backend = RemoteMultiplexerService(execute: execute) let owner = UUID() let session = try await backend.create( token: owner, startupCommand: "cd /tmp", shellIntegration: true, @@ -375,7 +386,7 @@ struct TerminalContextRuntimeTests { let state = State() let channel = makeChannel(state) await channel.configureStartup { execute in - let backend = RemoteTmuxService(execute: execute) + let backend = RemoteMultiplexerService(execute: execute) let owner = UUID() let session = try await backend.create(token: owner, startupCommand: nil, shellIntegration: true) let reference = RemoteSessionReference( @@ -430,7 +441,7 @@ struct TerminalContextRuntimeTests { else rm -f /root/.bash_profile; fi """ await channel.configureStartup { execute in - let backend = RemoteTmuxService(execute: execute) + let backend = RemoteMultiplexerService(execute: execute) let owner = UUID() let session = try await backend.create( token: owner, startupCommand: "exec vim -n -u NONE -i NONE", shellIntegration: true, @@ -496,7 +507,7 @@ struct TerminalContextRuntimeTests { try await channel.releaseTerminalForControl() if let reference = state.reference { let execute = try await channel.sessionCommandExecutor() - try await RemoteTmuxService(execute: execute).terminate(reference) + try await RemoteMultiplexerService(execute: execute).terminate(reference) } await channel.disconnect() } diff --git a/TFMTests/TerminalContextWireTests.swift b/TFMTests/TerminalContextWireTests.swift index 933b9698..15f7c427 100644 --- a/TFMTests/TerminalContextWireTests.swift +++ b/TFMTests/TerminalContextWireTests.swift @@ -49,14 +49,14 @@ struct TerminalContextWireTests { BEGIN """ let text = prefix + "\n" + record() + "END\nDONE\n" - let update = try RemoteTmuxRuntimeAdapter.parse(text, terminal: "/dev/pts/1") + let update = try RemoteTmuxRuntimeDialect.parse(text, terminal: "/dev/pts/1") #expect(update.active?.pane == "%2") #expect(update.contexts.first { $0.identity == update.active }?.capabilities.isEmpty == true) #expect(throws: (any Error).self) { - try RemoteTmuxRuntimeAdapter.parse(text, terminal: "/dev/pts/other") + try RemoteTmuxRuntimeDialect.parse(text, terminal: "/dev/pts/other") } let independent = text.replacingOccurrences(of: "|%2||root", with: "|%2|active-pane|root") - #expect(try RemoteTmuxRuntimeAdapter.parse(independent, terminal: "/dev/pts/1").status == .unavailable) + #expect(try RemoteTmuxRuntimeDialect.parse(independent, terminal: "/dev/pts/1").status == .unavailable) } @Test func connectionScopedFramesSurviveEverySplitWithoutLeakingControlText() throws { @@ -72,6 +72,51 @@ struct TerminalContextWireTests { } } + @Test @MainActor + func ordinaryCommandsRetainCwdWhileProbingForManualAttach() throws { + var filter = RemoteTerminalContextFilter(token: token) + let coordinator = TerminalContextCoordinator() + func receive(_ record: String) throws -> TerminalRuntimeUpdate { + let frame = Data("\u{1B}]777;TermindContext=\(token);\(record)\u{7}".utf8) + let update = try #require(filter.receive(frame).contexts.first) + coordinator.receive(update, generation: coordinator.generation) + return update + } + let prompt = try receive(record(pane: "shell")) + #expect(!RemoteTerminalContextFilter.needsObservation(prompt)) + let draft = try #require(coordinator.ticket(requiring: .replaceDraft)) + let running = try receive( + record(pane: "shell").replacingOccurrences(of: "R1|2|1|", with: "R1|3|0|") + + "E1|3|start|sleep%2010|\n") + #expect(RemoteTerminalContextFilter.needsObservation(running)) + #expect(running.status == .ordinary) + #expect(coordinator.directory == "/tmp/a|b") + #expect(!coordinator.isCurrent(draft, requiring: .replaceDraft)) + #expect(coordinator.ticket(requiring: .directory) == nil) + for status in [TerminalRuntimeUpdate.Status.unknown, .unavailable] { + let observed = RemoteTerminalContextFilter.reconcileObservation(.init(status: status), current: running) + #expect(observed.events.isEmpty) + coordinator.receive(observed, generation: coordinator.generation) + #expect(coordinator.directory == "/tmp/a|b") + #expect(coordinator.active?.promptReady == false) + #expect(coordinator.ticket(requiring: .replaceDraft) == nil) + } + + let attached = TerminalRuntimeUpdate(status: .multiplexer) + let switched = RemoteTerminalContextFilter.reconcileObservation(attached, current: running) + coordinator.receive(switched, generation: coordinator.generation) + #expect(coordinator.directory == nil) + let unavailable = RemoteTerminalContextFilter.reconcileObservation( + .init(status: .unavailable), current: attached) + #expect(unavailable.status == .unavailable) + #expect(unavailable.contexts.isEmpty) + let detached = try receive( + record(pane: "shell", sequence: 5) + "E1|3|start|sleep%2010|\nE1|4|finish|0|10000\nE1|5|prompt||\n") + #expect(!RemoteTerminalContextFilter.needsObservation(detached)) + #expect(coordinator.directory == "/tmp/a|b") + #expect(coordinator.ticket(requiring: .replaceDraft) != nil) + } + @Test func runtimeBudgetNeverQueuesBehindATerminal() async throws { let budget = SSHChannelBudget(maximum: 1) let terminal = try await budget.acquire() @@ -110,13 +155,33 @@ struct TerminalContextWireTests { text.replacingOccurrences(of: "P|%1|123|0|0|bash", with: "P|%1|456|0|0|bash"), text.replacingOccurrences(of: "P|%1|123|0|0|bash", with: "P|%1|123|0|0|unknown"), ] { - let update = try RemoteTmuxRuntimeAdapter.parse(unsafe, terminal: "/dev/pts/1") + let update = try RemoteTmuxRuntimeDialect.parse(unsafe, terminal: "/dev/pts/1") let active = try #require(update.contexts.first { $0.identity == update.active }) #expect(!active.capabilities.contains(.directory)) #expect(!active.capabilities.contains(.replaceDraft)) + #expect(active.directory == (active.identity.shellInstance.hasPrefix("unintegrated:") ? nil : "/tmp/a|b")) } } + @Test @MainActor + func tmuxForegroundCommandsKeepTheShellDirectoryButNotDraftCapabilities() throws { + let header = """ + tmux 3.3a + C|/dev/pts/1|555|100|777|$0|99|%1||root + P|%1|123|0|0|unknown + BEGIN + """ + let journal = record().replacingOccurrences(of: "R1|2|1|", with: "R1|3|0|") + "E1|3|start|sleep%2010|\n" + let update = try RemoteTmuxRuntimeDialect.parse(header + "\n" + journal + "END\nDONE\n", terminal: "/dev/pts/1") + let coordinator = TerminalContextCoordinator() + coordinator.receive(update, generation: coordinator.generation) + #expect(coordinator.directory == "/tmp/a|b") + #expect(coordinator.active?.promptReady == false) + #expect(coordinator.ticket(requiring: .readDraft) == nil) + #expect(coordinator.ticket(requiring: .replaceDraft) == nil) + #expect(coordinator.ticket(requiring: .directory) == nil) + } + @Test @MainActor func prefixModePreservesDirectoryButInvalidatesDraftOperations() throws { let header = """ @@ -127,11 +192,11 @@ struct TerminalContextWireTests { """ let text = header + "\n" + record() + "END\nDONE\n" let coordinator = TerminalContextCoordinator() - let ordinary = try RemoteTmuxRuntimeAdapter.parse(text, terminal: "/dev/pts/1") + let ordinary = try RemoteTmuxRuntimeDialect.parse(text, terminal: "/dev/pts/1") coordinator.receive(ordinary, generation: coordinator.generation) let draft = try #require(coordinator.ticket(requiring: .replaceDraft)) let identity = coordinator.active?.identity - let prefix = try RemoteTmuxRuntimeAdapter.parse( + let prefix = try RemoteTmuxRuntimeDialect.parse( text.replacingOccurrences(of: "|%1||root", with: "|%1||prefix"), terminal: "/dev/pts/1") coordinator.receive(prefix, generation: coordinator.generation)