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.
This commit is contained in:
Kaede Akino committed 2026-10-17 09:50:37 +08:00
1 parent 01530326eb
commit e457e9af98
21 files changed
+931 -266

No files matched your search

@@ -0,0 +1,457 @@
#if os(macOS)
import Foundation
import TermindApplication
import TermindDomain
import Testing
@testable import TermindRemoteInfrastructure
@Suite
nonisolated struct RemoteMultiplexerShellTests {
private static let shells = ["/bin/bash", "/bin/zsh", "/opt/homebrew/bin/fish"]
.filter { FileManager.default.isExecutableFile(atPath: $0) }
@Test(arguments: [
("100 100 100 pts/4 /bin/sh\n", true),
("100 100 100 pts/4 -ksh\n", true),
("100 100 100 pts/4 tmux\n", false),
("100 100 200 pts/4 bash\n200 200 200 pts/4 tmux\n", false),
("100 100 100 pts/4 bash\n200 100 100 pts/4 vim\n", false),
("100 100 100 pts/4 bash\n200 100 100 pts/4 zsh\n", false),
("100 100 100 pts/5 sh\n", false),
("100 100 100 pts/4 unknown\n", false),
("malformed\n", false),
("", false),
])
func fallbackRequiresOneVerifiedForegroundShell(value: (String, Bool)) async throws {
let fixture = try TmuxFixture()
defer { fixture.cleanUp() }
let probe = RemoteTerminalProcessProbe { command in
try Self.run(
"ps() { printf '%s' \(try RemoteMultiplexerCommands.quote(value.0)); }\n" + command,
home: fixture.home.path)
}
#expect(try await probe.isOrdinaryShell(terminal: "/dev/pts/4") == value.1)
}
@Test(arguments: [false, true])
func tmuxCreationPreservesTheInteractiveLocale(explicitLocale: Bool) throws {
let fixture = try TmuxFixture()
defer { fixture.cleanUp() }
let result = try fixture.run(
"""
export STUB_EMPTY=1 LANG=en_US.UTF-8
\(explicitLocale ? "export LC_ALL=C" : "unset LC_ALL")
\(try RemoteTmuxCommands.create(token: fixture.owner, startupCommand: nil, shellIntegration: true))
code=$?
rm -rf "$helper"
exit "$code"
""")
#expect(result.exitCode == 0, "\(result.stdout)")
let locale = try String(contentsOf: fixture.home.appendingPathComponent("locale"), encoding: .utf8)
#expect(locale == (explicitLocale ? "x|C|en_US.UTF-8" : "||en_US.UTF-8"))
#expect(FileManager.default.fileExists(atPath: fixture.home.appendingPathComponent("created").path))
}
@Test func ordinaryStartupRunsReadableNonExecutableHelpersWithAnUnsupportedShell() throws {
let fixture = try TmuxFixture()
defer { fixture.cleanUp() }
let install = try RemoteShellStartup.install(
token: UUID(), startupCommand: nil, shellIntegration: true, ordinary: true
)
.replacingOccurrences(of: "/tmp/termind-shell.XXXXXXXX", with: fixture.home.path + "/helper.XXXXXXXX")
let installation = try Self.run(install + "\nprintf '%s' \"$helper\"", home: fixture.home.path)
#expect(installation.exitCode == 0)
let helper = installation.stdout
for name in ["shell", "first"] {
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: helper + "/" + name)
}
let input = "printf usable > \"$HOME/usable\"; exit\n"
for launch in [
try RemoteShellStartup.ordinaryCommand(helper: helper),
"export TERMIND_TMUX_HELPERS=\(try RemoteMultiplexerCommands.quote(helper)); exec /bin/sh \"$TERMIND_TMUX_HELPERS/first\"",
] {
let result = try Self.run(
"""
export TERMIND_TMUX_SHELL=/bin/sh
printf '%s' \(try RemoteMultiplexerCommands.quote(input)) | /bin/sh -c \(try RemoteMultiplexerCommands.quote(launch))
""", home: fixture.home.path)
#expect(result.exitCode == 0, "\(result.stdout)")
#expect(
try String(contentsOf: fixture.home.appendingPathComponent("usable"), encoding: .utf8) == "usable")
try FileManager.default.removeItem(at: fixture.home.appendingPathComponent("usable"))
}
}
@Test func managedHelperExecutionFailurePrecedesNativeCreation() throws {
let fixture = try TmuxFixture()
defer { fixture.cleanUp() }
let create = try RemoteTmuxCommands.create(
token: fixture.owner, startupCommand: nil, shellIntegration: true
)
.replacingOccurrences(
of: "chmod 700 \"$helper/shell\" \"$helper/first\"",
with: "chmod 600 \"$helper/shell\" \"$helper/first\"")
let result = try fixture.run("export STUB_EMPTY=1\n" + create)
#expect(result.exitCode == 78)
#expect(!FileManager.default.fileExists(atPath: fixture.home.appendingPathComponent("created").path))
}
@Test func everyTmuxOperationResolvesTheExecutableOutsidePATH() throws {
let fixture = try TmuxFixture()
defer { fixture.cleanUp() }
try fixture.journals(stale: 0, live: 1, backend: "tmux")
let probe = try fixture.run(RemoteMultiplexerCommands.discovery(.tmux) + "\n\"$exe\" -V")
#expect(probe.stdout == "tmux 3.4\n")
let listing = try fixture.run(RemoteTmuxCommands.list)
let session = try #require(try RemoteTmuxCommands.parseSessions(listing.stdout).first)
let reference = RemoteSessionReference(
backend: .tmux, session: session, hostID: UUID(), endpoint: "test:22", username: "user",
ownershipToken: fixture.owner)
let create = try fixture.run(
RemoteTmuxCommands.create(token: fixture.owner, startupCommand: nil, shellIntegration: true))
#expect(try RemoteTmuxCommands.parseSessions(create.stdout).first?.id == session.id)
#expect(try fixture.run(RemoteTmuxCommands.attach(reference)).stdout == "TERMIND_ATTACHED\n")
#expect(try fixture.run(RemoteTmuxCommands.terminate(reference)).stdout == "TERMIND_ENDED\n")
let runtime = try fixture.run(RemoteTmuxRuntimeDialect.command(terminal: "/dev/pts/4"))
let update = try RemoteTmuxRuntimeDialect.parse(runtime.stdout, terminal: "/dev/pts/4")
let context = try #require(update.contexts.first)
#expect(context.capabilities.contains(.replaceDraft))
let client = try #require(RemoteTmuxClientIdentity(update: update, terminal: "/dev/pts/4"))
let resize = try fixture.run(
"kill() { [ \"$1:$2\" = '-WINCH:300' ]; }\n" + RemoteTmuxRuntimeDialect.resizeCommand(client: client))
#expect(resize.stdout == "TERMIND_RESIZED\n")
let request = UUID()
let draft = try fixture.run(
RemoteTerminalDraft.command(
context: context, terminal: "/dev/pts/4", helper: nil, clientIdentity: update.clientIdentity,
request: request, replacement: "echo draft"))
#expect(draft.exitCode == 0)
let requested = try String(
contentsOf: fixture.helper.appendingPathComponent("ctx.live0/request"), encoding: .utf8)
#expect(requested.hasPrefix(request.uuidString + "|1|replace|"))
let journal = fixture.helper.appendingPathComponent("ctx.live0/journal")
let updatedJournal = try String(contentsOf: journal, encoding: .utf8)
.replacingOccurrences(of: "R1|1|1|", with: "R1|2|1|")
try (updatedJournal + "E1|2|draft|%64%72%61%66%74|\(request)\n")
.write(to: journal, atomically: true, encoding: .utf8)
let result = try fixture.run(
RemoteTerminalDraft.resultCommand(context: context, helper: nil, request: request))
let record = try RemoteShellContextRecord(result.stdout)
#expect(record.entries.last?.1 == .draft(request: request.uuidString, value: "draft"))
}
@Test(arguments: [1, 129])
func tmuxJournalLimitCountsOnlyLivePaneProcesses(live: Int) throws {
let fixture = try TmuxFixture()
defer { fixture.cleanUp() }
try fixture.journals(stale: 129, live: live, backend: "tmux")
let result = try fixture.run(
"export STUB_PANES=\(live)\n" + RemoteTmuxRuntimeDialect.command(terminal: "/dev/pts/4"))
if live > 128 {
#expect(result.exitCode == 78)
} else {
#expect(result.exitCode == 0, "\(result.stdout)")
let update = try RemoteTmuxRuntimeDialect.parse(result.stdout, terminal: "/dev/pts/4")
#expect(update.contexts.count == 1)
#expect(update.contexts.first?.capabilities.contains(.replaceDraft) == true)
#expect(update.active?.shellInstance == "ctx.live0")
}
}
@Test(arguments: [1, 129])
func socketJournalLimitCountsOnlyValidatedProcesses(live: Int) throws {
let fixture = try TmuxFixture()
defer { fixture.cleanUp() }
try fixture.journals(stale: 129, live: live, backend: "zmx")
let result = try fixture.run(
"""
helper="$STUB_HELPER"
shell_check() { [ "$1" -ge 500 ]; }
foreground_shell() { shell_check "$@"; }
\(RemoteSocketMultiplexerRuntime.journalCommand(backend: .zmx))
""")
if live > 128 {
#expect(result.exitCode == 78)
} else {
#expect(result.exitCode == 0)
let lines = result.stdout.components(separatedBy: "\n")
#expect(lines.first == "BEGIN|1")
#expect(lines.filter { $0 == "BEGIN|1" }.count == 1)
let record = try RemoteShellContextRecord(lines[1...3].joined(separator: "\n"))
#expect(record.instance == "ctx.live0")
#expect(record.ready)
}
}
@Test(arguments: ["shell", "tmux", "zellij", "zmx", "herdr"], shells)
func shellHooksKeepCwdInTheJournalDuringCommands(backend: String, shell: String) throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent("termind-cwd-test-\(UUID())")
try FileManager.default.createDirectory(
at: root.appendingPathComponent("next"), withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let install = try RemoteShellStartup.install(
token: UUID(), startupCommand: "cd \"$HOME\"", shellIntegration: true,
backend: ["shell", "tmux"].contains(backend) ? nil : backend
).replacingOccurrences(
of: "/tmp/termind-shell.XXXXXXXX",
with: root.appendingPathComponent("termind-shell.XXXXXXXX").path
)
let input = """
cat "$__termind_context_dir/journal" > "$HOME/during"
cd "$HOME/next"
cat "$__termind_context_dir/journal" > "$HOME/after"
exit
"""
let result = try Self.run(
"""
\(install)
export TERMIND_TMUX_HELPERS="$helper" TERMIND_TMUX_SHELL=\(try RemoteMultiplexerCommands.quote(shell))
unset TMUX_PANE ZELLIJ_PANE_ID HERDR_PANE_ID
\(backend == "tmux" ? "export TMUX_PANE=%1" : "")
export ZELLIJ_PANE_ID=1 HERDR_PANE_ID=1
printf '%s\\n' \(try RemoteMultiplexerCommands.quote(input)) | /bin/sh "$helper/first"
""", home: root.path)
#expect(result.exitCode == 0, "\(result.stdout)")
let during = try RemoteShellContextRecord(
String(contentsOf: root.appendingPathComponent("during"), encoding: .utf8))
let after = try RemoteShellContextRecord(
String(contentsOf: root.appendingPathComponent("after"), encoding: .utf8))
#expect(during.backend == backend)
#expect(!during.ready)
#expect(!after.ready)
#expect(during.directory == root.resolvingSymlinksInPath().path)
#expect(after.directory == root.appendingPathComponent("next").resolvingSymlinksInPath().path)
#expect(after.sequence > during.sequence)
}
@Test(arguments: SessionPersistenceBackendID.supported, shells)
func startupSnippetRunsOnlyInFirstShellAndPreservesUserConfiguration(
backend: SessionPersistenceBackendID, shell: String
) throws {
for integration in [false, true] {
let root = FileManager.default.temporaryDirectory.appendingPathComponent("termind-shell-test-\(UUID())")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let name = URL(fileURLWithPath: shell).lastPathComponent
let userConfig = name == "zsh" ? ".zshrc" : ".bash_profile"
try "export USER_CONFIGURATION=preserved\n".write(
to: root.appendingPathComponent(userConfig), atomically: true, encoding: .utf8)
if name == "fish" {
let config = root.appendingPathComponent(".config/fish")
try FileManager.default.createDirectory(at: config, withIntermediateDirectories: true)
try "set -gx USER_CONFIGURATION preserved\n".write(
to: config.appendingPathComponent("config.fish"), atomically: true, encoding: .utf8)
}
let startup =
name == "fish"
? "cd \"$HOME\"; set -gx FROM_SNIPPET yes; printf once >> \"$HOME/once\""
: "cd \"$HOME\"; export FROM_SNIPPET=yes; printf once >> \"$HOME/once\""
let install = try RemoteShellStartup.install(
token: UUID(), startupCommand: startup, shellIntegration: integration,
backend: backend == .tmux ? nil : backend.rawValue,
environment: [.init(variable: "SESSION_ONLY", value: "host-value")]
)
.replacingOccurrences(
of: "/tmp/termind-shell.XXXXXXXX",
with: root.appendingPathComponent("termind-shell.XXXXXXXX").path
)
let firstInput = """
printf '%s|%s|%s|%s\\n' "$PWD" "$FROM_SNIPPET" "$USER_CONFIGURATION" "$SESSION_ONLY" > "$HOME/first-result"
exit
"""
let secondInput = """
printf '%s\\n' "$USER_CONFIGURATION" > "$HOME/second-result"
exit
"""
let script = """
\(install)
trap 'rm -rf "$helper"' EXIT
[ -z "${SESSION_ONLY:-}" ] || exit 83
export TERMIND_TMUX_HELPERS="$helper" TERMIND_TMUX_SHELL=\(try RemoteMultiplexerCommands.quote(shell))
export TMUX_PANE=%1 ZELLIJ_PANE_ID=1 HERDR_PANE_ID=1
printf '%s\\n' \(try RemoteMultiplexerCommands.quote(firstInput)) | /bin/sh "$helper/first"
printf '%s\\n' \(try RemoteMultiplexerCommands.quote(secondInput)) | /bin/sh "$helper/shell"
"""
let result = try Self.run(script, home: root.path)
#expect(result.exitCode == 0, "\(result.stderr)\n\(result.stdout)")
#expect(try String(contentsOf: root.appendingPathComponent("once"), encoding: .utf8) == "once")
let first = try String(contentsOf: root.appendingPathComponent("first-result"), encoding: .utf8)
#expect(first.hasSuffix("|yes|preserved|host-value\n"), "\(first)")
#expect(
try String(contentsOf: root.appendingPathComponent("second-result"), encoding: .utf8)
== "preserved\n")
#expect(
try String(contentsOf: root.appendingPathComponent(userConfig), encoding: .utf8)
== "export USER_CONFIGURATION=preserved\n")
}
}
@Test(arguments: shells)
func cancelledStartupCannotRunLaterWhenUserConfigurationFinishes(shell: String) throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent("termind-cancel-test-\(UUID())")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let install = try RemoteShellStartup.install(
token: UUID(), startupCommand: "printf late > \"$HOME/late\"", shellIntegration: true, backend: "zmx"
).replacingOccurrences(
of: "/tmp/termind-shell.XXXXXXXX", with: root.appendingPathComponent("termind-shell.XXXXXXXX").path)
let result = try Self.run(
"""
\(install)
export TERMIND_TMUX_HELPERS="$helper" TERMIND_TMUX_SHELL=\(try RemoteMultiplexerCommands.quote(shell))
mkdir "$helper/startup-decision"
printf 'printf ready > "$HOME/ready"\\nexit\\n' | /bin/sh "$helper/shell" || exit $?
[ ! -e "$helper/started" ]
""", home: root.path)
#expect(result.exitCode == 0, "\(result.stdout)")
#expect(try String(contentsOf: root.appendingPathComponent("ready"), encoding: .utf8) == "ready")
#expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent("late").path))
}
@Test func expiredAndMismatchedDraftRequestsAreConsumedWithoutReturningPayload() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent("termind-draft-test-\(UUID())")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let state = root.appendingPathComponent("state")
let request = root.appendingPathComponent("request")
let reader = root.appendingPathComponent("reader")
try "R1|10|1|%2Ftmp\n".write(to: state, atomically: true, encoding: .utf8)
try RemoteShellContextJournal.requestReader.write(to: reader, atomically: true, encoding: .utf8)
for (sequence, deadline) in [("10", "1"), ("9", String(Int(Date().timeIntervalSince1970) + 60))] {
try "\(UUID())|\(sequence)|replace|%65%63%68%6F|\(deadline)|-\n".write(
to: request, atomically: true, encoding: .utf8)
let result = try Self.run(
"sh \(RemoteMultiplexerCommands.quote(reader.path)) \(RemoteMultiplexerCommands.quote(root.path)) replace",
home: root.path)
#expect(result.exitCode != 0)
#expect(result.stdout.isEmpty)
#expect(!FileManager.default.fileExists(atPath: request.path))
}
}
private struct TmuxFixture {
let home = FileManager.default.temporaryDirectory.appendingPathComponent("termind-tmux-test-\(UUID())")
let helper = URL(
fileURLWithPath: "/tmp/termind-shell." + UUID().uuidString.replacingOccurrences(of: "-", with: ""))
let owner = UUID()
let token = UUID()
init() throws {
let bin = home.appendingPathComponent(".local/bin")
try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: helper, withIntermediateDirectories: true)
try token.uuidString.write(
to: helper.appendingPathComponent("event-token"), atomically: true, encoding: .utf8)
let executable = bin.appendingPathComponent("tmux")
try Self.stub.write(to: executable, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: executable.path)
}
func cleanUp() {
try? FileManager.default.removeItem(at: home)
try? FileManager.default.removeItem(at: helper)
}
func run(_ command: String) throws -> RemoteSessionCommandResult {
// Do not allow fixture execution to discover a real multiplexer installed on the test host.
let command = command.replacingOccurrences(
of: "/opt/homebrew/bin /usr/local/bin /usr/bin /snap/bin \"$HOME/.local/bin\" \"$HOME/.cargo/bin\"",
with: "\"$HOME/.local/bin\"")
return try RemoteMultiplexerShellTests.run(
"""
export PATH=/usr/bin:/bin
export STUB_HELPER=\(try RemoteMultiplexerCommands.quote(helper.path)) STUB_OWNER='\(owner)'
\(command)
""", home: home.path)
}
func journals(stale: Int, live: Int, backend: String) throws {
for (prefix, count, firstPID) in [("stale", stale, 10), ("live", live, 500)] {
for index in 0..<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
@@ -7,6 +7,29 @@ import Testing
@Suite
nonisolated struct RemoteMultiplexerTests {
@Test func ordinaryStartupFallbackIsBoundedOneShotAndGenerationScoped() {
let generation = UUID()
var unsupportedShell = RemoteShellStartupReadiness(generation: generation, now: 100)
unsupportedShell.receive(.init(status: .unknown))
let beforeDeadline = unsupportedShell.poll(generation: generation, now: 3_000_000_099)
let atDeadline = unsupportedShell.poll(generation: generation, now: 3_000_000_100)
let afterCompletion = unsupportedShell.poll(generation: generation, now: 4_000_000_000)
#expect(!beforeDeadline)
#expect(atDeadline)
#expect(!afterCompletion)
var replaced = RemoteShellStartupReadiness(generation: generation, now: 100)
let replacement = replaced.poll(generation: UUID(), now: 4_000_000_000)
let afterReplacement = replaced.poll(generation: generation, now: 4_000_000_000)
#expect(!replacement)
#expect(!afterReplacement)
for status in [TerminalRuntimeUpdate.Status.ordinary, .multiplexer] {
var integrated = RemoteShellStartupReadiness(generation: generation, now: 100)
integrated.receive(.init(status: status))
let afterIntegration = integrated.poll(generation: generation, now: 4_000_000_000)
#expect(!afterIntegration)
}
}
private static let token = UUID(uuidString: "11111111-2222-3333-4444-555555555555")!
private static let kinds = SessionPersistenceBackendID.supported
@@ -2,6 +2,8 @@ import Foundation
public nonisolated enum SSHShellIntegrationEvent: Sendable {
case runtime(TerminalRuntimeUpdate)
/// Ordinary PTY startup completed without hooks; this grants no runtime context capabilities.
case ordinaryStartupUnavailable(ticket: SSHOrdinaryStartupTicket?)
case available(shell: String, systemInfo: RemoteSystemInfo?, homeDirectory: String?)
case systemInfo(RemoteSystemInfo)
case homeResolutionUnavailable
@@ -10,3 +12,14 @@ public nonisolated enum SSHShellIntegrationEvent: Sendable {
case commandFinished(Int32, duration: TimeInterval?)
case draft(String)
}
/// A single connection/input epoch, revalidated by the transport before fallback submission.
public nonisolated struct SSHOrdinaryStartupTicket: Sendable, Equatable {
public let generation: UUID
public let inputRevision: UInt64
public init(generation: UUID, inputRevision: UInt64) {
self.generation = generation
self.inputRevision = inputRevision
}
}
@@ -210,23 +210,7 @@ nonisolated struct RemoteSocketMultiplexerRuntime: RemoteMultiplexerRuntime {
native=$(\(nativeCommand)) || exit 79
focus=$(\(focusCommand)) || exit 79
printf 'C|%s\\nN|%s\\nA|%s\\n' "$client" "$(hex "$native")" "$(hex "$focus")"
count=0
for file in "$helper"/ctx.*/journal; do
[ -f "$file" ] && [ ! -L "$file" ] || continue
count=$((count + 1)); [ "$count" -le 128 ] || exit 78
IFS='|' read -r tag token pane instance shell home pid history backend < "$file" || exit 78
[ "$tag" = M2 ] && [ "$backend" = '\(try identity.kind.rawValue)' ] || continue
[ "$token" = "$(cat "$helper/event-token")" ] || continue
dir=${file%/journal}
[ "${dir##*/}" = "$instance" ] || continue
shell_check "$pid" "$shell" "$dir" || continue
foreground=0
if foreground_shell "$pid" "$shell" "$dir"; then foreground=1; fi
printf 'BEGIN|%s\\n' "$foreground"
cat "$file" || exit 78
printf 'END\\n'
done
printf 'DONE\\n'
\(Self.journalCommand(backend: try identity.kind))
"""
let result = try await execute(command)
guard result.exitCode == 0, !result.truncated else { throw TerminalRuntimeError.unsupported }
@@ -234,6 +218,28 @@ nonisolated struct RemoteSocketMultiplexerRuntime: RemoteMultiplexerRuntime {
result.stdout, reference: reference, charset: charset, clientBound: boundClient != nil)
}
static func journalCommand(backend: SessionPersistenceBackendID) -> String {
"""
count=0
for file in "$helper"/ctx.*/journal; do
[ -f "$file" ] && [ ! -L "$file" ] || continue
IFS='|' read -r tag token pane instance shell home pid history backend < "$file" || exit 78
[ "$tag" = M2 ] && [ "$backend" = '\(backend.rawValue)' ] || continue
[ "$token" = "$(cat "$helper/event-token")" ] || continue
dir=${file%/journal}
[ "${dir##*/}" = "$instance" ] || continue
shell_check "$pid" "$shell" "$dir" || continue
count=$((count + 1)); [ "$count" -le 128 ] || exit 78
foreground=0
if foreground_shell "$pid" "$shell" "$dir"; then foreground=1; fi
printf 'BEGIN|%s\\n' "$foreground"
cat "$file" || exit 78
printf 'END\\n'
done
printf 'DONE\\n'
"""
}
static func parse(
_ text: String, reference: RemoteSessionReference, charset: RemoteCharset = .utf8, clientBound: Bool = false
) throws -> TerminalRuntimeUpdate {
@@ -3,6 +3,10 @@ import TermindDomain
/// Private, per-session startup files. Nothing is ever typed into an attached terminal.
nonisolated enum RemoteShellStartup {
static func ordinaryCommand(helper: String) throws -> String {
"export TERMIND_TMUX_HELPERS=\(try RemoteMultiplexerCommands.quote(helper)); exec /bin/sh \"$TERMIND_TMUX_HELPERS/shell\""
}
static func install(
token: UUID, startupCommand: String?, shellIntegration: Bool, ordinary: Bool = false,
backend: String? = nil, environment: [HostEnvironmentVariable] = []
@@ -54,6 +58,7 @@ nonisolated enum RemoteShellStartup {
+ "\n" + fishStartup
let shell = """
#!/bin/sh
if [ "${1:-}" = --termind-check-executable ]; then exit 0; fi
\(backend == nil ? "" : """
if [ "${TERMIND_SHELL_LOCALE_SET:-}" = x ]; then
export LC_ALL="$TERMIND_SHELL_LOCALE"
@@ -97,7 +102,7 @@ nonisolated enum RemoteShellStartup {
"""
// Run the first command inside the initialized shell, preserving cd/export state.
// Subsequent ordinary panes use `shell` directly and cannot replay this command.
let first = "#!/bin/sh\nexport TERMIND_TMUX_FIRST=1\nexec \"$TERMIND_TMUX_HELPERS/shell\"\n"
let first = "#!/bin/sh\nexport TERMIND_TMUX_FIRST=1\nexec /bin/sh \"$TERMIND_TMUX_HELPERS/shell\"\n"
var files = [
"shell": shell, "first": first, "bashrc": bashrc, "fish": fish, "event": RemoteShellContextJournal.writer,
"request-reader": RemoteShellContextJournal.requestReader, "event-token": eventToken.uuidString,
@@ -119,6 +124,36 @@ nonisolated enum RemoteShellStartup {
helper=$(mktemp -d "/tmp/termind-shell.XXXXXXXX") || exit 78
\(writes)
chmod 700 "$helper/shell" "$helper/first" || exit 78
\(ordinary ? "" : """
"$helper/shell" --termind-check-executable >/dev/null 2>&1 || { rm -rf "$helper"; exit 78; }
""")
"""
}
}
/// Only ordinary startup uses the plain-shell fallback, never multiplexer inspection.
nonisolated struct RemoteShellStartupReadiness {
private let generation: UUID
private var deadline: UInt64?
init(generation: UUID, now: UInt64 = DispatchTime.now().uptimeNanoseconds) {
self.generation = generation
deadline = now + 3_000_000_000
}
mutating func receive(_ update: TerminalRuntimeUpdate) {
if update.status == .ordinary || update.status == .multiplexer || update.backend != nil {
deadline = nil
}
}
mutating func poll(generation: UUID, now: UInt64 = DispatchTime.now().uptimeNanoseconds) -> Bool {
guard self.generation == generation else {
deadline = nil
return false
}
guard let deadline, now >= deadline else { return false }
self.deadline = nil
return true
}
}
@@ -65,6 +65,7 @@ nonisolated enum RemoteTerminalDraft {
throw TerminalRuntimeError.staleContext
}
root = """
\(RemoteTmuxCommands.executionPreamble)
helper=$(tmux show-environment -t \(try RemoteMultiplexerCommands.quote(String(session))) TERMIND_TMUX_HELPERS) || exit 79
helper=${helper#TERMIND_TMUX_HELPERS=}
"""
@@ -15,6 +15,34 @@ nonisolated struct RemoteTerminalProcessProbe {
let execute: RemoteSessionCommandExecutor
func isOrdinaryShell(terminal: String) async throws -> Bool {
let result = try await execute(Self.ordinaryShellCommand(terminal: terminal))
return result.exitCode == 0 && !result.truncated && result.stdout == "TERMIND_ORDINARY_SHELL\n"
}
static func ordinaryShellCommand(terminal: String) throws -> String {
guard RemoteTmuxClientIdentity.validTerminal(terminal) else { throw TerminalRuntimeError.invalidResponse }
return """
export LC_ALL=C
tty=\(try RemoteMultiplexerCommands.quote(String(terminal.dropFirst("/dev/".count))))
rows=$(ps -t "$tty" -o pid= -o pgid= -o tpgid= -o tty= -o comm= | head -c 65537) || exit 79
[ "${#rows}" -le 65536 ] || exit 79
printf '%s\\n' "$rows" | awk -v tty="$tty" '
NF != 5 || $1 !~ /^[0-9]+$/ || $2 !~ /^[0-9]+$/ || $3 !~ /^[0-9]+$/ || $4 != tty {
invalid=1; next
}
$2 == $3 && $3 > 0 {
count++
name=$5; sub(/^.*\\//,"",name); sub(/^-/,"",name)
if (name ~ /^(sh|dash|ksh|ksh93|bash|zsh|fish|csh|tcsh)$/) shells++
}
END {
if (!invalid && count == 1 && shells == 1) print "TERMIND_ORDINARY_SHELL"
else exit 79
}'
"""
}
func inspect(terminal: String) async throws -> Client? {
let result = try await execute(Self.command(terminal: terminal))
guard result.exitCode == 0, !result.truncated else { throw TerminalRuntimeError.unsupported }
@@ -16,6 +16,18 @@ nonisolated struct TmuxSessionIdentity: Codable {
}
public nonisolated enum RemoteTmuxCommands {
static var executionPreamble: String {
"""
termind_resolve_tmux() {
\(RemoteMultiplexerCommands.discovery(.tmux))
printf '%s' "$exe"
}
exe=$(termind_resolve_tmux) || exit $?
unset -f termind_resolve_tmux
export PATH="${exe%/*}:$PATH"
"""
}
public static func quote(_ value: String) throws -> String {
try RemoteMultiplexerCommands.quote(value)
}
@@ -42,7 +54,7 @@ public nonisolated enum RemoteTmuxCommands {
public static var list: String {
"""
export LC_ALL=C
\(executionPreamble)
\(describe)
ids=$(tmux list-sessions -F '#{session_id}' 2>&1)
code=$?
@@ -132,6 +144,7 @@ public nonisolated enum RemoteTmuxCommands {
case \(shell) in */bash|*/zsh|*/fish) ;; *) exit 80 ;; esac
"""
return """
\(executionPreamble)
\(describe)
session_shell=\(shell)
id=$(tmux list-sessions -f '#{==:#{session_name},\(name)}' -F '#{session_id}' 2>/dev/null)
@@ -173,13 +186,15 @@ public nonisolated enum RemoteTmuxCommands {
// The guard and attach share tmux's command queue; never use -d, -A, or a name prefix.
let command = "attach-session -t \(target)"
return
"exec tmux if-shell -t \(target) -F \(try quote(identityCondition(attachment))) \(try quote(command)) 'display-message -p TERMIND_IDENTITY_MISMATCH'"
executionPreamble
+ "\nexec tmux if-shell -t \(target) -F \(try quote(identityCondition(attachment))) \(try quote(command)) 'display-message -p TERMIND_IDENTITY_MISMATCH'"
}
public static func terminate(_ attachment: RemoteSessionReference) throws -> String {
guard attachment.ownershipToken != nil else { throw RemoteSessionFailure.ownershipMismatch }
let target = try quote(attachment.session.id)
return """
\(executionPreamble)
[ "$(tmux display-message -p -t \(target) \(try quote(identityCondition(attachment))))" = 1 ] || exit 79
tmux if-shell -t \(target) -F \(try quote(identityCondition(attachment))) \
\(try quote("kill-session -t \(target) ; display-message -p TERMIND_ENDED"))
@@ -41,12 +41,12 @@ nonisolated struct RemoteTmuxRuntimeDialect: RemoteMultiplexerRuntime {
// Revalidate on the server immediately before signalling. A detached client's TTY
// may already contain a shell, so this operation must never write terminal input.
return """
export LC_ALL=C
\(RemoteTmuxCommands.executionPreamble)
client=$(tmux list-clients -F '#{client_tty}|#{client_pid}|#{client_created}|#{pid}|#{session_id}|#{session_created}|#{client_flags}' 2>/dev/null | \
while IFS='|' read -r tty cp cc sp sid sc flags; do
if [ "$tty" = \(try RemoteTmuxCommands.quote(client.terminal)) ]; then
case ",$flags," in
*,read-only,*|*,ignore-size,*|*,active-pane,*|*,control-mode,*) continue ;;
(*,read-only,*|*,ignore-size,*|*,active-pane,*|*,control-mode,*) continue ;;
esac
printf '%s|%s|%s|%s|%s|%s\\n' "$tty" "$cp" "$cc" "$sp" "$sid" "$sc"
fi
@@ -60,8 +60,7 @@ nonisolated struct RemoteTmuxRuntimeDialect: RemoteMultiplexerRuntime {
static func command(terminal: String) throws -> String {
guard RemoteTmuxClientIdentity.validTerminal(terminal) else { throw TerminalRuntimeError.invalidResponse }
return """
export LC_ALL=C
command -v tmux >/dev/null 2>&1 || exit 77
\(RemoteTmuxCommands.executionPreamble)
tmux -V || exit 77
client=$(tmux list-clients -F 'C|#{client_tty}|#{client_pid}|#{client_created}|#{pid}|#{session_id}|#{session_created}|#{pane_id}|#{client_flags}|#{client_key_table}' 2>/dev/null | \
while IFS='|' read -r tag tty cp cc sp sid sc pane flags table; do
@@ -72,7 +71,8 @@ nonisolated struct RemoteTmuxRuntimeDialect: RemoteMultiplexerRuntime {
if [ -z "$client" ]; then printf 'NONE\\n'; exit 0; fi
printf '%s\\n' "$client"
sid=$(printf '%s' "$client" | cut -d '|' -f6)
tmux list-panes -s -t "$sid" -F 'P|#{pane_id}|#{pane_pid}|#{pane_in_mode}|#{synchronize-panes}|#{?#{==:#{pane_current_command},bash},bash,#{?#{==:#{pane_current_command},zsh},zsh,#{?#{==:#{pane_current_command},fish},fish,unknown}}}' || exit 78
panes=$(tmux list-panes -s -t "$sid" -F 'P|#{pane_id}|#{pane_pid}|#{pane_in_mode}|#{synchronize-panes}|#{?#{==:#{pane_current_command},bash},bash,#{?#{==:#{pane_current_command},zsh},zsh,#{?#{==:#{pane_current_command},fish},fish,unknown}}}') || exit 78
printf '%s\\n' "$panes"
pane=$(printf '%s' "$client" | cut -d '|' -f8)
cwd=$(tmux display-message -p -t "$pane" '#{pane_current_path}') || exit 78
foreground=$(tmux display-message -p -t "$pane" '#{pane_current_command}') || exit 78
@@ -87,6 +87,12 @@ nonisolated struct RemoteTmuxRuntimeDialect: RemoteMultiplexerRuntime {
count=0
for file in "$helper"/ctx.*/journal; do
[ -f "$file" ] && [ ! -L "$file" ] || continue
IFS='|' read -r tag token pane instance shell home pid history backend < "$file" || continue
case "$tag:$backend" in M1:|M2:tmux) ;; *) continue ;; esac
[ "$token" = "$(cat "$helper/event-token")" ] || continue
dir=${file%/journal}
[ ! -L "$dir" ] && [ "${dir##*/}" = "$instance" ] || continue
[ "$(printf '%s\\n' "$panes" | awk -F '|' -v pane="$pane" '$2 == pane {print $3}')" = "$pid" ] || continue
count=$((count + 1)); [ "$count" -le 128 ] || exit 78
printf 'BEGIN\\n'
cat "$file" || exit 78
@@ -216,6 +222,7 @@ extension RemoteTmuxRuntimeDialect {
else { throw TerminalRuntimeError.staleContext }
let target = try RemoteTmuxCommands.quote(session[0])
let root = """
\(RemoteTmuxCommands.executionPreamble)
[ "$(tmux display-message -p -t \(target) '#{pid}:#{session_created}')" = '\(identity.server):\(session[1])' ] || exit 79
[ "$(tmux list-clients -F '#{client_tty}|#{client_pid}|#{client_created}|#{pane_id}|#{client_flags}|#{client_key_table}|#{session_id}|#{session_created}' | \
awk -F '|' -v tty=\(try RemoteTmuxCommands.quote(terminal)) '$1 == tty {print $2 ":" $3 ":" $4 ":" $6 ":" $7 ":" $8; if ($5 ~ /active-pane|read-only/) print "UNSUPPORTED"}')" \
@@ -892,6 +892,11 @@ public actor SSHPTYChannel {
worker.write(data)
}
public func submitInitialCommand(_ data: Data, ticket: SSHOrdinaryStartupTicket) async {
guard resumable == nil else { return }
await worker.submitInitialCommand(data, ticket: ticket)
}
/// Opens an SFTP subsystem Channel on the PTY Channel's authenticated connection.
/// This is a fallback only: callers must first attempt their ordinary
/// independent SFTP connection.
@@ -969,6 +974,9 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
private var shellIntegrationLifecycleLogger: RemoteShellIntegrationLifecycleLogger
private var commandEventFilter: RemoteCommandEventFilter?
private var contextFilter: RemoteTerminalContextFilter?
private var ordinaryStartup: RemoteShellStartupReadiness?
private var ordinaryStartupInputRevision: UInt64?
private var ordinaryStartupTicket: SSHOrdinaryStartupTicket?
private var runtimeTask: Task<Void, Never>?
private var runtimeTerminal: String?
private var runtimeRevision: UInt64 = 0
@@ -1344,13 +1352,6 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
hostID: request.configuration.hostID,
enabled: shellIntegrationEnabled
)
if shellIntegrationEnabled {
let bootstrap = RemoteShellIntegrationBootstrap(charset: request.configuration.charset)
pendingWrites.append(bootstrap.startCommand)
commandEventFilter = RemoteCommandEventFilter(
token: bootstrap.token, charset: request.configuration.charset)
shellIntegration = bootstrap
}
startPump()
progress?(.connected(request.configuration.hostID))
}
@@ -1384,6 +1385,38 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
}
}
func submitInitialCommand(_ data: Data, ticket: SSHOrdinaryStartupTicket) async {
do {
let (terminal, revision) = try await queue.perform { [self] in
guard ordinaryStartupTicket == ticket,
ptyResize.generation == ticket.generation, runtimeInputRevision == ticket.inputRevision,
runtimeAttachment == nil, ptyChannel != nil, let runtimeTerminal
else { throw TerminalRuntimeError.staleContext }
ordinaryStartupTicket = nil
return (runtimeTerminal, runtimeRevision)
}
let probe = RemoteTerminalProcessProbe(
execute: try await sessionCommandExecutor(runtime: true, expectedInput: ticket.inputRevision))
guard try await probe.isOrdinaryShell(terminal: terminal) else { throw TerminalRuntimeError.unsupported }
try await queue.perform { [self] in
guard ptyResize.generation == ticket.generation, runtimeInputRevision == ticket.inputRevision,
runtimeRevision == revision, runtimeTerminal == terminal, runtimeAttachment == nil,
ptyChannel != nil, latestRuntimeUpdate?.status != .multiplexer,
latestRuntimeUpdate?.backend == nil
else { throw TerminalRuntimeError.staleContext }
runtimeInputRevision &+= 1
runtimeRevision &+= 1
let converted = (try? inputConverter?.convert(data).data) ?? data
if !converted.isEmpty { pendingWrites.append(converted) }
pump()
}
} catch {
logger.notice(
"ssh.initial_command.skipped",
metadata: ["reason": "ordinary_shell_not_verified"])
}
}
public func resize(columns: UInt16, rows: UInt16, widthPixels: UInt32, heightPixels: UInt32) {
guard columns > 0, rows > 0 else { return }
queue.async { [self] in
@@ -1534,16 +1567,19 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
(48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0)
})
else { throw TerminalRuntimeError.invalidResponse }
startupCommand =
"export TERMIND_TMUX_HELPERS=\(try RemoteMultiplexerCommands.quote(helper)); exec \"$TERMIND_TMUX_HELPERS/shell\""
await queue.perform { self.runtimeHelper = helper }
startupCommand = try RemoteShellStartup.ordinaryCommand(helper: helper)
try await queue.perform {
guard self.ptyResize.generation == generation else { throw CancellationError() }
self.runtimeHelper = helper
}
} catch {
try Task.checkCancellation()
// Startup-only integration is optional; never fall back to typing a bootstrap.
usesRuntime = false
startupCommand = "exec \"${SHELL:-/bin/sh}\" -l"
}
}
if let original = startupCommand, usesRuntime {
if let original = startupCommand, shellIntegrationEnabled {
startupCommand =
"printf '\\033]777;TermindTerminal=\(runtimeToken.uuidString);%s\\007' \"$(tty)\";\n" + original
}
@@ -1556,8 +1592,10 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
runtimeEnabled = runtimeIsEnabled
allowsDefaultHistorySource = launch == nil && !runtimeIsEnabled
runtimeWantsObservation = runtimeIsEnabled && launch != nil
if runtimeIsEnabled {
if shellIntegrationEnabled {
contextFilter = .init(token: runtimeToken.uuidString, charset: charset)
}
if runtimeIsEnabled {
publishRuntime(.init(status: .unknown))
} else if shellIntegrationEnabled {
publishRuntime(.init(status: .unavailable))
@@ -1620,6 +1658,10 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
outputConverter = try RemoteCharsetStreamConverter(charset: charset, direction: .decoding)
workingDirectoryFilter = RemoteOSC7CharsetFilter(charset: charset)
didReportExit = false
if launch == nil, shellIntegrationEnabled {
ordinaryStartup = .init(generation: generation)
ordinaryStartupInputRevision = runtimeInputRevision
}
progress?(.connected(request.configuration.hostID))
pump()
}
@@ -2030,6 +2072,18 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
shellIntegration = update.isComplete ? nil : integration
applyShellIntegrationUpdate(update)
}
if let generation = ptyResize.generation, ordinaryStartup?.poll(generation: generation) == true {
ordinaryStartupTicket =
runtimeTerminal == nil || runtimeInputRevision != ordinaryStartupInputRevision
? nil : .init(generation: generation, inputRevision: runtimeInputRevision)
shellIntegrationHandler(
.ordinaryStartupUnavailable(ticket: ordinaryStartupTicket))
if ordinaryStartupTicket == nil {
logger.notice(
"ssh.initial_command.skipped",
metadata: ["reason": "terminal_missing_or_input_changed"])
}
}
while !pendingWrites.isEmpty && DispatchTime.now().uptimeNanoseconds < deadline {
let written = pendingWrites.withFirstBytes { bytes -> Int in
@@ -2267,6 +2321,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
}
private func publishRuntime(_ proposed: TerminalRuntimeUpdate) {
ordinaryStartup?.receive(proposed)
var update = proposed
if runtimeControlUnavailable || channelBudget?.permitsConcurrentControl == false {
if update.status == .multiplexer {
@@ -2509,6 +2564,9 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
}
private func stopRuntimeObservation() {
ordinaryStartup = nil
ordinaryStartupInputRevision = nil
ordinaryStartupTicket = nil
activationTask?.task.cancel()
activationTask = nil
terminalActivation.reset()
+4 -15
View File
@@ -765,21 +765,10 @@ final class MobileAppModel {
return id
}
func discardWorkspaceSessions(_ ids: Set<String>, authorized: Bool = false) {
func discardWorkspaceSessions(_ ids: Set<String>) {
guard !ids.isEmpty else { return }
let recovered = ids.reduce(into: Set<UUID>()) { $0.formUnion(recoveredWorkspaceIDs[$1] ?? []) }
let targets = workspaceSessions.flatMap { session in
session.workspaces.filter { ids.contains(session.id) || recovered.contains($0.id) }
}
if !authorized,
deferPersistentClose(
targets,
action: { [weak self] in
self?.discardWorkspaceSessions(ids, authorized: true)
})
{
return
}
// Native dismissal detaches clients; only explicit workspace closure ends owned remote work.
persistentClose.cancel()
discardedSessionIDs.formUnion(ids)
orphanedSessionIDs.subtract(ids)
for id in Array(sceneSessionIDs.keys) where ids.contains(sceneSessionIDs[id] ?? "") { removeScene(id) }
@@ -798,7 +787,7 @@ final class MobileAppModel {
for workspaceID in recoveredIDs { session.close(workspaceID, rememberForReopen: false, authorized: true) }
}
for id in ids { recoveredWorkspaceIDs[id] = nil }
for session in workspaceSessions where ids.contains(session.id) { session.discard(authorized: true) }
for session in workspaceSessions where ids.contains(session.id) { session.discard() }
workspaceSessions.removeAll { ids.contains($0.id) }
}
+4 -20
View File
@@ -386,34 +386,18 @@ final class MobileSceneModel {
requestWindowClose(sessionID: workspaceSession.id)
}
func closeAllWindows(authorized: Bool = false) {
func closeAllWindows() {
guard !workspaceSession.isDiscarded else { return }
let sessionIDs = app.openWorkspaceSessionIDs()
if !authorized,
app.deferPersistentClose(
sessionIDs.flatMap { app.workspaceSession(for: $0).workspaces },
action: { [weak self] in self?.closeAllWindows(authorized: true) })
{
return
}
for id in sessionIDs.sorted() where id != workspaceSession.id {
requestWindowClose(sessionID: id, authorized: true)
requestWindowClose(sessionID: id)
}
if sessionIDs.contains(workspaceSession.id) {
requestWindowClose(sessionID: workspaceSession.id, authorized: true)
requestWindowClose(sessionID: workspaceSession.id)
}
}
private func requestWindowClose(sessionID: String, authorized: Bool = false) {
if !authorized,
app.deferPersistentClose(
app.workspaceSession(for: sessionID).workspaces,
action: { [weak self] in
self?.requestWindowClose(sessionID: sessionID, authorized: true)
})
{
return
}
private func requestWindowClose(sessionID: String) {
// UIKit's discard callback owns cleanup; a rejected request must preserve this session.
let logger = app.logging.makeLogger(label: "termind.window")
app.closeWorkspaceSession(sessionID) { error in
+4 -6
View File
@@ -98,12 +98,9 @@ final class MobileWorkspaceSessionModel {
}
}
func discard(authorized: Bool = false) {
func discard() {
guard !isDiscarded else { return }
if !authorized, app.deferPersistentClose(workspaces, action: { [weak self] in self?.discard(authorized: true) })
{
return
}
app.persistentClose.cancel()
let states = orderedWorkspaceItems.map { $0.workspace?.reopenState ?? .init(descriptor: $0.descriptor) }
app.rememberClosedWorkspaces(states, isWindow: true)
closeAll(rememberForReopen: false, authorized: true)
@@ -416,7 +413,8 @@ final class MobileWorkspaceSessionModel {
usernameHint: state.usernameHint, connectionOrigin: state.connectionOrigin,
externalSFTPResourcePath: state.externalSFTPResourcePath,
externalSFTPMustBeDirectory: state.externalSFTPMustBeDirectory)
workspace.restoreRemoteSession(state.remoteSession)
workspace.restoreRemoteSession(state.descriptor.remoteSession)
workspace.sessionPersistence.requiresSelection = state.descriptor.kind == .ssh
addWorkspaces([workspace], persists: state.connectionOrigin.allowsPersistence)
}
}
@@ -23,5 +23,8 @@ struct MobileWorkspaceConnectionServices {
(@Sendable (String) -> Void)?
) async throws -> any SFTPClientProtocol
var writeSSH: (SSHPTYChannel, Data) async -> Void
var submitInitialCommand: @MainActor (SSHPTYChannel, Data, SSHOrdinaryStartupTicket) async -> Void = {
await $0.submitInitialCommand($1, ticket: $2)
}
}
+25 -5
View File
@@ -146,6 +146,7 @@ final class MobileWorkspace: Identifiable {
private(set) var isShellIntegrationEnabled = true
private var shellIntegrationAvailable = false
private var isInitialCommandSubmissionReady = false
private var initialCommandStartupTicket: SSHOrdinaryStartupTicket?
private var pendingCommand: MobilePendingInitialCommand?
/// Re-resolved from the Host and Snippet library for each physical terminal shell.
private var pendingConnectionStartupCommand: MobilePendingInitialCommand?
@@ -694,6 +695,7 @@ final class MobileWorkspace: Identifiable {
isShellPromptReady = false
shellIntegrationAvailable = false
isInitialCommandSubmissionReady = false
initialCommandStartupTicket = nil
pendingConnectionStartupCommand = nil
workingDirectory = nil
commandExcludedFromHistory = nil
@@ -711,6 +713,19 @@ final class MobileWorkspace: Identifiable {
resetCommandGeneratorShellState()
return
}
if case .ordinaryStartupUnavailable(let ticket) = event {
guard sessionPersistence.attachment == nil else { return }
guard let ticket else {
pendingCommand = nil
pendingConnectionStartupCommand = nil
initialCommandStartupTicket = nil
return
}
initialCommandStartupTicket = ticket
isInitialCommandSubmissionReady = true
submitPendingInitialCommandIfReady()
return
}
if case .runtime(let update) = event {
receiveRuntimeContext(update)
return
@@ -718,7 +733,7 @@ final class MobileWorkspace: Identifiable {
guard !hasRuntimeContext else { return }
commandMonitor.receiveShellEvent(event)
switch event {
case .runtime: break
case .runtime, .ordinaryStartupUnavailable: break
case .available(let shell, let systemInfo, _):
integratedShell = shell
remoteSystemInfo = systemInfo
@@ -1476,9 +1491,15 @@ final class MobileWorkspace: Identifiable {
else { return }
let token = generation
let data = Data((command + "\r").utf8)
let startupTicket = initialCommandStartupTicket
initialCommandStartupTicket = nil
Task { [weak self] in
guard let self, generation == token, connected, ssh === session else { return }
await connectionServices.writeSSH(session, data)
if let startupTicket {
await connectionServices.submitInitialCommand(session, data, startupTicket)
} else {
await connectionServices.writeSSH(session, data)
}
}
}
@@ -1860,15 +1881,14 @@ final class MobileWorkspace: Identifiable {
var connectionOrigin: RemoteConnectionOrigin = .library
var externalSFTPResourcePath: String? = nil
var externalSFTPMustBeDirectory = false
var remoteSession: RemoteSessionReference? = nil
}
var reopenState: ReopenState {
ReopenState(
descriptor: descriptor, transientHost: transientHost, connectionRoute: connectionRoute,
descriptor: restorationDescriptor, transientHost: transientHost, connectionRoute: connectionRoute,
usernameHint: usernameHint, connectionOrigin: connectionOrigin,
externalSFTPResourcePath: externalSFTPResourcePath,
externalSFTPMustBeDirectory: externalSFTPMustBeDirectory, remoteSession: sessionPersistence.attachment)
externalSFTPMustBeDirectory: externalSFTPMustBeDirectory)
}
func disconnect() {
@@ -68,6 +68,9 @@ private final class MobileSSHCommandConnectionBox {
if data == Data("\u{1B}[777;1~".utf8) {
shellIntegrations.last?(.draft(draft))
}
},
submitInitialCommand: { [weak self] _, data, _ in
self?.writes.append(data)
}
)
}
@@ -94,6 +97,65 @@ private final class MobileSSHCommandConnectionBox {
@Suite @MainActor
struct MobileCommandGeneratorTests {
@Test func ordinaryStartupFallbackSubmitsOnceAndOldConnectionCannotReleaseANewSnippet() async throws {
var (host, library) = try commandFixture()
host.shellIntegrationMode = .enabled
let snippet = Snippet(title: "Startup", body: "echo startup")
host.startupSnippetID = snippet.id
library.upsert(snippet)
library.upsert(host)
let connections = MobileSSHCommandConnectionBox()
let workspace = MobileWorkspace(
descriptor: .init(hostID: host.id, title: host.name, kind: .ssh), hosts: library,
connectionServices: connections.services, initialCommand: "echo explicit")
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
await connections.sendShellEvents([.runtime(.init(status: .unavailable))])
#expect(await eventually { workspace.terminalContext.status == .unavailable })
#expect(connections.writes.isEmpty)
let fallback = SSHShellIntegrationEvent.ordinaryStartupUnavailable(
ticket: .init(generation: UUID(), inputRevision: 0))
await connections.sendShellEvents([fallback, fallback])
#expect(await eventually { connections.writes == [Data("echo explicit\r".utf8)] })
#expect(workspace.terminalContext.active == nil)
#expect(!workspace.canGenerateFromCurrentDraft)
#expect(workspace.workingDirectory == nil)
await connections.finishSession(.failure(.socket("test disconnect")), session: 0)
#expect(await eventually(attempts: 500) { connections.sessionCount == 2 && workspace.connected })
await connections.sendShellEvents([fallback], session: 0)
await connections.sendShellEvents([.runtime(.init(status: .unavailable))], session: 1)
#expect(await eventually { workspace.terminalContext.status == .unavailable })
#expect(connections.writes == [Data("echo explicit\r".utf8)])
await connections.sendShellEvents([fallback, fallback], session: 1)
#expect(
await eventually {
connections.writes == [Data("echo explicit\r".utf8), Data("echo startup\r".utf8)]
})
}
@Test func rejectedOrdinaryStartupDoesNotReplayAtALaterPrompt() async throws {
var (host, library) = try commandFixture()
host.shellIntegrationMode = .enabled
library.upsert(host)
let connections = MobileSSHCommandConnectionBox()
let workspace = MobileWorkspace(
descriptor: .init(hostID: host.id, title: host.name, kind: .ssh), hosts: library,
connectionServices: connections.services, initialCommand: "echo initial")
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
await connections.sendShellEvents([
.ordinaryStartupUnavailable(ticket: nil),
.available(shell: "bash", systemInfo: nil, homeDirectory: "/home"),
.promptReady,
])
#expect(await eventually { workspace.canGenerateFromCurrentDraft })
#expect(workspace.pendingInitialCommand == nil)
#expect(connections.writes.isEmpty)
}
@Test(arguments: ["shell", "tmux", "zellij", "zmx", "herdr"])
func runningCommandsKeepTheDisplayedDirectoryUntilTheShellReportsAnother(backend: String) async throws {
var (host, library) = try commandFixture()
@@ -8,6 +8,57 @@ import Testing
@Suite @MainActor
struct MobileTerminalRestorationTests {
@Test(arguments: [false, true])
func unopenedManagedReferenceSurvivesTabAndWindowReopen(window: Bool) throws {
let app = try MobileAppModel(inMemory: true)
defer { for workspace in app.workspaces { workspace.disconnect() } }
let host = Host(name: "Server", host: "example.invalid")
app.library.upsert(host)
let reference = RemoteSessionReference(
backend: .tmux, session: .init(id: "$4", name: "build", identity: Data("identity".utf8)),
hostID: host.id, endpoint: "example.invalid:22", username: "user", ownershipToken: UUID())
let descriptor = MobileWorkspaceDescriptor(
hostID: host.id, title: "Saved", kind: .ssh, remoteSession: reference)
let source = MobileWorkspaceSessionModel(id: "source", app: app, descriptors: [descriptor])
#expect(source.workspaces.isEmpty)
if window { source.discard() } else { source.close(descriptor.id) }
let record = try #require(app.closedWorkspaceRecords.first)
#expect(record.isWindow == window)
#expect(record.items.first?.remoteSession == reference)
// Exercise the record-only reconstruction, not just retained in-process state.
app.closedWorkspaceStates[record.id] = nil
let destination = app.workspaceSession(for: "destination")
#expect(app.restoreClosedRecord(record.id, into: destination) == descriptor.id)
let reopened = try #require(destination.workspaces.first)
#expect(reopened.sessionPersistence.attachment == reference)
#expect(reopened.sessionPersistence.requiresSelection)
#expect(reopened.pendingInitialCommand == nil)
}
@Test func endedLiveSessionDoesNotResurrectItsDescriptorReference() throws {
let app = try MobileAppModel(inMemory: true)
defer { for workspace in app.workspaces { workspace.disconnect() } }
let host = Host(name: "Server", host: "example.invalid")
app.library.upsert(host)
let reference = RemoteSessionReference(
backend: .tmux, session: .init(id: "$4", name: "build", identity: Data("identity".utf8)),
hostID: host.id, endpoint: "example.invalid:22", username: "user", ownershipToken: UUID())
let workspace = MobileWorkspace(
descriptor: .init(hostID: host.id, title: "Ended", kind: .ssh, remoteSession: reference),
hosts: app.library)
defer { workspace.disconnect() }
workspace.restoreRemoteSession(reference)
workspace.sessionPersistence.markEnded()
app.rememberClosedWorkspaces([workspace.reopenState])
let record = try #require(app.closedWorkspaceRecords.first)
#expect(record.items.first?.remoteSession == nil)
let destination = app.workspaceSession(for: "destination")
#expect(app.restoreClosedRecord(record.id, into: destination) == workspace.id)
let reopened = try #require(destination.workspaces.first)
#expect(reopened.sessionPersistence.attachment == nil)
#expect(reopened.sessionPersistence.requiresSelection)
}
@Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal])
func initialFailureRemainsRetryableAfterTransportCleanup(_ transport: HostTerminalProtocol) {
let host = Host(name: "Server", host: "example.invalid")
+24 -2
View File
@@ -790,8 +790,10 @@ struct MobileWorkspaceSessionTests {
let first = MobileSceneModel(app: app, sessionID: "a")
let second = MobileSceneModel(app: app, sessionID: "b")
first.open(hostID: fixture.host.id, kind: .ssh)
second.open(hostID: fixture.host.id, kind: .sftp)
second.open(hostID: fixture.host.id, kind: .ssh)
let original = try #require(second.workspaceSession.workspaces.first)
let reference = managedReference(hostID: fixture.host.id)
original.restoreRemoteSession(reference)
let history = app.connectionHistory.entries()
app.sceneChanged(first.id, phase: .active)
app.sceneChanged(second.id, phase: .background)
@@ -810,6 +812,7 @@ struct MobileWorkspaceSessionTests {
#expect(app.workspaceOwner(original.id) == nil)
#expect(app.closedWorkspaceRecords.first?.isWindow == true)
#expect(app.closedWorkspaceRecords.first?.items.map(\.id) == [original.id])
#expect(app.closedWorkspaceRecords.first?.items.first?.remoteSession == reference)
#expect(second.workspaceSession.open(hostID: fixture.host.id, kind: .ssh) == nil)
}
@@ -922,6 +925,8 @@ struct MobileWorkspaceSessionTests {
scene.open(hostID: fixture.host.id, kind: .ssh)
scene.activatePendingWorkspace()
let workspace = try #require(scene.activeWorkspace)
let reference = managedReference(hostID: fixture.host.id)
workspace.restoreRemoteSession(reference)
var requestedSessions: [String] = []
app.closeWorkspaceSession = { id, _ in requestedSessions.append(id) }
@@ -929,10 +934,12 @@ struct MobileWorkspaceSessionTests {
#expect(requestedSessions == ["owner"])
#expect(scene.activeWorkspace === workspace)
#expect(workspace.sessionPersistence.attachment == reference)
#expect(!scene.workspaceSession.isDiscarded)
app.discardWorkspaceSessions(["owner"])
#expect(scene.workspaceSession.workspaces.isEmpty)
#expect(scene.workspaceSession.isDiscarded)
#expect(app.closedWorkspaceRecords.first?.items.first?.remoteSession == reference)
}
@Test func closeAllWindowsSnapshotsNativeSessionsAndRetainsRejectedWindows() throws {
@@ -942,8 +949,12 @@ struct MobileWorkspaceSessionTests {
let first = MobileSceneModel(app: app, sessionID: "first")
let second = MobileSceneModel(app: app, sessionID: "second")
first.open(hostID: fixture.host.id, kind: .ssh)
second.open(hostID: fixture.host.id, kind: .sftp)
second.open(hostID: fixture.host.id, kind: .ssh)
let firstReference = managedReference(hostID: fixture.host.id)
first.workspaceSession.workspaces.first?.restoreRemoteSession(firstReference)
let retainedWorkspace = try #require(second.workspaceSession.workspaces.first)
let retainedReference = managedReference(hostID: fixture.host.id)
retainedWorkspace.restoreRemoteSession(retainedReference)
var nativeSessions: Set<String> = ["first", "second", "background"]
var requestedSessions: [String] = []
app.openWorkspaceSessionIDs = { nativeSessions }
@@ -962,6 +973,8 @@ struct MobileWorkspaceSessionTests {
#expect(requestedSessions == ["background", "second", "first"])
#expect(first.workspaceSession.isDiscarded)
#expect(second.workspaceSession.workspaces.first === retainedWorkspace)
#expect(retainedWorkspace.sessionPersistence.attachment == retainedReference)
#expect(app.closedWorkspaceRecords.first?.items.first?.remoteSession == firstReference)
#expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil)
#expect(!second.workspaceSession.isDiscarded)
#expect(app.error == nil)
@@ -974,6 +987,8 @@ struct MobileWorkspaceSessionTests {
let scene = MobileSceneModel(app: app, sessionID: "a")
scene.open(hostID: fixture.host.id, kind: .ssh)
let workspace = try #require(scene.workspaceSession.workspaces.first)
let reference = managedReference(hostID: fixture.host.id)
workspace.restoreRemoteSession(reference)
var reportFailure: (@MainActor (any Error) -> Void)?
app.closeWorkspaceSession = { _, failed in reportFailure = failed }
scene.openSettings()
@@ -983,6 +998,7 @@ struct MobileWorkspaceSessionTests {
failed(NSError(domain: "WindowCloseTest", code: 1))
#expect(scene.workspaceSession.workspaces.first === workspace)
#expect(workspace.sessionPersistence.attachment == reference)
#expect(!scene.workspaceSession.isDiscarded)
#expect(fixture.defaults.object(forKey: MobileWorkspaceStore.key) == nil)
#expect(scene.selectedDestination == .settings)
@@ -1046,6 +1062,12 @@ struct MobileWorkspaceSessionTests {
try #require(condition())
}
private func managedReference(hostID: UUID) -> RemoteSessionReference {
.init(
backend: .tmux, session: .init(id: "$1", name: "build", identity: Data("identity".utf8)),
hostID: hostID, endpoint: "test.invalid:22", username: "user", ownershipToken: UUID())
}
private final class Fixture {
let name = "WorkspaceSessions.\(UUID())"
let defaults: UserDefaults
+32 -2
View File
@@ -192,6 +192,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
private var pendingConnectionStartupCommand: PendingInitialCommand?
private var commandExcludedFromHistory: String?
private var isInitialCommandSubmissionReady = false
private var initialCommandStartupTicket: SSHOrdinaryStartupTicket?
var reconnectState: ReconnectState? { connection.reconnectState }
@@ -462,6 +463,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
isShellPromptReady = false
shellIntegrationAvailable = false
isInitialCommandSubmissionReady = false
initialCommandStartupTicket = nil
pendingConnectionStartupCommand = nil
activeShellCommand = nil
commandDraft.cancel()
@@ -1423,6 +1425,19 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
func receiveShellIntegrationEvent(_ event: SSHShellIntegrationEvent) {
guard isShellIntegrationEnabled else { return }
if case .ordinaryStartupUnavailable(let ticket) = event {
guard sessionPersistence.attachment == nil else { return }
guard let ticket else {
pendingInitialCommand = nil
pendingConnectionStartupCommand = nil
initialCommandStartupTicket = nil
return
}
initialCommandStartupTicket = ticket
isInitialCommandSubmissionReady = true
submitPendingInitialCommandIfReady()
return
}
if case .runtime(let update) = event {
receiveRuntimeContext(update)
return
@@ -1430,7 +1445,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
guard !hasRuntimeContext else { return }
commandMonitor.receiveShellEvent(event)
switch event {
case .runtime: break
case .runtime, .ordinaryStartupUnavailable: break
case .available(let shell, let systemInfo, let homeDirectory):
if let homeDirectory, homeDirectory.hasPrefix("/") {
homeResolution = .resolved(RemotePath.normalized(homeDirectory))
@@ -1538,7 +1553,22 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
in: initialCommand.command
)
}
insert(initialCommand.command, submit: true)
if let ticket = initialCommandStartupTicket {
initialCommandStartupTicket = nil
if let statusStore, let host = statusStore.host(hostID),
(try? statusStore.requireHostAccess(host)) == nil
{
return
}
let data = Data((initialCommand.command + "\r").utf8)
if let inputSink {
inputSink(data)
} else if let ssh {
Task { await ssh.submitInitialCommand(data, ticket: ticket) }
}
} else {
insert(initialCommand.command, submit: true)
}
}
private func submitPendingInitialCommandIfReady() {
@@ -9,6 +9,48 @@ import struct TermindDomain.Host
@Suite(.serialized) @MainActor
struct HostShellIntegrationSessionTests {
@Test(arguments: [false, true])
func ordinaryStartupFallbackSubmitsOnceButNeverIntoManagedAttachments(managed: Bool) {
let host = Host(name: "Server", host: "server.invalid", shellIntegrationMode: .enabled)
let session = SSHSessionModel(hostID: host.id, title: host.name, initialCommand: "echo initial")
var submitted = Data()
session.inputSink = { submitted.append($0) }
defer { session.disconnect() }
session.connect(using: HostLibraryModel(hosts: [host]))
session.status = .connected
if managed {
session.sessionPersistence.attachment = .init(
backend: .tmux, session: .init(id: "$1", name: "build", identity: Data()),
hostID: host.id, endpoint: "server.invalid:22", username: "user", ownershipToken: UUID())
}
session.receiveShellIntegrationEvent(.runtime(.init(status: .unknown)))
session.receiveShellIntegrationEvent(.runtime(.init(status: .unavailable)))
#expect(submitted.isEmpty)
let fallback = SSHShellIntegrationEvent.ordinaryStartupUnavailable(
ticket: .init(generation: UUID(), inputRevision: 0))
session.receiveShellIntegrationEvent(fallback)
session.receiveShellIntegrationEvent(fallback)
session.receiveShellIntegrationEvent(.promptReady)
#expect(String(decoding: submitted, as: UTF8.self) == (managed ? "" : "echo initial\r"))
#expect(!session.shellIntegrationAvailable)
#expect(!session.canGenerateFromCurrentDraft)
#expect(session.workingDirectory == nil)
}
@Test func rejectedOrdinaryStartupDoesNotReplayAtALaterPrompt() {
let host = Host(name: "Server", host: "server.invalid", shellIntegrationMode: .enabled)
let session = SSHSessionModel(hostID: host.id, title: host.name, initialCommand: "echo initial")
var submitted = Data()
session.inputSink = { submitted.append($0) }
defer { session.disconnect() }
session.connect(using: HostLibraryModel(hosts: [host]))
session.status = .connected
session.receiveShellIntegrationEvent(.ordinaryStartupUnavailable(ticket: nil))
session.receiveShellIntegrationEvent(.available(shell: "bash", systemInfo: nil, homeDirectory: "/home"))
session.receiveShellIntegrationEvent(.promptReady)
#expect(submitted.isEmpty)
}
@Test(arguments: ["shell", "tmux", "zellij", "zmx", "herdr"])
func runningCommandsKeepTheDisplayedDirectoryUntilTheShellReportsAnother(backend: String) throws {
let session = SSHSessionModel(hostID: UUID(), title: "Cwd")
-179
View File
@@ -1,179 +0,0 @@
import Foundation
import TermindApplication
import TermindDomain
import Testing
@testable import TermindRemoteInfrastructure
@Suite
nonisolated struct RemoteMultiplexerShellTests {
private static let shells = ["/bin/bash", "/bin/zsh", "/opt/homebrew/bin/fish"]
.filter { FileManager.default.isExecutableFile(atPath: $0) }
@Test(arguments: ["shell", "tmux", "zellij", "zmx", "herdr"], shells)
func shellHooksKeepCwdInTheJournalDuringCommands(backend: String, shell: String) throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent("termind-cwd-test-\(UUID())")
try FileManager.default.createDirectory(
at: root.appendingPathComponent("next"), withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let install = try RemoteShellStartup.install(
token: UUID(), startupCommand: "cd \"$HOME\"", shellIntegration: true,
backend: ["shell", "tmux"].contains(backend) ? nil : backend
).replacingOccurrences(
of: "/tmp/termind-shell.XXXXXXXX",
with: root.appendingPathComponent("termind-shell.XXXXXXXX").path
).replacingOccurrences(
of: "exec \"$TERMIND_TMUX_HELPERS/shell\"",
with: "exec /bin/sh \"$TERMIND_TMUX_HELPERS/shell\"")
let input = """
cat "$__termind_context_dir/journal" > "$HOME/during"
cd "$HOME/next"
cat "$__termind_context_dir/journal" > "$HOME/after"
exit
"""
let result = try Self.run(
"""
\(install)
export TERMIND_TMUX_HELPERS="$helper" TERMIND_TMUX_SHELL=\(try RemoteMultiplexerCommands.quote(shell))
unset TMUX_PANE ZELLIJ_PANE_ID HERDR_PANE_ID
\(backend == "tmux" ? "export TMUX_PANE=%1" : "")
export ZELLIJ_PANE_ID=1 HERDR_PANE_ID=1
printf '%s\\n' \(try RemoteMultiplexerCommands.quote(input)) | /bin/sh "$helper/first"
""", home: root.path)
#expect(result.exitCode == 0, "\(result.stdout)")
let during = try RemoteShellContextRecord(
String(contentsOf: root.appendingPathComponent("during"), encoding: .utf8))
let after = try RemoteShellContextRecord(
String(contentsOf: root.appendingPathComponent("after"), encoding: .utf8))
#expect(during.backend == backend)
#expect(!during.ready)
#expect(!after.ready)
#expect(during.directory == root.resolvingSymlinksInPath().path)
#expect(after.directory == root.appendingPathComponent("next").resolvingSymlinksInPath().path)
#expect(after.sequence > during.sequence)
}
@Test(arguments: SessionPersistenceBackendID.supported, shells)
func startupSnippetRunsOnlyInFirstShellAndPreservesUserConfiguration(
backend: SessionPersistenceBackendID, shell: String
) throws {
for integration in [false, true] {
let root = FileManager.default.temporaryDirectory.appendingPathComponent("termind-shell-test-\(UUID())")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let name = URL(fileURLWithPath: shell).lastPathComponent
let userConfig = name == "zsh" ? ".zshrc" : ".bash_profile"
try "export USER_CONFIGURATION=preserved\n".write(
to: root.appendingPathComponent(userConfig), atomically: true, encoding: .utf8)
if name == "fish" {
let config = root.appendingPathComponent(".config/fish")
try FileManager.default.createDirectory(at: config, withIntermediateDirectories: true)
try "set -gx USER_CONFIGURATION preserved\n".write(
to: config.appendingPathComponent("config.fish"), atomically: true, encoding: .utf8)
}
let startup =
name == "fish"
? "cd \"$HOME\"; set -gx FROM_SNIPPET yes; printf once >> \"$HOME/once\""
: "cd \"$HOME\"; export FROM_SNIPPET=yes; printf once >> \"$HOME/once\""
let install = try RemoteShellStartup.install(
token: UUID(), startupCommand: startup, shellIntegration: integration,
backend: backend == .tmux ? nil : backend.rawValue,
environment: [.init(variable: "SESSION_ONLY", value: "host-value")]
)
.replacingOccurrences(
of: "/tmp/termind-shell.XXXXXXXX",
with: root.appendingPathComponent("termind-shell.XXXXXXXX").path
)
.replacingOccurrences(
of: "exec \"$TERMIND_TMUX_HELPERS/shell\"",
with: "exec /bin/sh \"$TERMIND_TMUX_HELPERS/shell\"")
let firstInput = """
printf '%s|%s|%s|%s\\n' "$PWD" "$FROM_SNIPPET" "$USER_CONFIGURATION" "$SESSION_ONLY" > "$HOME/first-result"
exit
"""
let secondInput = """
printf '%s\\n' "$USER_CONFIGURATION" > "$HOME/second-result"
exit
"""
let script = """
\(install)
trap 'rm -rf "$helper"' EXIT
[ -z "${SESSION_ONLY:-}" ] || exit 83
export TERMIND_TMUX_HELPERS="$helper" TERMIND_TMUX_SHELL=\(try RemoteMultiplexerCommands.quote(shell))
export TMUX_PANE=%1 ZELLIJ_PANE_ID=1 HERDR_PANE_ID=1
printf '%s\\n' \(try RemoteMultiplexerCommands.quote(firstInput)) | /bin/sh "$helper/first"
printf '%s\\n' \(try RemoteMultiplexerCommands.quote(secondInput)) | /bin/sh "$helper/shell"
"""
let result = try Self.run(script, home: root.path)
#expect(result.exitCode == 0, "\(result.stderr)\n\(result.stdout)")
#expect(try String(contentsOf: root.appendingPathComponent("once"), encoding: .utf8) == "once")
let first = try String(contentsOf: root.appendingPathComponent("first-result"), encoding: .utf8)
#expect(first.hasSuffix("|yes|preserved|host-value\n"), "\(first)")
#expect(
try String(contentsOf: root.appendingPathComponent("second-result"), encoding: .utf8) == "preserved\n")
#expect(
try String(contentsOf: root.appendingPathComponent(userConfig), encoding: .utf8)
== "export USER_CONFIGURATION=preserved\n")
}
}
@Test(arguments: shells)
func cancelledStartupCannotRunLaterWhenUserConfigurationFinishes(shell: String) throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent("termind-cancel-test-\(UUID())")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let install = try RemoteShellStartup.install(
token: UUID(), startupCommand: "printf late > \"$HOME/late\"", shellIntegration: true, backend: "zmx"
).replacingOccurrences(
of: "/tmp/termind-shell.XXXXXXXX", with: root.appendingPathComponent("termind-shell.XXXXXXXX").path)
let result = try Self.run(
"""
\(install)
export TERMIND_TMUX_HELPERS="$helper" TERMIND_TMUX_SHELL=\(try RemoteMultiplexerCommands.quote(shell))
mkdir "$helper/startup-decision"
printf 'printf ready > "$HOME/ready"\\nexit\\n' | /bin/sh "$helper/shell" || exit $?
[ ! -e "$helper/started" ]
""", home: root.path)
#expect(result.exitCode == 0, "\(result.stdout)")
#expect(try String(contentsOf: root.appendingPathComponent("ready"), encoding: .utf8) == "ready")
#expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent("late").path))
}
@Test func expiredAndMismatchedDraftRequestsAreConsumedWithoutReturningPayload() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent("termind-draft-test-\(UUID())")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let state = root.appendingPathComponent("state")
let request = root.appendingPathComponent("request")
let reader = root.appendingPathComponent("reader")
try "R1|10|1|%2Ftmp\n".write(to: state, atomically: true, encoding: .utf8)
try RemoteShellContextJournal.requestReader.write(to: reader, atomically: true, encoding: .utf8)
for (sequence, deadline) in [("10", "1"), ("9", String(Int(Date().timeIntervalSince1970) + 60))] {
try "\(UUID())|\(sequence)|replace|%65%63%68%6F|\(deadline)|-\n".write(
to: request, atomically: true, encoding: .utf8)
let result = try Self.run(
"sh \(RemoteMultiplexerCommands.quote(reader.path)) \(RemoteMultiplexerCommands.quote(root.path)) replace",
home: root.path)
#expect(result.exitCode != 0)
#expect(result.stdout.isEmpty)
#expect(!FileManager.default.fileExists(atPath: request.path))
}
}
static func run(_ command: String, home: String) throws -> RemoteSessionCommandResult {
let process = Process()
let output = Pipe()
process.executableURL = URL(fileURLWithPath: "/bin/sh")
process.arguments = ["-c", command]
process.environment = ProcessInfo.processInfo.environment.merging([
"HOME": home, "XDG_CONFIG_HOME": home + "/.config", "XDG_DATA_HOME": home + "/.local/share",
"ZDOTDIR": home, "LC_ALL": "C",
]) { _, new in new }
process.standardOutput = output
process.standardError = output
try process.run()
let data = output.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
return .init(stdout: String(decoding: data, as: UTF8.self), stderr: "", exitCode: process.terminationStatus)
}
}