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)