Files
Termind/FrameworkTests/RemoteMultiplexerShellTests.swift
T
Kaede Akino e457e9af98 fix(multiplexer): preserve session ownership and safe shell startup
Detach managed sessions when native mobile windows are discarded, retain
live reopen references, and fence ordinary startup commands by input and
session generation. Handle noexec helpers, isolate tmux discovery locale,
and count only verified live contexts against the journal limit.

Move actual shell execution coverage into the hostless framework suite.
Validation: 76 focused macOS tests passed; iOS multiplexer regressions
passed with the inherited hosted-test artwork failure still present.
2026-10-17 09:50:37 +08:00

458 lines
26 KiB
Swift

#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