Files
Termind/FrameworkTests/RemoteMultiplexerShellTests.swift
T

461 lines
26 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
#if os(macOS)
import Foundation
import TermindApplication
import TermindDomain
import Testing
@testable import TermindRemoteInfrastructure
@Suite
nonisolated struct RemoteMultiplexerShellTests {
private static let shells = ["/bin/bash", "/bin/zsh", "/opt/homebrew/bin/fish"]
.filter { FileManager.default.isExecutableFile(atPath: $0) }
@Test(arguments: [
("100 100 100 pts/4 /bin/sh\n", true),
("100 100 100 pts/4 -ksh\n", true),
("100 100 100 pts/4 tmux\n", false),
("100 100 200 pts/4 bash\n200 200 200 pts/4 tmux\n", false),
("100 100 100 pts/4 bash\n200 100 100 pts/4 vim\n", false),
("100 100 100 pts/4 bash\n200 100 100 pts/4 zsh\n", false),
("100 100 100 pts/5 sh\n", false),
("100 100 100 pts/4 unknown\n", false),
("malformed\n", false),
("", false),
])
func fallbackRequiresOneVerifiedForegroundShell(value: (String, Bool)) async throws {
let fixture = try TmuxFixture()
defer { fixture.cleanUp() }
let probe = RemoteTerminalProcessProbe { command in
try Self.run(
"ps() { printf '%s' \(try RemoteMultiplexerCommands.quote(value.0)); }\n" + command,
home: fixture.home.path)
}
#expect(try await probe.isOrdinaryShell(terminal: "/dev/pts/4") == value.1)
}
@Test(arguments: [false, true])
func tmuxCreationPreservesTheInteractiveLocale(explicitLocale: Bool) throws {
let fixture = try TmuxFixture()
defer { fixture.cleanUp() }
let result = try fixture.run(
"""
export STUB_EMPTY=1 LANG=en_US.UTF-8
\(explicitLocale ? "export LC_ALL=C" : "unset LC_ALL")
\(try RemoteTmuxCommands.create(token: fixture.owner, startupCommand: nil, shellIntegration: true))
code=$?
rm -rf "$helper"
exit "$code"
""")
#expect(result.exitCode == 0, "\(result.stdout)")
let locale = try String(contentsOf: fixture.home.appendingPathComponent("locale"), encoding: .utf8)
#expect(locale == (explicitLocale ? "x|C|en_US.UTF-8" : "||en_US.UTF-8"))
#expect(FileManager.default.fileExists(atPath: fixture.home.appendingPathComponent("created").path))
}
@Test func ordinaryStartupRunsReadableNonExecutableHelpersWithAnUnsupportedShell() throws {
let fixture = try TmuxFixture()
defer { fixture.cleanUp() }
let install = try RemoteShellStartup.install(
token: UUID(), startupCommand: nil, shellIntegration: true, ordinary: true
)
.replacingOccurrences(of: "/tmp/termind-shell.XXXXXXXX", with: fixture.home.path + "/helper.XXXXXXXX")
let installation = try Self.run(install + "\nprintf '%s' \"$helper\"", home: fixture.home.path)
#expect(installation.exitCode == 0)
let helper = installation.stdout
for name in ["shell", "first"] {
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: helper + "/" + name)
}
let input = "printf usable > \"$HOME/usable\"; exit\n"
for launch in [
try RemoteShellStartup.ordinaryCommand(helper: helper),
"export TERMIND_TMUX_HELPERS=\(try RemoteMultiplexerCommands.quote(helper)); exec /bin/sh \"$TERMIND_TMUX_HELPERS/first\"",
] {
let result = try Self.run(
"""
export TERMIND_TMUX_SHELL=/bin/sh
printf '%s' \(try RemoteMultiplexerCommands.quote(input)) | /bin/sh -c \(try RemoteMultiplexerCommands.quote(launch))
""", home: fixture.home.path)
#expect(result.exitCode == 0, "\(result.stdout)")
#expect(
try String(contentsOf: fixture.home.appendingPathComponent("usable"), encoding: .utf8) == "usable")
try FileManager.default.removeItem(at: fixture.home.appendingPathComponent("usable"))
}
}
@Test func managedHelperExecutionFailurePrecedesNativeCreation() throws {
let fixture = try TmuxFixture()
defer { fixture.cleanUp() }
let create = try RemoteTmuxCommands.create(
token: fixture.owner, startupCommand: nil, shellIntegration: true
)
.replacingOccurrences(
of: "chmod 700 \"$helper/shell\" \"$helper/first\"",
with: "chmod 600 \"$helper/shell\" \"$helper/first\"")
let result = try fixture.run("export STUB_EMPTY=1\n" + create)
#expect(result.exitCode == 78)
#expect(!FileManager.default.fileExists(atPath: fixture.home.appendingPathComponent("created").path))
}
@Test func everyTmuxOperationResolvesTheExecutableOutsidePATH() throws {
let fixture = try TmuxFixture()
defer { fixture.cleanUp() }
try fixture.journals(stale: 0, live: 1, backend: "tmux")
let probe = try fixture.run(RemoteMultiplexerCommands.discovery(.tmux) + "\n\"$exe\" -V")
#expect(probe.stdout == "tmux 3.4\n")
let listing = try fixture.run(RemoteTmuxCommands.list)
let session = try #require(try RemoteTmuxCommands.parseSessions(listing.stdout).first)
let reference = RemoteSessionReference(
backend: .tmux, session: session, hostID: UUID(), endpoint: "test:22", username: "user",
ownershipToken: fixture.owner)
let create = try fixture.run(
RemoteTmuxCommands.create(token: fixture.owner, startupCommand: nil, shellIntegration: true))
#expect(try RemoteTmuxCommands.parseSessions(create.stdout).first?.id == session.id)
#expect(try fixture.run(RemoteTmuxCommands.attach(reference)).stdout == "TERMIND_ATTACHED\n")
#expect(try fixture.run(RemoteTmuxCommands.terminate(reference)).stdout == "TERMIND_ENDED\n")
let runtime = try fixture.run(RemoteTmuxRuntimeDialect.command(terminal: "/dev/pts/4"))
let update = try RemoteTmuxRuntimeDialect.parse(runtime.stdout, terminal: "/dev/pts/4")
let context = try #require(update.contexts.first)
#expect(context.capabilities.contains(.replaceDraft))
let client = try #require(RemoteTmuxClientIdentity(update: update, terminal: "/dev/pts/4"))
let resize = try fixture.run(
"kill() { [ \"$1:$2\" = '-WINCH:300' ]; }\n" + RemoteTmuxRuntimeDialect.resizeCommand(client: client))
#expect(resize.stdout == "TERMIND_RESIZED\n")
let request = UUID()
let draft = try fixture.run(
RemoteTerminalDraft.command(
context: context, terminal: "/dev/pts/4", helper: nil, clientIdentity: update.clientIdentity,
request: request, replacement: "echo draft"))
#expect(draft.exitCode == 0)
let requested = try String(
contentsOf: fixture.helper.appendingPathComponent("ctx.live0/request"), encoding: .utf8)
#expect(requested.hasPrefix(request.uuidString + "|1|replace|"))
let journal = fixture.helper.appendingPathComponent("ctx.live0/journal")
let updatedJournal = try String(contentsOf: journal, encoding: .utf8)
.replacingOccurrences(of: "R1|1|1|", with: "R1|2|1|")
try (updatedJournal + "E1|2|draft|%64%72%61%66%74|\(request)\n")
.write(to: journal, atomically: true, encoding: .utf8)
let result = try fixture.run(
RemoteTerminalDraft.resultCommand(context: context, helper: nil, request: request))
let record = try RemoteShellContextRecord(result.stdout)
#expect(record.entries.last?.1 == .draft(request: request.uuidString, value: "draft"))
}
@Test(arguments: [1, 129])
func tmuxJournalLimitCountsOnlyLivePaneProcesses(live: Int) throws {
let fixture = try TmuxFixture()
defer { fixture.cleanUp() }
try fixture.journals(stale: 129, live: live, backend: "tmux")
let result = try fixture.run(
"export STUB_PANES=\(live)\n" + RemoteTmuxRuntimeDialect.command(terminal: "/dev/pts/4"))
if live > 128 {
#expect(result.exitCode == 78)
} else {
#expect(result.exitCode == 0, "\(result.stdout)")
let update = try RemoteTmuxRuntimeDialect.parse(result.stdout, terminal: "/dev/pts/4")
#expect(update.contexts.count == 1)
#expect(update.contexts.first?.capabilities.contains(.replaceDraft) == true)
#expect(update.active?.shellInstance == "ctx.live0")
}
}
@Test(arguments: [1, 129])
func socketJournalLimitCountsOnlyValidatedProcesses(live: Int) throws {
let fixture = try TmuxFixture()
defer { fixture.cleanUp() }
try fixture.journals(stale: 129, live: live, backend: "zmx")
let result = try fixture.run(
"""
helper="$STUB_HELPER"
shell_check() { [ "$1" -ge 500 ]; }
foreground_shell() { shell_check "$@"; }
\(RemoteSocketMultiplexerRuntime.journalCommand(backend: .zmx))
""")
if live > 128 {
#expect(result.exitCode == 78)
} else {
#expect(result.exitCode == 0)
let lines = result.stdout.components(separatedBy: "\n")
#expect(lines.first == "BEGIN|1")
#expect(lines.filter { $0 == "BEGIN|1" }.count == 1)
let record = try RemoteShellContextRecord(lines[1...3].joined(separator: "\n"))
#expect(record.instance == "ctx.live0")
#expect(record.ready)
}
}
@Test(arguments: ["shell", "tmux", "zellij", "zmx", "herdr"], shells)
func shellHooksKeepCwdInTheJournalDuringCommands(backend: String, shell: String) throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent("termind-cwd-test-\(UUID())")
try FileManager.default.createDirectory(
at: root.appendingPathComponent("next"), withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let install = try RemoteShellStartup.install(
token: UUID(), startupCommand: "cd \"$HOME\"", shellIntegration: true,
backend: ["shell", "tmux"].contains(backend) ? nil : backend
).replacingOccurrences(
of: "/tmp/termind-shell.XXXXXXXX",
with: root.appendingPathComponent("termind-shell.XXXXXXXX").path
)
let input = """
cat "$__termind_context_dir/journal" > "$HOME/during"
cd "$HOME/next"
cat "$__termind_context_dir/journal" > "$HOME/after"
exit
"""
let result = try Self.run(
"""
\(install)
export TERMIND_TMUX_HELPERS="$helper" TERMIND_TMUX_SHELL=\(try RemoteMultiplexerCommands.quote(shell))
unset TMUX_PANE ZELLIJ_PANE_ID HERDR_PANE_ID
\(backend == "tmux" ? "export TMUX_PANE=%1" : "")
export ZELLIJ_PANE_ID=1 HERDR_PANE_ID=1
printf '%s\\n' \(try RemoteMultiplexerCommands.quote(input)) | /bin/sh "$helper/first"
""", home: root.path)
#expect(result.exitCode == 0, "\(result.stdout)")
let during = try RemoteShellContextRecord(
String(contentsOf: root.appendingPathComponent("during"), encoding: .utf8))
let after = try RemoteShellContextRecord(
String(contentsOf: root.appendingPathComponent("after"), encoding: .utf8))
#expect(during.backend == backend)
#expect(!during.ready)
#expect(!after.ready)
#expect(during.directory == root.resolvingSymlinksInPath().path)
#expect(after.directory == root.appendingPathComponent("next").resolvingSymlinksInPath().path)
#expect(after.sequence > during.sequence)
}
@Test(arguments: SessionPersistenceBackendID.supported, shells)
func startupSnippetRunsOnlyInFirstShellAndPreservesUserConfiguration(
backend: SessionPersistenceBackendID, shell: String
) throws {
for integration in [false, true] {
let root = FileManager.default.temporaryDirectory.appendingPathComponent("termind-shell-test-\(UUID())")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let name = URL(fileURLWithPath: shell).lastPathComponent
let userConfig = name == "zsh" ? ".zshrc" : ".bash_profile"
try "export USER_CONFIGURATION=preserved\n".write(
to: root.appendingPathComponent(userConfig), atomically: true, encoding: .utf8)
if name == "fish" {
let config = root.appendingPathComponent(".config/fish")
try FileManager.default.createDirectory(at: config, withIntermediateDirectories: true)
try "set -gx USER_CONFIGURATION preserved\n".write(
to: config.appendingPathComponent("config.fish"), atomically: true, encoding: .utf8)
}
let startup =
name == "fish"
? "cd \"$HOME\"; set -gx FROM_SNIPPET yes; printf once >> \"$HOME/once\""
: "cd \"$HOME\"; export FROM_SNIPPET=yes; printf once >> \"$HOME/once\""
let install = try RemoteShellStartup.install(
token: UUID(), startupCommand: startup, shellIntegration: integration,
backend: backend == .tmux ? nil : backend.rawValue,
environment: [.init(variable: "SESSION_ONLY", value: "host-value")]
)
.replacingOccurrences(
of: "/tmp/termind-shell.XXXXXXXX",
with: root.appendingPathComponent("termind-shell.XXXXXXXX").path
)
let firstInput = """
printf '%s|%s|%s|%s\\n' "$PWD" "$FROM_SNIPPET" "$USER_CONFIGURATION" "$SESSION_ONLY" > "$HOME/first-result"
exit
"""
let secondInput = """
printf '%s\\n' "$USER_CONFIGURATION" > "$HOME/second-result"
exit
"""
let script = """
\(install)
trap 'rm -rf "$helper"' EXIT
[ -z "${SESSION_ONLY:-}" ] || exit 83
export TERMIND_TMUX_HELPERS="$helper" TERMIND_TMUX_SHELL=\(try RemoteMultiplexerCommands.quote(shell))
export TMUX_PANE=%1 ZELLIJ_PANE_ID=1 HERDR_PANE_ID=1
printf '%s\\n' \(try RemoteMultiplexerCommands.quote(firstInput)) | /bin/sh "$helper/first"
printf '%s\\n' \(try RemoteMultiplexerCommands.quote(secondInput)) | /bin/sh "$helper/shell"
"""
let result = try Self.run(script, home: root.path)
#expect(result.exitCode == 0, "\(result.stderr)\n\(result.stdout)")
#expect(try String(contentsOf: root.appendingPathComponent("once"), encoding: .utf8) == "once")
let first = try String(contentsOf: root.appendingPathComponent("first-result"), encoding: .utf8)
#expect(first.hasSuffix("|yes|preserved|host-value\n"), "\(first)")
#expect(
try String(contentsOf: root.appendingPathComponent("second-result"), encoding: .utf8)
== "preserved\n")
#expect(
try String(contentsOf: root.appendingPathComponent(userConfig), encoding: .utf8)
== "export USER_CONFIGURATION=preserved\n")
}
}
@Test(arguments: shells)
func cancelledStartupCannotRunLaterWhenUserConfigurationFinishes(shell: String) throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent("termind-cancel-test-\(UUID())")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let install = try RemoteShellStartup.install(
token: UUID(), startupCommand: "printf late > \"$HOME/late\"", shellIntegration: true, backend: "zmx"
).replacingOccurrences(
of: "/tmp/termind-shell.XXXXXXXX", with: root.appendingPathComponent("termind-shell.XXXXXXXX").path)
let result = try Self.run(
"""
\(install)
export TERMIND_TMUX_HELPERS="$helper" TERMIND_TMUX_SHELL=\(try RemoteMultiplexerCommands.quote(shell))
mkdir "$helper/startup-decision"
printf 'printf ready > "$HOME/ready"\\nexit\\n' | /bin/sh "$helper/shell" || exit $?
[ ! -e "$helper/started" ]
""", home: root.path)
#expect(result.exitCode == 0, "\(result.stdout)")
#expect(try String(contentsOf: root.appendingPathComponent("ready"), encoding: .utf8) == "ready")
#expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent("late").path))
}
@Test func expiredAndMismatchedDraftRequestsAreConsumedWithoutReturningPayload() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent("termind-draft-test-\(UUID())")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let state = root.appendingPathComponent("state")
let request = root.appendingPathComponent("request")
let reader = root.appendingPathComponent("reader")
try "R1|10|1|%2Ftmp\n".write(to: state, atomically: true, encoding: .utf8)
try RemoteShellContextJournal.requestReader.write(to: reader, atomically: true, encoding: .utf8)
for (sequence, deadline) in [("10", "1"), ("9", String(Int(Date().timeIntervalSince1970) + 60))] {
try "\(UUID())|\(sequence)|replace|%65%63%68%6F|\(deadline)|-\n".write(
to: request, atomically: true, encoding: .utf8)
let result = try Self.run(
"sh \(RemoteMultiplexerCommands.quote(reader.path)) \(RemoteMultiplexerCommands.quote(root.path)) replace",
home: root.path)
#expect(result.exitCode != 0)
#expect(result.stdout.isEmpty)
#expect(!FileManager.default.fileExists(atPath: request.path))
}
}
private struct TmuxFixture {
let home = FileManager.default.temporaryDirectory.appendingPathComponent("termind-tmux-test-\(UUID())")
let helper = URL(
fileURLWithPath: "/tmp/termind-shell." + UUID().uuidString.replacingOccurrences(of: "-", with: ""))
let owner = UUID()
let token = UUID()
init() throws {
let bin = home.appendingPathComponent(".local/bin")
try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: helper, withIntermediateDirectories: true)
try token.uuidString.write(
to: helper.appendingPathComponent("event-token"), atomically: true, encoding: .utf8)
let executable = bin.appendingPathComponent("tmux")
try Self.stub.write(to: executable, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: executable.path)
}
func cleanUp() {
try? FileManager.default.removeItem(at: home)
try? FileManager.default.removeItem(at: helper)
}
func run(_ command: String) throws -> RemoteSessionCommandResult {
// Do not allow fixture execution to discover a real multiplexer installed on the test host.
let command = command.replacingOccurrences(
of: "/opt/homebrew/bin /usr/local/bin /usr/bin /snap/bin \"$HOME/.local/bin\" \"$HOME/.cargo/bin\"",
with: "\"$HOME/.local/bin\"")
return try RemoteMultiplexerShellTests.run(
"""
export PATH=/usr/bin:/bin
export STUB_HELPER=\(try RemoteMultiplexerCommands.quote(helper.path)) STUB_OWNER='\(owner)'
\(command)
""", home: home.path)
}
func journals(stale: Int, live: Int, backend: String) throws {
for (prefix, count, firstPID) in [("stale", stale, 10), ("live", live, 500)] {
for index in 0..<count {
let name = "ctx.\(prefix)\(index)"
let directory = helper.appendingPathComponent(name)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let pane = backend == "tmux" ? "%\(index + 1)" : "shell"
let metadata =
backend == "tmux"
? "M1|\(token)|\(pane)|\(name)|bash|%2Fhome|\(firstPID + index)|"
: "M2|\(token)|\(pane)|\(name)|bash|%2Fhome|\(firstPID + index)||\(backend)"
try "\(metadata)\nR1|1|1|%2Fwork\nE1|1|prompt||\n".write(
to: directory.appendingPathComponent("journal"), atomically: true, encoding: .utf8)
try "R1|1|1|%2Fwork\n".write(
to: directory.appendingPathComponent("state"), atomically: true, encoding: .utf8)
}
}
}
private static let stub = #"""
#!/bin/sh
printf '%s\n' "$*" >> "$HOME/calls"
for argument do format=$argument; done
case "$1" in
-V) printf 'tmux 3.4\n' ;;
list-sessions) [ "${STUB_EMPTY:-}" = 1 ] || printf '$1\n' ;;
new-session)
: > "$HOME/created"
printf '%s|%s|%s' "${LC_ALL+x}" "${LC_ALL-}" "${LANG-}" > "$HOME/locale"
printf '$1\n' ;;
set-option) ;;
show-environment)
case "$4" in
TERMIND_TMUX_OWNER) printf 'TERMIND_TMUX_OWNER=%s\n' "$STUB_OWNER" ;;
TERMIND_TMUX_HELPERS) printf 'TERMIND_TMUX_HELPERS=%s\n' "$STUB_HELPER" ;;
TERMIND_TMUX_SHELL) printf 'TERMIND_TMUX_SHELL=/bin/bash\n' ;;
*) exit 90 ;;
esac ;;
display-message)
case "$format" in
'#{session_name}') printf 'build\n' ;;
'#{pid}:#{session_created}') printf '200:100\n' ;;
'#{pane_current_path}') printf '/work\n' ;;
'#{pane_current_command}') printf 'bash\n' ;;
*session_id*) printf '$1 100 200 1 1\n' ;;
*) printf '1\n' ;;
esac ;;
list-clients)
case "$format" in
C*) printf 'C|/dev/pts/4|300|400|200|$1|100|%%1||root\n' ;;
*client_key_table*) printf '/dev/pts/4|300|400|%%1||root|$1|100\n' ;;
*) printf '/dev/pts/4|300|400|200|$1|100|\n' ;;
esac ;;
list-panes)
i=0
while [ "$i" -lt "${STUB_PANES:-1}" ]; do
printf 'P|%%%s|%s|0|0|bash\n' "$((i + 1))" "$((i + 500))"
i=$((i + 1))
done ;;
if-shell)
case "$*" in
*kill-session*) printf 'TERMIND_ENDED\n' ;;
*attach-session*) printf 'TERMIND_ATTACHED\n' ;;
esac ;;
*) exit 91 ;;
esac
"""#
}
static func run(_ command: String, home: String) throws -> RemoteSessionCommandResult {
let process = Process()
let output = Pipe()
process.executableURL = URL(fileURLWithPath: "/bin/sh")
process.arguments = ["-c", command]
process.environment = ProcessInfo.processInfo.environment.merging([
"HOME": home, "XDG_CONFIG_HOME": home + "/.config", "XDG_DATA_HOME": home + "/.local/share",
"ZDOTDIR": home, "LC_ALL": "C",
]) { _, new in new }
process.standardOutput = output
process.standardError = output
try process.run()
let data = output.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
return .init(stdout: String(decoding: data, as: UTF8.self), stderr: "", exitCode: process.terminationStatus)
}
}
#endif